From a9bbb92090dd174b53a5b8fb2db837278f7339e8 Mon Sep 17 00:00:00 2001 From: daniele Date: Sun, 26 Jul 2026 07:00:01 +0200 Subject: [PATCH] Backup automatico script del 2026-07-26 07:00 --- configs/weekly-maintenance.pi1.conf | 2 + configs/weekly-maintenance.pi2.conf | 2 + scripts/pi1-master/weekly-maintenance.sh | 51 ++- scripts/pi2-backup/super_watchdog.sh | 144 +----- scripts/pi2-backup/weekly-maintenance.sh | 51 ++- services/telegram-bot/arome_snow_alert.py | 18 +- services/telegram-bot/bot.py | 24 +- services/telegram-bot/check_ghiaccio.py | 51 ++- services/telegram-bot/civil_protection.py | 77 +++- services/telegram-bot/cron-alerts.env | 4 +- services/telegram-bot/freeze_alert.py | 22 +- services/telegram-bot/log_monitor.py | 42 +- services/telegram-bot/nowcast_120m_alert.py | 55 ++- services/telegram-bot/previsione7.py | 408 +++++++++++------ services/telegram-bot/severe_weather.py | 36 +- .../severe_weather_circondario.py | 26 +- services/telegram-bot/student_alert.py | 424 +++++++++++------- .../test_previsione7_interpretation.py | 119 +++++ services/telegram-bot/webapp_alert.py | 80 ++++ 19 files changed, 1086 insertions(+), 550 deletions(-) create mode 100644 services/telegram-bot/test_previsione7_interpretation.py create mode 100644 services/telegram-bot/webapp_alert.py diff --git a/configs/weekly-maintenance.pi1.conf b/configs/weekly-maintenance.pi1.conf index 13cb9a4..8174ac0 100644 --- a/configs/weekly-maintenance.pi1.conf +++ b/configs/weekly-maintenance.pi1.conf @@ -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 diff --git a/configs/weekly-maintenance.pi2.conf b/configs/weekly-maintenance.pi2.conf index cb8fb9f..8406446 100644 --- a/configs/weekly-maintenance.pi2.conf +++ b/configs/weekly-maintenance.pi2.conf @@ -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 diff --git a/scripts/pi1-master/weekly-maintenance.sh b/scripts/pi1-master/weekly-maintenance.sh index 513ead1..1acfddd 100755 --- a/scripts/pi1-master/weekly-maintenance.sh +++ b/scripts/pi1-master/weekly-maintenance.sh @@ -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=$? diff --git a/scripts/pi2-backup/super_watchdog.sh b/scripts/pi2-backup/super_watchdog.sh index 2dcb13c..2d1c382 100755 --- a/scripts/pi2-backup/super_watchdog.sh +++ b/scripts/pi2-backup/super_watchdog.sh @@ -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 "$@" diff --git a/scripts/pi2-backup/weekly-maintenance.sh b/scripts/pi2-backup/weekly-maintenance.sh index 513ead1..1acfddd 100755 --- a/scripts/pi2-backup/weekly-maintenance.sh +++ b/scripts/pi2-backup/weekly-maintenance.sh @@ -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=$? diff --git a/services/telegram-bot/arome_snow_alert.py b/services/telegram-bot/arome_snow_alert.py index cb714ff..18dc01f 100644 --- a/services/telegram-bot/arome_snow_alert.py +++ b/services/telegram-bot/arome_snow_alert.py @@ -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("Fonte dati: Open-Meteo") # Unisci con
(sarà convertito in \n in telegram_send_html) - ok = telegram_send_html("
".join(msg), chat_ids=chat_ids) + html_msg = "
".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) diff --git a/services/telegram-bot/bot.py b/services/telegram-bot/bot.py index 7b4ec77..e25bc78 100644 --- a/services/telegram-bot/bot.py +++ b/services/telegram-bot/bot.py @@ -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() diff --git a/services/telegram-bot/check_ghiaccio.py b/services/telegram-bot/check_ghiaccio.py index 925b4a8..8c98c1f 100644 --- a/services/telegram-bot/check_ghiaccio.py +++ b/services/telegram-bot/check_ghiaccio.py @@ -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() diff --git a/services/telegram-bot/civil_protection.py b/services/telegram-bot/civil_protection.py index d80a109..9c81c3e 100644 --- a/services/telegram-bot/civil_protection.py +++ b/services/telegram-bot/civil_protection.py @@ -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"📅 {html_lib.escape(day.get('date_label',''))}") for zone in sorted(alerts.keys()): - lines.append(f"📍 {html_lib.escape(zone)}") + note = ZONE_NOTES.get(zone) + zlabel = f"{zone} · {note}" if note else zone + lines.append(f"📍 {html_lib.escape(zlabel)}") 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"🗺️ {html_lib.escape(titolo)}") alerts = doc.get("alerts", {}) for zone in sorted(alerts.keys()): - lines.append(f"📍 {html_lib.escape(zone)}") + note = ZONE_NOTES.get(zone) + zlabel = f"{zone} · {note}" if note else zone + lines.append(f"📍 {html_lib.escape(zlabel)}") 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("Fonte: mappe.protezionecivile.gov.it") 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") diff --git a/services/telegram-bot/cron-alerts.env b/services/telegram-bot/cron-alerts.env index edb7336..72ff25a 100644 --- a/services/telegram-bot/cron-alerts.env +++ b/services/telegram-bot/cron-alerts.env @@ -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 diff --git a/services/telegram-bot/freeze_alert.py b/services/telegram-bot/freeze_alert.py index 6df2adf..3d2ab94 100644 --- a/services/telegram-bot/freeze_alert.py +++ b/services/telegram-bot/freeze_alert.py @@ -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 diff --git a/services/telegram-bot/log_monitor.py b/services/telegram-bot/log_monitor.py index 04049a7..c7a7410 100644 --- a/services/telegram-bot/log_monitor.py +++ b/services/telegram-bot/log_monitor.py @@ -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 diff --git a/services/telegram-bot/nowcast_120m_alert.py b/services/telegram-bot/nowcast_120m_alert.py index 305643c..1c5941c 100644 --- a/services/telegram-bot/nowcast_120m_alert.py +++ b/services/telegram-bot/nowcast_120m_alert.py @@ -1,11 +1,18 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +"""DEPRECATED 2026-07-25 (ADR-019). + +Sostituito da ``meteo-alert imminent`` (layer NWP 0–120′ 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__": diff --git a/services/telegram-bot/previsione7.py b/services/telegram-bot/previsione7.py index c1b00c1..bbd1c77 100755 --- a/services/telegram-bot/previsione7.py +++ b/services/telegram-bot/previsione7.py @@ -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 0–2d: 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("❄️ Fronte Freddo in Arrivo: Preparati a temperature in calo significativo. Controlla riscaldamento, proteggi piante sensibili.") + elif trend["type"] == "calo_termico" and trend["intensity"] == "forte": + advice.append("📉 Calo termico: 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("🔥 Ondata di Calore: 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"📊 EVOLUZIONE TEMPERATURE ({display_days} GIORNI)\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 = "🔥 Fronte Caldo in Arrivo" - 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 = "❄️ Fronte Freddo in Arrivo" - 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 = "📉 Calo Termico" elif trend_type == "riscaldamento": trend_desc = "📈 Riscaldamento Progressivo" - 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 = "📉 Raffreddamento Progressivo" - 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 = "➡️ Temperature Stabili" - desc_text = f"Temperature medie sostanzialmente stabili: da {first_avg:.1f}°C a {last_avg:.1f}°C (variazione {delta:+.1f}°C)." else: trend_desc = "🌡️ Variazione Termica" - 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: diff --git a/services/telegram-bot/severe_weather.py b/services/telegram-bot/severe_weather.py index c68042a..4c1003c 100644 --- a/services/telegram-bot/severe_weather.py +++ b/services/telegram-bot/severe_weather.py @@ -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, diff --git a/services/telegram-bot/severe_weather_circondario.py b/services/telegram-bot/severe_weather_circondario.py index c686366..dc5721a 100755 --- a/services/telegram-bot/severe_weather_circondario.py +++ b/services/telegram-bot/severe_weather_circondario.py @@ -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 diff --git a/services/telegram-bot/student_alert.py b/services/telegram-bot/student_alert.py index 458bb32..b21803c 100644 --- a/services/telegram-bot/student_alert.py +++ b/services/telegram-bot/student_alert.py @@ -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("🎓 A BOLOGNA") bo_comp = comparisons.get(bo["name"]) if bo_alerts["snow_alert"]: - msg.append(f"❄️ Neve (≥{PERSIST_HOURS}h) da ~{html.escape(bo_alerts['snow_run_time'] or '—')} (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) {html.escape(fascia)}." + ) msg.append(f"• Accumulo: 12h {bo_alerts['snow_12h']:.1f} cm | 24h {bo_alerts['snow_24h']:.1f} cm") 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 ~{html.escape(bo_alerts['rain_persist_time'] or '—')}.") - if rain_duration > 0: - msg.append(f"⏱️ Durata totale evento (48h): ~{rain_duration:.0f} ore | Accumulo totale: ~{total_rain:.1f} mm | Intensità max: {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"{html.escape(fascia)} " + f"(max 3h {bo_alerts['rain3_max']:.1f} mm)." + ) 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"• {html.escape(x['name'])}: " 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("Fonte dati: Open-Meteo") # FIX: usare \n invece di
- 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" "Fonte dati: Open-Meteo" ) + 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 --- diff --git a/services/telegram-bot/test_previsione7_interpretation.py b/services/telegram-bot/test_previsione7_interpretation.py new file mode 100644 index 0000000..ec03308 --- /dev/null +++ b/services/telegram-bot/test_previsione7_interpretation.py @@ -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.") diff --git a/services/telegram-bot/webapp_alert.py b/services/telegram-bot/webapp_alert.py new file mode 100644 index 0000000..acb145a --- /dev/null +++ b/services/telegram-bot/webapp_alert.py @@ -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"", "\n", text, flags=re.I) + text = re.sub(r"", "\n", text, flags=re.I) + text = re.sub(r"<[^>]+>", "", text) + text = ( + text.replace("*", "") + .replace("_", "") + .replace("`", "") + .replace(" ", " ") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + ) + 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