Backup automatico script del 2026-07-26 07:00

This commit is contained in:
daniele committed 2026-07-26 07:00:01 +02:00
1 parent 4802b021fe
commit a9bbb92090
19 files changed
+1086 -550

No files matched your search

+2
View File
@@ -3,3 +3,5 @@
# DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
# Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2)
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
# turni-app:live-latest
+2
View File
@@ -4,3 +4,5 @@
# CHECK_PIP3=true
# DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
# Cron consigliato: 40 0 * * 6 (tra irrigazione serale ~19:30 e notturna ~02:30)
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
# irrigazione, turni-app:beta/alpha, meteo-alert, loogle-casa, ewelink_smart_home
+39 -12
View File
@@ -45,12 +45,21 @@ done
case "$(hostname -s)" in
pi1)
HOST_LABEL="Pi-1 (Master)"
# Immagini build locali (no registry pubblico) → escluse da Watchtower
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
;;
pi2)
HOST_LABEL="Pi-2 (Backup)"
CHECK_PIP3=true
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
# Immagini build locali (no registry pubblico) → escluse da Watchtower
DOCKER_IGNORE_IMAGES=(
"irrigazione:latest"
"turni-app:beta-latest"
"turni-app:alpha-latest"
"meteo-alert:latest"
"loogle-casa:latest"
"ewelink_smart_home:1.4.6"
)
;;
esac
@@ -143,34 +152,52 @@ image_is_ignored() {
return 1
}
ensure_watchtower_labels() {
command -v docker >/dev/null 2>&1 || return 0
local name image
# Elenco nomi container da escludere (immagini locali / no registry).
# Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
watchtower_disabled_containers() {
local name image disabled=()
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" || continue
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
if image_is_ignored "$image"; then
disabled+=("$name")
fi
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
if ((${#disabled[@]} > 0)); then
local IFS=,
printf '%s' "${disabled[*]}"
fi
}
run_watchtower() {
command -v docker >/dev/null 2>&1 || return 0
ensure_watchtower_labels
local disable_list
disable_list=$(watchtower_disabled_containers)
log "▶ Watchtower (run-once, container aggiornabili da registry)"
append_report ""
append_report "=== Watchtower run-once ==="
if [[ -n "$disable_list" ]]; then
log "Watchtower: esclusi container locali: $disable_list"
append_report "Esclusi (immagini locali): $disable_list"
fi
local -a env_args=(
-e WATCHTOWER_CLEANUP=true
-e WATCHTOWER_ROLLING_RESTART=false
-e "WATCHTOWER_TIMEOUT=${WATCHTOWER_TIMEOUT}"
-e TZ=Europe/Rome
)
if [[ -n "$disable_list" ]]; then
env_args+=(-e "WATCHTOWER_DISABLE_CONTAINERS=${disable_list}")
fi
local output rc=0
output=$(docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_CLEANUP=true \
-e WATCHTOWER_ROLLING_RESTART=false \
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
-e TZ=Europe/Rome \
"${env_args[@]}" \
"$WATCHTOWER_IMAGE" \
--run-once 2>&1) || rc=$?
+2 -142
View File
@@ -1,142 +1,2 @@
#!/bin/bash
# ================================================
# 🔍 SUPER WATCHDOG DI RETE (Gira su Pi-2)
# ================================================
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
echo "ERRORE: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
exit 1
fi
STATE_DIR="/tmp/watchdog_states"
mkdir -p "$STATE_DIR"
# Riavvio giornaliero AP WiFi: silenzio allarmi per REBOOT_GRACE_MIN minuti
# dall'orario programmato. Se ancora DOWN dopo la finestra → allarme.
REBOOT_GRACE_MIN=5
TARGETS=(
"🍓 Pi-1 (Master)|192.168.128.80"
"🗄️ NAS DS920+|192.168.128.100"
"🌍 Internet (Google)|8.8.8.8"
"📡 Router Main|192.168.128.1"
"🗄️ NAS DS214|192.168.128.90"
"🔌 Switch Sala (.105)|192.168.128.105"
"🔌 Switch Taverna (.106)|192.168.128.106"
"🔌 Switch Lavanderia (.107)|192.168.128.107"
"📶 WiFi Sala (.101)|192.168.128.101"
"📶 WiFi Luca (.102)|192.168.128.102"
"📶 WiFi Taverna (.103)|192.168.128.103"
"📶 WiFi Dado (.104)|192.168.128.104"
"📶 WiFi Esterno (.108)|192.168.128.108"
"📶 WiFi Pozzo (.109)|192.168.128.109"
"📷 Cam Matrimoniale|192.168.135.2"
"📷 Cam Luca|192.168.135.3"
"📷 Cam Ingresso|192.168.135.4"
"📷 Cam Sala|192.168.135.5"
"📷 Cam Taverna|192.168.135.6"
"📷 Cam Retro|192.168.135.7"
)
send_alert() {
local title="$1"
local body="$2"
local severity="$3"
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category super_watchdog \
--severity "$severity" &
}
# Restituisce i minuti da mezzanotte dell'inizio riavvio (o vuoto se non programmato).
# 04:00 → Dado (.104), Sala (.101)
# 04:30 → Luca (.102), Taverna (.103)
# 14:00 → Esterno (.108), Pozzo (.109)
wifi_reboot_start_min() {
case "$1" in
192.168.128.101|192.168.128.104) echo 240 ;; # 04:00
192.168.128.102|192.168.128.103) echo 270 ;; # 04:30
192.168.128.108|192.168.128.109) echo 840 ;; # 14:00
*) echo "" ;;
esac
}
# 0 se siamo nella finestra [start, start+grace) del riavvio programmato.
in_wifi_reboot_window() {
local start
start=$(wifi_reboot_start_min "$1")
[[ -n "$start" ]] || return 1
local now_min=$((10#$(date +%H) * 60 + 10#$(date +%M)))
local end=$((start + REBOOT_GRACE_MIN))
[[ $now_min -ge $start && $now_min -lt $end ]]
}
echo "--- Inizio controllo $(date) ---"
for target_line in "${TARGETS[@]}"; do
NAME=$(echo "$target_line" | cut -d'|' -f1)
IP=$(echo "$target_line" | cut -d'|' -f2)
SAFE_IP="${IP//./_}"
STATE_FILE="$STATE_DIR/${SAFE_IP}.state"
if [ -f "$STATE_FILE" ]; then
LAST_STATE=$(cat "$STATE_FILE")
else
LAST_STATE="UP"
echo "UP" > "$STATE_FILE"
fi
ping -c 2 -W 1 "$IP" > /dev/null 2>&1
PING_RESULT=$?
if [ $PING_RESULT -eq 0 ]; then
if [ "$LAST_STATE" == "DOWN" ]; then
send_alert \
"Dispositivo online" \
"RISOLTO: $NAME è tornato ONLINE! IP: $IP" \
"info"
echo "UP" > "$STATE_FILE"
echo "--> $NAME tornato UP. Notifica inviata."
elif [ "$LAST_STATE" == "REBOOT_DOWN" ]; then
# Ripristino dopo riavvio programmato: nessun allarme off/on
echo "UP" > "$STATE_FILE"
echo "--> $NAME tornato UP dopo riavvio programmato. Nessuna notifica."
fi
else
if [ "$LAST_STATE" == "UP" ]; then
if in_wifi_reboot_window "$IP"; then
echo "REBOOT_DOWN" > "$STATE_FILE"
echo "--> $NAME DOWN in finestra riavvio programmato. Silenzio."
else
ICON="⚠️"
if [[ "$NAME" == *"🚨"* || "$NAME" == *"🌍"* ]]; then ICON="🚨 CRITICO:"; fi
send_alert \
"Dispositivo offline" \
"$ICON ALLARME: $NAME è OFFLINE! IP: $IP non risponde." \
"warning"
echo "DOWN" > "$STATE_FILE"
echo "--> $NAME andato DOWN. Notifica inviata."
fi
elif [ "$LAST_STATE" == "REBOOT_DOWN" ]; then
if in_wifi_reboot_window "$IP"; then
echo "$NAME ancora DOWN in finestra riavvio. Silenzio."
else
# Oltre i 5 minuti dal riavvio programmato: allarme reale
ICON="⚠️"
send_alert \
"Dispositivo offline" \
"$ICON ALLARME: $NAME è OFFLINE oltre il riavvio programmato! IP: $IP non risponde." \
"warning"
echo "DOWN" > "$STATE_FILE"
echo "--> $NAME ancora DOWN dopo finestra riavvio. Notifica inviata."
fi
else
echo "$NAME ancora DOWN. Nessuna notifica."
fi
fi
done
echo "--- Fine controllo ---"
#!/usr/bin/env bash
exec /home/daniely/rete/infra-monitor/perimeter-watch.sh "$@"
+39 -12
View File
@@ -45,12 +45,21 @@ done
case "$(hostname -s)" in
pi1)
HOST_LABEL="Pi-1 (Master)"
# Immagini build locali (no registry pubblico) → escluse da Watchtower
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
;;
pi2)
HOST_LABEL="Pi-2 (Backup)"
CHECK_PIP3=true
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
# Immagini build locali (no registry pubblico) → escluse da Watchtower
DOCKER_IGNORE_IMAGES=(
"irrigazione:latest"
"turni-app:beta-latest"
"turni-app:alpha-latest"
"meteo-alert:latest"
"loogle-casa:latest"
"ewelink_smart_home:1.4.6"
)
;;
esac
@@ -143,34 +152,52 @@ image_is_ignored() {
return 1
}
ensure_watchtower_labels() {
command -v docker >/dev/null 2>&1 || return 0
local name image
# Elenco nomi container da escludere (immagini locali / no registry).
# Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
watchtower_disabled_containers() {
local name image disabled=()
while IFS= read -r line; do
[[ -z "$line" ]] && continue
name=${line%%|*}
image=${line#*|}
image_is_ignored "$image" || continue
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
if image_is_ignored "$image"; then
disabled+=("$name")
fi
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
if ((${#disabled[@]} > 0)); then
local IFS=,
printf '%s' "${disabled[*]}"
fi
}
run_watchtower() {
command -v docker >/dev/null 2>&1 || return 0
ensure_watchtower_labels
local disable_list
disable_list=$(watchtower_disabled_containers)
log "▶ Watchtower (run-once, container aggiornabili da registry)"
append_report ""
append_report "=== Watchtower run-once ==="
if [[ -n "$disable_list" ]]; then
log "Watchtower: esclusi container locali: $disable_list"
append_report "Esclusi (immagini locali): $disable_list"
fi
local -a env_args=(
-e WATCHTOWER_CLEANUP=true
-e WATCHTOWER_ROLLING_RESTART=false
-e "WATCHTOWER_TIMEOUT=${WATCHTOWER_TIMEOUT}"
-e TZ=Europe/Rome
)
if [[ -n "$disable_list" ]]; then
env_args+=(-e "WATCHTOWER_DISABLE_CONTAINERS=${disable_list}")
fi
local output rc=0
output=$(docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_CLEANUP=true \
-e WATCHTOWER_ROLLING_RESTART=false \
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
-e TZ=Europe/Rome \
"${env_args[@]}" \
"$WATCHTOWER_IMAGE" \
--run-once 2>&1) || rc=$?
+14 -4
View File
@@ -337,7 +337,7 @@ def load_state() -> Dict:
return default
def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = None, last_notification_utc: Optional[str] = None) -> None:
def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = None, last_notification_utc: Optional[str] = None, summary: Optional[str] = None) -> None:
try:
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
state_data = {
@@ -354,6 +354,8 @@ def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = N
"casa_first_thr_time": casa_data.get("first_thr_time", ""),
"casa_duration_hours": casa_data.get("duration_hours", 0.0),
})
if summary:
state_data["summary"] = summary[:3000]
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state_data, f, ensure_ascii=False, indent=2)
except Exception as e:
@@ -1694,7 +1696,15 @@ def analyze_snow(chat_ids: Optional[List[str]] = None, debug_mode: bool = False)
msg.append("<i>Fonte dati: Open-Meteo</i>")
# Unisci con <br> (sarà convertito in \n in telegram_send_html)
ok = telegram_send_html("<br>".join(msg), chat_ids=chat_ids)
html_msg = "<br>".join(msg)
ok = telegram_send_html(html_msg, chat_ids=chat_ids)
snow_summary = None
try:
from webapp_alert import publish_web_alert, message_to_plain
snow_summary = message_to_plain(html_msg, is_html=True)
publish_web_alert(html_msg, "snow", "warning", is_html=True, title="Allerta neve")
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
# Genera e invia grafico (solo se abbiamo dati per Casa)
chart_generated = False
@@ -1731,11 +1741,11 @@ def analyze_snow(chat_ids: Optional[List[str]] = None, debug_mode: bool = False)
# Salva timestamp dell'ultima notifica
now_utc = datetime.datetime.now(datetime.timezone.utc)
save_state(True, sig, casa_data, last_notification_utc=now_utc.isoformat(timespec="seconds"))
save_state(True, sig, casa_data, last_notification_utc=now_utc.isoformat(timespec="seconds"), summary=snow_summary)
else:
LOGGER.warning("Notifica neve NON inviata (token mancante o errore Telegram).")
# Salva comunque lo state (senza aggiornare last_notification_utc)
save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", ""))
save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", ""), summary=snow_summary)
else:
LOGGER.info("Allerta attiva ma nessun cambiamento significativo. Motivo: %s", change_reason)
# Salva lo state anche se non inviamo (per mantenere alert_active e last_notification_utc)
+23 -1
View File
@@ -758,6 +758,14 @@ async def meteo_viaggio_command(update: Update, context: ContextTypes.DEFAULT_TY
async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None:
# Stesso comportamento di `/meteo` senza argomenti: Casa (+ viaggio se attivo) per utente.
try:
from telegram_gate import telegram_alerts_enabled
if not telegram_alerts_enabled():
logger.info("Report meteo mattutino sospeso (LOOGLE_TELEGRAM_ALERTS=0).")
return
except Exception:
pass
report_casa = call_meteo_script(["--home"])
for uid in ALLOWED_IDS:
chat_id = str(uid)
@@ -892,7 +900,21 @@ def main():
application.add_handler(CallbackQueryHandler(button_handler))
job_queue = application.job_queue
job_queue.run_daily(scheduled_morning_report, time=datetime.time(hour=7, minute=15, tzinfo=TZINFO), days=(0, 1, 2, 3, 4, 5, 6))
# Report meteo quotidiano 07:15 — sospeso se LOOGLE_TELEGRAM_ALERTS=0 (cron-alerts.env)
try:
from telegram_gate import telegram_alerts_enabled
morning_enabled = telegram_alerts_enabled()
except Exception:
morning_enabled = True
if morning_enabled:
job_queue.run_daily(
scheduled_morning_report,
time=datetime.time(hour=7, minute=15, tzinfo=TZINFO),
days=(0, 1, 2, 3, 4, 5, 6),
)
logger.info("Job scheduled_morning_report attivo (07:15).")
else:
logger.info("Job scheduled_morning_report NON registrato (Telegram sospeso).")
application.run_polling()
+40 -11
View File
@@ -78,7 +78,7 @@ def get_bot_token():
sys.exit(1)
def save_current_state(state, report_meta=None):
def save_current_state(state, report_meta=None, summary=None):
try:
# Aggiungi timestamp corrente per tracciare quando è stato salvato lo stato
if report_meta is None:
@@ -87,7 +87,19 @@ def save_current_state(state, report_meta=None):
"points": state,
"last_update": datetime.datetime.now().isoformat(),
"report_meta": report_meta,
"alert_active": any(int(v or 0) > 0 for v in (state or {}).values()),
}
if summary:
state_with_meta["summary"] = str(summary)[:3000]
else:
# Mantieni l'ultimo summary Telegram se non ci sono nuovi aggiornamenti
try:
with open(STATE_FILE, "r") as rf:
prev = json.load(rf)
if isinstance(prev, dict) and prev.get("summary"):
state_with_meta["summary"] = str(prev["summary"])[:3000]
except Exception:
pass
with open(STATE_FILE, 'w') as f:
json.dump(state_with_meta, f)
except Exception as e:
@@ -2251,7 +2263,26 @@ def main():
append_report(new_alerts, improvement_msg, important, report_meta, DEBUG_MODE)
# Genera e invia mappa solo quando ci sono aggiornamenti
ice_summary = None
if new_alerts or solved_alerts:
parts = []
if new_alerts:
parts.append("Aggiornamenti rischio:\n" + "\n\n".join(new_alerts[:12]))
if solved_alerts:
parts.append("Rientri:\n" + "\n".join(solved_alerts[:8]))
ice_summary = "\n\n".join(parts)
try:
from webapp_alert import publish_web_alert
publish_web_alert(
ice_summary,
"ghiaccio",
"warning",
is_html=True,
title="Rischio ghiaccio stradale",
)
except Exception as e:
if DEBUG_MODE:
print(f"Web summary failed: {e}")
if DEBUG_MODE:
print(f"Generazione mappa per {len(map_points_data)} punti...")
map_path = os.path.join(SCRIPT_DIR, "ice_risk_map.png")
@@ -2265,18 +2296,16 @@ def main():
f"🕒 {now.strftime('%d/%m/%Y %H:%M')}\n"
f"📊 Punti monitorati: {len(map_points_data)}"
)
# Invia anche i report testuali (allineati a Telegram/WebApp)
for block in (new_alerts + solved_alerts)[:15]:
try:
send_telegram_broadcast(token, block, debug_mode=DEBUG_MODE)
except Exception:
pass
photo_sent = send_telegram_photo(token, map_path, caption, debug_mode=DEBUG_MODE)
if DEBUG_MODE:
print(f"Mappa inviata via Telegram: {photo_sent}")
# Pulisci file temporaneo solo se non in debug mode (per permettere verifica)
if not DEBUG_MODE:
try:
if os.path.exists(map_path):
os.remove(map_path)
except Exception:
pass
elif DEBUG_MODE:
print(f"File mappa mantenuto per debug: {map_path}")
# Mantieni la mappa per la WebApp (non cancellare)
print("Mappa inviata.")
else:
if DEBUG_MODE:
@@ -2288,7 +2317,7 @@ def main():
print("Nessuna variazione.")
if not DEBUG_MODE:
save_current_state(current_state, report_meta=report_meta)
save_current_state(current_state, report_meta=report_meta, summary=ice_summary)
if __name__ == "__main__":
main()
+54 -23
View File
@@ -52,6 +52,12 @@ TARGET_ZONES = {
"EMR-D1": "Pianura bolognese",
}
# Annotazioni territoriali (San Marino adotta il sistema Emilia-Romagna)
ZONE_NOTES = {
"Alta collina romagnola": "include Repubblica di San Marino",
"Pianura romagnola": "area adiacente a San Marino",
}
# Mappa codice zona regionale Arpae -> nome leggibile (deriva da TARGET_ZONES,
# togliendo il prefisso "EMR-": es. EMR-D1 -> D1 "Pianura bolognese").
REGIONAL_TARGET_ZONES = {code.split("-")[-1]: name for code, name in TARGET_ZONES.items()}
@@ -177,15 +183,12 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE)
try:
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
mirror_alert_to_web = lambda *a, **k: False # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip civil_protection")
if message_html:
mirror_alert_to_web(message_html, "civil_protection", "warning", is_html=True)
return False
token = load_bot_token()
@@ -219,15 +222,6 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
except Exception as e:
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
if sent_ok:
try:
import sys
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import mirror_to_web
mirror_to_web(message_html, "civil_protection", "warning", is_html=True)
except Exception:
pass
return sent_ok
def load_state() -> dict:
@@ -441,7 +435,9 @@ def format_message(parsed: dict) -> str:
lines.append(f"📅 <b>{html_lib.escape(day.get('date_label',''))}</b>")
for zone in sorted(alerts.keys()):
lines.append(f"📍 <b>{html_lib.escape(zone)}</b>")
note = ZONE_NOTES.get(zone)
zlabel = f"{zone} · {note}" if note else zone
lines.append(f"📍 <b>{html_lib.escape(zlabel)}</b>")
for entry in alerts[zone]:
lines.append(html_lib.escape(entry))
lines.append("")
@@ -452,7 +448,9 @@ def format_message(parsed: dict) -> str:
lines.append(f"🗺️ <b>{html_lib.escape(titolo)}</b>")
alerts = doc.get("alerts", {})
for zone in sorted(alerts.keys()):
lines.append(f"📍 <b>{html_lib.escape(zone)}</b>")
note = ZONE_NOTES.get(zone)
zlabel = f"{zone} · {note}" if note else zone
lines.append(f"📍 <b>{html_lib.escape(zlabel)}</b>")
for entry in alerts[zone]:
lines.append(html_lib.escape(entry))
lines.append("")
@@ -462,6 +460,18 @@ def format_message(parsed: dict) -> str:
lines.append("<i>Fonte: mappe.protezionecivile.gov.it</i>")
return "\n".join(lines)
def _web_body_without_header(plain: str) -> str:
"""Rimuove la riga titolo 'PROTEZIONE CIVILE…' già usata come title WebApp."""
lines = (plain or "").splitlines()
if not lines:
return ""
first = lines[0].strip().upper()
if "PROTEZIONE CIVILE" in first:
return "\n".join(lines[1:]).strip()
return plain.strip()
# =============================================================================
# Main
# =============================================================================
@@ -521,16 +531,37 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False):
# A questo punto: ci sono allerte e sono nuove -> prova invio
msg = format_message(parsed)
sent_ok = telegram_send_html(msg, chat_ids=chat_ids)
web_ok = False
st = {
"date": today_str_italy(),
"last_alert_signature": sig,
}
try:
from webapp_alert import publish_web_alert, remember_summary, message_to_plain
plain = message_to_plain(msg, is_html=True)
card_body = _web_body_without_header(plain)
remember_summary(st, card_body)
web_ok = bool(
publish_web_alert(
card_body,
"civil_protection",
"warning",
is_html=False,
title="Allerta Protezione Civile / Arpae",
state=st,
)
)
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
if sent_ok:
LOGGER.info("Notifica allerta inviata con successo.")
save_state({
"date": today_str_italy(),
"last_alert_signature": sig,
})
if sent_ok or web_ok:
LOGGER.info(
"Notifica allerta consegnata (%s).",
"Telegram+WebApp" if sent_ok and web_ok else ("Telegram" if sent_ok else "WebApp"),
)
save_state(st)
else:
# Non aggiorniamo lo stato: quando risolvi token/rete, reinvierà.
LOGGER.warning("Invio non riuscito (token mancante o errore Telegram). Stato NON aggiornato.")
LOGGER.warning("Invio non riuscito (Telegram/WebApp). Stato NON aggiornato.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Civil protection alert")
+3 -1
View File
@@ -1,2 +1,4 @@
# 0 = sospende Telegram per script meteo/speedtest (WebApp invariata dove configurata)
# 0 = sospende Telegram per script meteo/speedtest (canale primario: WebApp Casa)
# Le variabili LOOGLE_INTERNAL_TOKEN / LOOGLE_CASA_URL / LOOGLE_NOTIFY_MODE
# vengono caricate automaticamente da /home/daniely/docker/loogle-casa/.env
LOOGLE_TELEGRAM_ALERTS=0
+21 -1
View File
@@ -507,6 +507,13 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
msg = "".join(msg_parts)
ok = telegram_send_html(msg, chat_ids=chat_ids)
web_ok = False
try:
from webapp_alert import publish_web_alert
web_ok = bool(publish_web_alert(msg, "freeze", "warning", is_html=True, state=state,
title="Allerta gelo"))
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
if ok:
LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s, nuove fasce: %d",
min_temp_val, min_temp_time.isoformat(), len(new_periods))
@@ -516,8 +523,15 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
"start": start.isoformat(),
"end": end.isoformat(),
})
elif web_ok:
LOGGER.info("Allerta gelo pubblicata su WebApp. Tmin=%.1f°C", min_temp_val)
for start, end in new_periods:
notified_periods.append({
"start": start.isoformat(),
"end": end.isoformat(),
})
else:
LOGGER.warning("Allerta gelo NON inviata (token mancante o errore Telegram).")
LOGGER.warning("Allerta gelo NON consegnata (Telegram/WebApp).")
else:
LOGGER.info("Gelo già notificato (nessuna nuova fascia oraria, peggioramento < 2°C). Tmin=%.1f°C", min_temp_val)
@@ -528,6 +542,12 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
"min_time": min_temp_time.isoformat(),
"notified_periods": notified_periods,
})
if not state.get("summary"):
state["summary"] = (
f"Allerta gelo a {LOCATION_NAME}.\n"
f"Minima prevista {min_temp_val:.1f}°C alle {fmt_dt(min_temp_time)} "
f"(prossime {HOURS_AHEAD}h)."
)
save_state(state)
return
+41 -1
View File
@@ -44,9 +44,45 @@ CATEGORIES = {
"telegram_error": re.compile(r"Telegram error|Bad Request|chat not found|can't parse entities", re.IGNORECASE),
"traceback": re.compile(r"Traceback", re.IGNORECASE),
"exception": re.compile(r"\bERROR\b|Exception", re.IGNORECASE),
"token_missing": re.compile(r"token missing|Token Telegram assente", re.IGNORECASE),
# Solo token realmente assente (non i messaggi ambigui con Telegram sospeso)
"token_missing": re.compile(
r"Telegram token missing|Token Telegram assente|Token Telegram mancante",
re.IGNORECASE,
),
}
# Comportamento atteso con LOOGLE_TELEGRAM_ALERTS=0 / canale WebApp: non sono problemi.
IGNORE_ISSUE_PATTERNS = [
re.compile(r"Telegram sospeso", re.IGNORECASE),
re.compile(r"Alert NON inviato \(token missing o errore Telegram\)", re.IGNORECASE),
re.compile(r"NOT sent \(token missing or Telegram error\)", re.IGNORECASE),
re.compile(r"Notifica NON inviata \(token/telegram\)", re.IGNORECASE),
re.compile(r"token missing o errore Telegram", re.IGNORECASE),
re.compile(r"token mancante o errore Telegram", re.IGNORECASE),
re.compile(r"Telegram saltato/sospeso", re.IGNORECASE),
re.compile(r"All-clear Telegram skip/fail", re.IGNORECASE),
re.compile(r"publish_web_alert failed", re.IGNORECASE), # gestito sotto come web_notify se serve
]
def telegram_alerts_suspended() -> bool:
env_path = os.path.join(BASE_DIR, "cron-alerts.env")
try:
with open(env_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("LOOGLE_TELEGRAM_ALERTS="):
val = line.split("=", 1)[1].strip().strip('"').strip("'").lower()
return val in ("0", "off", "false", "no", "disabled")
except OSError:
pass
val = os.environ.get("LOOGLE_TELEGRAM_ALERTS", "1").strip().lower()
return val in ("0", "off", "false", "no", "disabled")
def should_ignore_issue_line(line: str) -> bool:
return any(p.search(line) for p in IGNORE_ISSUE_PATTERNS)
def load_text_file(path: str) -> str:
try:
@@ -126,6 +162,8 @@ def analyze_logs(files: List[str], since: datetime.datetime, max_lines: int) ->
last_ts = ts
if not last_ts or last_ts < since:
continue
if should_ignore_issue_line(line):
continue
for cat, regex in CATEGORIES.items():
if regex.search(line):
category_hits[cat].append((last_ts, path, line))
@@ -169,6 +207,8 @@ def format_report(
lines.append(f"🧾 Log Monitor - ultimi {days} giorni")
lines.append(f"Intervallo: {since.strftime('%Y-%m-%d %H:%M')}{now.strftime('%Y-%m-%d %H:%M')}")
lines.append(f"File analizzati: {len(files)}")
if telegram_alerts_suspended():
lines.append("️ Telegram sospeso (LOOGLE_TELEGRAM_ALERTS=0): canale primario WebApp Casa")
lines.append("")
# Sezione log non aggiornati
+34 -21
View File
@@ -1,11 +1,18 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""DEPRECATED 2026-07-25 (ADR-019).
Sostituito da ``meteo-alert imminent`` (layer NWP 0120 in meteo-alert).
Il cron è disabilitato. Per forzare questo script legacy:
FORCE_LEGACY_NOWCAST_120M=1 python3 nowcast_120m_alert.py
"""
import argparse
import datetime
import json
import logging
import os
import sys
import time
from logging.handlers import RotatingFileHandler
from typing import Dict, List, Optional, Tuple
@@ -15,6 +22,14 @@ import requests
from dateutil import parser
from open_meteo_client import open_meteo_get
if os.environ.get("FORCE_LEGACY_NOWCAST_120M", "").strip() != "1":
print(
"DEPRECATED: use `meteo-alert imminent` (ADR-019). "
"Set FORCE_LEGACY_NOWCAST_120M=1 to run this script.",
file=sys.stderr,
)
raise SystemExit(0)
# =========================
# CONFIG
# =========================
@@ -129,14 +144,12 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
return False
try:
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
mirror_alert_to_web = lambda *a, **k: False # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip nowcast_120m")
mirror_alert_to_web(message, "nowcast_120m", "warning", is_html=False)
return False
token = load_bot_token()
@@ -169,15 +182,6 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
except Exception as e:
LOGGER.exception("Errore invio Telegram chat_id=%s: %s", chat_id, e)
if ok_any:
try:
import sys
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import mirror_to_web
mirror_to_web(message, "nowcast_120m", "warning", is_html=False)
except Exception:
pass
return ok_any
@@ -991,17 +995,26 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
)
ok = telegram_send_markdown(msg, chat_ids=chat_ids)
if ok:
LOGGER.info("Notifica inviata.")
# Salva state con eventi attivi aggiornati
state["active_events"] = active_events
web_ok = False
try:
from webapp_alert import publish_web_alert
web_ok = bool(publish_web_alert(msg, "nowcast_120m", "warning", is_html=False, state=state))
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
# Salva state con eventi attivi aggiornati
state["active_events"] = active_events
if ok or web_ok:
state["last_sent_utc"] = now_utc.isoformat(timespec="seconds")
save_state(state)
LOGGER.info("Notifica consegnata (%s).", "Telegram" if ok else "WebApp")
else:
LOGGER.error("Notifica NON inviata (token/telegram).")
# Salva comunque lo state aggiornato
state["active_events"] = active_events
save_state(state)
LOGGER.warning("Notifica NON consegnata (Telegram/WebApp).")
try:
from webapp_alert import remember_summary, message_to_plain
remember_summary(state, message_to_plain(msg, is_html=False))
except Exception:
pass
save_state(state)
if __name__ == "__main__":
+260 -148
View File
@@ -342,6 +342,38 @@ def _median_or_single(values):
return median(nums)
def _merge_weathercode(values, preferred=None):
"""
Unisce codici WMO categorici con moda (mai mediana: 61+71 66 falso gelicidio).
In pareggio preferisce `preferred` se presente tra i valori, altrimenti il primo.
"""
ints = []
for v in values:
if v is None:
continue
try:
ints.append(int(v))
except (TypeError, ValueError):
pass
if not ints:
return None
if preferred is not None:
try:
preferred = int(preferred)
except (TypeError, ValueError):
preferred = None
counts = {}
for c in ints:
counts[c] = counts.get(c, 0) + 1
max_count = max(counts.values())
modes = [c for c, n in counts.items() if n == max_count]
if len(modes) == 1:
return modes[0]
if preferred is not None and preferred in modes:
return preferred
return ints[0]
# Chiavi solo ICON Italia (precip 02d: niente mediana con AROME HD a San Marino)
HOURLY_KEYS_ICON_ONLY = [
"snow_depth", "showers", "precipitation", "rain", "snowfall",
@@ -349,6 +381,7 @@ HOURLY_KEYS_ICON_ONLY = [
DAILY_KEYS_ICON_ONLY = [
"showers_sum", "precipitation_sum", "rain_sum", "snowfall_sum", "precipitation_hours",
]
PREFERRED_WEATHERCODE_MODEL = "italia_meteo_arpae_icon_2i"
def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source_model=None):
@@ -397,17 +430,26 @@ def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source
out[key].append(val)
else:
vals = []
preferred_wc = None
for _m, h in hourly_by_model:
times = h.get("time", []) or []
arr = h.get(key, []) or []
for i, t in enumerate(times):
if _normalize_time_key(str(t)) == ref_k and i < len(arr) and arr[i] is not None:
try:
vals.append(float(arr[i]))
if key == "weathercode":
vals.append(int(arr[i]))
if _m == PREFERRED_WEATHERCODE_MODEL:
preferred_wc = int(arr[i])
else:
vals.append(float(arr[i]))
except (TypeError, ValueError):
pass
break
out[key].append(_median_or_single(vals) if vals else None)
if key == "weathercode":
out[key].append(_merge_weathercode(vals, preferred=preferred_wc) if vals else None)
else:
out[key].append(_median_or_single(vals) if vals else None)
n = len(out["time"])
if n > 1:
order = sorted(range(n), key=lambda i: str(out["time"][i]))
@@ -461,17 +503,26 @@ def _merge_daily_median(daily_by_model, single_source_keys=None, single_source_m
out[key].append(val)
else:
vals = []
preferred_wc = None
for _m, d in daily_by_model:
times = d.get("time", []) or []
arr = d.get(key, []) or []
for i, t in enumerate(times):
if str(t)[:10] == date_str and i < len(arr) and arr[i] is not None:
try:
vals.append(float(arr[i]))
if key == "weathercode":
vals.append(int(arr[i]))
if _m == PREFERRED_WEATHERCODE_MODEL:
preferred_wc = int(arr[i])
else:
vals.append(float(arr[i]))
except (TypeError, ValueError):
pass
break
out[key].append(_median_or_single(vals) if vals else None)
if key == "weathercode":
out[key].append(_merge_weathercode(vals, preferred=preferred_wc) if vals else None)
else:
out[key].append(_median_or_single(vals) if vals else None)
# Ordina cronologicamente (evita buchi nel report se l'unione non era ordinata)
n = len(out["time"])
if n > 1:
@@ -675,7 +726,7 @@ def format_day_label(day_index: int, daily_time_list, with_relative: bool = True
return f"giorno {day_index + 1}"
def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10):
"""Analizza trend temperatura per identificare fronti caldi/freddi con dettaglio completo"""
"""Analizza trend di Tmax e Tmin (non la media giornaliera) per identificare cali/rialzi."""
if not daily_temps_max or not daily_temps_min:
return None
@@ -683,93 +734,105 @@ def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10):
if max_days < 3:
return None
# Filtra valori None e calcola temperature medie giornaliere
avg_temps = []
valid_indices = []
tmax_series = []
tmin_series = []
for i in range(max_days):
t_max = daily_temps_max[i]
t_min = daily_temps_min[i]
if t_max is not None and t_min is not None:
avg_temps.append((float(t_max) + float(t_min)) / 2)
valid_indices.append(i)
tmax_series.append(float(t_max))
tmin_series.append(float(t_min))
else:
avg_temps.append(None)
tmax_series.append(None)
tmin_series.append(None)
if len([t for t in avg_temps if t is not None]) < 3:
valid_max = [t for t in tmax_series if t is not None]
valid_min = [t for t in tmin_series if t is not None]
if len(valid_max) < 3 or len(valid_min) < 3:
return None
# Analizza tendenza generale (prime 3 giorni vs ultimi 3 giorni validi)
valid_temps = [t for t in avg_temps if t is not None]
if len(valid_temps) < 3:
return None
first_max = mean(valid_max[:3])
last_max = mean(valid_max[-3:])
first_min = mean(valid_min[:3])
last_min = mean(valid_min[-3:])
delta_max = last_max - first_max
delta_min = last_min - first_min
# Delta dominante per classificare il tipo (maggiore |Δ|)
if abs(delta_max) >= abs(delta_min):
primary_delta = delta_max
primary_series = "max"
else:
primary_delta = delta_min
primary_series = "min"
first_avg = mean(valid_temps[:3])
last_avg = mean(valid_temps[-3:])
diff = last_avg - first_avg
# Contesto: massime finali ancora elevate → niente retorica "fronte freddo" invernale
warm_context = last_max >= 25.0
trend_type = None
trend_intensity = "moderato"
if diff > 5:
trend_type = "fronte_caldo"
trend_intensity = "forte" if diff > 8 else "moderato"
elif diff > 2:
if primary_delta > 5:
trend_type = "fronte_caldo" if not warm_context or primary_delta > 8 else "riscaldamento"
trend_intensity = "forte" if primary_delta > 8 else "moderato"
elif primary_delta > 2:
trend_type = "riscaldamento"
trend_intensity = "moderato"
elif diff < -5:
trend_type = "fronte_freddo"
trend_intensity = "forte" if diff < -8 else "moderato"
elif diff < -2:
elif primary_delta < -5:
if warm_context:
trend_type = "calo_termico"
else:
trend_type = "fronte_freddo"
trend_intensity = "forte" if primary_delta < -8 else "moderato"
elif primary_delta < -2:
trend_type = "raffreddamento"
trend_intensity = "moderato"
else:
trend_type = "stabile"
# Identifica giorni di cambio significativo
change_days = []
prev_temp = None
for i, temp in enumerate(avg_temps):
if temp is not None:
if prev_temp is not None:
day_diff = temp - prev_temp
if abs(day_diff) > 3: # Cambio significativo (>3°C)
prev_max = prev_min = None
for i in range(max_days):
tm = tmax_series[i]
tn = tmin_series[i]
if tm is not None and tn is not None:
if prev_max is not None and prev_min is not None:
d_max = tm - prev_max
d_min = tn - prev_min
if abs(d_max) > 3:
change_days.append({
"day": i,
"delta": round(day_diff, 1),
"from": round(prev_temp, 1),
"to": round(temp, 1)
"series": "max",
"delta": round(d_max, 1),
"from": round(prev_max, 1),
"to": round(tm, 1),
})
prev_temp = temp
# Analisi per periodi (primi 3 giorni, medio termine, lungo termine)
period_analysis = {}
if len(valid_temps) >= 7:
period_analysis["short_term"] = {
"avg": round(mean(valid_temps[:3]), 1),
"range": round(max(valid_temps[:3]) - min(valid_temps[:3]), 1)
}
mid_start = len(valid_temps) // 3
mid_end = (len(valid_temps) * 2) // 3
period_analysis["mid_term"] = {
"avg": round(mean(valid_temps[mid_start:mid_end]), 1),
"range": round(max(valid_temps[mid_start:mid_end]) - min(valid_temps[mid_start:mid_end]), 1)
}
period_analysis["long_term"] = {
"avg": round(mean(valid_temps[-3:]), 1),
"range": round(max(valid_temps[-3:]) - min(valid_temps[-3:]), 1)
}
if abs(d_min) > 3:
change_days.append({
"day": i,
"series": "min",
"delta": round(d_min, 1),
"from": round(prev_min, 1),
"to": round(tn, 1),
})
prev_max, prev_min = tm, tn
return {
"type": trend_type,
"intensity": trend_intensity,
"delta": round(diff, 1),
"delta": round(primary_delta, 1),
"delta_max": round(delta_max, 1),
"delta_min": round(delta_min, 1),
"primary_series": primary_series,
"warm_context": warm_context,
"change_days": change_days,
"first_avg": round(first_avg, 1),
"last_avg": round(last_avg, 1),
"period_analysis": period_analysis,
"daily_avg_temps": avg_temps,
"first_max": round(first_max, 1),
"last_max": round(last_max, 1),
"first_min": round(first_min, 1),
"last_min": round(last_min, 1),
# Compatibilità chiavi legacy (usate da generate_practical_advice)
"first_avg": round((first_max + first_min) / 2, 1),
"last_avg": round((last_max + last_min) / 2, 1),
"daily_max": daily_temps_max[:max_days],
"daily_min": daily_temps_min[:max_days]
"daily_min": daily_temps_min[:max_days],
}
def analyze_weather_transitions(daily_weathercodes):
@@ -807,13 +870,18 @@ def analyze_weather_transitions(daily_weathercodes):
return transitions
def get_precip_type(code):
"""Definisce il tipo di precipitazione in base al codice WMO."""
if (71 <= code <= 77) or code in [85, 86]:
def get_precip_type(code, temp=None):
"""Definisce il tipo di precipitazione in base al codice WMO (gate termico per neve/gelicidio)."""
try:
code = int(code) if code is not None else 0
except (TypeError, ValueError):
code = 0
cold_enough = temp is None or float(temp) <= 1.0
if ((71 <= code <= 77) or code in [85, 86]) and cold_enough:
return "❄️ Neve"
if code in [96, 99]:
return "⚡🌨 Grandine"
if code in [66, 67]:
if code in [66, 67] and cold_enough:
return "🧊☔ Pioggia Congelantesi"
return "☔ Pioggia"
@@ -824,6 +892,40 @@ def get_intensity_label(mm_h):
return "Moderata"
return "Forte ⚠️"
def _add_one_hour_hhmm(hhmm):
"""Somma 1 ora a una stringa HH:MM (mod 24)."""
try:
h, m = hhmm.split(":")
return f"{(int(h) + 1) % 24:02d}:{int(m):02d}"
except (ValueError, AttributeError):
return hhmm
def _format_event_hours(times, start_idx, end_idx_inclusive):
"""
Formato HH:MM-HH:MM per bucket orari Open-Meteo.
Una sola ora 20:00-21:00 (mai 20:00-20:00). Fine = ora successiva all'ultima inclusa.
"""
if not times or start_idx < 0 or start_idx >= len(times):
return "??:??-??:??"
end_idx_inclusive = max(start_idx, min(end_idx_inclusive, len(times) - 1))
start_time = times[start_idx].split("T")[1][:5] if "T" in str(times[start_idx]) else str(times[start_idx])[:5]
next_idx = end_idx_inclusive + 1
if next_idx < len(times):
end_time = times[next_idx].split("T")[1][:5] if "T" in str(times[next_idx]) else str(times[next_idx])[:5]
else:
last_t = times[end_idx_inclusive].split("T")[1][:5] if "T" in str(times[end_idx_inclusive]) else str(times[end_idx_inclusive])[:5]
end_time = _add_one_hour_hhmm(last_t)
if start_time == end_time:
end_time = _add_one_hour_hhmm(start_time)
return f"{start_time}-{end_time}"
ICE_EVENT_MAX_AIR_TEMP = 2.0 # scarta pericoli invernali se Tmin blocco > questa soglia
GELICIDIO_MAX_AIR_TEMP = 1.0
def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, snowfalls=None, rains=None, soil_temps=None, cloud_covers=None, wind_speeds=None):
"""Scansiona le 24 ore e trova blocchi di eventi continui."""
events = []
@@ -838,14 +940,13 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
if cloud_covers is None:
cloud_covers = [None] * len(times)
if wind_speeds is None:
wind_speeds = [None] * len(times)
wind_speeds = winds if winds else [None] * len(times)
# Calcola precipitazioni cumulative delle 3h precedenti per ogni punto
# Calcola precipitazioni cumulate nelle 3h precedenti per ogni ora
precip_3h_sum = []
rain_3h_sum = []
snow_3h_sum = []
for i in range(len(times)):
# Somma delle 3 ore precedenti (i-3, i-2, i-1)
start_idx = max(0, i - 3)
precip_sum = sum([float(p) if p is not None else 0.0 for p in precip[start_idx:i]])
rain_sum = sum([float(r) if r is not None else 0.0 for r in rains[start_idx:i]])
@@ -854,7 +955,7 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
rain_3h_sum.append(rain_sum)
snow_3h_sum.append(snow_sum)
# 1. PERICOLI (Ghiaccio, Gelo, Brina) - Logica migliorata allineata a check_ghiaccio.py
# 1. PERICOLI (Ghiaccio, Gelo, Brina) - con gate termici anti falsi positivi estivi
in_ice = False
start_ice = 0
ice_type = ""
@@ -883,7 +984,7 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
try:
hour = int(times[i].split("T")[1].split(":")[0]) if "T" in times[i] else 12
is_night = (hour >= 18) or (hour <= 6)
except:
except Exception:
is_night = False
# Calcola temperatura suolo: usa valore misurato se disponibile, altrimenti stima (1-2°C più fredda)
@@ -891,48 +992,41 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
t_soil = t - 1.5 # Approssimazione conservativa
# Applica raffreddamento radiativo: cielo sereno + notte + vento debole
# Riduce la temperatura del suolo di 0.5-1.5°C (come in check_ghiaccio.py)
t_soil_adjusted = t_soil
if is_night and cloud is not None and cloud < 20.0:
if wind is None or wind < 5.0:
cooling = 1.5 # Vento molto debole = più raffreddamento
cooling = 1.5
elif wind < 10.0:
cooling = 1.0
else:
cooling = 0.5
t_soil_adjusted = t_soil - cooling
# Precipitazioni nelle 3h precedenti
p_3h = precip_3h_sum[i] if i < len(precip_3h_sum) else 0.0
r_3h = rain_3h_sum[i] if i < len(rain_3h_sum) else 0.0
s_3h = snow_3h_sum[i] if i < len(snow_3h_sum) else 0.0
# LOGICA MIGLIORATA (allineata a check_ghiaccio.py):
current_ice_condition = None
# 1. GELICIDIO (Freezing Rain) - priorità massima
# 1. GELICIDIO: codice 66/67 richiede anche T aria <= soglia (evita WMO spurii in estate)
is_raining_code = (50 <= c <= 69) or (80 <= c <= 82)
if c in [66, 67] or (p > 0 and t <= 0 and is_raining_code):
if (c in [66, 67] and t <= GELICIDIO_MAX_AIR_TEMP) or (p > 0 and t <= 0 and is_raining_code):
current_ice_condition = "🧊☠️ GELICIDIO"
# 2. Black Ice o Neve Ghiacciata - Precipitazione nelle 3h precedenti + suolo gelato
elif p_3h > 0.1 and t_soil_adjusted < 0.0:
# Distingue tra neve e pioggia
# 2. Black Ice o Neve Ghiacciata
elif p_3h > 0.1 and t_soil_adjusted < 0.0 and t <= ICE_EVENT_MAX_AIR_TEMP:
has_snow = (s_3h > 0.1) or (snowfall_curr > 0.1)
has_rain = (r_3h > 0.1) or (rain_curr > 0.1)
if has_snow:
current_ice_condition = "⛸️⚠️ Neve ghiacciata (suolo gelato)"
elif has_rain:
current_ice_condition = "⛸️⚠️ Black Ice (strada bagnata + suolo gelato)"
else:
current_ice_condition = "⛸️⚠️ Black Ice (strada bagnata + suolo gelato)"
# 3. BRINA (Hoar Frost) - Suolo <= 0°C e punto di rugiada > suolo ma < 0°C
elif p_3h <= 0.1 and t_soil_adjusted <= 0.0 and d is not None:
# 3. BRINA
elif p_3h <= 0.1 and t_soil_adjusted <= 0.0 and d is not None and t <= ICE_EVENT_MAX_AIR_TEMP:
if d > t_soil_adjusted and d < 0.0:
current_ice_condition = "⛸️⚠️ GHIACCIO/BRINA"
# 4. GELATA - Temperatura aria < 0°C (senza altre condizioni)
# 4. GELATA
elif t < 0:
current_ice_condition = "🧊 Gelata"
@@ -941,23 +1035,22 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
start_ice = i
ice_type = current_ice_condition
elif (not current_ice_condition and in_ice) or (in_ice and current_ice_condition != ice_type) or (in_ice and i == len(times)-1):
end_idx = i if not current_ice_condition else i
if end_idx > start_ice:
start_time = times[start_ice].split("T")[1][:5]
end_time = times[min(end_idx, len(times)-1)].split("T")[1][:5]
temp_block = temps[start_ice:min(end_idx+1, len(temps))]
temp_block_clean = [t for t in temp_block if t is not None]
if not current_ice_condition and in_ice:
end_inclusive = i - 1
elif in_ice and current_ice_condition and current_ice_condition != ice_type:
end_inclusive = i - 1
else:
end_inclusive = i
if end_inclusive >= start_ice:
hours_str = _format_event_hours(times, start_ice, end_inclusive)
temp_block = temps[start_ice:end_inclusive + 1]
temp_block_clean = [tv for tv in temp_block if tv is not None]
min_t = min(temp_block_clean) if temp_block_clean else 0
# Per GHIACCIO/BRINA, verifica che la temperatura minima sia effettivamente sotto/sopra soglia critica
# Se la temperatura minima è > 1.5°C, non è un rischio reale
if ice_type == "⛸️⚠️ GHIACCIO/BRINA" and min_t > 1.5:
# Non segnalare se la temperatura minima è troppo alta
pass
else:
events.append(f"{ice_type}: {start_time}-{end_time} (Min: {min_t:.0f}°C)")
# Gate termico su tutti i pericoli invernali (non solo brina)
if min_t <= ICE_EVENT_MAX_AIR_TEMP:
events.append(f"{ice_type}: {hours_str} (Min: {min_t:.0f}°C)")
in_ice = False
if current_ice_condition:
if current_ice_condition:
in_ice = True
start_ice = i
ice_type = current_ice_condition
@@ -975,48 +1068,53 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
in_rain = True
start_idx = i
code_val = codes[i] if i < len(codes) and codes[i] is not None else 0
t_val = temps[i] if i < len(temps) and temps[i] is not None else None
try:
code_val = int(code_val) if code_val is not None else 0
except (ValueError, TypeError):
code_val = 0
current_rain_type = get_precip_type(code_val)
current_rain_type = get_precip_type(code_val, temp=t_val)
elif in_rain and is_raining and i < len(codes):
code_val = codes[i] if codes[i] is not None else 0
t_val = temps[i] if i < len(temps) and temps[i] is not None else None
try:
code_val = int(code_val) if code_val is not None else 0
except (ValueError, TypeError):
code_val = 0
new_type = get_precip_type(code_val)
new_type = get_precip_type(code_val, temp=t_val)
if new_type != current_rain_type:
end_idx = i
block_precip = precip[start_idx:end_idx] if end_idx <= len(precip) else precip[start_idx:]
end_inclusive = i - 1
block_precip = precip[start_idx:i] if i <= len(precip) else precip[start_idx:]
block_precip_clean = [p for p in block_precip if p is not None]
tot_mm = sum(block_precip_clean)
start_time = times[start_idx].split("T")[1][:5]
end_time = times[end_idx].split("T")[1][:5] if end_idx < len(times) else times[-1].split("T")[1][:5]
hours_str = _format_event_hours(times, start_idx, end_inclusive)
avg_intensity = tot_mm / len(block_precip) if block_precip else 0
events.append(
f"{current_rain_type} ({get_intensity_label(avg_intensity)}):\n"
f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm"
f" 🕒 {hours_str} | 💧 {tot_mm:.1f}mm"
)
start_idx = i
current_rain_type = new_type
elif (not is_raining and in_rain) or (in_rain and i == len(times)-1):
in_rain = False
end_idx = i if not is_raining else i + 1
block_precip = precip[start_idx:end_idx] if end_idx <= len(precip) else precip[start_idx:]
if not is_raining:
end_inclusive = i - 1
end_slice = i
else:
end_inclusive = i
end_slice = i + 1
block_precip = precip[start_idx:end_slice] if end_slice <= len(precip) else precip[start_idx:]
block_precip_clean = [p for p in block_precip if p is not None]
tot_mm = sum(block_precip_clean)
if tot_mm > 0:
start_time = times[start_idx].split("T")[1][:5]
end_time = times[min(end_idx-1, len(times)-1)].split("T")[1][:5]
if tot_mm > 0 and end_inclusive >= start_idx:
hours_str = _format_event_hours(times, start_idx, end_inclusive)
avg_intensity = tot_mm / len(block_precip) if block_precip else 0
events.append(
f"{current_rain_type} ({get_intensity_label(avg_intensity)}):\n"
f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm"
f" 🕒 {hours_str} | 💧 {tot_mm:.1f}mm"
)
# 3. VENTO
@@ -1027,10 +1125,10 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
if max_wind > SOGLIA_VENTO_KMH:
try:
peak_idx = winds.index(max_wind)
except ValueError:
peak_idx = 0
peak_time = times[min(peak_idx, len(times)-1)].split("T")[1][:5]
events.append(f"💨 Vento Forte: Picco {max_wind:.0f}km/h alle {peak_time}")
peak_time = times[peak_idx].split("T")[1][:5]
events.append(f"💨 Picco vento: {max_wind:.0f}km/h alle {peak_time}")
except (ValueError, IndexError, AttributeError):
events.append(f"💨 Picco vento: {max_wind:.0f}km/h")
return events
@@ -1042,6 +1140,8 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
if trend:
if trend["type"] == "fronte_freddo" and trend["intensity"] == "forte":
advice.append("❄️ <b>Fronte Freddo in Arrivo:</b> Preparati a temperature in calo significativo. Controlla riscaldamento, proteggi piante sensibili.")
elif trend["type"] == "calo_termico" and trend["intensity"] == "forte":
advice.append("📉 <b>Calo termico:</b> Massime e/o minime in netto ribasso rispetto all'inizio periodo, senza necessariamente gelo.")
elif trend["type"] == "fronte_caldo" and trend["intensity"] == "forte":
advice.append("🔥 <b>Ondata di Calore:</b> Temperature in aumento. Mantieni case fresche, idratazione importante, attenzione a persone fragili.")
elif trend["type"] == "raffreddamento":
@@ -1075,55 +1175,58 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
return advice
def format_detailed_trend_explanation(trend, daily_time_list=None, display_days=DISPLAY_FORECAST_DAYS):
"""Genera spiegazione dettagliata del trend temperatura sui giorni in previsione."""
"""Genera spiegazione dettagliata del trend su Tmax e Tmin (non temperature medie)."""
if not trend:
return ""
explanation = []
explanation.append(f"📊 <b>EVOLUZIONE TEMPERATURE ({display_days} GIORNI)</b>\n")
# Trend principale con spiegazione chiara
trend_type = trend["type"]
intensity = trend["intensity"]
delta = trend['delta']
first_avg = trend['first_avg']
last_avg = trend['last_avg']
first_max = trend["first_max"]
last_max = trend["last_max"]
first_min = trend["first_min"]
last_min = trend["last_min"]
delta_max = trend["delta_max"]
delta_min = trend["delta_min"]
if trend_type == "fronte_caldo":
trend_desc = "🔥 <b>Fronte Caldo in Arrivo</b>"
desc_text = f"Arrivo di aria più calda: temperatura media passerà da {first_avg:.1f}°C a {last_avg:.1f}°C (+{delta:.1f}°C)."
elif trend_type == "fronte_freddo":
trend_desc = "❄️ <b>Fronte Freddo in Arrivo</b>"
desc_text = f"Arrivo di aria più fredda: temperatura media scenderà da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)."
elif trend_type == "calo_termico":
trend_desc = "📉 <b>Calo Termico</b>"
elif trend_type == "riscaldamento":
trend_desc = "📈 <b>Riscaldamento Progressivo</b>"
desc_text = f"Tendenza al rialzo delle temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C (+{delta:.1f}°C)."
elif trend_type == "raffreddamento":
trend_desc = "📉 <b>Raffreddamento Progressivo</b>"
desc_text = f"Tendenza al ribasso delle temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)."
elif trend_type == "stabile":
trend_desc = "➡️ <b>Temperature Stabili</b>"
desc_text = f"Temperature medie sostanzialmente stabili: da {first_avg:.1f}°C a {last_avg:.1f}°C (variazione {delta:+.1f}°C)."
else:
trend_desc = "🌡️ <b>Variazione Termica</b>"
desc_text = f"Evoluzione temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)."
intensity_text = " (variazione significativa)" if intensity == "forte" else " (variazione moderata)"
explanation.append(f"{trend_desc}{intensity_text}")
explanation.append(f"{desc_text}")
explanation.append(
f"Massime: {first_max:.1f}{last_max:.1f}°C ({delta_max:+.1f}°C). "
f"Minime: {first_min:.1f}{last_min:.1f}°C ({delta_min:+.1f}°C)."
)
# Aggiungi solo picchi significativi in modo sintetico (entro i giorni in tabella)
if trend.get("change_days"):
significant_changes = [
c for c in trend["change_days"]
if abs(c["delta"]) > 3.0 and c["day"] < display_days
][:3]
][:4]
if significant_changes:
change_texts = []
for change in significant_changes:
day_name = format_day_label(change["day"], daily_time_list or [])
direction = "" if change['delta'] > 0 else ""
change_texts.append(f"{direction} {day_name}: {change['from']:.0f}°→{change['to']:.0f}°C")
direction = "" if change["delta"] > 0 else ""
series_lbl = "max" if change.get("series") == "max" else "min"
change_texts.append(
f"{direction} {day_name} {series_lbl}: {change['from']:.0f}°→{change['to']:.0f}°C"
)
if change_texts:
explanation.append(f"Picchi: {', '.join(change_texts)}")
@@ -1272,8 +1375,10 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
threshold_mm = 5.0 # Soglia default per pioggia
if precip_amount > 0.1:
# Se snowfall è disponibile e positivo, usa quello (più preciso)
if snow_sum_day > 0.1:
# Se snowfall è disponibile e positivo, usa quello (più preciso) solo con aria fredda
temps_clean_day = [float(t) for t in d_temps_day if t is not None]
day_t_min_early = min(temps_clean_day) if temps_clean_day else None
if snow_sum_day > 0.1 and day_t_min_early is not None and day_t_min_early <= ICE_EVENT_MAX_AIR_TEMP:
# Se c'è neve (anche poca), il simbolo è sempre ❄️ (priorità alla neve)
precip_type_symbol = "❄️" # Neve
threshold_mm = 0.5 # Soglia più bassa per neve (anche pochi mm sono significativi)
@@ -1284,12 +1389,14 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
hail_codes = [96, 99] # Codici WMO per grandine/temporale
snow_count = sum(1 for c in d_codes_day if c is not None and int(c) in snow_codes)
hail_count = sum(1 for c in d_codes_day if c is not None and int(c) in hail_codes)
temps_clean = [float(t) for t in d_temps_day if t is not None]
day_t_min = min(temps_clean) if temps_clean else None
if hail_count > 0:
precip_type_symbol = "⛈️" # Grandine/Temporale
threshold_mm = 5.0
elif snow_count > 0:
# Solo se weathercode indica esplicitamente neve
elif snow_count > 0 and day_t_min is not None and day_t_min <= ICE_EVENT_MAX_AIR_TEMP:
# Weathercode neve solo se aria vicino allo zero (evita falsi ❄️ estivi)
precip_type_symbol = "❄️" # Neve
threshold_mm = 0.5 # Soglia più bassa per neve
@@ -1493,8 +1600,9 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
pass # Gestito separatamente per l'icona meteo
if precip_sum > 0.1:
cold_enough_day = t_min <= ICE_EVENT_MAX_AIR_TEMP
# Priorità 1: Se sta nevicando (snowfall > 0) e c'è manto nevoso, considera entrambi
if has_snow_depth_data and max_snow_depth > 0:
if cold_enough_day and has_snow_depth_data and max_snow_depth > 0:
# C'è sia neve in caduta che manto nevoso persistente
if rain_sum > 0.1 or showers_sum > 0.1:
precip_type = "mixed" # Neve + pioggia/temporali
@@ -1505,7 +1613,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
# Il tipo di precipitazione resta quello basato su snowfall/rain
pass
# Priorità 2: Usa dati daily se disponibili
elif snowfall_sum > 0.1:
elif cold_enough_day and snowfall_sum > 0.1:
# C'è neve significativa
if snowfall_sum >= precip_sum * 0.5:
precip_type = "snow"
@@ -1527,7 +1635,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
else:
# Fallback: usa dati hourly se daily non disponibili
snow_sum_day = sum([float(s) for s in d_snow if s is not None]) if d_snow else 0.0
if snow_sum_day > 0.1:
if snow_sum_day > 0.1 and t_min <= ICE_EVENT_MAX_AIR_TEMP:
if snow_sum_day >= precip_sum * 0.5:
precip_type = "snow"
else:
@@ -1543,7 +1651,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
if hail_count > 0:
precip_type = "hail"
elif snow_count > rain_count:
elif snow_count > rain_count and t_min is not None and float(t_min) <= ICE_EVENT_MAX_AIR_TEMP:
precip_type = "snow"
else:
precip_type = "rain"
@@ -1563,8 +1671,8 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
weather_icon = "🌨️" # Precipitazione mista
else:
weather_icon = "🌧️" # Pioggia
elif has_snow_depth_data and max_snow_depth > 0:
# C'è manto nevoso persistente anche senza precipitazioni
elif has_snow_depth_data and max_snow_depth > 0 and t_min <= ICE_EVENT_MAX_AIR_TEMP:
# C'è manto nevoso persistente anche senza precipitazioni (solo se aria abbastanza fredda)
# Mostra icona neve anche se non sta nevicando
weather_icon = "❄️" # Manto nevoso presente
elif t_min < 0:
@@ -1699,8 +1807,8 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
# Caratterizza usando dati daily se disponibili
precip_parts = []
# Neve
if day_info.get('snowfall_sum', 0) > 0.1:
# Neve (solo con aria abbastanza fredda: evita cm spurii in estate)
if day_info.get('snowfall_sum', 0) > 0.1 and day_info['t_min'] <= ICE_EVENT_MAX_AIR_TEMP:
precip_parts.append(f"❄️ {day_info['snowfall_sum']:.1f}cm")
# Pioggia
@@ -1715,6 +1823,10 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
if not precip_parts:
precip_symbol = "❄️" if day_info['precip_type'] == "snow" else "⛈️" if day_info['precip_type'] in ("hail", "thunderstorms") else "🌨️" if day_info['precip_type'] == "mixed" else "🌧️"
precip_parts.append(f"{precip_symbol} {day_info['precip_sum']:.1f}mm")
elif day_info['precip_sum'] > 0.1 and day_info.get('snowfall_sum', 0) > 0.1 and day_info['t_min'] > ICE_EVENT_MAX_AIR_TEMP:
# snowfall spurio a caldo: assicurati che l'accumulo pioggia totale sia visibile
if not any("🌧️" in p or "⛈️" in p for p in precip_parts):
precip_parts.append(f"🌧️ {day_info['precip_sum']:.1f}mm")
line += f" | {' + '.join(precip_parts)}"
@@ -1738,7 +1850,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
elif snow_depth_avg is not None and snow_depth_avg > 0:
snow_depth_end = snow_depth_avg # Usa la media come fallback
if snow_depth_end is not None and snow_depth_end > 0:
if snow_depth_end is not None and snow_depth_end > 0 and day_info['t_min'] <= ICE_EVENT_MAX_AIR_TEMP:
snow_depth_str = f"❄️ Manto nevoso: {snow_depth_end:.1f} cm"
# Mostra evoluzione rispetto al giorno precedente
if prev_snow_depth_end is not None:
+17 -19
View File
@@ -273,15 +273,12 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
"""
try:
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
from telegram_gate import telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
mirror_alert_to_web = lambda *a, **k: False # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip severe_weather")
if message_html:
mirror_alert_to_web(message_html, "severe_weather", "warning", is_html=True)
return False
token = load_bot_token()
@@ -315,15 +312,6 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
except Exception as e:
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
if sent_ok:
try:
import sys
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import mirror_to_web
mirror_to_web(message_html, "severe_weather", "warning", is_html=True)
except Exception as e:
LOGGER.debug("Web dispatch failed: %s", e)
return sent_ok
@@ -1859,10 +1847,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
msg = f"{headline}\n{meta}\n{body}{footer}"
ok = telegram_send_html(msg, chat_ids=chat_ids)
if ok:
LOGGER.info("Alert sent successfully.")
else:
LOGGER.warning("Alert NOT sent (token missing or Telegram error).")
web_ok = False
# IMPORTANTE: Imposta alert_active = True solo se c'è una vera allerta,
# non se è solo un messaggio informativo in modalità debug
@@ -1879,10 +1864,23 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
state["alert_active"] = True
state["last_alert_type"] = alert_types if alert_types else None
state["last_alert_time"] = now.isoformat()
if ok:
try:
from webapp_alert import publish_web_alert
web_ok = bool(publish_web_alert(msg, "severe_weather", "warning", is_html=True, state=state))
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
if ok or web_ok:
record_notify_message(now, state, alert_signature)
save_state(state)
if ok:
LOGGER.info("Alert sent successfully (Telegram).")
elif web_ok:
LOGGER.info("Alert published on WebApp.")
else:
LOGGER.warning("Alert NOT delivered (Telegram/WebApp).")
if debug_message_only:
# In debug mode senza vere allerte, non modificare alert_active
LOGGER.debug("[DEBUG MODE] Messaggio inviato ma alert_active non modificato (nessuna vera allerta)")
return
@@ -1942,7 +1940,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
if ok:
LOGGER.info("All-clear sent successfully.")
else:
LOGGER.warning("All-clear NOT sent (token missing or Telegram error).")
LOGGER.info("All-clear Telegram skip/fail (WebApp primaria se attiva).")
state = {
"alert_active": False,
@@ -814,12 +814,30 @@ def analyze_all_locations(debug_mode: bool = False) -> None:
return
ok = telegram_send_html(msg, chat_ids=[TELEGRAM_CHAT_IDS[0]] if debug_mode else None)
web_ok = False
try:
from webapp_alert import publish_web_alert
web_ok = bool(publish_web_alert(msg, "severe_circondario", "warning", is_html=True, state=state))
except Exception as e:
LOGGER.debug("Web summary failed: %s", e)
delivered = bool(ok or web_ok)
if ok:
LOGGER.info("Alert inviato (%s) per %d località significative", category, len(significant_locations))
LOGGER.info("Alert inviato su Telegram (%s) per %d località significative", category, len(significant_locations))
elif web_ok:
LOGGER.info("Alert pubblicato su WebApp (%s) per %d località significative", category, len(significant_locations))
else:
LOGGER.warning("Alert NON inviato (token missing o errore Telegram)")
if ok and not debug_mode:
try:
from telegram_gate import telegram_alerts_enabled
suspended = not telegram_alerts_enabled()
except Exception:
suspended = False
if suspended:
LOGGER.warning("Alert NON pubblicato su WebApp (Telegram sospeso)")
else:
LOGGER.warning("Alert NON inviato (errore Telegram o WebApp)")
if delivered and not debug_mode:
record_notify(category, now, state)
state["last_signature"] = signature
state["last_signature_date"] = today
+274 -150
View File
@@ -135,6 +135,34 @@ def hhmm(dt: datetime.datetime) -> str:
return dt.strftime("%H:%M")
_WEEKDAYS_IT = ("lun", "mar", "mer", "gio", "ven", "sab", "dom")
def format_clock(dt: datetime.datetime, ref: Optional[datetime.datetime] = None) -> str:
"""HH:MM, con giorno corto se diverso da ref (default: oggi locale)."""
ref = ref or now_local()
label = hhmm(dt)
if dt.date() != ref.date():
return f"{_WEEKDAYS_IT[dt.weekday()]} {label}"
return label
def format_fascia(
start: Optional[datetime.datetime],
end: Optional[datetime.datetime],
ref: Optional[datetime.datetime] = None,
) -> str:
"""Fascia leggibile ~HH:MM~HH:MM (con giorno se serve)."""
if start is None:
return ""
ref = ref or now_local()
a = format_clock(start, ref)
if end is None or end == start:
return f"~{a}"
b = format_clock(end, ref)
return f"~{a}~{b}"
# =============================================================================
# Telegram
# =============================================================================
@@ -202,20 +230,100 @@ def load_state() -> Dict:
return default
def save_state(alert_active: bool, signature: str) -> None:
def save_state(alert_active: bool, signature: str, detail: Optional[Dict] = None) -> None:
try:
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
payload: Dict = {
"alert_active": alert_active,
"signature": signature,
"updated": now_local().isoformat(),
}
if detail:
payload.update(detail)
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(
{"alert_active": alert_active, "signature": signature, "updated": now_local().isoformat()},
f,
ensure_ascii=False,
indent=2,
)
json.dump(payload, f, ensure_ascii=False, indent=2)
except Exception as e:
LOGGER.exception("State write error: %s", e)
def build_plain_summary(bo_alerts: Dict, route_alerts: List[Dict]) -> str:
"""Testo leggibile per WebApp (dove / eventi / quando)."""
any_snow = bool(bo_alerts.get("snow_alert")) or any(x.get("snow_alert") for x in route_alerts)
any_rain = bool(bo_alerts.get("rain_alert")) or any(x.get("rain_alert") for x in route_alerts)
soglie: List[str] = []
if any_snow:
soglie.append(f"neve ≥{PERSIST_HOURS}h consecutive")
if any_rain:
soglie.append(
f"pioggia 3h ≥{SOGLIA_PIOGGIA_3H_MM:.0f} mm per ≥{PERSIST_HOURS}h"
)
lines: List[str] = [
f"Percorso scuola Bologna ↔ rientro · prossime {HOURS_AHEAD}h",
]
if soglie:
lines.append("Soglie: " + " · ".join(soglie))
lines.extend([
"",
"A Bologna:",
])
if bo_alerts.get("snow_alert"):
fascia = bo_alerts.get("snow_fascia") or f"~{bo_alerts.get('snow_run_time') or ''}"
lines.append(
f"• Neve {fascia} "
f"(12h {bo_alerts.get('snow_12h', 0):.1f} cm · 24h {bo_alerts.get('snow_24h', 0):.1f} cm)"
)
if bo_alerts.get("rain_alert"):
r3 = float(bo_alerts.get("rain3_max") or 0)
fascia = bo_alerts.get("rain_fascia") or f"~{bo_alerts.get('rain_persist_time') or ''}"
lines.append(f"• Pioggia forte {fascia} (max 3h {r3:.1f} mm)")
if not bo_alerts.get("snow_alert") and not bo_alerts.get("rain_alert"):
lines.append("• Nessuna criticità persistente (trigger Bologna assente nel dettaglio punti)")
issues = [x for x in route_alerts if x.get("snow_alert") or x.get("rain_alert")]
lines.append("")
lines.append("Caselli A14 / tratto:")
if not issues:
lines.append("• Nessuna criticità lungo il percorso")
else:
for x in issues:
parts: List[str] = []
if x.get("snow_alert"):
fascia = x.get("snow_fascia") or f"~{x.get('snow_run_time') or ''}"
parts.append(f"neve {fascia} (24h {x.get('snow_24h', 0):.1f} cm)")
if x.get("rain_alert"):
r3 = float(x.get("rain3_max") or 0)
fascia = x.get("rain_fascia") or f"~{x.get('rain_persist_time') or ''}"
parts.append(f"pioggia {fascia} (max 3h {r3:.1f} mm)")
lines.append(f"{x.get('name', '?')}: " + " | ".join(parts))
lines.append("")
lines.append("Fonte: Open-Meteo (AROME / confronto ICON IT)")
return "\n".join(lines)
def first_event_time(bo_alerts: Dict, route_alerts: List[Dict]) -> Optional[str]:
candidates: List[str] = []
for src in [bo_alerts, *route_alerts]:
if src.get("snow_alert") and src.get("snow_run_time"):
candidates.append(str(src["snow_run_time"]))
if src.get("rain_alert") and src.get("rain_persist_time"):
candidates.append(str(src["rain_persist_time"]))
return min(candidates) if candidates else None
def notify_webapp(title: str, body: str, severity: str = "warning") -> None:
try:
sys_path = "/home/daniely/docker/shared"
if sys_path not in __import__("sys").path:
__import__("sys").path.insert(0, sys_path)
from loogle_core.alert_dispatcher import dispatch_alert
dispatch_alert(title, body, category="student", severity=severity)
except Exception as e:
LOGGER.warning("WebApp notify fallita: %s", e)
def get_forecast(session: requests.Session, lat: float, lon: float, model: str) -> Optional[Dict]:
params = {
"latitude": lat,
@@ -357,10 +465,28 @@ def rolling_sum_3h(values: List[float]) -> List[float]:
return out
def first_persistent_run(values: List[float], threshold: float, persist: int) -> Tuple[bool, int, int, float]:
def first_persistent_run(
values: List[float], threshold: float, persist: int
) -> Tuple[bool, int, int, float]:
"""Prima run continua con valori >= soglia e lunghezza >= persist.
Ritorna (ok, start_idx, end_idx inclusivo, run_max). La run viene
estesa fino alla fine (non si ferma al minimo persist).
"""
consec = 0
run_start = -1
run_max = 0.0
found_start = -1
found_end = -1
found_max = 0.0
def _flush() -> None:
nonlocal found_start, found_end, found_max
if consec >= persist and found_start < 0:
found_start = run_start
found_end = run_start + consec - 1
found_max = run_max
for i, v in enumerate(values):
vv = float(v) if v is not None else 0.0
if vv >= threshold:
@@ -370,30 +496,19 @@ def first_persistent_run(values: List[float], threshold: float, persist: int) ->
else:
run_max = max(run_max, vv)
consec += 1
if consec >= persist:
return True, run_start, consec, run_max
else:
_flush()
if found_start >= 0:
break
consec = 0
return False, -1, 0, 0.0
run_start = -1
run_max = 0.0
else:
_flush()
def max_consecutive_gt(values: List[float], eps: float) -> Tuple[int, int]:
best_len = 0
best_start = -1
consec = 0
start = -1
for i, v in enumerate(values):
vv = float(v) if v is not None else 0.0
if vv > eps:
if consec == 0:
start = i
consec += 1
if consec > best_len:
best_len = consec
best_start = start
else:
consec = 0
return best_len, best_start
if found_start >= 0:
return True, found_start, found_end, found_max
return False, -1, -1, 0.0
def compute_stats(data: Dict) -> Optional[Dict]:
@@ -428,120 +543,67 @@ def compute_stats(data: Dict) -> Optional[Dict]:
rain3_max_idx = rain3.index(rain3_max) if rain3 else -1
rain3_max_time = hhmm(dt_w[rain3_max_idx]) if (rain3_max_idx >= 0 and rain3_max_idx < len(dt_w)) else ""
rain_persist_ok, rain_run_start, rain_run_len, rain_run_max = first_persistent_run(
rain_persist_ok, rain3_start, rain3_end, rain_run_max = first_persistent_run(
rain3, SOGLIA_PIOGGIA_3H_MM, PERSIST_HOURS
)
rain_persist_time = hhmm(dt_w[rain_run_start]) if (rain_persist_ok and rain_run_start < len(dt_w)) else ""
rain_run_len = (rain3_end - rain3_start + 1) if rain_persist_ok else 0
rain_persist_dt_start: Optional[datetime.datetime] = None
rain_persist_dt_end: Optional[datetime.datetime] = None
rain_persist_time = ""
rain_persist_end_time = ""
rain_fascia = ""
if rain_persist_ok and 0 <= rain3_start < len(dt_w):
# Fascia = ore di calendario coperte dalle finestre rolling (ultima = end+2)
covered_end_idx = min(rain3_end + 2, len(dt_w) - 1)
rain_persist_dt_start = dt_w[rain3_start]
rain_persist_dt_end = dt_w[covered_end_idx]
rain_persist_time = format_clock(rain_persist_dt_start)
rain_persist_end_time = format_clock(rain_persist_dt_end)
rain_fascia = format_fascia(rain_persist_dt_start, rain_persist_dt_end)
# Analizza evento pioggia completa (48h): rileva inizio e calcola durata e accumulo totale
rain_start_idx = None
rain_end_idx = None
total_rain_accumulation = 0.0
rain_duration_hours = 0.0
max_rain_intensity = 0.0
# Codici meteo che indicano pioggia (WMO)
RAIN_WEATHER_CODES = [61, 63, 65, 66, 67, 80, 81, 82]
# Trova inizio evento pioggia (prima occorrenza con precipitation > 0 OPPURE weathercode pioggia)
# Estendi l'analisi a 48 ore se disponibile
extended_end_idx = min(start_idx + 48, n) # Estendi a 48 ore
precip_extended = precip[start_idx:extended_end_idx]
weathercode_extended = [int(x) if x is not None else None for x in weathercode[start_idx:extended_end_idx]] if len(weathercode) > start_idx else []
for i, (p_val, code) in enumerate(zip(precip_extended, weathercode_extended if len(weathercode_extended) == len(precip_extended) else [None] * len(precip_extended))):
p_val_float = float(p_val) if p_val is not None else 0.0
is_rain = (p_val_float > 0.0) or (code is not None and code in RAIN_WEATHER_CODES)
if is_rain and rain_start_idx is None:
rain_start_idx = i
break
# Se trovato inizio, calcola durata e accumulo totale su 48 ore
if rain_start_idx is not None:
# Trova fine evento pioggia (ultima occorrenza con pioggia)
for i in range(len(precip_extended) - 1, rain_start_idx - 1, -1):
p_val = precip_extended[i] if i < len(precip_extended) else None
code = weathercode_extended[i] if i < len(weathercode_extended) else None
p_val_float = float(p_val) if p_val is not None else 0.0
is_rain = (p_val_float > 0.0) or (code is not None and code in RAIN_WEATHER_CODES)
if is_rain:
rain_end_idx = i
break
if rain_end_idx is not None:
# Calcola durata
times_extended = times[start_idx:extended_end_idx]
dt_extended = [parse_time_to_local(t) for t in times_extended]
if rain_end_idx < len(dt_extended) and rain_start_idx < len(dt_extended):
rain_duration_hours = (dt_extended[rain_end_idx] - dt_extended[rain_start_idx]).total_seconds() / 3600.0
# Calcola accumulo totale (somma di tutti i precipitation > 0)
total_rain_accumulation = sum(float(p) for p in precip_extended[rain_start_idx:rain_end_idx+1] if p is not None and float(p) > 0.0)
# Calcola intensità massima oraria
max_rain_intensity = max((float(p) for p in precip_extended[rain_start_idx:rain_end_idx+1] if p is not None), default=0.0)
# Flag orari neve: accumulo orario sopra eps OPPURE weathercode neve
snow_flags: List[float] = []
for i, s_val in enumerate(snow_w):
code = weathercode_w[i] if i < len(weathercode_w) else None
is_snow = (s_val > SNOW_HOURLY_EPS_CM) or (
code is not None and code in SNOW_WEATHER_CODES
)
snow_flags.append(1.0 if is_snow else 0.0)
# Analizza nevicata completa (48h): rileva inizio usando snowfall > 0 OPPURE weathercode
# Calcola durata e accumulo totale
snow_start_idx = None
snow_end_idx = None
total_snow_accumulation = 0.0
snow_duration_hours = 0.0
# Trova inizio nevicata (prima occorrenza con snowfall > 0 OPPURE weathercode neve)
for i, (s_val, code) in enumerate(zip(snow_w, weathercode_w if len(weathercode_w) == len(snow_w) else [None] * len(snow_w))):
is_snow = (s_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
if is_snow and snow_start_idx is None:
snow_start_idx = i
break
# Se trovato inizio, calcola durata e accumulo totale
if snow_start_idx is not None:
# Trova fine nevicata (ultima occorrenza con neve)
for i in range(len(snow_w) - 1, snow_start_idx - 1, -1):
s_val = snow_w[i]
code = weathercode_w[i] if i < len(weathercode_w) else None
is_snow = (s_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
if is_snow:
snow_end_idx = i
break
if snow_end_idx is not None:
# Calcola durata
snow_duration_hours = (dt_w[snow_end_idx] - dt_w[snow_start_idx]).total_seconds() / 3600.0
# Calcola accumulo totale (somma di tutti i snowfall > 0)
total_snow_accumulation = sum(s for s in snow_w[snow_start_idx:snow_end_idx+1] if s > 0.0)
# Per compatibilità con logica esistente
snow_run_len, snow_run_start = max_consecutive_gt(snow_w, eps=SNOW_HOURLY_EPS_CM)
snow_run_time = hhmm(dt_w[snow_run_start]) if (snow_run_start >= 0 and snow_run_start < len(dt_w)) else ""
snow_ok, snow_run_start, snow_run_end, _ = first_persistent_run(
snow_flags, 1.0, PERSIST_HOURS
)
snow_run_len = (snow_run_end - snow_run_start + 1) if snow_ok else 0
snow_persist_dt_start: Optional[datetime.datetime] = None
snow_persist_dt_end: Optional[datetime.datetime] = None
snow_run_time = ""
snow_run_end_time = ""
snow_fascia = ""
if snow_ok and 0 <= snow_run_start < len(dt_w) and 0 <= snow_run_end < len(dt_w):
snow_persist_dt_start = dt_w[snow_run_start]
snow_persist_dt_end = dt_w[snow_run_end]
snow_run_time = format_clock(snow_persist_dt_start)
snow_run_end_time = format_clock(snow_persist_dt_end)
snow_fascia = format_fascia(snow_persist_dt_start, snow_persist_dt_end)
# Se trovato inizio nevicata, usa quello invece del run
if snow_start_idx is not None:
snow_run_time = hhmm(dt_w[snow_start_idx])
# Durata minima per alert: almeno 2 ore
if snow_duration_hours >= PERSIST_HOURS:
snow_run_len = int(snow_duration_hours)
else:
snow_run_len = 0 # Durata troppo breve
snow_12h = sum(s for s in snow_w[:min(12, len(snow_w))] if s > 0.0)
snow_24h = sum(s for s in snow_w[:min(24, len(snow_w))] if s > 0.0)
snow_12h = sum(s for s in snow_w[: min(12, len(snow_w))] if s > 0.0)
snow_24h = sum(s for s in snow_w[: min(24, len(snow_w))] if s > 0.0)
return {
"rain3_max": float(rain3_max),
"rain3_max_time": rain3_max_time,
"rain_persist_ok": bool(rain_persist_ok),
"rain_persist_time": rain_persist_time,
"rain_persist_end_time": rain_persist_end_time,
"rain_fascia": rain_fascia,
"rain_persist_run_max": float(rain_run_max),
"rain_persist_run_len": int(rain_run_len),
"rain_duration_hours": float(rain_duration_hours),
"total_rain_accumulation_mm": float(total_rain_accumulation),
"max_rain_intensity_mm_h": float(max_rain_intensity),
"snow_run_len": int(snow_run_len),
"snow_run_time": snow_run_time,
"snow_run_end_time": snow_run_end_time,
"snow_fascia": snow_fascia,
"snow_12h": float(snow_12h),
"snow_24h": float(snow_24h),
"snow_duration_hours": float(snow_duration_hours),
"total_snow_accumulation_cm": float(total_snow_accumulation),
}
@@ -556,14 +618,15 @@ def point_alerts(point_name: str, stats: Dict) -> Dict:
"snow_24h": stats["snow_24h"],
"snow_run_len": stats["snow_run_len"],
"snow_run_time": stats["snow_run_time"],
"snow_run_end_time": stats.get("snow_run_end_time", ""),
"snow_fascia": stats.get("snow_fascia", ""),
"rain3_max": stats["rain3_max"],
"rain3_max_time": stats["rain3_max_time"],
"rain_persist_time": stats["rain_persist_time"],
"rain_persist_end_time": stats.get("rain_persist_end_time", ""),
"rain_fascia": stats.get("rain_fascia", ""),
"rain_persist_run_max": stats["rain_persist_run_max"],
"rain_persist_run_len": stats["rain_persist_run_len"],
"rain_duration_hours": stats.get("rain_duration_hours", 0.0),
"total_rain_accumulation_mm": stats.get("total_rain_accumulation_mm", 0.0),
"max_rain_intensity_mm_h": stats.get("max_rain_intensity_mm_h", 0.0),
}
@@ -660,7 +723,10 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
msg.append("🎓 <b>A BOLOGNA</b>")
bo_comp = comparisons.get(bo["name"])
if bo_alerts["snow_alert"]:
msg.append(f"❄️ Neve (≥{PERSIST_HOURS}h) da ~<b>{html.escape(bo_alerts['snow_run_time'] or '')}</b> (run ~{bo_alerts['snow_run_len']}h).")
fascia = bo_alerts.get("snow_fascia") or f"~{bo_alerts.get('snow_run_time') or ''}"
msg.append(
f"❄️ Neve (≥{PERSIST_HOURS}h) <b>{html.escape(fascia)}</b>."
)
msg.append(f"• Accumulo: 12h <b>{bo_alerts['snow_12h']:.1f} cm</b> | 24h <b>{bo_alerts['snow_24h']:.1f} cm</b>")
if bo_comp and bo_comp.get("snow"):
comp = bo_comp["snow"]
@@ -670,13 +736,12 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
msg.append(f"❄️ Neve: nessuna persistenza ≥ {PERSIST_HOURS}h (24h {bo_alerts['snow_24h']:.1f} cm).")
if bo_alerts["rain_alert"]:
rain_duration = bo_alerts.get("rain_duration_hours", 0.0)
total_rain = bo_alerts.get("total_rain_accumulation_mm", 0.0)
max_intensity = bo_alerts.get("max_rain_intensity_mm_h", 0.0)
msg.append(f"🌧️ Pioggia molto forte (3h ≥ {SOGLIA_PIOGGIA_3H_MM:.0f} mm, ≥{PERSIST_HOURS}h) da ~<b>{html.escape(bo_alerts['rain_persist_time'] or '')}</b>.")
if rain_duration > 0:
msg.append(f"⏱️ <b>Durata totale evento (48h):</b> ~{rain_duration:.0f} ore | <b>Accumulo totale:</b> ~{total_rain:.1f} mm | <b>Intensità max:</b> {max_intensity:.1f} mm/h")
fascia = bo_alerts.get("rain_fascia") or f"~{bo_alerts.get('rain_persist_time') or ''}"
msg.append(
f"🌧️ Pioggia molto forte (3h ≥ {SOGLIA_PIOGGIA_3H_MM:.0f} mm, ≥{PERSIST_HOURS}h) "
f"<b>{html.escape(fascia)}</b> "
f"(max 3h <b>{bo_alerts['rain3_max']:.1f} mm</b>)."
)
if bo_comp and bo_comp.get("rain"):
comp = bo_comp["rain"]
icon_r3 = bo_comp["icon_stats"]["rain3_max"]
@@ -695,14 +760,17 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
line = f"• <b>{html.escape(x['name'])}</b>: "
parts: List[str] = []
if x["snow_alert"]:
parts.append(f"❄️ neve (≥{PERSIST_HOURS}h) da ~{html.escape(x['snow_run_time'] or '')} (24h {x['snow_24h']:.1f} cm)")
fascia = x.get("snow_fascia") or f"~{x.get('snow_run_time') or ''}"
parts.append(
f"❄️ neve (≥{PERSIST_HOURS}h) {html.escape(fascia)} "
f"(24h {x['snow_24h']:.1f} cm)"
)
if x["rain_alert"]:
rain_dur = x.get("rain_duration_hours", 0.0)
rain_tot = x.get("total_rain_accumulation_mm", 0.0)
if rain_dur > 0:
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '')} (durata ~{rain_dur:.0f}h, totale ~{rain_tot:.1f}mm)")
else:
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '')}")
fascia = x.get("rain_fascia") or f"~{x.get('rain_persist_time') or ''}"
parts.append(
f"🌧️ pioggia forte {html.escape(fascia)} "
f"(max 3h {x['rain3_max']:.1f} mm)"
)
line += " | ".join(parts)
msg.append(line)
@@ -725,15 +793,65 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
msg.append("<i>Fonte dati: Open-Meteo</i>")
# FIX: usare \n invece di <br/>
ok = telegram_send_html("\n".join(msg), chat_ids=chat_ids)
html_msg = "\n".join(msg)
plain = build_plain_summary(bo_alerts, route_alerts)
ok = telegram_send_html(html_msg, chat_ids=chat_ids)
if ok:
LOGGER.info("Notifica inviata.")
LOGGER.info("Notifica inviata su Telegram.")
else:
LOGGER.warning("Notifica NON inviata.")
LOGGER.info("Telegram saltato/sospeso; consegna via WebApp.")
save_state(True, sig)
detail = {
"summary": plain,
"window_hours": HOURS_AHEAD,
"persist_hours": PERSIST_HOURS,
"rain_threshold_mm_3h": SOGLIA_PIOGGIA_3H_MM,
"first_event_time": first_event_time(bo_alerts, route_alerts),
"bologna": {
"snow_alert": bool(bo_alerts.get("snow_alert")),
"rain_alert": bool(bo_alerts.get("rain_alert")),
"snow_run_time": bo_alerts.get("snow_run_time"),
"snow_run_end_time": bo_alerts.get("snow_run_end_time"),
"snow_fascia": bo_alerts.get("snow_fascia"),
"rain_persist_time": bo_alerts.get("rain_persist_time"),
"rain_persist_end_time": bo_alerts.get("rain_persist_end_time"),
"rain_fascia": bo_alerts.get("rain_fascia"),
"snow_24h": bo_alerts.get("snow_24h"),
"rain3_max": bo_alerts.get("rain3_max"),
},
"route_issues": [
{
"name": x.get("name"),
"snow_alert": bool(x.get("snow_alert")),
"rain_alert": bool(x.get("rain_alert")),
"snow_run_time": x.get("snow_run_time"),
"snow_run_end_time": x.get("snow_run_end_time"),
"snow_fascia": x.get("snow_fascia"),
"rain_persist_time": x.get("rain_persist_time"),
"rain_persist_end_time": x.get("rain_persist_end_time"),
"rain_fascia": x.get("rain_fascia"),
"snow_24h": x.get("snow_24h"),
"rain3_max": x.get("rain3_max"),
"rain_persist_run_len": x.get("rain_persist_run_len"),
}
for x in route_alerts
if x.get("snow_alert") or x.get("rain_alert")
],
}
save_state(True, sig, detail)
notify_webapp("Allerta percorso scuola", plain, severity="warning")
else:
LOGGER.info("Allerta già notificata e invariata.")
# Aggiorna comunque summary nello state se manca (card WebApp)
if not state.get("summary"):
plain = build_plain_summary(bo_alerts, route_alerts)
save_state(True, sig, {
"summary": plain,
"window_hours": HOURS_AHEAD,
"persist_hours": PERSIST_HOURS,
"rain_threshold_mm_3h": SOGLIA_PIOGGIA_3H_MM,
"first_event_time": first_event_time(bo_alerts, route_alerts),
})
return
# --- Scenario B: Rientro ---
@@ -747,12 +865,18 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
f"di neve (≥{PERSIST_HOURS}h) o pioggia 3h sopra soglia (≥{PERSIST_HOURS}h).\n"
"<i>Fonte dati: Open-Meteo</i>"
)
plain = (
f"Allerta rientrata (Bologna / percorso scuola).\n"
f"Nelle prossime {HOURS_AHEAD} ore non risultano più neve persistente "
f"(≥{PERSIST_HOURS}h) né pioggia 3h sopra soglia (≥{PERSIST_HOURS}h)."
)
ok = telegram_send_html(msg, chat_ids=chat_ids)
if ok:
LOGGER.info("Rientro notificato.")
LOGGER.info("Rientro notificato su Telegram.")
else:
LOGGER.warning("Rientro NON inviato.")
LOGGER.info("Rientro Telegram saltato/sospeso; consegna via WebApp.")
save_state(False, "")
notify_webapp("Allerta percorso scuola rientrata", plain, severity="info")
return
# --- Scenario C: Tranquillo ---
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Regression checks for Meteo7 interpretation (weathercode mode, ice gates, hours, trend max/min)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from previsione7 import (
_merge_weathercode,
_format_event_hours,
analyze_daily_events,
analyze_temperature_trend,
format_detailed_trend_explanation,
get_precip_type,
)
def test_weathercode_mode_not_median():
# 61 (rain) + 71 (snow) → mediana numerica sarebbe 66 (FZRA); moda deve evitare 66
assert _merge_weathercode([61, 71], preferred=61) == 61
assert _merge_weathercode([61, 71], preferred=71) == 71
assert _merge_weathercode([61, 61, 71]) == 61
assert _merge_weathercode([66, 66, 61]) == 66
print("OK weathercode mode")
def test_get_precip_type_temp_gate():
assert "Congelantesi" not in get_precip_type(67, temp=28.0)
assert "Pioggia" in get_precip_type(67, temp=28.0)
assert "Congelantesi" in get_precip_type(67, temp=0.5)
assert "Neve" not in get_precip_type(71, temp=20.0)
assert "Neve" in get_precip_type(71, temp=-1.0)
print("OK precip type temp gate")
def test_gelicidio_hot_air_no_event():
times = [f"2026-07-21T{h:02d}:00" for h in range(24)]
codes = [0] * 24
codes[20] = 67 # FZRA spurio
precip = [0.0] * 24
precip[20] = 1.2
temps = [28.0] * 24
dewpoints = [18.0] * 24
winds = [5.0] * 24
events = analyze_daily_events(
times, codes, None, precip, winds, temps, dewpoints,
snowfalls=[0.0] * 24, rains=precip[:], soil_temps=[25.0] * 24,
cloud_covers=[50.0] * 24, wind_speeds=winds,
)
ice_like = [e for e in events if "GELICIDIO" in e or "BRINA" in e or "Gelata" in e or "Black Ice" in e]
assert not ice_like, f"unexpected ice events in heat: {ice_like}"
rain_like = [e for e in events if "Pioggia" in e or "Congelantesi" in e]
assert rain_like, "expected rain event"
assert all("Congelantesi" not in e for e in rain_like)
print("OK no gelicidio at 28°C")
def test_single_hour_event_range():
times = [f"2026-07-21T{h:02d}:00" for h in range(24)]
assert _format_event_hours(times, 20, 20) == "20:00-21:00"
assert _format_event_hours(times, 20, 21) == "20:00-22:00"
# last hour of day
assert _format_event_hours(times, 23, 23) == "23:00-00:00"
codes = [0] * 24
precip = [0.0] * 24
precip[20] = 2.0
temps = [22.0] * 24
events = analyze_daily_events(
times, codes, None, precip, [5.0] * 24, temps, [12.0] * 24,
snowfalls=[0.0] * 24, rains=precip[:],
)
rain_ev = [e for e in events if "🕒" in e]
assert rain_ev, events
assert "20:00-21:00" in rain_ev[0]
assert "20:00-20:00" not in rain_ev[0]
print("OK single-hour 20:00-21:00")
def test_trend_max_min_not_media():
# Heatwave: max stay high, slight drop → calo_termico not fronte_freddo
tmax = [34, 35, 36, 33, 30, 29, 28, 27, 26, 25]
tmin = [22, 23, 24, 21, 20, 19, 18, 17, 16, 15]
trend = analyze_temperature_trend(tmax, tmin, days=10)
assert trend is not None
assert "first_max" in trend and "first_min" in trend
assert trend["type"] == "calo_termico"
text = format_detailed_trend_explanation(trend, daily_time_list=[f"2026-07-{21+i:02d}" for i in range(10)])
assert "media" not in text.lower()
assert "Massime:" in text and "Minime:" in text
assert "Fronte Freddo" not in text
print("OK trend max/min calo_termico")
def test_real_gelicidio_cold():
times = [f"2026-01-10T{h:02d}:00" for h in range(24)]
codes = [0] * 24
codes[8] = 67
precip = [0.0] * 24
precip[8] = 0.5
temps = [5.0] * 24
temps[8] = -0.5
events = analyze_daily_events(
times, codes, None, precip, [3.0] * 24, temps, [-1.0] * 24,
snowfalls=[0.0] * 24, rains=precip[:], soil_temps=[-1.0] * 24,
cloud_covers=[80.0] * 24, wind_speeds=[3.0] * 24,
)
assert any("GELICIDIO" in e for e in events), events
print("OK real gelicidio at subzero")
if __name__ == "__main__":
test_weathercode_mode_not_median()
test_get_precip_type_temp_gate()
test_gelicidio_hot_air_no_event()
test_single_hour_event_range()
test_trend_max_min_not_media()
test_real_gelicidio_cold()
print("\nAll interpretation regression checks passed.")
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Helper: testo plain + summary state + mirror WebApp per allerte meteo."""
from __future__ import annotations
import logging
import re
import sys
from typing import Any, Dict, Optional
LOGGER = logging.getLogger("webapp_alert")
def message_to_plain(message: str, is_html: bool = False) -> str:
text = message or ""
if is_html:
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"</p\s*>", "\n", text, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
text = (
text.replace("*", "")
.replace("_", "")
.replace("`", "")
.replace("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
)
lines = [ln.rstrip() for ln in text.splitlines()]
# collassa righe vuote multiple
out: list[str] = []
blank = False
for ln in lines:
if not ln.strip():
if not blank:
out.append("")
blank = True
else:
out.append(ln.strip())
blank = False
return "\n".join(out).strip()
def remember_summary(state: Optional[Dict[str, Any]], plain: str, limit: int = 3000) -> str:
summary = (plain or "").strip()[:limit]
if state is not None and summary:
state["summary"] = summary
return summary
def publish_web_alert(
message: str,
category: str,
severity: str = "warning",
*,
is_html: bool = False,
title: Optional[str] = None,
state: Optional[Dict[str, Any]] = None,
) -> bool:
"""Salva summary nello state (se passato) e notifica la WebApp (solo canale web)."""
plain = message_to_plain(message, is_html=is_html)
if not plain:
return False
remember_summary(state, plain)
head = (title or plain.split("\n", 1)[0]).strip()[:80] or category
body = plain[:2500]
try:
shared = "/home/daniely/docker/shared"
if shared not in sys.path:
sys.path.insert(0, shared)
from loogle_core.alert_dispatcher import send_web
ok = send_web(head, body, category=category, severity=severity)
if not ok:
LOGGER.debug("send_web returned False for %s", category)
return bool(ok)
except Exception as exc:
LOGGER.warning("publish_web_alert failed (%s): %s", category, exc)
return False