From 4555d6615eca1bd921105b41ff18598a568ed19c Mon Sep 17 00:00:00 2001 From: daniele Date: Sun, 11 Jan 2026 07:00:03 +0100 Subject: [PATCH] Backup automatico script del 2026-01-11 07:00 --- services/telegram-bot/._bot.py | Bin 4096 -> 4096 bytes services/telegram-bot/._meteo.py | Bin 4096 -> 4096 bytes services/telegram-bot/arome_snow_alert.py | 1449 ++++++++++- services/telegram-bot/bot.py | 776 ++++-- services/telegram-bot/check_ghiaccio.py | 2154 ++++++++++++++++- services/telegram-bot/civil_protection.py | 45 +- services/telegram-bot/daily_report.py | 26 +- services/telegram-bot/freeze_alert.py | 304 ++- services/telegram-bot/meteo.py | 603 +++-- services/telegram-bot/net_quality.py | 34 +- services/telegram-bot/nowcast_120m_alert.py | 759 +++++- services/telegram-bot/previsione7.py | 1658 +++++++++++-- services/telegram-bot/road_weather.py | 1821 ++++++++++++++ services/telegram-bot/scheduler_viaggi.py | 120 + services/telegram-bot/severe_weather.py | 1096 ++++++++- .../severe_weather_circondario.py | 635 +++++ .../telegram-bot/smart_irrigation_advisor.py | 1525 ++++++++++++ services/telegram-bot/snow_radar.py | 747 ++++++ services/telegram-bot/student_alert.py | 344 ++- services/telegram-bot/test_snow_chart_show.py | 164 ++ 20 files changed, 13373 insertions(+), 887 deletions(-) mode change 100755 => 100644 services/telegram-bot/bot.py create mode 100644 services/telegram-bot/road_weather.py create mode 100644 services/telegram-bot/scheduler_viaggi.py create mode 100755 services/telegram-bot/severe_weather_circondario.py create mode 100755 services/telegram-bot/smart_irrigation_advisor.py create mode 100755 services/telegram-bot/snow_radar.py create mode 100644 services/telegram-bot/test_snow_chart_show.py diff --git a/services/telegram-bot/._bot.py b/services/telegram-bot/._bot.py index 4b0e3e2b7b1adb61fd2c29f6545aa1503812bddf..2e655ad7b8ebdfc218abd9b501c3b0b4140b28b2 100755 GIT binary patch delta 71 zcmZorXi%7tY%+s^fq@%{kpUx+0~Hrd&d=3LEGWoH)hj5Px6OG=AVQ&JL3Qk4UO hp&BkstdZeQz8aPZ;)kC2!3(4|P8a3d{De=25dh{e7XSbN diff --git a/services/telegram-bot/._meteo.py b/services/telegram-bot/._meteo.py index 753c063950e2cb70bc9b0dda8040e6da5ce0dd5f..2e655ad7b8ebdfc218abd9b501c3b0b4140b28b2 100755 GIT binary patch delta 59 zcmZorXi%7tEIEULfq@%{kpUx+Gh CoDLEI delta 108 zcmZorXi%7tEO~%|fk7IGkpUBsb6{ePj6IM(4a5Qr3{uJYxq68O1v#mDIf=z3rNyZ! sDTyVi$^pS3jf_kTFHZz!?vD!$^)ijj1PS#C)~RmX$ji6+37-rj0H_ujumAu6 diff --git a/services/telegram-bot/arome_snow_alert.py b/services/telegram-bot/arome_snow_alert.py index 5a104d8..6a0ff21 100644 --- a/services/telegram-bot/arome_snow_alert.py +++ b/services/telegram-bot/arome_snow_alert.py @@ -1,14 +1,16 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import argparse import datetime import html import json import logging import os +import tempfile import time from logging.handlers import RotatingFileHandler -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple from zoneinfo import ZoneInfo import requests @@ -18,13 +20,14 @@ from dateutil import parser # arome_snow_alert.py # # Scopo: -# Monitorare neve prevista nelle prossime 24 ore su più punti (Casa/Titano/Dogana/Carpegna) +# Monitorare neve prevista nelle prossime 48 ore su più punti (Casa/Titano/Dogana/Carpegna) # e notificare su Telegram se: -# - esiste almeno 1 ora nelle prossime 24h con snowfall > 0.2 cm +# - esiste almeno 1 ora nelle prossime 48h con snowfall > 0.2 cm # (nessuna persistenza richiesta) # # Modello meteo: -# SOLO AROME HD 1.5 km (Meteo-France): meteofrance_arome_france_hd +# meteofrance_seamless (fornisce snowfall, rain, weathercode) +# Comparazione con italia_meteo_arpae_icon_2i quando scostamento >30% # # Token Telegram: # Nessun token in chiaro. Lettura in ordine: @@ -55,13 +58,23 @@ POINTS = [ ] # ----------------- LOGICA ALLERTA ----------------- -TZ = "Europe/Rome" +TZ = "Europe/Berlin" TZINFO = ZoneInfo(TZ) -HOURS_AHEAD = 24 +HOURS_AHEAD = 48 # Soglia precipitazione neve oraria (cm/h): NOTIFICA se qualsiasi ora > soglia SNOW_HOURLY_THRESHOLD_CM = 0.2 +# Codici meteo che indicano neve (WMO) +SNOW_WEATHER_CODES = [71, 73, 75, 77, 85, 86] # Neve leggera, moderata, forte, granelli, rovesci + +# Soglie per notifiche incrementali (cambiamenti significativi) +SNOW_QUANTITY_CHANGE_THRESHOLD_PCT = 20.0 # 20% variazione +SNOW_QUANTITY_CHANGE_THRESHOLD_CM = 1.0 # O almeno 1 cm +START_TIME_CHANGE_THRESHOLD_HOURS = 2.0 # ±2 ore + +# Aggiornamenti periodici per eventi prolungati +PERIODIC_UPDATE_INTERVAL_HOURS = 6.0 # Invia aggiornamento ogni 6 ore anche senza cambiamenti significativi # Stagione invernale: 1 Nov -> 15 Apr WINTER_START_MONTH = 11 @@ -73,8 +86,10 @@ STATE_FILE = "/home/daniely/docker/telegram-bot/snow_state.json" # ----------------- OPEN-METEO ----------------- OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" -HTTP_HEADERS = {"User-Agent": "rpi-arome-snow-alert/2.1"} -MODEL = "meteofrance_arome_france_hd" +HTTP_HEADERS = {"User-Agent": "rpi-arome-snow-alert/3.0"} +MODEL_AROME = "meteofrance_seamless" +MODEL_ICON_IT = "italia_meteo_arpae_icon_2i" +COMPARISON_THRESHOLD = 0.30 # 30% scostamento per comparazione # ----------------- LOG FILE ----------------- BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -161,27 +176,53 @@ def parse_time_to_local(t: str) -> datetime.datetime: def hhmm(dt: datetime.datetime) -> str: return dt.strftime("%H:%M") +def ddmmyy_hhmm(dt: datetime.datetime) -> str: + """Formato: DD/MM HH:MM""" + return dt.strftime("%d/%m %H:%M") + # ============================================================================= # Telegram # ============================================================================= -def telegram_send_html(message_html: str) -> bool: - """Invia solo in caso di allerta (mai per errori).""" +def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) -> bool: + """Invia solo in caso di allerta (mai per errori). + + Args: + message_html: Messaggio HTML da inviare + chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) + """ token = load_bot_token() if not token: LOGGER.warning("Telegram token missing: message not sent.") return False + # Telegram HTML non supporta
come tag self-closing + # Sostituiamo
con \n e usiamo Markdown + message_md = message_html.replace("
", "\n").replace("
", "\n") + # Manteniamo i tag HTML base (b, i, code, pre) convertendoli in Markdown + message_md = message_md.replace("", "*").replace("", "*") + message_md = message_md.replace("", "_").replace("", "_") + message_md = message_md.replace("", "`").replace("", "`") + message_md = message_md.replace(">", ">").replace("<", "<") + message_md = message_md.replace("&", "&") + #
 rimane come codice monospazio in Markdown
+    message_md = message_md.replace("
", "```\n").replace("
", "\n```") + # Correggi doppio %% (HTML entity) -> % (Markdown non richiede escape) + message_md = message_md.replace("%%", "%") + + if chat_ids is None: + chat_ids = TELEGRAM_CHAT_IDS + url = f"https://api.telegram.org/bot{token}/sendMessage" base_payload = { - "text": message_html, - "parse_mode": "HTML", + "text": message_md, + "parse_mode": "Markdown", "disable_web_page_preview": True, } sent_ok = False with requests.Session() as s: - for chat_id in TELEGRAM_CHAT_IDS: + for chat_id in chat_ids: payload = dict(base_payload) payload["chat_id"] = chat_id try: @@ -198,11 +239,75 @@ def telegram_send_html(message_html: str) -> bool: return sent_ok +def telegram_send_photo(photo_path: str, caption: str, chat_ids: Optional[List[str]] = None) -> bool: + """ + Invia foto via Telegram API. + + Args: + photo_path: Percorso file immagine + caption: Didascalia foto (max 1024 caratteri) + chat_ids: Lista chat IDs (default: TELEGRAM_CHAT_IDS) + + Returns: + True se inviata con successo, False altrimenti + """ + token = load_bot_token() + if not token: + LOGGER.warning("Telegram token missing: photo not sent.") + return False + + if not os.path.exists(photo_path): + LOGGER.error("File foto non trovato: %s", photo_path) + return False + + if chat_ids is None: + chat_ids = TELEGRAM_CHAT_IDS + + url = f"https://api.telegram.org/bot{token}/sendPhoto" + + # Limite Telegram per caption: 1024 caratteri + if len(caption) > 1024: + caption = caption[:1021] + "..." + + sent_ok = False + with requests.Session() as s: + for chat_id in chat_ids: + try: + with open(photo_path, 'rb') as photo_file: + files = {'photo': photo_file} + data = { + 'chat_id': chat_id, + 'caption': caption, + 'parse_mode': 'HTML' + } + resp = s.post(url, files=files, data=data, timeout=30) + if resp.status_code == 200: + sent_ok = True + LOGGER.info("Foto inviata a chat_id=%s", chat_id) + else: + LOGGER.error("Telegram error chat_id=%s status=%s body=%s", + chat_id, resp.status_code, resp.text[:500]) + time.sleep(0.5) + except Exception as e: + LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e) + + return sent_ok + + # ============================================================================= # State # ============================================================================= def load_state() -> Dict: - default = {"alert_active": False, "signature": "", "updated": ""} + default = { + "alert_active": False, + "signature": "", + "updated": "", + "last_notification_utc": "", # Timestamp UTC dell'ultima notifica inviata + "casa_snow_24h": 0.0, + "casa_peak_hourly": 0.0, + "casa_first_thr_time": "", + "casa_duration_hours": 0.0, + } if os.path.exists(STATE_FILE): try: with open(STATE_FILE, "r", encoding="utf-8") as f: @@ -213,16 +318,25 @@ def load_state() -> Dict: return default -def save_state(alert_active: bool, signature: str) -> None: +def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = None, last_notification_utc: Optional[str] = None) -> None: try: os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) + state_data = { + "alert_active": alert_active, + "signature": signature, + "updated": now_local().isoformat(), + } + if last_notification_utc: + state_data["last_notification_utc"] = last_notification_utc + if casa_data: + state_data.update({ + "casa_snow_24h": casa_data.get("snow_24h", 0.0), + "casa_peak_hourly": casa_data.get("peak_hourly", 0.0), + "casa_first_thr_time": casa_data.get("first_thr_time", ""), + "casa_duration_hours": casa_data.get("duration_hours", 0.0), + }) 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(state_data, f, ensure_ascii=False, indent=2) except Exception as e: LOGGER.exception("State write error: %s", e) @@ -230,28 +344,232 @@ def save_state(alert_active: bool, signature: str) -> None: # ============================================================================= # Open-Meteo # ============================================================================= -def get_forecast(session: requests.Session, lat: float, lon: float) -> Optional[Dict]: +def get_forecast(session: requests.Session, lat: float, lon: float, model: str, extended: bool = False) -> Optional[Dict]: + """ + Recupera previsioni meteo. + + Args: + session: Session HTTP + lat: Latitudine + lon: Longitudine + model: Modello meteo + extended: Se True, richiede dati estesi (fino a 10 giorni) senza minutely_15 per analisi a lungo termine + """ + # Parametri hourly: aggiungi rain e snow_depth quando necessario + hourly_params = "snowfall,weathercode" + if model == MODEL_AROME: + hourly_params += ",rain" # AROME fornisce rain + elif model == MODEL_ICON_IT: + hourly_params += ",rain,snow_depth" # ICON Italia fornisce rain e snow_depth + params = { "latitude": lat, "longitude": lon, - "hourly": "snowfall", + "hourly": hourly_params, + "daily": "snowfall_sum", # Aggiungi daily per colpo d'occhio 24/48h "timezone": TZ, - "forecast_days": 2, - "models": MODEL, + "models": model, } + + if extended: + # Per analisi estesa, richiedi fino a 10 giorni (solo hourly, no minutely_15) + params["forecast_days"] = 10 + else: + params["forecast_days"] = 2 + # Aggiungi minutely_15 per AROME Seamless (dettaglio 15 minuti per inizio preciso) + # Se fallisce, riprova senza minutely_15 + if model == MODEL_AROME: + params["minutely_15"] = "snowfall,precipitation_probability,precipitation,rain,temperature_2m" + try: r = session.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25) if r.status_code == 400: + # Se 400 e abbiamo minutely_15, riprova senza + if "minutely_15" in params and model == MODEL_AROME: + LOGGER.warning("Open-Meteo 400 con minutely_15 (model=%s), riprovo senza minutely_15", model) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass try: j = r.json() - LOGGER.error("Open-Meteo 400 (model=%s lat=%.4f lon=%.4f): %s", MODEL, lat, lon, j.get("reason", j)) + LOGGER.error("Open-Meteo 400 (model=%s lat=%.4f lon=%.4f): %s", model, lat, lon, j.get("reason", j)) except Exception: - LOGGER.error("Open-Meteo 400 (model=%s lat=%.4f lon=%.4f): %s", MODEL, lat, lon, r.text[:500]) + LOGGER.error("Open-Meteo 400 (model=%s lat=%.4f lon=%.4f): %s", model, lat, lon, r.text[:500]) + return None + elif r.status_code == 504: + # Gateway Timeout: se abbiamo minutely_15, riprova senza + if "minutely_15" in params and model == MODEL_AROME: + LOGGER.warning("Open-Meteo 504 Gateway Timeout con minutely_15 (model=%s), riprovo senza minutely_15", model) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + LOGGER.error("Open-Meteo 504 Gateway Timeout (model=%s lat=%.4f lon=%.4f)", model, lat, lon) return None r.raise_for_status() - return r.json() + data = r.json() + + # Converti snow_depth da metri a cm per ICON Italia (Open-Meteo restituisce in metri) + hourly_data = data.get("hourly", {}) + if hourly_data and "snow_depth" in hourly_data and model == MODEL_ICON_IT: + snow_depth_values = hourly_data.get("snow_depth", []) + # Converti da metri a cm (moltiplica per 100) + snow_depth_cm = [] + for sd in snow_depth_values: + if sd is not None: + try: + val_m = float(sd) + val_cm = val_m * 100.0 # Converti da metri a cm + snow_depth_cm.append(val_cm) + except (ValueError, TypeError): + snow_depth_cm.append(None) + else: + snow_depth_cm.append(None) + hourly_data["snow_depth"] = snow_depth_cm + data["hourly"] = hourly_data + + # Verifica se minutely_15 ha buchi (anche solo 1 None = fallback a hourly) + if "minutely_15" in params and model == MODEL_AROME: + minutely = data.get("minutely_15", {}) or {} + minutely_times = minutely.get("time", []) or [] + minutely_precip = minutely.get("precipitation", []) or [] + minutely_snow = minutely.get("snowfall", []) or [] + + # Controlla se ci sono buchi (anche solo 1 None) + if minutely_times: + # Controlla tutti i parametri principali per buchi + has_holes = False + # Controlla precipitation + if minutely_precip and any(v is None for v in minutely_precip): + has_holes = True + # Controlla snowfall + if minutely_snow and any(v is None for v in minutely_snow): + has_holes = True + + if has_holes: + LOGGER.warning("minutely_15 ha buchi (valori None rilevati, model=%s), riprovo senza minutely_15", model) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + + return data + except requests.exceptions.Timeout: + # Timeout: se abbiamo minutely_15, riprova senza + if "minutely_15" in params and model == MODEL_AROME: + LOGGER.warning("Open-Meteo Timeout con minutely_15 (model=%s), riprovo senza minutely_15", model) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + LOGGER.exception("Open-Meteo timeout (model=%s lat=%.4f lon=%.4f)", model, lat, lon) + return None except Exception as e: - LOGGER.exception("Open-Meteo request error (model=%s lat=%.4f lon=%.4f): %s", MODEL, lat, lon, e) + # Altri errori: se abbiamo minutely_15, riprova senza + if "minutely_15" in params and model == MODEL_AROME: + LOGGER.warning("Open-Meteo error con minutely_15 (model=%s): %s, riprovo senza minutely_15", model, str(e)) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + LOGGER.exception("Open-Meteo request error (model=%s lat=%.4f lon=%.4f): %s", model, lat, lon, e) + return None + + +def compare_models(arome_snow_24h: float, icon_snow_24h: float) -> Optional[Dict]: + """Confronta i due modelli e ritorna info se scostamento >30%""" + if arome_snow_24h == 0 and icon_snow_24h == 0: + return None + + # Calcola scostamento percentuale + if arome_snow_24h > 0: + diff_pct = abs(icon_snow_24h - arome_snow_24h) / arome_snow_24h + elif icon_snow_24h > 0: + diff_pct = abs(arome_snow_24h - icon_snow_24h) / icon_snow_24h + else: + return None + + if diff_pct > COMPARISON_THRESHOLD: + return { + "diff_pct": diff_pct * 100, + "arome": arome_snow_24h, + "icon": icon_snow_24h + } + return None + + +def find_extended_end_time(session: requests.Session, lat: float, lon: float, model: str, first_thr_dt: datetime.datetime, now: datetime.datetime) -> Optional[str]: + """ + Trova la fine prevista del fenomeno nevoso usando dati estesi (hourly fino a 10 giorni). + + Args: + session: Session HTTP + lat: Latitudine + lon: Longitudine + model: Modello meteo + first_thr_dt: Data/ora di inizio del fenomeno + now: Data/ora corrente + + Returns: + Stringa con data/ora di fine prevista (formato DD/MM HH:MM) o None se non trovata + """ + try: + # Richiedi dati estesi (hourly, no minutely_15, fino a 10 giorni) + data_extended = get_forecast(session, lat, lon, model, extended=True) + if not data_extended: + return None + + hourly = data_extended.get("hourly", {}) or {} + times_ext = hourly.get("time", []) or [] + snow_ext = hourly.get("snowfall", []) or [] + + if not times_ext or not snow_ext: + return None + + # Cerca l'ultima ora sopra soglia nei dati estesi + last_thr_dt_ext = None + for i in range(len(times_ext) - 1, -1, -1): + try: + dt_ext = parse_time_to_local(times_ext[i]) + if dt_ext < first_thr_dt: + break # Non andare prima dell'inizio + if dt_ext < now: + continue # Salta il passato + + s_val = float(snow_ext[i]) if snow_ext[i] is not None else 0.0 + if s_val > SNOW_HOURLY_THRESHOLD_CM: + last_thr_dt_ext = dt_ext + break + except Exception: + continue + + if last_thr_dt_ext: + return ddmmyy_hhmm(last_thr_dt_ext) + + return None + except Exception as e: + LOGGER.exception("Errore nel calcolo fine estesa: %s", e) return None @@ -260,8 +578,14 @@ def get_forecast(session: requests.Session, lat: float, lon: float) -> Optional[ # ============================================================================= def compute_snow_stats(data: Dict) -> Optional[Dict]: hourly = data.get("hourly", {}) or {} + daily = data.get("daily", {}) or {} times = hourly.get("time", []) or [] snow = hourly.get("snowfall", []) or [] + weathercode = hourly.get("weathercode", []) or [] # Per rilevare neve anche quando snowfall è basso + + # Recupera snowfall_sum dai dati daily (solo per riferimento, non usato per calcoli) + # NOTA: I calcoli di accumulo (3h, 6h, 12h, 24h, 48h) vengono fatti da dati hourly + # perché daily rappresenta l'accumulo del giorno solare, non delle prossime N ore n = min(len(times), len(snow)) if n == 0: @@ -282,37 +606,108 @@ def compute_snow_stats(data: Dict) -> Optional[Dict]: if start_idx == -1: return None + # Analizza sempre le prossime 48 ore end_idx = min(start_idx + HOURS_AHEAD, n) if end_idx <= start_idx: return None times_w = times[start_idx:end_idx] snow_w = [float(x) if x is not None else 0.0 for x in snow[start_idx:end_idx]] + weathercode_w = [int(x) if x is not None else None for x in weathercode[start_idx:end_idx]] if len(weathercode) > start_idx else [] dt_w = [parse_time_to_local(t) for t in times_w] + + # Recupera snow_depth se disponibile (solo per ICON Italia) + snow_depth_w = [] + snow_depth_max = 0.0 + if "snow_depth" in hourly: + snow_depth_raw = hourly.get("snow_depth", []) or [] + if len(snow_depth_raw) > start_idx: + snow_depth_w = [float(x) if x is not None else None for x in snow_depth_raw[start_idx:end_idx]] + # Calcola massimo snow_depth nelle 48 ore (solo valori validi) + valid_sd = [sd for sd in snow_depth_w if sd is not None and sd >= 0] + if valid_sd: + snow_depth_max = max(valid_sd) - # Accumuli informativi + # Verifica se c'è un fenomeno nevoso nelle 48 ore + # Considera neve se: snowfall > soglia OPPURE weather_code indica neve + has_snow_event = any( + (s > SNOW_HOURLY_THRESHOLD_CM) or + (i < len(weathercode_w) and weathercode_w[i] in SNOW_WEATHER_CODES) + for i, s in enumerate(snow_w) + ) + + # Accumuli informativi (solo ore con neve effettiva) def sum_h(h: int) -> float: upto = min(h, len(snow_w)) - return float(sum(snow_w[:upto])) + # Somma solo snowfall > 0 (accumulo totale previsto) + return float(sum(s for s in snow_w[:upto] if s > 0.0)) s3 = sum_h(3) s6 = sum_h(6) s12 = sum_h(12) - s24 = sum_h(24) + s24 = sum_h(24) # Sempre calcolato da hourly (prossime 24h) + s48 = sum_h(48) # Calcolato da hourly (prossime 48h) + + # Accumulo totale previsto (somma di tutti i snowfall orari > 0 nelle 48h) + total_accumulation_cm = float(sum(s for s in snow_w if s > 0.0)) + + # NOTA: NON usiamo snow_24h_daily[0] perché è l'accumulo di OGGI, non delle prossime 24h + # snow_48h viene calcolato correttamente da hourly (prossime 48h), non da daily[1] - # Picco orario e prima occorrenza > soglia + # Picco orario e prima occorrenza di neve (snowfall > 0 OPPURE weathercode neve) peak = max(snow_w) if snow_w else 0.0 peak_time = "" - first_thr_time = "" + first_snow_time = "" # Inizio nevicata (snowfall > 0 o weathercode) + first_snow_val = 0.0 + first_thr_time = "" # Prima occorrenza sopra soglia (per compatibilità) first_thr_val = 0.0 - for i, v in enumerate(snow_w): + # Usa minutely_15 per trovare inizio preciso se disponibile + minutely = data.get("minutely_15", {}) or {} + minutely_times = minutely.get("time", []) or [] + minutely_snow = minutely.get("snowfall", []) or [] + minutely_available = bool(minutely_times) and len(minutely_times) > 0 + + if minutely_available: + # Trova prima occorrenza precisa (risoluzione 15 minuti) + # Analizza sempre le 48 ore + minutely_weathercode = hourly.get("weathercode", []) or [] # Minutely non ha weathercode, usiamo hourly come fallback + for i, (t_str, s_val) in enumerate(zip(minutely_times, minutely_snow)): + try: + dt_min = parse_time_to_local(t_str) + if dt_min < now or dt_min > (now + datetime.timedelta(hours=HOURS_AHEAD)): + continue + + s_float = float(s_val) if s_val is not None else 0.0 + # Rileva inizio neve: snowfall > 0 (anche se sotto soglia) + if s_float > 0.0: + if not first_snow_time: + first_snow_time = ddmmyy_hhmm(dt_min) + first_snow_val = s_float + first_thr_time = first_snow_time # Per compatibilità + first_thr_val = s_float + # Rileva picco sopra soglia + if s_float > SNOW_HOURLY_THRESHOLD_CM: + if s_float > peak: + peak = s_float + peak_time = hhmm(dt_min) + except Exception: + continue + + # Fallback a dati hourly se minutely non disponibile o non ha trovato nulla + if not first_snow_time: + for i, (v, code) in enumerate(zip(snow_w, weathercode_w if len(weathercode_w) == len(snow_w) else [None] * len(snow_w))): + # Rileva inizio neve: snowfall > 0 OPPURE weathercode indica neve + is_snow = (v > 0.0) or (code is not None and code in SNOW_WEATHER_CODES) + if is_snow and not first_snow_time: + first_snow_time = ddmmyy_hhmm(dt_w[i]) + first_snow_val = v if v > 0.0 else 0.0 + first_thr_time = first_snow_time # Per compatibilità + first_thr_val = first_snow_val + # Rileva picco if v > peak: peak = v peak_time = hhmm(dt_w[i]) - if (not first_thr_time) and (v > SNOW_HOURLY_THRESHOLD_CM): - first_thr_time = hhmm(dt_w[i]) - first_thr_val = v if not peak_time and peak > 0 and dt_w: try: @@ -321,16 +716,103 @@ def compute_snow_stats(data: Dict) -> Optional[Dict]: except Exception: peak_time = "" + # Calcola durata del fenomeno e andamento + duration_hours = 0.0 + snow_timeline = [] # Lista di (datetime, valore) per grafico + first_snow_dt = None + last_snow_dt = None + + # Usa first_snow_time se disponibile, altrimenti first_thr_time + start_time_to_use = first_snow_time if first_snow_time else first_thr_time + + if start_time_to_use: + # Trova datetime di inizio (prima occorrenza di neve) + for i, dt in enumerate(dt_w): + snow_val = snow_w[i] + code = weathercode_w[i] if i < len(weathercode_w) else None + is_snow = (snow_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES) + + if ddmmyy_hhmm(dt) == start_time_to_use or (not first_snow_dt and is_snow): + first_snow_dt = dt + break + + # Trova fine del fenomeno (ultima ora con neve: snowfall > 0 OPPURE weathercode) + if first_snow_dt: + for i in range(len(dt_w) - 1, -1, -1): + snow_val = snow_w[i] + code = weathercode_w[i] if i < len(weathercode_w) else None + is_snow = (snow_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES) + + if is_snow: + last_snow_dt = dt_w[i] + break + + if last_snow_dt: + duration_hours = (last_snow_dt - first_snow_dt).total_seconds() / 3600.0 + + # Costruisci timeline per grafico (usa minutely se disponibile, altrimenti hourly) + # La timeline include tutto il fenomeno nelle 48 ore + if minutely_available: + for i, (t_str, s_val) in enumerate(zip(minutely_times, minutely_snow)): + try: + dt_min = parse_time_to_local(t_str) + if dt_min < first_snow_dt or dt_min > last_snow_dt: + continue + # Limita la timeline alle 48 ore + if dt_min > (now + datetime.timedelta(hours=HOURS_AHEAD)): + continue + s_float = float(s_val) if s_val is not None else 0.0 + snow_timeline.append((dt_min, s_float)) + except Exception: + continue + else: + # Usa hourly - include tutto il fenomeno fino alla fine + for i, dt in enumerate(dt_w): + if dt >= first_snow_dt and dt <= last_snow_dt: + snow_timeline.append((dt, snow_w[i])) + + # Calcola orario di fine precipitazioni + last_thr_time = "" + is_ongoing = False # Indica se il fenomeno continua oltre la finestra analizzata + extended_end_time = "" # Fine prevista se estesa oltre 48h + + if last_snow_dt: + last_thr_time = ddmmyy_hhmm(last_snow_dt) + + # Verifica se il fenomeno continua oltre le 48 ore analizzate + # Questo accade se l'ultimo punto analizzato (dopo 48 ore) ha ancora neve + if has_snow_event: + # Controlla se siamo al limite delle 48 ore e l'ultimo punto ha ancora neve + hours_analyzed = (end_idx - start_idx) + if hours_analyzed >= HOURS_AHEAD and len(snow_w) > 0: + last_snow_val = snow_w[-1] + last_code = weathercode_w[-1] if len(weathercode_w) > 0 else None + last_has_snow = (last_snow_val > 0.0) or (last_code is not None and last_code in SNOW_WEATHER_CODES) + if last_has_snow: + is_ongoing = True + # L'ultimo punto ha ancora neve, quindi il fenomeno continua oltre le 48h + # La fine effettiva sarà calcolata con dati estesi se disponibili + return { "snow_3h": s3, "snow_6h": s6, "snow_12h": s12, "snow_24h": s24, + "snow_48h": s48, # Calcolato da hourly (prossime 48h), non da daily + "total_accumulation_cm": total_accumulation_cm, # Accumulo totale previsto (somma di tutti i snowfall > 0) "peak_hourly": float(peak), "peak_time": peak_time, - "first_thr_time": first_thr_time, - "first_thr_val": float(first_thr_val), - "triggered": bool(first_thr_time), + "first_thr_time": first_thr_time, # Per compatibilità + "first_thr_val": float(first_thr_val), # Per compatibilità + "first_snow_time": first_snow_time if first_snow_time else first_thr_time, # Inizio nevicata + "first_snow_val": float(first_snow_val), + "last_thr_time": last_thr_time, + "duration_hours": duration_hours, + "snow_timeline": snow_timeline, + "triggered": bool(first_snow_time or first_thr_time), + "is_ongoing": is_ongoing, # Indica se continua oltre 48h + "extended_end_time": extended_end_time, # Fine prevista se estesa + "snow_depth_max": float(snow_depth_max), # Massimo snow_depth nelle 48 ore (cm) } @@ -342,10 +824,17 @@ def point_summary(name: str, st: Dict) -> Dict: "snow_6h": st["snow_6h"], "snow_12h": st["snow_12h"], "snow_24h": st["snow_24h"], + "snow_48h": st.get("snow_48h"), + "snow_depth_max": st.get("snow_depth_max", 0.0), "peak_hourly": st["peak_hourly"], "peak_time": st["peak_time"], "first_thr_time": st["first_thr_time"], "first_thr_val": st["first_thr_val"], + "last_thr_time": st.get("last_thr_time", ""), + "duration_hours": st.get("duration_hours", 0.0), + "snow_timeline": st.get("snow_timeline", []), + "is_ongoing": st.get("is_ongoing", False), + "extended_end_time": st.get("extended_end_time", ""), } @@ -362,54 +851,714 @@ def build_signature(summaries: List[Dict]) -> str: return "|".join(parts) +def generate_snow_chart(timeline: List[Tuple[datetime.datetime, float]], max_width: int = 50) -> str: + """Genera grafico ASCII orizzontale dell'andamento nevoso (barre affiancate)""" + if not timeline: + return "" + + if len(timeline) == 0: + return "" + + # Estrai valori + values = [v for _, v in timeline] + + if not values or max(values) == 0: + return "" + + max_val = max(values) + + # Caratteri per barre verticali (8 livelli) + bars = "▁▂▃▄▅▆▇█" + + # Determina se i dati sono a 15 minuti o orari + is_15min = len(timeline) > 20 + + # Frequenza etichette temporali: ogni ora (4 barre se 15min, 1 barra se hourly) + label_freq = 4 if is_15min else 1 + + # Crea grafico orizzontale: barre affiancate + # Ogni riga può contenere max_width barre, poi va a capo + result_lines = [] + + # Processa timeline in blocchi di max_width + for start_idx in range(0, len(timeline), max_width): + end_idx = min(start_idx + max_width, len(timeline)) + block = timeline[start_idx:end_idx] + + # Crea riga etichette temporali (solo ogni ora) + # Usa una lista di caratteri per allineare perfettamente con le barre + # Ogni carattere corrisponde esattamente a una barra + time_line_chars = [" "] * len(block) + bar_line_parts = [] + + for i, (dt, val) in enumerate(block): + global_idx = start_idx + i + + # Calcola livello barra + if max_val > 0: + level = int((val / max_val) * 7) + level = min(level, 7) + else: + level = 0 + + bar = bars[level] + bar_line_parts.append(bar) + + # Aggiungi etichetta temporale solo ogni ora, posizionata sopra la prima barra dell'ora + # Mostra anche l'etichetta se siamo all'ultimo punto e corrisponde a un'ora intera + is_hour_start = (global_idx == 0 or global_idx % label_freq == 0) + is_last_point = (global_idx == len(timeline) - 1) + # Se è l'ultimo punto e l'ora è intera (minuti == 0), mostra l'etichetta + if is_hour_start or (is_last_point and dt.minute == 0): + time_str = dt.strftime("%H:%M") + hour_str = time_str[:2] # Prendi solo "HH" (es. "07") + # Posiziona l'etichetta sopra le prime due barre dell'ora + # Ogni carattere dell'etichetta corrisponde esattamente a una barra + # IMPORTANTE: se il blocco ha solo 1 elemento, estendi time_line_chars per accogliere entrambe le cifre + if i + 1 >= len(time_line_chars) and len(hour_str) > 1: + # Estendi time_line_chars se necessario per la seconda cifra + time_line_chars.extend([" "] * (i + 2 - len(time_line_chars))) + if i < len(time_line_chars): + time_line_chars[i] = hour_str[0] if len(hour_str) > 0 else " " + if i + 1 < len(time_line_chars) and len(hour_str) > 1: + time_line_chars[i + 1] = hour_str[1] + # Le posizioni i+2 e i+3 (se esistono) rimangono spazi per le altre barre dell'ora + + # Tronca time_line_chars alla lunghezza effettiva delle barre per allineamento + # Ma assicurati che sia almeno lunga quanto le barre + time_line_chars = time_line_chars[:max(len(bar_line_parts), len(time_line_chars))] + + # Crea le righe - ogni carattere dell'etichetta corrisponde a una barra + time_line = "".join(time_line_chars) + bar_line = "".join(bar_line_parts) + + # Assicurati che le righe abbiano la stessa lunghezza + max_len = max(len(time_line), len(bar_line)) + time_line = time_line.ljust(max_len) + bar_line = bar_line.ljust(max_len) + + # Aggiungi al risultato (etichetta sopra barre) + result_lines.append(time_line) + result_lines.append(bar_line) + + # Aggiungi riga vuota tra i blocchi (tranne l'ultimo) + if end_idx < len(timeline): + result_lines.append("") + + return "\n".join(result_lines) + + +def generate_snow_chart_image( + data_arome: Dict, + data_icon: Optional[Dict], + output_path: str, + location_name: str = "Casa" +) -> bool: + """ + Genera un grafico orario di 48 ore con linee parallele per: + - snow_depth da ICON Italia (☃️) + - snowfall da AROME seamless (❄️) + - rain da AROME seamless (💧) + - snowfall da ICON Italia (❄️) + - rain da ICON Italia (💧) + + Args: + data_arome: Dati AROME seamless + data_icon: Dati ICON Italia (opzionale) + output_path: Percorso file immagine output + location_name: Nome località per titolo + + Returns: + True se generato con successo, False altrimenti + """ + try: + import matplotlib + matplotlib.use('Agg') # Backend non-interattivo + import matplotlib.pyplot as plt + import matplotlib.dates as mdates + from matplotlib.lines import Line2D + + # Configura font per supportare emoji + # Nota: matplotlib potrebbe non supportare completamente emoji colorati, + # ma proveremo a usare caratteri Unicode alternativi se il font emoji non è disponibile + try: + import matplotlib.font_manager as fm + import os + + # Cerca direttamente il percorso del font Noto Color Emoji + possible_paths = [ + '/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf', + '/usr/share/fonts/opentype/noto/NotoColorEmoji.ttf', + '/usr/share/fonts/NotoColorEmoji.ttf', + ] + + emoji_font_path = None + for path in possible_paths: + if os.path.exists(path): + emoji_font_path = path + break + + if emoji_font_path: + # Carica il font direttamente e registralo + try: + # Registra il font con matplotlib + fm.fontManager.addfont(emoji_font_path) + prop = fm.FontProperties(fname=emoji_font_path) + # Configura matplotlib per usare questo font + plt.rcParams['font.family'] = 'sans-serif' + # Aggiungi alla lista dei font di fallback + if 'Noto Color Emoji' not in plt.rcParams['font.sans-serif']: + plt.rcParams['font.sans-serif'].insert(0, 'Noto Color Emoji') + LOGGER.debug("Font emoji caricato: %s", emoji_font_path) + except Exception as e: + LOGGER.debug("Errore caricamento font emoji: %s", e) + except Exception as e: + LOGGER.debug("Errore configurazione font emoji: %s", e) + pass # Se fallisce, usa il font di default + + except ImportError: + LOGGER.warning("matplotlib non disponibile: grafico non generato") + return False + + try: + # Estrai dati hourly da AROME + hourly_arome = data_arome.get("hourly", {}) or {} + times_arome = hourly_arome.get("time", []) or [] + snowfall_arome = hourly_arome.get("snowfall", []) or [] + rain_arome = hourly_arome.get("rain", []) or [] + + # Estrai dati hourly da ICON Italia (se disponibile) + snowfall_icon = [] + rain_icon = [] + snow_depth_icon = [] + times_icon = [] + + if data_icon: + hourly_icon = data_icon.get("hourly", {}) or {} + times_icon = hourly_icon.get("time", []) or [] + snowfall_icon = hourly_icon.get("snowfall", []) or [] + rain_icon = hourly_icon.get("rain", []) or [] + # snow_depth è già convertito in cm da get_forecast + snow_depth_icon = hourly_icon.get("snow_depth", []) or [] + + # Prendi solo le prossime 48 ore dall'ora corrente + now = now_local() + times_parsed = [] + for t_str in times_arome: + try: + dt = parse_time_to_local(t_str) + if dt >= now and (dt - now).total_seconds() <= HOURS_AHEAD * 3600: + times_parsed.append(dt) + except Exception: + continue + + if not times_parsed: + LOGGER.warning("Nessun dato valido per grafico nelle prossime 48 ore") + return False + + # Filtra i dati per le ore valide + start_idx_arome = len(times_arome) - len(times_parsed) + if start_idx_arome < 0: + start_idx_arome = 0 + + times_plot = times_parsed[:48] # Massimo 48 ore + snowfall_arome_plot = [] + rain_arome_plot = [] + + for i, dt in enumerate(times_plot): + idx_arome = start_idx_arome + i + if idx_arome < len(snowfall_arome): + snowfall_arome_plot.append(float(snowfall_arome[idx_arome]) if snowfall_arome[idx_arome] is not None else 0.0) + else: + snowfall_arome_plot.append(0.0) + + if idx_arome < len(rain_arome): + rain_arome_plot.append(float(rain_arome[idx_arome]) if rain_arome[idx_arome] is not None else 0.0) + else: + rain_arome_plot.append(0.0) + + # Allinea dati ICON Italia ai timestamp di AROME + snowfall_icon_plot = [] + rain_icon_plot = [] + snow_depth_icon_plot = [] + + if times_icon and data_icon: + # Crea mappa timestamp -> valori per ICON (con tolleranza per allineamento) + icon_map = {} + for idx, t_str in enumerate(times_icon): + try: + dt = parse_time_to_local(t_str) + if dt >= now and (dt - now).total_seconds() <= HOURS_AHEAD * 3600: + # Converti snow_depth se necessario (dovrebbe già essere in cm da get_forecast) + sd_val = snow_depth_icon[idx] if idx < len(snow_depth_icon) else None + if sd_val is not None: + try: + sd_val = float(sd_val) + except (ValueError, TypeError): + sd_val = None + + icon_map[dt] = { + 'snowfall': float(snowfall_icon[idx]) if idx < len(snowfall_icon) and snowfall_icon[idx] is not None else 0.0, + 'rain': float(rain_icon[idx]) if idx < len(rain_icon) and rain_icon[idx] is not None else 0.0, + 'snow_depth': sd_val + } + except Exception: + continue + + # Allinea ai timestamp di AROME (cerca corrispondenza esatta o più vicina entro 30 minuti) + for dt in times_plot: + # Cerca corrispondenza esatta + if dt in icon_map: + val = icon_map[dt] + snowfall_icon_plot.append(val['snowfall']) + rain_icon_plot.append(val['rain']) + snow_depth_icon_plot.append(val['snow_depth']) + else: + # Cerca corrispondenza più vicina (entro 30 minuti) + found = False + best_dt = None + best_diff = None + for icon_dt, val in icon_map.items(): + diff_seconds = abs((icon_dt - dt).total_seconds()) + if diff_seconds <= 1800: # Entro 30 minuti + if best_diff is None or diff_seconds < best_diff: + best_diff = diff_seconds + best_dt = icon_dt + found = True + + if found and best_dt: + val = icon_map[best_dt] + snowfall_icon_plot.append(val['snowfall']) + rain_icon_plot.append(val['rain']) + snow_depth_icon_plot.append(val['snow_depth']) + else: + # Nessuna corrispondenza trovata: usa valori zero/None + snowfall_icon_plot.append(0.0) + rain_icon_plot.append(0.0) + snow_depth_icon_plot.append(None) + else: + # Se ICON non disponibile, riempi con None/0 + snowfall_icon_plot = [0.0] * len(times_plot) + rain_icon_plot = [0.0] * len(times_plot) + snow_depth_icon_plot = [None] * len(times_plot) + + # Crea figura e assi + fig, ax = plt.subplots(figsize=(14, 8), facecolor='white') + fig.patch.set_facecolor('white') + + # Colori e stili per le linee (accattivanti e ben distinguibili) + colors = { + 'snow_depth_icon': '#1E90FF', # Dodger blue per snow_depth (☃️) + 'snowfall_arome': '#DC143C', # Crimson per snowfall AROME (❄️) + 'rain_arome': '#00AA00', # Dark green per rain AROME (💧) + 'snowfall_icon': '#FF69B4', # Hot pink per snowfall ICON (❄️) + 'rain_icon': '#20B2AA' # Light sea green per rain ICON (💧) + } + + markers = { + 'snow_depth_icon': 'D', # Diamond (☃️) + 'snowfall_arome': 's', # Square (❄️) + 'rain_arome': 'o', # Circle (💧) + 'snowfall_icon': '^', # Triangle up (❄️) + 'rain_icon': 'v' # Triangle down (💧) + } + + # Traccia le linee + lines_handles = [] + lines_labels = [] + + # 1. snow_depth da ICON Italia - scala a sinistra (cm) + if any(sd is not None and sd > 0 for sd in snow_depth_icon_plot): + valid_data = [(t, sd) for t, sd in zip(times_plot, snow_depth_icon_plot) if sd is not None and sd >= 0] + if valid_data: + times_sd, values_sd = zip(*valid_data) + line_sd = ax.plot(times_sd, values_sd, color=colors['snow_depth_icon'], + marker=markers['snow_depth_icon'], markersize=7, linewidth=3.0, + label='Manto nevoso (ICON Italia) [cm]', linestyle='-', alpha=0.95, + markeredgecolor='white', markeredgewidth=1.5) + lines_handles.append(line_sd[0]) + lines_labels.append('Manto nevoso (ICON Italia) [cm]') + + # 2. snowfall da AROME seamless - scala a sinistra (cm) + if any(s > 0 for s in snowfall_arome_plot): + line_sf_arome = ax.plot(times_plot, snowfall_arome_plot, color=colors['snowfall_arome'], + marker=markers['snowfall_arome'], markersize=6, linewidth=2.5, + label='Neve (AROME) [cm]', linestyle='-', alpha=0.9, + markeredgecolor='white', markeredgewidth=1.2) + lines_handles.append(line_sf_arome[0]) + lines_labels.append('Neve (AROME) [cm]') + + # 3. rain da AROME seamless - scala a sinistra (mm) + if any(r > 0 for r in rain_arome_plot): + line_r_arome = ax.plot(times_plot, rain_arome_plot, color=colors['rain_arome'], + marker=markers['rain_arome'], markersize=6, linewidth=2.5, + label='Pioggia (AROME) [mm]', linestyle='-', alpha=0.9, + markeredgecolor='white', markeredgewidth=1.2) + lines_handles.append(line_r_arome[0]) + lines_labels.append('Pioggia (AROME) [mm]') + + # 4. snowfall da ICON Italia - scala a sinistra (cm) + if any(s > 0 for s in snowfall_icon_plot): + line_sf_icon = ax.plot(times_plot, snowfall_icon_plot, color=colors['snowfall_icon'], + marker=markers['snowfall_icon'], markersize=6, linewidth=2.5, + label='Neve (ICON Italia) [cm]', linestyle='--', alpha=0.9, + markeredgecolor='white', markeredgewidth=1.2, dashes=(5, 3)) + lines_handles.append(line_sf_icon[0]) + lines_labels.append('Neve (ICON Italia) [cm]') + + # 5. rain da ICON Italia - scala a sinistra (mm) + if any(r > 0 for r in rain_icon_plot): + line_r_icon = ax.plot(times_plot, rain_icon_plot, color=colors['rain_icon'], + marker=markers['rain_icon'], markersize=6, linewidth=2.5, + label='Pioggia (ICON Italia) [mm]', linestyle='--', alpha=0.9, + markeredgecolor='white', markeredgewidth=1.2, dashes=(5, 3)) + lines_handles.append(line_r_icon[0]) + lines_labels.append('Pioggia (ICON Italia) [mm]') + + if not lines_handles: + LOGGER.warning("Nessun dato da visualizzare nel grafico") + plt.close(fig) + return False + + # Configurazione assi (grafica accattivante e ben leggibile) + ax.set_xlabel('Ora', fontsize=13, fontweight='bold', color='#333333') + ax.set_ylabel('Precipitazioni (cm per neve, mm per pioggia)', fontsize=13, fontweight='bold', color='#333333') + # Rimuovi emoji dal titolo per evitare problemi con font (emoji nella legenda sono OK) + location_clean = location_name.replace('🏠 ', '').replace('💧', '').replace('❄️', '').strip() + ax.set_title(f'Previsioni Neve e Precipitazioni - {location_clean}\n48 ore', + fontsize=15, fontweight='bold', pad=25, color='#1a1a1a') + ax.grid(True, alpha=0.4, linestyle='--', linewidth=0.8, color='#888888') + ax.set_facecolor('#F8F8F8') + + # Colora gli assi + ax.spines['top'].set_visible(False) + ax.spines['right'].set_visible(False) + ax.spines['left'].set_color('#666666') + ax.spines['bottom'].set_color('#666666') + + # Formatta asse X (date/ore) + ax.xaxis.set_major_formatter(mdates.DateFormatter('%d/%m %H:%M')) + ax.xaxis.set_major_locator(mdates.HourLocator(interval=6)) # Ogni 6 ore + plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right', fontsize=10) + plt.setp(ax.yaxis.get_majorticklabels(), fontsize=10) + + # Legenda migliorata (con testo descrittivo e marker colorati distinti) + legend = ax.legend(handles=lines_handles, labels=lines_labels, loc='upper left', + framealpha=0.98, fontsize=11, ncol=1, frameon=True, + shadow=True, fancybox=True, edgecolor='#CCCCCC') + legend.get_frame().set_facecolor('#FFFFFF') + legend.get_frame().set_linewidth(1.5) + + # Migliora layout (sopprimi warning emoji nel font) + import warnings + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', category=UserWarning, message='.*Glyph.*missing from font.*') + plt.tight_layout() + + # Salva immagine + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', category=UserWarning, message='.*Glyph.*missing from font.*') + plt.savefig(output_path, dpi=150, facecolor='white', bbox_inches='tight') + plt.close(fig) + + LOGGER.info("Grafico generato: %s", output_path) + return True + + except Exception as e: + LOGGER.exception("Errore generazione grafico: %s", e) + return False + + +def is_significant_change(current: Dict, previous: Dict) -> Tuple[bool, str]: + """Verifica se ci sono cambiamenti significativi. Ritorna (is_significant, reason)""" + if not previous.get("alert_active", False): + return True, "nuova_allerta" + + prev_snow_24h = previous.get("casa_snow_24h", 0.0) + prev_peak = previous.get("casa_peak_hourly", 0.0) + prev_first_time = previous.get("casa_first_thr_time", "") + + curr_snow_24h = current.get("snow_24h", 0.0) + curr_peak = current.get("peak_hourly", 0.0) + curr_first_time = current.get("first_thr_time", "") + + # Annullamento + if not current.get("triggered", False): + return True, "annullamento" + + # Variazione quantità (20% o 1 cm) + if prev_snow_24h > 0: + pct_change = abs(curr_snow_24h - prev_snow_24h) / prev_snow_24h * 100 + abs_change = abs(curr_snow_24h - prev_snow_24h) + if pct_change >= SNOW_QUANTITY_CHANGE_THRESHOLD_PCT or abs_change >= SNOW_QUANTITY_CHANGE_THRESHOLD_CM: + return True, f"variazione_quantita_{'+' if curr_snow_24h > prev_snow_24h else '-'}" + + # Variazione picco + if prev_peak > 0: + pct_change = abs(curr_peak - prev_peak) / prev_peak * 100 + if pct_change >= SNOW_QUANTITY_CHANGE_THRESHOLD_PCT: + return True, f"variazione_picco_{'+' if curr_peak > prev_peak else '-'}" + + # Variazione orario inizio (±2 ore) + if prev_first_time and curr_first_time: + try: + # Parse date from "DD/MM HH:MM" + prev_parts = prev_first_time.split() + curr_parts = curr_first_time.split() + if len(prev_parts) == 2 and len(curr_parts) == 2: + prev_date_str, prev_time_str = prev_parts + curr_date_str, curr_time_str = curr_parts + + # Determina l'anno prima del parsing per evitare warning di deprecazione + now = now_local() + + # Estrai mese e giorno dalla stringa senza parsing + prev_day, prev_month = map(int, prev_date_str.split("/")) + curr_day, curr_month = map(int, curr_date_str.split("/")) + + # Determina l'anno appropriato + if prev_month > now.month or (prev_month == now.month and prev_day > now.day): + prev_year = now.year - 1 + else: + prev_year = now.year + + if curr_month > now.month or (curr_month == now.month and curr_day > now.day): + curr_year = now.year - 1 + else: + curr_year = now.year + + # Parse completo con anno incluso + prev_dt = datetime.datetime.strptime(f"{prev_date_str}/{prev_year} {prev_time_str}", "%d/%m/%Y %H:%M") + curr_dt = datetime.datetime.strptime(f"{curr_date_str}/{curr_year} {curr_time_str}", "%d/%m/%Y %H:%M") + + hours_diff = abs((curr_dt - prev_dt).total_seconds() / 3600.0) + if hours_diff >= START_TIME_CHANGE_THRESHOLD_HOURS: + return True, f"variazione_orario_{hours_diff:.1f}h" + except Exception: + pass + + return False, "" + + +def is_periodic_update_due(previous: Dict) -> bool: + """ + Verifica se è il momento di inviare un aggiornamento periodico. + + Ritorna True se: + - L'allerta è attiva + - È passato almeno PERIODIC_UPDATE_INTERVAL_HOURS dall'ultima notifica + """ + if not previous.get("alert_active", False): + return False + + last_notification_str = previous.get("last_notification_utc", "") + if not last_notification_str: + # Se non c'è timestamp, considera che sia dovuto (primo aggiornamento periodico) + return True + + try: + last_notification = datetime.datetime.fromisoformat(last_notification_str) + if last_notification.tzinfo is None: + last_notification = last_notification.replace(tzinfo=datetime.timezone.utc) + else: + last_notification = last_notification.astimezone(datetime.timezone.utc) + + now_utc = datetime.datetime.now(datetime.timezone.utc) + hours_since_last = (now_utc - last_notification).total_seconds() / 3600.0 + + return hours_since_last >= PERIODIC_UPDATE_INTERVAL_HOURS + except Exception as e: + LOGGER.debug("Errore parsing last_notification_utc: %s", e) + return True # In caso di errore, considera dovuto + + # ============================================================================= # Main # ============================================================================= -def analyze_snow() -> None: +def analyze_snow(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None: if not is_winter_season(): LOGGER.info("Fuori stagione invernale: nessuna verifica/notify.") - save_state(False, "") + save_state(False, "", None) return now_str = now_local().strftime("%H:%M") - LOGGER.info("--- Check Neve AROME HD %s ---", now_str) + LOGGER.info("--- Check Neve AROME Seamless %s ---", now_str) state = load_state() was_active = bool(state.get("alert_active", False)) last_sig = state.get("signature", "") summaries: List[Dict] = [] + summaries_icon: Dict[str, Dict] = {} # point_name -> icon summary (solo se scostamento significativo) + comparisons: Dict[str, Dict] = {} # point_name -> comparison info + + # Conserva dati raw per Casa per generare grafico + casa_data_arome_raw: Optional[Dict] = None + casa_data_icon_raw: Optional[Dict] = None + casa_location_name = "🏠 Casa" with requests.Session() as session: for p in POINTS: - data = get_forecast(session, p["lat"], p["lon"]) - if not data: - LOGGER.warning("Forecast non disponibile per %s (skip).", p["name"]) + # Recupera AROME seamless + data_arome = get_forecast(session, p["lat"], p["lon"], MODEL_AROME) + if not data_arome: + LOGGER.warning("Forecast AROME non disponibile per %s (skip).", p["name"]) + continue + + # Conserva dati raw per Casa per generare grafico + if p["name"] == "🏠 Casa": + casa_data_arome_raw = data_arome + + st_arome = compute_snow_stats(data_arome) + if not st_arome: + LOGGER.warning("Statistiche AROME non calcolabili per %s (skip).", p["name"]) continue - st = compute_snow_stats(data) - if not st: - LOGGER.warning("Statistiche non calcolabili per %s (skip).", p["name"]) - continue + # Se il fenomeno continua oltre le 48 ore, cerca la fine nei dati estesi + if st_arome.get("is_ongoing", False) and st_arome.get("first_thr_time"): + try: + # Parse first_thr_time per ottenere first_thr_dt + first_thr_parts = st_arome["first_thr_time"].split() + if len(first_thr_parts) == 2: + first_date_str, first_time_str = first_thr_parts + first_day, first_month = map(int, first_date_str.split("/")) + now = now_local() + if first_month > now.month or (first_month == now.month and first_day > now.day): + first_year = now.year - 1 + else: + first_year = now.year + first_thr_dt = datetime.datetime.strptime(f"{first_date_str}/{first_year} {first_time_str}", "%d/%m/%Y %H:%M") + + # Cerca fine estesa (prova prima AROME, poi ICON se AROME non disponibile) + extended_end = find_extended_end_time(session, p["lat"], p["lon"], MODEL_AROME, first_thr_dt, now) + if not extended_end: + # Fallback a ICON Italia se AROME non ha dati estesi + extended_end = find_extended_end_time(session, p["lat"], p["lon"], MODEL_ICON_IT, first_thr_dt, now) + + if extended_end: + st_arome["extended_end_time"] = extended_end + LOGGER.info("Fine estesa trovata per %s: %s", p["name"], extended_end) + except Exception as e: + LOGGER.exception("Errore nel calcolo fine estesa per %s: %s", p["name"], e) - summaries.append(point_summary(p["name"], st)) + # Recupera ICON Italia per comparazione (sempre per Casa, opzionale per altri) + # Per Casa recuperiamo sempre ICON per mostrare nel grafico e nel messaggio + if p["name"] == "🏠 Casa": + data_icon = get_forecast(session, p["lat"], p["lon"], MODEL_ICON_IT) + if data_icon: + casa_data_icon_raw = data_icon + st_icon = compute_snow_stats(data_icon) + if st_icon: + summaries_icon[p["name"]] = point_summary(p["name"], st_icon) + # Calcola comparazione per logging (ma mostra sempre ICON nel messaggio per Casa) + comp = compare_models(st_arome["snow_24h"], st_icon["snow_24h"]) + if comp: + comparisons[p["name"]] = comp + LOGGER.info("Scostamento >30%% per %s: AROME=%.1f cm, ICON=%.1f cm (diff=%.1f%%)", + p["name"], comp["arome"], comp["icon"], comp["diff_pct"]) + else: + # Anche senza scostamento significativo, mostra ICON per Casa se ha dati interessanti + # (snow_depth > 0 o snow_48h > 0 anche se snow_24h non supera soglia) + if st_icon.get("snow_depth_max", 0) > 0 or st_icon.get("snow_48h", 0) > 0: + LOGGER.info("ICON disponibile per Casa: snow_depth_max=%.1f cm, snow_48h=%.1f cm", + st_icon.get("snow_depth_max", 0), st_icon.get("snow_48h", 0)) + else: + # Per altri punti, recupera ICON solo se necessario per comparazione + data_icon = get_forecast(session, p["lat"], p["lon"], MODEL_ICON_IT) + if data_icon: + st_icon = compute_snow_stats(data_icon) + if st_icon: + comp = compare_models(st_arome["snow_24h"], st_icon["snow_24h"]) + if comp: + comparisons[p["name"]] = comp + summaries_icon[p["name"]] = point_summary(p["name"], st_icon) + LOGGER.info("Scostamento >30%% per %s: AROME=%.1f cm, ICON=%.1f cm (diff=%.1f%%)", + p["name"], comp["arome"], comp["icon"], comp["diff_pct"]) + + summaries.append(point_summary(p["name"], st_arome)) time.sleep(0.2) if not summaries: LOGGER.error("Nessun punto ha restituito statistiche valide.") return - any_trigger = any(s["triggered"] for s in summaries) + # Calcola any_trigger escludendo Carpegna (solo Casa, Titano, Dogana possono attivare l'allerta) + # Carpegna viene comunque mostrato nel report se ha trigger, ma non attiva l'allerta + any_trigger = any(s["triggered"] for s in summaries if s["name"] != "🏔️ Carpegna") + + # Verifica se solo Carpegna ha trigger (per logging) + carpegna_triggered = any(s["triggered"] for s in summaries if s["name"] == "🏔️ Carpegna") + if carpegna_triggered and not any_trigger: + LOGGER.info("Neve prevista solo a Carpegna: allerta non inviata (Carpegna escluso dal trigger principale)") + sig = build_signature(summaries) + + # Prepara dati Casa per verifica cambiamenti + casa = next((s for s in summaries if s["name"] == "🏠 Casa"), None) + casa_data = None + if casa: + casa_data = { + "snow_24h": casa["snow_24h"], + "peak_hourly": casa["peak_hourly"], + "first_thr_time": casa["first_thr_time"], + "duration_hours": casa.get("duration_hours", 0.0), + "triggered": casa["triggered"], + } + + # Verifica cambiamenti significativi + is_significant, change_reason = is_significant_change( + casa_data or {}, + state + ) if casa_data else (True, "nuova_allerta") + + # Verifica se è il momento di un aggiornamento periodico + is_periodic_due = is_periodic_update_due(state) # --- Scenario A: soglia superata --- if any_trigger: - if (not was_active) or (sig != last_sig): + # Se change_reason è "annullamento" ma any_trigger è True, significa che Casa non ha più trigger + # ma altri punti sì. Non è un annullamento generale, quindi trattiamolo come aggiornamento normale. + if change_reason == "annullamento": + change_reason = "variazione_punti_monitorati" + is_significant = True + # Invia notifica se: + # - Prima allerta (not was_active) + # - Cambiamento significativo + # - Aggiornamento periodico dovuto (ogni 6 ore) + # - Modalità debug (bypassa controlli) + if debug_mode or (not was_active) or is_significant or is_periodic_due: + if debug_mode: + LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato") msg: List[str] = [] - msg.append("❄️ ALLERTA NEVE (AROME HD)") + + # Titolo con indicazione tipo notifica + # Nota: "annullamento" non può verificarsi nello Scenario A (any_trigger=True) + if not was_active: + title = "❄️ ALLERTA NEVE" + elif is_periodic_due and not is_significant: + title = "⏱️ AGGIORNAMENTO PERIODICO ALLERTA NEVE" + change_reason = "aggiornamento_periodico" + elif "variazione_quantita" in change_reason: + direction = "📈" if "+" in change_reason else "📉" + title = f"{direction} AGGIORNAMENTO ALLERTA NEVE" + elif "variazione_orario" in change_reason: + title = "⏰ AGGIORNAMENTO ALLERTA NEVE" + else: + title = "🔄 AGGIORNAMENTO ALLERTA NEVE" + + msg.append(title) msg.append(f"🕒 Aggiornamento ore {html.escape(now_str)}") - msg.append(f"🛰️ Modello: {html.escape(MODEL)}") - msg.append(f"⏱️ Finestra: prossime {HOURS_AHEAD} ore | Trigger: snowfall > {SNOW_HOURLY_THRESHOLD_CM:.1f} cm/h") + if change_reason and change_reason != "nuova_allerta" and change_reason != "annullamento": + msg.append(f"📊 Motivo: {html.escape(change_reason.replace('_', ' '))}") + # La finestra di analisi è sempre 48 ore + window_text = f"prossime {HOURS_AHEAD} ore" + msg.append(f"⏱️ Finestra: {window_text} | Trigger: snowfall > {SNOW_HOURLY_THRESHOLD_CM:.1f} cm/h") + msg.append("") + + # Sezione AROME Seamless + msg.append("🛰️ AROME SEAMLESS") + msg.append(f"Modello: {html.escape(MODEL_AROME)}") msg.append("") casa = next((s for s in summaries if s["name"] == "🏠 Casa"), None) @@ -417,6 +1566,8 @@ def analyze_snow() -> None: msg.append("🏠 CASA") msg.append(f"• 03h: {casa['snow_3h']:.1f} cm | 06h: {casa['snow_6h']:.1f} cm") msg.append(f"• 12h: {casa['snow_12h']:.1f} cm | 24h: {casa['snow_24h']:.1f} cm") + if casa.get("snow_48h") is not None: + msg.append(f"• 📊 Totale previsto 48h: {casa['snow_48h']:.1f} cm") if casa["triggered"]: msg.append( f"• Primo superamento soglia: {html.escape(casa['first_thr_time'] or '—')} " @@ -424,54 +1575,185 @@ def analyze_snow() -> None: ) if casa["peak_hourly"] > 0: msg.append(f"• Picco orario: {casa['peak_hourly']:.1f} cm/h (~{html.escape(casa['peak_time'] or '—')})") + if casa.get("duration_hours", 0) > 0: + msg.append(f"• Durata stimata: {casa['duration_hours']:.1f} ore") + if casa.get("last_thr_time"): + msg.append(f"• Fine precipitazioni nevose: {html.escape(casa['last_thr_time'])}") + + # Se il fenomeno continua oltre le 48 ore, segnalalo + if casa.get("is_ongoing", False): + if casa.get("extended_end_time"): + msg.append(f"• ⚠️ Fenomeno in corso oltre 48h - Fine prevista: {html.escape(casa['extended_end_time'])}") + else: + msg.append(f"• ⚠️ Fenomeno in corso oltre 48h - Fine non ancora determinabile") + + # Aggiungi grafico andamento + timeline = casa.get("snow_timeline", []) + if timeline: + chart = generate_snow_chart(timeline, max_width=30) + if chart: + msg.append("") + msg.append("📊 Andamento previsto:") + msg.append("
" + html.escape(chart) + "
") + msg.append("") - msg.append("🌍 NEL CIRCONDARIO") - lines = [] + msg.append("🌍 NEL CIRCONDARIO (AROME)") + lines_arome = [] for s in summaries: if not s["triggered"]: continue - lines.append( + lines_arome.append( f"{s['name']}: primo > soglia alle {s['first_thr_time'] or '—'} " f"({s['first_thr_val']:.1f} cm/h), picco {s['peak_hourly']:.1f} cm/h, 24h {s['snow_24h']:.1f} cm" ) - if lines: - msg.append("
" + html.escape("\n".join(lines)) + "
") + if lines_arome: + msg.append("
" + html.escape("\n".join(lines_arome)) + "
") else: msg.append("Nessun punto ha superato la soglia (anomalia).") + # Sezione ICON Italia (sempre mostrata per Casa se disponibile, altrimenti solo se scostamento significativo) + casa_icon_available = "🏠 Casa" in summaries_icon + if casa_icon_available or (comparisons and summaries_icon): + msg.append("") + msg.append("─" * 15) # Linea corta per iPhone + msg.append("") + msg.append("🇮🇹 ICON ITALIA (ARPAE 2i)") + msg.append(f"Modello: {html.escape(MODEL_ICON_IT)}") + msg.append("") + # Mostra avviso scostamento solo se c'è effettivo scostamento (non per Casa se mostrato sempre) + if comparisons and ("🏠 Casa" not in comparisons or not casa_icon_available): + msg.append("⚠️ Scostamento significativo rilevato (>30%%)") + msg.append("") + + # Casa ICON + casa_icon = summaries_icon.get("🏠 Casa") + if casa_icon: + msg.append("🏠 CASA") + msg.append(f"• 03h: {casa_icon['snow_3h']:.1f} cm | 06h: {casa_icon['snow_6h']:.1f} cm") + msg.append(f"• 12h: {casa_icon['snow_12h']:.1f} cm | 24h: {casa_icon['snow_24h']:.1f} cm") + if casa_icon.get("snow_48h") is not None: + msg.append(f"• 📊 Totale previsto 48h: {casa_icon['snow_48h']:.1f} cm") + # Mostra snow_depth se > 0 (manto nevoso persistente) + if casa_icon.get("snow_depth_max", 0) > 0: + msg.append(f"• ⛄ Spessore manto nevoso massimo: {casa_icon['snow_depth_max']:.1f} cm") + if casa_icon["triggered"]: + msg.append( + f"• Primo superamento soglia: {html.escape(casa_icon['first_thr_time'] or '—')} " + f"({casa_icon['first_thr_val']:.1f} cm/h)" + ) + if casa_icon["peak_hourly"] > 0: + msg.append(f"• Picco orario: {casa_icon['peak_hourly']:.1f} cm/h (~{html.escape(casa_icon['peak_time'] or '—')})") + msg.append("") + + # Circondario ICON + msg.append("🌍 NEL CIRCONDARIO (ICON ITALIA)") + lines_icon = [] + for point_name, comp in comparisons.items(): + s_icon = summaries_icon.get(point_name) + if s_icon and s_icon["triggered"]: + lines_icon.append( + f"{point_name}: primo > soglia alle {s_icon['first_thr_time'] or '—'} " + f"({s_icon['first_thr_val']:.1f} cm/h), picco {s_icon['peak_hourly']:.1f} cm/h, 24h {s_icon['snow_24h']:.1f} cm" + ) + + if lines_icon: + msg.append("
" + html.escape("\n".join(lines_icon)) + "
") + else: + # Mostra comunque i punti con scostamento anche se non hanno trigger + for point_name, comp in comparisons.items(): + s_icon = summaries_icon.get(point_name) + if s_icon: + msg.append( + f"• {html.escape(point_name)}: 24h {s_icon['snow_24h']:.1f} cm " + f"(scostamento {comp['diff_pct']:.0f}%%)" + ) + + msg.append("") msg.append("Fonte dati: Open-Meteo") - ok = telegram_send_html("
".join(msg)) + # Unisci con
(sarà convertito in \n in telegram_send_html) + ok = telegram_send_html("
".join(msg), chat_ids=chat_ids) + + # Genera e invia grafico (solo se abbiamo dati per Casa) + chart_generated = False + if casa_data_arome_raw: + try: + # Crea file temporaneo per il grafico + chart_path = os.path.join(tempfile.gettempdir(), f"snow_chart_{int(time.time())}.png") + chart_ok = generate_snow_chart_image( + casa_data_arome_raw, + casa_data_icon_raw, + chart_path, + location_name=casa_location_name + ) + if chart_ok: + chart_caption = f"📊 Grafico Precipitazioni 48h\n🏠 {casa_location_name}\n🕒 Aggiornamento ore {html.escape(now_str)}" + photo_ok = telegram_send_photo(chart_path, chart_caption, chat_ids=chat_ids) + if photo_ok: + chart_generated = True + LOGGER.info("Grafico inviato con successo") + # Rimuovi file temporaneo dopo l'invio + try: + if os.path.exists(chart_path): + os.remove(chart_path) + except Exception: + pass + else: + LOGGER.warning("Generazione grafico fallita") + except Exception as e: + LOGGER.exception("Errore generazione/invio grafico: %s", e) + if ok: - LOGGER.info("Notifica neve inviata.") + reason_text = change_reason if change_reason else "aggiornamento_periodico" if is_periodic_due else "sconosciuto" + LOGGER.info("Notifica neve inviata. Motivo: %s", reason_text) + + # 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")) else: LOGGER.warning("Notifica neve NON inviata (token mancante o errore Telegram).") - - save_state(True, sig) + # Salva comunque lo state (senza aggiornare last_notification_utc) + save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", "")) else: - LOGGER.info("Allerta già notificata e invariata.") + 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) + save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", "")) return # --- Scenario B: rientro (nessun superamento) --- + # Invia notifica di annullamento SOLO se: + # 1. C'era un'allerta attiva (was_active) + # 2. È stata effettivamente inviata una notifica precedente (last_notification_utc presente) + # Questo evita di inviare "annullamento" quando lo stato è attivo ma non è mai stata inviata una notifica if was_active and not any_trigger: - msg = ( - "🟢 PREVISIONE NEVE ANNULLATA
" - f"🕒 Aggiornamento ore {html.escape(now_str)}

" - f"Nelle prossime {HOURS_AHEAD} ore non risultano ore con snowfall > {SNOW_HOURLY_THRESHOLD_CM:.1f} cm/h.
" - "Fonte dati: Open-Meteo" - ) - ok = telegram_send_html(msg) - if ok: - LOGGER.info("Rientro neve notificato.") + last_notification_str = state.get("last_notification_utc", "") + has_previous_notification = bool(last_notification_str and last_notification_str.strip()) + + if has_previous_notification: + # C'è stata una notifica precedente, quindi possiamo inviare l'annullamento + msg = ( + "🟢 PREVISIONE NEVE ANNULLATA
" + f"🕒 Aggiornamento ore {html.escape(now_str)}
" + f"Nelle prossime {HOURS_AHEAD} ore non risultano ore con snowfall > {SNOW_HOURLY_THRESHOLD_CM:.1f} cm/h.
" + "Fonte dati: Open-Meteo" + ) + ok = telegram_send_html(msg, chat_ids=chat_ids) + if ok: + LOGGER.info("Rientro neve notificato.") + else: + LOGGER.warning("Rientro neve NON inviato (token mancante o errore Telegram).") + save_state(False, "", None) else: - LOGGER.warning("Rientro neve NON inviato (token mancante o errore Telegram).") - save_state(False, "") + # Non c'è stata una notifica precedente, quindi non inviare l'annullamento + # ma resetta comunque lo stato per evitare loop + LOGGER.info("Allerta attiva ma nessuna notifica precedente trovata. Resetto stato senza inviare annullamento.") + save_state(False, "", None) return # --- Scenario C: tranquillo --- - save_state(False, "") + save_state(False, "", None) top = sorted(summaries, key=lambda x: x["snow_24h"], reverse=True)[:3] LOGGER.info( "Nessuna neve sopra soglia. Top accumuli 24h: %s", @@ -480,4 +1762,11 @@ def analyze_snow() -> None: if __name__ == "__main__": - analyze_snow() + arg_parser = argparse.ArgumentParser(description="Monitor neve AROME Seamless") + arg_parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0]) + args = arg_parser.parse_args() + + # In modalità debug, invia solo al primo chat ID (admin) e bypassa anti-spam + chat_ids = [TELEGRAM_CHAT_IDS[0]] if args.debug else None + + analyze_snow(chat_ids=chat_ids, debug_mode=args.debug) diff --git a/services/telegram-bot/bot.py b/services/telegram-bot/bot.py old mode 100755 new mode 100644 index e800a49..7d0ef18 --- a/services/telegram-bot/bot.py +++ b/services/telegram-bot/bot.py @@ -3,7 +3,10 @@ import subprocess import os import datetime import requests +import shlex +import json from functools import wraps +from typing import Optional from zoneinfo import ZoneInfo from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ( @@ -15,13 +18,10 @@ from telegram.ext import ( ) # ============================================================================= -# LOOGLE BOT V9.0 (ULTIMATE + CAMERAS + MODULAR) -# - Dashboard Sistema (SSH/WOL/Monitor) -# - Meteo Smart (Meteo.py / Previsione7.py) -# - CCTV Hub (Cam.py + FFMPEG) +# LOOGLE BOT V8.1 (MODULARE + ON-DEMAND METEO) # ============================================================================= -# --- CONFIGURAZIONE AMBIENTE --- +# --- CONFIGURAZIONE --- BOT_TOKEN = os.environ.get('BOT_TOKEN') allowed_users_raw = os.environ.get('ALLOWED_USER_ID', '') ALLOWED_IDS = [int(x.strip()) for x in allowed_users_raw.split(',') if x.strip().isdigit()] @@ -32,20 +32,25 @@ MASTER_IP = "192.168.128.80" TZ = "Europe/Rome" TZINFO = ZoneInfo(TZ) -# --- GESTIONE PERCORSI DINAMICA (DOCKER FRIENDLY) --- +# PERCORSI SCRIPT SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -METEO_SCRIPT = os.path.join(SCRIPT_DIR, "meteo.py") +METEO_SCRIPT = os.path.join(SCRIPT_DIR, "meteo.py") # SCRIPT METEO SEPARATO METEO7_SCRIPT = os.path.join(SCRIPT_DIR, "previsione7.py") -CAM_SCRIPT = os.path.join(SCRIPT_DIR, "cam.py") +SEVERE_SCRIPT = os.path.join(SCRIPT_DIR, "severe_weather.py") +ICE_CHECK_SCRIPT = os.path.join(SCRIPT_DIR, "check_ghiaccio.py") +IRRIGATION_SCRIPT = os.path.join(SCRIPT_DIR, "smart_irrigation_advisor.py") +SNOW_RADAR_SCRIPT = os.path.join(SCRIPT_DIR, "snow_radar.py") -# --- LISTE DISPOSITIVI --- +# FILE STATO VIAGGI +VIAGGI_STATE_FILE = os.path.join(SCRIPT_DIR, "viaggi_attivi.json") + +# --- LISTE DISPOSITIVI (CORE/INFRA) --- CORE_DEVICES = [ {"name": "🍓 Pi-1 (Master)", "ip": MASTER_IP, "type": "pi", "user": SSH_USER}, {"name": "🍓 Pi-2 (Backup)", "ip": "127.0.0.1", "type": "local", "user": ""}, {"name": "🗄️ NAS 920+", "ip": "192.168.128.100", "type": "nas", "user": NAS_USER}, {"name": "🗄️ NAS 214", "ip": "192.168.128.90", "type": "nas", "user": NAS_USER} ] - INFRA_DEVICES = [ {"name": "📡 Router", "ip": "192.168.128.1"}, {"name": "📶 WiFi Sala", "ip": "192.168.128.101"}, @@ -58,19 +63,14 @@ INFRA_DEVICES = [ {"name": "🔌 Sw Tav", "ip": "192.168.128.108"} ] -# Configurazione Logging logging.basicConfig(format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO) logger = logging.getLogger(__name__) -# ============================================================================= -# SEZIONE 1: FUNZIONI UTILI E HELPER -# ============================================================================= - +# --- FUNZIONI SISTEMA (SSH/PING) --- def run_cmd(command, ip=None, user=None): - """Esegue comandi shell locali o via SSH""" try: if ip == "127.0.0.1" or ip is None: - return subprocess.check_output(command, shell=True, stderr=subprocess.STDOUT, timeout=8).decode('utf-8').strip() + return subprocess.check_output(command, shell=True, stderr=subprocess.STDOUT, timeout=5).decode('utf-8').strip() else: safe_cmd = command.replace("'", "'\\''") full_cmd = f"ssh -o LogLevel=ERROR -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=3 {user}@{ip} '{safe_cmd}'" @@ -84,13 +84,11 @@ def get_ping_icon(ip): except Exception: return "🔴" def get_device_stats(device): - ip, user, dtype = device['ip'], device['user'], device['type'] + ip, user, dtype = device['ip'], device['type'], device['user'] uptime_raw = run_cmd("uptime -p 2>/dev/null || uptime", ip, user) if not uptime_raw or "Err" in uptime_raw: return "🔴 **OFFLINE**" - uptime = uptime_raw.replace("up ", "").split(", load")[0].split(", ")[0] temp = "N/A" - if dtype in ["pi", "local"]: t = run_cmd("cat /sys/class/thermal/thermal_zone0/temp", ip, user) if t.isdigit(): temp = f"{int(t)/1000:.1f}°C" @@ -98,7 +96,6 @@ def get_device_stats(device): t = run_cmd("cat /sys/class/hwmon/hwmon0/temp1_input 2>/dev/null || cat /sys/class/thermal/thermal_zone0/temp", ip, user) if t.isdigit(): v = int(t); temp = f"{v/1000:.1f}°C" if v > 1000 else f"{v}°C" - if dtype == "nas": ram_cmd = "free | grep Mem | awk '{printf \"%.0f%%\", $3*100/$2}'" else: ram_cmd = "free -m | awk 'NR==2{if ($2>0) printf \"%.0f%%\", $3*100/$2; else print \"0%\"}'" disk_path = "/" if dtype != "nas" else "/volume1" @@ -111,27 +108,124 @@ def read_log_file(filepath, lines=15): except Exception as e: return f"Errore: {str(e)}" def run_speedtest(): - try: return subprocess.check_output("speedtest-cli --simple", shell=True, timeout=60).decode('utf-8') + try: return subprocess.check_output("speedtest-cli --simple", shell=True, timeout=50).decode('utf-8') except: return "Errore Speedtest" -def call_script_text(script_path, args_list): - """Wrapper per lanciare script che restituiscono testo (Meteo)""" +# --- GESTIONE VIAGGI ATTIVI --- +def load_viaggi_state() -> dict: + """Carica lo stato dei viaggi attivi da file JSON""" + if os.path.exists(VIAGGI_STATE_FILE): + try: + with open(VIAGGI_STATE_FILE, "r", encoding="utf-8") as f: + return json.load(f) or {} + except Exception as e: + logger.error(f"Errore lettura viaggi state: {e}") + return {} + return {} + +def save_viaggi_state(state: dict) -> None: + """Salva lo stato dei viaggi attivi su file JSON""" try: - cmd = ["python3", script_path] + args_list + with open(VIAGGI_STATE_FILE, "w", encoding="utf-8") as f: + json.dump(state, f, ensure_ascii=False, indent=2) + except Exception as e: + logger.error(f"Errore scrittura viaggi state: {e}") + +def get_timezone_from_coords(lat: float, lon: float) -> str: + """Ottiene la timezone da coordinate usando timezonefinder o fallback""" + try: + from timezonefinder import TimezoneFinder + tf = TimezoneFinder() + tz = tf.timezone_at(lng=lon, lat=lat) + if tz: + return tz + except ImportError: + logger.warning("timezonefinder non installato, uso fallback") + except Exception as e: + logger.warning(f"Errore timezonefinder: {e}") + + # Fallback: stima timezone da longitudine (approssimativo) + # Ogni 15 gradi = 1 ora di differenza da UTC + offset_hours = int(lon / 15) + # Mappatura approssimativa a timezone IANA + if -10 <= offset_hours <= 2: # Europa + return "Europe/Rome" + elif 3 <= offset_hours <= 5: # Medio Oriente + return "Asia/Dubai" + elif 6 <= offset_hours <= 8: # Asia centrale + return "Asia/Kolkata" + elif 9 <= offset_hours <= 11: # Asia orientale + return "Asia/Tokyo" + elif -5 <= offset_hours <= -3: # Americhe orientali + return "America/New_York" + elif -8 <= offset_hours <= -6: # Americhe occidentali + return "America/Los_Angeles" + else: + return "UTC" + +def add_viaggio(chat_id: str, location: str, lat: float, lon: float, name: str, timezone: Optional[str] = None) -> None: + """Aggiunge o aggiorna un viaggio attivo per un chat_id (sovrascrive se esiste)""" + if timezone is None: + timezone = get_timezone_from_coords(lat, lon) + + state = load_viaggi_state() + state[chat_id] = { + "location": location, + "lat": lat, + "lon": lon, + "name": name, + "timezone": timezone, + "activated": datetime.datetime.now().isoformat() + } + save_viaggi_state(state) + +def remove_viaggio(chat_id: str) -> bool: + """Rimuove un viaggio attivo per un chat_id. Ritorna True se rimosso, False se non esisteva""" + state = load_viaggi_state() + if chat_id in state: + del state[chat_id] + save_viaggi_state(state) + return True + return False + +def get_viaggio(chat_id: str) -> dict: + """Ottiene il viaggio attivo per un chat_id, o None se non esiste""" + state = load_viaggi_state() + return state.get(chat_id) + +# --- HELPER PER LANCIARE SCRIPT ESTERNI --- +def call_meteo_script(args_list): + """Lancia meteo.py e cattura l'output testuale""" + try: + # Esegui: python3 meteo.py --arg1 val1 ... + cmd = ["python3", METEO_SCRIPT] + args_list result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) - return result.stdout.strip() if result.returncode == 0 else f"⚠️ Errore Script:\n{result.stderr}" - except Exception as e: return f"❌ Errore esecuzione: {e}" + return result.stdout if result.returncode == 0 else f"Errore Script: {result.stderr}" + except Exception as e: + return f"Errore esecuzione script: {e}" -# ============================================================================= -# SEZIONE 2: GESTORI COMANDI (HANDLERS) -# ============================================================================= +def call_meteo7_script(args_list): + """Lancia previsione7.py e cattura l'output testuale""" + try: + # Esegui: python3 previsione7.py arg1 arg2 ... + cmd = ["python3", METEO7_SCRIPT] + args_list + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + # previsione7.py invia direttamente a Telegram, quindi l'output potrebbe essere vuoto + # Ritorniamo un messaggio di conferma se lo script è eseguito correttamente + if result.returncode == 0: + return "✅ Report previsione 7 giorni generato e inviato" + else: + return f"⚠️ Errore Script: {result.stderr[:500]}" + except Exception as e: + return f"⚠️ Errore esecuzione script: {e}" -# Decoratore Sicurezza +# --- HANDLERS BOT --- def restricted(func): @wraps(func) async def wrapped(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs): user_id = update.effective_user.id - if user_id not in ALLOWED_IDS: return + if user_id not in ALLOWED_IDS: + return return await func(update, context, *args, **kwargs) return wrapped @@ -140,161 +234,522 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: keyboard = [ [InlineKeyboardButton("🖥️ Core Server", callback_data="menu_core"), InlineKeyboardButton("🔍 Scan LAN", callback_data="menu_lan")], [InlineKeyboardButton("🛡️ Pi-hole", callback_data="menu_pihole"), InlineKeyboardButton("🌐 Rete", callback_data="menu_net")], - [InlineKeyboardButton("🌤️ Meteo Casa", callback_data="req_meteo_home"), InlineKeyboardButton("📹 Camere", callback_data="menu_cams")], - [InlineKeyboardButton("📜 Logs", callback_data="menu_logs")] + [InlineKeyboardButton("🌤️ Meteo Casa", callback_data="req_meteo_home"), InlineKeyboardButton("📜 Logs", callback_data="menu_logs")] ] - text = "🎛 **Loogle Control Center v9.0**\n\n🔹 `/meteo `\n🔹 `/meteo7 ` (7 Giorni)\n🔹 `/cam ` (Snapshot)" - + text = "🎛 **Loogle Control Center v8.1**\nComandi disponibili:\n🔹 `/meteo `\n🔹 `/meteo7 ` (Previsione 7gg)\n🔹 Pulsanti sotto" if update.message: await update.message.reply_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") else: await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") @restricted async def meteo_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - query = " ".join(context.args).strip() - if not query or query.lower() == "casa": - await update.message.reply_text("⏳ **Scarico Meteo Casa...**", parse_mode="Markdown") - report = call_script_text(METEO_SCRIPT, ["--home"]) - else: - await update.message.reply_text(f"🔄 Cerco '{query}'...", parse_mode="Markdown") - report = call_script_text(METEO_SCRIPT, ["--query", query]) + chat_id = str(update.effective_chat.id) + if not context.args: + # Se non ci sono argomenti, controlla se c'è un viaggio attivo + viaggio_attivo = get_viaggio(chat_id) + if viaggio_attivo: + # Invia report per Casa + località viaggio + await update.message.reply_text( + f"🔄 Generazione report meteo per Casa e {viaggio_attivo['name']}...", + parse_mode="Markdown" + ) + + # Report Casa + report_casa = call_meteo_script(["--home"]) + await update.message.reply_text( + f"🏠 **Report Meteo - Casa**\n\n{report_casa}", + parse_mode="Markdown" + ) + + # Report località viaggio + report_viaggio = call_meteo_script([ + "--query", viaggio_attivo["location"], + "--timezone", viaggio_attivo.get("timezone", "Europe/Rome") + ]) + await update.message.reply_text( + f"✈️ **Report Meteo - {viaggio_attivo['name']}**\n\n{report_viaggio}", + parse_mode="Markdown" + ) + else: + # Nessun viaggio attivo: invia report per Casa + await update.message.reply_text("🔄 Generazione report meteo per Casa...", parse_mode="Markdown") + report_casa = call_meteo_script(["--home"]) + await update.message.reply_text( + f"🏠 **Report Meteo - Casa**\n\n{report_casa}", + parse_mode="Markdown" + ) + return + + city = " ".join(context.args) + await update.message.reply_text(f"🔄 Cerco '{city}'...", parse_mode="Markdown") + + # LANCIAMO LO SCRIPT ESTERNO! + report = call_meteo_script(["--query", city]) await update.message.reply_text(report, parse_mode="Markdown") @restricted async def meteo7_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - chat_id = update.effective_chat.id - query = "casa" - if context.args: query = " ".join(context.args) + chat_id = str(update.effective_chat.id) + + if not context.args: + # Se non ci sono argomenti, controlla se c'è un viaggio attivo + viaggio_attivo = get_viaggio(chat_id) + if viaggio_attivo: + # Invia previsione 7gg per Casa + località viaggio + await update.message.reply_text( + f"📡 Calcolo previsione 7gg per Casa e {viaggio_attivo['name']}...", + parse_mode="Markdown" + ) + + # Previsione Casa + subprocess.Popen([ + "python3", METEO7_SCRIPT, + "casa", + "--chat_id", chat_id + ]) + + # Previsione località viaggio + subprocess.Popen([ + "python3", METEO7_SCRIPT, + viaggio_attivo["location"], + "--chat_id", chat_id, + "--timezone", viaggio_attivo.get("timezone", "Europe/Rome") + ]) + + await update.message.reply_text( + f"✅ Previsioni 7 giorni in arrivo per:\n" + f"🏠 Casa\n" + f"✈️ {viaggio_attivo['name']}", + parse_mode="Markdown" + ) + else: + # Nessun viaggio attivo, invia solo Casa + await update.message.reply_text(f"📡 Calcolo previsione 7gg per Casa...", parse_mode="Markdown") + subprocess.Popen(["python3", METEO7_SCRIPT, "casa", "--chat_id", chat_id]) + return + + query = " ".join(context.args) await update.message.reply_text(f"📡 Calcolo previsione 7gg per: {query}...", parse_mode="Markdown") - subprocess.Popen(["python3", METEO7_SCRIPT, query, "--chat_id", str(chat_id)]) + # Lancia in background + subprocess.Popen(["python3", METEO7_SCRIPT, query, "--chat_id", chat_id]) @restricted -async def cam_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not context.args: - # Se non c'è argomento, mostra il menu camere - keyboard = [ - [InlineKeyboardButton("📷 Sala", callback_data="req_cam_sala"), InlineKeyboardButton("📷 Ingresso", callback_data="req_cam_ingresso")], - [InlineKeyboardButton("📷 Taverna", callback_data="req_cam_taverna"), InlineKeyboardButton("📷 Retro", callback_data="req_cam_retro")], - [InlineKeyboardButton("📷 Matrim.", callback_data="req_cam_matrimoniale"), InlineKeyboardButton("📷 Luca", callback_data="req_cam_luca")], - [InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")] - ] - await update.message.reply_text("📹 **Scegli una telecamera:**", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") - return +async def snowradar_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Comando /snowradar: analisi neve in griglia 30km da San Marino""" + chat_id = str(update.effective_chat.id) + + # Costruisci comando base + # --debug: quando chiamato da Telegram, invia solo al chat_id richiedente + # --chat_id: passa il chat_id specifico per inviare il messaggio + cmd = ["python3", SNOW_RADAR_SCRIPT, "--debug", "--chat_id", chat_id] + + # Messaggio di avvio + await update.message.reply_text( + "❄️ **Snow Radar**\n\n" + "Analisi neve in corso... Il report verrà inviato a breve.", + parse_mode="Markdown" + ) + + # Avvia in background + subprocess.Popen(cmd, cwd=SCRIPT_DIR) - cam_name = context.args[0] - await update.message.reply_chat_action(action="upload_photo") +@restricted +async def irrigazione_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Comando /irrigazione: consulente agronomico per gestione irrigazione""" + chat_id = str(update.effective_chat.id) + + # Costruisci comando base + # --force: quando chiamato da Telegram, sempre invia (bypassa logica auto-reporting) + cmd = ["python3", IRRIGATION_SCRIPT, "--telegram", "--chat_id", chat_id, "--force"] + + # Opzioni: --debug, o parametri posizionali per location + if context.args: + args_str = " ".join(context.args).lower() + + # Flag opzionali + if "--debug" in args_str or "debug" in args_str: + cmd.append("--debug") + + # Se ci sono altri argomenti non-flag, assumi siano per location + remaining_args = [a for a in context.args if not a.startswith("--") and a.lower() not in ["debug", "force"]] + if remaining_args: + # Prova a interpretare come location (potrebbero essere coordinate o nome) + location_str = " ".join(remaining_args) + # Se sembra essere coordinate numeriche, usa --lat e --lon + parts = location_str.split() + if len(parts) == 2: + try: + lat = float(parts[0]) + lon = float(parts[1]) + cmd.extend(["--lat", str(lat), "--lon", str(lon)]) + except ValueError: + # Non sono numeri, probabilmente è un nome location + cmd.extend(["--location", location_str]) + else: + cmd.extend(["--location", location_str]) + + # Messaggio di avvio + await update.message.reply_text( + "🌱 **Consulente Irrigazione**\n\n" + "Analisi in corso... Il report verrà inviato a breve.", + parse_mode="Markdown" + ) + + # Esegui in background + subprocess.Popen(cmd) + +@restricted +async def road_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Comando /road: analizza tutti i rischi meteo lungo percorso stradale""" + chat_id = str(update.effective_chat.id) + + if not context.args or len(context.args) < 2: + await update.message.reply_text( + "⚠️ **Uso:** `/road `\n\n" + "Esempio: `/road Bologna Rimini`\n" + "Esempio: `/road \"San Marino\" Rimini`\n" + "Esempio: `/road \"San Marino di Castrozza\" \"San Martino di Castrozza\"`\n" + "Usa virgolette per nomi con spazi multipli.\n\n" + "Analizza tutti i rischi meteo lungo il percorso: ghiaccio, neve, pioggia, rovesci, nebbia, grandine, temporali.", + parse_mode="Markdown" + ) + return + + # Parsing intelligente degli argomenti con supporto virgolette usando shlex + def parse_quoted_args(args): + """Parsa argomenti considerando virgolette per nomi multipli usando shlex.""" + # Unisci tutti gli argomenti in una stringa e usa shlex per parsing corretto + args_str = " ".join(args) + try: + # shlex.split gestisce correttamente virgolette singole e doppie + parsed = shlex.split(args_str, posix=True) + return parsed + except ValueError: + # Fallback: se shlex fallisce, usa metodo semplice + result = [] + current = [] + in_quotes = False + quote_char = None + + for arg in args: + # Se inizia con virgolette, entra in modalità quote + if arg.startswith('"') or arg.startswith("'"): + in_quotes = True + quote_char = arg[0] + arg_clean = arg[1:] # Rimuovi virgolette iniziali + current = [arg_clean] + # Se finisce con virgolette, esci dalla modalità quote + elif arg.endswith('"') or arg.endswith("'"): + if in_quotes and (arg.endswith(quote_char) if quote_char else True): + arg_clean = arg[:-1] # Rimuovi virgolette finali + current.append(arg_clean) + result.append(" ".join(current)) + current = [] + in_quotes = False + quote_char = None + else: + result.append(arg) + # Se siamo dentro le virgolette, aggiungi all'argomento corrente + elif in_quotes: + current.append(arg) + # Altrimenti, argomento normale + else: + result.append(arg) + + # Se rimangono argomenti non chiusi, uniscili + if current: + result.append(" ".join(current)) + + return result + + parsed_args = parse_quoted_args(context.args) + + if len(parsed_args) < 2: + await update.message.reply_text( + "⚠️ Errore: servono almeno 2 località.\n" + "Usa virgolette per nomi multipli: `/road \"San Marino\" Rimini`", + parse_mode="Markdown" + ) + return + + city1 = parsed_args[0] + city2 = parsed_args[1] + + await update.message.reply_text( + f"🔄 Analisi rischi meteo stradali: {city1} → {city2}...", + parse_mode="Markdown" + ) try: - # Timeout 15s per RTSP - result = subprocess.run(["python3", CAM_SCRIPT, cam_name], capture_output=True, text=True, timeout=15) - output = result.stdout.strip() + # Importa funzioni da road_weather.py + import sys + sys.path.insert(0, SCRIPT_DIR) + from road_weather import ( + analyze_route_weather_risks, + format_route_weather_report, + generate_route_weather_map, + PANDAS_AVAILABLE + ) - if output.startswith("OK:"): - img_path = output.split(":", 1)[1] - await update.message.reply_photo(photo=open(img_path, 'rb'), caption=f"📷 **{cam_name.capitalize()}**") - elif output.startswith("ERR:"): - await update.message.reply_text(output.split(":", 1)[1]) - else: - await update.message.reply_text(f"❌ Risposta imprevista dallo script: {output}") + # Verifica disponibilità pandas + if not PANDAS_AVAILABLE: + await update.message.reply_text( + "❌ **Errore: dipendenze mancanti**\n\n" + "`pandas` e `numpy` sono richiesti per l'analisi avanzata.\n\n" + "**Installazione nel container Docker:**\n" + "```bash\n" + "docker exec -it pip install --break-system-packages pandas numpy\n" + "```\n\n" + "Oppure aggiungi al Dockerfile:\n" + "```dockerfile\n" + "RUN pip install --break-system-packages pandas numpy\n" + "```", + parse_mode="Markdown" + ) + return + + # Analizza percorso (auto-detect del miglior modello disponibile per la zona) + df = analyze_route_weather_risks(city1, city2, model_slug=None) + + if df is None or df.empty: + await update.message.reply_text( + f"❌ Errore: Impossibile ottenere dati per il percorso {city1} → {city2}.\n" + f"Verifica che i nomi delle località siano corretti.", + parse_mode="Markdown" + ) + return + + # Formatta e invia report (compatto, sempre in un singolo messaggio) + report = format_route_weather_report(df, city1, city2) + await update.message.reply_text(report, parse_mode="Markdown") + + # Genera e invia mappa del percorso (sempre, dopo il messaggio testuale) + try: + import tempfile + map_file = tempfile.NamedTemporaryFile(delete=False, suffix='.png', dir=SCRIPT_DIR) + map_path = map_file.name + map_file.close() + map_generated = generate_route_weather_map(df, city1, city2, map_path) + if map_generated: + now = datetime.datetime.now() + caption = ( + f"🛣️ Mappa Rischi Meteo Stradali\n" + f"📍 {city1} → {city2}\n" + f"🕒 {now.strftime('%d/%m/%Y %H:%M')}" + ) + + # Invia foto via Telegram + with open(map_path, 'rb') as photo_file: + await update.message.reply_photo( + photo=photo_file, + caption=caption, + parse_mode="HTML" + ) + + # Pulisci file temporaneo + try: + os.unlink(map_path) + except: + pass + except Exception as map_error: + logger.warning(f"Errore generazione mappa road: {map_error}") + # Non bloccare l'esecuzione se la mappa fallisce + + except ImportError as e: + # Gestione specifica per ImportError con messaggio dettagliato + error_msg = str(e) + await update.message.reply_text( + f"❌ **Errore: dipendenze mancanti**\n\n{error_msg}", + parse_mode="Markdown" + ) except Exception as e: - await update.message.reply_text(f"❌ Errore critico: {e}") + logger.error(f"Errore road_command: {e}", exc_info=True) + await update.message.reply_text( + f"❌ Errore durante l'analisi: {str(e)}", + parse_mode="Markdown" + ) + +@restricted +async def meteo_viaggio_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Comando /meteo_viaggio: attiva/disattiva monitoraggio meteo per viaggio""" + chat_id = str(update.effective_chat.id) + + # Gestione comando "fine" + if context.args and len(context.args) == 1 and context.args[0].lower() in ["fine", "stop", "termina"]: + viaggio_rimosso = remove_viaggio(chat_id) + if viaggio_rimosso: + await update.message.reply_text( + "🎉 **Viaggio terminato!**\n\n" + "✅ Il monitoraggio meteo personalizzato è stato disattivato.\n" + "🏠 Ora riceverai solo gli avvisi per Casa.\n\n" + "Bentornato a Casa! 👋", + parse_mode="Markdown" + ) + else: + await update.message.reply_text( + "ℹ️ Nessun viaggio attivo da terminare.\n" + "Usa `/meteo_viaggio ` per attivare un nuovo viaggio.", + parse_mode="Markdown" + ) + return + + # Gestione attivazione viaggio + if not context.args: + viaggio_attivo = get_viaggio(chat_id) + if viaggio_attivo: + await update.message.reply_text( + f"ℹ️ **Viaggio attivo**\n\n" + f"📍 **{viaggio_attivo['name']}**\n" + f"Attivato: {viaggio_attivo.get('activated', 'N/A')}\n\n" + f"Usa `/meteo_viaggio fine` per terminare.", + parse_mode="Markdown" + ) + else: + await update.message.reply_text( + "⚠️ Usa: `/meteo_viaggio `\n\n" + "Esempio: `/meteo_viaggio Roma`\n\n" + "Per terminare: `/meteo_viaggio fine`", + parse_mode="Markdown" + ) + return + + location = " ".join(context.args) + + await update.message.reply_text(f"🔄 Attivazione monitoraggio viaggio per: **{location}**\n⏳ Elaborazione in corso...", parse_mode="Markdown") + + # Ottieni coordinate dalla localizzazione (usa meteo.py per geocoding) + try: + # Importa funzione get_coordinates da meteo.py + import sys + sys.path.insert(0, SCRIPT_DIR) + from meteo import get_coordinates + + coords = get_coordinates(location) + if not coords: + await update.message.reply_text(f"❌ Località '{location}' non trovata. Verifica il nome e riprova.", parse_mode="Markdown") + return + + lat, lon, name, cc = coords + + # Ottieni timezone per questa localizzazione + timezone = get_timezone_from_coords(lat, lon) + + # Conferma riconoscimento località + await update.message.reply_text( + f"✅ **Località riconosciuta!**\n\n" + f"📍 **{name}**\n" + f"🌍 Coordinate: {lat:.4f}, {lon:.4f}\n" + f"🕐 Fuso orario: {timezone}\n\n" + f"⏳ Generazione report meteo in corso...", + parse_mode="Markdown" + ) + + # Salva viaggio attivo (sovrascrive se esiste già) + add_viaggio(chat_id, location, lat, lon, name, timezone) + + # Esegui meteo.py in modo sincrono e invia output come conferma + try: + report_meteo = call_meteo_script([ + "--query", location, + "--timezone", timezone + ]) + + if report_meteo and not report_meteo.startswith("Errore") and not report_meteo.startswith("⚠️"): + # Invia report meteo come conferma + await update.message.reply_text( + f"📊 **Report Meteo - {name}**\n\n{report_meteo}", + parse_mode="Markdown" + ) + else: + await update.message.reply_text( + f"⚠️ Errore nella generazione del report meteo:\n{report_meteo}", + parse_mode="Markdown" + ) + except Exception as e: + logger.exception(f"Errore esecuzione meteo.py: {e}") + await update.message.reply_text( + f"⚠️ Errore durante la generazione del report meteo: {str(e)}", + parse_mode="Markdown" + ) + + # Esegui previsione7.py (invia direttamente a Telegram) + try: + # Nota: previsione7.py invia direttamente a Telegram, quindi eseguiamo lo script + result_meteo7 = subprocess.run( + ["python3", METEO7_SCRIPT, location, "--chat_id", chat_id, "--timezone", timezone], + capture_output=True, + text=True, + timeout=60 + ) + + if result_meteo7.returncode == 0: + await update.message.reply_text( + f"✅ **Monitoraggio viaggio attivato!**\n\n" + f"📨 **Report inviati:**\n" + f"• Report meteo dettagliato ✓\n" + f"• Previsione 7 giorni ✓\n\n" + f"🎯 **Monitoraggio attivo per:**\n" + f"📍 {name}\n" + f"🕐 Fuso orario: {timezone}\n\n" + f"📬 **Riceverai automaticamente:**\n" + f"• Report meteo alle 8:00 AM (ora locale)\n" + f"• Previsione 7 giorni alle 7:30 AM (ora locale)\n" + f"• Avvisi meteo severi in tempo reale\n\n" + f"Per terminare: `/meteo_viaggio fine`", + parse_mode="Markdown" + ) + else: + await update.message.reply_text( + f"✅ Report meteo inviato!\n" + f"⚠️ Errore nella previsione 7 giorni:\n{result_meteo7.stderr[:500]}\n\n" + f"🎯 **Monitoraggio attivo per:** {name}", + parse_mode="Markdown" + ) + except Exception as e: + logger.exception(f"Errore esecuzione previsione7.py: {e}") + await update.message.reply_text( + f"✅ Report meteo inviato!\n" + f"⚠️ Errore nella previsione 7 giorni: {str(e)}\n\n" + f"🎯 **Monitoraggio attivo per:** {name}", + parse_mode="Markdown" + ) + + # Lancia severe_weather.py in background (non blocca la risposta) + subprocess.Popen([ + "python3", SEVERE_SCRIPT, + "--lat", str(lat), + "--lon", str(lon), + "--location", name, + "--timezone", timezone, + "--chat_id", chat_id + ]) + except Exception as e: + logger.exception("Errore in meteo_viaggio: %s", e) + await update.message.reply_text(f"❌ Errore durante l'elaborazione: {str(e)}", parse_mode="Markdown") async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None: - # Meteo automatico alle 8:00 - report = call_script_text(METEO_SCRIPT, ["--home"]) + # LANCIAMO LO SCRIPT ESTERNO PER CASA + report = call_meteo_script(["--home"]) for uid in ALLOWED_IDS: try: await context.bot.send_message(chat_id=uid, text=report, parse_mode="Markdown") except: pass -@restricted -async def clip_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not context.args: - await update.message.reply_text("⚠️ Usa: `/clip ` (es. /clip sala)", parse_mode="Markdown") - return - - cam_name = context.args[0] - await update.message.reply_chat_action(action="upload_video") # Icona "sta inviando video..." - await update.message.reply_text(f"🎥 **Registro 10s da {cam_name}...**", parse_mode="Markdown") - - try: - # Lancia lo script con flag --video - result = subprocess.run(["python3", CAM_SCRIPT, cam_name, "--video"], capture_output=True, text=True, timeout=20) - output = result.stdout.strip() - - if output.startswith("OK:"): - vid_path = output.split(":", 1)[1] - await update.message.reply_video(video=open(vid_path, 'rb'), caption=f"🎥 **Clip: {cam_name.capitalize()}**") - elif output.startswith("ERR:"): - await update.message.reply_text(output.split(":", 1)[1]) - - except Exception as e: - await update.message.reply_text(f"❌ Errore critico: {e}") - @restricted async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: query = update.callback_query - await query.answer() # Risposta immediata per togliere il loading dal pulsante + await query.answer() data = query.data - # --- NAVIGAZIONE MENU --- - if data == "main_menu": - await start(update, context) + if data == "main_menu": await start(update, context) - # --- SEZIONE METEO --- elif data == "req_meteo_home": await query.edit_message_text("⏳ **Scaricamento Meteo Casa...**", parse_mode="Markdown") - report = call_script_text(METEO_SCRIPT, ["--home"]) + # LANCIAMO LO SCRIPT ESTERNO + report = call_meteo_script(["--home"]) keyboard = [[InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]] await query.edit_message_text(report, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") - # --- SEZIONE CAMERE --- - elif data == "menu_cams": - keyboard = [ - [InlineKeyboardButton("📷 Sala", callback_data="req_cam_sala"), InlineKeyboardButton("📷 Ingresso", callback_data="req_cam_ingresso")], - [InlineKeyboardButton("📷 Taverna", callback_data="req_cam_taverna"), InlineKeyboardButton("📷 Retro", callback_data="req_cam_retro")], - [InlineKeyboardButton("📷 Matrim.", callback_data="req_cam_matrimoniale"), InlineKeyboardButton("📷 Luca", callback_data="req_cam_luca")], - [InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")] - ] - await query.edit_message_text("📹 **Centrale Video**\nSeleziona una telecamera:", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") - - elif data.startswith("req_cam_"): - cam_name = data.replace("req_cam_", "") - # Non editiamo il messaggio, inviamo una nuova foto sotto - try: - result = subprocess.run(["python3", CAM_SCRIPT, cam_name], capture_output=True, text=True, timeout=15) - output = result.stdout.strip() - - if output.startswith("OK:"): - img_path = output.split(":", 1)[1] - await context.bot.send_photo(chat_id=update.effective_chat.id, photo=open(img_path, 'rb'), caption=f"📷 **{cam_name.capitalize()}**") - elif output.startswith("ERR:"): - await context.bot.send_message(chat_id=update.effective_chat.id, text=output.split(":", 1)[1]) - except Exception as e: - await context.bot.send_message(chat_id=update.effective_chat.id, text=f"❌ Errore richiesta cam: {e}") - - elif data.startswith("req_vid_"): - cam_name = data.replace("req_vid_", "") - await query.answer("🎥 Registrazione in corso (10s)...") - # Inviamo un messaggio di attesa perché ci mette un po' - msg = await context.bot.send_message(chat_id=update.effective_chat.id, text=f"⏳ Registro clip: {cam_name}...") - - try: - result = subprocess.run(["python3", CAM_SCRIPT, cam_name, "--video"], capture_output=True, text=True, timeout=20) - output = result.stdout.strip() - - # Cancelliamo il messaggio di attesa - await context.bot.delete_message(chat_id=update.effective_chat.id, message_id=msg.message_id) - - if output.startswith("OK:"): - vid_path = output.split(":", 1)[1] - await context.bot.send_video(chat_id=update.effective_chat.id, video=open(vid_path, 'rb'), caption=f"🎥 **Clip: {cam_name.capitalize()}**") - elif output.startswith("ERR:"): - await context.bot.send_message(chat_id=update.effective_chat.id, text=output.split(":", 1)[1]) - except Exception as e: - await context.bot.send_message(chat_id=update.effective_chat.id, text=f"❌ Errore: {e}") - - # --- SEZIONE SISTEMA CORE --- elif data == "menu_core": keyboard = [] for i, dev in enumerate(CORE_DEVICES): keyboard.append([InlineKeyboardButton(dev['name'], callback_data=f"stat_{i}")]) @@ -313,7 +768,6 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> await query.edit_message_text(f"⏳ Controllo {dev['name']}...", parse_mode="Markdown") await query.edit_message_text(f"🔹 **{dev['name']}**\n\n{get_device_stats(dev)}", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_core")]]), parse_mode="Markdown") - # --- SEZIONE LAN --- elif data == "menu_lan": await query.edit_message_text("⏳ **Scansione LAN...**", parse_mode="Markdown") report = "🔍 **DIAGNOSTICA LAN**\n\n" @@ -337,7 +791,6 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> res = run_cmd("reboot", dev['ip'], "admin") await query.edit_message_text(f"⚡ Inviato a {dev['name']}...\n\n`{res}`", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_reboot")]]), parse_mode="Markdown") - # --- SEZIONE PI-HOLE --- elif data == "menu_pihole": status_raw = run_cmd("sudo pihole status", MASTER_IP, SSH_USER) icon = "✅" if "Enabled" in status_raw or "enabled" in status_raw else "🔴" @@ -351,18 +804,16 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> elif "restart" in data: run_cmd("sudo systemctl restart pihole-FTL", MASTER_IP, SSH_USER) await query.edit_message_text("✅ Comando inviato.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_pihole")]]), parse_mode="Markdown") - # --- SEZIONE RETE --- elif data == "menu_net": ip = run_cmd("curl -s ifconfig.me") keyboard = [[InlineKeyboardButton("🚀 Speedtest", callback_data="net_speedtest")], [InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]] await query.edit_message_text(f"🌐 **Rete**\n🌍 IP: `{ip}`", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") elif data == "net_speedtest": - await query.edit_message_text("🚀 **Speedtest... (attendi 40s)**", parse_mode="Markdown") + await query.edit_message_text("🚀 **Speedtest...**", parse_mode="Markdown") res = run_speedtest() await query.edit_message_text(f"🚀 **Risultato:**\n\n`{res}`", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_net")]]), parse_mode="Markdown") - # --- SEZIONE LOGS --- elif data == "menu_logs": keyboard = [[InlineKeyboardButton("🐶 Watchdog", callback_data="log_wd"), InlineKeyboardButton("💾 Backup", callback_data="log_bk")], [InlineKeyboardButton("🔄 NPM Sync", callback_data="log_npm"), InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]] await query.edit_message_text("📜 **Logs**", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") @@ -372,25 +823,24 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> log_c = read_log_file(paths[data]) await query.edit_message_text(f"📜 **Log:**\n\n`{log_c}`", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_logs")]]), parse_mode="Markdown") + def main(): - logger.info("Avvio Loogle Bot v9.0 (Modular)...") + logger.info("Avvio Loogle Bot v8.1 (Modulare)...") application = Application.builder().token(BOT_TOKEN).build() - # Registrazione Comandi application.add_handler(CommandHandler("start", start)) application.add_handler(CommandHandler("meteo", meteo_command)) application.add_handler(CommandHandler("meteo7", meteo7_command)) - application.add_handler(CommandHandler("cam", cam_command)) - application.add_handler(CommandHandler("clip", clip_command)) - - # Registrazione Callback Menu + application.add_handler(CommandHandler("meteo_viaggio", meteo_viaggio_command)) + application.add_handler(CommandHandler("road", road_command)) + application.add_handler(CommandHandler("irrigazione", irrigazione_command)) + application.add_handler(CommandHandler("snowradar", snowradar_command)) application.add_handler(CallbackQueryHandler(button_handler)) - # Scheduler job_queue = application.job_queue job_queue.run_daily(scheduled_morning_report, time=datetime.time(hour=8, minute=0, tzinfo=TZINFO), days=(0, 1, 2, 3, 4, 5, 6)) application.run_polling() if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/services/telegram-bot/check_ghiaccio.py b/services/telegram-bot/check_ghiaccio.py index 1dc120f..ef26461 100644 --- a/services/telegram-bot/check_ghiaccio.py +++ b/services/telegram-bot/check_ghiaccio.py @@ -1,8 +1,22 @@ +import argparse import requests import datetime import os import sys import json +import time +from typing import Dict, List, Tuple, Optional + +# Import opzionale di pandas e numpy per analisi avanzata +try: + import pandas as pd + import numpy as np + PANDAS_AVAILABLE = True +except ImportError: + PANDAS_AVAILABLE = False + # Placeholder per evitare errori di riferimento + pd = None + np = None # --- TELEGRAM CONFIG --- ADMIN_CHAT_ID = "64463169" @@ -11,31 +25,45 @@ TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"] # FILES TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token") TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token" -STATE_FILE = os.path.expanduser("~/.ghiaccio_multimodel_state.json") +# File di stato salvato nella cartella del progetto +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +STATE_FILE = os.path.join(SCRIPT_DIR, ".ghiaccio_multimodel_state.json") # --- CONFIGURAZIONE GRIGLIA --- +# Griglia più fitta per maggiore precisione di rilevazione GRID_POINTS = [ - {"id": "G01", "name": "Nord-Est (Dogana/Falciano)", "lat": 43.9850, "lon": 12.4950}, - {"id": "G02", "name": "Nord (Serravalle/Galazzano)", "lat": 43.9680, "lon": 12.4780}, - {"id": "G03", "name": "Zona Ind. Ovest (Gualdicciolo)", "lat": 43.9480, "lon": 12.4180}, - {"id": "G04", "name": "Ovest (Chiesanuova/Confine)", "lat": 43.9150, "lon": 12.4220}, - {"id": "G05", "name": "Centro-Est (Domagnano/Valdragone)","lat": 43.9480, "lon": 12.4650}, - {"id": "G06", "name": "Centro-Ovest (Acquaviva/Ventoso)", "lat": 43.9420, "lon": 12.4350}, - {"id": "G07", "name": "Monte Titano (Città/Murata)", "lat": 43.9300, "lon": 12.4480}, - {"id": "G08", "name": "Sotto-Monte (Borgo/Cailungo)", "lat": 43.9550, "lon": 12.4500}, - {"id": "G09", "name": "Valle Est (Faetano/Corianino)", "lat": 43.9280, "lon": 12.4980}, - {"id": "G10", "name": "Sud-Ovest (Fiorentino)", "lat": 43.9080, "lon": 12.4580}, - {"id": "G11", "name": "Sud-Est (Montegiardino)", "lat": 43.9020, "lon": 12.4820}, - {"id": "G12", "name": "Estremo Sud (Cerbaiola)", "lat": 43.8880, "lon": 12.4650} + {"id": "G01", "name": "Dogana", "lat": 43.9850, "lon": 12.4950}, + {"id": "G02", "name": "Serravalle", "lat": 43.9680, "lon": 12.4780}, + {"id": "G03", "name": "Galazzano", "lat": 43.9650, "lon": 12.4650}, + {"id": "G04", "name": "Acquaviva", "lat": 43.9480, "lon": 12.4180}, + {"id": "G05", "name": "Chiesanuova", "lat": 43.9150, "lon": 12.4220}, + {"id": "G06", "name": "Domagnano", "lat": 43.9480, "lon": 12.4650}, + {"id": "G07", "name": "Centro Storico", "lat": 43.9350, "lon": 12.4450}, + {"id": "G08", "name": "Fonte dell'Ovo", "lat": 43.9300, "lon": 12.4480}, + {"id": "G09", "name": "Cailungo", "lat": 43.9550, "lon": 12.4500}, + {"id": "G10", "name": "Faetano", "lat": 43.9280, "lon": 12.4980}, + {"id": "G11", "name": "Fiorentino", "lat": 43.9080, "lon": 12.4580}, + {"id": "G12", "name": "Cerbaiola", "lat": 43.8977, "lon": 12.4704}, + {"id": "G13", "name": "Confine Chiesanuova", "lat": 43.9050, "lon": 12.4100}, + {"id": "G14", "name": "Torraccia", "lat": 43.9544, "lon": 12.5080}, + {"id": "G15", "name": "Piandavello", "lat": 43.9501, "lon": 12.4836}, + {"id": "G16", "name": "Ponte Mellini", "lat": 43.9668, "lon": 12.5006}, + {"id": "G17", "name": "Murata", "lat": 43.9184, "lon": 12.4521}, + {"id": "G18", "name": "Borgo Maggiore", "lat": 43.9379, "lon": 12.4488}, + {"id": "G19", "name": "Santa Mustiola", "lat": 43.9344, "lon": 12.4357}, + {"id": "G20", "name": "Montegiardino", "lat": 43.9097, "lon": 12.4855} ] # Modelli da consultare (Nome Visualizzato : Slug API) -# 'icon_eu': Ottimo generale | 'arome_medium': Alta risoluzione orografica +# 'icon_eu': Ottimo generale | 'meteofrance_seamless': AROME Seamless (alta risoluzione, supporta minutely_15) MODELS_TO_CHECK = { "ICON": "icon_eu", - "AROME": "arome_medium" + "AROME": "meteofrance_seamless" # Aggiornato da arome_medium per avere dati più recenti e minutely_15 } +# Modello preferito per analisi avanzata ghiaccio (ICON Italia fornisce soil_temperature_0cm) +ICON_ITALIA_MODEL = "italia_meteo_arpae_icon_2i" + def get_bot_token(): paths = [TOKEN_FILE_HOME, TOKEN_FILE_ETC] for path in paths: @@ -48,48 +76,824 @@ def get_bot_token(): print("ERRORE: Token non trovato.") sys.exit(1) + +def save_current_state(state): + try: + # Aggiungi timestamp corrente per tracciare quando è stato salvato lo stato + state_with_meta = { + "points": state, + "last_update": datetime.datetime.now().isoformat() + } + with open(STATE_FILE, 'w') as f: + json.dump(state_with_meta, f) + except Exception as e: + print(f"Errore salvataggio stato: {e}") + def load_previous_state(): if not os.path.exists(STATE_FILE): return {} try: with open(STATE_FILE, 'r') as f: - return json.load(f) + data = json.load(f) + # Supporta sia il formato vecchio (solo dict di punti) che nuovo (con metadata) + if isinstance(data, dict) and "points" in data: + return data["points"] + else: + return data except Exception: return {} -def save_current_state(state): - try: - with open(STATE_FILE, 'w') as f: - json.dump(state, f) - except Exception as e: - print(f"Errore salvataggio stato: {e}") +def is_improvement_report_allowed() -> bool: + """ + Verifica se è consentito inviare un report di miglioramento. + I report di miglioramento possono essere inviati solo alle ore 7:00, 15:00, 23:00. + + Returns: + True se l'ora corrente è 7, 15 o 23, False altrimenti + """ + current_hour = datetime.datetime.now().hour + allowed_hours = [7, 15, 23] + return current_hour in allowed_hours -def get_weather_data(lat, lon, model_slug): +def get_weather_data(lat, lon, model_slug, include_past_days=1): + """ + Recupera dati meteo. Per AROME Seamless, include anche minutely_15 e parametri isobarici + per l'algoritmo FMI_CLIM di rilevamento gelicidio. + + Args: + lat, lon: Coordinate geografiche + model_slug: Slug del modello meteo + include_past_days: Numero di giorni passati da includere (default: 1 per 24h precedenti) + """ url = "https://api.open-meteo.com/v1/forecast" + + # Parametri base per tutti i modelli (inclusi cloud_cover e windspeed_10m per analisi avanzata) + # Aggiunto snowfall e snow_depth per verificare presenza neve (importante per logica "allerta rientrata") + # Aggiunto rain e weathercode per analisi precipitazioni dettagliata + hourly_params = "temperature_2m,dew_point_2m,precipitation,rain,showers,soil_temperature_0cm,relative_humidity_2m,surface_pressure,cloud_cover,windspeed_10m,snowfall,snow_depth,weathercode" + + # Per AROME Seamless, aggiungi parametri isobarici per algoritmo FMI_CLIM + if model_slug == "meteofrance_seamless": + hourly_params += ",temperature_925hPa,relative_humidity_925hPa,temperature_850hPa,relative_humidity_850hPa,temperature_700hPa,relative_humidity_700hPa" + params = { "latitude": lat, "longitude": lon, - "hourly": "temperature_2m,dew_point_2m,precipitation,soil_temperature_0cm,relative_humidity_2m", + "hourly": hourly_params, "models": model_slug, "timezone": "Europe/San_Marino", - "past_days": 0, - "forecast_days": 1 + "past_days": include_past_days, # Include 24h precedenti per analisi storica + "forecast_days": 2 # Aumentato a 2 giorni per analisi finestra temporale } + + # Aggiungi minutely_15 per AROME Seamless (risoluzione 15 minuti) + if model_slug == "meteofrance_seamless": + params["minutely_15"] = "temperature_2m,precipitation,rain,snowfall" + try: - response = requests.get(url, params=params, timeout=10) + response = requests.get(url, params=params, timeout=15) response.raise_for_status() return response.json() - except Exception: + except Exception as e: + print(f"Errore richiesta API per {model_slug}: {e}") return None -def analyze_risk(weather_data): - """Analizza i dati di un singolo modello e ritorna rischio e dettagli.""" +# ============================================================================= +# Parametri di Calibrazione FMI_CLIM (Kämäräinen et al. 2017, Tabella 1) +# Modificati per ridurre falsi positivi mantenendo alta sensibilità +# ============================================================================= +H_COLD_THR = 69.0 # hPa (Profondità minima strato freddo) +T_COLD_THR = 0.09 # °C (Temp max al suolo considerata 'fredda') - mantenuta bassa per evitare falsi negativi +T_MELT_THR = 0.0 # °C (Temp min per considerare uno strato 'in fusione') - aumentata da -0.64°C a 0.0°C per ridurre falsi positivi mantenendo sensibilità +RH_MELT_THR = 89.0 # % (Umidità relativa minima nello strato di fusione) +PR_THR_6H = 0.39 # mm/6h +PR_THR_1H = 0.1 # mm/h - aumentata da 0.065 per richiedere precipitazione più significativa +# Differenza minima temperatura tra strato di fusione e suolo (per ridurre falsi positivi) +T_MELT_SURFACE_DIFF = 1.0 # °C - lo strato di fusione deve essere almeno 1°C più caldo del suolo (bilanciato tra riduzione falsi positivi e mantenimento sensibilità) + +# Livelli di pressione analizzati dall'algoritmo +PRESSURE_LEVELS = [925, 850, 700] + + +def detect_freezing_rain_fmi(hourly_data, idx, icon_hourly_data=None): + """ + Implementa l'algoritmo FMI_CLIM per rilevare gelicidio (Freezing Rain - FZRA). + Basato su Kämäräinen et al. (2017). + + Args: + hourly_data: Dati hourly del modello principale (AROME Seamless) + idx: Indice dell'ora corrente + icon_hourly_data: Dati hourly di ICON (opzionale, per ottenere soil_temperature_0cm se AROME non lo fornisce) + + Returns: (is_fzra: bool, details: str) + """ + try: + # Pre-condizioni: estrazione dati superficie + t_2m = hourly_data.get("temperature_2m", [None])[idx] if idx < len(hourly_data.get("temperature_2m", [])) else None + t_soil = hourly_data.get("soil_temperature_0cm", [None])[idx] if idx < len(hourly_data.get("soil_temperature_0cm", [])) else None + + # Se AROME non fornisce soil_temperature_0cm (None o NaN), prova a ottenerlo da ICON + # AROME Seamless spesso non fornisce questo parametro, quindi usiamo ICON come fallback + t_soil_from_icon = False + if t_soil is None and icon_hourly_data: + icon_times = icon_hourly_data.get("time", []) + if icon_times and len(icon_times) > idx: + icon_t_soil = icon_hourly_data.get("soil_temperature_0cm", [None])[idx] if idx < len(icon_hourly_data.get("soil_temperature_0cm", [])) else None + if icon_t_soil is not None: + t_soil = icon_t_soil + t_soil_from_icon = True + + precip = hourly_data.get("precipitation", [None])[idx] if idx < len(hourly_data.get("precipitation", [])) else None + p_surf = hourly_data.get("surface_pressure", [None])[idx] if idx < len(hourly_data.get("surface_pressure", [])) else None + + # Verifica disponibilità parametri isobarici + has_isobaric = all( + f"temperature_{pl}hPa" in hourly_data and + f"relative_humidity_{pl}hPa" in hourly_data + for pl in PRESSURE_LEVELS + ) + + if not has_isobaric or precip is None or p_surf is None: + return False, "" + + # --- Pre-condizioni (Fig. 2 Pseudo-code) --- + + # A. C'è precipitazione sufficiente? + if precip <= PR_THR_1H: + return False, "" + + # B. La temperatura al suolo è sufficientemente bassa? + # PRIORITÀ: usa soil_temperature_0cm se disponibile (più accurato per gelicidio) + # Fallback a t_2m se soil_temperature non disponibile + t_surface = t_soil if t_soil is not None else t_2m + if t_surface is None: + return False, "" + + if t_surface > T_COLD_THR: + return False, "" + + # C. C'è almeno un livello in quota più caldo della soglia di fusione? + t_925 = hourly_data.get("temperature_925hPa", [None])[idx] if idx < len(hourly_data.get("temperature_925hPa", [])) else None + t_850 = hourly_data.get("temperature_850hPa", [None])[idx] if idx < len(hourly_data.get("temperature_850hPa", [])) else None + t_700 = hourly_data.get("temperature_700hPa", [None])[idx] if idx < len(hourly_data.get("temperature_700hPa", [])) else None + + if any(t is None for t in [t_925, t_850, t_700]): + return False, "" + + t_max_aloft = max(t_925, t_850, t_700) + if t_max_aloft <= T_MELT_THR: + return False, "" + + # --- Logica Strato Freddo e Strato Caldo --- + + # Calcolo la pressione limite sopra la quale cercare lo strato di fusione + p_threshold = p_surf - H_COLD_THR + + # Trova il livello di pressione standard più vicino che si trova SOPRA la soglia + p_cold = None + for pl in sorted(PRESSURE_LEVELS, reverse=True): # 925, 850, 700 + if pl <= p_threshold: + p_cold = pl + break + + if p_cold is None: + # La superficie è troppo alta, non c'è spazio per i 69 hPa di strato freddo + return False, "" + + # Verifica esistenza "Moist Melt Layer" (Strato umido di fusione) + moist_melt_layer_exists = False + + for pl in PRESSURE_LEVELS: + if pl <= p_cold: # Verifica solo i livelli sopra lo strato freddo + t_level = hourly_data.get(f"temperature_{pl}hPa", [None])[idx] + rh_level = hourly_data.get(f"relative_humidity_{pl}hPa", [None])[idx] + + if t_level is None or rh_level is None: + continue + + # Condizione di fusione: T alta e RH alta + # Aggiunta condizione: lo strato di fusione deve essere significativamente più caldo del suolo + # (per ridurre falsi positivi quando la differenza è minima) + if t_level > T_MELT_THR and rh_level >= RH_MELT_THR: + # Verifica che ci sia una differenza significativa tra strato di fusione e superficie + if t_level >= t_surface + T_MELT_SURFACE_DIFF: + moist_melt_layer_exists = True + break + + if moist_melt_layer_exists: + # Mostra temperatura suolo se disponibile, altrimenti temperatura 2m + # Se t_soil proviene da ICON (non da AROME), lo indichiamo nel messaggio + temp_label = "T_suolo" + temp_value = t_soil if t_soil is not None else t_2m + if t_soil is None: + temp_label = "T2m" + elif t_soil_from_icon: + # t_soil proviene da ICON, non da AROME + temp_label = "T_suolo(ICON)" + details = f"{temp_label} {temp_value:.1f}°C, Precip {precip:.2f}mm/h, P_surf {p_surf:.0f}hPa" + return True, details + + return False, "" + + except (KeyError, TypeError, IndexError) as e: + return False, "" + + +def analyze_past_24h_conditions(weather_data: Dict) -> Dict: + """ + Analizza le condizioni delle 24 ore precedenti per valutare persistenza ghiaccio. + + Returns: + Dict con: + - has_precipitation: bool (se c'è stata pioggia/neve/shower nelle 24h) + - precipitation_types: List[str] (tipi di precipitazione: rain, snowfall, showers) + - total_rain_mm: float + - total_snowfall_cm: float + - total_showers_mm: float + - min_temp_2m: float (temperatura minima) + - min_soil_temp: float (temperatura suolo minima) + - hours_below_zero: int (ore con T<0°C) + - hours_below_zero_soil: int (ore con T_suolo<0°C) + - precipitation_with_freeze: bool (precipitazioni con T<0°C) + - ice_formation_likely: bool (probabile formazione ghiaccio) + - ice_melting_likely: bool (probabile scioglimento ghiaccio) + - history: List[Dict] (storico orario per analisi dinamica) + """ + if not weather_data or "hourly" not in weather_data: + return {} + + hourly = weather_data["hourly"] + times = hourly.get("time", []) + + if not times: + return {} + + # Converti times in datetime + try: + if PANDAS_AVAILABLE: + timestamps = pd.to_datetime(times) + else: + timestamps = [] + for t in times: + try: + if isinstance(t, str): + timestamps.append(datetime.datetime.fromisoformat(t.replace('Z', '+00:00'))) + else: + timestamps.append(t) + except: + continue + except Exception as e: + print(f"Errore conversione timestamp: {e}") + return {} + + now = datetime.datetime.now() + # Filtra solo le 24h precedenti (ora corrente - 24h) + past_24h_start = now - datetime.timedelta(hours=24) + + # Estrai dati + temp_2m = hourly.get("temperature_2m", []) + soil_temp = hourly.get("soil_temperature_0cm", []) + precipitation = hourly.get("precipitation", []) + rain = hourly.get("rain", []) + snowfall = hourly.get("snowfall", []) + showers = hourly.get("showers", []) + weathercode = hourly.get("weathercode", []) + + # Analizza solo le 24h precedenti + history = [] + total_rain = 0.0 + total_snowfall = 0.0 + total_showers = 0.0 + min_temp_2m = None + min_soil_temp = None + hours_below_zero = 0 + hours_below_zero_soil = 0 + precipitation_types = set() + precipitation_with_freeze = False + + for i, ts in enumerate(timestamps): + # Converti timestamp se necessario + if isinstance(ts, str): + try: + ts_dt = datetime.datetime.fromisoformat(ts) + except: + continue + else: + ts_dt = ts + + # Solo 24h precedenti + if ts_dt < past_24h_start or ts_dt >= now: + continue + + # Estrai valori per questa ora + t_2m = temp_2m[i] if i < len(temp_2m) and temp_2m[i] is not None else None + t_soil = soil_temp[i] if i < len(soil_temp) and soil_temp[i] is not None else None + prec = precipitation[i] if i < len(precipitation) and precipitation[i] is not None else 0.0 + r = rain[i] if i < len(rain) and rain[i] is not None else 0.0 + snow = snowfall[i] if i < len(snowfall) and snowfall[i] is not None else 0.0 + show = showers[i] if i < len(showers) and showers[i] is not None else 0.0 + wcode = weathercode[i] if i < len(weathercode) and weathercode[i] is not None else None + + # Aggiorna totali + if r > 0: + total_rain += r + precipitation_types.add("rain") + if snow > 0: + total_snowfall += snow + precipitation_types.add("snowfall") + if show > 0: + total_showers += show + precipitation_types.add("showers") + + # Aggiorna temperature minime + if t_2m is not None: + if min_temp_2m is None or t_2m < min_temp_2m: + min_temp_2m = t_2m + if t_2m < 0.0: + hours_below_zero += 1 + + if t_soil is not None: + if min_soil_temp is None or t_soil < min_soil_temp: + min_soil_temp = t_soil + if t_soil < 0.0: + hours_below_zero_soil += 1 + + # Verifica precipitazioni con temperature sotto zero + if (prec > 0.1 or r > 0.1 or snow > 0.1 or show > 0.1) and (t_2m is not None and t_2m < 0.0) or (t_soil is not None and t_soil < 0.0): + precipitation_with_freeze = True + + # Aggiungi a storico + history.append({ + "timestamp": ts_dt, + "temp_2m": t_2m, + "soil_temp": t_soil, + "precipitation": prec, + "rain": r, + "snowfall": snow, + "showers": show, + "weathercode": wcode + }) + + # Analizza l'andamento temporale: trova inizio/fine precipitazioni e andamento temperatura + precipitation_events = [] + current_event = None + + for h in sorted(history, key=lambda x: x["timestamp"]): + has_prec = (h.get("precipitation", 0) > 0.1 or h.get("rain", 0) > 0.1 or + h.get("snowfall", 0) > 0.1 or h.get("showers", 0) > 0.1) + + if has_prec and current_event is None: + # Inizio nuovo evento + current_event = { + "start": h["timestamp"], + "end": h["timestamp"], + "types": set(), + "total_rain": h.get("rain", 0), + "total_snowfall": h.get("snowfall", 0), + "total_showers": h.get("showers", 0), + "temp_at_start": h.get("temp_2m"), + "soil_temp_at_start": h.get("soil_temp"), + } + if h.get("rain", 0) > 0.1: + current_event["types"].add("rain") + if h.get("snowfall", 0) > 0.1: + current_event["types"].add("snowfall") + if h.get("showers", 0) > 0.1: + current_event["types"].add("showers") + elif has_prec and current_event is not None: + # Continua evento + current_event["end"] = h["timestamp"] + current_event["total_rain"] += h.get("rain", 0) + current_event["total_snowfall"] += h.get("snowfall", 0) + current_event["total_showers"] += h.get("showers", 0) + if h.get("rain", 0) > 0.1: + current_event["types"].add("rain") + if h.get("snowfall", 0) > 0.1: + current_event["types"].add("snowfall") + if h.get("showers", 0) > 0.1: + current_event["types"].add("showers") + elif not has_prec and current_event is not None: + # Fine evento + current_event["types"] = list(current_event["types"]) + precipitation_events.append(current_event) + current_event = None + + # Se c'è un evento ancora in corso, aggiungilo + if current_event is not None: + current_event["types"] = list(current_event["types"]) + precipitation_events.append(current_event) + + # Analizza andamento temperatura dopo ogni evento precipitativo + for event in precipitation_events: + event_end = event["end"] + # Trova temperature nelle 6 ore successive alla fine dell'evento + temps_after = [] + for h in sorted(history, key=lambda x: x["timestamp"]): + if h["timestamp"] > event_end: + hours_after = (h["timestamp"] - event_end).total_seconds() / 3600.0 + if hours_after <= 6.0: + temps_after.append({ + "hours_after": hours_after, + "temp_2m": h.get("temp_2m"), + "soil_temp": h.get("soil_temp"), + }) + + event["temps_after"] = temps_after + # Valuta se temperature sono compatibili con persistenza ghiaccio + # Ghiaccio persiste se T < +2°C (aria) o T < +4°C (suolo) dopo le precipitazioni + event["ice_persistence_likely"] = False + if temps_after: + for t_after in temps_after: + t_check = t_after.get("soil_temp") if t_after.get("soil_temp") is not None else t_after.get("temp_2m") + if t_check is not None: + threshold = 4.0 if t_after.get("soil_temp") is not None else 2.0 + if t_check < threshold: + event["ice_persistence_likely"] = True + break + + # Verifica se c'è ancora un fenomeno precipitativo in atto (nelle prossime ore) + ongoing_precipitation = False + ongoing_precipitation_type = None + now = datetime.datetime.now() + hourly = weather_data.get("hourly", {}) + times_future = hourly.get("time", []) + + # Cerca nelle prossime 12 ore + if times_future: + try: + current_hour_str = now.strftime("%Y-%m-%dT%H:00") + if current_hour_str in times_future: + idx_now = times_future.index(current_hour_str) + end_idx = min(idx_now + 12, len(times_future)) + + snowfall_future = hourly.get("snowfall", []) + rain_future = hourly.get("rain", []) + precipitation_future = hourly.get("precipitation", []) + temp_future = hourly.get("temperature_2m", []) + soil_temp_future = hourly.get("soil_temperature_0cm", []) + + for i in range(idx_now, end_idx): + snow = snowfall_future[i] if i < len(snowfall_future) and snowfall_future[i] is not None else 0.0 + r = rain_future[i] if i < len(rain_future) and rain_future[i] is not None else 0.0 + prec = precipitation_future[i] if i < len(precipitation_future) and precipitation_future[i] is not None else 0.0 + t_2m = temp_future[i] if i < len(temp_future) and temp_future[i] is not None else None + t_soil = soil_temp_future[i] if i < len(soil_temp_future) and soil_temp_future[i] is not None else None + + # Verifica se c'è precipitazione con T<0°C (potenziale black ice o gelicidio) + if (snow > 0.1 or r > 0.1 or prec > 0.1): + ongoing_precipitation = True + if snow > 0.1: + ongoing_precipitation_type = "neve" + elif r > 0.1: + ongoing_precipitation_type = "pioggia" + else: + ongoing_precipitation_type = "precipitazione" + + # Verifica se può formare black ice o gelicidio + t_check = t_soil if t_soil is not None else t_2m + if t_check is not None and t_check < 0.0: + ongoing_precipitation_type += " con T<0°C (rischio black ice/gelicidio)" + break + except (ValueError, IndexError): + pass + + # Valuta probabile formazione ghiaccio + # Condizione: precipitazioni con T<0°C nelle 24h precedenti + ice_formation_likely = precipitation_with_freeze + + # Valuta probabile scioglimento ghiaccio + # Condizione: T è salita sopra soglia di scongelamento (+2°C o +4°C) + # Verifica se nelle ultime ore la temperatura è salita sopra la soglia + ice_melting_likely = False + if history: + # Controlla le ultime 6 ore + recent_history = sorted(history, key=lambda x: x["timestamp"], reverse=True)[:6] + if recent_history: + # Se la temperatura è salita sopra +2°C nelle ultime ore, ghiaccio probabilmente sciolto + # Soglia conservativa: +2°C per aria, +4°C per suolo (più lento a scaldarsi) + for h in recent_history: + t_check = h.get("soil_temp") if h.get("soil_temp") is not None else h.get("temp_2m") + if t_check is not None: + # Soglia: +2°C per aria, +4°C per suolo + threshold = 4.0 if h.get("soil_temp") is not None else 2.0 + if t_check > threshold: + ice_melting_likely = True + break + + return { + "has_precipitation": total_rain > 0.1 or total_snowfall > 0.1 or total_showers > 0.1, + "precipitation_types": list(precipitation_types), + "total_rain_mm": total_rain, + "total_snowfall_cm": total_snowfall, + "total_showers_mm": total_showers, + "min_temp_2m": min_temp_2m, + "min_soil_temp": min_soil_temp, + "hours_below_zero": hours_below_zero, + "hours_below_zero_soil": hours_below_zero_soil, + "precipitation_with_freeze": precipitation_with_freeze, + "ice_formation_likely": ice_formation_likely, + "ice_melting_likely": ice_melting_likely, + "precipitation_events": precipitation_events, # Lista di eventi precipitativi + "ongoing_precipitation": ongoing_precipitation, # Se c'è ancora precipitazione in atto + "ongoing_precipitation_type": ongoing_precipitation_type, # Tipo di precipitazione in atto + "history": history + } + + +def calculate_ice_risk_dataframe(weather_data: Dict, model_slug: str = "italia_meteo_arpae_icon_2i", + hours_ahead: int = 24): + """ + Calcola l'Indice di Rischio Ghiaccio Stradale per le prossime 24 ore. + + REQUIRES: pandas e numpy installati. + Se non disponibili, solleva ImportError con istruzioni. + """ + if not PANDAS_AVAILABLE: + raise ImportError( + "pandas e numpy sono richiesti per l'analisi avanzata del ghiaccio.\n" + "Installa con: pip install --break-system-packages pandas numpy\n" + "Oppure nel container Docker esegui:\n" + " docker exec -it pip install --break-system-packages pandas numpy" + ) + """ + Calcola l'Indice di Rischio Ghiaccio Stradale per le prossime 24 ore. + + Analisi avanzata basata su logiche fisiche di meteorologia stradale: + 1. Rischio Brina (Hoar Frost) + 2. Rischio Ghiaccio Nero (Black Ice da bagnatura) + 3. Rischio Freezing Rain (già implementato con FMI_CLIM) + 4. Effetto Cielo Sereno (raffreddamento radiativo) + + Args: + weather_data: Dati meteo da Open-Meteo API + model_slug: Slug del modello (default: "icon_eu") + hours_ahead: Numero di ore da analizzare (default: 24) + + Returns: + DataFrame Pandas con colonne: + - timestamp: datetime + - temp_2m: Temperatura a 2m + - dewpoint_2m: Punto di rugiada a 2m + - precipitation: Precipitazione + - soil_temp_0cm: Temperatura suolo (0cm) + - cloud_cover: Copertura nuvolosa totale + - wind_speed: Velocità vento + - Ice_Warning_Level: None, Low, Medium, High + - Ice_Phenomenon: Descrizione del fenomeno + - Risk_Score: Punteggio numerico 0-3 + """ + if not weather_data or "hourly" not in weather_data: + return pd.DataFrame() + + hourly = weather_data["hourly"] + times = hourly.get("time", []) + + if not times: + return pd.DataFrame() + + # Converti times in datetime + try: + timestamps = pd.to_datetime(times) + except: + return pd.DataFrame() + + # Estrai dati + temp_2m = hourly.get("temperature_2m", [None] * len(times)) + dewpoint_2m = hourly.get("dew_point_2m", [None] * len(times)) + precipitation = hourly.get("precipitation", [None] * len(times)) + rain = hourly.get("rain", [None] * len(times)) + snowfall = hourly.get("snowfall", [None] * len(times)) + soil_temp_0cm = hourly.get("soil_temperature_0cm", [None] * len(times)) + cloud_cover = hourly.get("cloud_cover", [None] * len(times)) + wind_speed = hourly.get("windspeed_10m", [None] * len(times)) + + # Crea DataFrame base + now = datetime.datetime.now() + df = pd.DataFrame({ + 'timestamp': timestamps, + 'temp_2m': temp_2m, + 'dewpoint_2m': dewpoint_2m, + 'precipitation': precipitation, + 'rain': rain, + 'snowfall': snowfall, + 'soil_temp_0cm': soil_temp_0cm, + 'cloud_cover': cloud_cover, + 'wind_speed': wind_speed + }) + + # Filtra solo prossime ore + df = df[df['timestamp'] >= now].head(hours_ahead) + + if df.empty: + return df + + # Inizializza colonne di output + df['Ice_Warning_Level'] = None + df['Ice_Phenomenon'] = "" + df['Risk_Score'] = 0 + + # Converti None in NaN per calcoli + numeric_cols = ['temp_2m', 'dewpoint_2m', 'precipitation', 'rain', 'snowfall', + 'soil_temp_0cm', 'cloud_cover', 'wind_speed'] + for col in numeric_cols: + df[col] = pd.to_numeric(df[col], errors='coerce') + + # Calcola ora del giorno per determinare notte/giorno + df['hour'] = df['timestamp'].dt.hour + df['is_night'] = (df['hour'] >= 18) | (df['hour'] <= 6) + + # Calcola precipitazione cumulativa delle 3 ore precedenti + df['precip_3h_sum'] = df['precipitation'].rolling(window=3, min_periods=1, closed='left').sum() + + # --- GESTIONE FALLBACK per soil_temperature_0cm --- + # Se soil_temp_0cm non è disponibile (None o tutti NaN), usa approssimazione da temp_2m + # L'approssimazione sottrae 1-2°C dalla temperatura a 2m (suolo è generalmente più freddo) + if df['soil_temp_0cm'].isna().all(): + # Nessun dato soil_temp_0cm disponibile: usa approssimazione da temp_2m + # Durante la notte o con cielo sereno, il suolo può essere 1-2°C più freddo dell'aria a 2m + df['soil_temp_0cm'] = df['temp_2m'] - 1.5 # Approssimazione conservativa + df['soil_temp_source'] = 'estimated' # Flag per tracciare che è stimato + else: + df['soil_temp_source'] = 'measured' + + # --- LOGICA 1: Rischio Brina (Hoar Frost) --- + # Condizione: Soil Temp <= 0°C E Dewpoint > Soil Temp (ma < 0°C) + brina_mask = ( + df['soil_temp_0cm'].notna() & + (df['soil_temp_0cm'] <= 0.0) & + df['dewpoint_2m'].notna() & + (df['dewpoint_2m'] > df['soil_temp_0cm']) & + (df['dewpoint_2m'] < 0.0) + ) + + # --- LOGICA 2: Rischio Ghiaccio Nero (Black Ice da bagnatura) o Neve Ghiacciata --- + # Condizione: Precipitazione > 0.1mm nelle 3h precedenti E Soil Temp < 0°C + # (anche se aria > 0°C) + # Distingue tra neve e pioggia per il messaggio appropriato + black_ice_mask = ( + (df['precip_3h_sum'] > 0.1) & + df['soil_temp_0cm'].notna() & + (df['soil_temp_0cm'] < 0.0) + ) + + # Calcola se c'è neve nelle 3h precedenti (per distinguere neve da pioggia) + df['snowfall_3h_sum'] = df['snowfall'].rolling(window=3, min_periods=1, closed='left').sum() + df['rain_3h_sum'] = df['rain'].rolling(window=3, min_periods=1, closed='left').sum() + + # --- LOGICA 3: Effetto Cielo Sereno (raffreddamento radiativo) --- + # Se Cloud Cover < 20% durante la notte, aumenta probabilità di raffreddamento + # Questo penalizza la Soil Temp prevista (sottrae 0.5-1.5°C teorici) + clear_sky_mask = ( + df['is_night'] & + df['cloud_cover'].notna() & + (df['cloud_cover'] < 20.0) + ) + + # Aggiusta Soil Temp per effetto cielo sereno (raffreddamento radiativo) + df['soil_temp_adjusted'] = df['soil_temp_0cm'].copy() + if clear_sky_mask.any(): + # Raffreddamento radiativo: sottrai 0.5-1.5°C a seconda del vento + # Vento debole (< 5 km/h) = più raffreddamento + wind_factor = df['wind_speed'].fillna(10.0) + cooling = np.where(wind_factor < 5.0, 1.5, + np.where(wind_factor < 10.0, 1.0, 0.5)) + df.loc[clear_sky_mask, 'soil_temp_adjusted'] = ( + df.loc[clear_sky_mask, 'soil_temp_0cm'] - cooling[clear_sky_mask] + ) + + # Rivaluta condizioni con soil_temp_adjusted + brina_mask_adjusted = ( + df['soil_temp_adjusted'].notna() & + (df['soil_temp_adjusted'] <= 0.0) & + df['dewpoint_2m'].notna() & + (df['dewpoint_2m'] > df['soil_temp_adjusted']) & + (df['dewpoint_2m'] < 0.0) + ) + + black_ice_mask_adjusted = ( + (df['precip_3h_sum'] > 0.1) & + df['soil_temp_adjusted'].notna() & + (df['soil_temp_adjusted'] < 0.0) + ) + + # --- ASSEGNAZIONE LIVELLI DI RISCHIO --- + # Priorità: Black Ice/Neve Ghiacciata > Brina > Nessun rischio + + # Ghiaccio Nero o Neve Ghiacciata = High Risk (3) + # Distingue tra neve e pioggia per messaggio appropriato + if black_ice_mask_adjusted.any(): + # Verifica se c'è neve nelle 3h precedenti O nell'ora corrente/futura + # (considera sia neve passata che neve in arrivo) + # Usa np.logical_or per combinare le condizioni + has_snow_past = df['snowfall_3h_sum'] > 0.1 + has_snow_current = df['snowfall'] > 0.1 + has_snow_combined = has_snow_past | has_snow_current + has_snow_mask = black_ice_mask_adjusted & has_snow_combined + + # Se c'è solo pioggia (no neve) + has_rain_past = df['rain_3h_sum'] > 0.1 + has_rain_current = df['rain'] > 0.1 + has_rain_combined = has_rain_past | has_rain_current + has_rain_only_mask = black_ice_mask_adjusted & ~has_snow_mask & has_rain_combined + + # Se c'è neve (anche con pioggia), il rischio è neve/neve ghiacciata (livello 4) + # La neve prevale sempre sulla pioggia + if has_snow_mask.any(): + df.loc[has_snow_mask, 'Risk_Score'] = 4 + df.loc[has_snow_mask, 'Ice_Warning_Level'] = 'Very High' + df.loc[has_snow_mask, 'Ice_Phenomenon'] = 'Neve/Neve ghiacciata (suolo gelato)' + + # Se c'è solo pioggia (no neve), è Black Ice (livello 3) + if has_rain_only_mask.any(): + df.loc[has_rain_only_mask, 'Risk_Score'] = 3 + df.loc[has_rain_only_mask, 'Ice_Warning_Level'] = 'High' + df.loc[has_rain_only_mask, 'Ice_Phenomenon'] = 'Possibile Black Ice (strada bagnata + suolo gelato)' + + # Fallback per altri casi (precipitazione generica senza neve/pioggia specifica) + other_precip_mask = black_ice_mask_adjusted & ~has_snow_mask & ~has_rain_only_mask + if other_precip_mask.any(): + df.loc[other_precip_mask, 'Risk_Score'] = 3 + df.loc[other_precip_mask, 'Ice_Warning_Level'] = 'High' + df.loc[other_precip_mask, 'Ice_Phenomenon'] = 'Possibile Black Ice (strada bagnata + suolo gelato)' + + # --- LOGICA 4: Rilevazione Neve (presenza/persistenza) --- + # Verifica presenza di neve nelle 24h precedenti o future (anche senza suolo gelato) + # Questo è un layer separato per indicare presenza/persistenza di neve + # Calcola neve cumulativa nelle 24h precedenti (rolling window backward) + df['snowfall_24h_past'] = df['snowfall'].rolling(window=24, min_periods=1, closed='left').sum() + # Calcola neve cumulativa nelle 24h future (rolling window forward) + df['snowfall_24h_future'] = df['snowfall'].rolling(window=24, min_periods=1, closed='right').sum() + + # Se c'è neve significativa (>= 0.5 cm) nelle 24h precedenti o future, segna come livello 4 + # (anche se il suolo non è gelato, la neve presente è un rischio) + # Ma solo se non è già stato segnato come neve ghiacciata (livello 4 da black_ice_mask) + snow_presence_mask = ( + (df['snowfall_24h_past'] >= 0.5) | (df['snowfall_24h_future'] >= 0.5) | + (df['snowfall'] >= 0.1) # Neve nell'ora corrente + ) & (df['Risk_Score'] < 4) # Solo se non è già stato segnato come neve ghiacciata + + if snow_presence_mask.any(): + df.loc[snow_presence_mask, 'Risk_Score'] = 4 + df.loc[snow_presence_mask, 'Ice_Warning_Level'] = 'Very High' + df.loc[snow_presence_mask, 'Ice_Phenomenon'] = 'Neve presente/persistente' + + # Brina = Medium Risk (1-2) + brina_high = brina_mask_adjusted & ~black_ice_mask_adjusted & (df['soil_temp_adjusted'] < -1.0) + brina_medium = brina_mask_adjusted & ~black_ice_mask_adjusted & ~brina_high + + if brina_high.any(): + df.loc[brina_high, 'Risk_Score'] = 2 + df.loc[brina_high, 'Ice_Warning_Level'] = 'Medium' + df.loc[brina_high, 'Ice_Phenomenon'] = 'Brina (condizioni favorevoli)' + + if brina_medium.any(): + df.loc[brina_medium, 'Risk_Score'] = 1 + df.loc[brina_medium, 'Ice_Warning_Level'] = 'Low' + df.loc[brina_medium, 'Ice_Phenomenon'] = 'Brina (possibile)' + + # Assicura che se Risk_Score > 0, Ice_Warning_Level e Ice_Phenomenon siano sempre definiti + # (fallback per casi edge) + missing_level = (df['Risk_Score'] > 0) & (df['Ice_Warning_Level'].isna()) + if missing_level.any(): + df.loc[missing_level & (df['Risk_Score'] >= 3), 'Ice_Warning_Level'] = 'High' + df.loc[missing_level & (df['Risk_Score'] == 2), 'Ice_Warning_Level'] = 'Medium' + df.loc[missing_level & (df['Risk_Score'] == 1), 'Ice_Warning_Level'] = 'Low' + + missing_phenomenon = (df['Risk_Score'] > 0) & (df['Ice_Phenomenon'] == '') + if missing_phenomenon.any(): + df.loc[missing_phenomenon & (df['Risk_Score'] >= 3), 'Ice_Phenomenon'] = 'Ghiaccio Nero (Black Ice)' + df.loc[missing_phenomenon & (df['Risk_Score'] == 2), 'Ice_Phenomenon'] = 'Brina' + df.loc[missing_phenomenon & (df['Risk_Score'] == 1), 'Ice_Phenomenon'] = 'Brina (possibile)' + + # Rimuovi colonne temporanee (mantieni soil_temp_source se presente) + cols_to_drop = ['hour', 'is_night', 'precip_3h_sum', 'snowfall_3h_sum', 'rain_3h_sum', + 'soil_temp_adjusted', 'snowfall_24h_past', 'snowfall_24h_future'] + if 'soil_temp_source' not in df.columns: + cols_to_drop.append('soil_temp_source') + df = df.drop(columns=[c for c in cols_to_drop if c in df.columns]) + + return df + + +def analyze_risk(weather_data, model_slug, icon_weather_data=None): + """ + Analizza i dati di un singolo modello e ritorna rischio e dettagli. + Rischio 3 = GELICIDIO (FZRA), Rischio 2 = GHIACCIO VIVO, Rischio 1 = BRINA + + Args: + weather_data: Dati del modello principale + model_slug: Slug del modello (es. "meteofrance_seamless", "icon_eu") + icon_weather_data: Dati ICON opzionali (per fornire soil_temperature_0cm se AROME non lo ha) + """ if not weather_data: return 0, "" hourly = weather_data.get("hourly", {}) times = hourly.get("time", []) + if not times: + return 0, "" + now = datetime.datetime.now() current_hour_str = now.strftime("%Y-%m-%dT%H:00") @@ -98,33 +902,684 @@ def analyze_risk(weather_data): except ValueError: return 0, "" - # Estrazione dati (gestione sicura se mancano chiavi) + # PRIORITÀ 1: Rilevamento GELICIDIO (FZRA) usando algoritmo FMI_CLIM + # Solo per AROME Seamless (ha parametri isobarici) + # Passa anche i dati ICON per ottenere soil_temperature_0cm se AROME non lo fornisce + if model_slug == "meteofrance_seamless": + icon_hourly = icon_weather_data.get("hourly", {}) if icon_weather_data else None + is_fzra, fzra_details = detect_freezing_rain_fmi(hourly, idx, icon_hourly_data=icon_hourly) + if is_fzra: + return 3, f"🔴🔴 GELICIDIO (FZRA) ({fzra_details})" + + # PRIORITÀ 2: Analisi ghiaccio/brina tradizionale (fallback per tutti i modelli) try: - t_soil = hourly["soil_temperature_0cm"][idx] - t_dew = hourly["dew_point_2m"][idx] - hum = hourly["relative_humidity_2m"][idx] + t_soil = hourly.get("soil_temperature_0cm", [None])[idx] if idx < len(hourly.get("soil_temperature_0cm", [])) else None + t_dew = hourly.get("dew_point_2m", [None])[idx] if idx < len(hourly.get("dew_point_2m", [])) else None + t_2m = hourly.get("temperature_2m", [None])[idx] if idx < len(hourly.get("temperature_2m", [])) else None + hum = hourly.get("relative_humidity_2m", [None])[idx] if idx < len(hourly.get("relative_humidity_2m", [])) else None start_idx = max(0, idx - 6) - precip_history = hourly["precipitation"][start_idx : idx+1] + precip_history = hourly.get("precipitation", [])[start_idx : idx+1] precip_sum = sum(p for p in precip_history if p is not None) - except (KeyError, TypeError): + except (KeyError, TypeError, IndexError): return 0, "" + # Se mancano dati essenziali, non possiamo analizzare + if t_2m is None: + return 0, "" + + # Se manca t_soil o t_dew, usa solo t_2m e hum per analisi brina + # SOGLIE MOLTO RESTRITTIVE per ridurre falsi positivi (solo quando dati suolo non disponibili) if t_soil is None or t_dew is None: + # Analisi brina semplificata (solo temperatura aria + umidità) + # Soglia molto restrittiva: t_2m <= 1.0°C e hum > 90% + # Solo quando non abbiamo dati del suolo (fallback) + if t_2m is not None and hum is not None: + if t_2m <= 1.0 and hum > 90: + # Richiede anche punto di rugiada molto vicino se disponibile + if t_dew is not None: + if abs(t_2m - t_dew) < 0.3: + details = f"Aria {t_2m:.1f}°C, Umid {hum:.0f}%" + return 1, f"🟡 Rischio BRINA ({details})" + else: + # Senza t_dew, solo condizioni estreme + if t_2m <= 0.0 and hum > 95: + details = f"Aria {t_2m:.1f}°C, Umid {hum:.0f}%" + return 1, f"🟡 Rischio BRINA ({details})" return 0, "" - details = f"Suolo {t_soil}°C, Umid {hum}%" + details = f"Suolo {t_soil:.1f}°C" + if t_2m is not None: + details += f", Aria {t_2m:.1f}°C" + if hum is not None: + details += f", Umid {hum:.0f}%" - if precip_sum > 0.2 and t_soil <= 0: + # GHIACCIO VIVO: precipitazioni su suolo gelato + if t_soil is not None and precip_sum > 0.2 and t_soil <= 0: return 2, f"🔴 GHIACCIO VIVO ({details})" - elif t_soil <= 0 and t_soil <= t_dew: - return 1, f"🟡 Rischio BRINA ({details})" + + # Rischio BRINA: suolo gelato e punto di rugiada raggiunto + # Questa è la condizione più affidabile per la brina + if t_soil is not None and t_dew is not None: + if t_soil <= 0 and t_soil <= t_dew: + return 1, f"🟡 Rischio BRINA ({details})" + + # Brina anche con temperatura aria bassa e alta umidità (solo se suolo non disponibile o suolo > 0°C) + # SOGLIE MOLTO RESTRITTIVE per ridurre falsi positivi: + # - Temperatura molto bassa (≤ 1.0°C invece di ≤ 1.5°C) + # - Umidità molto alta (> 90% invece di > 85%) + # - Richiede punto di rugiada molto vicino alla temperatura (≤ 0.3°C) + # - Solo se suolo non disponibile O suolo > 2°C (per evitare falsi positivi quando suolo è caldo) + if t_2m is not None and hum is not None: + # Applica solo se: suolo non disponibile O suolo > 2°C (per evitare falsi positivi) + soil_ok_for_air_frost = (t_soil is None) or (t_soil is not None and t_soil > 2.0) + + if soil_ok_for_air_frost: + # Condizione molto restrittiva: t_2m <= 1.0°C, hum > 90%, punto di rugiada molto vicino + if t_2m <= 1.0 and hum > 90: + if t_dew is not None and abs(t_2m - t_dew) < 0.3: # Punto di rugiada molto vicino (0.3°C) + return 1, f"🟡 Rischio BRINA ({details})" + # Condizione estrema: temperatura molto bassa (≤ 0.0°C) e umidità altissima (> 95%) + elif t_2m <= 0.0 and hum > 95: + return 1, f"🟡 Rischio BRINA ({details})" return 0, details +def check_ice_persistence_conditions(weather_data, model_slug, hours_check=12): + """ + Verifica se ci sono condizioni che mantengono il ghiaccio già formato, + anche se non ci sono più condizioni favorevoli alla formazione di nuovo ghiaccio. + + Usa l'analisi delle 24h precedenti per valutare persistenza e scongelamento. + + Condizioni che mantengono il ghiaccio: + 1. Neve presente (snowfall recente o snow_depth > 0) + 2. Temperature vicine allo zero (tra -2°C e +2°C) che impediscono lo scioglimento + 3. Precipitazioni con T<0°C nelle 24h precedenti (possibile ghiaccio residuo) + + Args: + weather_data: Dati meteo dal modello + model_slug: Slug del modello + hours_check: Numero di ore da controllare (default: 12) + + Returns: + (has_snow: bool, has_cold_temps: bool, details: str, past_24h_info: Dict) + """ + if not weather_data or "hourly" not in weather_data: + return False, False, "", {} + + hourly = weather_data.get("hourly", {}) + times = hourly.get("time", []) + + if not times: + return False, False, "", {} + + now = datetime.datetime.now() + current_hour_str = now.strftime("%Y-%m-%dT%H:00") + + try: + idx = times.index(current_hour_str) + except ValueError: + return False, False, "", {} + + # Analizza 24h precedenti per valutare persistenza + past_24h_analysis = analyze_past_24h_conditions(weather_data) + past_24h_info = past_24h_analysis if past_24h_analysis else {} + + # Controlla le prossime hours_check ore + end_idx = min(idx + hours_check, len(times)) + + # 1. Verifica presenza neve + has_snow = False + snowfall_data = hourly.get("snowfall", []) + snow_depth_data = hourly.get("snow_depth", []) + + # Controlla snowfall nelle prossime ore (neve in arrivo o recente) + if snowfall_data: + for i in range(idx, end_idx): + if i < len(snowfall_data) and snowfall_data[i] is not None and snowfall_data[i] > 0.1: + has_snow = True + break + + # Controlla snow_depth (neve già presente al suolo) + if not has_snow and snow_depth_data: + for i in range(idx, end_idx): + if i < len(snow_depth_data) and snow_depth_data[i] is not None and snow_depth_data[i] > 0.5: + has_snow = True + break + + # Verifica anche neve nelle 24h precedenti + if not has_snow and past_24h_info.get("total_snowfall_cm", 0) > 0.1: + has_snow = True + + # 2. Verifica temperature che mantengono il ghiaccio già formato + has_cold_temps = False + temp_2m_data = hourly.get("temperature_2m", []) + soil_temp_data = hourly.get("soil_temperature_0cm", []) + + # Il ghiaccio persiste se la temperatura è < +2°C (non si scioglie) + # Questo include: + # - Temperature < -2°C: molto freddo, ghiaccio sicuramente presente + # - Temperature tra -2°C e 0°C: ghiaccio non si scioglie + # - Temperature tra 0°C e +2°C: ghiaccio può ancora persistere (scioglimento lento) + # Solo temperature > +2°C permettono lo scioglimento completo del ghiaccio + min_temp_found = None + for i in range(idx, end_idx): + t_2m = temp_2m_data[i] if i < len(temp_2m_data) and temp_2m_data[i] is not None else None + t_soil = soil_temp_data[i] if i < len(soil_temp_data) and soil_temp_data[i] is not None else None + + # Usa temperatura suolo se disponibile (più accurata), altrimenti temperatura aria + t_check = t_soil if t_soil is not None else t_2m + + if t_check is not None and t_check < 2.0: + has_cold_temps = True + if min_temp_found is None or t_check < min_temp_found: + min_temp_found = t_check + break + + # Verifica anche temperature minime nelle 24h precedenti + if not has_cold_temps: + min_temp_2m_past = past_24h_info.get("min_temp_2m") + min_soil_temp_past = past_24h_info.get("min_soil_temp") + if min_temp_2m_past is not None and min_temp_2m_past < 2.0: + has_cold_temps = True + if min_temp_found is None or min_temp_2m_past < min_temp_found: + min_temp_found = min_temp_2m_past + elif min_soil_temp_past is not None and min_soil_temp_past < 4.0: # Suolo più lento a scaldarsi + has_cold_temps = True + if min_temp_found is None or min_soil_temp_past < min_temp_found: + min_temp_found = min_soil_temp_past + + # Costruisci dettagli con informazioni 24h precedenti + details_parts = [] + if has_snow: + details_parts.append("neve presente") + if has_cold_temps: + # Distingui tra temperature molto basse e vicine allo zero per messaggio più chiaro + if min_temp_found is not None and min_temp_found < -2.0: + details_parts.append("temperature molto basse (< -2°C)") + else: + details_parts.append("temperature che mantengono il ghiaccio (< +2°C)") + + # Aggiungi informazioni sulle 24h precedenti se rilevanti + if past_24h_info.get("precipitation_with_freeze", False): + details_parts.append("precipitazioni con T<0°C nelle 24h precedenti") + if past_24h_info.get("ice_formation_likely", False): + details_parts.append("formazione ghiaccio probabile nelle 24h precedenti") + if past_24h_info.get("ice_melting_likely", False): + details_parts.append("scioglimento probabile (T salita sopra soglia)") + + details = ", ".join(details_parts) if details_parts else "" + + return has_snow, has_cold_temps, details, past_24h_info + +def get_coordinates_from_city(city_name: str) -> Optional[Tuple[float, float, str]]: + """Ottiene coordinate lat/lon da nome città usando Open-Meteo Geocoding API.""" + # Caso speciale: "Casa" -> Strada Cà Toro,12, San Marino + if city_name.lower().strip() in ["casa", "home"]: + return 43.9356, 12.4296, "🏠 Casa (Strada Cà Toro, San Marino)" + + url = "https://geocoding-api.open-meteo.com/v1/search" + try: + resp = requests.get(url, params={"name": city_name, "count": 1, "language": "it", "format": "json"}, timeout=5) + res = resp.json().get("results", []) + if res: + res = res[0] + name = f"{res.get('name')} ({res.get('country_code', 'IT').upper()})" + return res['latitude'], res['longitude'], name + except Exception as e: + print(f"Errore geocoding per {city_name}: {e}") + return None + +def get_location_name_from_coords(lat: float, lon: float) -> Optional[str]: + """ + Ottiene il nome della località da coordinate usando Nominatim (OpenStreetMap). + Reverse geocoding gratuito, no API key richiesta. + """ + url = "https://nominatim.openstreetmap.org/reverse" + try: + params = { + "lat": lat, + "lon": lon, + "format": "json", + "accept-language": "it", + "zoom": 10, # Livello di dettaglio: 10 = città/paese + "addressdetails": 1 + } + headers = { + "User-Agent": "Telegram-Bot-Ice-Road/1.0" # Nominatim richiede User-Agent + } + resp = requests.get(url, params=params, headers=headers, timeout=5) + if resp.status_code == 200: + data = resp.json() + address = data.get("address", {}) + + # Priorità: città > paese > comune > frazione + location_name = ( + address.get("city") or + address.get("town") or + address.get("village") or + address.get("municipality") or + address.get("county") or + address.get("state") + ) + + if location_name: + # Aggiungi provincia/regione se disponibile + state = address.get("state") + if state and state != location_name: + return f"{location_name} ({state})" + return location_name + + # Fallback: usa display_name se disponibile + display_name = data.get("display_name", "") + if display_name: + # Prendi solo la prima parte (prima della virgola) + return display_name.split(",")[0].strip() + except Exception as e: + print(f"Errore reverse geocoding per ({lat}, {lon}): {e}") + return None + +def decode_polyline(polyline_str: str) -> List[Tuple[float, float]]: + """ + Decodifica un polyline codificato di Google Maps in una lista di coordinate (lat, lon). + + Args: + polyline_str: Stringa polyline codificata + + Returns: + Lista di tuple (lat, lon) + """ + def _decode_value(value_str: str) -> int: + """Decodifica un valore dal polyline.""" + result = 0 + shift = 0 + for char in value_str: + b = ord(char) - 63 + result |= (b & 0x1f) << shift + shift += 5 + if b < 0x20: + break + return ~result if (result & 1) else result >> 1 + + points = [] + index = 0 + lat = 0 + lon = 0 + + while index < len(polyline_str): + # Decodifica latitudine + value_str = "" + while index < len(polyline_str): + char = polyline_str[index] + value_str += char + index += 1 + if ord(char) < 0x20: + break + + lat_delta = _decode_value(value_str) + lat += lat_delta + + # Decodifica longitudine + if index >= len(polyline_str): + break + + value_str = "" + while index < len(polyline_str): + char = polyline_str[index] + value_str += char + index += 1 + if ord(char) < 0x20: + break + + lon_delta = _decode_value(value_str) + lon += lon_delta + + points.append((lat / 1e5, lon / 1e5)) + + return points + + +def get_google_maps_api_key() -> Optional[str]: + """ + Ottiene la chiave API di Google Maps da variabile d'ambiente. + + Returns: + Chiave API o None se non disponibile + """ + # Prova variabili d'ambiente comuni + api_key = os.environ.get('GOOGLE_MAPS_API_KEY', '').strip() + if api_key: + return api_key + + api_key = os.environ.get('GOOGLE_API_KEY', '').strip() + if api_key: + return api_key + + return None + + +def calculate_route_points(lat1: float, lon1: float, lat2: float, lon2: float, + num_points: int = 5) -> List[Tuple[float, float]]: + """ + Calcola punti intermedi lungo un percorso stradale reale tra due coordinate. + Usa Google Maps Directions API se disponibile, altrimenti fallback a linea d'aria. + + Args: + lat1, lon1: Coordinate punto di partenza + lat2, lon2: Coordinate punto di arrivo + num_points: Numero minimo di punti intermedi desiderati (default: 5) + (ignorato se si usa Google Maps, che restituisce tutti i punti del percorso) + + Returns: + Lista di tuple (lat, lon) lungo il percorso + """ + # Prova prima con Google Maps Directions API + api_key = get_google_maps_api_key() + if api_key: + try: + url = "https://maps.googleapis.com/maps/api/directions/json" + params = { + 'origin': f"{lat1},{lon1}", + 'destination': f"{lat2},{lon2}", + 'key': api_key, + 'mode': 'driving', # Modalità guida + 'alternatives': False # Solo il percorso principale + } + + response = requests.get(url, params=params, timeout=10) + if response.status_code == 200: + data = response.json() + + if data.get('status') == 'OK' and data.get('routes'): + route = data['routes'][0] + # Estrai polyline dal percorso + overview_polyline = route.get('overview_polyline', {}) + encoded_polyline = overview_polyline.get('points', '') + + if encoded_polyline: + # Decodifica polyline per ottenere tutti i punti del percorso + route_points = decode_polyline(encoded_polyline) + + if route_points: + # Se il percorso ha troppi punti, campiona per avere un numero ragionevole + # ma mantieni sempre partenza e arrivo + if len(route_points) > 20: + # Campiona i punti mantenendo partenza e arrivo + sampled_points = [route_points[0]] # Partenza + step = len(route_points) // (num_points + 1) + for i in range(1, len(route_points) - 1, max(1, step)): + sampled_points.append(route_points[i]) + sampled_points.append(route_points[-1]) # Arrivo + return sampled_points + else: + return route_points + except Exception as e: + # In caso di errore, fallback a linea d'aria + print(f"Errore Google Maps Directions API: {e}. Uso fallback linea d'aria.") + + # Fallback: calcola punti lungo linea d'aria + points = [] + for i in range(num_points + 1): + ratio = i / num_points if num_points > 0 else 0 + lat = lat1 + (lat2 - lat1) * ratio + lon = lon1 + (lon2 - lon1) * ratio + points.append((lat, lon)) + return points + +def get_best_model_for_location(lat: float, lon: float) -> str: + """ + Determina il miglior modello disponibile per una località. + Priorità: ICON Italia (se in Italia) > ICON EU (Europa) > AROME Seamless (Francia/limitrofi) + """ + # ICON Italia copre approssimativamente: 36-48°N, 6-19°E (Italia e zone limitrofe) + if 36.0 <= lat <= 48.0 and 6.0 <= lon <= 19.0: + # Prova prima ICON Italia + test_data = get_weather_data(lat, lon, "italia_meteo_arpae_icon_2i") + if test_data and test_data.get("hourly", {}).get("soil_temperature_0cm"): + return "italia_meteo_arpae_icon_2i" + + # ICON EU copre Europa (35-72°N, -12-35°E) + if 35.0 <= lat <= 72.0 and -12.0 <= lon <= 35.0: + test_data = get_weather_data(lat, lon, "icon_eu") + if test_data: + # Verifica se ICON EU fornisce soil_temperature_0cm per questa zona + if test_data.get("hourly", {}).get("soil_temperature_0cm"): + return "icon_eu" + # Anche senza soil_temp, ICON EU può essere usato con approssimazione + return "icon_eu" + + # AROME Seamless copre Francia e zone limitrofe + if 41.0 <= lat <= 52.0 and -5.0 <= lon <= 10.0: + test_data = get_weather_data(lat, lon, "meteofrance_seamless") + if test_data: + return "meteofrance_seamless" + + # Fallback: ICON EU (copertura più ampia) + return "icon_eu" + +def analyze_route_ice_risk(city1: str, city2: str, model_slug: Optional[str] = None) -> Optional[pd.DataFrame]: + """ + Analizza il rischio di ghiaccio lungo un percorso stradale tra due località. + + Args: + city1: Nome città di partenza + city2: Nome città di arrivo + model_slug: Modello meteo da usare (None = auto-detect basato su località) + + Returns: + DataFrame con analisi del rischio per ogni punto del percorso, o None se errore + """ + # Ottieni coordinate + coord1 = get_coordinates_from_city(city1) + coord2 = get_coordinates_from_city(city2) + + if not coord1 or not coord2: + return None + + lat1, lon1, name1 = coord1 + lat2, lon2, name2 = coord2 + + # Se modello non specificato, determina automaticamente + if model_slug is None: + # Usa il punto medio del percorso per determinare il miglior modello + mid_lat = (lat1 + lat2) / 2 + mid_lon = (lon1 + lon2) / 2 + model_slug = get_best_model_for_location(mid_lat, mid_lon) + + # Calcola punti lungo il percorso (8 punti intermedi per copertura adeguata) + route_points = calculate_route_points(lat1, lon1, lat2, lon2, num_points=8) + + # Analizza ogni punto con fallback automatico se il modello principale non funziona + all_results = [] + models_used = set() + + for i, (lat, lon) in enumerate(route_points): + # Prova prima il modello principale + weather_data = get_weather_data(lat, lon, model_slug) + point_model = model_slug + + # Se fallisce o non ha soil_temp_0cm, prova modelli alternativi + if not weather_data: + # Fallback: prova altri modelli + for fallback_model in ["icon_eu", "italia_meteo_arpae_icon_2i", "meteofrance_seamless"]: + if fallback_model != model_slug: + test_data = get_weather_data(lat, lon, fallback_model) + if test_data: + weather_data = test_data + point_model = fallback_model + break + + if not weather_data: + continue + + models_used.add(point_model) + + # Analizza condizioni 24h precedenti per persistenza ghiaccio + past_24h_analysis = analyze_past_24h_conditions(weather_data) + if not past_24h_analysis: + # Se l'analisi fallisce, usa valori di default + past_24h_analysis = {} + + # Calcola rischio per 24h + df = calculate_ice_risk_dataframe(weather_data, point_model, hours_ahead=24) + if df.empty: + continue + + # Aggiungi info punto + df['point_index'] = i + df['point_lat'] = lat + df['point_lon'] = lon + df['model_used'] = point_model + + # Aggiungi analisi 24h precedenti come colonne aggiuntive + # (saranno duplicate per ogni riga del DataFrame, ma utili per il report) + df['past_24h_has_precip'] = past_24h_analysis.get("has_precipitation", False) + df['past_24h_precip_types'] = str(past_24h_analysis.get("precipitation_types", [])) + df['past_24h_total_rain_mm'] = past_24h_analysis.get("total_rain_mm", 0.0) + df['past_24h_total_snowfall_cm'] = past_24h_analysis.get("total_snowfall_cm", 0.0) + df['past_24h_total_showers_mm'] = past_24h_analysis.get("total_showers_mm", 0.0) + df['past_24h_min_temp_2m'] = past_24h_analysis.get("min_temp_2m") + df['past_24h_min_soil_temp'] = past_24h_analysis.get("min_soil_temp") + df['past_24h_hours_below_zero'] = past_24h_analysis.get("hours_below_zero", 0) + df['past_24h_hours_below_zero_soil'] = past_24h_analysis.get("hours_below_zero_soil", 0) + df['past_24h_precip_with_freeze'] = past_24h_analysis.get("precipitation_with_freeze", False) + df['past_24h_ice_formation_likely'] = past_24h_analysis.get("ice_formation_likely", False) + df['past_24h_ice_melting_likely'] = past_24h_analysis.get("ice_melting_likely", False) + df['past_24h_ongoing_precipitation'] = past_24h_analysis.get("ongoing_precipitation", False) + df['past_24h_ongoing_precipitation_type'] = past_24h_analysis.get("ongoing_precipitation_type", "") + # Salva storico come JSON string (per evitare problemi con DataFrame) + import json + df['past_24h_history'] = json.dumps(past_24h_analysis.get("history", []), default=str) + df['past_24h_precipitation_events'] = json.dumps(past_24h_analysis.get("precipitation_events", []), default=str) + + # Crea etichetta punto con nome località + if i == 0: + df['point_label'] = f"Partenza: {name1}" + df['point_name'] = name1 + elif i == len(route_points) - 1: + df['point_label'] = f"Arrivo: {name2}" + df['point_name'] = name2 + else: + # Per punti intermedi, usa reverse geocoding per ottenere nome località + # Delay per rispettare rate limiting di Nominatim (1 req/sec) + if i > 1: # Non delay per primo punto (già fatto per partenza) + time.sleep(1.1) # 1.1 secondi per sicurezza + + location_name = get_location_name_from_coords(lat, lon) + if location_name: + df['point_name'] = location_name + df['point_label'] = f"{location_name}" + else: + # Fallback se reverse geocoding fallisce + df['point_name'] = f"Punto {i+1}" + df['point_label'] = f"Punto {i+1}/{len(route_points)}" + + all_results.append(df) + + if not all_results: + return None + + # Combina tutti i DataFrame + result_df = pd.concat(all_results, ignore_index=True) + + # Aggiungi nota se sono stati usati modelli diversi o approssimazioni + if len(models_used) > 1: + result_df['note'] = f"Usati modelli: {', '.join(models_used)}" + elif result_df['soil_temp_source'].iloc[0] == 'estimated' if 'soil_temp_source' in result_df.columns else False: + result_df['note'] = "Temperatura suolo stimata (non disponibile nel modello)" + + return result_df + def generate_maps_link(lat, lon): return f"[Mappa]" +def format_route_ice_report(df: pd.DataFrame, city1: str, city2: str) -> str: + """ + Formatta un DataFrame di analisi rischio ghiaccio lungo percorso in messaggio Telegram compatto. + Versione semplificata ora che c'è anche la mappa visiva. + """ + if df.empty: + return "❌ Nessun dato disponibile per il percorso." + + # Raggruppa per punto e trova rischio massimo per ogni punto + max_risk_per_point = df.groupby('point_index').agg({ + 'Risk_Score': 'max', + 'point_label': 'first', + 'point_name': 'first', + }).sort_values('point_index') + + # Trova ore con rischio per ogni punto + risk_hours = df[df['Risk_Score'] > 0].groupby('point_index').agg({ + 'timestamp': lambda x: f"{x.min().strftime('%d/%m %H:%M')} - {x.max().strftime('%d/%m %H:%M')}", + 'Ice_Phenomenon': lambda x: x.iloc[0] if len(x) > 0 and pd.notna(x.iloc[0]) and x.iloc[0] != '' else 'Rischio ghiaccio', + 'Risk_Score': 'max', + 'Ice_Warning_Level': lambda x: x.iloc[0] if len(x) > 0 and pd.notna(x.iloc[0]) else 'Unknown' + }) + + # Costruisci messaggio compatto + msg = f"🛣️ **Rischio Ghiaccio Stradale**\n" + msg += f"📍 {city1} → {city2}\n\n" + + points_with_risk = [] + for idx, row in max_risk_per_point.iterrows(): + risk_score = row['Risk_Score'] + if risk_score > 0: + point_name = row.get('point_name', row.get('point_label', f'Punto {idx}')) + + # Ottieni dati dal gruppo risk_hours se disponibile + if idx in risk_hours.index: + risk_level = risk_hours.loc[idx, 'Ice_Warning_Level'] + phenomenon = risk_hours.loc[idx, 'Ice_Phenomenon'] + time_range = risk_hours.loc[idx, 'timestamp'] + else: + # Fallback: cerca nel DataFrame originale + point_df = df[df['point_index'] == idx] + if len(point_df) > 0: + risk_level = point_df['Ice_Warning_Level'].iloc[0] if pd.notna(point_df['Ice_Warning_Level'].iloc[0]) else 'Unknown' + phenomenon = point_df['Ice_Phenomenon'].iloc[0] if pd.notna(point_df['Ice_Phenomenon'].iloc[0]) and point_df['Ice_Phenomenon'].iloc[0] != '' else 'Rischio ghiaccio' + time_range = f"{point_df['timestamp'].min().strftime('%d/%m %H:%M')} - {point_df['timestamp'].max().strftime('%d/%m %H:%M')}" + else: + risk_level = 'Unknown' + phenomenon = 'Rischio ghiaccio' + time_range = '' + + risk_emoji = "🔴" if risk_score >= 3 else "🟠" if risk_score >= 2 else "🟡" + + # Messaggio compatto per punto + point_msg = f"{risk_emoji} {point_name}: {risk_level} ({phenomenon})\n" + point_msg += f" ⏰ {time_range}\n" + + points_with_risk.append(point_msg) + + if points_with_risk: + msg += "⚠️ **Punti a rischio:**\n" + msg += "\n".join(points_with_risk) + else: + msg += "✅ Nessun rischio rilevato per le prossime 24h" + + # Riepilogo compatto + risk_df = df[df['Risk_Score'] > 0] + if not risk_df.empty: + min_time = risk_df['timestamp'].min() + max_time = risk_df['timestamp'].max() + time_span_hours = (max_time - min_time).total_seconds() / 3600 + points_with_any_risk = risk_df['point_index'].nunique() + total_points = len(max_risk_per_point) + + # Conta per livello di rischio + high_risk_count = len(risk_df[risk_df['Risk_Score'] >= 3]['point_index'].unique()) + medium_risk_count = len(risk_df[(risk_df['Risk_Score'] == 2)]['point_index'].unique()) + low_risk_count = len(risk_df[(risk_df['Risk_Score'] == 1)]['point_index'].unique()) + + msg += f"\n\n📊 **Riepilogo:**\n" + msg += f"• Punti: {points_with_any_risk}/{total_points} a rischio\n" + if high_risk_count > 0: + msg += f"• 🔴 Alto: {high_risk_count} | 🟠 Medio: {medium_risk_count} | 🟡 Basso: {low_risk_count}\n" + msg += f"• ⏰ {min_time.strftime('%d/%m %H:%M')} - {max_time.strftime('%d/%m %H:%M')} ({time_span_hours:.1f}h)\n" + + return msg + def send_telegram_broadcast(token, message, debug_mode=False): base_url = f"https://api.telegram.org/bot{token}/sendMessage" recipients = [ADMIN_CHAT_ID] if debug_mode else TELEGRAM_CHAT_IDS @@ -138,8 +1593,337 @@ def send_telegram_broadcast(token, message, debug_mode=False): except Exception: pass + +def generate_route_ice_map(df: pd.DataFrame, city1: str, city2: str, output_path: str) -> bool: + """ + Genera una mappa grafica del percorso con punti colorati in base al livello di rischio ghiaccio. + + Args: + df: DataFrame con analisi del rischio per ogni punto del percorso + city1: Nome città di partenza + city2: Nome città di arrivo + output_path: Percorso file output PNG + + Returns: + True se generata con successo, False altrimenti + """ + try: + import matplotlib + matplotlib.use('Agg') # Backend senza GUI + import matplotlib.pyplot as plt + import matplotlib.patches as mpatches + except ImportError as e: + print(f"matplotlib non disponibile: {e}. Mappa non generata.") + return False + + # Prova a importare contextily per mappa di sfondo + try: + import contextily as ctx + CONTEXTILY_AVAILABLE = True + except ImportError: + CONTEXTILY_AVAILABLE = False + print("contextily non disponibile. Mappa generata senza sfondo geografico.") + + if df.empty: + return False + + # Raggruppa per punto e trova rischio massimo per ogni punto + max_risk_per_point = df.groupby('point_index').agg({ + 'Risk_Score': 'max', + 'point_label': 'first', + 'point_name': 'first', + 'point_lat': 'first', + 'point_lon': 'first' + }).sort_values('point_index') + + # Estrai coordinate e livelli di rischio + lats = max_risk_per_point['point_lat'].tolist() + lons = max_risk_per_point['point_lon'].tolist() + names = max_risk_per_point['point_name'].fillna(max_risk_per_point['point_label']).tolist() + risk_levels = max_risk_per_point['Risk_Score'].astype(int).tolist() + + # Calcola limiti mappa con margine + lat_min, lat_max = min(lats), max(lats) + lon_min, lon_max = min(lons), max(lons) + + # Aggiungi margine del 10% + lat_range = lat_max - lat_min + lon_range = lon_max - lon_min + lat_min -= lat_range * 0.1 + lat_max += lat_range * 0.1 + lon_min -= lon_range * 0.1 + lon_max += lon_range * 0.1 + + # Crea figura + fig, ax = plt.subplots(figsize=(14, 10)) + fig.patch.set_facecolor('white') + + # Configura assi PRIMA di aggiungere lo sfondo + ax.set_xlim(lon_min, lon_max) + ax.set_ylim(lat_min, lat_max) + ax.set_aspect('equal', adjustable='box') + + # Aggiungi mappa di sfondo OpenStreetMap se disponibile + if CONTEXTILY_AVAILABLE: + try: + ctx.add_basemap(ax, crs='EPSG:4326', source=ctx.providers.OpenStreetMap.Mapnik, + alpha=0.6, attribution_size=6) + except Exception as e: + print(f"Errore aggiunta mappa sfondo: {e}") + CONTEXTILY_AVAILABLE = False + + # Disegna linea del percorso + ax.plot(lons, lats, 'k--', linewidth=2, alpha=0.5, zorder=3, label='Percorso') + + # Colori per livelli di rischio: verde (0), giallo (1), arancione (2), rosso scuro (3), azzurro (4=neve) + colors_map = {0: '#32CD32', 1: '#FFD700', 2: '#FF8C00', 3: '#8B0000', 4: '#00CED1'} + colors = [colors_map.get(level, '#808080') for level in risk_levels] + + # Disegna punti + scatter = ax.scatter(lons, lats, c=colors, s=400, + edgecolors='black', linewidths=2.5, alpha=0.85, zorder=5) + + # Evidenzia partenza e arrivo con marker diversi + if len(lats) >= 2: + # Partenza (primo punto) + ax.scatter([lons[0]], [lats[0]], c='blue', s=600, marker='s', + edgecolors='white', linewidths=3, alpha=0.9, zorder=6, label='Partenza') + # Arrivo (ultimo punto) + ax.scatter([lons[-1]], [lats[-1]], c='red', s=600, marker='s', + edgecolors='white', linewidths=3, alpha=0.9, zorder=6, label='Arrivo') + + # Aggiungi etichette per i punti + for lon, lat, name, risk_level in zip(lons, lats, names, risk_levels): + # Offset intelligente per evitare sovrapposizioni + offset_x = 10 if risk_level > 0 else 8 + offset_y = 10 if risk_level > 0 else 8 + + # Nome abbreviato se troppo lungo + display_name = name + if len(display_name) > 20: + display_name = display_name[:17] + "..." + + ax.annotate(display_name, (lon, lat), xytext=(offset_x, offset_y), textcoords='offset points', + fontsize=8, fontweight='bold', + bbox=dict(boxstyle='round,pad=0.4', facecolor='white', alpha=0.95, + edgecolor='black', linewidth=1.2), + zorder=7) + + # Legenda personalizzata per livelli di rischio + legend_elements = [ + mpatches.Patch(facecolor='#32CD32', label='Nessun rischio'), + mpatches.Patch(facecolor='#FFD700', label='Brina (Livello 1)'), + mpatches.Patch(facecolor='#FF8C00', label='Ghiaccio vivo (Livello 2)'), + mpatches.Patch(facecolor='#8B0000', label='Gelicidio (Livello 3)'), + mpatches.Patch(facecolor='#00CED1', label='Neve (Livello 4)'), + ] + ax.legend(handles=legend_elements, loc='lower left', fontsize=9, + framealpha=0.95, edgecolor='black', fancybox=True, shadow=True) + + # Configura assi + ax.set_xlabel('Longitudine (°E)', fontsize=11, fontweight='bold') + ax.set_ylabel('Latitudine (°N)', fontsize=11, fontweight='bold') + ax.set_title(f'RISCHIO GHIACCIO STRADALE\n{city1} → {city2}', + fontsize=14, fontweight='bold', pad=20) + + # Griglia solo se non c'è mappa di sfondo + if not CONTEXTILY_AVAILABLE: + ax.grid(True, alpha=0.3, linestyle='--', zorder=1) + + # Info timestamp in alto a sinistra + now = datetime.datetime.now() + points_with_risk = sum(1 for r in risk_levels if r > 0) + info_text = f"Aggiornamento: {now.strftime('%d/%m/%Y %H:%M')}\nPunti monitorati: {len(risk_levels)}\nPunti a rischio: {points_with_risk}" + ax.text(0.02, 0.98, info_text, transform=ax.transAxes, + fontsize=9, verticalalignment='top', horizontalalignment='left', + bbox=dict(boxstyle='round,pad=0.5', facecolor='white', alpha=0.9, + edgecolor='gray', linewidth=1.5), + zorder=10) + + plt.tight_layout() + + # Salva + try: + plt.savefig(output_path, dpi=150, bbox_inches='tight', facecolor='white') + plt.close(fig) + return True + except Exception as e: + print(f"Errore salvataggio mappa: {e}") + plt.close(fig) + return False + + +def send_telegram_photo(token, photo_path, caption, debug_mode=False): + """Invia foto via Telegram API.""" + if not os.path.exists(photo_path): + return False + + url = f"https://api.telegram.org/bot{token}/sendPhoto" + recipients = [ADMIN_CHAT_ID] if debug_mode else TELEGRAM_CHAT_IDS + + # Limite Telegram per caption: 1024 caratteri + if len(caption) > 1024: + caption = caption[:1021] + "..." + + sent_ok = False + for chat_id in recipients: + try: + with open(photo_path, 'rb') as photo_file: + files = {'photo': photo_file} + data = { + 'chat_id': chat_id, + 'caption': caption, + 'parse_mode': 'HTML' + } + resp = requests.post(url, files=files, data=data, timeout=30) + if resp.status_code == 200: + sent_ok = True + time.sleep(0.5) + except Exception as e: + if debug_mode: + print(f"Errore invio foto: {e}") + + return sent_ok + + +def generate_ice_risk_map(points_data: List[Dict], output_path: str) -> bool: + """ + Genera una mappa grafica con punti colorati in base al livello di rischio ghiaccio. + + Args: + points_data: Lista di dict con 'name', 'lat', 'lon', 'risk_level' (0-3) + output_path: Percorso file output PNG + + Returns: + True se generata con successo, False altrimenti + """ + try: + import matplotlib + matplotlib.use('Agg') # Backend senza GUI + import matplotlib.pyplot as plt + import matplotlib.patches as mpatches + from matplotlib.colors import ListedColormap + except ImportError as e: + print(f"matplotlib non disponibile: {e}. Mappa non generata.") + return False + + # Prova a importare contextily per mappa di sfondo + try: + import contextily as ctx + CONTEXTILY_AVAILABLE = True + except ImportError: + CONTEXTILY_AVAILABLE = False + print("contextily non disponibile. Mappa generata senza sfondo geografico.") + + if not points_data: + return False + + # Estrai coordinate e livelli di rischio + lats = [p["lat"] for p in points_data] + lons = [p["lon"] for p in points_data] + names = [p["name"] for p in points_data] + risk_levels = [p.get("risk_level", 0) for p in points_data] + + # Crea figura + fig, ax = plt.subplots(figsize=(12, 10)) + fig.patch.set_facecolor('white') + + # Limiti mappa per San Marino (più zoomata) + lat_min, lat_max = 43.88, 43.99 + lon_min, lon_max = 12.40, 12.52 + + # Configura assi PRIMA di aggiungere lo sfondo + ax.set_xlim(lon_min, lon_max) + ax.set_ylim(lat_min, lat_max) + ax.set_aspect('equal', adjustable='box') + + # Aggiungi mappa di sfondo OpenStreetMap se disponibile + if CONTEXTILY_AVAILABLE: + try: + ctx.add_basemap(ax, crs='EPSG:4326', source=ctx.providers.OpenStreetMap.Mapnik, + alpha=0.6, attribution_size=6) + except Exception as e: + print(f"Errore aggiunta mappa sfondo: {e}") + CONTEXTILY_AVAILABLE = False + + # Colori per livelli di rischio: verde (0), giallo (1), arancione (2), rosso scuro (3), azzurro (4=neve) + colors_map = {0: '#32CD32', 1: '#FFD700', 2: '#FF8C00', 3: '#8B0000', 4: '#00CED1'} + colors = [colors_map.get(level, '#808080') for level in risk_levels] + + # Disegna punti + scatter = ax.scatter(lons, lats, c=colors, s=300, + edgecolors='black', linewidths=2, alpha=0.85, zorder=5) + + # Aggiungi etichette per tutti i punti con posizionamento personalizzato + label_positions = { + "Galazzano": (-15, 15), # Alto a sx + "Centro Storico": (-15, -15), # Basso a sx + "Santa Mustiola": (-15, 15), # Alto a sx + } + + for lon, lat, name, risk_level in zip(lons, lats, names, risk_levels): + # Usa posizionamento personalizzato se disponibile, altrimenti default + if name in label_positions: + offset_x, offset_y = label_positions[name] + else: + # Offset intelligente per evitare sovrapposizioni + offset_x = 8 if risk_level > 0 else 5 + offset_y = 8 if risk_level > 0 else 5 + + ax.annotate(name, (lon, lat), xytext=(offset_x, offset_y), textcoords='offset points', + fontsize=7, fontweight='bold', + bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.9, + edgecolor='black', linewidth=1), + zorder=6) + + # Colorbar personalizzata per livelli di rischio + legend_elements = [ + mpatches.Patch(facecolor='#32CD32', label='Nessun rischio'), + mpatches.Patch(facecolor='#FFD700', label='Brina (Livello 1)'), + mpatches.Patch(facecolor='#FF8C00', label='Ghiaccio vivo (Livello 2)'), + mpatches.Patch(facecolor='#8B0000', label='Gelicidio (Livello 3)'), + mpatches.Patch(facecolor='#00CED1', label='Neve (Livello 4)'), + ] + ax.legend(handles=legend_elements, loc='lower left', fontsize=9, + framealpha=0.95, edgecolor='black', fancybox=True, shadow=True) + + # Configura assi + ax.set_xlabel('Longitudine (°E)', fontsize=11, fontweight='bold') + ax.set_ylabel('Latitudine (°N)', fontsize=11, fontweight='bold') + ax.set_title('RISCHIO GHIACCIO STRADALE - San Marino', + fontsize=14, fontweight='bold', pad=20) + + # Griglia solo se non c'è mappa di sfondo + if not CONTEXTILY_AVAILABLE: + ax.grid(True, alpha=0.3, linestyle='--', zorder=1) + + # Info timestamp in alto a sinistra + now = datetime.datetime.now() + info_text = f"Aggiornamento: {now.strftime('%d/%m/%Y %H:%M')}\nPunti monitorati: {len(points_data)}" + ax.text(0.02, 0.98, info_text, transform=ax.transAxes, + fontsize=9, verticalalignment='top', horizontalalignment='left', + bbox=dict(boxstyle='round,pad=0.5', facecolor='white', alpha=0.9, + edgecolor='gray', linewidth=1.5), + zorder=10) + + plt.tight_layout() + + # Salva + try: + plt.savefig(output_path, dpi=150, bbox_inches='tight', facecolor='white') + plt.close(fig) + return True + except Exception as e: + print(f"Errore salvataggio mappa: {e}") + plt.close(fig) + return False + def main(): - DEBUG_MODE = "--debug" in sys.argv + parser = argparse.ArgumentParser(description="Check ghiaccio stradale") + parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % ADMIN_CHAT_ID) + args = parser.parse_args() + + DEBUG_MODE = args.debug token = get_bot_token() previous_state = load_previous_state() @@ -148,6 +1932,9 @@ def main(): new_alerts = [] solved_alerts = [] + # Raccogli dati per la mappa + map_points_data = [] + print(f"--- Check Multi-Modello {datetime.datetime.now()} ---") for point in GRID_POINTS: @@ -158,67 +1945,290 @@ def main(): triggered_models = [] alert_messages = [] - # CICLO SUI MODELLI (ICON, AROME) + # Ottieni i dati di entrambi i modelli PRIMA del ciclo + # (necessario per passare i dati ICON ad AROME quando analizza gelicidio) + all_models_data = {} for model_name, model_slug in MODELS_TO_CHECK.items(): data = get_weather_data(point["lat"], point["lon"], model_slug) - risk, msg = analyze_risk(data) + if data is None: + if DEBUG_MODE: + print(f" ⚠️ {model_name}: Dati non disponibili") + else: + all_models_data[model_slug] = data + + # CICLO SUI MODELLI (ICON, AROME) - Usa analisi temporale avanzata + for model_name, model_slug in MODELS_TO_CHECK.items(): + data = all_models_data.get(model_slug) + if data is None: + continue - if risk > 0: - triggered_models.append(model_name) - alert_messages.append(msg) - if risk > max_risk_level: - max_risk_level = risk + # Usa calculate_ice_risk_dataframe per analisi temporale pregressa e futura + if not PANDAS_AVAILABLE: + # Fallback alla vecchia logica se pandas non disponibile + icon_data = all_models_data.get("icon_eu") if model_slug == "meteofrance_seamless" else None + risk, msg = analyze_risk(data, model_slug, icon_weather_data=icon_data) + if DEBUG_MODE: + print(f" {model_name}: Rischio={risk}, Msg={msg[:50] if msg else 'Nessun rischio'}") + if risk > 0: + triggered_models.append(model_name) + alert_messages.append(msg) + if risk > max_risk_level: + max_risk_level = risk + else: + # Analisi temporale avanzata con pandas + try: + df = calculate_ice_risk_dataframe(data, model_slug, hours_ahead=24) + if df.empty: + continue + + # Estrai rischio massimo dal DataFrame + max_risk_in_df = int(df['Risk_Score'].max()) if 'Risk_Score' in df.columns else 0 + + if DEBUG_MODE: + risk_counts = df['Risk_Score'].value_counts().to_dict() if 'Risk_Score' in df.columns else {} + print(f" {model_name}: Rischio max={max_risk_in_df}, Distribuzione={risk_counts}") + + if max_risk_in_df > 0: + triggered_models.append(model_name) + # Crea messaggio descrittivo basato sul DataFrame + high_risk_rows = df[df['Risk_Score'] == max_risk_in_df] + if not high_risk_rows.empty: + first_high_risk = high_risk_rows.iloc[0] + phenomenon = first_high_risk.get('Ice_Phenomenon', 'Rischio ghiaccio') + level = first_high_risk.get('Ice_Warning_Level', 'Unknown') + msg = f"{level}: {phenomenon}" + alert_messages.append(msg) + if max_risk_in_df > max_risk_level: + max_risk_level = max_risk_in_df + except Exception as e: + if DEBUG_MODE: + print(f" ⚠️ {model_name}: Errore analisi avanzata: {e}") + # Fallback alla vecchia logica + icon_data = all_models_data.get("icon_eu") if model_slug == "meteofrance_seamless" else None + risk, msg = analyze_risk(data, model_slug, icon_weather_data=icon_data) + if risk > 0: + triggered_models.append(model_name) + alert_messages.append(msg) + if risk > max_risk_level: + max_risk_level = risk # Salvataggio stato (prendiamo il rischio massimo rilevato tra i modelli) current_state[pid] = max_risk_level old_level = previous_state.get(pid, 0) maps_link = generate_maps_link(point["lat"], point["lon"]) + + # Aggiungi punto ai dati per la mappa + map_points_data.append({ + "name": point["name"], + "lat": point["lat"], + "lon": point["lon"], + "risk_level": max_risk_level + }) # --- LOGICA NOTIFICHE --- - # 1. Nessun cambiamento di LIVELLO + if DEBUG_MODE: + print(f" Stato: old_level={old_level}, max_risk_level={max_risk_level}, triggered_models={triggered_models}") + + # 1. Nessun cambiamento di LIVELLO - non inviare (anti-spam) if max_risk_level == old_level: + if DEBUG_MODE: + print(f" ⏭️ Skip: rischio invariato ({max_risk_level})") continue - # 2. Nuovo Rischio o Aggravamento + # 2. Nuovo Rischio o Aggravamento (rischio aumenta) if max_risk_level > old_level: + if DEBUG_MODE: + print(f" 📈 Nuovo/aggravamento: {old_level} → {max_risk_level}") # Creiamo una stringa che dice chi ha rilevato cosa sources = " + ".join(triggered_models) # Prendiamo il messaggio del rischio più alto (o il primo) main_msg = alert_messages[0] if alert_messages else "Dati incerti" + # Aggiungi informazioni sulle 24h precedenti se disponibili + past_24h_details = [] + for model_name, model_slug in MODELS_TO_CHECK.items(): + data = all_models_data.get(model_slug) + if data: + _, _, _, past_24h_info = check_ice_persistence_conditions(data, model_slug, hours_check=12) + if past_24h_info: + # Informazioni rilevanti sulle 24h precedenti + if past_24h_info.get("precipitation_with_freeze", False): + precip_str = [] + if past_24h_info.get("total_rain_mm", 0) > 0.1: + precip_str.append(f"Pioggia: {past_24h_info['total_rain_mm']:.1f}mm") + if past_24h_info.get("total_snowfall_cm", 0) > 0.1: + precip_str.append(f"Neve: {past_24h_info['total_snowfall_cm']:.1f}cm") + if precip_str: + past_24h_details.append(f"⬅️ {model_name}: {', '.join(precip_str)} con T<0°C") + if past_24h_info.get("ice_formation_likely", False): + past_24h_details.append(f"🧊 {model_name}: Formazione ghiaccio probabile nelle 24h precedenti") + final_msg = (f"📍 {point['name']} {maps_link}\n" f"{main_msg}\n" f"📡 Rilevato da: {sources}") + + # Aggiungi informazioni 24h precedenti se disponibili + if past_24h_details: + final_msg += f"\n\n📊 Analisi 24h precedenti:\n" + for detail in past_24h_details: + final_msg += f"{detail}\n" + new_alerts.append(final_msg) # 3. Rischio Cessato (Tutti i modelli danno verde) + # IMPORTANTE: Non inviare "allerta rientrata" se ci sono ancora condizioni che mantengono il ghiaccio + # (neve presente o temperature vicine allo zero) elif max_risk_level == 0 and old_level > 0: - solved_alerts.append(f"✅ {point['name']} {maps_link}: Rischio rientrato (Tutti i modelli).") + # Verifica se ci sono condizioni che mantengono il ghiaccio già formato + # Controlla tutti i modelli disponibili per avere una visione completa + ice_persists = False + persistence_details = [] + all_past_24h_info = [] - # 4. Aggiornamento (es. Da Ghiaccio a Brina) - elif max_risk_level > 0: - sources = " + ".join(triggered_models) - main_msg = alert_messages[0] - new_alerts.append(f"📍 {point['name']} {maps_link} [AGGIORNAMENTO]\n{main_msg}\n📡 Fonte: {sources}") + for model_name, model_slug in MODELS_TO_CHECK.items(): + data = all_models_data.get(model_slug) + if data: + has_snow, has_cold, details, past_24h_info = check_ice_persistence_conditions(data, model_slug, hours_check=12) + if has_snow or has_cold: + ice_persists = True + if details: + persistence_details.append(f"{model_name}: {details}") + # Raccogli informazioni 24h precedenti + if past_24h_info: + all_past_24h_info.append((model_name, past_24h_info)) + + if ice_persists: + # Non inviare "allerta rientrata" perché il ghiaccio potrebbe ancora essere presente + # Ma aggiungi informazioni dettagliate sulla persistenza + if DEBUG_MODE: + print(f" ⏸️ Rischio cessato ma condizioni persistenti: {', '.join(persistence_details)}") + + # Costruisci messaggio dettagliato con informazioni 24h precedenti + persist_msg = f"⏸️ {point['name']} {maps_link}: Rischio cessato ma persistenza ghiaccio possibile\n" + persist_msg += f"📊 Condizioni persistenti: {', '.join(persistence_details) if persistence_details else 'ghiaccio residuo possibile'}\n" + + # Aggiungi dettagli 24h precedenti se disponibili + for model_name, past_info in all_past_24h_info: + if past_info.get("has_precipitation", False): + precip_details = [] + if past_info.get("total_rain_mm", 0) > 0.1: + precip_details.append(f"Pioggia: {past_info['total_rain_mm']:.1f}mm") + if past_info.get("total_snowfall_cm", 0) > 0.1: + precip_details.append(f"Neve: {past_info['total_snowfall_cm']:.1f}cm") + if past_info.get("total_showers_mm", 0) > 0.1: + precip_details.append(f"Rovesci: {past_info['total_showers_mm']:.1f}mm") + if precip_details: + persist_msg += f"⬅️ {model_name} ultime 24h: {', '.join(precip_details)}\n" + + if past_info.get("precipitation_with_freeze", False): + persist_msg += f"🧊 {model_name}: Precipitazioni con T<0°C nelle 24h precedenti\n" + if past_info.get("ice_melting_likely", False): + persist_msg += f"☀️ {model_name}: Scioglimento probabile (T salita sopra soglia)\n" + + # Non aggiungere a solved_alerts - il ghiaccio potrebbe ancora essere presente + # Ma potremmo inviare un messaggio informativo se in debug mode + if DEBUG_MODE: + new_alerts.append(persist_msg) + else: + # Condizioni completamente risolte: neve sciolta e temperature sopra lo zero + if DEBUG_MODE: + print(f" ✅ Rischio cessato: {old_level} → 0 (condizioni completamente risolte)") + + # Limita i report di miglioramento a 3 al giorno (ore 7:00, 15:00, 23:00) + if not is_improvement_report_allowed(): + if DEBUG_MODE: + current_hour = datetime.datetime.now().hour + print(f" ⏸️ Report miglioramento saltato: ora {current_hour} non è tra 7, 15, 23") + continue + + # Verifica se c'è stato scioglimento nelle 24h precedenti + melting_info = [] + for model_name, past_info in all_past_24h_info: + if past_info.get("ice_melting_likely", False): + melting_info.append(model_name) + + solved_msg = f"✅ {point['name']} {maps_link}: Rischio rientrato" + if melting_info: + solved_msg += f" (Scioglimento confermato: {', '.join(melting_info)})" + else: + solved_msg += " (Tutti i modelli)" + solved_alerts.append(solved_msg) + + # 4. Rischio Diminuito (es. Da Ghiaccio a Brina, o da Brina a nessun rischio ma non ancora 0) + elif max_risk_level < old_level and max_risk_level > 0: + if DEBUG_MODE: + print(f" 📉 Rischio diminuito: {old_level} → {max_risk_level}") + + # Limita i report di miglioramento a 3 al giorno (ore 7:00, 15:00, 23:00) + if not is_improvement_report_allowed(): + if DEBUG_MODE: + current_hour = datetime.datetime.now().hour + print(f" ⏸️ Report miglioramento saltato: ora {current_hour} non è tra 7, 15, 23") + continue + + sources = " + ".join(triggered_models) + main_msg = alert_messages[0] if alert_messages else "Dati incerti" + + # Aggiungi informazioni sulle 24h precedenti se disponibili + past_24h_details = [] + for model_name, model_slug in MODELS_TO_CHECK.items(): + data = all_models_data.get(model_slug) + if data: + _, _, _, past_24h_info = check_ice_persistence_conditions(data, model_slug, hours_check=12) + if past_24h_info: + # Informazioni rilevanti sulle 24h precedenti + if past_24h_info.get("ice_melting_likely", False): + past_24h_details.append(f"☀️ {model_name}: Scioglimento in corso (T salita sopra soglia)") + elif past_24h_info.get("precipitation_with_freeze", False): + past_24h_details.append(f"⚠️ {model_name}: Possibile ghiaccio residuo (precipitazioni con T<0°C nelle 24h precedenti)") + + improvement_msg = f"📍 {point['name']} {maps_link} [MIGLIORAMENTO]\n{main_msg}\n📡 Fonte: {sources}" + + # Aggiungi informazioni 24h precedenti se disponibili + if past_24h_details: + improvement_msg += f"\n\n📊 Analisi 24h precedenti:\n" + for detail in past_24h_details: + improvement_msg += f"{detail}\n" + + new_alerts.append(improvement_msg) - # Invio - messages_to_send = [] - - if new_alerts: - messages_to_send.append("❄️ ALLERTA GHIACCIO STRADALE ❄️\n" + "\n\n".join(new_alerts)) - - if solved_alerts: - messages_to_send.append("ℹ️ ALLARMI CESSATI\n" + "\n".join(solved_alerts)) - - if messages_to_send: - full_message = "\n\n".join(messages_to_send) - send_telegram_broadcast(token, full_message, debug_mode=DEBUG_MODE) - print("Notifiche inviate.") - else: - print("Nessuna variazione.") + # Genera e invia mappa solo quando ci sono aggiornamenti + if new_alerts or solved_alerts: if DEBUG_MODE: - send_telegram_broadcast(token, "Nessuna variazione (Check Debug OK).", debug_mode=True) + print(f"Generazione mappa per {len(map_points_data)} punti...") + map_path = os.path.join(SCRIPT_DIR, "ice_risk_map.png") + map_generated = generate_ice_risk_map(map_points_data, map_path) + if map_generated: + if DEBUG_MODE: + print(f"Mappa generata con successo: {map_path}") + now = datetime.datetime.now() + caption = ( + f"🧊 RISCHIO GHIACCIO STRADALE - San Marino\n" + f"🕒 {now.strftime('%d/%m/%Y %H:%M')}\n" + f"📊 Punti monitorati: {len(map_points_data)}" + ) + 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}") + print("Mappa inviata.") + else: + if DEBUG_MODE: + print("Errore nella generazione della mappa.") + else: + if DEBUG_MODE: + print("Nessuna variazione - mappa non inviata.") + else: + print("Nessuna variazione.") if not DEBUG_MODE: save_current_state(current_state) diff --git a/services/telegram-bot/civil_protection.py b/services/telegram-bot/civil_protection.py index 95ebf70..187e5dc 100644 --- a/services/telegram-bot/civil_protection.py +++ b/services/telegram-bot/civil_protection.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import argparse import datetime import html as html_lib import json @@ -10,6 +11,7 @@ import re import time from html.parser import HTMLParser from logging.handlers import RotatingFileHandler +from typing import List, Optional from zoneinfo import ZoneInfo import requests @@ -126,17 +128,24 @@ def load_bot_token() -> str: return "" -def telegram_send_html(message_html: str) -> bool: +def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) -> bool: """ Prova a inviare il messaggio. Non solleva eccezioni. Ritorna True se almeno un invio ha avuto status 200. Importante: lo script chiama questa funzione SOLO in caso di allerte. + + Args: + message_html: Messaggio HTML da inviare + chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) """ token = load_bot_token() if not token: LOGGER.warning("Token Telegram assente. Nessun invio effettuato.") return False + if chat_ids is None: + chat_ids = TELEGRAM_CHAT_IDS + url = f"https://api.telegram.org/bot{token}/sendMessage" base_payload = { "text": message_html, @@ -146,7 +155,7 @@ def telegram_send_html(message_html: str) -> bool: sent_ok = False with requests.Session() as s: - for chat_id in TELEGRAM_CHAT_IDS: + for chat_id in chat_ids: payload = dict(base_payload) payload["chat_id"] = chat_id try: @@ -322,7 +331,7 @@ def format_message(parsed: dict) -> str: # Main # ============================================================================= -def main(): +def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False): LOGGER.info("--- Controllo Protezione Civile (Bollettino ufficiale) ---") try: @@ -340,8 +349,20 @@ def main(): LOGGER.debug("%s label=%s", k, d.get("date_label", "")) LOGGER.debug("%s alerts=%s", k, d.get("alerts", {})) - # Regola: invia Telegram SOLO se esistono allerte + # Regola: invia Telegram SOLO se esistono allerte (tranne in debug) if not has_any_alert(parsed): + if debug_mode: + # In modalità debug, crea un messaggio informativo anche se non ci sono allerte + LOGGER.info("[DEBUG MODE] Nessuna allerta, ma creo messaggio informativo") + msg = format_message(parsed) + # Aggiungi prefisso per indicare che non ci sono allerte + msg = f"ℹ️ DEBUG: Nessuna allerta attiva\n\n{msg}" + sent_ok = telegram_send_html(msg, chat_ids=chat_ids) + if sent_ok: + LOGGER.info("Messaggio debug inviato con successo.") + else: + LOGGER.warning("Invio debug non riuscito (token mancante o errore Telegram).") + else: LOGGER.info("Nessuna allerta nelle zone monitorate. Nessuna notifica inviata.") return @@ -349,13 +370,16 @@ def main(): state = load_state() last_sig = state.get("last_alert_signature", "") - if sig == last_sig: + # In modalità debug, bypassa controlli anti-spam + if debug_mode: + LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato") + elif sig == last_sig: LOGGER.info("Allerta già notificata e invariata. Nessuna nuova notifica.") return # A questo punto: ci sono allerte e sono nuove -> prova invio msg = format_message(parsed) - sent_ok = telegram_send_html(msg) + sent_ok = telegram_send_html(msg, chat_ids=chat_ids) if sent_ok: LOGGER.info("Notifica allerta inviata con successo.") @@ -368,4 +392,11 @@ def main(): LOGGER.warning("Invio non riuscito (token mancante o errore Telegram). Stato NON aggiornato.") if __name__ == "__main__": - main() + parser = argparse.ArgumentParser(description="Civil protection alert") + parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0]) + args = parser.parse_args() + + # In modalità debug, invia solo al primo chat ID (admin) e bypassa anti-spam + chat_ids = [TELEGRAM_CHAT_IDS[0]] if args.debug else None + + main(chat_ids=chat_ids, debug_mode=args.debug) diff --git a/services/telegram-bot/daily_report.py b/services/telegram-bot/daily_report.py index 61a4dcc..5cdc64c 100644 --- a/services/telegram-bot/daily_report.py +++ b/services/telegram-bot/daily_report.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import argparse import sqlite3 import os import datetime @@ -92,7 +93,12 @@ def load_bot_token() -> str: return tok.strip() if tok else "" -def send_telegram_message(message: str) -> None: +def send_telegram_message(message: str, chat_ids: Optional[List[str]] = None) -> None: + """ + Args: + message: Messaggio da inviare + chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) + """ if not message: return @@ -101,9 +107,12 @@ def send_telegram_message(message: str) -> None: LOGGER.error("Token Telegram mancante (env/file). Messaggio NON inviato.") return + if chat_ids is None: + chat_ids = TELEGRAM_CHAT_IDS + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" - for chat_id in TELEGRAM_CHAT_IDS: + for chat_id in chat_ids: payload = { "chat_id": chat_id, "text": message, @@ -312,7 +321,7 @@ def generate_report(db_path: str) -> Optional[str]: return msg -def main() -> None: +def main(chat_ids: Optional[List[str]] = None) -> None: db_path = find_local_db_path() if not db_path: db_path = docker_copy_db_to_temp() @@ -329,10 +338,17 @@ def main() -> None: report = generate_report(db_path) if report: - send_telegram_message(report) + send_telegram_message(report, chat_ids=chat_ids) else: LOGGER.info("Nessun report da inviare.") if __name__ == "__main__": - main() + parser = argparse.ArgumentParser(description="Daily report") + parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0]) + args = parser.parse_args() + + # In modalità debug, invia solo al primo chat ID (admin) + chat_ids = [TELEGRAM_CHAT_IDS[0]] if args.debug else None + + main(chat_ids=chat_ids) diff --git a/services/telegram-bot/freeze_alert.py b/services/telegram-bot/freeze_alert.py index d2620c3..cabd820 100644 --- a/services/telegram-bot/freeze_alert.py +++ b/services/telegram-bot/freeze_alert.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import argparse import datetime import html import json @@ -8,7 +9,7 @@ import logging import os import time from logging.handlers import RotatingFileHandler -from typing import Dict, Optional, Tuple +from typing import Dict, List, Optional, Tuple from zoneinfo import ZoneInfo import requests @@ -46,14 +47,14 @@ LON = 12.4296 LOCATION_NAME = "🏠 Casa (Strada Cà Toro)" # ----------------- THRESHOLD ----------------- -SOGLIA_GELO = 0.0 # °C (allerta se min < 0.0°C) +SOGLIA_GELO = 0.0 # °C (allerta se min <= 0.0°C, include anche temperature esattamente a zero) # ----------------- HORIZON ----------------- HOURS_AHEAD = 48 FORECAST_DAYS = 3 # per coprire bene 48h # ----------------- TIMEZONE ----------------- -TZ = "Europe/Rome" +TZ = "Europe/Berlin" TZINFO = ZoneInfo(TZ) # ----------------- FILES ----------------- @@ -143,16 +144,23 @@ def fmt_dt(dt: datetime.datetime) -> str: # ============================================================================= # TELEGRAM # ============================================================================= -def telegram_send_html(message_html: str) -> bool: +def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) -> bool: """ Non solleva eccezioni. Ritorna True se almeno un invio ha successo. IMPORTANTE: chiamare solo per allerte (mai per errori). + + Args: + message_html: Messaggio HTML da inviare + chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) """ token = load_bot_token() if not token: LOGGER.warning("Telegram token missing: message not sent.") return False + if chat_ids is None: + chat_ids = TELEGRAM_CHAT_IDS + url = f"https://api.telegram.org/bot{token}/sendMessage" base_payload = { "text": message_html, @@ -162,7 +170,7 @@ def telegram_send_html(message_html: str) -> bool: sent_ok = False with requests.Session() as s: - for chat_id in TELEGRAM_CHAT_IDS: + for chat_id in chat_ids: payload = dict(base_payload) payload["chat_id"] = chat_id try: @@ -189,12 +197,16 @@ def load_state() -> Dict: "min_time": "", "signature": "", "updated": "", + "notified_periods": [], # Lista di fasce orarie già notificate: [{"start": iso, "end": iso}, ...] } if os.path.exists(STATE_FILE): try: with open(STATE_FILE, "r", encoding="utf-8") as f: data = json.load(f) or {} default.update(data) + # Assicura che notified_periods esista + if "notified_periods" not in default: + default["notified_periods"] = [] except Exception as e: LOGGER.exception("State read error: %s", e) return default @@ -220,6 +232,8 @@ def get_forecast() -> Optional[Dict]: "hourly": "temperature_2m", "timezone": TZ, "forecast_days": FORECAST_DAYS, + "models": "meteofrance_seamless", # Usa seamless per avere minutely_15 + "minutely_15": "temperature_2m", # Dettaglio 15 minuti per inizio preciso gelo } try: r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25) @@ -237,50 +251,159 @@ def get_forecast() -> Optional[Dict]: return None -def compute_min_next_48h(data: Dict) -> Optional[Tuple[float, datetime.datetime]]: +def compute_freezing_periods(data: Dict) -> Tuple[Optional[float], Optional[datetime.datetime], List[Tuple[datetime.datetime, datetime.datetime]]]: + """ + Calcola la temperatura minima e tutte le fasce orarie con gelo (temp <= 0°C). + + Returns: + (min_temp_val, min_temp_time, freezing_periods) + freezing_periods: lista di tuple (start_time, end_time) per ogni fascia oraria con gelo + """ hourly = data.get("hourly", {}) or {} + minutely = data.get("minutely_15", {}) or {} times = hourly.get("time", []) or [] temps = hourly.get("temperature_2m", []) or [] - - n = min(len(times), len(temps)) - if n == 0: - return None + + LOGGER.debug("Dati hourly: %d timestamps, %d temperature", len(times), len(temps)) + + # Usa minutely_15 se disponibile per maggiore precisione + minutely_times = minutely.get("time", []) or [] + minutely_temps = minutely.get("temperature_2m", []) or [] + use_minutely = bool(minutely_times) and len(minutely_times) > 0 + + LOGGER.debug("Dati minutely_15: %d timestamps, %d temperature, use_minutely=%s", + len(minutely_times), len(minutely_temps), use_minutely) now = now_local() limit_time = now + datetime.timedelta(hours=HOURS_AHEAD) + LOGGER.debug("Finestra temporale: da %s a %s", now.isoformat(), limit_time.isoformat()) min_temp_val = 100.0 min_temp_time: Optional[datetime.datetime] = None + freezing_periods: List[Tuple[datetime.datetime, datetime.datetime]] = [] + temps_near_zero = [] # Per debug: temperature vicine allo zero (0-2°C) - for i in range(n): - try: - t_obj = parse_time_to_local(times[i]) - except Exception: - continue + # Priorità a minutely_15 se disponibile (risoluzione 15 minuti) + if use_minutely: + for i, t_str in enumerate(minutely_times): + try: + t_obj = parse_time_to_local(t_str) + except Exception: + continue - # solo intervallo (now, now+48h] - if t_obj <= now or t_obj > limit_time: - continue + if t_obj <= now or t_obj > limit_time: + continue - try: - temp = float(temps[i]) - except Exception: - continue + try: + temp = float(minutely_temps[i]) if i < len(minutely_temps) and minutely_temps[i] is not None else 100.0 + except Exception: + continue - if temp < min_temp_val: - min_temp_val = temp - min_temp_time = t_obj + # Raccogli temperature vicine allo zero per debug + if 0.0 <= temp <= 2.0: + temps_near_zero.append((temp, t_obj)) + + if temp < min_temp_val: + min_temp_val = temp + min_temp_time = t_obj + else: + # Fallback a hourly + n = min(len(times), len(temps)) + if n == 0: + return None, None, [] + + for i in range(n): + try: + t_obj = parse_time_to_local(times[i]) + except Exception: + continue + + if t_obj <= now or t_obj > limit_time: + continue + + try: + temp = float(temps[i]) + except Exception: + continue + + # Raccogli temperature vicine allo zero per debug + if 0.0 <= temp <= 2.0: + temps_near_zero.append((temp, t_obj)) + + if temp < min_temp_val: + min_temp_val = temp + min_temp_time = t_obj + + # Raggruppa le temperature <= 0°C in fasce orarie continue + # Una fascia oraria è un periodo continuo di tempo con temperatura <= 0°C + freezing_times: List[datetime.datetime] = [] + if use_minutely: + for i, t_str in enumerate(minutely_times): + try: + t_obj = parse_time_to_local(t_str) + except Exception: + continue + if t_obj <= now or t_obj > limit_time: + continue + try: + temp = float(minutely_temps[i]) if i < len(minutely_temps) and minutely_temps[i] is not None else 100.0 + except Exception: + continue + if temp <= SOGLIA_GELO: + freezing_times.append(t_obj) + else: + for i in range(min(len(times), len(temps))): + try: + t_obj = parse_time_to_local(times[i]) + except Exception: + continue + if t_obj <= now or t_obj > limit_time: + continue + try: + temp = float(temps[i]) + except Exception: + continue + if temp <= SOGLIA_GELO: + freezing_times.append(t_obj) + + # Raggruppa in fasce orarie continue (max gap di 1 ora tra due timestamp consecutivi) + if freezing_times: + freezing_times.sort() + current_start = freezing_times[0] + current_end = freezing_times[0] + + for t in freezing_times[1:]: + # Se il gap è > 1 ora, chiudi la fascia corrente e inizia una nuova + if (t - current_end).total_seconds() > 3600: + freezing_periods.append((current_start, current_end)) + current_start = t + current_end = t + # Aggiungi l'ultima fascia + freezing_periods.append((current_start, current_end)) if min_temp_time is None: - return None + LOGGER.warning("Nessuna temperatura minima trovata nella finestra temporale") + return None, None, [] - return float(min_temp_val), min_temp_time + LOGGER.debug("Temperatura minima trovata: %.1f°C alle %s", min_temp_val, min_temp_time.isoformat()) + LOGGER.info("Fasce orarie con gelo rilevate: %d", len(freezing_periods)) + for i, (start, end) in enumerate(freezing_periods[:5]): # Mostra prime 5 + LOGGER.info(" Fascia %d: %s - %s", i+1, start.strftime("%d/%m %H:%M"), end.strftime("%d/%m %H:%M")) + + # Log temperature vicine allo zero per debug + if temps_near_zero: + temps_near_zero.sort(key=lambda x: x[0]) # Ordina per temperatura + LOGGER.info("Temperature vicine allo zero (0-2°C) rilevate: %d occorrenze", len(temps_near_zero)) + for temp, t_obj in temps_near_zero[:5]: # Mostra prime 5 + LOGGER.info(" %.1f°C alle %s", temp, t_obj.strftime("%d/%m %H:%M")) + + return float(min_temp_val), min_temp_time, freezing_periods # ============================================================================= # MAIN # ============================================================================= -def analyze_freeze() -> None: +def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None: LOGGER.info("--- Controllo Gelo (next %sh) ---", HOURS_AHEAD) data = get_forecast() @@ -288,52 +411,112 @@ def analyze_freeze() -> None: # errori: solo log return - result = compute_min_next_48h(data) - if not result: + result = compute_freezing_periods(data) + if result[0] is None: LOGGER.error("Impossibile calcolare minima nelle prossime %s ore.", HOURS_AHEAD) return - min_temp_val, min_temp_time = result - is_freezing = (min_temp_val < SOGLIA_GELO) + min_temp_val, min_temp_time, freezing_periods = result + LOGGER.info("Temperatura minima rilevata: %.1f°C alle %s", min_temp_val, min_temp_time.isoformat()) + LOGGER.info("Soglia gelo: %.1f°C", SOGLIA_GELO) + + # Segnala se temperatura <= soglia (include anche 0.0°C e temperature vicine allo zero) + # Cambiato da < a <= per includere anche temperature esattamente a 0.0°C + is_freezing = (min_temp_val <= SOGLIA_GELO) + LOGGER.info("Condizione gelo: min_temp_val (%.1f) <= SOGLIA_GELO (%.1f) = %s", + min_temp_val, SOGLIA_GELO, is_freezing) state = load_state() was_active = bool(state.get("alert_active", False)) - last_sig = str(state.get("signature", "")) + notified_periods = state.get("notified_periods", []) + LOGGER.info("Stato precedente: alert_active=%s, last_min_temp=%.1f, notified_periods=%d", + was_active, state.get("min_temp", 100.0), len(notified_periods)) - # firma per evitare spam: temp (0.1) + timestamp - sig = f"{min_temp_val:.1f}|{min_temp_time.isoformat()}" + # Verifica se ci sono nuove fasce orarie con gelo non ancora notificate + new_periods = [] + for period_start, period_end in freezing_periods: + is_new = True + for notified in notified_periods: + # Una fascia è considerata "già notificata" se si sovrappone significativamente + # (almeno 1 ora di sovrapposizione) con una fascia già notificata + try: + notif_start = parser.isoparse(notified["start"]) + notif_end = parser.isoparse(notified["end"]) + # Calcola sovrapposizione + overlap_start = max(period_start, notif_start) + overlap_end = min(period_end, notif_end) + if overlap_start < overlap_end: + overlap_hours = (overlap_end - overlap_start).total_seconds() / 3600 + if overlap_hours >= 1.0: # Almeno 1 ora di sovrapposizione + is_new = False + break + except Exception: + continue + if is_new: + new_periods.append((period_start, period_end)) - if is_freezing: # invia se: # - prima non era attivo, oppure - # - peggiora di almeno 2°C, oppure - # - cambia la firma (es. orario minima spostato o min diversa) + # - peggiora di almeno 2°C rispetto alla minima precedente, oppure + # - c'è almeno una nuova fascia oraria con gelo non ancora notificata prev_min = float(state.get("min_temp", 100.0) or 100.0) + has_new_periods = len(new_periods) > 0 + significant_worsening = (min_temp_val < prev_min - 2.0) - should_notify = (not was_active) or (min_temp_val < prev_min - 2.0) or (sig != last_sig) + should_notify = (not was_active) or significant_worsening or has_new_periods + + # In modalità debug, bypassa tutti i controlli anti-spam e invia sempre + if debug_mode: + should_notify = True + LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato") + + LOGGER.info("Nuove fasce orarie con gelo: %d (notificate: %d)", len(new_periods), len(notified_periods)) + if has_new_periods: + for i, (start, end) in enumerate(new_periods): + LOGGER.info(" Nuova fascia %d: %s - %s", i+1, start.strftime("%d/%m %H:%M"), end.strftime("%d/%m %H:%M")) if should_notify: - msg = ( - "❄️ ALLERTA GELO
" - f"📍 {html.escape(LOCATION_NAME)}

" - f"Minima prevista (entro {HOURS_AHEAD}h): {min_temp_val:.1f}°C
" - f"📅 Quando: {html.escape(fmt_dt(min_temp_time))}

" - "Proteggere piante e tubature esterne." - ) - ok = telegram_send_html(msg) + # Costruisci messaggio con dettagli sulle nuove fasce orarie + period_details = [] + if has_new_periods: + for start, end in new_periods[:3]: # Max 3 fasce nel messaggio + if start.date() == end.date(): + period_details.append(f"{start.strftime('%d/%m %H:%M')}-{end.strftime('%H:%M')}") + else: + period_details.append(f"{start.strftime('%d/%m %H:%M')}-{end.strftime('%d/%m %H:%M')}") + + msg_parts = [ + "❄️ ALLERTA GELO\n", + f"📍 {html.escape(LOCATION_NAME)}\n\n", + f"Minima prevista (entro {HOURS_AHEAD}h): {min_temp_val:.1f}°C\n", + f"📅 Quando: {html.escape(fmt_dt(min_temp_time))}", + ] + if period_details: + msg_parts.append("\n🕒 Fasce orarie: " + ", ".join(period_details)) + msg_parts.append("\n\nProteggere piante e tubature esterne.") + + msg = "".join(msg_parts) + ok = telegram_send_html(msg, chat_ids=chat_ids) if ok: - LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s", min_temp_val, min_temp_time.isoformat()) + LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s, nuove fasce: %d", + min_temp_val, min_temp_time.isoformat(), len(new_periods)) + # Aggiorna le fasce notificate + 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).") else: - LOGGER.info("Gelo già notificato (invariato o peggioramento < 2°C). Tmin=%.1f°C", min_temp_val) + LOGGER.info("Gelo già notificato (nessuna nuova fascia oraria, peggioramento < 2°C). Tmin=%.1f°C", min_temp_val) state.update({ "alert_active": True, "min_temp": min_temp_val, "min_time": min_temp_time.isoformat(), - "signature": sig, + "notified_periods": notified_periods, }) save_state(state) return @@ -341,12 +524,12 @@ def analyze_freeze() -> None: # --- RIENTRO --- if was_active and not is_freezing: msg = ( - "☀️ RISCHIO GELO RIENTRATO
" - f"📍 {html.escape(LOCATION_NAME)}

" - f"Le previsioni nelle prossime {HOURS_AHEAD} ore indicano temperature sopra lo zero.
" + "☀️ RISCHIO GELO RIENTRATO\n" + f"📍 {html.escape(LOCATION_NAME)}\n\n" + f"Le previsioni nelle prossime {HOURS_AHEAD} ore indicano temperature sopra lo zero.\n" f"Minima prevista: {min_temp_val:.1f}°C (alle {html.escape(fmt_dt(min_temp_time))})." ) - ok = telegram_send_html(msg) + ok = telegram_send_html(msg, chat_ids=chat_ids) if ok: LOGGER.info("Rientro gelo notificato. Tmin=%.1f°C", min_temp_val) else: @@ -356,7 +539,7 @@ def analyze_freeze() -> None: "alert_active": False, "min_temp": min_temp_val, "min_time": min_temp_time.isoformat(), - "signature": "", + "notified_periods": [], # Reset quando il gelo rientra }) save_state(state) return @@ -366,11 +549,18 @@ def analyze_freeze() -> None: "alert_active": False, "min_temp": min_temp_val, "min_time": min_temp_time.isoformat(), - "signature": "", + "notified_periods": [], # Reset quando non c'è gelo }) save_state(state) LOGGER.info("Nessun gelo. Tmin=%.1f°C at %s", min_temp_val, min_temp_time.isoformat()) if __name__ == "__main__": - analyze_freeze() + arg_parser = argparse.ArgumentParser(description="Freeze alert") + arg_parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0]) + args = arg_parser.parse_args() + + # In modalità debug, invia solo al primo chat ID (admin) e bypassa anti-spam + chat_ids = [TELEGRAM_CHAT_IDS[0]] if args.debug else None + + analyze_freeze(chat_ids=chat_ids, debug_mode=args.debug) diff --git a/services/telegram-bot/meteo.py b/services/telegram-bot/meteo.py index cd0703c..97b4daf 100644 --- a/services/telegram-bot/meteo.py +++ b/services/telegram-bot/meteo.py @@ -4,8 +4,11 @@ import datetime import argparse import sys import logging +import os +import time +from typing import Optional, List from zoneinfo import ZoneInfo -from dateutil import parser as date_parser # pyright: ignore[reportMissingModuleSource] +from dateutil import parser as date_parser # Setup logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') @@ -15,16 +18,77 @@ logger = logging.getLogger(__name__) HOME_LAT = 43.9356 HOME_LON = 12.4296 HOME_NAME = "🏠 Casa (Wide View ±12km)" -TZ = "Europe/Rome" +TZ = "Europe/Berlin" TZINFO = ZoneInfo(TZ) -# Offset ~12-15km +# Offset ~12-15km per i 5 punti OFFSET_LAT = 0.12 OFFSET_LON = 0.16 OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search" -HTTP_HEADERS = {"User-Agent": "loogle-bot-v10.4"} +HTTP_HEADERS = {"User-Agent": "loogle-bot-v10.5"} + +# --- TELEGRAM CONFIG --- +TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token") +TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token" + +def read_text_file(path: str) -> str: + try: + with open(path, "r", encoding="utf-8") as f: + return f.read().strip() + except FileNotFoundError: + return "" + except PermissionError: + return "" + except Exception as e: + logger.error(f"Error reading {path}: {e}") + return "" + +def load_bot_token() -> str: + tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() + if tok: + return tok + tok = read_text_file(TOKEN_FILE_HOME) + if tok: + return tok + tok = read_text_file(TOKEN_FILE_ETC) + return tok.strip() if tok else "" + +def telegram_send_markdown(message_md: str, chat_ids: Optional[List[str]] = None) -> bool: + """Invia messaggio Markdown a Telegram. Returns True se almeno un invio è riuscito.""" + token = load_bot_token() + if not token: + logger.warning("Telegram token missing: message not sent.") + return False + + if chat_ids is None: + return False # Se non specificato, non inviare + + url = f"https://api.telegram.org/bot{token}/sendMessage" + base_payload = { + "text": message_md, + "parse_mode": "Markdown", + "disable_web_page_preview": True, + } + + sent_ok = False + with requests.Session() as s: + for chat_id in chat_ids: + payload = dict(base_payload) + payload["chat_id"] = chat_id + try: + resp = s.post(url, json=payload, timeout=15) + if resp.status_code == 200: + sent_ok = True + else: + logger.error("Telegram error chat_id=%s status=%s body=%s", + chat_id, resp.status_code, resp.text[:500]) + time.sleep(0.25) + except Exception as e: + logger.exception("Telegram exception chat_id=%s err=%s", chat_id, e) + + return sent_ok def now_local() -> datetime.datetime: return datetime.datetime.now(TZINFO) @@ -44,15 +108,30 @@ def degrees_to_cardinal(d: int) -> str: return dirs[round(d / 45) % 8] except: return "N" +# --- HELPER SICUREZZA DATI --- +def get_val(val, default=0.0): + if val is None: return default + return float(val) + +def safe_get_list(hourly_data, key, length, default=None): + if key in hourly_data and hourly_data[key] is not None: + return hourly_data[key] + return [default] * length + def get_icon_set(prec, snow, code, is_day, cloud, vis, temp, rain, gust, cape, cloud_type): sky = "☁️" try: + # LOGICA NEVE (v10.5 Fix): + # È neve se c'è accumulo OPPURE se il codice meteo dice neve (anche senza accumulo) + is_snowing = snow > 0 or (code in [71, 73, 75, 77, 85, 86]) + if cloud_type == 'F': sky = "🌫️" elif code in (95, 96, 99): sky = "⛈️" if prec > 0 else "🌩️" - elif prec >= 0.1: sky = "🌨️" if snow > 0 else "🌧️" + elif prec >= 0.1: + sky = "🌨️" if is_snowing else "🌧️" else: - # LOGICA PERCEZIONE UMANA + # LOGICA PERCEZIONE UMANA (Nubi Alte vs Basse) if cloud_type == 'H': if cloud <= 40: sky = "☀️" if is_day else "🌙" elif cloud <= 80: sky = "🌤️" if is_day else "🌙" @@ -65,7 +144,8 @@ def get_icon_set(prec, snow, code, is_day, cloud, vis, temp, rain, gust, cape, c else: sky = "☁️" sgx = "-" - if snow > 0 or (code is not None and code in (71,73,75,77,85,86)): sgx = "☃️" + # Simbolo laterale (Priorità agli eventi pericolosi) + if is_snowing: sgx = "☃️" elif temp < 0 or (code is not None and code in (66,67)): sgx = "🧊" elif cape > 2000: sgx = "🌪️" elif cape > 1000: sgx = "⚡" @@ -92,15 +172,36 @@ def get_coordinates(city_name: str): logger.error(f"Geocoding error: {e}") return None -def choose_best_model(lat, lon, cc): - if cc == 'JP': return "jma_msm", "JMA MSM" - if cc in ['NO', 'SE', 'FI', 'DK', 'IS']: return "metno_nordic", "Yr.no" - if cc in ['GB', 'IE']: return "ukmo_global", "UK MetOffice" - if cc == 'IT' or cc == 'SM': return "meteofrance_arome_france_hd", "AROME HD" - if cc in ['DE', 'AT', 'CH', 'LI', 'FR']: return "icon_d2", "ICON-D2" - return "gfs_global", "NOAA GFS" +def choose_best_model(lat, lon, cc, is_home=False): + """ + Sceglie il modello meteo. + - Per Casa: usa AROME Seamless (ha snowfall) + - Per altre località: usa best match di Open-Meteo (senza specificare models) + """ + if is_home: + # Per Casa, usa AROME Seamless (ha snowfall e dati dettagliati) + return "meteofrance_seamless", "AROME HD" + else: + # Per query worldwide, usa best match (Open-Meteo sceglie automaticamente) + return None, "Best Match" -def get_forecast(lat, lon, model): +def get_forecast(lat, lon, model=None, is_home=False, timezone=None, retry_after_60s=False): + """ + Recupera forecast. Se model è None, usa best match di Open-Meteo. + Per Casa (is_home=True), usa AROME Seamless. + + Args: + retry_after_60s: Se True, attende 10 secondi prima di riprovare (per retry) + """ + # Usa timezone personalizzata se fornita, altrimenti default + tz_to_use = timezone if timezone else TZ + + # Se è un retry, attendi 10 secondi (ridotto da 60s per evitare timeout esterni) + if retry_after_60s: + logger.info("Attendo 10 secondi prima del retry...") + time.sleep(10) + + # Generiamo 5 punti: Centro, N, S, E, W lats = [lat, lat + OFFSET_LAT, lat - OFFSET_LAT, lat, lat] lons = [lon, lon, lon, lon + OFFSET_LON, lon - OFFSET_LON] @@ -108,38 +209,132 @@ def get_forecast(lat, lon, model): lon_str = ",".join(map(str, lons)) params = { - "latitude": lat_str, "longitude": lon_str, "timezone": TZ, + "latitude": lat_str, "longitude": lon_str, "timezone": tz_to_use, "forecast_days": 3, - "models": model, "wind_speed_unit": "kmh", "precipitation_unit": "mm", "hourly": "temperature_2m,apparent_temperature,relative_humidity_2m,cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,windspeed_10m,winddirection_10m,windgusts_10m,precipitation,rain,snowfall,weathercode,is_day,cape,visibility,uv_index" } + + # Aggiungi models solo se specificato (per Casa usa AROME, per altre località best match) + if model: + params["models"] = model + + # Nota: minutely_15 non è usato in meteo.py (solo per script di allerta) try: r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25) if r.status_code != 200: - logger.error(f"API Error {r.status_code}: {r.text}") - return None - return r.json() + # Dettagli errore più specifici + error_details = f"Status {r.status_code}" + try: + error_json = r.json() + if "reason" in error_json: + error_details += f": {error_json['reason']}" + elif "error" in error_json: + error_details += f": {error_json['error']}" + else: + error_details += f": {r.text[:200]}" + except: + error_details += f": {r.text[:200]}" + logger.error(f"API Error {error_details}") + return None, error_details # Restituisce anche i dettagli dell'errore + response_data = r.json() + # Open-Meteo per multiple locations (lat/lon separati da virgola) restituisce + # direttamente un dict con "hourly", "daily", etc. che contiene liste di valori + # per ogni location. Per semplicità, restituiamo il dict così com'è + # e lo gestiamo nel codice chiamante + return response_data, None + except requests.exceptions.Timeout as e: + error_details = f"Timeout dopo 25s: {str(e)}" + logger.error(f"Request timeout: {error_details}") + return None, error_details + except requests.exceptions.ConnectionError as e: + error_details = f"Errore connessione: {str(e)}" + logger.error(f"Connection error: {error_details}") + return None, error_details except Exception as e: - logger.error(f"Request error: {e}") - return None + error_details = f"Errore richiesta: {type(e).__name__}: {str(e)}" + logger.error(f"Request error: {error_details}") + return None, error_details -def safe_get_list(hourly_data, key, length, default=None): - if key in hourly_data and hourly_data[key] is not None: - return hourly_data[key] - return [default] * length - -def get_val(val, default=0.0): - if val is None: return default - return float(val) - -def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT") -> str: - model_id, model_name = choose_best_model(lat, lon, cc) +def get_visibility_forecast(lat, lon): + """ + Recupera visibilità per località dove il modello principale non la fornisce. + Prova prima ECMWF IFS, poi fallback a best match (GFS o ICON-D2). + """ + # Prova prima con ECMWF IFS + params_ecmwf = { + "latitude": lat, + "longitude": lon, + "timezone": TZ, + "forecast_days": 3, + "models": "ecmwf_ifs04", + "hourly": "visibility" + } + try: + r = requests.get(OPEN_METEO_URL, params=params_ecmwf, headers=HTTP_HEADERS, timeout=15) + if r.status_code == 200: + data = r.json() + hourly = data.get("hourly", {}) + vis = hourly.get("visibility", []) + # Verifica se ci sono valori validi (non tutti None) + if vis and any(v is not None for v in vis): + return vis + except Exception as e: + logger.debug(f"ECMWF IFS visibility request error: {e}") + + # Fallback: usa best match (senza models) che seleziona automaticamente GFS o ICON-D2 + params_best = { + "latitude": lat, + "longitude": lon, + "timezone": TZ, + "forecast_days": 3, + "hourly": "visibility" + } + try: + r = requests.get(OPEN_METEO_URL, params=params_best, headers=HTTP_HEADERS, timeout=15) + if r.status_code == 200: + data = r.json() + hourly = data.get("hourly", {}) + return hourly.get("visibility", []) + except Exception as e: + logger.error(f"Visibility request error: {e}") + return None + +def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", timezone=None) -> str: + # Determina se è Casa + is_home = (abs(lat - HOME_LAT) < 0.01 and abs(lon - HOME_LON) < 0.01) + + # Usa timezone personalizzata se fornita, altrimenti default + tz_to_use = timezone if timezone else TZ + + model_id, model_name = choose_best_model(lat, lon, cc, is_home=is_home) + + # Tentativo 1: Richiesta iniziale + data_list, error_details = get_forecast(lat, lon, model_id, is_home=is_home, timezone=tz_to_use, retry_after_60s=False) + + # Se fallisce e siamo a Casa con AROME, prova retry dopo 10 secondi + if not data_list and is_home and model_id == "meteofrance_seamless": + logger.warning(f"Primo tentativo AROME fallito: {error_details}. Retry dopo 10 secondi...") + data_list, error_details = get_forecast(lat, lon, model_id, is_home=is_home, timezone=tz_to_use, retry_after_60s=True) + + # Se ancora fallisce e siamo a Casa, fallback a best match + if not data_list and is_home: + logger.warning(f"AROME fallito anche dopo retry: {error_details}. Fallback a best match...") + data_list, error_details = get_forecast(lat, lon, None, is_home=False, timezone=tz_to_use, retry_after_60s=False) + if data_list: + model_name = "Best Match (fallback)" + logger.info("Fallback a best match riuscito") + + # Se ancora fallisce, restituisci errore dettagliato + if not data_list: + error_msg = f"❌ Errore API Meteo ({model_name})" + if error_details: + error_msg += f"\n\nDettagli: {error_details}" + return error_msg - data_list = get_forecast(lat, lon, model_id) - if not data_list: return f"❌ Errore API Meteo ({model_name})." if not isinstance(data_list, list): data_list = [data_list] + # Punto centrale (Casa) per dati specifici data_center = data_list[0] hourly_c = data_center.get("hourly", {}) times = hourly_c.get("time", []) @@ -163,16 +358,24 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT") l_vis = safe_get_list(hourly_c, "visibility", L, 10000) l_uv = safe_get_list(hourly_c, "uv_index", L, 0) - # Estraggo anche i dati nuvole LOCALI per il tipo + # Se è Casa e AROME non fornisce visibilità (tutti None), recuperala da best match + if is_home and model_id == "meteofrance_seamless": + vis_check = [v for v in l_vis if v is not None] + if not vis_check: # Tutti None, recupera da best match + vis_data = get_visibility_forecast(lat, lon) + if vis_data and len(vis_data) >= L: + l_vis = vis_data[:L] + + # Dati nuvole LOCALI per decidere il TIPO (L, M, H, F) + l_cl_tot_loc = safe_get_list(hourly_c, "cloud_cover", L, 0) # Copertura totale locale l_cl_low_loc = safe_get_list(hourly_c, "cloud_cover_low", L, 0) l_cl_mid_loc = safe_get_list(hourly_c, "cloud_cover_mid", L, 0) l_cl_hig_loc = safe_get_list(hourly_c, "cloud_cover_high", L, 0) - # --- DATI GLOBALI (MEDIA) --- + # --- DATI GLOBALI (MEDIA 5 PUNTI) --- acc_cl_tot = [0.0] * L points_cl_tot = [ [] for _ in range(L) ] - p_names = ["Casa", "Nord", "Sud", "Est", "Ovest"] - + for d in data_list: h = d.get("hourly", {}) for i in range(L): @@ -181,6 +384,7 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT") cm = get_val(safe_get_list(h, "cloud_cover_mid", L)[i]) ch = get_val(safe_get_list(h, "cloud_cover_high", L)[i]) + # Calcolo robusto del totale per singolo punto real_point_total = max(cc, cl, cm, ch) acc_cl_tot[i] += real_point_total @@ -189,8 +393,9 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT") num_points = len(data_list) avg_cl_tot = [x / num_points for x in acc_cl_tot] + # --- DEBUG MODE --- if debug_mode: - output = f"🔍 **DEBUG 5 PUNTI (V10.4)**\n" + output = f"🔍 **DEBUG METEO (v10.5)**\n" now_h = now_local().replace(minute=0, second=0, microsecond=0) idx = 0 for i, t_str in enumerate(times): @@ -201,134 +406,195 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT") # Valori Locali loc_L = get_val(l_cl_low_loc[idx]) loc_H = get_val(l_cl_hig_loc[idx]) + code_now = int(get_val(l_code[idx])) output += f"Ora: {parse_time(times[idx]).strftime('%H:%M')}\n" - output += f"📍 **LOCALE (Casa)**: L:{int(loc_L)}% | M:{int(get_val(l_cl_mid_loc[idx]))}% | H:{int(loc_H)}%\n" + output += f"📍 **LOCALE (Casa)**: L:{int(loc_L)}% | H:{int(loc_H)}%\n" output += f"🌍 **MEDIA GLOBALE**: {int(avg_cl_tot[idx])}%\n" + output += f"❄️ **NEVE**: Codice={code_now}, Accumulo={get_val(l_snow[idx])}cm\n" decision = "H" if loc_L > 40: decision = "L (Priorità Locale)" - output += f"👉 **Decisione**: {decision}\n" + output += f"👉 **Decisione Nuvole**: {decision}\n" return output - now = now_local().replace(minute=0, second=0, microsecond=0) - blocks = [] - header = f"{'LT':<2} {'T°':>4} {'h%':>3} {'mm':>3} {'Vento':<5} {'Nv%':>5} {'Sk':<2} {'Sx':<2}" - separator = "-" * 31 + # --- GENERAZIONE TABELLA --- + # Usa timezone personalizzata se fornita + tz_to_use_info = ZoneInfo(tz_to_use) if tz_to_use else TZINFO + now_local_tz = datetime.datetime.now(tz_to_use_info) - for (label, hours_duration, step) in [("Prime 24h", 24, 1), ("Successive 24h", 24, 2)]: - end_time = now + datetime.timedelta(hours=hours_duration) - lines = [header, separator] - count = 0 + # Inizia dall'ora corrente (arrotondata all'ora) + current_hour = now_local_tz.replace(minute=0, second=0, microsecond=0) + + # Fine finestra: 48 ore dopo current_hour + end_hour = current_hour + datetime.timedelta(hours=48) + + # Raccogli tutti i timestamp validi nelle 48 ore successive + valid_indices = [] + for i, t_str in enumerate(times): + try: + dt = parse_time(t_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=tz_to_use_info) + else: + dt = dt.astimezone(tz_to_use_info) + + # Include solo timestamp >= current_hour e < end_hour + if current_hour <= dt < end_hour: + valid_indices.append((i, dt)) + except Exception as e: + logger.error(f"Errore parsing timestamp {i}: {e}") + continue + + if not valid_indices: + return f"❌ Nessun dato disponibile per le prossime 48 ore (da {current_hour.strftime('%H:%M')})." + + # Separa in blocchi per giorno: cambia intestazione quando passa da 23 a 00 + blocks = [] + header = f"{'LT':<2} {'T°':>4} {'h%':>3} {'mm':<3} {'Vento':<5} {'Nv%':>5} {'Sk':<2} {'Sx':<2}" + separator = "-" * 31 + + current_day = None + current_block_lines = [] + hours_from_start = 0 # Contatore ore dall'inizio (0-47) + + for idx, dt in valid_indices: + # Determina se questo timestamp appartiene a un nuovo giorno + # (passaggio da 23 a 00) + day_date = dt.date() + is_new_day = (current_day is not None and day_date != current_day) - for i, t_str in enumerate(times): - try: - dt = parse_time(t_str) - if dt < now or dt >= end_time: continue - if dt.hour % step != 0: continue - - T = get_val(l_temp[i], 0) - App = get_val(l_app[i], 0) - Rh = int(get_val(l_rh[i], 50)) - - t_suffix = "" - diff = App - T - if diff <= -2.5: t_suffix = "W" - elif diff >= 2.5: t_suffix = "H" - t_s = f"{int(round(T))}{t_suffix}" + # Determina se mostrare questo timestamp in base alla posizione nelle 48h + # Prime 24h: ogni ora (step=1) + # Dalla 25a alla 48a: ogni 2 ore (step=2) + if hours_from_start < 24: + step = 1 # Prime 24h: dettaglio 1 ora + else: + step = 2 # Dalla 25a alla 48a: dettaglio 2 ore + + # Controlla se questo timestamp deve essere mostrato + should_show = (hours_from_start % step == 0) + + # Se è un nuovo giorno, chiudi il blocco precedente + if is_new_day and current_block_lines: + # Chiudi blocco precedente (solo se ha contenuto oltre header e separator) + if len(current_block_lines) > 2: + day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][current_day.weekday()]} {current_day.day}" + blocks.append(f"*{day_label}*\n```text\n" + "\n".join(current_block_lines) + "\n```") + current_block_lines = [] + + # Aggiorna current_day se è cambiato + if current_day is None or is_new_day: + current_day = day_date + + # Mostra questo timestamp solo se deve essere incluso + if should_show: + # Se è il primo elemento di questo blocco (o primo elemento dopo cambio giorno), aggiungi header + if not current_block_lines: + # Assicurati che current_day corrisponda al giorno della prima riga mostrata + current_day = day_date + current_block_lines.append(header) + current_block_lines.append(separator) + # --- DATI BASE --- + T = get_val(l_temp[idx], 0) + App = get_val(l_app[idx], 0) + Rh = int(get_val(l_rh[idx], 50)) + + t_suffix = "" + diff = App - T + if diff <= -2.5: t_suffix = "W" + elif diff >= 2.5: t_suffix = "H" + t_s = f"{int(round(T))}{t_suffix}" - Pr = get_val(l_prec[i], 0) - Sn = get_val(l_snow[i], 0) - Code = int(l_code[i]) if l_code[i] is not None else 0 - - p_suffix = "" - if Code in [96, 99]: p_suffix = "G" - elif Code in [66, 67]: p_suffix = "Z" - elif Sn > 0 or Code in [71, 73, 75, 77, 85, 86]: p_suffix = "N" - p_s = "--" if Pr < 0.2 else f"{int(round(Pr))}{p_suffix}" + Pr = get_val(l_prec[idx], 0) + Sn = get_val(l_snow[idx], 0) + Code = int(get_val(l_code[idx], 0)) + Rain = get_val(l_rain[idx], 0) + + # Determina se è neve + is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86]) + + # Formattazione MM + p_suffix = "" + if Code in [96, 99]: p_suffix = "G" + elif Code in [66, 67]: p_suffix = "Z" + elif is_snowing and Pr >= 0.2: p_suffix = "N" + + p_s = "--" if Pr < 0.2 else f"{int(round(Pr))}{p_suffix}" - # --- CLOUD LOGIC (V10.4: LOCAL PRIORITY) --- - - # Usiamo la MEDIA per la quantità (Panoramica) - c_avg_tot = int(avg_cl_tot[i]) - - # Usiamo i dati LOCALI per il tipo (Cosa ho sulla testa) - loc_L = get_val(l_cl_low_loc[i]) - loc_M = get_val(l_cl_mid_loc[i]) - loc_H = get_val(l_cl_hig_loc[i]) - Vis = get_val(l_vis[i], 10000) + # --- CLOUD LOGIC --- + Cl = int(get_val(l_cl_tot_loc[idx], 0)) + Vis = get_val(l_vis[idx], 10000) + + # Calcola tipo nuvole per get_icon_set (L/M/H/F) + loc_L = get_val(l_cl_low_loc[idx]) + loc_M = get_val(l_cl_mid_loc[idx]) + loc_H = get_val(l_cl_hig_loc[idx]) + types = {'L': loc_L, 'M': loc_M, 'H': loc_H} + dominant_type = max(types, key=types.get) + + # Override: Se nubi basse locali > 40%, vincono loro + if loc_L > 40: + dominant_type = 'L' - # Step 1: Default matematico LOCALE - types = {'L': loc_L, 'M': loc_M, 'H': loc_H} - dominant_type = max(types, key=types.get) - - # Quantità da mostrare: Media Globale - Cl = c_avg_tot - - # Step 2: Override Tattico LOCALE - # Se LOCALMENTE le basse sono > 40%, vincono loro. - # (Soglia abbassata a 40 per catturare il 51%) - if loc_L > 40: - dominant_type = 'L' - # Se localmente è nuvoloso basso, forziamo la copertura visiva alta - # anche se la media globale è più bassa - if Cl < loc_L: Cl = int(loc_L) + # Nebbia + is_fog = False + if Vis < 1500: + is_fog = True + elif Code in [45, 48]: + is_fog = True + + if is_fog: + dominant_type = 'F' + + # Formattazione Nv% + if is_fog: + cl_str = "FOG" + else: + cl_str = f"{Cl}" - # Step 3: Nebbia (F) - is_fog = False - if Vis < 2000 or Code in [45, 48]: - is_fog = True - elif Rh >= 96 and loc_L > 40: - is_fog = True - - if is_fog: - dominant_type = 'F' - if Cl < 100: Cl = 100 + UV = get_val(l_uv[idx], 0) + uv_suffix = "" + if UV >= 10: uv_suffix = "E" + elif UV >= 7: uv_suffix = "H" - # Check varianza spaziale - min_p = min(points_cl_tot[i]) - max_p = max(points_cl_tot[i]) - var_symbol = "" - if (max_p - min_p) > 20: - var_symbol = "~" + # --- VENTO --- + Wspd = get_val(l_wspd[idx], 0) + Gust = get_val(l_gust[idx], 0) + Wdir = int(get_val(l_wdir[idx], 0)) + Cape = get_val(l_cape[idx], 0) + IsDay = int(get_val(l_day[idx], 1)) + + card = degrees_to_cardinal(Wdir) + w_val = Gust if (Gust - Wspd) > 15 else Wspd + w_txt = f"{card} {int(round(w_val))}" + if (Gust - Wspd) > 15: + g_txt = f"G{int(round(w_val))}" + if len(card)+len(g_txt) <= 5: w_txt = f"{card}{g_txt}" + elif len(card)+1+len(g_txt) <= 5: w_txt = f"{card} {g_txt}" + else: w_txt = g_txt + w_fmt = f"{w_txt:<5}" + + # --- ICONE --- + sky, sgx = get_icon_set(Pr, Sn, Code, IsDay, Cl, Vis, T, Rain, Gust, Cape, dominant_type) + + # Se c'è precipitazione nevosa, mostra ❄️ nella colonna Sk (invece di 🌨️) + if is_snowing and Pr >= 0.2: + sky = "❄️" + + sky_fmt = f"{sky}{uv_suffix}" - cl_str = f"{var_symbol}{Cl}{dominant_type}" - - UV = get_val(l_uv[i], 0) - uv_suffix = "" - if UV >= 10: uv_suffix = "E" - elif UV >= 7: uv_suffix = "H" - - Wspd = get_val(l_wspd[i], 0) - Gust = get_val(l_gust[i], 0) - Wdir = int(get_val(l_wdir[i], 0)) - Cape = get_val(l_cape[i], 0) - IsDay = int(l_day[i]) if l_day[i] is not None else 1 - - card = degrees_to_cardinal(Wdir) - w_val = Gust if (Gust - Wspd) > 15 else Wspd - w_txt = f"{card} {int(round(w_val))}" - if (Gust - Wspd) > 15: - g_txt = f"G{int(round(w_val))}" - if len(card)+len(g_txt) <= 5: w_txt = f"{card}{g_txt}" - elif len(card)+1+len(g_txt) <= 5: w_txt = f"{card} {g_txt}" - else: w_txt = g_txt - w_fmt = f"{w_txt:<5}" - - sky, sgx = get_icon_set(Pr, Sn, Code, IsDay, Cl, Vis, T, get_val(l_rain[i], 0), Gust, Cape, dominant_type) - sky_fmt = f"{sky}{uv_suffix}" - - lines.append(f"{dt.strftime('%H'):<2} {t_s:>4} {Rh:>3} {p_s:>3} {w_fmt} {cl_str:>5} {sky_fmt:<2} {sgx:<2}") - count += 1 - - except Exception as e: - logger.error(f"Errore riga meteo {i}: {e}") - continue - - if count > 0: - day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][now.weekday()]} {now.day}" - blocks.append(f"*{day_label} ({label})*\n```text\n" + "\n".join(lines) + "\n```") - now = end_time + current_block_lines.append(f"{dt.strftime('%H'):<2} {t_s:>4} {Rh:>3} {p_s:>3} {w_fmt} {cl_str:>5} {sky_fmt:<2} {sgx:<2}") + + hours_from_start += 1 + + # Chiudi ultimo blocco (solo se ha contenuto oltre header e separator) + if current_block_lines and len(current_block_lines) > 2: # Header + separator + almeno 1 riga dati + day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][current_day.weekday()]} {current_day.day}" + blocks.append(f"*{day_label}*\n```text\n" + "\n".join(current_block_lines) + "\n```") + + if not blocks: + return f"❌ Nessun dato da mostrare nelle prossime 48 ore (da {current_hour.strftime('%H:%M')})." return f"🌤️ *METEO REPORT*\n📍 {location_name}\n🧠 Fonte: {model_name}\n\n" + "\n\n".join(blocks) @@ -337,16 +603,41 @@ if __name__ == "__main__": args_parser.add_argument("--query", help="Nome città") args_parser.add_argument("--home", action="store_true", help="Usa coordinate casa") args_parser.add_argument("--debug", action="store_true", help="Mostra i valori dei 5 punti") + args_parser.add_argument("--chat_id", help="Chat ID Telegram per invio diretto (opzionale, può essere multiplo separato da virgola)") + args_parser.add_argument("--timezone", help="Timezone IANA (es: Europe/Rome, America/New_York)") args = args_parser.parse_args() + # Determina chat_ids se specificato + chat_ids = None + if args.chat_id: + chat_ids = [cid.strip() for cid in args.chat_id.split(",") if cid.strip()] + + # Genera report + report = None if args.home: - print(generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM")) + report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM") elif args.query: coords = get_coordinates(args.query) if coords: lat, lon, name, cc = coords - print(generate_weather_report(lat, lon, name, args.debug, cc)) + report = generate_weather_report(lat, lon, name, args.debug, cc) else: - print(f"❌ Città '{args.query}' non trovata.") + error_msg = f"❌ Città '{args.query}' non trovata." + if chat_ids: + telegram_send_markdown(error_msg, chat_ids) + else: + print(error_msg) + sys.exit(1) else: - print("Uso: meteo.py --query 'Nome Città' oppure --home [--debug]") \ No newline at end of file + usage_msg = "Uso: meteo.py --query 'Nome Città' oppure --home [--debug] [--chat_id ID]" + if chat_ids: + telegram_send_markdown(usage_msg, chat_ids) + else: + print(usage_msg) + sys.exit(1) + + # Invia o stampa + if chat_ids: + telegram_send_markdown(report, chat_ids) + else: + print(report) \ No newline at end of file diff --git a/services/telegram-bot/net_quality.py b/services/telegram-bot/net_quality.py index 94b0223..d3b71b3 100644 --- a/services/telegram-bot/net_quality.py +++ b/services/telegram-bot/net_quality.py @@ -1,3 +1,4 @@ +import argparse import subprocess import re import os @@ -5,10 +6,11 @@ import json import time import urllib.request import urllib.parse +from typing import List, Optional # --- CONFIGURAZIONE --- BOT_TOKEN="8155587974:AAF9OekvBpixtk8ZH6KoIc0L8edbhdXt7A4" -CHAT_ID="64463169" +TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"] # BERSAGLIO (Cloudflare è solitamente il più stabile per i ping) TARGET_HOST = "1.1.1.1" @@ -20,10 +22,17 @@ LIMIT_JITTER = 30.0 # ms di deviazione (sopra 30ms lagga la voce/gioco) # File di stato STATE_FILE = "/home/daniely/docker/telegram-bot/quality_state.json" -def send_telegram(msg): - if "INSERISCI" in TELEGRAM_BOT_TOKEN: return - url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage" - for chat_id in TELEGRAM_CHAT_IDS: +def send_telegram(msg, chat_ids: Optional[List[str]] = None): + """ + Args: + msg: Messaggio da inviare + chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) + """ + if not BOT_TOKEN or "INSERISCI" in BOT_TOKEN: return + if chat_ids is None: + chat_ids = TELEGRAM_CHAT_IDS + url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage" + for chat_id in chat_ids: try: payload = {"chat_id": chat_id, "text": msg, "parse_mode": "Markdown"} data = urllib.parse.urlencode(payload).encode('utf-8') @@ -44,7 +53,7 @@ def save_state(active): with open(STATE_FILE, 'w') as f: json.dump({"alert_active": active}, f) except: pass -def measure_quality(): +def measure_quality(chat_ids: Optional[List[str]] = None): print("--- Avvio Test Qualità Linea ---") # Esegue 50 ping rapidi (0.2s intervallo) @@ -98,7 +107,7 @@ def measure_quality(): msg += f"⚠️ **Jitter (Instabilità):** `{jitter}ms` (Soglia {LIMIT_JITTER}ms)\n" msg += f"\n_Ping Medio: {avg_ping}ms_" - send_telegram(msg) + send_telegram(msg, chat_ids=chat_ids) save_state(True) print("Allarme inviato.") else: @@ -109,11 +118,18 @@ def measure_quality(): msg = f"✅ **QUALITÀ LINEA RIPRISTINATA**\n\n" msg += f"I parametri sono rientrati nella norma.\n" msg += f"Ping: `{avg_ping}ms` | Jitter: `{jitter}ms` | Loss: `{loss}%`" - send_telegram(msg) + send_telegram(msg, chat_ids=chat_ids) save_state(False) print("Recovery inviata.") else: print("Linea OK.") if __name__ == "__main__": - measure_quality() + parser = argparse.ArgumentParser(description="Network quality monitor") + parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0]) + args = parser.parse_args() + + # In modalità debug, invia solo al primo chat ID (admin) + chat_ids = [TELEGRAM_CHAT_IDS[0]] if args.debug else None + + measure_quality(chat_ids=chat_ids) diff --git a/services/telegram-bot/nowcast_120m_alert.py b/services/telegram-bot/nowcast_120m_alert.py index fe709f6..29a00f4 100644 --- a/services/telegram-bot/nowcast_120m_alert.py +++ b/services/telegram-bot/nowcast_120m_alert.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import argparse import datetime import json import logging @@ -22,7 +23,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) LOG_FILE = os.path.join(BASE_DIR, "nowcast_120m_alert.log") STATE_FILE = os.path.join(BASE_DIR, "nowcast_120m_state.json") -TZ = "Europe/Rome" +TZ = "Europe/Berlin" TZINFO = ZoneInfo(TZ) # Casa (San Marino) @@ -37,7 +38,9 @@ TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token") # Open-Meteo OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" -MODEL = "meteofrance_arome_france_hd" +MODEL_AROME = "meteofrance_seamless" +MODEL_ICON_IT = "italia_meteo_arpae_icon_2i" +COMPARISON_THRESHOLD = 0.30 # 30% scostamento per comparazione # Finestra di valutazione WINDOW_MINUTES = 120 @@ -51,8 +54,14 @@ RAIN_CONFIRM_HOURS = 2 # "confermato": almeno 2 ore consecutive WIND_GUST_STRONG_KMH = 62.0 WIND_CONFIRM_HOURS = 2 # almeno 2 ore consecutive -# Neve: accumulo nelle prossime 2 ore >= 2 cm +# Neve: accumulo nelle prossime 2 ore >= 2 cm (eventi significativi) SNOW_ACCUM_2H_CM = 2.0 +# Soglia più bassa per rilevare l'inizio della neve (anche leggera) +SNOW_ACCUM_2H_LIGHT_CM = 0.3 # 0.3 cm in 2 ore per rilevare inizio neve +# Soglia per neve persistente: accumulo totale su 6 ore (anche se distribuito) +SNOW_ACCUM_6H_PERSISTENT_CM = 0.15 # 0.15 cm in 6 ore per neve persistente +# Codici meteo che indicano neve (WMO) +SNOW_WEATHER_CODES = [71, 73, 75, 77, 85, 86] # Neve leggera, moderata, forte, granelli, rovesci # Anti-spam: minimo intervallo tra invii uguali (in minuti) MIN_RESEND_MINUTES = 180 @@ -107,9 +116,13 @@ def load_bot_token() -> str: return tok.strip() if tok else "" -def telegram_send_markdown(message: str) -> bool: +def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -> bool: """ Invia SOLO se message presente. Errori solo su log. + + Args: + message: Messaggio Markdown da inviare + chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) """ if not message: return False @@ -119,6 +132,9 @@ def telegram_send_markdown(message: str) -> bool: LOGGER.error("Token Telegram mancante. Messaggio NON inviato.") return False + if chat_ids is None: + chat_ids = TELEGRAM_CHAT_IDS + url = f"https://api.telegram.org/bot{token}/sendMessage" payload_base = { "text": message, @@ -128,7 +144,7 @@ def telegram_send_markdown(message: str) -> bool: ok_any = False with requests.Session() as s: - for chat_id in TELEGRAM_CHAT_IDS: + for chat_id in chat_ids: payload = dict(payload_base) payload["chat_id"] = chat_id try: @@ -151,13 +167,23 @@ def parse_time_local(t: str) -> datetime.datetime: return dt.astimezone(TZINFO) -def get_forecast() -> Optional[Dict]: +def get_forecast(model: str, use_minutely: bool = True, forecast_days: int = 2) -> Optional[Dict]: + """ + Recupera forecast. Se use_minutely=True e model è AROME, include anche minutely_15 + per dettaglio 15 minuti nelle prossime 48 ore. + Se minutely_15 fallisce o ha troppi buchi, riprova automaticamente senza minutely_15. + + Args: + model: Modello meteo da usare + use_minutely: Se True, include dati minutely_15 per AROME + forecast_days: Numero di giorni di previsione (default: 2 per 48h) + """ params = { "latitude": LAT, "longitude": LON, "timezone": TZ, - "forecast_days": 2, - "models": MODEL, + "forecast_days": forecast_days, + "models": model, "wind_speed_unit": "kmh", "precipitation_unit": "mm", "hourly": ",".join([ @@ -165,21 +191,306 @@ def get_forecast() -> Optional[Dict]: "windspeed_10m", "windgusts_10m", "snowfall", + "weathercode", # Aggiunto per rilevare neve anche quando snowfall è basso ]), } + + # Aggiungi minutely_15 per AROME Seamless (dettaglio 15 minuti) + # Se fallisce, riprova senza minutely_15 + if use_minutely and model == MODEL_AROME: + params["minutely_15"] = "snowfall,precipitation_probability,precipitation,rain,temperature_2m,wind_speed_10m,wind_direction_10m" + try: r = requests.get(OPEN_METEO_URL, params=params, timeout=25) if r.status_code == 400: + # Se 400 e abbiamo minutely_15, riprova senza + if "minutely_15" in params and model == MODEL_AROME: + LOGGER.warning("Open-Meteo 400 con minutely_15 (model=%s), riprovo senza minutely_15", model) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass try: j = r.json() - LOGGER.error("Open-Meteo 400: %s", j.get("reason", j)) + LOGGER.error("Open-Meteo 400 (model=%s): %s", model, j.get("reason", j)) except Exception: - LOGGER.error("Open-Meteo 400: %s", r.text[:300]) + LOGGER.error("Open-Meteo 400 (model=%s): %s", model, r.text[:300]) + return None + elif r.status_code == 504: + # Gateway Timeout: se abbiamo minutely_15, riprova senza + if "minutely_15" in params and model == MODEL_AROME: + LOGGER.warning("Open-Meteo 504 Gateway Timeout con minutely_15 (model=%s), riprovo senza minutely_15", model) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + LOGGER.error("Open-Meteo 504 Gateway Timeout (model=%s)", model) return None r.raise_for_status() - return r.json() + data = r.json() + + # Verifica se minutely_15 ha buchi (anche solo 1 None = fallback a hourly) + if "minutely_15" in params and model == MODEL_AROME: + minutely = data.get("minutely_15", {}) or {} + minutely_times = minutely.get("time", []) or [] + minutely_precip = minutely.get("precipitation", []) or [] + minutely_snow = minutely.get("snowfall", []) or [] + + # Controlla se ci sono buchi (anche solo 1 None) + if minutely_times: + # Controlla tutti i parametri principali per buchi + has_holes = False + # Controlla precipitation + if minutely_precip and any(v is None for v in minutely_precip): + has_holes = True + # Controlla snowfall + if minutely_snow and any(v is None for v in minutely_snow): + has_holes = True + + if has_holes: + LOGGER.warning("minutely_15 ha buchi (valori None rilevati, model=%s), riprovo senza minutely_15", model) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + + return data + except requests.exceptions.Timeout: + # Timeout: se abbiamo minutely_15, riprova senza + if "minutely_15" in params and model == MODEL_AROME: + LOGGER.warning("Open-Meteo Timeout con minutely_15 (model=%s), riprovo senza minutely_15", model) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + LOGGER.exception("Open-Meteo timeout (model=%s)", model) + return None except Exception as e: - LOGGER.exception("Errore chiamata Open-Meteo: %s", e) + # Altri errori: se abbiamo minutely_15, riprova senza + if "minutely_15" in params and model == MODEL_AROME: + LOGGER.warning("Open-Meteo error con minutely_15 (model=%s): %s, riprovo senza minutely_15", model, str(e)) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + LOGGER.exception("Errore chiamata Open-Meteo (model=%s): %s", model, e) + return None + + +def find_precise_start_minutely( + minutely_data: Dict, + param_name: str, + threshold: float, + window_start: datetime.datetime, + window_end: datetime.datetime, + confirm_intervals: int = 2 # 2 intervalli da 15 min = 30 min conferma +) -> Optional[Dict]: + """ + Trova inizio preciso usando dati minutely_15 (risoluzione 15 minuti) + + Returns: + { + "start": datetime, + "start_precise": str (HH:MM), + "value_at_start": float, + "confirmed": bool + } or None + """ + minutely = minutely_data.get("minutely_15", {}) or {} + times = minutely.get("time", []) or [] + values = minutely.get(param_name, []) or [] + + if not times or not values: + return None + + for i, (t_str, val) in enumerate(zip(times, values)): + try: + dt = parse_time_local(t_str) + if dt < window_start or dt > window_end: + continue + + val_float = float(val) if val is not None else 0.0 + + if val_float >= threshold: + # Verifica conferma (almeno confirm_intervals consecutivi) + confirmed = True + if i + confirm_intervals - 1 < len(values): + for k in range(1, confirm_intervals): + next_val = float(values[i + k]) if i + k < len(values) and values[i + k] is not None else 0.0 + if next_val < threshold: + confirmed = False + break + else: + confirmed = False + + if confirmed: + return { + "start": dt, + "start_precise": dt.strftime("%H:%M"), + "value_at_start": val_float, + "confirmed": confirmed + } + except Exception: + continue + + return None + + +def analyze_snowfall_event( + times: List[str], + snowfall: List[float], + weathercode: List[int], + start_idx: int, + max_hours: int = 48 +) -> Dict: + """ + Analizza una nevicata completa partendo da start_idx. + + Calcola: + - Durata totale (ore consecutive con neve) + - Accumulo totale (somma di tutti i snowfall > 0) + - Ore di inizio e fine + + Args: + times: Lista di timestamp + snowfall: Lista di valori snowfall (già in cm) + weathercode: Lista di weather codes + start_idx: Indice di inizio della nevicata + max_hours: Massimo numero di ore da analizzare (default: 48) + + Returns: + Dict con: + - duration_hours: durata in ore + - total_accumulation_cm: accumulo totale in cm + - start_time: datetime di inizio + - end_time: datetime di fine (o None se continua oltre max_hours) + - is_ongoing: True se continua oltre max_hours + """ + from zoneinfo import ZoneInfo + + if start_idx >= len(times): + return None + + start_dt = parse_time_local(times[start_idx]) + end_idx = start_idx + total_accum = 0.0 + duration = 0 + + # Analizza fino a max_hours in avanti o fino alla fine dei dati + max_idx = min(start_idx + max_hours, len(times)) + + for i in range(start_idx, max_idx): + snow_val = snowfall[i] if i < len(snowfall) and snowfall[i] is not None else 0.0 + code = weathercode[i] if i < len(weathercode) and weathercode[i] is not None else None + + # Considera neve se: snowfall > 0 OPPURE weather_code indica neve + is_snow = (snow_val > 0.0) or (code in SNOW_WEATHER_CODES) + + if is_snow: + duration += 1 + total_accum += snow_val + end_idx = i + else: + # Se c'è una pausa, continua comunque a cercare (potrebbe essere una pausa temporanea) + # Ma se la pausa è > 2 ore, considera la nevicata terminata + pause_hours = 0 + for j in range(i, min(i + 3, max_idx)): + next_snow = snowfall[j] if j < len(snowfall) and snowfall[j] is not None else 0.0 + next_code = weathercode[j] if j < len(weathercode) and weathercode[j] is not None else None + if (next_snow > 0.0) or (next_code in SNOW_WEATHER_CODES): + break + pause_hours += 1 + + # Se pausa > 2 ore, termina l'analisi + if pause_hours >= 2: + break + + end_dt = parse_time_local(times[end_idx]) if end_idx < len(times) else None + is_ongoing = (end_idx >= max_idx - 1) and (end_idx < len(times) - 1) + + return { + "duration_hours": duration, + "total_accumulation_cm": total_accum, + "start_time": start_dt, + "end_time": end_dt, + "is_ongoing": is_ongoing, + "start_idx": start_idx, + "end_idx": end_idx + } + + +def find_snowfall_start( + times: List[str], + snowfall: List[float], + weathercode: List[int], + window_start: datetime.datetime, + window_end: datetime.datetime +) -> Optional[int]: + """ + Trova l'inizio di una nevicata nella finestra temporale. + + Una nevicata inizia quando: + - snowfall > 0 OPPURE weather_code indica neve (71, 73, 75, 77, 85, 86) + + Returns: + Indice del primo timestamp con neve, o None + """ + for i, t_str in enumerate(times): + try: + dt = parse_time_local(t_str) + if dt < window_start or dt > window_end: + continue + + snow_val = snowfall[i] if i < len(snowfall) and snowfall[i] is not None else 0.0 + code = weathercode[i] if i < len(weathercode) and weathercode[i] is not None else None + + # Rileva inizio neve + if (snow_val > 0.0) or (code in SNOW_WEATHER_CODES): + return i + except Exception: + continue + + return None + + +def compare_values(arome_val: float, icon_val: float) -> Optional[Dict]: + """Confronta due valori e ritorna info se scostamento >30%""" + if arome_val == 0 and icon_val == 0: + return None + + if arome_val > 0: + diff_pct = abs(icon_val - arome_val) / arome_val + elif icon_val > 0: + diff_pct = abs(arome_val - icon_val) / icon_val + else: + return None + + if diff_pct > COMPARISON_THRESHOLD: + return { + "diff_pct": diff_pct * 100, + "arome": arome_val, + "icon": icon_val + } return None @@ -190,7 +501,10 @@ def load_state() -> Dict: return json.load(f) or {} except Exception: return {} - return {} + return { + "active_events": {}, + "last_sent_utc": "" + } def save_state(state: Dict) -> None: @@ -201,6 +515,154 @@ def save_state(state: Dict) -> None: pass +def is_event_already_active( + active_events: Dict, + event_type: str, + start_time: datetime.datetime, + tolerance_hours: float = 2.0 +) -> bool: + """ + Verifica se un evento con lo stesso inizio è già attivo. + + Args: + active_events: Dict con eventi attivi per tipo + event_type: Tipo evento ("SNOW", "RAIN", "WIND") + start_time: Timestamp di inizio dell'evento + tolerance_hours: Tolleranza in ore per considerare lo stesso evento + + Returns: + True se l'evento è già attivo + """ + events_of_type = active_events.get(event_type, []) + + for event in events_of_type: + try: + event_start_str = event.get("start_time", "") + if not event_start_str: + continue + + event_start = datetime.datetime.fromisoformat(event_start_str) + # Normalizza timezone + if event_start.tzinfo is None: + event_start = event_start.replace(tzinfo=TZINFO) + else: + event_start = event_start.astimezone(TZINFO) + + # Normalizza start_time + if start_time.tzinfo is None: + start_time = start_time.replace(tzinfo=TZINFO) + else: + start_time = start_time.astimezone(TZINFO) + + # Verifica se l'inizio è entro la tolleranza + time_diff = abs((start_time - event_start).total_seconds() / 3600.0) + if time_diff <= tolerance_hours: + return True + except Exception: + continue + + return False + + +def add_active_event( + active_events: Dict, + event_type: str, + start_time: datetime.datetime, + end_time: Optional[datetime.datetime] = None, + is_ongoing: bool = False +) -> None: + """ + Aggiunge un evento attivo allo state. + """ + if event_type not in active_events: + active_events[event_type] = [] + + # Normalizza timezone + if start_time.tzinfo is None: + start_time = start_time.replace(tzinfo=TZINFO) + else: + start_time = start_time.astimezone(TZINFO) + + event = { + "start_time": start_time.isoformat(), + "first_alerted": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "type": event_type, + "is_ongoing": is_ongoing + } + + if end_time: + if end_time.tzinfo is None: + end_time = end_time.replace(tzinfo=TZINFO) + else: + end_time = end_time.astimezone(TZINFO) + event["end_time"] = end_time.isoformat() + + active_events[event_type].append(event) + + +def cleanup_ended_events( + active_events: Dict, + now: datetime.datetime +) -> None: + """ + Rimuove eventi terminati dallo state (ma non invia notifiche di fine). + + Un evento è considerato terminato se: + - Ha un end_time nel passato E non è ongoing + - O se l'evento è più vecchio di 48 ore (safety cleanup) + """ + if now.tzinfo is None: + now = now.replace(tzinfo=TZINFO) + else: + now = now.astimezone(TZINFO) + + for event_type in list(active_events.keys()): + events = active_events[event_type] + kept_events = [] + + for event in events: + try: + start_time_str = event.get("start_time", "") + if not start_time_str: + continue + + start_time = datetime.datetime.fromisoformat(start_time_str) + if start_time.tzinfo is None: + start_time = start_time.replace(tzinfo=TZINFO) + else: + start_time = start_time.astimezone(TZINFO) + + # Safety cleanup: rimuovi eventi più vecchi di 48 ore + age_hours = (now - start_time).total_seconds() / 3600.0 + if age_hours > 48: + LOGGER.debug("Rimosso evento %s vecchio di %.1f ore (cleanup)", event_type, age_hours) + continue + + # Verifica se l'evento è terminato + end_time_str = event.get("end_time") + is_ongoing = event.get("is_ongoing", False) + + if end_time_str and not is_ongoing: + end_time = datetime.datetime.fromisoformat(end_time_str) + if end_time.tzinfo is None: + end_time = end_time.replace(tzinfo=TZINFO) + else: + end_time = end_time.astimezone(TZINFO) + + # Se end_time è nel passato, rimuovi l'evento + if end_time < now: + LOGGER.debug("Rimosso evento %s terminato alle %s", event_type, end_time_str) + continue + + # Mantieni l'evento + kept_events.append(event) + except Exception as e: + LOGGER.debug("Errore cleanup evento %s: %s", event_type, e) + continue + + active_events[event_type] = kept_events + + def find_confirmed_start( times: List[str], cond: List[bool], @@ -233,24 +695,36 @@ def find_confirmed_start( return None -def main() -> None: +def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None: LOGGER.info("--- Nowcast 120m alert ---") - data = get_forecast() - if not data: + # Estendi forecast a 3 giorni per avere 48h di analisi neve completa + data_arome = get_forecast(MODEL_AROME, forecast_days=3) + if not data_arome: return - hourly = data.get("hourly", {}) or {} - times = hourly.get("time", []) or [] - precip = hourly.get("precipitation", []) or [] - gust = hourly.get("windgusts_10m", []) or [] - snow = hourly.get("snowfall", []) or [] + hourly_arome = data_arome.get("hourly", {}) or {} + times = hourly_arome.get("time", []) or [] + precip_arome = hourly_arome.get("precipitation", []) or [] + gust_arome = hourly_arome.get("windgusts_10m", []) or [] + snow_arome = hourly_arome.get("snowfall", []) or [] + weathercode_arome = hourly_arome.get("weathercode", []) or [] # Per rilevare neve anche con snowfall basso + + # Recupera dati ICON Italia per comparazione (48h) + data_icon = get_forecast(MODEL_ICON_IT, use_minutely=False, forecast_days=3) + hourly_icon = data_icon.get("hourly", {}) or {} if data_icon else {} + precip_icon = hourly_icon.get("precipitation", []) or [] + gust_icon = hourly_icon.get("windgusts_10m", []) or [] + snow_icon = hourly_icon.get("snowfall", []) or [] + weathercode_icon = hourly_icon.get("weathercode", []) or [] if not times: LOGGER.error("Open-Meteo: hourly.time mancante/vuoto") return now = now_local() + # Finestra per rilevare inizio neve: prossime 2 ore + window_start = now window_end = now + datetime.timedelta(minutes=WINDOW_MINUTES) # Normalizza array a lunghezza times @@ -262,82 +736,199 @@ def main() -> None: except Exception: return 0.0 - rain_cond = [(val(precip, i) >= RAIN_INTENSE_MM_H) for i in range(n)] - wind_cond = [(val(gust, i) >= WIND_GUST_STRONG_KMH) for i in range(n)] + rain_cond = [(val(precip_arome, i) >= RAIN_INTENSE_MM_H) for i in range(n)] + wind_cond = [(val(gust_arome, i) >= WIND_GUST_STRONG_KMH) for i in range(n)] - # Per neve: accumulo su 2 ore consecutive (i e i+1) >= soglia - snow2_cond = [] - for i in range(n): - if i + 1 < n: - snow2 = val(snow, i) + val(snow, i + 1) - snow2_cond.append(snow2 >= SNOW_ACCUM_2H_CM) - else: - snow2_cond.append(False) + # Per neve: nuova logica - rileva inizio nevicata e analizza evento completo (48h) + snow_start_i = find_snowfall_start(times, snow_arome, weathercode_arome, window_start, window_end) - rain_i = find_confirmed_start(times, rain_cond, RAIN_CONFIRM_HOURS, now, window_end) - wind_i = find_confirmed_start(times, wind_cond, WIND_CONFIRM_HOURS, now, window_end) - snow_i = find_confirmed_start(times, snow2_cond, 1, now, window_end) # già condensa su 2h + rain_i = find_confirmed_start(times, rain_cond, RAIN_CONFIRM_HOURS, window_start, window_end) + wind_i = find_confirmed_start(times, wind_cond, WIND_CONFIRM_HOURS, window_start, window_end) if DEBUG: - LOGGER.debug("window now=%s end=%s model=%s", now.isoformat(timespec="minutes"), window_end.isoformat(timespec="minutes"), MODEL) + LOGGER.debug("window now=%s end=%s model=%s", now.isoformat(timespec="minutes"), window_end.isoformat(timespec="minutes"), MODEL_AROME) LOGGER.debug("rain_start=%s wind_start=%s snow_start=%s", rain_i, wind_i, snow_i) alerts: List[str] = [] sig_parts: List[str] = [] + comparisons: Dict[str, Dict] = {} # tipo_allerta -> comparison info + + # Usa minutely_15 per trovare inizio preciso (se disponibile) + minutely_arome = data_arome.get("minutely_15", {}) or {} + minutely_available = bool(minutely_arome.get("time")) # Pioggia intensa if rain_i is not None: start_dt = parse_time_local(times[rain_i]) + + # Se minutely_15 disponibile, trova inizio preciso (risoluzione 15 minuti) + precise_start = None + if minutely_available: + precise_start = find_precise_start_minutely( + minutely_arome, "precipitation", RAIN_INTENSE_MM_H, window_start, window_end, confirm_intervals=2 + ) + if precise_start: + start_dt = precise_start["start"] + # picco entro finestra - max_r = 0.0 + max_r_arome = 0.0 for i in range(n): dt = parse_time_local(times[i]) - if dt < now or dt > window_end: + if dt < window_start or dt > window_end: continue - max_r = max(max_r, val(precip, i)) - alerts.append( + max_r_arome = max(max_r_arome, val(precip_arome, i)) + + # Calcola picco ICON se disponibile + max_r_icon = 0.0 + if len(precip_icon) >= n: + for i in range(n): + dt = parse_time_local(times[i]) + if dt < window_start or dt > window_end: + continue + max_r_icon = max(max_r_icon, val(precip_icon, i)) + + # Comparazione + comp_rain = compare_values(max_r_arome, max_r_icon) if max_r_icon > 0 else None + if comp_rain: + comparisons["rain"] = comp_rain + + start_time_str = precise_start["start_precise"] if precise_start else start_dt.strftime('%H:%M') + detail_note = f" (dettaglio 15 min)" if precise_start else "" + + alert_text = ( f"🌧️ *PIOGGIA INTENSA*\n" - f"Inizio confermato: `{start_dt.strftime('%H:%M')}` (≥ {RAIN_INTENSE_MM_H:.0f} mm/h per {RAIN_CONFIRM_HOURS}h)\n" - f"Picco stimato (entro {WINDOW_MINUTES}m): `{max_r:.1f} mm/h`" + f"Inizio confermato: `{start_time_str}`{detail_note} (≥ {RAIN_INTENSE_MM_H:.0f} mm/h per {RAIN_CONFIRM_HOURS}h)\n" + f"Picco stimato (entro {WINDOW_MINUTES}m): `{max_r_arome:.1f} mm/h`" ) - sig_parts.append(f"RAIN@{start_dt.strftime('%Y%m%d%H%M')}/peak{max_r:.1f}") + if comp_rain: + alert_text += f"\n⚠️ *Discordanza modelli*: AROME `{max_r_arome:.1f}` mm/h | ICON `{max_r_icon:.1f}` mm/h (scostamento {comp_rain['diff_pct']:.0f}%)" + alerts.append(alert_text) + sig_parts.append(f"RAIN@{start_dt.strftime('%Y%m%d%H%M')}/peak{max_r_arome:.1f}") # Vento forte (raffiche) if wind_i is not None: start_dt = parse_time_local(times[wind_i]) - max_g = 0.0 + + # Verifica se l'evento è già attivo + is_already_active = is_event_already_active(active_events, "WIND", start_dt, tolerance_hours=2.0) + + if is_already_active: + LOGGER.info("Evento vento già attivo (inizio: %s), non invio notifica", start_dt.strftime('%Y-%m-%d %H:%M')) + else: + max_g_arome = 0.0 + for i in range(n): + dt = parse_time_local(times[i]) + if dt < window_start or dt > window_end: + continue + max_g_arome = max(max_g_arome, val(gust_arome, i)) + + # Calcola picco ICON se disponibile + max_g_icon = 0.0 + if len(gust_icon) >= n: for i in range(n): dt = parse_time_local(times[i]) - if dt < now or dt > window_end: + if dt < window_start or dt > window_end: continue - max_g = max(max_g, val(gust, i)) - alerts.append( + max_g_icon = max(max_g_icon, val(gust_icon, i)) + + # Comparazione + comp_wind = compare_values(max_g_arome, max_g_icon) if max_g_icon > 0 else None + if comp_wind: + comparisons["wind"] = comp_wind + + alert_text = ( f"💨 *VENTO FORTE*\n" f"Inizio confermato: `{start_dt.strftime('%H:%M')}` (raffiche ≥ {WIND_GUST_STRONG_KMH:.0f} km/h per {WIND_CONFIRM_HOURS}h)\n" - f"Raffica max stimata (entro {WINDOW_MINUTES}m): `{max_g:.0f} km/h`" + f"Raffica max stimata (entro {WINDOW_MINUTES}m): `{max_g_arome:.0f} km/h`" ) - sig_parts.append(f"WIND@{start_dt.strftime('%Y%m%d%H%M')}/peak{max_g:.0f}") + if comp_wind: + alert_text += f"\n⚠️ *Discordanza modelli*: AROME `{max_g_arome:.0f}` km/h | ICON `{max_g_icon:.0f}` km/h (scostamento {comp_wind['diff_pct']:.0f}%)" + alerts.append(alert_text) + sig_parts.append(f"WIND@{start_dt.strftime('%Y%m%d%H%M')}") + + # Aggiungi evento attivo allo state (stima fine: 6 ore dopo inizio) + estimated_end = start_dt + datetime.timedelta(hours=6) + add_active_event(active_events, "WIND", start_dt, estimated_end, is_ongoing=False) - # Neve (accumulo 2h) - if snow_i is not None: - start_dt = parse_time_local(times[snow_i]) - snow2 = val(snow, snow_i) + val(snow, snow_i + 1) - alerts.append( - f"❄️ *NEVE*\n" - f"Inizio stimato: `{start_dt.strftime('%H:%M')}`\n" - f"Accumulo 2h stimato: `{snow2:.1f} cm` (soglia ≥ {SNOW_ACCUM_2H_CM:.1f} cm)" - ) - sig_parts.append(f"SNOW@{start_dt.strftime('%Y%m%d%H%M')}/acc{snow2:.1f}") + # Neve: analizza evento completo (48h) + if snow_start_i is not None: + # Analizza la nevicata completa + snow_event = analyze_snowfall_event(times, snow_arome, weathercode_arome, snow_start_i, max_hours=48) + + if snow_event: + start_dt = snow_event["start_time"] + total_accum_cm = snow_event["total_accumulation_cm"] + duration_hours = snow_event["duration_hours"] + end_dt = snow_event["end_time"] + is_ongoing = snow_event["is_ongoing"] + + # Determina severità in base all'accumulo totale + is_significant = total_accum_cm >= SNOW_ACCUM_2H_CM + severity_emoji = "❄️" if is_significant else "🌨️" + severity_text = "NEVE SIGNIFICATIVA" if is_significant else "NEVE" + + # Se minutely_15 disponibile, trova inizio preciso + precise_start_snow = None + if minutely_available: + precise_start_snow = find_precise_start_minutely( + minutely_arome, "snowfall", 0.01, window_start, window_end, confirm_intervals=1 + ) + + start_time_str = precise_start_snow["start_precise"] if precise_start_snow else start_dt.strftime('%H:%M') + detail_note = f" (dettaglio 15 min)" if precise_start_snow else "" + + # Calcola accumulo ICON per comparazione + if data_icon and snow_start_i < len(snow_icon): + icon_event = analyze_snowfall_event(times, snow_icon, weathercode_icon, snow_start_i, max_hours=48) + icon_accum_cm = icon_event["total_accumulation_cm"] if icon_event else 0.0 + comp_snow = compare_values(total_accum_cm, icon_accum_cm) if icon_accum_cm > 0 else None + else: + comp_snow = None + + if comp_snow: + comparisons["snow"] = comp_snow + + # Costruisci messaggio con durata e accumulo totale + end_time_str = end_dt.strftime('%H:%M') if end_dt and not is_ongoing else "in corso" + duration_text = f"{duration_hours}h" if duration_hours > 0 else "<1h" + + alert_text = ( + f"{severity_emoji} *{severity_text}*\n" + f"Inizio: `{start_time_str}`{detail_note}\n" + f"Durata prevista: `{duration_text}`" + ) + + if end_dt and not is_ongoing: + alert_text += f" (fino alle `{end_time_str}`)" + elif is_ongoing: + alert_text += f" (continua oltre 48h)" + + alert_text += f"\nAccumulo totale previsto: `{total_accum_cm:.2f} cm`" + + if comp_snow: + alert_text += f"\n⚠️ *Discordanza modelli*: AROME `{total_accum_cm:.2f}` cm | ICON `{icon_accum_cm:.2f}` cm (scostamento {comp_snow['diff_pct']:.0f}%)" + + alerts.append(alert_text) + sig_parts.append(f"SNOW@{start_dt.strftime('%Y%m%d%H%M')}/dur{duration_hours}h/acc{total_accum_cm:.1f}") - if not alerts: - LOGGER.info("Nessuna allerta confermata entro %s minuti.", WINDOW_MINUTES) - return signature = "|".join(sig_parts) - # Anti-spam - state = load_state() - last_sig = str(state.get("signature", "")) + # Se non ci sono nuovi eventi, non inviare nulla (non inviare notifiche di fine evento) + if not alerts: + if debug_mode: + # In modalità debug, crea un messaggio informativo anche se non ci sono allerte + LOGGER.info("[DEBUG MODE] Nessuna allerta, ma creo messaggio informativo") + alerts.append("ℹ️ Nessuna allerta confermata entro %s minuti." % WINDOW_MINUTES) + sig_parts.append("NO_ALERT") + else: + LOGGER.info("Nessuna allerta confermata entro %s minuti.", WINDOW_MINUTES) + # Salva state aggiornato (con eventi puliti) anche se non inviamo notifiche + state["active_events"] = active_events + save_state(state) + return + + # Anti-spam: controlla solo se ci sono nuovi eventi last_sent = state.get("last_sent_utc", "") last_sent_dt = None if last_sent: @@ -352,28 +943,48 @@ def main() -> None: delta_min = (now_utc - last_sent_dt).total_seconds() / 60.0 too_soon = delta_min < MIN_RESEND_MINUTES - if signature == last_sig and too_soon: - LOGGER.info("Allerta già inviata di recente (signature invariata).") + # In modalità debug, bypassa controlli anti-spam + if debug_mode: + LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato") + elif too_soon: + LOGGER.info("Allerta già inviata di recente (troppo presto).") + # Salva state aggiornato anche se non inviamo + state["active_events"] = active_events + save_state(state) return + model_info = MODEL_AROME + if comparisons: + model_info = f"{MODEL_AROME} + ICON Italia (discordanza rilevata)" + msg = ( f"⚠️ *ALLERTA METEO (entro {WINDOW_MINUTES} minuti)*\n" f"📍 {LOCATION_NAME}\n" - f"🕒 Agg. `{now.strftime('%d/%m %H:%M')}` (modello: `{MODEL}`)\n\n" + f"🕒 Agg. `{now.strftime('%d/%m %H:%M')}` (modello: `{model_info}`)\n\n" + "\n\n".join(alerts) - + "\n\n_Fonte: Open-Meteo (AROME HD 1.5km)_" + + "\n\n_Fonte: Open-Meteo_" ) - ok = telegram_send_markdown(msg) + ok = telegram_send_markdown(msg, chat_ids=chat_ids) if ok: LOGGER.info("Notifica inviata.") - save_state({ - "signature": signature, - "last_sent_utc": now_utc.isoformat(timespec="seconds"), - }) + # Salva state con eventi attivi aggiornati + state["active_events"] = active_events + state["last_sent_utc"] = now_utc.isoformat(timespec="seconds") + save_state(state) else: LOGGER.error("Notifica NON inviata (token/telegram).") + # Salva comunque lo state aggiornato + state["active_events"] = active_events + save_state(state) if __name__ == "__main__": - main() + arg_parser = argparse.ArgumentParser(description="Nowcast 120m alert") + arg_parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0]) + args = arg_parser.parse_args() + + # In modalità debug, invia solo al primo chat ID (admin) e bypassa anti-spam + chat_ids = [TELEGRAM_CHAT_IDS[0]] if args.debug else None + + main(chat_ids=chat_ids, debug_mode=args.debug) diff --git a/services/telegram-bot/previsione7.py b/services/telegram-bot/previsione7.py index a9eb45f..bbfb6ec 100755 --- a/services/telegram-bot/previsione7.py +++ b/services/telegram-bot/previsione7.py @@ -1,112 +1,740 @@ #!/usr/bin/env python3 +""" +Assistente Climatico Intelligente - Report Meteo Avanzato +Analizza evoluzione meteo, fronti, cambiamenti e fornisce consigli pratici +""" import requests import argparse import datetime import os import sys from zoneinfo import ZoneInfo -from collections import defaultdict +from collections import defaultdict, Counter, Counter +from typing import List, Dict, Tuple, Optional +from statistics import mean, median # --- CONFIGURAZIONE DEFAULT --- DEFAULT_LAT = 43.9356 DEFAULT_LON = 12.4296 -DEFAULT_NAME = "🏠 Casa (Strada Cà Toro)" +DEFAULT_NAME = "🏠 Casa (Strada Cà Toro,12 - San Marino)" # --- TIMEZONE --- -TZ_STR = "Europe/Rome" +TZ_STR = "Europe/Berlin" +TZINFO = ZoneInfo(TZ_STR) # --- TELEGRAM CONFIG --- ADMIN_CHAT_ID = "64463169" +TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"] TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token") TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token" +TOKEN_FILE_VOLUME = "/Volumes/Pi2/etc/telegram_dpc_bot_token" # --- SOGLIE --- SOGLIA_VENTO_KMH = 40.0 MIN_MM_PER_EVENTO = 0.1 +# --- MODELLI METEO --- +# Modelli a breve termine (alta risoluzione, 48-72h) +SHORT_TERM_MODELS = ["meteofrance_seamless", "icon_d2"] # Usa seamless invece di arome_france_hd +# Modelli a lungo termine (globale, 10 giorni) +LONG_TERM_MODELS = ["gfs_global", "ecmwf_ifs04"] +# Modelli alternativi +MODELS_IT_SM = ["meteofrance_seamless", "icon_d2", "gfs_global"] +MODEL_NAMES = { + "meteofrance_arome_france_hd": "AROME HD", + "meteofrance_seamless": "AROME Seamless", + "icon_d2": "ICON-D2", + "gfs_global": "GFS", + "ecmwf_ifs04": "ECMWF", + "jma_msm": "JMA MSM", + "metno_nordic": "Yr.no", + "ukmo_global": "UK MetOffice", + "icon_eu": "ICON-EU", + "italia_meteo_arpae_icon_2i": "ICON Italia (ARPAE 2i)" +} + +def choose_models_by_country(cc, is_home=False): + """ + Seleziona modelli meteo ottimali. + - Per Casa: usa AROME Seamless e ICON-D2 (alta risoluzione) + - Per Italia: usa italia_meteo_arpae_icon_2i (include snow_depth quando > 0) + - Per altre località: usa best match di Open-Meteo (senza specificare models) + Ritorna (short_term_models, long_term_models) + """ + cc = cc.upper() if cc else "UNKNOWN" + + # Modelli a lungo termine (sempre globali, funzionano ovunque) + long_term_default = ["gfs_global", "ecmwf_ifs04"] + + if is_home: + # Per Casa, usa AROME Seamless, ICON-D2 e ICON Italia (alta risoluzione europea) + # ICON Italia include snow_depth quando disponibile (> 0) + return ["meteofrance_seamless", "icon_d2", "italia_meteo_arpae_icon_2i"], long_term_default + elif cc == "IT": + # Per Italia, usa ICON Italia (ARPAE 2i) che include snow_depth quando disponibile + return ["italia_meteo_arpae_icon_2i"], long_term_default + else: + # Per query worldwide, usa best match (Open-Meteo sceglie automaticamente) + # Ritorna None per indicare best match + return None, long_term_default + def get_bot_token(): - for path in [TOKEN_FILE_ETC, TOKEN_FILE_HOME]: + paths = [TOKEN_FILE_HOME, TOKEN_FILE_ETC, TOKEN_FILE_VOLUME] + for path in paths: if os.path.exists(path): try: - with open(path, 'r') as f: return f.read().strip() - except: pass - sys.exit(1) + with open(path, 'r') as f: + return f.read().strip() + except: + pass + return None def get_coordinates(query): if not query or query.lower() == "casa": - return DEFAULT_LAT, DEFAULT_LON, DEFAULT_NAME + return DEFAULT_LAT, DEFAULT_LON, DEFAULT_NAME, "SM" url = "https://geocoding-api.open-meteo.com/v1/search" try: resp = requests.get(url, params={"name": query, "count": 1, "language": "it", "format": "json"}, timeout=5) - res = resp.json().get("results", [])[0] - return res['latitude'], res['longitude'], f"{res.get('name')} ({res.get('country_code','')})" - except: return None, None, None + res = resp.json().get("results", []) + if res: + res = res[0] + cc = res.get("country_code", "IT").upper() + name = f"{res.get('name')} ({cc})" + return res['latitude'], res['longitude'], name, cc + except: + pass + return None, None, None, None -def get_weather(lat, lon): - url = "https://api.open-meteo.com/v1/forecast" - params = { - "latitude": lat, "longitude": lon, - "hourly": "temperature_2m,precipitation_probability,precipitation,weathercode,windspeed_10m,dewpoint_2m", - "daily": "temperature_2m_max,temperature_2m_min,sunrise,sunset", - "timezone": TZ_STR, "models": "best_match", "forecast_days": 8 - } +def degrees_to_cardinal(d: int) -> str: + """Converte gradi in direzione cardinale (N, NE, E, SE, S, SW, W, NW)""" + dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] try: - resp = requests.get(url, params=params, timeout=10) - resp.raise_for_status() - return resp.json() - except: return None + return dirs[round(d / 45) % 8] + except: + return "N" + +def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forecast_days=10, timezone=None): + """ + Recupera dati da modelli a breve e lungo termine per ensemble completo. + Se short_term_models è None, usa best match di Open-Meteo (senza specificare models). + """ + results = {} + + # Recupera modelli a breve termine (alta risoluzione, fino a ~72h) + if short_term_models is None: + # Best match: non specificare models, Open-Meteo sceglie automaticamente + url = "https://api.open-meteo.com/v1/forecast" + params = { + "latitude": lat, "longitude": lon, + "hourly": "temperature_2m,precipitation_probability,precipitation,snowfall,rain,snow_depth,weathercode,windspeed_10m,windgusts_10m,winddirection_10m,dewpoint_2m,relative_humidity_2m,cloud_cover,soil_temperature_0cm", + "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_hours,precipitation_probability_max,snowfall_sum,showers_sum,rain_sum,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max", + "timezone": timezone if timezone else TZ_STR, "forecast_days": min(forecast_days, 3) # Limita a 3 giorni per modelli ad alta risoluzione + } + try: + resp = requests.get(url, params=params, timeout=20) + if resp.status_code == 200: + data = resp.json() + # Converti snow_depth da metri a cm per tutti i modelli (Open-Meteo restituisce in metri) + hourly_data = data.get("hourly", {}) + if hourly_data and "snow_depth" in hourly_data: + snow_depth_values = hourly_data.get("snow_depth", []) + # Converti da metri a cm (moltiplica per 100) + snow_depth_cm = [] + for sd in snow_depth_values: + if sd is not None: + try: + val_m = float(sd) + val_cm = val_m * 100.0 # Converti da metri a cm + snow_depth_cm.append(val_cm) + except (ValueError, TypeError): + snow_depth_cm.append(None) + else: + snow_depth_cm.append(None) + hourly_data["snow_depth"] = snow_depth_cm + data["hourly"] = hourly_data + results["best_match"] = data + results["best_match"]["model_type"] = "short_term" + else: + results["best_match"] = None + except: + results["best_match"] = None + else: + # Modelli specifici (per Casa: AROME + ICON, per Italia: ICON ARPAE) + for model in short_term_models: + url = "https://api.open-meteo.com/v1/forecast" + # Per italia_meteo_arpae_icon_2i, includi sempre snow_depth (supportato quando > 0) + hourly_params = "temperature_2m,precipitation_probability,precipitation,snowfall,rain,snow_depth,weathercode,windspeed_10m,windgusts_10m,winddirection_10m,dewpoint_2m,relative_humidity_2m,cloud_cover,soil_temperature_0cm" + + params = { + "latitude": lat, "longitude": lon, + "hourly": hourly_params, + "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_hours,precipitation_probability_max,snowfall_sum,showers_sum,rain_sum,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max", + "timezone": timezone if timezone else TZ_STR, "models": model, "forecast_days": min(forecast_days, 3) # Limita a 3 giorni per modelli ad alta risoluzione + } + try: + resp = requests.get(url, params=params, timeout=20) + if resp.status_code == 200: + data = resp.json() + # Converti snow_depth da metri a cm per tutti i modelli (Open-Meteo restituisce in metri) + hourly_data = data.get("hourly", {}) + if hourly_data and "snow_depth" in hourly_data: + snow_depth_values = hourly_data.get("snow_depth", []) + # Converti da metri a cm (moltiplica per 100) + snow_depth_cm = [] + for sd in snow_depth_values: + if sd is not None: + try: + val_m = float(sd) + val_cm = val_m * 100.0 # Converti da metri a cm + snow_depth_cm.append(val_cm) + except (ValueError, TypeError): + snow_depth_cm.append(None) + else: + snow_depth_cm.append(None) + hourly_data["snow_depth"] = snow_depth_cm + data["hourly"] = hourly_data + + # Per italia_meteo_arpae_icon_2i, verifica se snow_depth è disponibile e > 0 + if model == "italia_meteo_arpae_icon_2i": + if hourly_data and "snow_depth" in hourly_data: + snow_depth_values_cm = hourly_data.get("snow_depth", []) + # Verifica se almeno un valore di snow_depth è > 0 (ora già in cm) + has_snow_depth = False + if snow_depth_values_cm: + for sd in snow_depth_values_cm[:24]: # Controlla prime 24h + if sd is not None: + try: + if float(sd) > 0.5: # > 0.5 cm + has_snow_depth = True + break + except (ValueError, TypeError): + continue + # Se snow_depth > 0, assicurati che sia incluso nei dati + if has_snow_depth: + data["has_snow_depth_data"] = True + results[model] = data + results[model]["model_type"] = "short_term" + else: + results[model] = None + except: + results[model] = None + + # Recupera modelli a lungo termine (globale, fino a 10 giorni) + for model in long_term_models: + url = "https://api.open-meteo.com/v1/forecast" + params = { + "latitude": lat, "longitude": lon, + "hourly": "temperature_2m,precipitation_probability,precipitation,snowfall,rain,snow_depth,weathercode,windspeed_10m,windgusts_10m,winddirection_10m,dewpoint_2m,relative_humidity_2m,cloud_cover,soil_temperature_0cm", + "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_hours,precipitation_probability_max,snowfall_sum,showers_sum,rain_sum,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max", + "timezone": timezone if timezone else TZ_STR, "models": model, "forecast_days": forecast_days + } + try: + resp = requests.get(url, params=params, timeout=25) + if resp.status_code == 200: + data = resp.json() + # Converti snow_depth da metri a cm per tutti i modelli (Open-Meteo restituisce in metri) + hourly_data = data.get("hourly", {}) + if hourly_data and "snow_depth" in hourly_data: + snow_depth_values = hourly_data.get("snow_depth", []) + # Converti da metri a cm (moltiplica per 100) + snow_depth_cm = [] + for sd in snow_depth_values: + if sd is not None: + try: + val_m = float(sd) + val_cm = val_m * 100.0 # Converti da metri a cm + snow_depth_cm.append(val_cm) + except (ValueError, TypeError): + snow_depth_cm.append(None) + else: + snow_depth_cm.append(None) + hourly_data["snow_depth"] = snow_depth_cm + data["hourly"] = hourly_data + results[model] = data + results[model]["model_type"] = "long_term" + else: + results[model] = None + except Exception: + results[model] = None + + return results + +def merge_multi_model_forecast(models_data, forecast_days=10): + """Combina dati da modelli a breve e lungo termine in un forecast unificato""" + merged = { + "daily": { + "time": [], + "temperature_2m_max": [], + "temperature_2m_min": [], + "precipitation_sum": [], + "precipitation_hours": [], + "precipitation_probability_max": [], + "snowfall_sum": [], + "showers_sum": [], + "rain_sum": [], + "weathercode": [], + "windspeed_10m_max": [], + "windgusts_10m_max": [] + }, + "hourly": { + "time": [], + "temperature_2m": [], + "precipitation": [], + "snowfall": [], + "snow_depth": [], + "rain": [], + "weathercode": [], + "windspeed_10m": [], + "winddirection_10m": [], + "dewpoint_2m": [], + "precipitation_probability": [], + "cloud_cover": [], + "soil_temperature_0cm": [] + }, + "models_used": [] + } + + # Trova modello a breve termine disponibile (cerca tutti i modelli con type "short_term") + # Priorità: ICON Italia per snow_depth, altrimenti primo disponibile + short_term_data = None + short_term_model = None + icon_italia_data = None + icon_italia_model = None + + # Prima cerca ICON Italia (ha snow_depth quando disponibile) + # Cerca anche altri modelli che potrebbero avere snow_depth (icon_d2, etc.) + for model in models_data.keys(): + if models_data[model] and models_data[model].get("model_type") == "short_term": + # Priorità a ICON Italia, ma cerca anche altri modelli con snow_depth + if model == "italia_meteo_arpae_icon_2i": + icon_italia_data = models_data[model] + icon_italia_model = model + # ICON-D2 può avere anche snow_depth + elif model == "icon_d2" and icon_italia_data is None: + # Usa ICON-D2 come fallback se ICON Italia non disponibile + hourly_data = models_data[model].get("hourly", {}) + snow_depth_values = hourly_data.get("snow_depth", []) if hourly_data else [] + # Verifica se ha dati di snow_depth validi + has_valid_snow_depth = False + if snow_depth_values: + for sd in snow_depth_values[:24]: + if sd is not None: + try: + if float(sd) > 0: + has_valid_snow_depth = True + break + except (ValueError, TypeError): + continue + if has_valid_snow_depth: + icon_italia_data = models_data[model] + icon_italia_model = model + + # Poi cerca primo modello disponibile (per altri parametri) + for model in models_data.keys(): + if models_data[model] and models_data[model].get("model_type") == "short_term": + short_term_data = models_data[model] + short_term_model = model + break + + # Trova modello a lungo termine disponibile (cerca tutti i modelli con type "long_term") + long_term_data = None + long_term_model = None + for model in models_data.keys(): + if models_data[model] and models_data[model].get("model_type") == "long_term": + long_term_data = models_data[model] + long_term_model = model + break + + if not short_term_data and not long_term_data: + return None + + # Usa dati a breve termine per primi 2-3 giorni, poi passa a lungo termine + cutoff_day = 2 # Usa modelli ad alta risoluzione per primi 2 giorni + + if short_term_data: + # Gestisci best_match o modelli specifici + if short_term_model == "best_match": + model_display = "Best Match" + else: + model_display = MODEL_NAMES.get(short_term_model, short_term_model) + # Verifica se ICON Italia ha dati di snow_depth (controllo diretto, non solo il flag) + has_icon_snow_depth = False + if icon_italia_data: + icon_hourly = icon_italia_data.get("hourly", {}) + icon_snow_depth = icon_hourly.get("snow_depth", []) if icon_hourly else [] + # Verifica se ci sono dati non-null di snow_depth + if icon_snow_depth: + for sd in icon_snow_depth[:72]: # Controlla prime 72h + if sd is not None: + try: + if float(sd) > 0: # Anche valori piccoli + has_icon_snow_depth = True + break + except (ValueError, TypeError): + continue + + # Se ICON Italia ha dati di snow_depth, aggiungilo ai modelli usati + if has_icon_snow_depth or (icon_italia_data and icon_italia_data.get("has_snow_depth_data")): + icon_display = MODEL_NAMES.get("italia_meteo_arpae_icon_2i", "ICON Italia") + merged["models_used"].append(f"{model_display} + {icon_display} (snow_depth) (0-{cutoff_day+1}d)") + else: + merged["models_used"].append(f"{model_display} (0-{cutoff_day+1}d)") + short_daily = short_term_data.get("daily", {}) + short_hourly = short_term_data.get("hourly", {}) + + # Prendi dati daily dai primi giorni del modello a breve termine + short_daily_times = short_daily.get("time", [])[:cutoff_day+1] + for i, day_time in enumerate(short_daily_times): + merged["daily"]["time"].append(day_time) + for key in ["temperature_2m_max", "temperature_2m_min", "precipitation_sum", "precipitation_hours", "precipitation_probability_max", "snowfall_sum", "showers_sum", "rain_sum", "weathercode", "windspeed_10m_max", "windgusts_10m_max"]: + val = short_daily.get(key, [])[i] if i < len(short_daily.get(key, [])) else None + merged["daily"][key].append(val) + + # Prendi dati hourly dal modello a breve termine + # Priorità: usa snow_depth da ICON Italia se disponibile, altrimenti dal modello principale + short_hourly_times = short_hourly.get("time", []) + icon_italia_hourly = icon_italia_data.get("hourly", {}) if icon_italia_data else {} + icon_italia_hourly_times = icon_italia_hourly.get("time", []) if icon_italia_hourly else [] + icon_italia_snow_depth = icon_italia_hourly.get("snow_depth", []) if icon_italia_hourly else [] + # Crea mappa timestamp -> snow_depth per ICON Italia (per corrispondenza esatta o approssimata) + icon_snow_depth_map = {} + if icon_italia_hourly_times and icon_italia_snow_depth: + for idx, ts in enumerate(icon_italia_hourly_times): + if idx < len(icon_italia_snow_depth) and icon_italia_snow_depth[idx] is not None: + try: + val_cm = float(icon_italia_snow_depth[idx]) + if val_cm >= 0: # Solo valori validi (già in cm) + icon_snow_depth_map[ts] = val_cm + except (ValueError, TypeError): + continue + + cutoff_hour = (cutoff_day + 1) * 24 + for i, hour_time in enumerate(short_hourly_times[:cutoff_hour]): + merged["hourly"]["time"].append(hour_time) + for key in ["temperature_2m", "precipitation", "snowfall", "rain", "weathercode", "windspeed_10m", "winddirection_10m", "dewpoint_2m", "precipitation_probability", "cloud_cover", "soil_temperature_0cm"]: + val = short_hourly.get(key, [])[i] if i < len(short_hourly.get(key, [])) else None + merged["hourly"][key].append(val) + # Per snow_depth: usa ICON Italia se disponibile (corrispondenza per timestamp), altrimenti modello principale + # NOTA: I valori sono già convertiti in cm durante il recupero dall'API + val_snow_depth = None + # Cerca corrispondenza esatta per timestamp + if hour_time in icon_snow_depth_map: + # Usa snow_depth da ICON Italia per questo timestamp (già in cm) + val_snow_depth = icon_snow_depth_map[hour_time] + else: + # Fallback 1: cerca corrispondenza per ora approssimata (se i timestamp non corrispondono esattamente) + # Estrai solo la parte ora (YYYY-MM-DDTHH) per corrispondenza approssimata + hour_time_base = hour_time[:13] if len(hour_time) >= 13 else hour_time # "2025-01-09T12" + for icon_ts, icon_val in icon_snow_depth_map.items(): + if icon_ts.startswith(hour_time_base): + val_snow_depth = icon_val + break + # Fallback 2: se non trovato, cerca il valore più vicino nello stesso giorno + if val_snow_depth is None and hour_time_base: + day_date_str = hour_time[:10] if len(hour_time) >= 10 else None # "2025-01-09" + if day_date_str: + # Cerca tutti i valori di ICON Italia per lo stesso giorno + same_day_values = [v for ts, v in icon_snow_depth_map.items() if ts.startswith(day_date_str)] + if same_day_values: + # Usa il primo valore disponibile per quel giorno (approssimazione) + val_snow_depth = same_day_values[0] + # Fallback 3: usa snow_depth dal modello principale se ICON Italia non disponibile + if val_snow_depth is None and i < len(short_hourly.get("snow_depth", [])): + val_snow_depth = short_hourly.get("snow_depth", [])[i] + merged["hourly"]["snow_depth"].append(val_snow_depth) + + if long_term_data: + merged["models_used"].append(f"{MODEL_NAMES.get(long_term_model, long_term_model)} ({cutoff_day+1}-{forecast_days}d)") + long_daily = long_term_data.get("daily", {}) + long_hourly = long_term_data.get("hourly", {}) + + # Prendi dati daily dal modello a lungo termine per i giorni successivi + long_daily_times = long_daily.get("time", []) + start_idx = cutoff_day + 1 + + for i in range(start_idx, min(len(long_daily_times), forecast_days)): + merged["daily"]["time"].append(long_daily_times[i]) + for key in ["temperature_2m_max", "temperature_2m_min", "precipitation_sum", "precipitation_hours", "precipitation_probability_max", "snowfall_sum", "showers_sum", "rain_sum", "weathercode", "windspeed_10m_max", "windgusts_10m_max"]: + val = long_daily.get(key, [])[i] if i < len(long_daily.get(key, [])) else None + merged["daily"][key].append(val) + + # Per i dati hourly, completa con dati a lungo termine se necessario + long_hourly_times = long_hourly.get("time", []) + current_hourly_count = len(merged["hourly"]["time"]) + needed_hours = forecast_days * 24 + + if current_hourly_count < needed_hours: + start_hour_idx = current_hourly_count + for i in range(start_hour_idx, min(len(long_hourly_times), needed_hours)): + merged["hourly"]["time"].append(long_hourly_times[i]) + for key in ["temperature_2m", "precipitation", "snowfall", "snow_depth", "rain", "weathercode", "windspeed_10m", "winddirection_10m", "dewpoint_2m", "precipitation_probability", "cloud_cover", "soil_temperature_0cm"]: + val = long_hourly.get(key, [])[i] if i < len(long_hourly.get(key, [])) else None + merged["hourly"][key].append(val) + + return merged + +def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10): + """Analizza trend temperatura per identificare fronti caldi/freddi con dettaglio completo""" + if not daily_temps_max or not daily_temps_min: + return None + + max_days = min(days, len(daily_temps_max), len(daily_temps_min)) + if max_days < 3: + return None + + # Filtra valori None e calcola temperature medie giornaliere + avg_temps = [] + valid_indices = [] + 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) + else: + avg_temps.append(None) + + if len([t for t in avg_temps if t is not None]) < 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_avg = mean(valid_temps[:3]) + last_avg = mean(valid_temps[-3:]) + diff = last_avg - first_avg + + trend_type = None + trend_intensity = "moderato" + + if diff > 5: + trend_type = "fronte_caldo" + trend_intensity = "forte" if diff > 8 else "moderato" + elif diff > 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: + 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) + change_days.append({ + "day": i, + "delta": round(day_diff, 1), + "from": round(prev_temp, 1), + "to": round(temp, 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) + } + + return { + "type": trend_type, + "intensity": trend_intensity, + "delta": round(diff, 1), + "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, + "daily_max": daily_temps_max[:max_days], + "daily_min": daily_temps_min[:max_days] + } + +def analyze_weather_transitions(daily_weathercodes): + """Analizza transizioni meteo significative""" + transitions = [] + if not daily_weathercodes or len(daily_weathercodes) < 2: + return transitions + + # Categorie meteo + def get_category(code): + if code is None: + return "variabile" + code = int(code) + if code in (0, 1): return "sereno" + if code in (2, 3): return "nuvoloso" + if code in (45, 48): return "nebbia" + if code in (51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82): return "pioggia" + if code in (71, 73, 75, 77, 85, 86): return "neve" + if code in (95, 96, 99): return "temporale" + return "variabile" + + for i in range(1, min(len(daily_weathercodes), 8)): + prev_code = daily_weathercodes[i-1] if i-1 < len(daily_weathercodes) else None + curr_code = daily_weathercodes[i] if i < len(daily_weathercodes) else None + prev_cat = get_category(prev_code) + curr_cat = get_category(curr_code) + + if prev_cat != curr_cat: + transitions.append({ + "day": i, + "from": prev_cat, + "to": curr_cat, + "significant": prev_cat in ["sereno", "nuvoloso"] and curr_cat in ["pioggia", "neve", "temporale"] + }) + + return transitions def get_precip_type(code): """Definisce il tipo di precipitazione in base al codice WMO.""" - # Neve (71-77, 85-86) - if (71 <= code <= 77) or code in [85, 86]: return "❄️ Neve" - # Grandine (96-99) - if code in [96, 99]: return "⚡🌨 Grandine" - # Pioggia congelantesi (66-67) - if code in [66, 67]: return "🧊☔ Pioggia Congelantesi" - # Pioggia standard + if (71 <= code <= 77) or code in [85, 86]: + return "❄️ Neve" + if code in [96, 99]: + return "⚡🌨 Grandine" + if code in [66, 67]: + return "🧊☔ Pioggia Congelantesi" return "☔ Pioggia" def get_intensity_label(mm_h): - if mm_h < 2.5: return "Debole" - if mm_h < 7.6: return "Moderata" + if mm_h < 2.5: + return "Debole" + if mm_h < 7.6: + return "Moderata" return "Forte ⚠️" -def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints): +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 = [] - # ========================================== - # 1. LIVELLO PERICOLI (Ghiaccio, Gelo, Brina) - # ========================================== + # Prepara array per calcoli avanzati (allineato a check_ghiaccio.py) + if snowfalls is None: + snowfalls = [0.0] * len(times) + if rains is None: + rains = [0.0] * len(times) + if soil_temps is None: + soil_temps = [None] * len(times) + if cloud_covers is None: + cloud_covers = [None] * len(times) + if wind_speeds is None: + wind_speeds = [None] * len(times) + + # Calcola precipitazioni cumulative delle 3h precedenti per ogni punto + 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]]) + snow_sum = sum([float(s) if s is not None else 0.0 for s in snowfalls[start_idx:i]]) + precip_3h_sum.append(precip_sum) + rain_3h_sum.append(rain_sum) + snow_3h_sum.append(snow_sum) + + # 1. PERICOLI (Ghiaccio, Gelo, Brina) - Logica migliorata allineata a check_ghiaccio.py in_ice = False start_ice = 0 ice_type = "" for i in range(len(times)): - t = temps[i] - d = dewpoints[i] - p = precip[i] - c = codes[i] + t = temps[i] if i < len(temps) and temps[i] is not None else 10 + d = dewpoints[i] if i < len(dewpoints) and dewpoints[i] is not None else t + p = precip[i] if i < len(precip) and precip[i] is not None else 0 + c = codes[i] if i < len(codes) and codes[i] is not None else 0 + if c is not None: + try: + c = int(c) + except (ValueError, TypeError): + c = 0 + else: + c = 0 + # Estrai parametri avanzati + t_soil = soil_temps[i] if i < len(soil_temps) and soil_temps[i] is not None else None + cloud = cloud_covers[i] if i < len(cloud_covers) and cloud_covers[i] is not None else None + wind = wind_speeds[i] if i < len(wind_speeds) and wind_speeds[i] is not None else None + snowfall_curr = snowfalls[i] if i < len(snowfalls) and snowfalls[i] is not None else 0.0 + rain_curr = rains[i] if i < len(rains) and rains[i] is not None else 0.0 + + # Determina se è notte (18:00-06:00) per raffreddamento radiativo + 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: + is_night = False + + # Calcola temperatura suolo: usa valore misurato se disponibile, altrimenti stima (1-2°C più fredda) + if t_soil is None: + 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 + 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 - # A. GELICIDIO (Pericolo massimo) - # Se il codice è esplicitamente Gelicidio (66,67) OPPURE piove (codici pioggia) con T < 0 + # 1. GELICIDIO (Freezing Rain) - priorità massima 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): current_ice_condition = "🧊☠️ GELICIDIO" - # B. GHIACCIO/BRINA (Strada Scivolosa) - # Niente precipitazioni, T bassa (<2°C) e DewPoint vicinissimo alla T (<1°C diff) - elif p == 0 and t <= 2.0 and (t - d) < 1.0: - current_ice_condition = "⛸️⚠️ GHIACCIO/BRINA (Strada Scivolosa)" + # 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 + 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)" - # C. GELATA SEMPLICE (T < 0) + # 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: + if d > t_soil_adjusted and d < 0.0: + current_ice_condition = "⛸️⚠️ GHIACCIO/BRINA" + + # 4. GELATA - Temperatura aria < 0°C (senza altre condizioni) elif t < 0: - current_ice_condition = "❄️ Gelata notturna" + current_ice_condition = "🧊 Gelata" - # Logica raggruppamento if current_ice_condition and not in_ice: in_ice = True start_ice = i @@ -115,149 +743,905 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints): 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[end_idx].split("T")[1][:5] - min_t_block = min(temps[start_ice:end_idx+1]) - events.append(f"{ice_type}: {start_time}-{end_time} (Min: {min_t_block}°C)") - + 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] + 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)") in_ice = False if current_ice_condition: in_ice = True start_ice = i ice_type = current_ice_condition - # ========================================== - # 2. LIVELLO PRECIPITAZIONI (Pioggia, Neve) - # ========================================== - # Nota: Non sopprimiamo più nulla. Se nevica mentre gela, li segnaliamo entrambi. + # 2. PRECIPITAZIONI in_rain = False start_idx = 0 current_rain_type = "" for i in range(len(times)): - is_raining = precip[i] >= MIN_MM_PER_EVENTO + p_val = precip[i] if i < len(precip) and precip[i] is not None else 0 + is_raining = p_val >= MIN_MM_PER_EVENTO if is_raining and not in_rain: in_rain = True start_idx = i - current_rain_type = get_precip_type(codes[i]) - - # Cambio tipo precipitazione (es. da Pioggia a Neve nello stesso blocco) - elif in_rain and is_raining and get_precip_type(codes[i]) != current_rain_type: - # Chiudiamo il blocco precedente e ne apriamo uno nuovo - end_idx = i - block_precip = precip[start_idx:end_idx] - tot_mm = sum(block_precip) - max_prob = max(probs[start_idx:end_idx]) - start_time = times[start_idx].split("T")[1][:5] - end_time = times[end_idx].split("T")[1][:5] # Qui combacia - avg_intensity = tot_mm / len(block_precip) - - events.append( - f"{current_rain_type} ({get_intensity_label(avg_intensity)}):\n" - f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm | Prob: {max_prob}%" - ) - - # Riavvia nuovo tipo - start_idx = i - current_rain_type = get_precip_type(codes[i]) - + code_val = codes[i] if i < len(codes) and codes[i] is not None else 0 + 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) + elif in_rain and is_raining and i < len(codes): + code_val = codes[i] if codes[i] is not None else 0 + 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) + 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:] + block_precip_clean = [p for p in block_precip if p is not None] + tot_mm = sum(block_precip_clean) + prob_block = probs[start_idx:end_idx] if end_idx <= len(probs) else probs[start_idx:] + prob_block_clean = [p for p in prob_block if p is not None] + max_prob = max(prob_block_clean) if prob_block_clean else 0 + 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] + 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 | Prob: {max_prob}%" + ) + 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] - tot_mm = sum(block_precip) + block_precip = precip[start_idx:end_idx] if end_idx <= 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: - max_prob = max(probs[start_idx:end_idx]) + prob_block = probs[start_idx:end_idx] if end_idx <= len(probs) else probs[start_idx:] + prob_block_clean = [p for p in prob_block if p is not None] + max_prob = max(prob_block_clean) if prob_block_clean else 0 start_time = times[start_idx].split("T")[1][:5] - end_time = times[end_idx-1].split("T")[1][:5] - avg_intensity = tot_mm / len(block_precip) + end_time = times[min(end_idx-1, len(times)-1)].split("T")[1][:5] + 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 | Prob: {max_prob}%" ) - # ========================================== - # 3. LIVELLO VENTO - # ========================================== - max_wind = max(winds) - if max_wind > SOGLIA_VENTO_KMH: - peak_idx = winds.index(max_wind) - peak_time = times[peak_idx].split("T")[1][:5] - events.append(f"💨 Vento Forte: Picco {max_wind}km/h alle {peak_time}") + # 3. VENTO + if winds: + wind_values = [w for w in winds if w is not None] + if wind_values: + max_wind = max(wind_values) + 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}") return events -def format_report(data, location_name): - hourly = data['hourly'] - daily = data['daily'] +def generate_practical_advice(trend, transitions, events_summary, daily_data): + """Genera consigli pratici basati sull'analisi meteo""" + advice = [] - msg = f"🌍 METEO ALERT: {location_name.upper()}\n" - msg += f"📡 Modelli: AROME/ICON HD\n\n" + # Consigli basati su trend temperatura + 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"] == "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": + advice.append("🌡️ Raffreddamento: Temperature in calo graduale. Vestiti a strati, verifica isolamento porte/finestre.") + elif trend["type"] == "riscaldamento": + advice.append("☀️ Riscaldamento: Temperature in aumento. Buon momento per attività all'aperto, ventilazione naturale.") + + # Consigli basati su transizioni meteo + significant_rain = any(t["to"] in ["pioggia", "neve", "temporale"] and t["significant"] for t in transitions[:3]) + if significant_rain: + advice.append("🌧️ Precipitazioni in Arrivo: Prepara ombrelli/impermeabili. Evita viaggi non necessari durante picchi, controlla grondaie.") + + # Consigli basati su eventi pericolosi + has_ice = any("GELICIDIO" in e or "GHIACCIO" in e for events in events_summary for e in events if events) + if has_ice: + advice.append("⚠️ Rischio Ghiaccio: Strade scivolose previste. Evita viaggi non urgenti, guida con estrema cautela, antigelo/sale pronti.") + + # Consigli basati su vento forte + has_wind = any("Vento Forte" in e for events in events_summary for e in events if events) + if has_wind: + advice.append("💨 Vento Forte: Fissa oggetti in balcone/giardino, attenzione a rami, guidare con prudenza su strade esposte.") + + # Consigli stagionali generali + if daily_data and len(daily_data) > 0: + first_day = daily_data[0] + if first_day.get("t_min", 15) < 5: + advice.append("🏠 Gestione Domestica: Temperature basse previste. Verifica caldaia, risparmio energetico con isolamento, attenzione a tubazioni esterne.") + elif first_day.get("precip_sum", 0) > 20: + advice.append("💧 Piogge Intense: Accumuli significativi previsti. Controlla drenaggi, pozzetti, evitare zone soggette ad allagamenti.") + + return advice +def format_detailed_trend_explanation(trend, daily_data_list): + """Genera spiegazione dettagliata del trend temperatura su 10 giorni""" + if not trend: + return "" + + explanation = [] + explanation.append(f"📊 EVOLUZIONE TEMPERATURE (10 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'] + + 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 == "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}") + + # Aggiungi solo picchi significativi in modo sintetico + if trend.get("change_days"): + significant_changes = [c for c in trend["change_days"] if abs(c['delta']) > 3.0][:3] + if significant_changes: + change_texts = [] + for change in significant_changes: + day_name = f"Giorno {change['day']+1}" + direction = "↑" if change['delta'] > 0 else "↓" + change_texts.append(f"{direction} {day_name}: {change['from']:.0f}°→{change['to']:.0f}°C") + if change_texts: + explanation.append(f"Picchi: {', '.join(change_texts)}") + + explanation.append("") + + return "\n".join(explanation) + +def format_weather_context_report(models_data, location_name, country_code): + """Genera report contestuale intelligente con ensemble multi-modello""" + # Combina modelli a breve e lungo termine + merged_data = merge_multi_model_forecast(models_data, forecast_days=10) + + if not merged_data: + return "❌ Errore: Nessun dato meteo disponibile" + + hourly = merged_data.get('hourly', {}) + daily = merged_data.get('daily', {}) + models_used = merged_data.get('models_used', []) + + if not daily or not daily.get('time'): + return "❌ Errore: Dati meteo incompleti" + + msg_parts = [] + + # HEADER + models_text = " + ".join(models_used) if models_used else "Multi-modello" + msg_parts.append(f"🌍 METEO FORECAST") + msg_parts.append(f"{location_name.upper()}") + msg_parts.append(f"📡 Ensemble: {models_text}\n") + + # ANALISI TREND TEMPERATURA (Fronti) - Completa su 10 giorni + daily_temps_max = daily.get('temperature_2m_max', []) + daily_temps_min = daily.get('temperature_2m_min', []) + trend = analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10) + + # Spiegazione dettagliata trend (sempre, anche se stabile) + if trend: + trend_explanation = format_detailed_trend_explanation(trend, daily_data_list=[]) + if trend_explanation: + msg_parts.append(trend_explanation) + + # ANALISI TRANSIZIONI METEO - Include anche precipitazioni prossimi giorni + daily_time_list = daily.get('time', []) # Definito qui per uso successivo + daily_weathercodes = daily.get('weathercode', []) + transitions = analyze_weather_transitions(daily_weathercodes) + + # Cerca anche precipitazioni significative nei primi giorni + precip_list = daily.get('precipitation_sum', []) + weather_changes = [] + + # Aggiungi transizioni significative + if transitions: + significant_trans = [t for t in transitions if t.get("significant", False)] + for trans in significant_trans[:5]: + day_names = ["oggi", "domani", "dopodomani", "fra 3 giorni", "fra 4 giorni", "fra 5 giorni", "fra 6 giorni"] + day_idx = trans["day"] - 1 + if day_idx < len(day_names): + day_ref = day_names[day_idx] + else: + day_ref = f"fra {trans['day']} giorni" + weather_changes.append({ + "day": trans["day"], + "day_ref": day_ref, + "from": trans["from"], + "to": trans["to"], + "type": "transition" + }) + + # Aggiungi precipitazioni significative per domani/dopodomani se non già presenti + # Usa snowfall (più affidabile) per determinare il tipo (pioggia/neve/grandine) + # Prepara mappa hourly per giorni daily_map = defaultdict(list) - for i, t in enumerate(hourly['time']): + times = hourly.get('time', []) + for i, t in enumerate(times): daily_map[t.split("T")[0]].append(i) - count = 0 - for day_date, indices in daily_map.items(): - if count >= 7: break + for day_idx in range(min(3, len(precip_list))): + if day_idx >= len(precip_list) or precip_list[day_idx] is None: + continue - d_times = [hourly['time'][i] for i in indices] - d_codes = [hourly['weathercode'][i] for i in indices] - d_probs = [hourly['precipitation_probability'][i] for i in indices] - d_precip = [hourly['precipitation'][i] for i in indices] - d_winds = [hourly['windspeed_10m'][i] for i in indices] - d_temps = [hourly['temperature_2m'][i] for i in indices] - d_dews = [hourly['dewpoint_2m'][i] for i in indices] - + precip_amount = float(precip_list[day_idx]) + day_num = day_idx + 1 + + # Verifica se già presente come transizione + already_present = any(wc["day"] == day_num for wc in weather_changes) + if already_present: + continue + + # Ottieni dati hourly per questo giorno per determinare tipo precipitazione + day_date = daily_time_list[day_idx].split("T")[0] if day_idx < len(daily_time_list) else None + if not day_date: + continue + + indices = daily_map.get(day_date, []) + d_snow_day = [hourly.get('snowfall', [])[i] for i in indices if i < len(hourly.get('snowfall', []))] + d_codes_day = [hourly.get('weathercode', [])[i] for i in indices if i < len(hourly.get('weathercode', []))] + d_temps_day = [hourly.get('temperature_2m', [])[i] for i in indices if i < len(hourly.get('temperature_2m', []))] + d_dews_day = [hourly.get('dewpoint_2m', [])[i] for i in indices if i < len(hourly.get('dewpoint_2m', []))] + + # Calcola accumulo neve per questo giorno + snow_sum_day = sum([float(s) for s in d_snow_day if s is not None]) if d_snow_day else 0.0 + + # Determina tipo precipitazione usando snowfall (priorità) o weathercode (fallback) + # NON inventiamo neve basandoci su temperatura - solo se snowfall>0 o weathercode indica neve + # PRIORITÀ: Se c'è neve (anche poca), il simbolo è sempre ❄️, anche se la pioggia è maggiore + precip_type_symbol = "💧" # Default pioggia + 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 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) + else: + # Fallback: verifica weathercode per neve esplicita + # Solo se i modelli indicano esplicitamente neve nei codici WMO + snow_codes = [71, 73, 75, 77, 85, 86] # Codici WMO per neve + 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) + + if hail_count > 0: + precip_type_symbol = "⛈️" # Grandine/Temporale + threshold_mm = 5.0 + elif snow_count > 0: + # Solo se weathercode indica esplicitamente neve + precip_type_symbol = "❄️" # Neve + threshold_mm = 0.5 # Soglia più bassa per neve + + # Aggiungi solo se supera la soglia appropriata + if precip_amount > threshold_mm: + day_names = ["oggi", "domani", "dopodomani"] + if day_idx < len(day_names): + weather_changes.append({ + "day": day_num, + "day_ref": day_names[day_idx], + "from": "variabile", + "to": "precipitazioni", + "type": "precip", + "amount": precip_amount, + "precip_symbol": precip_type_symbol + }) + + if weather_changes: + # Ordina per giorno + weather_changes.sort(key=lambda x: x["day"]) + msg_parts.append("🔄 CAMBIAMENTI METEO SIGNIFICATIVI") + for wc in weather_changes[:5]: + if wc["type"] == "transition": + from_icon = "☀️" if wc["from"] == "sereno" else "☁️" if wc["from"] == "nuvoloso" else "🌧️" + to_icon = "🌧️" if "pioggia" in wc["to"] else "❄️" if "neve" in wc["to"] else "⛈️" if "temporale" in wc["to"] else "☁️" + msg_parts.append(f" {from_icon}→{to_icon} {wc['day_ref']}: {wc['from']} → {wc['to']}") + else: + # Precipitazioni - usa simbolo appropriato + precip_sym = wc.get('precip_symbol', '💧') + msg_parts.append(f" ☁️→{precip_sym} {wc['day_ref']}: precipitazioni ({wc['amount']:.1f}mm)") + msg_parts.append("") + + # DETTAGLIO GIORNALIERO + # Usa i dati daily come riferimento principale (sono più affidabili) + # daily_time_list già definito sopra + temp_min_list = daily.get('temperature_2m_min', []) + temp_max_list = daily.get('temperature_2m_max', []) + + # Limita ai giorni per cui abbiamo dati daily validi + max_days = min(len(daily_time_list), len(temp_min_list), len(temp_max_list), 10) + + # Mappa hourly per eventi dettagliati + daily_map = defaultdict(list) + times = hourly.get('time', []) + for i, t in enumerate(times): + daily_map[t.split("T")[0]].append(i) + + events_summary = [] + daily_details = [] + + for count in range(max_days): + day_date = daily_time_list[count].split("T")[0] if count < len(daily_time_list) else None + if not day_date: + break + + # Ottieni indici hourly per questo giorno + indices = daily_map.get(day_date, []) + + # Estrai dati hourly per questo giorno (se disponibili) + d_times = [hourly['time'][i] for i in indices if i < len(hourly.get('time', []))] + d_codes = [hourly.get('weathercode', [])[i] for i in indices if i < len(hourly.get('weathercode', []))] + d_probs = [hourly.get('precipitation_probability', [])[i] for i in indices if i < len(hourly.get('precipitation_probability', []))] + d_precip = [hourly.get('precipitation', [])[i] for i in indices if i < len(hourly.get('precipitation', []))] + d_snow = [hourly.get('snowfall', [])[i] for i in indices if i < len(hourly.get('snowfall', []))] + d_winds = [hourly.get('windspeed_10m', [])[i] for i in indices if i < len(hourly.get('windspeed_10m', []))] + d_winddir = [hourly.get('winddirection_10m', [])[i] for i in indices if i < len(hourly.get('winddirection_10m', []))] + d_temps = [hourly.get('temperature_2m', [])[i] for i in indices if i < len(hourly.get('temperature_2m', []))] + d_dews = [hourly.get('dewpoint_2m', [])[i] for i in indices if i < len(hourly.get('dewpoint_2m', []))] + d_clouds = [hourly.get('cloud_cover', [])[i] for i in indices if i < len(hourly.get('cloud_cover', []))] + d_rains = [hourly.get('rain', [])[i] for i in indices if i < len(hourly.get('rain', []))] + d_soil_temps = [hourly.get('soil_temperature_0cm', [])[i] for i in indices if i < len(hourly.get('soil_temperature_0cm', []))] + d_snow_depth = [hourly.get('snow_depth', [])[i] for i in indices if i < len(hourly.get('snow_depth', []))] + + # Usa dati daily come primario (più affidabili) try: - t_min = daily['temperature_2m_min'][count] - t_max = daily['temperature_2m_max'][count] - except: - t_min, t_max = min(d_temps), max(d_temps) - - events_list = analyze_daily_events(d_times, d_codes, d_probs, d_precip, d_winds, d_temps, d_dews) + t_min_val = temp_min_list[count] if count < len(temp_min_list) else None + t_max_val = temp_max_list[count] if count < len(temp_max_list) else None + + # Se dati daily validi, usali; altrimenti calcola da hourly + if t_min_val is not None and t_max_val is not None: + t_min = float(t_min_val) + t_max = float(t_max_val) + elif d_temps and any(t is not None for t in d_temps): + temp_clean = [float(t) for t in d_temps if t is not None] + t_min = min(temp_clean) + t_max = max(temp_clean) + else: + # Se non ci sono dati, salta questo giorno + continue + + # Usa dati daily per caratterizzazione precipitazioni + precip_list = daily.get('precipitation_sum', []) + snowfall_list = daily.get('snowfall_sum', []) + rain_list = daily.get('rain_sum', []) + showers_list = daily.get('showers_sum', []) + + if count < len(precip_list) and precip_list[count] is not None: + precip_sum = float(precip_list[count]) + elif d_precip and any(p is not None for p in d_precip): + precip_sum = sum([float(p) for p in d_precip if p is not None]) + else: + precip_sum = 0.0 + + # Caratterizza precipitazioni usando dati daily + snowfall_sum = 0.0 + rain_sum = 0.0 + showers_sum = 0.0 + + if count < len(snowfall_list) and snowfall_list[count] is not None: + snowfall_sum = float(snowfall_list[count]) + elif d_snow and any(s is not None for s in d_snow): + snowfall_sum = sum([float(s) for s in d_snow if s is not None]) + + if count < len(rain_list) and rain_list[count] is not None: + rain_sum = float(rain_list[count]) + + if count < len(showers_list) and showers_list[count] is not None: + showers_sum = float(showers_list[count]) + + wind_list = daily.get('windspeed_10m_max', []) + if count < len(wind_list) and wind_list[count] is not None: + wind_max = float(wind_list[count]) + elif d_winds and any(w is not None for w in d_winds): + wind_max = max([float(w) for w in d_winds if w is not None]) + else: + wind_max = 0.0 + + # Calcola direzione vento dominante + wind_dir_deg = None + wind_dir_list = daily.get('winddirection_10m_dominant', []) + if count < len(wind_dir_list) and wind_dir_list[count] is not None: + wind_dir_deg = float(wind_dir_list[count]) + elif d_winddir and any(wd is not None for wd in d_winddir): + # Media delle direzioni vento del giorno + wind_dir_clean = [float(wd) for wd in d_winddir if wd is not None] + if wind_dir_clean: + # Calcola media circolare + import math + sin_sum = sum(math.sin(math.radians(wd)) for wd in wind_dir_clean) + cos_sum = sum(math.cos(math.radians(wd)) for wd in wind_dir_clean) + wind_dir_deg = math.degrees(math.atan2(sin_sum / len(wind_dir_clean), cos_sum / len(wind_dir_clean))) + if wind_dir_deg < 0: + wind_dir_deg += 360 + wind_dir_cardinal = degrees_to_cardinal(int(wind_dir_deg)) if wind_dir_deg is not None else "N" + except (ValueError, TypeError, IndexError) as e: + # Se ci sono errori nei dati, salta questo giorno + continue + + events_list = analyze_daily_events(d_times, d_codes, d_probs, d_precip, d_winds, d_temps, d_dews, + snowfalls=d_snow, rains=d_rains, soil_temps=d_soil_temps, + cloud_covers=d_clouds, wind_speeds=d_winds) + events_summary.append(events_list) dt = datetime.datetime.strptime(day_date, "%Y-%m-%d") - day_str = dt.strftime("%a %d/%m") + # Nomi giorni in italiano + giorni_ita = ["Lun", "Mar", "Mer", "Gio", "Ven", "Sab", "Dom"] + day_str = f"{giorni_ita[dt.weekday()]} {dt.strftime('%d/%m')}" - msg += f"📅 {day_str} 🌡️ {t_min:.0f}°/{t_max:.0f}°C\n" - - if events_list: - for ev in events_list: - msg += f" ➤ {ev}\n" + # Icona meteo principale basata sul weathercode del giorno + wcode = daily.get('weathercode', [])[count] if count < len(daily.get('weathercode', [])) else None + if wcode is None and d_codes: + # Se non c'è weathercode daily, usa il più frequente tra gli hourly + codes_clean = [int(c) for c in d_codes if c is not None] + if codes_clean: + wcode = Counter(codes_clean).most_common(1)[0][0] + else: + wcode = 0 else: - msg += " ✅ Nessun fenomeno rilevante\n" + wcode = int(wcode) if wcode is not None else 0 - msg += "\n" + # Calcola probabilità e tipo precipitazione per il giorno (PRIMA di determinare icona) + precip_prob = 0 + precip_type = None + if d_probs and any(p is not None for p in d_probs): + prob_values = [p for p in d_probs if p is not None] + precip_prob = max(prob_values) if prob_values else 0 + + # Determina tipo precipitazione usando dati daily (più affidabili) + # Usa snowfall_sum, rain_sum, showers_sum per caratterizzazione precisa + # PRIORITÀ: Se snow_depth > 0 (modello italia_meteo_arpae_icon_2i), usa questi dati per determinare neve + precip_type = None + + # Verifica snow_depth (per modello italia_meteo_arpae_icon_2i quando snow_depth > 0) + # NOTA: I valori sono già convertiti in cm durante il recupero dall'API + has_snow_depth_data = False + max_snow_depth = 0.0 + if d_snow_depth and len(d_snow_depth) > 0: + snow_depth_valid = [] + for sd in d_snow_depth: + if sd is not None: + try: + val_cm = float(sd) # Già in cm + if val_cm >= 0: + snow_depth_valid.append(val_cm) + except (ValueError, TypeError): + continue + if snow_depth_valid: + max_snow_depth = max(snow_depth_valid) + if max_snow_depth > 0: # Se snow_depth > 0 cm, considera presenza di neve persistente + has_snow_depth_data = True + + # Verifica snow_depth INDIPENDENTEMENTE dalle precipitazioni + # snow_depth rappresenta il manto nevoso persistente, non la neve che cade + if has_snow_depth_data and max_snow_depth > 0: + # C'è manto nevoso al suolo (indipendente da snowfall) + # Questo influenza l'icona meteo, ma non il tipo di precipitazione se non sta nevicando + pass # Gestito separatamente per l'icona meteo + + if precip_sum > 0.1: + # Priorità 1: Se sta nevicando (snowfall > 0) e c'è manto nevoso, considera entrambi + if 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 + elif snowfall_sum > 0.1: + precip_type = "snow" # Neve in caduta + manto nevoso + else: + # Solo manto nevoso persistente, nessuna neve in caduta + # Il tipo di precipitazione resta quello basato su snowfall/rain + pass + # Priorità 2: Usa dati daily se disponibili + elif snowfall_sum > 0.1: + # C'è neve significativa + if snowfall_sum >= precip_sum * 0.5: + precip_type = "snow" + elif rain_sum > 0.1 or showers_sum > 0.1: + # Mista (neve + pioggia/temporali) + precip_type = "mixed" + else: + precip_type = "snow" + elif rain_sum > 0.1: + # Pioggia + if showers_sum > 0.1: + # Temporali (showers) + precip_type = "thunderstorms" + else: + precip_type = "rain" + elif showers_sum > 0.1: + # Solo temporali + precip_type = "thunderstorms" + 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 >= precip_sum * 0.5: + precip_type = "snow" + else: + precip_type = "rain" + else: + # Fallback: verifica weathercode per neve esplicita + snow_codes = [71, 73, 75, 77, 85, 86] # Codici WMO per neve + rain_codes = [51, 53, 55, 56, 57, 61, 63, 65, 80, 81, 82, 66, 67] # Codici WMO per pioggia + hail_codes = [96, 99] # Codici WMO per grandine/temporale + snow_count = sum(1 for c in d_codes if c is not None and int(c) in snow_codes) + rain_count = sum(1 for c in d_codes if c is not None and int(c) in rain_codes) + hail_count = sum(1 for c in d_codes if c is not None and int(c) in hail_codes) + + if hail_count > 0: + precip_type = "hail" + elif snow_count > rain_count: + precip_type = "snow" + else: + precip_type = "rain" + + # Determina icona basandosi su precipitazioni (priorità) e poi nuvolosità/weathercode + # Usa precip_type già calcolato + # NOTA: snow_depth è INDIPENDENTE da precipitazioni - influenza l'icona anche senza nevicate + has_precip = precip_sum > 0.1 + + if has_precip: + # Precipitazioni: usa precip_type per determinare icona + if precip_type == "snow": + weather_icon = "❄️" # Neve + elif precip_type == "thunderstorms" or precip_type == "hail" or wcode in (95, 96, 99): + weather_icon = "⛈️" # Temporale/Grandine + elif precip_type == "mixed": + 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 + # Mostra icona neve anche se non sta nevicando + weather_icon = "❄️" # Manto nevoso presente + elif t_min < 0: + # Giorno freddo (t_min < 0): usa icona ghiaccio (indipendentemente da snow_depth) + # Se c'è anche snow_depth, viene già gestito sopra + weather_icon = "🧊" # Gelo/Ghiaccio (giorno freddo) + elif wcode in (45, 48): + weather_icon = "🌫️" # Nebbia + else: + # Nessuna precipitazione: usa nuvolosità se disponibile, altrimenti weathercode + avg_cloud = 0 + if d_clouds and any(c is not None for c in d_clouds): + cloud_clean = [float(c) for c in d_clouds if c is not None] + avg_cloud = sum(cloud_clean) / len(cloud_clean) if cloud_clean else 0 + + if avg_cloud > 0: + # Usa nuvolosità media + if avg_cloud <= 25: + weather_icon = "☀️" # Sereno + elif avg_cloud <= 50: + weather_icon = "⛅" # Parzialmente nuvoloso + elif avg_cloud <= 75: + weather_icon = "☁️" # Nuvoloso + else: + weather_icon = "☁️" # Molto nuvoloso + else: + # Fallback a weathercode + if wcode in (0, 1): + weather_icon = "☀️" # Sereno + elif wcode in (2, 3): + weather_icon = "⛅" # Parzialmente nuvoloso + else: + weather_icon = "☁️" # Nuvoloso + + # Recupera probabilità max daily se disponibile + prob_max_list = daily.get('precipitation_probability_max', []) + precip_prob_max = None + if count < len(prob_max_list) and prob_max_list[count] is not None: + precip_prob_max = int(prob_max_list[count]) + elif precip_prob > 0: + precip_prob_max = int(precip_prob) + + # Calcola spessore manto nevoso (snow_depth) per questo giorno + # Nota: snow_depth è disponibile solo per alcuni modelli (es. ICON Italia, ICON-D2) + # Se il modello non supporta snow_depth, tutti i valori saranno None o mancanti + snow_depth_min = None + snow_depth_max = None + snow_depth_avg = None + snow_depth_end = None + + # Verifica se abbiamo dati snow_depth validi per questo giorno + # NOTA: I valori sono già convertiti in cm durante il recupero dall'API + # Estrai anche direttamente dall'array hourly per sicurezza (fallback se d_snow_depth è vuoto) + # PRIORITÀ: usa sempre i dati dall'array hourly per quel giorno (più affidabile) + all_snow_depth_values = [] + hourly_snow_depth = hourly.get('snow_depth', []) + hourly_times = hourly.get('time', []) + + # Cerca direttamente nell'array hourly per questo giorno usando i timestamp (più affidabile) + if hourly_snow_depth and hourly_times and day_date: + for idx, ts in enumerate(hourly_times): + if ts.startswith(day_date) and idx < len(hourly_snow_depth): + if hourly_snow_depth[idx] is not None: + all_snow_depth_values.append(hourly_snow_depth[idx]) + + # Se non trovato con timestamp, usa d_snow_depth come fallback + if not all_snow_depth_values and d_snow_depth and len(d_snow_depth) > 0: + all_snow_depth_values = d_snow_depth + + if all_snow_depth_values and len(all_snow_depth_values) > 0: + # Filtra solo valori validi (>= 0 e non null, già in cm) + snow_depth_clean = [] + has_valid_data = False + for sd in all_snow_depth_values: + if sd is not None: + has_valid_data = True # Almeno un dato non-null presente + try: + val_cm = float(sd) # Già in cm + if val_cm >= 0: # Solo valori non negativi + snow_depth_clean.append(val_cm) + except (ValueError, TypeError): + continue + + # Calcola statistiche se abbiamo almeno un valore non-null (il modello supporta snow_depth) + # snow_depth è INDIPENDENTE da snowfall: rappresenta il manto nevoso persistente al suolo + # Mostriamo sempre quando disponibile e > 0, anche se non nevica + if has_valid_data and snow_depth_clean: + max_depth = max(snow_depth_clean) + min_depth = min(snow_depth_clean) + # Calcola sempre le statistiche se ci sono dati validi, anche se il valore è piccolo + if max_depth > 0: # Mostra se almeno un valore > 0 cm + snow_depth_min = min_depth + snow_depth_max = max_depth + snow_depth_avg = sum(snow_depth_clean) / len(snow_depth_clean) + # Prendi l'ultimo valore non-null del giorno (spessore alla fine del giorno) + # Ordina per valore per trovare l'ultimo valore del giorno (non necessariamente l'ultimo della lista) + snow_depth_end = snow_depth_clean[-1] if snow_depth_clean else None + # Preferisci l'ultimo valore non-null originale per avere il valore alla fine del giorno + if all_snow_depth_values: + for sd in reversed(all_snow_depth_values): + if sd is not None: + try: + val_cm = float(sd) + if val_cm > 0: + snow_depth_end = val_cm + break + except (ValueError, TypeError): + continue + + day_info = { + "day_str": day_str, + "t_min": t_min, + "t_max": t_max, + "precip_sum": precip_sum, + "precip_prob": precip_prob_max if precip_prob_max is not None else precip_prob, + "precip_type": precip_type, + "snowfall_sum": snowfall_sum, + "rain_sum": rain_sum, + "showers_sum": showers_sum, + "wind_max": wind_max, + "wind_dir": wind_dir_cardinal, + "events": events_list, + "weather_icon": weather_icon, + "snow_depth_min": snow_depth_min, + "snow_depth_max": snow_depth_max, + "snow_depth_avg": snow_depth_avg, + "snow_depth_end": snow_depth_end + } + daily_details.append(day_info) count += 1 - return msg + # Formatta dettagli giornalieri (tutti i giorni disponibili) + msg_parts.append("📅 PREVISIONI GIORNALIERE") + prev_snow_depth_end = None # Traccia lo spessore del giorno precedente per mostrare evoluzione + for day_info in daily_details: + line = f"{day_info['weather_icon']} {day_info['day_str']} 🌡️ {day_info['t_min']:.0f}°/{day_info['t_max']:.0f}°C" + + # Aggiungi informazioni precipitazioni con caratterizzazione dettagliata + # Nota: mm è accumulo totale giornaliero (somma di tutte le ore) + if day_info['precip_sum'] > 0.1: + # Caratterizza usando dati daily se disponibili + precip_parts = [] + + # Neve + if day_info.get('snowfall_sum', 0) > 0.1: + precip_parts.append(f"❄️ {day_info['snowfall_sum']:.1f}cm") + + # Pioggia + if day_info.get('rain_sum', 0) > 0.1: + precip_parts.append(f"🌧️ {day_info['rain_sum']:.1f}mm") + + # Temporali (showers) + if day_info.get('showers_sum', 0) > 0.1: + precip_parts.append(f"⛈️ {day_info['showers_sum']:.1f}mm") + + # Se non abbiamo dati daily dettagliati, usa il tipo generale + 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") + + line += f" | {' + '.join(precip_parts)}" + + # Aggiungi probabilità se disponibile + if day_info['precip_prob'] and day_info['precip_prob'] > 0: + line += f" ({int(day_info['precip_prob'])}%)" + elif day_info['precip_prob'] > 50: + # Probabilità alta ma nessuna precipitazione prevista (può essere un errore del modello) + line += f" | 💧 Possibile ({int(day_info['precip_prob'])}%)" + + # Aggiungi vento (sempre se disponibile, formattato come direzione intensità) + if day_info['wind_max'] > 0: + wind_str = f"{day_info['wind_dir']} {day_info['wind_max']:.0f}km/h" + line += f" | 💨 {wind_str}" + msg_parts.append(line) + + # Mostra spessore manto nevoso se disponibile e > 0 + # snow_depth è INDIPENDENTE da snowfall: rappresenta il manto nevoso persistente al suolo + # Deve essere sempre mostrato quando disponibile, anche nei giorni senza nevicate + # Se snow_depth_end è None ma ci sono dati validi, ricalcola + snow_depth_end = day_info.get('snow_depth_end') + if snow_depth_end is None: + # Prova a ricalcolare da snow_depth_min/max/avg se disponibili + snow_depth_max = day_info.get('snow_depth_max') + snow_depth_avg = day_info.get('snow_depth_avg') + if snow_depth_max is not None and snow_depth_max > 0: + snow_depth_end = snow_depth_max # Usa il massimo come fallback + 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: + 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 = snow_depth_end - prev_snow_depth_end + if abs(diff) > 0.5: # Solo se variazione significativa + if diff > 0: + snow_depth_str += f" (↑ +{diff:.1f} cm)" + else: + snow_depth_str += f" (↓ {diff:.1f} cm)" + # Mostra range se c'è variazione significativa durante il giorno + snow_depth_min = day_info.get('snow_depth_min') + snow_depth_max = day_info.get('snow_depth_max') + if snow_depth_min is not None and snow_depth_max is not None: + if snow_depth_max - snow_depth_min > 1.0: # Variazione > 1cm durante il giorno + snow_depth_str += f" [range: {snow_depth_min:.1f}-{snow_depth_max:.1f} cm]" + msg_parts.append(f" {snow_depth_str}") + + if day_info['events']: + for ev in day_info['events'][:3]: # Limita a 3 eventi principali + msg_parts.append(f" ➤ {ev}") + + # Aggiorna per il prossimo giorno + prev_snow_depth_end = snow_depth_end if snow_depth_end is not None else prev_snow_depth_end + msg_parts.append("") + + + return "\n".join(msg_parts) -def send_telegram(text, chat_id, token): - requests.post(f"https://api.telegram.org/bot{token}/sendMessage", - json={"chat_id": chat_id, "text": text, "parse_mode": "HTML"}) +def send_telegram(text, chat_id, token, debug_mode=False): + if not token: + return False + url = f"https://api.telegram.org/bot{token}/sendMessage" + payload = { + "chat_id": chat_id, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": True + } + try: + resp = requests.post(url, json=payload, timeout=15) + return resp.status_code == 200 + except: + return False def main(): parser = argparse.ArgumentParser() parser.add_argument("query", nargs="?", default="casa") parser.add_argument("--chat_id") parser.add_argument("--debug", action="store_true") + parser.add_argument("--home", action="store_true") + parser.add_argument("--timezone", help="Timezone IANA (es: Europe/Rome, America/New_York)") args = parser.parse_args() token = get_bot_token() - dest_chat = args.chat_id if args.chat_id and not args.debug else ADMIN_CHAT_ID + debug_mode = args.debug - lat, lon, name = get_coordinates(args.query) - if not lat: return send_telegram(f"❌ '{args.query}' non trovato.", dest_chat, token) - - data = get_weather(lat, lon) - if not data: return send_telegram("❌ Errore dati meteo.", dest_chat, token) - - send_telegram(format_report(data, name), dest_chat, token) + # Determina destinatari + if debug_mode: + recipients = [ADMIN_CHAT_ID] + elif args.chat_id: + recipients = [args.chat_id] + else: + recipients = TELEGRAM_CHAT_IDS + + # Determina località + if args.home or (not args.query or args.query.lower() == "casa"): + lat, lon, name, cc = DEFAULT_LAT, DEFAULT_LON, DEFAULT_NAME, "SM" + else: + coords = get_coordinates(args.query) + if not coords[0]: + error_msg = f"❌ Località '{args.query}' non trovata." + if token: + for chat_id in recipients: + send_telegram(error_msg, chat_id, token, debug_mode) + else: + print(error_msg) + return + lat, lon, name, cc = coords + + # Recupera dati multi-modello (breve + lungo termine) - selezione intelligente basata su country code + # Determina se è Casa + is_home = (abs(lat - DEFAULT_LAT) < 0.01 and abs(lon - DEFAULT_LON) < 0.01) + + # Recupera dati multi-modello (breve + lungo termine) + # - Per Casa: usa AROME Seamless e ICON-D2 + # - Per altre località: usa best match di Open-Meteo + short_models, long_models = choose_models_by_country(cc, is_home=is_home) + + # Usa timezone personalizzata se fornita + timezone = args.timezone if hasattr(args, 'timezone') and args.timezone else None + + models_data = get_weather_multi_model(lat, lon, short_models, long_models, forecast_days=10, timezone=timezone) + + if not any(models_data.values()): + error_msg = "❌ Errore: Impossibile recuperare dati meteo." + if token: + for chat_id in recipients: + send_telegram(error_msg, chat_id, token, debug_mode) + else: + print(error_msg) + return + + # Genera report + report = format_weather_context_report(models_data, name, cc) + + if debug_mode: + report = f"🛠 [DEBUG MODE] 🛠\n\n{report}" + + # Invia + if token: + success = False + for chat_id in recipients: + if send_telegram(report, chat_id, token, debug_mode): + success = True + if not success: + print("❌ Errore invio Telegram") + else: + print(report) if __name__ == "__main__": main() diff --git a/services/telegram-bot/road_weather.py b/services/telegram-bot/road_weather.py new file mode 100644 index 0000000..bf53bb7 --- /dev/null +++ b/services/telegram-bot/road_weather.py @@ -0,0 +1,1821 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Road Weather Analysis - Analisi completa dei rischi meteo lungo un percorso stradale. +Analizza: ghiaccio, neve, pioggia, rovesci, pioggia intensa, nebbia, grandine, temporali. +""" + +import argparse +import datetime +import json +import logging +import os +import requests +import time +from logging.handlers import RotatingFileHandler +from typing import Dict, List, Tuple, Optional + +# Setup logging +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +LOG_FILE = os.path.join(SCRIPT_DIR, "road_weather.log") + +def setup_logger() -> logging.Logger: + logger = logging.getLogger("road_weather") + logger.setLevel(logging.INFO) + logger.handlers.clear() + + fh = RotatingFileHandler(LOG_FILE, maxBytes=1_000_000, backupCount=5, encoding="utf-8") + fh.setLevel(logging.DEBUG) + fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s") + fh.setFormatter(fmt) + logger.addHandler(fh) + + return logger + +LOGGER = setup_logger() + +# Import opzionale di pandas e numpy per analisi avanzata +try: + import pandas as pd + import numpy as np + PANDAS_AVAILABLE = True +except ImportError: + PANDAS_AVAILABLE = False + pd = None + np = None + +# ============================================================================= +# CONFIGURAZIONE +# ============================================================================= + +# Modelli meteo disponibili +MODELS = { + "ICON Italia": "italia_meteo_arpae_icon_2i", + "ICON EU": "icon_eu", + "AROME Seamless": "meteofrance_seamless" +} + +# Soglie di rischio +THRESHOLDS = { + # Ghiaccio/Neve + "ice_temp_air": 2.0, # °C - temperatura aria per rischio ghiaccio + "ice_temp_soil": 4.0, # °C - temperatura suolo per rischio ghiaccio + "snowfall_cm_h": 0.5, # cm/h - neve significativa + + # Pioggia + "rain_light_mm_h": 2.5, # mm/h - pioggia leggera + "rain_moderate_mm_h": 7.5, # mm/h - pioggia moderata + "rain_heavy_mm_h": 15.0, # mm/h - pioggia intensa + "rain_very_heavy_mm_h": 30.0, # mm/h - pioggia molto intensa + + # Vento + "wind_strong_kmh": 50.0, # km/h - vento forte + "wind_very_strong_kmh": 70.0, # km/h - vento molto forte + + # Nebbia + "fog_visibility_m": 200.0, # m - visibilità per nebbia + + # Temporali + "cape_lightning": 800.0, # J/kg - CAPE per rischio fulminazioni + "cape_severe": 1500.0, # J/kg - CAPE per temporali severi + "wind_gust_downburst": 60.0, # km/h - raffiche per downburst +} + +# Weather codes WMO +WEATHER_CODES = { + # Pioggia + 61: "Pioggia leggera", + 63: "Pioggia moderata", + 65: "Pioggia forte", + 66: "Pioggia gelata leggera", + 67: "Pioggia gelata forte", + 80: "Rovesci leggeri", + 81: "Rovesci moderati", + 82: "Rovesci violenti", + + # Neve + 71: "Nevischio leggero", + 73: "Nevischio moderato", + 75: "Nevischio forte", + 77: "Granelli di neve", + 85: "Rovesci di neve leggeri", + 86: "Rovesci di neve forti", + + # Grandine + 89: "Grandine", + 90: "Grandine con temporale", + + # Temporali + 95: "Temporale", + 96: "Temporale con grandine", + 99: "Temporale violento con grandine", + + # Nebbia + 45: "Nebbia", + 48: "Nebbia con brina", +} + +# ============================================================================= +# UTILITY FUNCTIONS +# ============================================================================= + +def get_google_maps_api_key() -> Optional[str]: + """Ottiene la chiave API di Google Maps da variabile d'ambiente.""" + api_key = os.environ.get('GOOGLE_MAPS_API_KEY', '').strip() + if api_key: + return api_key + api_key = os.environ.get('GOOGLE_API_KEY', '').strip() + if api_key: + return api_key + # Debug: verifica tutte le variabili d'ambiente che contengono GOOGLE + if os.environ.get('DEBUG_GOOGLE_MAPS', ''): + google_vars = {k: v[:10] + '...' if len(v) > 10 else v for k, v in os.environ.items() if 'GOOGLE' in k.upper()} + LOGGER.debug(f"Variabili GOOGLE trovate: {google_vars}") + return None + + +def decode_polyline(polyline_str: str) -> List[Tuple[float, float]]: + """Decodifica un polyline codificato di Google Maps (algoritmo standard).""" + if not polyline_str: + LOGGER.warning("Polyline string vuota") + return [] + + def _decode_value(index: int) -> Tuple[int, int]: + """Decodifica un valore dal polyline e ritorna (valore, nuovo_indice).""" + result = 0 + shift = 0 + b = 0x20 + + while b >= 0x20 and index < len(polyline_str): + b = ord(polyline_str[index]) - 63 + result |= (b & 0x1f) << shift + shift += 5 + index += 1 + + if result & 1: + result = ~result + + return (result >> 1, index) + + points = [] + index = 0 + lat = 0 + lon = 0 + + try: + while index < len(polyline_str): + # Decodifica latitudine + lat_delta, index = _decode_value(index) + lat += lat_delta + + # Decodifica longitudine (se disponibile) + if index >= len(polyline_str): + # Se abbiamo solo la latitudine, aggiungiamo il punto comunque + # (potrebbe essere l'ultimo punto del percorso) + LOGGER.debug(f"Fine stringa dopo latitudine, aggiungo punto con lon precedente") + points.append((lat / 1e5, lon / 1e5)) + break + + lon_delta, index = _decode_value(index) + lon += lon_delta + + points.append((lat / 1e5, lon / 1e5)) + + LOGGER.info(f"Polyline decodificato: {len(points)} punti estratti") + if len(points) > 0: + LOGGER.debug(f"Primo punto: {points[0]}, Ultimo punto: {points[-1]}") + else: + LOGGER.warning("Nessun punto estratto dal polyline") + + return points + except Exception as e: + LOGGER.error(f"Errore durante decodifica polyline: {e}", exc_info=True) + return [] + + +def calculate_route_points(lat1: float, lon1: float, lat2: float, lon2: float, + num_points: int = 8) -> List[Tuple[float, float]]: + """Calcola punti lungo percorso stradale reale usando Google Maps.""" + api_key = get_google_maps_api_key() + + # Debug: verifica se la chiave è stata trovata + if not api_key: + # Prova a verificare tutte le variabili d'ambiente + all_env_vars = {k: '***' for k in os.environ.keys() if 'GOOGLE' in k.upper() or 'MAPS' in k.upper()} + if all_env_vars: + LOGGER.warning(f"Variabili GOOGLE trovate ma non riconosciute: {list(all_env_vars.keys())}") + else: + LOGGER.warning("Nessuna variabile GOOGLE_MAPS_API_KEY o GOOGLE_API_KEY trovata") + + if api_key: + LOGGER.info(f"Google Maps API Key trovata (lunghezza: {len(api_key)} caratteri)") + try: + # Prova prima con Routes API (nuova) - POST request + url = f"https://routes.googleapis.com/directions/v2:computeRoutes" + headers = { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': api_key, + 'X-Goog-FieldMask': 'routes.distanceMeters,routes.duration,routes.polyline.encodedPolyline' + } + payload = { + "origin": { + "location": { + "latLng": { + "latitude": lat1, + "longitude": lon1 + } + } + }, + "destination": { + "location": { + "latLng": { + "latitude": lat2, + "longitude": lon2 + } + } + }, + "travelMode": "DRIVE", + "routingPreference": "TRAFFIC_AWARE", + "computeAlternativeRoutes": False, + "polylineEncoding": "ENCODED_POLYLINE" + } + + LOGGER.info(f"Chiamata Google Maps Routes API: origin=({lat1},{lon1}), dest=({lat2},{lon2})") + try: + response = requests.post(url, headers=headers, json=payload, timeout=10) + LOGGER.info(f"Google Maps Routes API HTTP status: {response.status_code}") + except requests.exceptions.RequestException as e: + LOGGER.error(f"Errore richiesta HTTP Routes API: {e}", exc_info=True) + raise + + if response.status_code == 200: + try: + data = response.json() + LOGGER.debug(f"Google Maps Routes API response keys: {list(data.keys())}") + except json.JSONDecodeError as e: + LOGGER.error(f"Errore parsing JSON risposta Routes API: {e}") + LOGGER.error(f"Response text: {response.text[:500]}") + raise + + if 'routes' in data and len(data['routes']) > 0: + route = data['routes'][0] + # Routes API usa 'polyline' invece di 'overview_polyline' + polyline_data = route.get('polyline', {}) + encoded_polyline = polyline_data.get('encodedPolyline', '') + + LOGGER.info(f"Polyline presente: {bool(encoded_polyline)}, lunghezza: {len(encoded_polyline) if encoded_polyline else 0}") + + if encoded_polyline: + route_points = decode_polyline(encoded_polyline) + if route_points: + LOGGER.info(f"✅ Google Maps Routes API: percorso trovato con {len(route_points)} punti") + if len(route_points) > 20: + sampled_points = [route_points[0]] + step = len(route_points) // (num_points + 1) + for i in range(1, len(route_points) - 1, max(1, step)): + sampled_points.append(route_points[i]) + sampled_points.append(route_points[-1]) + LOGGER.info(f"✅ Percorso campionato a {len(sampled_points)} punti per analisi") + return sampled_points + else: + return route_points + else: + LOGGER.warning("Polyline decodificato ma risultato vuoto") + else: + LOGGER.warning("Polyline non presente nella risposta Routes API") + LOGGER.warning(f"Route keys: {list(route.keys())}") + LOGGER.warning(f"Route data: {json.dumps(route, indent=2)[:1000]}") + else: + LOGGER.warning("Nessuna route nella risposta Routes API") + LOGGER.warning(f"Response keys: {list(data.keys())}") + LOGGER.warning(f"Response data: {json.dumps(data, indent=2)[:1000]}") + else: + LOGGER.error(f"Google Maps Routes API HTTP error: {response.status_code}") + try: + error_data = response.json() + LOGGER.error(f"Error details: {json.dumps(error_data, indent=2)[:1000]}") + except: + LOGGER.error(f"Response text: {response.text[:500]}") + + # Fallback: prova con Directions API (legacy) se Routes API fallisce + LOGGER.info("Tentativo fallback a Directions API (legacy)...") + url_legacy = "https://maps.googleapis.com/maps/api/directions/json" + params_legacy = { + 'origin': f"{lat1},{lon1}", + 'destination': f"{lat2},{lon2}", + 'key': api_key, + 'mode': 'driving', + 'alternatives': False + } + response_legacy = requests.get(url_legacy, params=params_legacy, timeout=10) + if response_legacy.status_code == 200: + data_legacy = response_legacy.json() + status = data_legacy.get('status', 'UNKNOWN') + if status == 'OK' and data_legacy.get('routes'): + route_legacy = data_legacy['routes'][0] + overview_polyline = route_legacy.get('overview_polyline', {}) + encoded_polyline = overview_polyline.get('points', '') + if encoded_polyline: + route_points = decode_polyline(encoded_polyline) + if route_points: + LOGGER.info(f"✅ Google Maps Directions API (legacy): percorso trovato con {len(route_points)} punti") + if len(route_points) > 20: + sampled_points = [route_points[0]] + step = len(route_points) // (num_points + 1) + for i in range(1, len(route_points) - 1, max(1, step)): + sampled_points.append(route_points[i]) + sampled_points.append(route_points[-1]) + return sampled_points + else: + return route_points + else: + error_message = data_legacy.get('error_message', 'Nessun messaggio') + LOGGER.error(f"Directions API (legacy) errore: {status} - {error_message}") + except requests.exceptions.RequestException as e: + LOGGER.error(f"Errore richiesta Google Maps Routes API: {e}", exc_info=True) + except Exception as e: + LOGGER.error(f"Errore Google Maps Routes API: {e}", exc_info=True) + else: + LOGGER.warning("Google Maps API Key non trovata - uso fallback linea d'aria") + + # Fallback: linea d'aria + LOGGER.info("Uso fallback: percorso in linea d'aria (non segue strade reali)") + + # Fallback: linea d'aria + points = [] + for i in range(num_points + 1): + ratio = i / num_points if num_points > 0 else 0 + lat = lat1 + (lat2 - lat1) * ratio + lon = lon1 + (lon2 - lon1) * ratio + points.append((lat, lon)) + return points + + +def get_coordinates_from_city(city_name: str) -> Optional[Tuple[float, float, str]]: + """Ottiene coordinate da nome città usando Open-Meteo Geocoding API.""" + # Gestione caso speciale "Casa" + if not city_name or city_name.lower() == "casa": + # Coordinate fisse per Casa (San Marino) + return (43.9356, 12.4296, "Casa") + + url = "https://geocoding-api.open-meteo.com/v1/search" + params = {"name": city_name, "count": 1, "language": "it"} + try: + resp = requests.get(url, params=params, timeout=5) + if resp.status_code == 200: + data = resp.json() + if data.get("results"): + result = data["results"][0] + return (result["latitude"], result["longitude"], result.get("name", city_name)) + except Exception as e: + LOGGER.warning(f"Errore geocoding per {city_name}: {e}") + return None + + +def get_location_name_from_coords(lat: float, lon: float) -> Optional[str]: + """Ottiene nome località da coordinate usando Nominatim.""" + url = "https://nominatim.openstreetmap.org/reverse" + try: + params = { + "lat": lat, + "lon": lon, + "format": "json", + "accept-language": "it", + "zoom": 10, + "addressdetails": 1 + } + headers = {"User-Agent": "Telegram-Bot-Road-Weather/1.0"} + resp = requests.get(url, params=params, headers=headers, timeout=5) + if resp.status_code == 200: + data = resp.json() + address = data.get("address", {}) + location_name = ( + address.get("city") or + address.get("town") or + address.get("village") or + address.get("municipality") or + address.get("county") or + address.get("state") + ) + if location_name: + state = address.get("state") + if state and state != location_name: + return f"{location_name} ({state})" + return location_name + except Exception as e: + LOGGER.warning(f"Errore reverse geocoding: {e}") + return None + + +def get_best_model_for_location(lat: float, lon: float) -> str: + """Determina il miglior modello disponibile per una località.""" + if 36.0 <= lat <= 48.0 and 6.0 <= lon <= 19.0: + test_data = get_weather_data(lat, lon, "italia_meteo_arpae_icon_2i") + if test_data: + return "italia_meteo_arpae_icon_2i" + + if 35.0 <= lat <= 72.0 and -12.0 <= lon <= 35.0: + test_data = get_weather_data(lat, lon, "icon_eu") + if test_data: + return "icon_eu" + + if 41.0 <= lat <= 52.0 and -5.0 <= lon <= 10.0: + test_data = get_weather_data(lat, lon, "meteofrance_seamless") + if test_data: + return "meteofrance_seamless" + + return "icon_eu" + + +def get_weather_data(lat: float, lon: float, model_slug: str) -> Optional[Dict]: + """Ottiene dati meteo da Open-Meteo.""" + url = f"https://api.open-meteo.com/v1/forecast" + + # Parametri base (aggiunto soil_temperature_0cm per analisi ghiaccio più accurata) + hourly_params = "temperature_2m,relative_humidity_2m,precipitation,rain,showers,snowfall,weathercode,visibility,wind_speed_10m,wind_gusts_10m,soil_temperature_0cm,dew_point_2m" + + # Aggiungi CAPE se disponibile (AROME Seamless o ICON) + if model_slug in ["meteofrance_seamless", "italia_meteo_arpae_icon_2i", "icon_eu"]: + hourly_params += ",cape" + + params = { + "latitude": lat, + "longitude": lon, + "models": model_slug, + "hourly": hourly_params, + "forecast_days": 2, + "past_days": 1, # Include 24h precedenti per analisi trend + "timezone": "auto" + } + + try: + resp = requests.get(url, params=params, timeout=10) + if resp.status_code == 200: + data = resp.json() + # Verifica che snowfall sia presente nei dati + if data.get("hourly", {}).get("snowfall") is None: + LOGGER.warning(f"Modello {model_slug} non fornisce dati snowfall per ({lat}, {lon})") + return data + except Exception as e: + LOGGER.error(f"Errore fetch dati meteo: {e}") + return None + + +# ============================================================================= +# ANALISI 24H PRECEDENTI +# ============================================================================= + +def analyze_past_24h_conditions(weather_data: Dict) -> Dict: + """ + Analizza le condizioni delle 24 ore precedenti per valutare trend e persistenza ghiaccio. + + Returns: + Dict con: + - has_precipitation: bool + - total_rain_mm: float + - total_snowfall_cm: float + - min_temp_2m: float + - hours_below_zero: int + - ice_persistence_likely: bool (ghiaccio persistente se T<2°C e/o neve presente) + - snow_present: bool + """ + if not weather_data or "hourly" not in weather_data: + return {} + + hourly = weather_data["hourly"] + times = hourly.get("time", []) + + if not times: + return {} + + now = datetime.datetime.now(datetime.timezone.utc) + past_24h_start = now - datetime.timedelta(hours=24) + + # Converti times in datetime + timestamps = [] + for ts_str in times: + try: + if 'Z' in ts_str: + ts = datetime.datetime.fromisoformat(ts_str.replace('Z', '+00:00')) + else: + ts = datetime.datetime.fromisoformat(ts_str) + if ts.tzinfo is None: + ts = ts.replace(tzinfo=datetime.timezone.utc) + timestamps.append(ts) + except: + continue + + temp_2m = hourly.get("temperature_2m", []) + soil_temp = hourly.get("soil_temperature_0cm", []) + precipitation = hourly.get("precipitation", []) + rain = hourly.get("rain", []) + snowfall = hourly.get("snowfall", []) + weathercode = hourly.get("weathercode", []) + + total_rain = 0.0 + total_snowfall = 0.0 + min_temp_2m = None + min_soil_temp = None + hours_below_zero = 0 + hours_below_2c = 0 + hours_below_zero_soil = 0 + snow_present = False + + for i, ts in enumerate(timestamps): + # Solo 24h precedenti + if ts < past_24h_start or ts >= now: + continue + + t_2m = temp_2m[i] if i < len(temp_2m) and temp_2m[i] is not None else None + t_soil = soil_temp[i] if i < len(soil_temp) and soil_temp[i] is not None else None + r = rain[i] if i < len(rain) and rain[i] is not None else 0.0 + snow = snowfall[i] if i < len(snowfall) and snowfall[i] is not None else 0.0 + code = weathercode[i] if i < len(weathercode) and weathercode[i] is not None else None + + if t_2m is not None: + if min_temp_2m is None or t_2m < min_temp_2m: + min_temp_2m = t_2m + if t_2m < 0: + hours_below_zero += 1 + if t_2m < 2.0: + hours_below_2c += 1 + + if t_soil is not None: + if min_soil_temp is None or t_soil < min_soil_temp: + min_soil_temp = t_soil + if t_soil < 0: + hours_below_zero_soil += 1 + + total_rain += r + total_snowfall += snow + + # Neve presente se snowfall > 0 o weathercode indica neve (71, 73, 75, 77, 85, 86) + if snow > 0.1 or (code is not None and code in [71, 73, 75, 77, 85, 86]): + snow_present = True + + # Ghiaccio persistente se: neve presente OPPURE (suolo gelato OPPURE T<2°C per molte ore E precipitazioni recenti) + ice_persistence_likely = snow_present or (min_soil_temp is not None and min_soil_temp <= 0) or (hours_below_2c >= 6 and total_rain > 0) + + # Analizza precipitazioni ultime 12 ore (più rilevanti per condizioni attuali) + now_12h = now - datetime.timedelta(hours=12) + total_rain_12h = 0.0 + total_snowfall_12h = 0.0 + max_precip_intensity_12h = 0.0 + + for i, ts in enumerate(timestamps): + if ts < now_12h or ts >= now: + continue + r = rain[i] if i < len(rain) and rain[i] is not None else 0.0 + snow = snowfall[i] if i < len(snowfall) and snowfall[i] is not None else 0.0 + prec = precipitation[i] if i < len(precipitation) and precipitation[i] is not None else 0.0 + total_rain_12h += r + total_snowfall_12h += snow + if prec > max_precip_intensity_12h: + max_precip_intensity_12h = prec + + # Calcola intensità media (mm/h) nelle ultime 12h + avg_precip_intensity_12h = (total_rain_12h + total_snowfall_12h * 10) / 12.0 if total_rain_12h > 0 or total_snowfall_12h > 0 else 0.0 + + # Analizza temperature attuali e previste (prossime 6h) + current_temp = None + next_6h_temps = [] + next_6h_snow = [] + + for i, ts in enumerate(timestamps): + if ts < now: + continue + if ts >= now + datetime.timedelta(hours=6): + break + + t_2m = temp_2m[i] if i < len(temp_2m) and temp_2m[i] is not None else None + snow = snowfall[i] if i < len(snowfall) and snowfall[i] is not None else 0.0 + + if current_temp is None and t_2m is not None: + current_temp = t_2m + + if t_2m is not None: + next_6h_temps.append(t_2m) + if snow > 0: + next_6h_snow.append(snow) + + # Calcola min/max temperature prossime 6h + min_temp_next_6h = min(next_6h_temps) if next_6h_temps else None + max_temp_next_6h = max(next_6h_temps) if next_6h_temps else None + avg_temp_next_6h = sum(next_6h_temps) / len(next_6h_temps) if next_6h_temps else None + + return { + 'has_precipitation': total_rain > 0 or total_snowfall > 0, + 'total_rain_mm': total_rain, + 'total_snowfall_cm': total_snowfall, + 'total_rain_12h_mm': total_rain_12h, + 'total_snowfall_12h_cm': total_snowfall_12h, + 'avg_precip_intensity_12h_mmh': avg_precip_intensity_12h, + 'max_precip_intensity_12h_mmh': max_precip_intensity_12h, + 'min_temp_2m': min_temp_2m, + 'min_soil_temp': min_soil_temp, + 'current_temp_2m': current_temp, + 'min_temp_next_6h': min_temp_next_6h, + 'max_temp_next_6h': max_temp_next_6h, + 'avg_temp_next_6h': avg_temp_next_6h, + 'hours_below_zero': hours_below_zero, + 'hours_below_2c': hours_below_2c, + 'hours_below_zero_soil': hours_below_zero_soil, + 'ice_persistence_likely': ice_persistence_likely, + 'snow_present': snow_present, + 'snow_next_6h_cm': sum(next_6h_snow) if next_6h_snow else 0.0 + } + + +# ============================================================================= +# ANALISI RISCHI METEO +# ============================================================================= + +def evaluate_ice_risk_temporal(weather_data: Dict, hour_idx: int, past_24h_info: Dict) -> Tuple[int, str]: + """ + Valuta il rischio ghiaccio basandosi sull'evoluzione temporale delle temperature e precipitazioni. + + Algoritmo: + - Temperatura scesa almeno a 0°C nelle 24h precedenti + - Precipitazioni (pioggia/temporali) presenti con temperature sotto zero + - Nessuna risalita significativa sopra 3°C nelle ore precedenti che indicherebbe scioglimento + + Returns: + (risk_level: int, description: str) + risk_level: 0=nessuno, 1=brina, 2=ghiaccio, 3=gelicidio + """ + if not past_24h_info: + return 0, "" + + # Estrai dati 24h precedenti + min_temp_24h = past_24h_info.get('min_temp_2m') + hours_below_zero = past_24h_info.get('hours_below_zero', 0) + hours_below_2c = past_24h_info.get('hours_below_2c', 0) + total_rain_24h = past_24h_info.get('total_rain_mm', 0) + total_rain_12h = past_24h_info.get('total_rain_12h_mm', 0) + avg_temp_next_6h = past_24h_info.get('avg_temp_next_6h') + current_temp = past_24h_info.get('current_temp_2m') + + # Estrai dati ora corrente + hourly = weather_data.get("hourly", {}) + times = hourly.get("time", []) + temps = hourly.get("temperature_2m", []) + soil_temps = hourly.get("soil_temperature_0cm", []) + rain = hourly.get("rain", []) + showers = hourly.get("showers", []) + weathercode = hourly.get("weathercode", []) + + if hour_idx >= len(times) or hour_idx >= len(temps): + return 0, "" + + temp_current = temps[hour_idx] if hour_idx < len(temps) and temps[hour_idx] is not None else None + soil_temp_current = soil_temps[hour_idx] if hour_idx < len(soil_temps) and soil_temps[hour_idx] is not None else None + rain_current = rain[hour_idx] if hour_idx < len(rain) and rain[hour_idx] is not None else 0.0 + showers_current = showers[hour_idx] if hour_idx < len(showers) and showers[hour_idx] is not None else 0.0 + code_current = weathercode[hour_idx] if hour_idx < len(weathercode) and weathercode[hour_idx] is not None else None + + # Usa temperatura suolo se disponibile (più accurata per gelicidio/ghiaccio), altrimenti temperatura aria + temp_for_ice = soil_temp_current if soil_temp_current is not None else temp_current + + # Verifica se c'è precipitazione in atto o prevista + has_precipitation = (rain_current > 0.1) or (showers_current > 0.1) + is_rain_code = code_current is not None and code_current in [61, 63, 65, 66, 67, 80, 81, 82] + + # Condizione 1: Temperatura scesa almeno a 0°C nelle 24h precedenti + if min_temp_24h is None or min_temp_24h > 0: + return 0, "" + + # Condizione 2: Precipitazioni presenti (nelle 24h precedenti o attuali) con temperature sotto zero + has_precip_with_freeze = False + if has_precipitation and temp_current is not None and temp_current <= 0: + has_precip_with_freeze = True + elif total_rain_24h > 0.5 and min_temp_24h <= 0: + has_precip_with_freeze = True + elif is_rain_code and temp_current is not None and temp_current <= 0: + has_precip_with_freeze = True + + # Condizione 3: Verifica risalite significative (scioglimento) + # Se la temperatura media nelle prossime 6h è > 3°C, probabilmente il ghiaccio si scioglie + is_melting = False + if avg_temp_next_6h is not None and avg_temp_next_6h > 3.0: + is_melting = True + if current_temp is not None and current_temp > 3.0: + is_melting = True + + # Se sta sciogliendo, riduci il rischio + if is_melting: + return 0, "" + + # Valuta livello di rischio basato su condizioni + # GELICIDIO (3): Precipitazione (pioggia/temporali) in atto/futura con T<0°C (suolo o aria) + # Il gelicidio si forma quando la pioggia cade su una superficie gelata e congela immediatamente + # Usa temperatura suolo se disponibile (più accurata), altrimenti temperatura aria + temp_threshold = temp_for_ice if temp_for_ice is not None else temp_current + + if has_precipitation and temp_threshold is not None and temp_threshold <= 0: + precip_type = "" + precip_amount = 0.0 + if is_rain_code: + precip_type = "pioggia" + precip_amount = rain_current + showers_current + elif rain_current > 0.1: + precip_type = "pioggia" + precip_amount = rain_current + elif showers_current > 0.1: + precip_type = "rovesci/temporali" + precip_amount = showers_current + + if precip_type: + temp_display = temp_for_ice if temp_for_ice is not None else temp_current + temp_label = "T_suolo" if temp_for_ice is not None else "T_aria" + return 3, f"🔴🔴 Gelicidio previsto ({temp_label}: {temp_display:.1f}°C, {precip_type}: {precip_amount:.1f}mm/h)" + + # GHIACCIO (2): Temperature sotto zero per molte ore con precipitazioni recenti O persistenza ghiaccio + # Black ice o ghiaccio persistente da precipitazioni precedenti + if hours_below_zero >= 6 and (total_rain_12h > 0.5 or has_precipitation): + return 2, f"🔴 Ghiaccio persistente (Tmin: {min_temp_24h:.1f}°C, {hours_below_zero}h <0°C)" + elif hours_below_2c >= 6 and total_rain_24h > 0.5: + # C'è stata pioggia con temperature basse, possibile black ice + return 2, f"🔴 Ghiaccio possibile (Tmin: {min_temp_24h:.1f}°C, {hours_below_2c}h <2°C, pioggia: {total_rain_24h:.1f}mm)" + elif temp_threshold is not None and temp_threshold < 0 and total_rain_24h > 0.5: + # Temperatura attuale sotto zero e c'è stata pioggia nelle 24h, possibile black ice + temp_display = temp_threshold + temp_label = "T_suolo" if temp_for_ice is not None else "T_aria" + return 2, f"🔴 Ghiaccio possibile ({temp_label}: {temp_display:.1f}°C, pioggia recente: {total_rain_24h:.1f}mm)" + + # BRINA (1): Temperature basse ma condizioni meno severe + # Suolo gelato o temperature vicine allo zero senza precipitazioni significative + if min_temp_24h <= 0 and hours_below_2c >= 3: + return 1, f"🟡 Brina possibile (Tmin: {min_temp_24h:.1f}°C, {hours_below_2c}h <2°C)" + elif temp_threshold is not None and temp_threshold <= 1.0 and temp_threshold >= -2.0 and total_rain_24h < 0.5: + # Temperature vicine allo zero senza precipitazioni significative = brina + temp_display = temp_threshold + temp_label = "T_suolo" if temp_for_ice is not None else "T_aria" + return 1, f"🟡 Brina possibile ({temp_label}: {temp_display:.1f}°C)" + + return 0, "" + + +def analyze_weather_risks(weather_data: Dict, model_slug: str, hours_ahead: int = 24, past_24h_info: Optional[Dict] = None) -> List[Dict]: + """ + Analizza tutti i rischi meteo per le prossime ore. + + Returns: + Lista di dict con rischi per ogni ora: { + 'timestamp': str, + 'risks': List[Dict], # Lista rischi con tipo, livello, descrizione + 'max_risk_level': int # 0-4 (0=nessuno, 1=basso, 2=medio, 3=alto, 4=molto alto) + } + """ + if not weather_data or not weather_data.get("hourly"): + return [] + + hourly = weather_data["hourly"] + times = hourly.get("time", []) + temps = hourly.get("temperature_2m", []) + precip = hourly.get("precipitation", []) + rain = hourly.get("rain", []) + showers = hourly.get("showers", []) + snowfall = hourly.get("snowfall", []) + weathercode = hourly.get("weathercode", []) + visibility = hourly.get("visibility", []) + wind_speed = hourly.get("wind_speed_10m", []) + wind_gusts = hourly.get("wind_gusts_10m", []) + + # Prova a ottenere CAPE se disponibile (AROME o ICON) + cape = hourly.get("cape", []) + + results = [] + # Usa timezone-aware datetime per il confronto + now = datetime.datetime.now(datetime.timezone.utc) + + # Analizza condizioni 24h precedenti se non fornite + if past_24h_info is None: + past_24h_info = analyze_past_24h_conditions(weather_data) + + for i in range(min(hours_ahead, len(times))): + if i >= len(times): + break + + try: + timestamp_str = times[i] + # Assicurati che il timestamp sia timezone-aware + try: + if 'Z' in timestamp_str: + timestamp = datetime.datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) + elif '+' in timestamp_str or timestamp_str.count('-') > 2: + # Formato con timezone offset + timestamp = datetime.datetime.fromisoformat(timestamp_str) + else: + # Timezone-naive, aggiungi UTC + timestamp = datetime.datetime.fromisoformat(timestamp_str) + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=datetime.timezone.utc) + except (ValueError, AttributeError): + # Fallback: prova parsing semplice e aggiungi UTC + timestamp = datetime.datetime.fromisoformat(timestamp_str) + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=datetime.timezone.utc) + + # Assicurati che entrambi siano timezone-aware per il confronto + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=datetime.timezone.utc) + + # Salta ore passate + if timestamp < now: + continue + + risks = [] + max_risk_level = 0 + + # 1. NEVE (controlla prima la neve, è più importante) + temp = temps[i] if i < len(temps) and temps[i] is not None else None + snow = snowfall[i] if i < len(snowfall) and snowfall[i] is not None else 0.0 + code = weathercode[i] if i < len(weathercode) and weathercode[i] is not None else None + + # Codici WMO per neve: 71, 73, 75, 77, 85, 86 + is_snow_weathercode = code in [71, 73, 75, 77, 85, 86] if code is not None else False + + # Debug logging per neve + if snow > 0 or is_snow_weathercode: + LOGGER.debug(f"Neve rilevata: snowfall={snow:.2f} cm/h, weathercode={code}, is_snow_code={is_snow_weathercode}") + + if snow > THRESHOLDS["snowfall_cm_h"] or is_snow_weathercode: + # C'è neve prevista o in atto - Livello 4 (azzurro/blu) + snow_level = 4 + snow_desc = f"Neve: {snow:.1f} cm/h" if snow > 0 else f"Neve prevista (codice: {code})" + risks.append({ + "type": "neve", + "level": snow_level, + "description": snow_desc, + "value": snow + }) + max_risk_level = max(max_risk_level, snow_level) + LOGGER.info(f"Rischio neve aggiunto: {snow_desc}, livello {snow_level}") + elif temp is not None and temp < THRESHOLDS["ice_temp_air"]: + # Valuta rischio ghiaccio usando analisi temporale evolutiva + ice_level, ice_desc = evaluate_ice_risk_temporal(weather_data, i, past_24h_info) + + if ice_level > 0: + # Determina tipo di rischio in base al livello e descrizione + risk_type = "ghiaccio" # Default + if ice_level == 3 and ("gelicidio" in ice_desc.lower() or "fzra" in ice_desc.lower()): + risk_type = "gelicidio" + elif ice_level == 1 or "brina" in ice_desc.lower(): + risk_type = "brina" + elif ice_level == 2: + risk_type = "ghiaccio" + + # Rischio rilevato tramite analisi temporale + risks.append({ + "type": risk_type, + "level": ice_level, + "description": ice_desc, + "value": temp + }) + max_risk_level = max(max_risk_level, ice_level) + elif temp < 2.0: + # Fallback: rischio brina basato solo su temperatura attuale + risks.append({ + "type": "brina", + "level": 1, + "description": f"🟡 Brina possibile (T: {temp:.1f}°C)", + "value": temp + }) + max_risk_level = max(max_risk_level, 1) + + # 2. PIOGGIA + rain_val = rain[i] if i < len(rain) and rain[i] is not None else 0.0 + precip_val = precip[i] if i < len(precip) and precip[i] is not None else 0.0 + code = weathercode[i] if i < len(weathercode) and weathercode[i] is not None else None + + if rain_val >= THRESHOLDS["rain_very_heavy_mm_h"]: + risks.append({ + "type": "pioggia_intensa", + "level": 4, + "description": f"Pioggia molto intensa: {rain_val:.1f} mm/h", + "value": rain_val + }) + max_risk_level = max(max_risk_level, 4) + elif rain_val >= THRESHOLDS["rain_heavy_mm_h"]: + risks.append({ + "type": "pioggia_forte", + "level": 3, + "description": f"Pioggia forte: {rain_val:.1f} mm/h", + "value": rain_val + }) + max_risk_level = max(max_risk_level, 3) + elif rain_val >= THRESHOLDS["rain_moderate_mm_h"]: + risks.append({ + "type": "pioggia_moderata", + "level": 2, + "description": f"Pioggia moderata: {rain_val:.1f} mm/h", + "value": rain_val + }) + max_risk_level = max(max_risk_level, 2) + elif rain_val >= THRESHOLDS["rain_light_mm_h"]: + risks.append({ + "type": "pioggia_leggera", + "level": 1, + "description": f"Pioggia leggera: {rain_val:.1f} mm/h", + "value": rain_val + }) + max_risk_level = max(max_risk_level, 1) + + # 3. ROVESCI + showers_val = showers[i] if i < len(showers) and showers[i] is not None else 0.0 + if showers_val > 0: + if code in [82, 89, 90, 96, 99]: # Rovesci violenti o con grandine + risks.append({ + "type": "rovesci_violenti", + "level": 4, + "description": f"Rovesci violenti: {showers_val:.1f} mm/h", + "value": showers_val + }) + max_risk_level = max(max_risk_level, 4) + elif showers_val >= THRESHOLDS["rain_heavy_mm_h"]: + risks.append({ + "type": "rovesci_forti", + "level": 3, + "description": f"Rovesci forti: {showers_val:.1f} mm/h", + "value": showers_val + }) + max_risk_level = max(max_risk_level, 3) + else: + risks.append({ + "type": "rovesci", + "level": 1, + "description": f"Rovesci: {showers_val:.1f} mm/h", + "value": showers_val + }) + max_risk_level = max(max_risk_level, 1) + + # 4. GRANDINE + if code in [89, 90, 96, 99]: + risks.append({ + "type": "grandine", + "level": 4, + "description": "Grandine", + "value": 1.0 + }) + max_risk_level = max(max_risk_level, 4) + + # 5. TEMPORALI + if code in [95, 96, 99]: + cape_val = cape[i] if i < len(cape) and cape[i] is not None else 0.0 + if cape_val >= THRESHOLDS["cape_severe"]: + risks.append({ + "type": "temporale_severo", + "level": 4, + "description": f"Temporale severo (CAPE: {cape_val:.0f} J/kg)", + "value": cape_val + }) + max_risk_level = max(max_risk_level, 4) + elif cape_val >= THRESHOLDS["cape_lightning"]: + risks.append({ + "type": "temporale", + "level": 3, + "description": f"Temporale (CAPE: {cape_val:.0f} J/kg)", + "value": cape_val + }) + max_risk_level = max(max_risk_level, 3) + else: + risks.append({ + "type": "temporale", + "level": 2, + "description": "Temporale", + "value": 1.0 + }) + max_risk_level = max(max_risk_level, 2) + + # 6. VENTO FORTE + wind_gust = wind_gusts[i] if i < len(wind_gusts) and wind_gusts[i] is not None else 0.0 + if wind_gust >= THRESHOLDS["wind_very_strong_kmh"]: + risks.append({ + "type": "vento_molto_forte", + "level": 4, + "description": f"Vento molto forte: {wind_gust:.0f} km/h", + "value": wind_gust + }) + max_risk_level = max(max_risk_level, 4) + elif wind_gust >= THRESHOLDS["wind_strong_kmh"]: + risks.append({ + "type": "vento_forte", + "level": 2, + "description": f"Vento forte: {wind_gust:.0f} km/h", + "value": wind_gust + }) + max_risk_level = max(max_risk_level, 2) + + # 7. NEBBIA + vis = visibility[i] if i < len(visibility) and visibility[i] is not None else None + if vis is not None and vis < THRESHOLDS["fog_visibility_m"]: + risks.append({ + "type": "nebbia", + "level": 3 if vis < 50 else 2, + "description": f"Nebbia (visibilità: {vis:.0f} m)", + "value": vis + }) + max_risk_level = max(max_risk_level, 3 if vis < 50 else 2) + elif code in [45, 48]: + risks.append({ + "type": "nebbia", + "level": 2, + "description": "Nebbia", + "value": 1.0 + }) + max_risk_level = max(max_risk_level, 2) + + results.append({ + "timestamp": timestamp_str, + "risks": risks, + "max_risk_level": max_risk_level + }) + + except Exception as e: + LOGGER.error(f"Errore analisi ora {i}: {e}", exc_info=True) + continue + + return results + + +# ============================================================================= +# ANALISI PERCORSO +# ============================================================================= + +def analyze_route_weather_risks(city1: str, city2: str, model_slug: Optional[str] = None) -> Optional[pd.DataFrame]: + """ + Analizza tutti i rischi meteo lungo un percorso stradale. + + Returns: + DataFrame con analisi per ogni punto del percorso + """ + if not PANDAS_AVAILABLE: + return None + + # Ottieni coordinate + coord1 = get_coordinates_from_city(city1) + coord2 = get_coordinates_from_city(city2) + + if not coord1 or not coord2: + return None + + lat1, lon1, name1 = coord1 + lat2, lon2, name2 = coord2 + + # Determina modello + if model_slug is None: + mid_lat = (lat1 + lat2) / 2 + mid_lon = (lon1 + lon2) / 2 + model_slug = get_best_model_for_location(mid_lat, mid_lon) + + # Calcola punti lungo percorso + route_points = calculate_route_points(lat1, lon1, lat2, lon2, num_points=8) + + all_results = [] + + for i, (lat, lon) in enumerate(route_points): + # Determina nome località PRIMA di analizzare + if i == 0: + point_name = name1 + elif i == len(route_points) - 1: + point_name = name2 + else: + if i > 1: + time.sleep(1.1) # Rate limiting Nominatim + point_name = get_location_name_from_coords(lat, lon) or f"Punto {i+1}" + + weather_data = get_weather_data(lat, lon, model_slug) + if not weather_data: + # Aggiungi comunque una riga per indicare che il punto è stato analizzato + all_results.append({ + 'point_index': i, + 'point_lat': lat, + 'point_lon': lon, + 'timestamp': datetime.datetime.now(datetime.timezone.utc), + 'risk_type': 'dati_non_disponibili', + 'risk_level': 0, + 'risk_description': 'Dati meteo non disponibili', + 'risk_value': 0.0, + 'max_risk_level': 0, + 'point_name': point_name + }) + continue + + # Analizza condizioni 24h precedenti + past_24h = analyze_past_24h_conditions(weather_data) + + # Analizza rischi (passa anche past_24h per analisi temporale evolutiva) + risk_analysis = analyze_weather_risks(weather_data, model_slug, hours_ahead=24, past_24h_info=past_24h) + + if not risk_analysis: + # Se non ci sono rischi, aggiungi comunque una riga per il punto + all_results.append({ + 'point_index': i, + 'point_lat': lat, + 'point_lon': lon, + 'timestamp': datetime.datetime.now(datetime.timezone.utc), + 'risk_type': 'nessuno', + 'risk_level': 0, + 'risk_description': 'Nessun rischio', + 'risk_value': 0.0, + 'max_risk_level': 0, + 'point_name': point_name, + 'past_24h': past_24h # Aggiungi analisi 24h precedenti anche se nessun rischio + }) + continue + + # Converti in DataFrame + for hour_data in risk_analysis: + timestamp_str = hour_data["timestamp"] + # Assicurati che il timestamp sia timezone-aware + try: + if 'Z' in timestamp_str: + timestamp = datetime.datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) + elif '+' in timestamp_str or timestamp_str.count('-') > 2: + timestamp = datetime.datetime.fromisoformat(timestamp_str) + else: + timestamp = datetime.datetime.fromisoformat(timestamp_str) + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=datetime.timezone.utc) + except (ValueError, AttributeError): + timestamp = datetime.datetime.fromisoformat(timestamp_str) + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=datetime.timezone.utc) + + # Assicurati che sia timezone-aware + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=datetime.timezone.utc) + + # Crea riga per ogni rischio o una riga con rischio massimo + if hour_data["risks"]: + for risk in hour_data["risks"]: + all_results.append({ + 'point_index': i, + 'point_lat': lat, + 'point_lon': lon, + 'timestamp': timestamp, + 'risk_type': risk["type"], + 'risk_level': risk["level"], + 'risk_description': risk["description"], + 'risk_value': risk.get("value", 0.0), + 'max_risk_level': hour_data["max_risk_level"], + 'point_name': point_name, + 'past_24h': past_24h + }) + else: + all_results.append({ + 'point_index': i, + 'point_lat': lat, + 'point_lon': lon, + 'timestamp': timestamp, + 'risk_type': 'nessuno', + 'risk_level': 0, + 'risk_description': 'Nessun rischio', + 'risk_value': 0.0, + 'max_risk_level': 0, + 'point_name': point_name, + 'past_24h': past_24h + }) + + if not all_results: + return None + + df = pd.DataFrame(all_results) + return df + + +# ============================================================================= +# FORMATTAZIONE REPORT +# ============================================================================= + +def format_route_weather_report(df: pd.DataFrame, city1: str, city2: str) -> str: + """Formatta report compatto dei rischi meteo lungo percorso.""" + if df.empty: + return "❌ Nessun dato disponibile per il percorso." + + # Raggruppa per punto e trova rischio massimo + analisi 24h + # Usa funzione custom per past_24h per assicurarsi che venga preservato correttamente + def first_dict(series): + """Prende il primo valore non-nullo, utile per dict.""" + for val in series: + if val is not None and (isinstance(val, dict) or (isinstance(val, str) and val != '')): + return val + return {} + + max_risk_per_point = df.groupby('point_index').agg({ + 'max_risk_level': 'max', + 'point_name': 'first', + 'past_24h': first_dict # Usa funzione custom per preservare dict + }).sort_values('point_index') + + # Rimuovi duplicati per nome (punti con stesso nome ma indici diversi) + # Considera anche neve/ghiaccio persistente nella scelta + seen_names = {} + unique_indices = [] + for idx, row in max_risk_per_point.iterrows(): + point_name = row['point_name'] + # Normalizza nome (rimuovi suffissi tra parentesi) + name_key = point_name.split('(')[0].strip() + past_24h = row.get('past_24h', {}) if isinstance(row.get('past_24h'), dict) else {} + has_snow_ice = past_24h.get('snow_present') or past_24h.get('ice_persistence_likely') + + if name_key not in seen_names: + seen_names[name_key] = idx + unique_indices.append(idx) + else: + # Se duplicato, mantieni quello con rischio maggiore O con neve/ghiaccio + existing_idx = seen_names[name_key] + existing_row = max_risk_per_point.loc[existing_idx] + existing_past_24h = existing_row.get('past_24h', {}) if isinstance(existing_row.get('past_24h'), dict) else {} + existing_has_snow_ice = existing_past_24h.get('snow_present') or existing_past_24h.get('ice_persistence_likely') + + # Priorità: rischio maggiore, oppure neve/ghiaccio se rischio uguale + if row['max_risk_level'] > existing_row['max_risk_level']: + unique_indices.remove(existing_idx) + seen_names[name_key] = idx + unique_indices.append(idx) + elif row['max_risk_level'] == existing_row['max_risk_level'] and has_snow_ice and not existing_has_snow_ice: + # Stesso rischio, ma questo ha neve/ghiaccio + unique_indices.remove(existing_idx) + seen_names[name_key] = idx + unique_indices.append(idx) + + # Filtra solo punti unici + max_risk_per_point = max_risk_per_point.loc[unique_indices] + + # Calcola effective_risk_level per ogni punto UNICO (considerando persistenza) + effective_risk_levels_dict = {} + for idx, row in max_risk_per_point.iterrows(): + level = int(row['max_risk_level']) + past_24h = row.get('past_24h', {}) if isinstance(row.get('past_24h'), dict) else {} + + # Se livello è 0, verifica persistenza per assegnare livello appropriato + if level == 0 and past_24h: + if past_24h.get('snow_present'): + level = 4 # Neve presente + elif past_24h.get('ice_persistence_likely'): + # Se ice_persistence_likely è True, significa che c'è ghiaccio persistente + # (calcolato in analyze_past_24h_conditions basandosi su suolo gelato, + # precipitazioni con temperature basse, o neve presente) + # Quindi deve essere classificato come ghiaccio (livello 2), non brina + level = 2 # Ghiaccio persistente + + effective_risk_levels_dict[idx] = level + + # Aggiungi effective_risk_level al DataFrame + max_risk_per_point['effective_risk_level'] = max_risk_per_point.index.map(effective_risk_levels_dict) + + # Trova rischi unici per ogni punto (raggruppa per tipo, mantieni solo il più grave) + risks_per_point = {} + # Prima aggiungi rischi futuri (max_risk_level > 0) + for idx, row in df[df['max_risk_level'] > 0].iterrows(): + point_idx = row['point_index'] + if point_idx not in risks_per_point: + risks_per_point[point_idx] = {} + + risk_type = row['risk_type'] + risk_level = row['risk_level'] + risk_desc = row['risk_description'] + + # Raggruppa per tipo di rischio, mantieni solo quello con livello più alto + if risk_type not in risks_per_point[point_idx] or risks_per_point[point_idx][risk_type]['level'] < risk_level: + risks_per_point[point_idx][risk_type] = { + 'type': risk_type, + 'desc': risk_desc, + 'level': risk_level + } + + # Poi aggiungi punti con persistenza ma senza rischi futuri (max_risk_level == 0 ma effective_risk > 0) + for idx, row in max_risk_per_point.iterrows(): + effective_risk = row.get('effective_risk_level', 0) + max_risk = int(row['max_risk_level']) + + # Se ha persistenza ma non rischi futuri, aggiungi rischio basato su persistenza + if effective_risk > 0 and max_risk == 0: + if idx not in risks_per_point: + risks_per_point[idx] = {} + + past_24h = row.get('past_24h', {}) if isinstance(row.get('past_24h'), dict) else {} + + # Determina tipo di rischio basandosi su effective_risk_level + if effective_risk >= 4: + risk_type = 'neve' + risk_desc = "Neve presente" + elif effective_risk == 2: + risk_type = 'ghiaccio' + # Determina descrizione basandosi su condizioni + min_temp = past_24h.get('min_temp_2m') + hours_below_2c = past_24h.get('hours_below_2c', 0) + if min_temp is not None: + risk_desc = f"Ghiaccio persistente (Tmin: {min_temp:.1f}°C, {hours_below_2c}h <2°C)" + else: + risk_desc = "Ghiaccio persistente" + elif effective_risk == 1: + risk_type = 'brina' + min_temp = past_24h.get('min_temp_2m') + if min_temp is not None: + risk_desc = f"Brina possibile (Tmin: {min_temp:.1f}°C)" + else: + risk_desc = "Brina possibile" + else: + continue # Skip se non abbiamo un tipo valido + + # Aggiungi al dict rischi (usa idx come chiave, non point_idx) + risks_per_point[idx][risk_type] = { + 'type': risk_type, + 'desc': risk_desc, + 'level': effective_risk + } + + # Verifica se la chiave Google Maps è disponibile + api_key_available = get_google_maps_api_key() is not None + + # Costruisci messaggio + msg = f"🛣️ **Rischi Meteo Stradali**\n" + msg += f"📍 {city1} → {city2}\n" + if not api_key_available: + msg += f"⚠️ Percorso in linea d'aria (configura GOOGLE_MAPS_API_KEY per percorso stradale reale)\n" + msg += "\n" + + points_with_risk = [] + LOGGER.debug(f"Analizzando {len(max_risk_per_point)} punti per report") + for idx, row in max_risk_per_point.iterrows(): + max_risk = row['max_risk_level'] + effective_risk = row.get('effective_risk_level', max_risk) # Usa effective_risk_level se disponibile + point_name = row['point_name'] + past_24h = row.get('past_24h', {}) if isinstance(row.get('past_24h'), dict) else {} + + LOGGER.debug(f"Punto {point_name}: max_risk={max_risk}, effective_risk={effective_risk}, snow_present={past_24h.get('snow_present')}, ice_persistent={past_24h.get('ice_persistence_likely')}") + + # Mostra punto se ha rischio futuro (max_risk > 0) OPPURE persistenza (effective_risk > 0) + if effective_risk > 0: + risks = risks_per_point.get(idx, []) + + # Emoji basati su effective_risk_level (allineati con check_ghiaccio.py) + # Neve: ❄️, Gelicidio: 🔴🔴, Ghiaccio: 🔴, Brina: 🟡 + risk_emoji = "⚪" # Default + if effective_risk >= 4: + risk_emoji = "❄️" # Neve (usiamo ❄️ invece di ⚪ per maggiore chiarezza) + elif effective_risk == 3: + # Verifica se è gelicidio + risk_types_str = ' '.join([r.get('type', '') for r in (list(risks.values()) if isinstance(risks, dict) else risks)]) + if 'gelicidio' in risk_types_str.lower() or 'fzra' in risk_types_str.lower(): + risk_emoji = "🔴🔴" # Gelicidio + else: + risk_emoji = "🔴" # Ghiaccio + elif effective_risk == 2: + # Ghiaccio (livello 2) + risk_emoji = "🔴" # Ghiaccio + elif effective_risk == 1: + risk_emoji = "🟡" # Brina + + # Converti dict in lista e ordina per livello (più grave prima) + risk_list = list(risks.values()) if isinstance(risks, dict) else risks + risk_list.sort(key=lambda x: x.get('level', 0), reverse=True) + + # Raggruppa rischi per tipo e crea descrizioni strutturate + risk_by_type = {} + for risk in risk_list: + risk_type = risk.get('type', '') + risk_level = risk.get('level', 0) + risk_desc = risk.get('desc', '') + + # Raggruppa per tipo, mantieni il più grave + if risk_type not in risk_by_type or risk_by_type[risk_type]['level'] < risk_level: + risk_by_type[risk_type] = { + 'desc': risk_desc, + 'level': risk_level + } + + # Crea descrizioni ordinate per tipo (neve prima, poi gelicidio, ghiaccio, brina, poi altri) + type_order = ['neve', 'gelicidio', 'ghiaccio', 'brina', 'pioggia_intensa', 'pioggia_forte', 'rovesci_violenti', + 'grandine', 'temporale_severo', 'temporale', 'vento_molto_forte', 'nebbia'] + risk_descriptions = [] + + # Prima aggiungi rischi ordinati + for risk_type in type_order: + if risk_type in risk_by_type: + risk_info = risk_by_type[risk_type] + risk_desc = risk_info['desc'] + + # Semplifica e formatta descrizioni in base al tipo + if risk_type == 'neve': + risk_descriptions.append(f"❄️ {risk_desc}") + elif risk_type == 'gelicidio': + # Estrai temperatura se presente + import re + temp_match = re.search(r'T: ([\d\.-]+)°C', risk_desc) + if temp_match: + risk_descriptions.append(f"🔴🔴 Gelicidio (T: {temp_match.group(1)}°C)") + else: + risk_descriptions.append("🔴🔴 Gelicidio") + elif risk_type == 'ghiaccio': + import re + temp_match = re.search(r'T: ([\d\.-]+)°C|Tmin: ([\d\.-]+)°C', risk_desc) + if temp_match: + temp_val = temp_match.group(1) or temp_match.group(2) + risk_descriptions.append(f"🧊 Ghiaccio (T: {temp_val}°C)") + else: + risk_descriptions.append("🧊 Ghiaccio") + elif risk_type == 'brina': + import re + temp_match = re.search(r'T: ([\d\.-]+)°C|Tmin: ([\d\.-]+)°C', risk_desc) + if temp_match: + temp_val = temp_match.group(1) or temp_match.group(2) + risk_descriptions.append(f"🟡 Brina (T: {temp_val}°C)") + else: + risk_descriptions.append("🟡 Brina") + else: + risk_descriptions.append(risk_desc) + + # Poi aggiungi altri rischi non in type_order + for risk_type, risk_info in risk_by_type.items(): + if risk_type not in type_order: + risk_descriptions.append(risk_info['desc']) + + # Costruisci messaggio punto dettagliato per situational awareness + point_msg = f"{risk_emoji} **{point_name}**\n" + + # Sezione 1: Condizioni attuali e ultime 12h + current_info = [] + if past_24h: + # Temperatura attuale + if past_24h.get('current_temp_2m') is not None: + current_info.append(f"🌡️ T: {past_24h['current_temp_2m']:.1f}°C") + + # Precipitazioni ultime 12h + if past_24h.get('total_snowfall_12h_cm', 0) > 0.5: + current_info.append(f"❄️ {past_24h['total_snowfall_12h_cm']:.1f}cm/12h") + elif past_24h.get('total_rain_12h_mm', 0) > 1: + current_info.append(f"🌧️ {past_24h['total_rain_12h_mm']:.1f}mm/12h") + + # Temperatura minima 24h + if past_24h.get('min_temp_2m') is not None: + t_min = past_24h['min_temp_2m'] + current_info.append(f"📉 Tmin: {t_min:.1f}°C") + + if current_info: + point_msg += f" • {' | '.join(current_info)}\n" + + # Sezione 2: Previsioni prossime 6h + forecast_info = [] + if past_24h: + # Temperature previste + if past_24h.get('min_temp_next_6h') is not None and past_24h.get('max_temp_next_6h') is not None: + t_min_6h = past_24h['min_temp_next_6h'] + t_max_6h = past_24h['max_temp_next_6h'] + if t_min_6h == t_max_6h: + forecast_info.append(f"📊 6h: {t_min_6h:.1f}°C") + else: + forecast_info.append(f"📊 6h: {t_min_6h:.1f}→{t_max_6h:.1f}°C") + + # Neve prevista + if past_24h.get('snow_next_6h_cm', 0) > 0.1: + forecast_info.append(f"❄️ +{past_24h['snow_next_6h_cm']:.1f}cm") + + # Rischi futuri (prossime 24h) + future_risks = [] + if risk_descriptions: + for desc in risk_descriptions[:4]: # Max 4 rischi + if "❄️" in desc: + future_risks.append("❄️ Neve") + elif "🧊" in desc: + import re + temp_match = re.search(r'\(T: ([\d\.-]+)°C\)', desc) + if temp_match: + future_risks.append(f"🧊 Ghiaccio ({temp_match.group(1)}°C)") + else: + future_risks.append("🧊 Ghiaccio") + elif "🌧️" in desc or "Pioggia" in desc: + future_risks.append("🌧️ Pioggia") + elif "⛈️" in desc or "Temporale" in desc: + future_risks.append("⛈️ Temporale") + elif "💨" in desc or "Vento" in desc: + future_risks.append("💨 Vento") + elif "🌫️" in desc or "Nebbia" in desc: + future_risks.append("🌫️ Nebbia") + + if forecast_info or future_risks: + point_msg += f" • " + if forecast_info: + point_msg += f"{' | '.join(forecast_info)}" + if future_risks: + if forecast_info: + point_msg += " | " + point_msg += f"Rischi: {', '.join(future_risks[:3])}" + point_msg += "\n" + + # Sezione 3: Stato persistenza + persistence_info = [] + if past_24h: + if past_24h.get('snow_present'): + persistence_info.append("❄️ Neve presente") + if past_24h.get('ice_persistence_likely') and not past_24h.get('snow_present'): + persistence_info.append("🧊 Ghiaccio persistente") + if past_24h.get('hours_below_2c', 0) >= 6: + persistence_info.append(f"⏱️ {past_24h['hours_below_2c']}h <2°C") + + if persistence_info: + point_msg += f" • {' | '.join(persistence_info)}\n" + + points_with_risk.append(point_msg) + elif effective_risk > 0 and max_risk == 0: + # Mostra punti senza rischi futuri ma con persistenza (ghiaccio/brina/neve già formato) + # Determina emoji basandosi su effective_risk_level (allineati con check_ghiaccio.py) + if effective_risk >= 4: + risk_emoji = "❄️" # Neve + elif effective_risk == 2: + risk_emoji = "🔴" # Ghiaccio + elif effective_risk == 1: + risk_emoji = "🟡" # Brina + else: + risk_emoji = "⚪" # Default + + point_msg = f"{risk_emoji} **{point_name}**\n" + + # Condizioni attuali + current_info = [] + if past_24h.get('current_temp_2m') is not None: + current_info.append(f"🌡️ T: {past_24h['current_temp_2m']:.1f}°C") + if past_24h.get('total_snowfall_12h_cm', 0) > 0.5: + current_info.append(f"❄️ {past_24h['total_snowfall_12h_cm']:.1f}cm/12h") + elif past_24h.get('total_rain_12h_mm', 0) > 1: + current_info.append(f"🌧️ {past_24h['total_rain_12h_mm']:.1f}mm/12h") + if past_24h.get('min_temp_2m') is not None: + current_info.append(f"📉 Tmin: {past_24h['min_temp_2m']:.1f}°C") + + if current_info: + point_msg += f" • {' | '.join(current_info)}\n" + + # Previsioni 6h + forecast_info = [] + if past_24h.get('min_temp_next_6h') is not None and past_24h.get('max_temp_next_6h') is not None: + t_min_6h = past_24h['min_temp_next_6h'] + t_max_6h = past_24h['max_temp_next_6h'] + if t_min_6h == t_max_6h: + forecast_info.append(f"📊 6h: {t_min_6h:.1f}°C") + else: + forecast_info.append(f"📊 6h: {t_min_6h:.1f}→{t_max_6h:.1f}°C") + if past_24h.get('snow_next_6h_cm', 0) > 0.1: + forecast_info.append(f"❄️ +{past_24h['snow_next_6h_cm']:.1f}cm") + + if forecast_info: + point_msg += f" • {' | '.join(forecast_info)}\n" + + # Persistenza + persistence_info = [] + if past_24h.get('snow_present'): + persistence_info.append("❄️ Neve presente") + if past_24h.get('ice_persistence_likely') and not past_24h.get('snow_present'): + persistence_info.append("🧊 Ghiaccio persistente") + if past_24h.get('hours_below_2c', 0) >= 6: + persistence_info.append(f"⏱️ {past_24h['hours_below_2c']}h <2°C") + + if persistence_info: + point_msg += f" • {' | '.join(persistence_info)}\n" + + points_with_risk.append(point_msg) + LOGGER.debug(f"Aggiunto punto con neve/ghiaccio persistente: {point_name}") + + LOGGER.info(f"Totale punti con rischio/neve/ghiaccio: {len(points_with_risk)}") + if points_with_risk: + msg += "⚠️ **Punti a rischio:**\n" + msg += "\n".join(points_with_risk) + else: + msg += "✅ Nessun rischio significativo per le prossime 24h" + + # Riepilogo (usa effective_risk_level per conteggio corretto) + total_points = len(max_risk_per_point) + points_with_any_risk = sum(1 for r in effective_risk_levels_dict.values() if r > 0) + + # Conta per livello usando effective_risk_level + neve_count = sum(1 for r in effective_risk_levels_dict.values() if r >= 4) + gelicidio_count = sum(1 for r in effective_risk_levels_dict.values() if r == 3) + ghiaccio_count = sum(1 for r in effective_risk_levels_dict.values() if r == 2) + brina_count = sum(1 for r in effective_risk_levels_dict.values() if r == 1) + + if points_with_any_risk > 0: + msg += f"\n\n📊 **Riepilogo:**\n" + msg += f"• Punti: {points_with_any_risk}/{total_points} a rischio\n" + risk_parts = [] + if neve_count > 0: + risk_parts.append(f"⚪ Neve: {neve_count}") + if gelicidio_count > 0: + risk_parts.append(f"🔴🔴 Gelicidio: {gelicidio_count}") + if ghiaccio_count > 0: + risk_parts.append(f"🔴 Ghiaccio: {ghiaccio_count}") + if brina_count > 0: + risk_parts.append(f"🟡 Brina: {brina_count}") + if risk_parts: + msg += f"• {' | '.join(risk_parts)}\n" + + return msg + + +# ============================================================================= +# GENERAZIONE MAPPA +# ============================================================================= + +def generate_route_weather_map(df: pd.DataFrame, city1: str, city2: str, output_path: str) -> bool: + """Genera mappa con rischi meteo lungo percorso.""" + try: + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + import matplotlib.patches as mpatches + from matplotlib.lines import Line2D + except ImportError: + return False + + try: + import contextily as ctx + CONTEXTILY_AVAILABLE = True + except ImportError: + CONTEXTILY_AVAILABLE = False + + if df.empty: + return False + + # Raggruppa per punto + max_risk_per_point = df.groupby('point_index').agg({ + 'max_risk_level': 'max', + 'point_name': 'first', + 'point_lat': 'first', + 'point_lon': 'first', + 'past_24h': 'first', + 'risk_type': lambda x: ','.join([str(v) for v in x.unique() if pd.notna(v) and str(v) != '']) if len(x.unique()) > 0 else '' + }).sort_values('point_index') + + # Calcola effective_risk_level considerando anche persistenza + effective_risk_levels = [] + for idx, row in max_risk_per_point.iterrows(): + level = int(row['max_risk_level']) + risk_type_str = str(row.get('risk_type', '')) + past_24h_data = row.get('past_24h', {}) + + # Se livello è 0, verifica persistenza per assegnare livello appropriato + if level == 0 and isinstance(past_24h_data, dict): + if past_24h_data.get('snow_present'): + level = 4 # Neve presente + elif past_24h_data.get('ice_persistence_likely'): + # Se ice_persistence_likely è True, significa che c'è ghiaccio persistente + # (calcolato in analyze_past_24h_conditions basandosi su suolo gelato, + # precipitazioni con temperature basse, o neve presente) + # Quindi deve essere classificato come ghiaccio (livello 2), non brina + level = 2 # Ghiaccio persistente + + # Considera anche risk_type se presente + risk_type_lower = risk_type_str.lower() + if 'neve' in risk_type_lower: + level = max(level, 4) + elif 'gelicidio' in risk_type_lower or 'fzra' in risk_type_lower: + level = max(level, 3) + elif 'ghiaccio' in risk_type_lower and 'brina' not in risk_type_lower: + level = max(level, 2) + elif 'brina' in risk_type_lower: + level = max(level, 1) + + effective_risk_levels.append(level) + + max_risk_per_point['effective_risk_level'] = effective_risk_levels + + lats = max_risk_per_point['point_lat'].tolist() + lons = max_risk_per_point['point_lon'].tolist() + names = max_risk_per_point['point_name'].fillna("Punto").tolist() + risk_levels = max_risk_per_point['effective_risk_level'].astype(int).tolist() + risk_types = max_risk_per_point['risk_type'].fillna('').tolist() + past_24h_list = max_risk_per_point['past_24h'].tolist() + + # Calcola limiti mappa + lat_min, lat_max = min(lats), max(lats) + lon_min, lon_max = min(lons), max(lons) + lat_range = lat_max - lat_min + lon_range = lon_max - lon_min + lat_min -= lat_range * 0.1 + lat_max += lat_range * 0.1 + lon_min -= lon_range * 0.1 + lon_max += lon_range * 0.1 + + fig, ax = plt.subplots(figsize=(14, 10)) + fig.patch.set_facecolor('white') + + ax.set_xlim(lon_min, lon_max) + ax.set_ylim(lat_min, lat_max) + ax.set_aspect('equal', adjustable='box') + + if CONTEXTILY_AVAILABLE: + try: + ctx.add_basemap(ax, crs='EPSG:4326', source=ctx.providers.OpenStreetMap.Mapnik, + alpha=0.6, attribution_size=6) + except: + CONTEXTILY_AVAILABLE = False + + # Linea percorso + ax.plot(lons, lats, 'k--', linewidth=2, alpha=0.5, zorder=3) + + # Determina colori: allineati con check_ghiaccio.py + # Verde (0), Giallo (1=brina), Arancione (2=ghiaccio), Rosso scuro (3=gelicidio), Azzurro (4=neve) + colors = [] + edge_colors = [] + markers = [] + + for i, (level, risk_type_str, past_24h_data) in enumerate(zip(risk_levels, risk_types, past_24h_list)): + # level ora contiene già effective_risk_level (calcolato sopra considerando persistenza) + # Determina tipo esatto basandosi su livello e risk_type_str + risk_type_lower = risk_type_str.lower() + + # Determina colore e marker basato su livello (allineato con check_ghiaccio.py): + # - Neve: livello 4 (azzurro/blu) + # - Gelicidio: livello 3 (rosso scuro #8B0000) + # - Ghiaccio: livello 2 (arancione #FF8C00) + # - Brina: livello 1 (giallo #FFD700) + # - Nessun rischio: livello 0 (verde #32CD32) + + if level == 4 or 'neve' in risk_type_lower: + # Neve: azzurro/blu (livello 4) + colors.append('#87CEEB') # Sky blue per neve + edge_colors.append('#4682B4') # Steel blue per bordo + markers.append('*') # Asterisco per neve (come nella legenda) + elif level == 3 or 'gelicidio' in risk_type_lower or 'fzra' in risk_type_lower: + # Gelicidio: rosso scuro (livello 3) + colors.append('#8B0000') # Dark red + edge_colors.append('#FF0000') # Red per bordo + markers.append('D') # Diamante per gelicidio + elif level == 2 or ('ghiaccio' in risk_type_lower and 'brina' not in risk_type_lower): + # Ghiaccio: arancione (livello 2) + colors.append('#FF8C00') # Dark orange + edge_colors.append('#FF6600') # Orange per bordo + markers.append('D') # Diamante per ghiaccio + elif level == 1 or 'brina' in risk_type_lower: + # Brina: giallo (livello 1) + colors.append('#FFD700') # Gold + edge_colors.append('#FFA500') # Orange per bordo + markers.append('o') # Cerchio per brina + else: + # Nessun rischio: verde (livello 0) + colors.append('#32CD32') # Lime green + edge_colors.append('black') + markers.append('o') # Cerchio normale + + # Punti con colori e marker diversi + for lon, lat, color, edge_color, marker in zip(lons, lats, colors, edge_colors, markers): + ax.scatter([lon], [lat], c=[color], s=400, marker=marker, + edgecolors=edge_color, linewidths=2.5, alpha=0.85, zorder=5) + + # Partenza e arrivo + if len(lats) >= 2: + ax.scatter([lons[0]], [lats[0]], c='blue', s=600, marker='s', + edgecolors='white', linewidths=3, alpha=0.9, zorder=6) + ax.scatter([lons[-1]], [lats[-1]], c='red', s=600, marker='s', + edgecolors='white', linewidths=3, alpha=0.9, zorder=6) + + # Etichette + for lon, lat, name, risk_level in zip(lons, lats, names, risk_levels): + display_name = name[:20] + "..." if len(name) > 20 else name + ax.annotate(display_name, (lon, lat), xytext=(10, 10), textcoords='offset points', + fontsize=8, fontweight='bold', + bbox=dict(boxstyle='round,pad=0.4', facecolor='white', alpha=0.95, + edgecolor='black', linewidth=1.2), + zorder=7) + + # Legenda: allineata con check_ghiaccio.py (5 livelli: 0-4) + legend_elements = [ + mpatches.Patch(facecolor='#32CD32', label='Nessun rischio'), + mpatches.Patch(facecolor='#FFD700', label='Brina (1)'), + mpatches.Patch(facecolor='#FF8C00', label='Ghiaccio (2)'), + mpatches.Patch(facecolor='#8B0000', label='Gelicidio (3)'), + Line2D([0], [0], marker='*', color='w', markerfacecolor='#87CEEB', + markeredgecolor='#4682B4', markersize=14, markeredgewidth=2, label='* Neve'), + ] + + ax.legend(handles=legend_elements, loc='lower left', fontsize=9, + framealpha=0.95, edgecolor='black', fancybox=True, shadow=True) + + ax.set_xlabel('Longitudine (°E)', fontsize=11, fontweight='bold') + ax.set_ylabel('Latitudine (°N)', fontsize=11, fontweight='bold') + ax.set_title(f'RISCHI METEO STRADALI\n{city1} → {city2}', + fontsize=14, fontweight='bold', pad=20) + + if not CONTEXTILY_AVAILABLE: + ax.grid(True, alpha=0.3, linestyle='--', zorder=1) + + now = datetime.datetime.now() + # Conta punti con rischio usando effective_risk_level + points_with_risk = sum(1 for r in risk_levels if r > 0) + info_text = f"Aggiornamento: {now.strftime('%d/%m/%Y %H:%M')}\nPunti: {len(risk_levels)}\nA rischio: {points_with_risk}" + ax.text(0.02, 0.98, info_text, transform=ax.transAxes, + fontsize=9, verticalalignment='top', horizontalalignment='left', + bbox=dict(boxstyle='round,pad=0.5', facecolor='white', alpha=0.9, + edgecolor='gray', linewidth=1.5), + zorder=10) + + plt.tight_layout() + + try: + plt.savefig(output_path, dpi=150, bbox_inches='tight', facecolor='white') + plt.close(fig) + return True + except Exception as e: + LOGGER.error(f"Errore salvataggio mappa: {e}") + plt.close(fig) + return False diff --git a/services/telegram-bot/scheduler_viaggi.py b/services/telegram-bot/scheduler_viaggi.py new file mode 100644 index 0000000..4f47a25 --- /dev/null +++ b/services/telegram-bot/scheduler_viaggi.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Scheduler dinamico per viaggi attivi +- Lancia meteo.py alle 8:00 AM local time per ogni viaggio attivo +- Lancia previsione7.py alle 7:30 AM local time per ogni viaggio attivo +- Gestisce fusi orari diversi per ogni località +""" + +import os +import json +import subprocess +import datetime +from zoneinfo import ZoneInfo +from typing import Dict, List, Tuple + +# PERCORSI SCRIPT +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +METEO_SCRIPT = os.path.join(SCRIPT_DIR, "meteo.py") +METEO7_SCRIPT = os.path.join(SCRIPT_DIR, "previsione7.py") +VIAGGI_STATE_FILE = os.path.join(SCRIPT_DIR, "viaggi_attivi.json") + +def load_viaggi_state() -> Dict: + """Carica lo stato dei viaggi attivi da file JSON""" + if os.path.exists(VIAGGI_STATE_FILE): + try: + with open(VIAGGI_STATE_FILE, "r", encoding="utf-8") as f: + return json.load(f) or {} + except Exception as e: + print(f"Errore lettura viaggi state: {e}") + return {} + return {} + +def get_next_scheduled_time(target_hour: int, target_minute: int, timezone_str: str) -> Tuple[datetime.datetime, bool]: + """ + Calcola il prossimo orario schedulato in UTC per un target locale. + + Args: + target_hour: Ora target (0-23) + target_minute: Minuto target (0-59) + timezone_str: Timezone IANA (es: "Europe/Rome") + + Returns: + (datetime UTC, should_run_now): True se dovrebbe essere eseguito ora + """ + try: + tz = ZoneInfo(timezone_str) + now_utc = datetime.datetime.now(datetime.timezone.utc) + now_local = now_utc.astimezone(tz) + + # Crea datetime target per oggi + target_local = now_local.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0) + + # Se l'orario è già passato oggi, programma per domani + if now_local >= target_local: + target_local += datetime.timedelta(days=1) + + # Converti in UTC + target_utc = target_local.astimezone(datetime.timezone.utc) + + # Verifica se dovrebbe essere eseguito ora (entro 5 minuti) + time_diff = (target_utc - now_utc).total_seconds() + should_run_now = 0 <= time_diff <= 300 # Entro 5 minuti + + return target_utc, should_run_now + except Exception as e: + print(f"Errore calcolo orario per {timezone_str}: {e}") + return None, False + +def launch_meteo_viaggio(chat_id: str, viaggio: Dict, script_type: str = "meteo") -> None: + """Lancia meteo.py o previsione7.py per un viaggio attivo""" + lat = viaggio.get("lat") + lon = viaggio.get("lon") + location = viaggio.get("location") + name = viaggio.get("name") + timezone = viaggio.get("timezone", "Europe/Rome") + + if script_type == "meteo": + script = METEO_SCRIPT + args = ["--query", location, "--chat_id", chat_id, "--timezone", timezone] + elif script_type == "meteo7": + script = METEO7_SCRIPT + args = [location, "--chat_id", chat_id, "--timezone", timezone] + else: + return + + try: + subprocess.Popen(["python3", script] + args) + print(f"✅ Lanciato {script_type} per chat_id={chat_id}, località={name}, timezone={timezone}") + except Exception as e: + print(f"❌ Errore lancio {script_type} per chat_id={chat_id}: {e}") + +def check_and_launch_scheduled() -> None: + """Controlla e lancia gli script schedulati per tutti i viaggi attivi""" + viaggi = load_viaggi_state() + if not viaggi: + return + + now_utc = datetime.datetime.now(datetime.timezone.utc) + + for chat_id, viaggio in viaggi.items(): + timezone = viaggio.get("timezone", "Europe/Rome") + name = viaggio.get("name", "Unknown") + + # Controlla meteo.py (8:00 AM local time) + target_utc_meteo, should_run_meteo = get_next_scheduled_time(8, 0, timezone) + if should_run_meteo: + print(f"🕐 Eseguendo meteo.py per {name} (chat_id={chat_id}) alle 8:00 {timezone}") + launch_meteo_viaggio(chat_id, viaggio, "meteo") + + # Controlla previsione7.py (7:30 AM local time) + target_utc_meteo7, should_run_meteo7 = get_next_scheduled_time(7, 30, timezone) + if should_run_meteo7: + print(f"🕐 Eseguendo previsione7.py per {name} (chat_id={chat_id}) alle 7:30 {timezone}") + launch_meteo_viaggio(chat_id, viaggio, "meteo7") + +if __name__ == "__main__": + # Questo script dovrebbe essere eseguito periodicamente (es. ogni 5 minuti) da Portainer + # Controlla se ci sono viaggi attivi che devono essere eseguiti ora + check_and_launch_scheduled() diff --git a/services/telegram-bot/severe_weather.py b/services/telegram-bot/severe_weather.py index a8bbac5..40aed9b 100644 --- a/services/telegram-bot/severe_weather.py +++ b/services/telegram-bot/severe_weather.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import argparse import datetime import html import json @@ -15,10 +16,13 @@ import requests from dateutil import parser # ============================================================================= -# SEVERE WEATHER ALERT (next 24h) - Casa (LAT/LON) -# - Freezing rain/drizzle (WMO codes 56,57,66,67) -> priorità alta, basta 1 occorrenza +# SEVERE WEATHER ALERT (next 48h) - Casa (LAT/LON) # - Wind gusts persistence: >= soglia per almeno 2 ore consecutive # - Rain persistence: soglia (mm/3h) superata per almeno 2 ore (2 finestre 3h consecutive) +# - Convective storms (temporali severi): analisi combinata ICON Italia + AROME Seamless +# * Fulminazioni (CAPE > 800 J/kg + LPI > 0) +# * Downburst/Temporali violenti (CAPE > 1500 J/kg + Wind Gusts > 60 km/h) +# * Nubifragi (Precipitation > 20mm/h o somma 3h > 40mm) # # Telegram token: NOT in clear. # Read order: @@ -41,8 +45,8 @@ TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token") TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token" # ----------------- LOCATION ----------------- -LAT = 43.9356 -LON = 12.4296 +DEFAULT_LAT = 43.9356 +DEFAULT_LON = 12.4296 # ----------------- THRESHOLDS ----------------- # Vento (km/h) - soglie come da tuo set @@ -56,11 +60,16 @@ RAIN_3H_LIMIT = 25.0 # Persistenza minima richiesta (ore) PERSIST_HOURS = 2 # richiesta utente: >=2 ore -# Freezing rain/drizzle codes -FREEZING_CODES = {56, 57, 66, 67} - # ----------------- HORIZON ----------------- -HOURS_AHEAD = 24 +HOURS_AHEAD = 48 # Esteso a 48h per analisi temporali severi + +# ----------------- CONVECTIVE STORM THRESHOLDS ----------------- +CAPE_LIGHTNING_THRESHOLD = 800.0 # J/kg - Soglia per rischio fulminazioni +CAPE_SEVERE_THRESHOLD = 1500.0 # J/kg - Soglia per temporali violenti +WIND_GUST_DOWNBURST_THRESHOLD = 60.0 # km/h - Soglia vento per downburst +RAIN_INTENSE_THRESHOLD_H = 20.0 # mm/h - Soglia per nubifragio orario +RAIN_INTENSE_THRESHOLD_3H = 40.0 # mm/3h - Soglia per nubifragio su 3 ore +STORM_SCORE_THRESHOLD = 40.0 # Storm Severity Score minimo per allerta # ----------------- FILES ----------------- STATE_FILE = "/home/daniely/docker/telegram-bot/weather_state.json" @@ -69,14 +78,17 @@ LOG_FILE = os.path.join(BASE_DIR, "weather_alert.log") # ----------------- OPEN-METEO ----------------- OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" -TZ = "Europe/Rome" +TZ = "Europe/Berlin" TZINFO = ZoneInfo(TZ) HTTP_HEADERS = {"User-Agent": "rpi-severe-weather/2.0"} -# Force model: AROME France HD 1.5 km -MODEL_PRIMARY = "meteofrance_arome_france_hd" +# Force model: AROME Seamless (fornisce rain/snowfall/weathercode) +MODEL_PRIMARY = "meteofrance_seamless" # Fallback (stessa famiglia Meteo-France) per continuità operativa -MODEL_FALLBACK = "meteofrance_seamless" +MODEL_FALLBACK = "meteofrance_arome_france_hd" +# Modello per comparazione +MODEL_ICON_IT = "italia_meteo_arpae_icon_2i" +COMPARISON_THRESHOLD = 0.30 # 30% scostamento per comparazione # Se True, invia messaggio "rientrata" quando tutto torna sotto soglia (non è un errore) SEND_ALL_CLEAR = True @@ -162,19 +174,31 @@ def hhmm(dt: datetime.datetime) -> str: return dt.strftime("%H:%M") +def ddmmyy_hhmm(dt: datetime.datetime) -> str: + """Formatta datetime come 'dd/mm HH:MM' per includere data e ora.""" + return dt.strftime("%d/%m %H:%M") + + # ============================================================================= # TELEGRAM # ============================================================================= -def telegram_send_html(message_html: str) -> bool: +def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) -> bool: """ Never raises. Returns True if at least one chat_id succeeded. IMPORTANT: called only on REAL ALERTS (not on errors). + + Args: + message_html: Messaggio HTML da inviare + chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) """ token = load_bot_token() if not token: LOGGER.warning("Telegram token missing: message not sent.") return False + if chat_ids is None: + chat_ids = TELEGRAM_CHAT_IDS + url = f"https://api.telegram.org/bot{token}/sendMessage" base_payload = { "text": message_html, @@ -184,7 +208,7 @@ def telegram_send_html(message_html: str) -> bool: sent_ok = False with requests.Session() as s: - for chat_id in TELEGRAM_CHAT_IDS: + for chat_id in chat_ids: payload = dict(base_payload) payload["chat_id"] = chat_id try: @@ -210,7 +234,10 @@ def load_state() -> Dict: "wind_level": 0, "last_wind_peak": 0.0, "last_rain_3h": 0.0, - "freezing_active": False, + "convective_storm_active": False, + "last_storm_score": 0.0, + "last_alert_type": None, # Tipo di allerta: "VENTO", "PIOGGIA", "TEMPORALI", o lista combinata + "last_alert_time": None, # Timestamp ISO dell'ultima notifica } if os.path.exists(STATE_FILE): try: @@ -234,21 +261,60 @@ def save_state(state: Dict) -> None: # ============================================================================= # OPEN-METEO # ============================================================================= -def fetch_forecast(models_value: str) -> Optional[Dict]: +def fetch_forecast(models_value: str, lat: Optional[float] = None, lon: Optional[float] = None, timezone: Optional[str] = None) -> Optional[Dict]: + if lat is None: + lat = DEFAULT_LAT + if lon is None: + lon = DEFAULT_LON + + # Usa timezone personalizzata se fornita, altrimenti default + tz_to_use = timezone if timezone else TZ + + # Parametri base per tutti i modelli + hourly_params = "precipitation,wind_gusts_10m,weather_code" + + # Parametri specifici per modello + if models_value == MODEL_PRIMARY or models_value == MODEL_FALLBACK: + # AROME: aggiungi CAPE (Convective Available Potential Energy) e altri parametri convettivi + hourly_params += ",cape,convective_inhibition" + elif models_value == MODEL_ICON_IT: + # ICON Italia: prova a richiedere LPI (Lightning Potential Index) se disponibile + # Nota: il parametro esatto potrebbe variare, proviamo più varianti + # Se non disponibile, useremo CAPE come proxy + hourly_params += ",cape" # ICON potrebbe avere CAPE, usiamolo come fallback + params = { - "latitude": LAT, - "longitude": LON, - "hourly": "precipitation,wind_gusts_10m,weather_code", - "timezone": TZ, + "latitude": lat, + "longitude": lon, + "hourly": hourly_params, + "timezone": tz_to_use, "forecast_days": 2, "wind_speed_unit": "kmh", "precipitation_unit": "mm", "models": models_value, } + + # Aggiungi minutely_15 per AROME Seamless (dettaglio 15 minuti per inizio preciso eventi) + # Se fallisce o ha buchi, riprova senza minutely_15 + use_minutely = False + if models_value == MODEL_PRIMARY: + params["minutely_15"] = "precipitation,rain,snowfall,wind_speed_10m,wind_direction_10m,weather_code,temperature_2m" + use_minutely = True try: r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25) if r.status_code == 400: + # Se 400 e abbiamo minutely_15, riprova senza + if use_minutely and "minutely_15" in params: + LOGGER.warning("Open-Meteo 400 con minutely_15 (models=%s), riprovo senza minutely_15", models_value) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = requests.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass # Log reason if present; no Telegram on errors try: j = r.json() @@ -256,27 +322,623 @@ def fetch_forecast(models_value: str) -> Optional[Dict]: except Exception: LOGGER.error("Open-Meteo 400 (models=%s): %s", models_value, r.text[:500]) return None + elif r.status_code == 504: + # Gateway Timeout: se abbiamo minutely_15, riprova senza + if use_minutely and "minutely_15" in params: + LOGGER.warning("Open-Meteo 504 Gateway Timeout con minutely_15 (models=%s), riprovo senza minutely_15", models_value) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = requests.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + LOGGER.error("Open-Meteo 504 Gateway Timeout (models=%s)", models_value) + return None r.raise_for_status() - return r.json() + data = r.json() + + # Verifica se minutely_15 ha buchi (anche solo 1 None = fallback a hourly) + if use_minutely and "minutely_15" in params: + minutely = data.get("minutely_15", {}) or {} + minutely_times = minutely.get("time", []) or [] + minutely_precip = minutely.get("precipitation", []) or [] + minutely_rain = minutely.get("rain", []) or [] + + # Controlla se ci sono buchi (anche solo 1 None) + if minutely_times: + has_holes = False + # Controlla precipitation + if minutely_precip and any(v is None for v in minutely_precip): + has_holes = True + # Controlla rain + if minutely_rain and any(v is None for v in minutely_rain): + has_holes = True + + if has_holes: + LOGGER.warning("minutely_15 ha buchi (valori None rilevati, models=%s), riprovo senza minutely_15", models_value) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = requests.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + + return data + except requests.exceptions.Timeout: + # Timeout: se abbiamo minutely_15, riprova senza + if use_minutely and "minutely_15" in params: + LOGGER.warning("Open-Meteo Timeout con minutely_15 (models=%s), riprovo senza minutely_15", models_value) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = requests.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + LOGGER.exception("Open-Meteo request timeout (models=%s)", models_value) + return None except Exception as e: LOGGER.exception("Open-Meteo request error (models=%s): %s", models_value, e) return None -def get_forecast() -> Tuple[Optional[Dict], str]: +def get_forecast(lat: Optional[float] = None, lon: Optional[float] = None, timezone: Optional[str] = None) -> Tuple[Optional[Dict], str]: LOGGER.debug("Requesting Open-Meteo with models=%s", MODEL_PRIMARY) - data = fetch_forecast(MODEL_PRIMARY) + data = fetch_forecast(MODEL_PRIMARY, lat=lat, lon=lon, timezone=timezone) if data is not None: return data, MODEL_PRIMARY LOGGER.warning("Primary model failed (%s). Trying fallback=%s", MODEL_PRIMARY, MODEL_FALLBACK) - data2 = fetch_forecast(MODEL_FALLBACK) + data2 = fetch_forecast(MODEL_FALLBACK, lat=lat, lon=lon, timezone=timezone) if data2 is not None: return data2, MODEL_FALLBACK return None, MODEL_PRIMARY +def compare_values(arome_val: float, icon_val: float) -> Optional[Dict]: + """Confronta due valori e ritorna info se scostamento >30%""" + if arome_val == 0 and icon_val == 0: + return None + + if arome_val > 0: + diff_pct = abs(icon_val - arome_val) / arome_val + elif icon_val > 0: + diff_pct = abs(arome_val - icon_val) / icon_val + else: + return None + + if diff_pct > COMPARISON_THRESHOLD: + return { + "diff_pct": diff_pct * 100, + "arome": arome_val, + "icon": icon_val + } + return None + + +# ============================================================================= +# RAINFALL EVENT ANALYSIS (48h extended) +# ============================================================================= +def analyze_rainfall_event( + times: List[str], + precipitation: List[float], + weathercode: List[int], + start_idx: int, + max_hours: int = 48, + threshold_mm_h: Optional[float] = None +) -> Optional[Dict]: + """ + Analizza un evento di pioggia intensa completo partendo da start_idx. + + Calcola: + - Durata totale (ore consecutive con pioggia significativa) + - Accumulo totale (somma di tutti i precipitation > 0 o sopra soglia) + - Ore di inizio e fine + - Intensità massima oraria + + Args: + times: Lista di timestamp + precipitation: Lista di valori precipitation (in mm) + weathercode: Lista di weather codes + start_idx: Indice di inizio dell'evento + max_hours: Massimo numero di ore da analizzare (default: 48) + threshold_mm_h: Soglia minima per considerare pioggia significativa (mm/h). Se None, usa qualsiasi pioggia > 0 + + Returns: + Dict con: + - duration_hours: durata in ore + - total_accumulation_mm: accumulo totale in mm + - max_intensity_mm_h: intensità massima oraria + - start_time: datetime di inizio + - end_time: datetime di fine (o None se continua oltre max_hours) + - is_ongoing: True se continua oltre max_hours + """ + # Codici meteo che indicano pioggia (WMO) + RAIN_WEATHER_CODES = [61, 63, 65, 66, 67, 80, 81, 82] # Pioggia leggera, moderata, forte, congelante, rovesci + + if start_idx >= len(times): + return None + + start_dt = parse_time_to_local(times[start_idx]) + end_idx = start_idx + total_accum = 0.0 + duration = 0 + max_intensity = 0.0 + + # Analizza fino a max_hours in avanti o fino alla fine dei dati + max_idx = min(start_idx + max_hours, len(times)) + + for i in range(start_idx, max_idx): + precip_val = precipitation[i] if i < len(precipitation) and precipitation[i] is not None else 0.0 + code = weathercode[i] if i < len(weathercode) and weathercode[i] is not None else None + + # Considera pioggia significativa se: + # - precipitation > soglia (se specificata) OPPURE + # - precipitation > 0 e weather_code indica pioggia + is_rain = False + if threshold_mm_h is not None: + is_rain = precip_val >= threshold_mm_h + else: + is_rain = (precip_val > 0.0) or (code in RAIN_WEATHER_CODES) + + if is_rain: + duration += 1 + total_accum += precip_val + max_intensity = max(max_intensity, precip_val) + end_idx = i + else: + # Se c'è una pausa, continua comunque a cercare (potrebbe essere una pausa temporanea) + # Ma se la pausa è > 2 ore, considera l'evento terminato + pause_hours = 0 + for j in range(i, min(i + 3, max_idx)): + next_precip = precipitation[j] if j < len(precipitation) and precipitation[j] is not None else 0.0 + next_code = weathercode[j] if j < len(weathercode) and weathercode[j] is not None else None + next_is_rain = False + if threshold_mm_h is not None: + next_is_rain = next_precip >= threshold_mm_h + else: + next_is_rain = (next_precip > 0.0) or (next_code in RAIN_WEATHER_CODES) + + if next_is_rain: + break + pause_hours += 1 + + # Se pausa > 2 ore, termina l'analisi + if pause_hours >= 2: + break + + end_dt = parse_time_to_local(times[end_idx]) if end_idx < len(times) else None + is_ongoing = (end_idx >= max_idx - 1) and (end_idx < len(times) - 1) + + return { + "duration_hours": duration, + "total_accumulation_mm": total_accum, + "max_intensity_mm_h": max_intensity, + "start_time": start_dt, + "end_time": end_dt, + "is_ongoing": is_ongoing, + "start_idx": start_idx, + "end_idx": end_idx + } + + +def find_rainfall_start( + times: List[str], + precipitation: List[float], + weathercode: List[int], + window_start: datetime.datetime, + window_end: datetime.datetime, + threshold_mm_h: Optional[float] = None +) -> Optional[int]: + """ + Trova l'inizio di un evento di pioggia intensa nella finestra temporale. + + Returns: + Indice del primo timestamp con pioggia significativa, o None + """ + RAIN_WEATHER_CODES = [61, 63, 65, 66, 67, 80, 81, 82] + + for i, t_str in enumerate(times): + try: + t_dt = parse_time_to_local(t_str) + if t_dt < window_start or t_dt > window_end: + continue + + precip_val = precipitation[i] if i < len(precipitation) and precipitation[i] is not None else 0.0 + code = weathercode[i] if i < len(weathercode) and weathercode[i] is not None else None + + is_rain = False + if threshold_mm_h is not None: + is_rain = precip_val >= threshold_mm_h + else: + is_rain = (precip_val > 0.0) or (code in RAIN_WEATHER_CODES) + + if is_rain: + return i + except Exception: + continue + + return None + + +# ============================================================================= +# CONVECTIVE STORM EVENT ANALYSIS (48h extended) +# ============================================================================= +def analyze_convective_storm_event( + storm_events: List[Dict], + times: List[str], + start_idx: int, + max_hours: int = 48 +) -> Optional[Dict]: + """ + Analizza un evento di temporale convettivo completo su 48 ore. + + Calcola: + - Durata totale dell'evento convettivo + - Score massimo e medio + - Accumulo totale precipitazione associato + - Intensità massima (precipitazione oraria) + + Args: + storm_events: Lista di eventi convettivi (da analyze_convective_risk) + times: Lista di timestamp + start_idx: Indice di inizio dell'evento + max_hours: Massimo numero di ore da analizzare (default: 48) + + Returns: + Dict con: + - duration_hours: durata totale in ore + - max_score: score massimo + - avg_score: score medio + - total_precipitation_mm: accumulo totale precipitazione + - max_precipitation_mm_h: intensità massima oraria + - start_time: datetime di inizio + - end_time: datetime di fine + - is_ongoing: True se continua oltre max_hours + """ + if not storm_events: + return None + + if start_idx >= len(times): + return None + + # Filtra eventi nella finestra temporale + start_dt = parse_time_to_local(times[start_idx]) + max_idx = min(start_idx + max_hours, len(times)) + end_dt = parse_time_to_local(times[max_idx - 1]) if max_idx <= len(times) else None + + # Eventi nella finestra + window_events = [] + for event in storm_events: + event_dt = parse_time_to_local(event["timestamp"]) + if start_dt <= event_dt <= (end_dt or event_dt): + window_events.append(event) + + if not window_events: + return None + + # Calcola statistiche + scores = [e["score"] for e in window_events] + precipitations = [e["precip"] for e in window_events] + + duration = len(window_events) + max_score = max(scores) if scores else 0.0 + avg_score = sum(scores) / len(scores) if scores else 0.0 + total_precip = sum(precipitations) if precipitations else 0.0 + max_precip = max(precipitations) if precipitations else 0.0 + + first_event = window_events[0] + last_event = window_events[-1] + event_start = parse_time_to_local(first_event["timestamp"]) + event_end = parse_time_to_local(last_event["timestamp"]) + + is_ongoing = (max_idx >= len(times) - 1) + + return { + "duration_hours": duration, + "max_score": max_score, + "avg_score": avg_score, + "total_precipitation_mm": total_precip, + "max_precipitation_mm_h": max_precip, + "start_time": event_start, + "end_time": event_end, + "is_ongoing": is_ongoing, + "event_count": len(window_events) + } + + +# ============================================================================= +# CONVECTIVE STORM ANALYSIS (Nowcasting) +# ============================================================================= +def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[str], start_idx: int, end_idx: int) -> List[Dict]: + """ + Analizza il potenziale di temporali severi combinando dati ICON Italia e AROME Seamless. + + Args: + icon_data: Dati ICON Italia con LPI (Lightning Potential Index) + arome_data: Dati AROME Seamless con CAPE, Wind Gusts, Precipitation + times_base: Lista timestamp di riferimento (da AROME) + start_idx: Indice di inizio finestra analisi + end_idx: Indice di fine finestra analisi + + Returns: + Lista di dizionari con dettagli rischio per ogni ora che supera soglia + Ogni dict contiene: timestamp, score, threats (lista), cape, lpi, gusts, precip + """ + if not icon_data or not arome_data: + return [] + + icon_hourly = icon_data.get("hourly", {}) or {} + arome_hourly = arome_data.get("hourly", {}) or {} + + # Estrai dati + icon_times = icon_hourly.get("time", []) or [] + + # Prova diverse varianti per LPI (il nome parametro può variare) + icon_lpi = (icon_hourly.get("lightning_potential_index", []) or + icon_hourly.get("lightning_potential", []) or + icon_hourly.get("lpi", []) or + []) + + # Se LPI non disponibile, usa CAPE da ICON come proxy (CAPE alto può indicare attività convettiva) + icon_cape = icon_hourly.get("cape", []) or [] + # Se abbiamo CAPE da ICON ma non LPI, usiamo CAPE > 800 come indicatore di possibile attività elettrica + if not icon_lpi and icon_cape: + # Convertiamo CAPE in LPI proxy: CAPE > 800 = LPI > 0 + icon_lpi = [1.0 if (cape is not None and float(cape) > 800) else 0.0 for cape in icon_cape] + + arome_cape = arome_hourly.get("cape", []) or [] + arome_gusts = arome_hourly.get("wind_gusts_10m", []) or [] + arome_precip = arome_hourly.get("precipitation", []) or [] + + # Allineamento: sincronizza timestamp (ICON e AROME possono avere risoluzioni diverse) + # Per semplicità, assumiamo che abbiano la stessa risoluzione oraria e li allineiamo per indice + results = [] + + # Pre-calcola somme precipitazione su 3 ore per AROME + arome_precip_3h = [] + for i in range(len(arome_precip)): + if i < 2: + arome_precip_3h.append(0.0) + else: + try: + sum_3h = sum(float(arome_precip[j]) for j in range(i-2, i+1) if arome_precip[j] is not None) + arome_precip_3h.append(sum_3h) + except Exception: + arome_precip_3h.append(0.0) + + # Analizza ogni ora nella finestra + for i in range(start_idx, min(end_idx, len(times_base), len(arome_cape), len(arome_gusts), len(arome_precip))): + if i >= len(times_base): + break + + # Estrai valori per questa ora + try: + cape_val = float(arome_cape[i]) if i < len(arome_cape) and arome_cape[i] is not None else 0.0 + gusts_val = float(arome_gusts[i]) if i < len(arome_gusts) and arome_gusts[i] is not None else 0.0 + precip_val = float(arome_precip[i]) if i < len(arome_precip) and arome_precip[i] is not None else 0.0 + precip_3h_val = arome_precip_3h[i] if i < len(arome_precip_3h) else 0.0 + except (ValueError, TypeError, IndexError): + continue + + # Estrai LPI da ICON (allineamento per indice, assumendo stesso timestamp) + lpi_val = 0.0 + if i < len(icon_times) and i < len(icon_lpi): + # Verifica che i timestamp corrispondano approssimativamente + try: + icon_time = parse_time_to_local(icon_times[i]) + arome_time = parse_time_to_local(times_base[i]) + # Se i timestamp sono entro 30 minuti, considera allineati + time_diff = abs((icon_time - arome_time).total_seconds() / 60) + if time_diff < 30: + lpi_val = float(icon_lpi[i]) if icon_lpi[i] is not None else 0.0 + except (ValueError, TypeError, IndexError): + pass + + # Calcola Storm Severity Score (0-100) + score = 0.0 + threats = [] + + # 1. Componente Energia (CAPE): 0-40 punti + if cape_val > 0: + cape_score = min(40.0, (cape_val / 2000.0) * 40.0) # 2000 J/kg = 40 punti + score += cape_score + + # 2. Componente Fulminazioni (LPI): 0-30 punti + if lpi_val > 0: + lpi_score = min(30.0, lpi_val * 10.0) # LPI normalizzato (assumendo scala 0-3) + score += lpi_score + + # 3. Componente Dinamica (Wind Gusts + Precip): 0-30 punti + if gusts_val > WIND_GUST_DOWNBURST_THRESHOLD and precip_val > 0.1: + dynamic_score = min(30.0, ((gusts_val - WIND_GUST_DOWNBURST_THRESHOLD) / 40.0) * 30.0) + score += dynamic_score + + # Identifica minacce specifiche + # Fulminazioni + if cape_val > CAPE_LIGHTNING_THRESHOLD and lpi_val > 0: + threats.append("Fulminazioni") + + # Downburst/Temporale violento + if cape_val > CAPE_SEVERE_THRESHOLD and gusts_val > WIND_GUST_DOWNBURST_THRESHOLD: + threats.append("Downburst/Temporale violento") + + # Nubifragio + if precip_val > RAIN_INTENSE_THRESHOLD_H or precip_3h_val > RAIN_INTENSE_THRESHOLD_3H: + threats.append("Nubifragio") + + # Aggiungi risultato solo se supera soglia + if score >= STORM_SCORE_THRESHOLD or threats: + results.append({ + "timestamp": times_base[i], + "score": score, + "threats": threats, + "cape": cape_val, + "lpi": lpi_val, + "gusts": gusts_val, + "precip": precip_val, + "precip_3h": precip_3h_val, + }) + + return results + + +def format_convective_alert(storm_events: List[Dict], times: List[str], start_idx: int) -> str: + """Formatta messaggio di allerta per temporali severi con dettagli completi.""" + if not storm_events: + return "" + + # Analisi estesa su 48 ore + storm_analysis = analyze_convective_storm_event(storm_events, times, start_idx, max_hours=48) + + # Calcola statistiche aggregate + max_score = max(e["score"] for e in storm_events) + max_cape_overall = max(e["cape"] for e in storm_events) + max_lpi_overall = max((e["lpi"] for e in storm_events if e["lpi"] > 0), default=0.0) + max_gusts_overall = max(e["gusts"] for e in storm_events) + max_precip_h_overall = max(e["precip"] for e in storm_events) + max_precip_3h_overall = max(e["precip_3h"] for e in storm_events) + + # Determina il periodo complessivo + first_event = storm_events[0] + last_event = storm_events[-1] + first_time = parse_time_to_local(first_event["timestamp"]) + last_time = parse_time_to_local(last_event["timestamp"]) + + # Usa durata dall'analisi estesa se disponibile, altrimenti conta eventi + if storm_analysis: + duration_hours = storm_analysis["duration_hours"] + total_precip = storm_analysis["total_precipitation_mm"] + max_precip_h = storm_analysis["max_precipitation_mm_h"] + else: + duration_hours = len(storm_events) + total_precip = sum(e["precip"] for e in storm_events) + max_precip_h = max_precip_h_overall + + # Raggruppa per tipo di minaccia + by_threat = {} + for event in storm_events: + for threat in event.get("threats", []): + if threat not in by_threat: + by_threat[threat] = [] + by_threat[threat].append(event) + + # Intestazione principale con score e periodo + msg_parts = [ + "⛈️ ALLERTA TEMPORALI SEVERI", + f"📊 Storm Severity Score max: {max_score:.0f}/100", + f"🕒 Periodo rischio: {first_time.strftime('%d/%m %H:%M')} - {last_time.strftime('%d/%m %H:%M')}", + f"⏱️ Durata stimata: ~{duration_hours} ore", + ] + + # Dettagli per tipo di minaccia + for threat_type, events in sorted(by_threat.items(), key=lambda x: len(x[1]), reverse=True): + if threat_type == "Fulminazioni": + msg_parts.append("\n⚡ RISCHIO FULMINAZIONI") + + # Timeline delle fulminazioni + first = events[0] + last = events[-1] + first_time_threat = hhmm(parse_time_to_local(first["timestamp"])) + last_time_threat = hhmm(parse_time_to_local(last["timestamp"])) + + # Valori specifici per questa minaccia + max_cape = max(e["cape"] for e in events) + min_cape = min((e["cape"] for e in events if e["cape"] > 0), default=0) + avg_cape = sum(e["cape"] for e in events) / len(events) if events else 0 + max_lpi = max((e["lpi"] for e in events if e["lpi"] > 0), default=0.0) + hours_with_lpi = sum(1 for e in events if e["lpi"] > 0) + + msg_parts.append( + f"🕒 Periodo: {first_time_threat} - {last_time_threat} ({len(events)} ore)\n" + f"⚡ CAPE: max {max_cape:.0f} J/kg | min {min_cape:.0f} J/kg | media {avg_cape:.0f} J/kg\n" + f"💥 LPI: max {max_lpi:.2f} | ore con attività: {hours_with_lpi}/{len(events)}\n" + f"⚠️ Alta probabilità di fulminazioni. Evitare attività all'aperto." + ) + + elif threat_type == "Downburst/Temporale violento": + msg_parts.append("\n🌪️ RISCHIO TEMPORALE VIOLENTO") + + first = events[0] + last = events[-1] + first_time_threat = hhmm(parse_time_to_local(first["timestamp"])) + last_time_threat = hhmm(parse_time_to_local(last["timestamp"])) + + max_cape = max(e["cape"] for e in events) + min_cape = min((e["cape"] for e in events if e["cape"] > 0), default=0) + max_gusts = max(e["gusts"] for e in events) + min_gusts = min((e["gusts"] for e in events if e["gusts"] > WIND_GUST_DOWNBURST_THRESHOLD), default=0) + avg_gusts = sum(e["gusts"] for e in events) / len(events) if events else 0 + + # Determina livello di rischio vento + if max_gusts > 90: + wind_level = "🔴 ESTREMO" + elif max_gusts > 75: + wind_level = "🟠 ALTO" + else: + wind_level = "🟡 MODERATO" + + msg_parts.append( + f"🕒 Periodo: {first_time_threat} - {last_time_threat} ({len(events)} ore)\n" + f"⚡ CAPE: max {max_cape:.0f} J/kg | min {min_cape:.0f} J/kg\n" + f"💨 Raffiche vento: max {max_gusts:.0f} km/h | min {min_gusts:.0f} km/h | media {avg_gusts:.0f} km/h\n" + f"🌪️ Livello rischio: {wind_level}\n" + f"⚠️ Possibili downburst e venti distruttivi. Rimanere in luoghi sicuri." + ) + + elif threat_type == "Nubifragio": + msg_parts.append("\n💧 RISCHIO NUBIFRAGIO") + + first = events[0] + last = events[-1] + first_time_threat = hhmm(parse_time_to_local(first["timestamp"])) + last_time_threat = hhmm(parse_time_to_local(last["timestamp"])) + + max_precip_h = max(e["precip"] for e in events) + max_precip_3h = max(e["precip_3h"] for e in events) + avg_precip = sum(e["precip"] for e in events) / len(events) if events else 0 + # Usa accumulo totale dall'analisi estesa se disponibile + if storm_analysis: + total_precip_estimate = storm_analysis["total_precipitation_mm"] + else: + total_precip_estimate = sum(e["precip"] for e in events) + + # Determina intensità + if max_precip_h > 50: + intensity = "🔴 ESTREMO (>50 mm/h)" + elif max_precip_h > 30: + intensity = "🟠 ALTO (30-50 mm/h)" + elif max_precip_h > 20: + intensity = "🟡 MODERATO (20-30 mm/h)" + else: + intensity = "🟢 BASSO" + + msg_parts.append( + f"🕒 Periodo: {first_time_threat} - {last_time_threat} ({len(events)} ore)\n" + f"🌧️ Intensità: max {max_precip_h:.1f} mm/h ({intensity})\n" + f"💧 Accumulo 3h: max {max_precip_3h:.1f} mm\n" + f"📊 Media oraria: {avg_precip:.1f} mm/h | Accumulo totale (48h): ~{total_precip_estimate:.1f} mm\n" + f"⚠️ Possibili allagamenti e frane. Evitare sottopassi e zone a rischio." + ) + + # Riepilogo condizioni ambientali + msg_parts.append("\n📈 CONDIZIONI AMBIENTALI") + msg_parts.append( + f"⚡ CAPE massimo: {max_cape_overall:.0f} J/kg\n" + f"💥 LPI massimo: {max_lpi_overall:.2f}\n" + f"💨 Raffiche massime: {max_gusts_overall:.0f} km/h\n" + f"🌧️ Precipitazione max oraria: {max_precip_h_overall:.1f} mm/h" + ) + + return "\n".join(msg_parts) + + # ============================================================================= # PERSISTENCE LOGIC # ============================================================================= @@ -330,7 +992,7 @@ def best_wind_persistent_level( # Extend peak as the run continues # If user prefers "meglio uno in più", we take the first qualifying run. if best_len == 0: - best_start = hhmm(dt_list[run_start]) + best_start = ddmmyy_hhmm(dt_list[run_start]) best_peak = run_peak best_len = consec else: @@ -408,7 +1070,7 @@ def best_rain_persistent_3h( # take first persistent run (meglio uno in più), but keep track of max within it if best_persist == 0: start_i = window_starts[run_start_j] - best_start = hhmm(dt_list[start_i]) + best_start = ddmmyy_hhmm(dt_list[start_i]) best_persist = consec best_max = run_max else: @@ -442,48 +1104,89 @@ def wind_message(level: int, peak: float, start_hhmm: str, run_len: int) -> str: thr = WIND_YELLOW return ( - f"{icon} {title}
" - f"Persistenza: ≥ {PERSIST_HOURS} ore sopra soglia ({thr:.0f} km/h).
" - f"🕒 Inizio stimato: {html.escape(start_hhmm or '—')}
" + f"{icon} {title}\n" + f"Persistenza: ≥ {PERSIST_HOURS} ore sopra soglia ({thr:.0f} km/h).\n" + f"🕒 Inizio stimato: {html.escape(start_hhmm or '—')}\n" f"📈 Picco in finestra: {peak:.0f} km/h (run ~{run_len}h)." ) -def rain_message(max_3h: float, start_hhmm: str, persist_h: int) -> str: - return ( - "🌧️ PIOGGIA INTENSA
" - f"Persistenza: ≥ {PERSIST_HOURS} ore sopra soglia ({RAIN_3H_LIMIT:.1f} mm/3h).
" - f"🕒 Inizio stimato: {html.escape(start_hhmm or '—')}
" +def rain_message(max_3h: float, start_hhmm: str, persist_h: int, rain_analysis: Optional[Dict] = None) -> str: + """ + Formatta messaggio per pioggia intensa persistente. + + Args: + max_3h: Massimo accumulo su 3 ore + start_hhmm: Ora di inizio stimata + persist_h: Ore di persistenza + rain_analysis: Risultato di analyze_rainfall_event (opzionale, per analisi estesa 48h) + """ + msg_parts = [ + "🌧️ PIOGGIA INTENSA", + f"Persistenza: ≥ {PERSIST_HOURS} ore sopra soglia ({RAIN_3H_LIMIT:.1f} mm/3h).", + f"🕒 Inizio stimato: {html.escape(start_hhmm or '—')}", f"📈 Max su 3 ore in finestra: {max_3h:.1f} mm (persistenza ~{persist_h}h)." - ) + ] + + # Aggiungi informazioni dall'analisi estesa se disponibile + if rain_analysis: + total_mm = rain_analysis.get("total_accumulation_mm", 0.0) + duration_h = rain_analysis.get("duration_hours", 0) + max_intensity = rain_analysis.get("max_intensity_mm_h", 0.0) + end_time = rain_analysis.get("end_time") + + if end_time: + end_str = end_time.strftime("%d/%m %H:%M") + msg_parts.append(f"⏱️ Durata totale evento (48h): ~{duration_h} ore (fino alle {end_str})") + else: + msg_parts.append(f"⏱️ Durata totale evento (48h): ~{duration_h} ore (in corso)") + + msg_parts.append(f"💧 Accumulo totale previsto: ~{total_mm:.1f} mm") + msg_parts.append(f"🌧️ Intensità massima oraria: {max_intensity:.1f} mm/h") + + return "\n".join(msg_parts) # ============================================================================= # MAIN # ============================================================================= -def analyze() -> None: - LOGGER.info("--- Controllo Meteo Severo (Wind/Rain/Ice) ---") +def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat: Optional[float] = None, lon: Optional[float] = None, location_name: Optional[str] = None, timezone: Optional[str] = None) -> None: + if lat is None: + lat = DEFAULT_LAT + if lon is None: + lon = DEFAULT_LON + if location_name is None: + location_name = f"Casa (LAT {lat:.4f}, LON {lon:.4f})" + + LOGGER.info("--- Controllo Meteo Severo (Wind/Rain/Ice) per %s (timezone: %s) ---", location_name, timezone or TZ) - data, model_used = get_forecast() - if not data: + data_arome, model_used = get_forecast(lat=lat, lon=lon, timezone=timezone) + if not data_arome: # No Telegram on errors return - hourly = (data.get("hourly", {}) or {}) - times = hourly.get("time", []) or [] - gusts = hourly.get("wind_gusts_10m", []) or [] - rain = hourly.get("precipitation", []) or [] - wcode = hourly.get("weather_code", []) or [] + hourly_arome = (data_arome.get("hourly", {}) or {}) + times = hourly_arome.get("time", []) or [] + gusts_arome = hourly_arome.get("wind_gusts_10m", []) or [] + rain_arome = hourly_arome.get("precipitation", []) or [] + wcode_arome = hourly_arome.get("weather_code", []) or [] - n = min(len(times), len(gusts), len(rain), len(wcode)) + # Recupera dati ICON Italia per comparazione e analisi convettiva + data_icon = fetch_forecast(MODEL_ICON_IT, lat=lat, lon=lon, timezone=timezone) + hourly_icon = (data_icon.get("hourly", {}) or {}) if data_icon else {} + gusts_icon = hourly_icon.get("wind_gusts_10m", []) or [] + rain_icon = hourly_icon.get("precipitation", []) or [] + wcode_icon = hourly_icon.get("weather_code", []) or [] + + n = min(len(times), len(gusts_arome), len(rain_arome), len(wcode_arome)) if n == 0: LOGGER.error("Empty hourly series (model=%s).", model_used) return times = times[:n] - gusts = gusts[:n] - rain = rain[:n] - wcode = wcode[:n] + gusts = gusts_arome[:n] + rain = rain_arome[:n] + wcode = wcode_arome[:n] now = now_local() state = load_state() @@ -508,19 +1211,24 @@ def analyze() -> None: LOGGER.debug("model=%s start_idx=%s end_idx=%s (hours=%s)", model_used, start_idx, end_idx, end_idx - start_idx) - # --- Freezing detection (no persistence needed) --- - freezing_detected = False - freezing_time = "" - for i in range(start_idx, end_idx): - try: - code = int(wcode[i]) - except Exception: - code = -1 - if code in FREEZING_CODES: - freezing_detected = True - if not freezing_time: - freezing_time = hhmm(parse_time_to_local(times[i])) - break + # --- Convective storm analysis (temporali severi) --- + storm_events = [] + if data_icon and data_arome: + if DEBUG: + LOGGER.debug("Avvio analisi convettiva (ICON + AROME)") + storm_events = analyze_convective_risk(data_icon, data_arome, times, start_idx, end_idx) + if DEBUG: + LOGGER.debug("Analisi convettiva completata: %d eventi rilevati", len(storm_events)) + if storm_events: + for evt in storm_events[:3]: # Mostra primi 3 eventi + LOGGER.debug(" Evento: %s - Score: %.1f - Threats: %s", + hhmm(parse_time_to_local(evt["timestamp"])), + evt["score"], evt.get("threats", [])) + elif DEBUG: + if not data_icon: + LOGGER.debug("Analisi convettiva saltata: dati ICON non disponibili") + if not data_arome: + LOGGER.debug("Analisi convettiva saltata: dati AROME non disponibili") # --- Wind persistence --- wind_level_curr, wind_peak, wind_start, wind_run_len = best_wind_persistent_level( @@ -541,29 +1249,66 @@ def analyze() -> None: persist_hours=PERSIST_HOURS ) + # --- Comparazioni con ICON Italia --- + comparisons: Dict[str, Dict] = {} + + # Compara vento (picco) + if len(gusts_icon) >= n and wind_level_curr > 0: + max_g_icon = 0.0 + for i in range(start_idx, end_idx): + if i < len(gusts_icon) and gusts_icon[i] is not None: + max_g_icon = max(max_g_icon, float(gusts_icon[i])) + comp_wind = compare_values(wind_peak, max_g_icon) if max_g_icon > 0 else None + if comp_wind: + comparisons["wind"] = comp_wind + + # Compara pioggia (max 3h) + if len(rain_icon) >= n and rain_max_3h > 0: + # Calcola max 3h ICON + max_3h_icon = 0.0 + for i in range(start_idx, min(end_idx - 2, len(rain_icon) - 2)): + if all(rain_icon[i+j] is not None for j in range(3)): + sum_3h = sum(float(rain_icon[i+j]) for j in range(3)) + max_3h_icon = max(max_3h_icon, sum_3h) + comp_rain = compare_values(rain_max_3h, max_3h_icon) if max_3h_icon > 0 else None + if comp_rain: + comparisons["rain"] = comp_rain + # --- Decide notifications --- alerts: List[str] = [] should_notify = False - # 1) Freezing rain (priority) - if freezing_detected: - if not bool(state.get("freezing_active", False)): - alerts.append( - "🧊 ALLARME GELICIDIO
" - "Prevista pioggia che gela (freezing rain/drizzle).
" - f"🕒 Inizio stimato: {html.escape(freezing_time or '—')}
" - "Pericolo ghiaccio su strada." - ) + # 1) Convective storms (temporali severi) - priorità alta + if storm_events: + prev_storm_active = bool(state.get("convective_storm_active", False)) + max_score = max(e["score"] for e in storm_events) + prev_score = float(state.get("last_storm_score", 0.0) or 0.0) + + # Notifica se: nuovo evento, o score aumenta significativamente (+15 punti) + if debug_mode or not prev_storm_active or (max_score >= prev_score + 15.0): + if debug_mode: + LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato per temporali severi") + convective_msg = format_convective_alert(storm_events, times, start_idx) + if convective_msg: + alerts.append(convective_msg) should_notify = True - state["freezing_active"] = True + state["convective_storm_active"] = True + state["last_storm_score"] = float(max_score) else: - state["freezing_active"] = False + state["convective_storm_active"] = False + state["last_storm_score"] = 0.0 # 2) Wind (persistent) if wind_level_curr > 0: prev_level = int(state.get("wind_level", 0) or 0) - if (not was_alarm) or (wind_level_curr > prev_level): - alerts.append(wind_message(wind_level_curr, wind_peak, wind_start, wind_run_len)) + if debug_mode or (not was_alarm) or (wind_level_curr > prev_level): + if debug_mode: + LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato per vento") + wind_msg = wind_message(wind_level_curr, wind_peak, wind_start, wind_run_len) + if "wind" in comparisons: + comp = comparisons["wind"] + wind_msg += f"\n⚠️ Discordanza modelli: AROME {comp['arome']:.0f} km/h | ICON {comp['icon']:.0f} km/h (scostamento {comp['diff_pct']:.0f}%)" + alerts.append(wind_msg) should_notify = True state["wind_level"] = wind_level_curr state["last_wind_peak"] = float(wind_peak) @@ -576,52 +1321,160 @@ def analyze() -> None: prev_rain = float(state.get("last_rain_3h", 0.0) or 0.0) # "Meglio uno in più": notifica anche al primo superamento persistente, # e ri-notifica se peggiora di >= +10mm sul massimo 3h - if (not was_alarm) or (rain_max_3h >= prev_rain + 10.0): - alerts.append(rain_message(rain_max_3h, rain_start, rain_persist)) + if debug_mode or (not was_alarm) or (rain_max_3h >= prev_rain + 10.0): + if debug_mode: + LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato per pioggia") + + # Analisi estesa su 48 ore per pioggia intensa + rain_analysis = None + if rain_start: + # Trova l'indice di inizio dell'evento cercando il timestamp corrispondente + rain_start_idx = -1 + for i, t in enumerate(times): + try: + t_dt = parse_time_to_local(t) + if ddmmyy_hhmm(t_dt) == rain_start: + rain_start_idx = i + break + except Exception: + continue + + if rain_start_idx >= 0 and rain_start_idx < len(times): + # Usa soglia minima per considerare pioggia significativa (8 mm/h, coerente con RAIN_INTENSE_THRESHOLD_H) + rain_analysis = analyze_rainfall_event( + times=times, + precipitation=rain, + weathercode=wcode, + start_idx=rain_start_idx, + max_hours=48, + threshold_mm_h=8.0 # Soglia per pioggia intensa + ) + + rain_msg = rain_message(rain_max_3h, rain_start, rain_persist, rain_analysis=rain_analysis) + if "rain" in comparisons: + comp = comparisons["rain"] + rain_msg += f"\n⚠️ Discordanza modelli: AROME {comp['arome']:.1f} mm | ICON {comp['icon']:.1f} mm (scostamento {comp['diff_pct']:.0f}%)" + alerts.append(rain_msg) should_notify = True state["last_rain_3h"] = float(rain_max_3h) else: state["last_rain_3h"] = 0.0 is_alarm_now = ( - freezing_detected + (storm_events is not None and len(storm_events) > 0) or (wind_level_curr > 0) or (rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT) ) + # In modalità debug, forza invio anche se non ci sono allerte + debug_message_only = False + if debug_mode and not alerts: + LOGGER.info("[DEBUG MODE] Nessuna allerta, ma creo messaggio informativo") + alerts.append("ℹ️ Nessuna condizione meteo severa rilevata nelle prossime %s ore." % HOURS_AHEAD) + should_notify = True + debug_message_only = True # Segnala che è solo un messaggio debug, non una vera allerta + # --- Send only on alerts (never on errors) --- if should_notify and alerts: headline = "⚠️ AVVISO METEO SEVERO" + model_info = model_used + if comparisons: + model_info = f"{model_used} + ICON Italia (discordanza rilevata)" + + # Se ci sono temporali severi, aggiungi informazioni sui modelli usati + if storm_events: + model_info = f"{model_used} + ICON Italia (analisi convettiva combinata)" + meta = ( - f"📍 Casa (LAT {LAT:.4f}, LON {LON:.4f})
" - f"🕒 Finestra: prossime {HOURS_AHEAD} ore
" - f"🛰️ Modello: {html.escape(model_used)}
" - f"⏱️ Persistenza minima: {PERSIST_HOURS} ore
" + f"📍 {html.escape(location_name)}\n" + f"🕒 Finestra: prossime {HOURS_AHEAD} ore\n" + f"🛰️ Modello: {html.escape(model_info)}\n" + f"⏱️ Persistenza minima: {PERSIST_HOURS} ore\n" ) - body = "

".join(alerts) - footer = "

Fonte dati: Open-Meteo" - msg = f"{headline}
{meta}
{body}{footer}" + body = "\n\n".join(alerts) + footer = "\n\nFonte dati: Open-Meteo | Analisi nowcasting per temporali severi" + msg = f"{headline}\n{meta}\n{body}{footer}" - ok = telegram_send_html(msg) + 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).") - state["alert_active"] = True - save_state(state) + # IMPORTANTE: Imposta alert_active = True solo se c'è una vera allerta, + # non se è solo un messaggio informativo in modalità debug + if not debug_message_only: + # Determina il tipo di allerta basandosi sulle condizioni attuali + alert_types = [] + if storm_events and len(storm_events) > 0: + alert_types.append("TEMPORALI SEVERI") + if wind_level_curr > 0: + wind_labels = {3: "TEMPESTA", 2: "VENTO MOLTO FORTE", 1: "VENTO FORTE"} + alert_types.append(wind_labels.get(wind_level_curr, "VENTO FORTE")) + if rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT: + alert_types.append("PIOGGIA INTENSA") + + state["alert_active"] = True + state["last_alert_type"] = alert_types if alert_types else None + state["last_alert_time"] = now.isoformat() + save_state(state) + else: + # 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 # Optional: cleared message (transition only) if SEND_ALL_CLEAR and was_alarm and (not is_alarm_now): - msg = ( - "🟢 ALLERTA METEO RIENTRATA
" - "Condizioni rientrate sotto le soglie di guardia.
" - f"🕒 Finestra: prossime {HOURS_AHEAD} ore
" - f"🛰️ Modello: {html.escape(model_used)}
" + # Recupera informazioni sull'allerta che è rientrata + last_alert_type = state.get("last_alert_type") + last_alert_time_str = state.get("last_alert_time") + + # Formatta il tipo di allerta + alert_type_text = "" + if last_alert_type: + if isinstance(last_alert_type, list): + alert_type_text = " + ".join(last_alert_type) + else: + alert_type_text = str(last_alert_type) + + # Formatta l'ora di notifica + alert_time_text = "" + if last_alert_time_str: + try: + alert_time_dt = parse_time_to_local(last_alert_time_str) + alert_time_text = ddmmyy_hhmm(alert_time_dt) + except Exception: + try: + # Fallback: prova a parsare come ISO + alert_time_dt = parser.isoparse(last_alert_time_str) + if alert_time_dt.tzinfo is None: + alert_time_dt = alert_time_dt.replace(tzinfo=TZINFO) + else: + alert_time_dt = alert_time_dt.astimezone(TZINFO) + alert_time_text = ddmmyy_hhmm(alert_time_dt) + except Exception: + alert_time_text = last_alert_time_str + + # Costruisci il messaggio + msg_parts = [ + "🟢 ALLERTA METEO RIENTRATA", + "Condizioni rientrate sotto le soglie di guardia." + ] + + if alert_type_text: + msg_parts.append(f"📋 Tipo allerta rientrata: {html.escape(alert_type_text)}") + + if alert_time_text: + msg_parts.append(f"🕐 Notificata alle: {html.escape(alert_time_text)}") + + msg_parts.extend([ + f"🕒 Finestra: prossime {HOURS_AHEAD} ore", + f"🛰️ Modello: {html.escape(model_used)}", f"⏱️ Persistenza minima: {PERSIST_HOURS} ore" - ) - ok = telegram_send_html(msg) + ]) + + msg = "\n".join(msg_parts) + ok = telegram_send_html(msg, chat_ids=chat_ids) if ok: LOGGER.info("All-clear sent successfully.") else: @@ -632,7 +1485,10 @@ def analyze() -> None: "wind_level": 0, "last_wind_peak": 0.0, "last_rain_3h": 0.0, - "freezing_active": False, + "convective_storm_active": False, + "last_storm_score": 0.0, + "last_alert_type": None, + "last_alert_time": None, } save_state(state) return @@ -640,11 +1496,55 @@ def analyze() -> None: # No new alert state["alert_active"] = bool(is_alarm_now) save_state(state) + storm_count = len(storm_events) if storm_events else 0 LOGGER.info( - "No new alert. model=%s wind_level=%s rain3h=%.1fmm(persist=%sh) ice=%s", - model_used, wind_level_curr, rain_max_3h, rain_persist, freezing_detected + "No new alert. model=%s wind_level=%s rain3h=%.1fmm(persist=%sh) storms=%d", + model_used, wind_level_curr, rain_max_3h, rain_persist, storm_count ) + if DEBUG and storm_events: + max_score = max(e["score"] for e in storm_events) + LOGGER.debug("Storm events present (max_score=%.1f) but below notification threshold", max_score) if __name__ == "__main__": - analyze() + arg_parser = argparse.ArgumentParser(description="Severe weather alert") + arg_parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0]) + arg_parser.add_argument("--lat", type=float, help="Latitudine (default: Casa)") + arg_parser.add_argument("--lon", type=float, help="Longitudine (default: Casa)") + arg_parser.add_argument("--location", help="Nome località (default: Casa)") + arg_parser.add_argument("--timezone", help="Timezone IANA (es: Europe/Rome, America/New_York)") + arg_parser.add_argument("--chat_id", help="Chat ID Telegram per invio diretto (opzionale, può essere multiplo separato da virgola)") + arg_parser.add_argument("--check_viaggi", action="store_true", help="Controlla viaggi attivi e invia per tutte le localizzazioni") + args = arg_parser.parse_args() + + # Se --check_viaggi, controlla viaggi attivi e invia per tutte le localizzazioni + if args.check_viaggi: + VIAGGI_STATE_FILE = os.path.join(BASE_DIR, "viaggi_attivi.json") + if os.path.exists(VIAGGI_STATE_FILE): + try: + with open(VIAGGI_STATE_FILE, "r", encoding="utf-8") as f: + viaggi = json.load(f) or {} + for chat_id, viaggio in viaggi.items(): + LOGGER.info("Processando viaggio attivo per chat_id=%s: %s", chat_id, viaggio.get("name")) + analyze( + chat_ids=[chat_id], + debug_mode=False, + lat=viaggio.get("lat"), + lon=viaggio.get("lon"), + location_name=viaggio.get("name"), + timezone=viaggio.get("timezone") + ) + time.sleep(1) # Pausa tra invii + except Exception as e: + LOGGER.exception("Errore lettura viaggi attivi: %s", e) + # Invia anche per Casa (comportamento normale) + analyze(chat_ids=None, debug_mode=args.debug) + else: + # Comportamento normale: determina chat_ids + chat_ids = None + if args.chat_id: + chat_ids = [cid.strip() for cid in args.chat_id.split(",") if cid.strip()] + elif args.debug: + chat_ids = [TELEGRAM_CHAT_IDS[0]] + + analyze(chat_ids=chat_ids, debug_mode=args.debug, lat=args.lat, lon=args.lon, location_name=args.location, timezone=args.timezone) diff --git a/services/telegram-bot/severe_weather_circondario.py b/services/telegram-bot/severe_weather_circondario.py new file mode 100755 index 0000000..81d6861 --- /dev/null +++ b/services/telegram-bot/severe_weather_circondario.py @@ -0,0 +1,635 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import argparse +import datetime +import html +import json +import logging +import os +import time +from logging.handlers import RotatingFileHandler +from typing import Dict, List, Optional, Tuple +from zoneinfo import ZoneInfo + +import requests +from dateutil import parser + +# ============================================================================= +# SEVERE WEATHER ALERT CIRCONDARIO (next 48h) - Analisi Temporali Severi +# - Analizza rischio temporali severi per 9 località del circondario +# - Fulminazioni elevate (CAPE > 800 J/kg + LPI > 0) +# - Downburst (CAPE > 1500 J/kg + Wind Gusts > 60 km/h) +# - Nubifragi (Precipitation > 20mm/h o somma 3h > 40mm) +# - Rischio Alluvioni (precipitazioni intense e prolungate) +# +# Telegram token: NOT in clear. +# Read order: +# 1) env TELEGRAM_BOT_TOKEN +# 2) ~/.telegram_dpc_bot_token +# 3) /etc/telegram_dpc_bot_token +# +# Debug: +# DEBUG=1 python3 severe_weather_circondario.py +# +# Log: +# ./weather_alert_circondario.log (same folder as this script) +# ============================================================================= + +DEBUG = os.environ.get("DEBUG", "0").strip() == "1" + +# ----------------- TELEGRAM ----------------- +TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"] +TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token") +TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token" + +# ----------------- LOCALITÀ CIRCONDARIO ----------------- +# Coordinate delle località da monitorare +LOCALITA_CIRCONDARIO = [ + {"name": "Bologna", "lat": 44.4938, "lon": 11.3387}, + {"name": "Imola", "lat": 44.3552, "lon": 11.7164}, + {"name": "Faenza", "lat": 44.2856, "lon": 11.8798}, + {"name": "Ravenna", "lat": 44.4175, "lon": 12.1996}, + {"name": "Forlì", "lat": 44.2231, "lon": 12.0401}, + {"name": "Cesena", "lat": 44.1390, "lon": 12.2435}, + {"name": "Rimini", "lat": 44.0678, "lon": 12.5695}, + {"name": "Riccione", "lat": 44.0015, "lon": 12.6484}, + {"name": "Pesaro", "lat": 43.9100, "lon": 12.9133}, +] + +# ----------------- THRESHOLDS ----------------- +HOURS_AHEAD = 24 # Analisi 24 ore + +# ----------------- CONVECTIVE STORM THRESHOLDS ----------------- +CAPE_LIGHTNING_THRESHOLD = 800.0 # J/kg - Soglia per rischio fulminazioni +CAPE_SEVERE_THRESHOLD = 1500.0 # J/kg - Soglia per temporali violenti +WIND_GUST_DOWNBURST_THRESHOLD = 60.0 # km/h - Soglia vento per downburst +RAIN_INTENSE_THRESHOLD_H = 20.0 # mm/h - Soglia per nubifragio orario +RAIN_INTENSE_THRESHOLD_3H = 40.0 # mm/3h - Soglia per nubifragio su 3 ore +RAIN_FLOOD_THRESHOLD_24H = 100.0 # mm/24h - Soglia per rischio alluvioni +STORM_SCORE_THRESHOLD = 40.0 # Storm Severity Score minimo per allerta + +# ----------------- FILES ----------------- +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +STATE_FILE = os.path.join(BASE_DIR, "weather_state_circondario.json") +LOG_FILE = os.path.join(BASE_DIR, "weather_alert_circondario.log") + +# ----------------- OPEN-METEO ----------------- +OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" +GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search" +TZ = "Europe/Rome" # Timezone Italia per il circondario +TZINFO = ZoneInfo(TZ) +HTTP_HEADERS = {"User-Agent": "rpi-severe-weather-circondario/1.0"} + +# Modelli meteo +MODEL_PRIMARY = "meteofrance_seamless" +MODEL_FALLBACK = "meteofrance_arome_france_hd" +MODEL_ICON_IT = "italia_meteo_arpae_icon_2i" + + +# ============================================================================= +# LOGGING +# ============================================================================= +def setup_logger() -> logging.Logger: + logger = logging.getLogger("severe_weather_circondario") + logger.setLevel(logging.DEBUG if DEBUG else logging.INFO) + logger.handlers.clear() + + fh = RotatingFileHandler(LOG_FILE, maxBytes=1_000_000, backupCount=5, encoding="utf-8") + fh.setLevel(logging.DEBUG) + fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s") + fh.setFormatter(fmt) + logger.addHandler(fh) + + if DEBUG: + sh = logging.StreamHandler() + sh.setLevel(logging.DEBUG) + sh.setFormatter(fmt) + logger.addHandler(sh) + + return logger + + +LOGGER = setup_logger() + + +# ============================================================================= +# UTILS +# ============================================================================= +def ensure_parent_dir(path: str) -> None: + parent = os.path.dirname(path) + if parent and not os.path.exists(parent): + os.makedirs(parent, exist_ok=True) + + +def now_local() -> datetime.datetime: + return datetime.datetime.now(TZINFO) + + +def read_text_file(path: str) -> str: + try: + with open(path, "r", encoding="utf-8") as f: + return f.read().strip() + except FileNotFoundError: + return "" + except PermissionError: + LOGGER.debug("Permission denied reading %s", path) + return "" + except Exception as e: + LOGGER.exception("Error reading %s: %s", path, e) + return "" + + +def load_bot_token() -> str: + tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() + if tok: + return tok + tok = read_text_file(TOKEN_FILE_HOME) + if tok: + return tok + tok = read_text_file(TOKEN_FILE_ETC) + return tok.strip() if tok else "" + + +def parse_time_to_local(t: str) -> datetime.datetime: + """Robust timezone handling.""" + dt = parser.isoparse(t) + if dt.tzinfo is None: + return dt.replace(tzinfo=TZINFO) + return dt.astimezone(TZINFO) + + +def hhmm(dt: datetime.datetime) -> str: + return dt.strftime("%H:%M") + + +def ddmmyyhhmm(dt: datetime.datetime) -> str: + return dt.strftime("%d/%m %H:%M") + + +# ============================================================================= +# TELEGRAM +# ============================================================================= +def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) -> bool: + """Never raises. Returns True if at least one chat_id succeeded.""" + token = load_bot_token() + if not token: + LOGGER.warning("Telegram token missing: message not sent.") + return False + + if chat_ids is None: + chat_ids = TELEGRAM_CHAT_IDS + + url = f"https://api.telegram.org/bot{token}/sendMessage" + base_payload = { + "text": message_html, + "parse_mode": "HTML", + "disable_web_page_preview": True, + } + + sent_ok = False + with requests.Session() as s: + for chat_id in chat_ids: + payload = dict(base_payload) + payload["chat_id"] = chat_id + try: + resp = s.post(url, json=payload, timeout=15) + if resp.status_code == 200: + sent_ok = True + else: + LOGGER.error("Telegram error chat_id=%s status=%s body=%s", + chat_id, resp.status_code, resp.text[:500]) + time.sleep(0.25) + except Exception as e: + LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e) + + return sent_ok + + +# ============================================================================= +# STATE +# ============================================================================= +def load_state() -> Dict: + default = { + "alert_active": False, + "locations": {}, # {location_name: {"last_score": 0.0, "last_storm_time": None}} + } + if os.path.exists(STATE_FILE): + try: + with open(STATE_FILE, "r", encoding="utf-8") as f: + data = json.load(f) or {} + default.update(data) + except Exception as e: + LOGGER.exception("State read error: %s", e) + return default + + +def save_state(state: Dict) -> None: + try: + ensure_parent_dir(STATE_FILE) + with open(STATE_FILE, "w", encoding="utf-8") as f: + json.dump(state, f, ensure_ascii=False, indent=2) + except Exception as e: + LOGGER.exception("State write error: %s", e) + + +# ============================================================================= +# OPEN-METEO +# ============================================================================= +def fetch_forecast(models_value: str, lat: float, lon: float) -> Optional[Dict]: + params = { + "latitude": lat, + "longitude": lon, + "hourly": "precipitation,wind_gusts_10m,weather_code,cape", + "timezone": TZ, + "forecast_days": 2, + "wind_speed_unit": "kmh", + "precipitation_unit": "mm", + "models": models_value, + } + + # Aggiungi CAPE e parametri convettivi + if models_value == MODEL_PRIMARY or models_value == MODEL_FALLBACK: + params["hourly"] += ",convective_inhibition" + elif models_value == MODEL_ICON_IT: + params["hourly"] += ",cape" # ICON potrebbe avere CAPE + + try: + r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25) + if r.status_code == 400: + try: + j = r.json() + LOGGER.error("Open-Meteo 400 (models=%s, lat=%.4f, lon=%.4f): %s", + models_value, lat, lon, j.get("reason", j)) + except Exception: + LOGGER.error("Open-Meteo 400 (models=%s): %s", models_value, r.text[:500]) + return None + r.raise_for_status() + return r.json() + except Exception as e: + LOGGER.exception("Open-Meteo request error (models=%s, lat=%.4f, lon=%.4f): %s", + models_value, lat, lon, e) + return None + + +def get_forecast(lat: float, lon: float) -> Tuple[Optional[Dict], Optional[Dict], str]: + """Ritorna (arome_data, icon_data, model_used)""" + LOGGER.debug("Requesting Open-Meteo for lat=%.4f lon=%.4f", lat, lon) + + # Prova AROME Seamless + data_arome = fetch_forecast(MODEL_PRIMARY, lat, lon) + model_used = MODEL_PRIMARY + if data_arome is None: + LOGGER.warning("Primary model failed (%s). Trying fallback=%s", MODEL_PRIMARY, MODEL_FALLBACK) + data_arome = fetch_forecast(MODEL_FALLBACK, lat, lon) + model_used = MODEL_FALLBACK + + # Prova ICON Italia per LPI + data_icon = fetch_forecast(MODEL_ICON_IT, lat, lon) + + return data_arome, data_icon, model_used + + +# ============================================================================= +# CONVECTIVE STORM ANALYSIS (from severe_weather.py) +# ============================================================================= +def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[str], + start_idx: int, end_idx: int) -> List[Dict]: + """Analizza il potenziale di temporali severi combinando dati ICON Italia e AROME Seamless.""" + if not icon_data or not arome_data: + return [] + + icon_hourly = icon_data.get("hourly", {}) or {} + arome_hourly = arome_data.get("hourly", {}) or {} + + icon_times = icon_hourly.get("time", []) or [] + icon_lpi = (icon_hourly.get("lightning_potential_index", []) or + icon_hourly.get("lightning_potential", []) or + icon_hourly.get("lpi", []) or []) + + icon_cape = icon_hourly.get("cape", []) or [] + if not icon_lpi and icon_cape: + icon_lpi = [1.0 if (cape is not None and float(cape) > 800) else 0.0 for cape in icon_cape] + + arome_cape = arome_hourly.get("cape", []) or [] + arome_gusts = arome_hourly.get("wind_gusts_10m", []) or [] + arome_precip = arome_hourly.get("precipitation", []) or [] + + results = [] + + # Pre-calcola somme precipitazione + arome_precip_3h = [] + for i in range(len(arome_precip)): + if i < 2: + arome_precip_3h.append(0.0) + else: + try: + sum_3h = sum(float(arome_precip[j]) for j in range(i-2, i+1) if arome_precip[j] is not None) + arome_precip_3h.append(sum_3h) + except Exception: + arome_precip_3h.append(0.0) + + # Pre-calcola somma 24h per rischio alluvioni + arome_precip_24h = [] + for i in range(len(arome_precip)): + if i < 23: + arome_precip_24h.append(0.0) + else: + try: + sum_24h = sum(float(arome_precip[j]) for j in range(i-23, i+1) if arome_precip[j] is not None) + arome_precip_24h.append(sum_24h) + except Exception: + arome_precip_24h.append(0.0) + + # Analizza ogni ora + for i in range(start_idx, min(end_idx, len(times_base), len(arome_cape), len(arome_gusts), len(arome_precip))): + if i >= len(times_base): + break + + try: + cape_val = float(arome_cape[i]) if i < len(arome_cape) and arome_cape[i] is not None else 0.0 + gusts_val = float(arome_gusts[i]) if i < len(arome_gusts) and arome_gusts[i] is not None else 0.0 + precip_val = float(arome_precip[i]) if i < len(arome_precip) and arome_precip[i] is not None else 0.0 + precip_3h_val = arome_precip_3h[i] if i < len(arome_precip_3h) else 0.0 + precip_24h_val = arome_precip_24h[i] if i < len(arome_precip_24h) else 0.0 + except (ValueError, TypeError, IndexError): + continue + + lpi_val = 0.0 + if i < len(icon_times) and i < len(icon_lpi): + try: + icon_time = parse_time_to_local(icon_times[i]) + arome_time = parse_time_to_local(times_base[i]) + time_diff = abs((icon_time - arome_time).total_seconds() / 60) + if time_diff < 30: + lpi_val = float(icon_lpi[i]) if icon_lpi[i] is not None else 0.0 + except (ValueError, TypeError, IndexError): + pass + + # Calcola Storm Severity Score + score = 0.0 + threats = [] + + if cape_val > 0: + cape_score = min(40.0, (cape_val / 2000.0) * 40.0) + score += cape_score + + if lpi_val > 0: + if lpi_val == 1.0: + lpi_score = 20.0 + else: + lpi_score = min(30.0, lpi_val * 10.0) + score += lpi_score + + if gusts_val > WIND_GUST_DOWNBURST_THRESHOLD and precip_val > 0.1: + dynamic_score = min(30.0, ((gusts_val - WIND_GUST_DOWNBURST_THRESHOLD) / 40.0) * 30.0) + score += dynamic_score + + # Identifica minacce + if cape_val > CAPE_LIGHTNING_THRESHOLD and lpi_val > 0: + threats.append("Fulminazioni") + + if cape_val > CAPE_SEVERE_THRESHOLD and gusts_val > WIND_GUST_DOWNBURST_THRESHOLD: + threats.append("Downburst/Temporale violento") + + if precip_val > RAIN_INTENSE_THRESHOLD_H or precip_3h_val > RAIN_INTENSE_THRESHOLD_3H: + threats.append("Nubifragio") + + # Rischio alluvioni: precipitazioni intense e prolungate (accumulo 24h > 100mm) + if precip_24h_val > RAIN_FLOOD_THRESHOLD_24H: + threats.append("Rischio Alluvioni") + # Bonus al score per rischio alluvioni + flood_bonus = min(10.0, (precip_24h_val - RAIN_FLOOD_THRESHOLD_24H) / 10.0) + score += flood_bonus + + if score >= STORM_SCORE_THRESHOLD or threats: + results.append({ + "timestamp": times_base[i], + "score": score, + "threats": threats, + "cape": cape_val, + "lpi": lpi_val, + "gusts": gusts_val, + "precip": precip_val, + "precip_3h": precip_3h_val, + "precip_24h": precip_24h_val, + }) + + return results + + +# ============================================================================= +# MESSAGE FORMATTING +# ============================================================================= +def format_location_alert(location_name: str, storm_events: List[Dict]) -> str: + """Formatta alert per una singola località.""" + if not storm_events: + return "" + + max_score = max(e["score"] for e in storm_events) + first_time = parse_time_to_local(storm_events[0]["timestamp"]) + last_time = parse_time_to_local(storm_events[-1]["timestamp"]) + duration_hours = len(storm_events) + + # Raggruppa minacce + all_threats = set() + for event in storm_events: + all_threats.update(event.get("threats", [])) + + threats_str = ", ".join(all_threats) if all_threats else "Temporali severi" + + max_cape = max(e["cape"] for e in storm_events) + max_precip_24h = max((e.get("precip_24h", 0) for e in storm_events), default=0) + + msg = ( + f"📍 {html.escape(location_name)}\n" + f"📊 Score: {max_score:.0f}/100 | {threats_str}\n" + f"🕒 {ddmmyyhhmm(first_time)} - {ddmmyyhhmm(last_time)} (~{duration_hours}h)\n" + f"⚡ CAPE max: {max_cape:.0f} J/kg" + ) + + if max_precip_24h > RAIN_FLOOD_THRESHOLD_24H: + msg += f" | 💧 Accumulo 24h: {max_precip_24h:.1f} mm ⚠️" + + return msg + + +def format_circondario_alert(locations_data: Dict[str, List[Dict]]) -> str: + """Formatta alert aggregato per tutto il circondario.""" + if not locations_data: + return "" + + headline = "⛈️ ALLERTA TEMPORALI SEVERI - CIRCONDARIO" + + # Statistiche aggregate + total_locations = len(locations_data) + max_score_overall = max( + max((e["score"] for e in events), default=0) + for events in locations_data.values() + ) + + # Trova prima e ultima occorrenza + all_times = [] + for events in locations_data.values(): + for event in events: + all_times.append(parse_time_to_local(event["timestamp"])) + + if all_times: + first_time_overall = min(all_times) + last_time_overall = max(all_times) + period_str = f"{ddmmyyhhmm(first_time_overall)} - {ddmmyyhhmm(last_time_overall)}" + else: + period_str = "N/A" + + meta = ( + f"📍 {total_locations} località con rischio temporali severi\n" + f"📊 Storm Severity Score max: {max_score_overall:.0f}/100\n" + f"🕒 Periodo: {period_str}\n" + f"🛰️ Modelli: AROME Seamless + ICON Italia\n" + ) + + # Lista località + location_parts = [] + for loc_name, events in sorted(locations_data.items()): + loc_msg = format_location_alert(loc_name, events) + if loc_msg: + location_parts.append(loc_msg) + + body = "\n\n".join(location_parts) + footer = "\n\nFonte dati: Open-Meteo | Analisi nowcasting temporali severi" + + return f"{headline}\n{meta}\n{body}{footer}" + + +# ============================================================================= +# MAIN ANALYSIS +# ============================================================================= +def analyze_location(location: Dict) -> Optional[List[Dict]]: + """Analizza rischio temporali severi per una singola località.""" + name = location["name"] + lat = location["lat"] + lon = location["lon"] + + LOGGER.debug("Analizzando %s (%.4f, %.4f)", name, lat, lon) + + data_arome, data_icon, model_used = get_forecast(lat, lon) + if not data_arome: + LOGGER.warning("Nessun dato AROME per %s", name) + return None + + hourly_arome = (data_arome.get("hourly", {}) or {}) + times = hourly_arome.get("time", []) or [] + + if not times: + LOGGER.warning("Nessun timestamp per %s", name) + return None + + # Trova finestra temporale + now = now_local() + start_idx = -1 + for i, t in enumerate(times): + if parse_time_to_local(t) >= now: + start_idx = i + break + + if start_idx == -1: + LOGGER.warning("Nessun indice di partenza valido per %s", name) + return None + + end_idx = min(start_idx + HOURS_AHEAD, len(times)) + + if data_icon: + storm_events = analyze_convective_risk(data_icon, data_arome, times, start_idx, end_idx) + if DEBUG and storm_events: + LOGGER.debug(" %s: %d eventi rilevati", name, len(storm_events)) + return storm_events + else: + LOGGER.warning("Nessun dato ICON per %s, analisi convettiva limitata", name) + return None + + +def analyze_all_locations(debug_mode: bool = False) -> None: + """Analizza tutte le località del circondario.""" + LOGGER.info("=== Analisi Temporali Severi - Circondario ===") + + state = load_state() + was_alert_active = bool(state.get("alert_active", False)) + + locations_with_risk = {} + + for location in LOCALITA_CIRCONDARIO: + name = location["name"] + storm_events = analyze_location(location) + + if storm_events: + locations_with_risk[name] = storm_events + max_score = max(e["score"] for e in storm_events) + + # Controlla se è un nuovo evento o peggioramento + loc_state = state.get("locations", {}).get(name, {}) + prev_score = float(loc_state.get("last_score", 0.0) or 0.0) + + if debug_mode or not loc_state.get("alert_sent", False) or (max_score >= prev_score + 15.0): + # Aggiorna stato + if "locations" not in state: + state["locations"] = {} + state["locations"][name] = { + "last_score": float(max_score), + "alert_sent": True, + "last_storm_time": storm_events[0]["timestamp"] + } + + time.sleep(0.5) # Rate limiting per API + + # Invia alert se ci sono località a rischio + if locations_with_risk or debug_mode: + if locations_with_risk: + msg = format_circondario_alert(locations_with_risk) + if msg: + ok = telegram_send_html(msg) + if ok: + LOGGER.info("Alert inviato per %d località", len(locations_with_risk)) + else: + LOGGER.warning("Alert NON inviato (token missing o errore Telegram)") + + state["alert_active"] = True + save_state(state) + elif debug_mode: + # In modalità debug, invia messaggio anche senza rischi + msg = ( + "ℹ️ ANALISI CIRCONDARIO - Nessun Rischio\n" + f"📍 Analizzate {len(LOCALITA_CIRCONDARIO)} località\n" + f"🕒 Finestra: prossime {HOURS_AHEAD} ore\n" + "Nessun temporale severo previsto nel circondario." + ) + telegram_send_html(msg) + LOGGER.info("Messaggio debug inviato (nessun rischio)") + + # All-clear se era attivo e ora non c'è più rischio + if was_alert_active and not locations_with_risk: + msg = ( + "🟢 ALLERTA TEMPORALI SEVERI - RIENTRATA\n" + "Condizioni rientrate sotto le soglie di guardia per tutte le località del circondario." + ) + telegram_send_html(msg) + LOGGER.info("All-clear inviato") + + state["alert_active"] = False + state["locations"] = {} + save_state(state) + elif not locations_with_risk: + state["alert_active"] = False + save_state(state) + + +if __name__ == "__main__": + arg_parser = argparse.ArgumentParser(description="Severe weather alert - Circondario") + arg_parser.add_argument("--debug", action="store_true", + help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0]) + args = arg_parser.parse_args() + + chat_ids = None + if args.debug: + chat_ids = [TELEGRAM_CHAT_IDS[0]] + + analyze_all_locations(debug_mode=args.debug) diff --git a/services/telegram-bot/smart_irrigation_advisor.py b/services/telegram-bot/smart_irrigation_advisor.py new file mode 100755 index 0000000..e2d43d5 --- /dev/null +++ b/services/telegram-bot/smart_irrigation_advisor.py @@ -0,0 +1,1525 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Smart Irrigation Advisor - Consulente Agronomico "Smart Season" +Fornisce consigli pragmatici per la gestione stagionale dell'irrigazione del giardino +basati su dati meteo e stato del suolo. +""" + +import argparse +import datetime +import json +import logging +import os +import sys +from logging.handlers import RotatingFileHandler +from typing import Dict, List, Optional, Tuple +from zoneinfo import ZoneInfo + +import requests +from dateutil import parser + +# ============================================================================= +# CONFIGURATION +# ============================================================================= + +DEBUG = os.environ.get("DEBUG", "0").strip() == "1" + +# Location +DEFAULT_LAT = 43.9356 +DEFAULT_LON = 12.4296 +DEFAULT_LOCATION_NAME = "🏠 Casa" + +# Timezone +TZ = "Europe/Berlin" +TZINFO = ZoneInfo(TZ) + +# Open-Meteo +OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" +MODEL_AROME = "meteofrance_seamless" +MODEL_ICON = "italia_meteo_arpae_icon_2i" +HTTP_HEADERS = {"User-Agent": "Smart-Irrigation-Advisor/1.0"} + +# Files +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +LOG_FILE = os.path.join(BASE_DIR, "irrigation_advisor.log") +STATE_FILE = os.path.join(BASE_DIR, "irrigation_state.json") + +# Telegram (opzionale, per integrazione bot) +TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"] +TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token") +TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token" + +# Soglie Agronomiche +SOIL_TEMP_WAKEUP_THRESHOLD = 10.0 # °C - Soglia di risveglio vegetativo +SOIL_TEMP_WAKEUP_DAYS_MIN = 3 # Giorni consecutivi minimi per risveglio +SOIL_TEMP_WAKEUP_DAYS_MAX = 5 # Giorni consecutivi massimi per risveglio +SOIL_TEMP_SHUTDOWN_THRESHOLD = 10.0 # °C - Soglia di chiusura autunnale +SOIL_TEMP_WAKEUP_INDICATOR = 8.0 # °C - Soglia indicatore di avvicinamento al risveglio (sblocca report) +SOIL_MOISTURE_FIELD_CAPACITY = 0.6 # Capacità di campo (60% - valore tipico per terreno medio) +SOIL_MOISTURE_WILTING_POINT = 0.3 # Punto di avvizzimento (30%) +SOIL_MOISTURE_AUTUMN_HIGH = 0.8 # 80% - Umidità alta in autunno +SOIL_MOISTURE_DEEP_STRESS = 0.35 # 35% - Umidità profonda critica (vicina a punto di avvizzimento) +PRECIP_THRESHOLD_SIGNIFICANT = 5.0 # mm - Pioggia significativa +AUTUMN_HIGH_MOISTURE_DAYS = 10 # Giorni consecutivi con umidità alta per chiusura + +# ============================================================================= +# CLASSIFICAZIONE VALORI PARAMETRI +# ============================================================================= +# Soglie per classificare i parametri come bassi, medio/bassi, medi, alti, medio/alti + +# Evapotraspirazione (ET₀) - mm/d +ET0_LOW = 2.0 # < 2.0 mm/d = basso +ET0_MEDIUM_LOW = 3.5 # 2.0-3.5 mm/d = medio/basso +ET0_MEDIUM_HIGH = 5.0 # 3.5-5.0 mm/d = medio/alto +# > 5.0 mm/d = alto + +# Temperatura suolo - °C +SOIL_TEMP_LOW = 5.0 # < 5°C = basso +SOIL_TEMP_MEDIUM_LOW = 10.0 # 5-10°C = medio/basso +SOIL_TEMP_MEDIUM_HIGH = 15.0 # 10-15°C = medio/alto +# > 15°C = alto + +# Umidità suolo - frazione (0-1) +SOIL_MOISTURE_LOW = 0.3 # < 0.3 (30%) = basso (punto di avvizzimento) +SOIL_MOISTURE_MEDIUM_LOW = 0.5 # 0.3-0.5 (30-50%) = medio/basso +SOIL_MOISTURE_MEDIUM_HIGH = 0.7 # 0.5-0.7 (50-70%) = medio/alto +# > 0.7 (70%) = alto (vicino a capacità di campo) + +# VPD - kPa +VPD_LOW = 0.5 # < 0.5 kPa = basso (umido) +VPD_MEDIUM_LOW = 0.8 # 0.5-0.8 kPa = medio/basso +VPD_MEDIUM_HIGH = 1.2 # 0.8-1.2 kPa = medio/alto +# > 1.2 kPa = alto (secco, stress idrico) + +# Sunshine duration - ore/giorno +SUNSHINE_LOW = 4.0 # < 4h = basso +SUNSHINE_MEDIUM_LOW = 6.0 # 4-6h = medio/basso +SUNSHINE_MEDIUM_HIGH = 8.0 # 6-8h = medio/alto +# > 8h = alto + +# Precipitazioni - mm/giorno +PRECIP_DAILY_LOW = 2.0 # < 2mm/giorno = basso +PRECIP_DAILY_MEDIUM_LOW = 5.0 # 2-5mm/giorno = medio/basso +PRECIP_DAILY_MEDIUM_HIGH = 15.0 # 5-15mm/giorno = medio/alto +# > 15mm/giorno = alto + +# ============================================================================= +# LOGGING +# ============================================================================= + +def setup_logger() -> logging.Logger: + logger = logging.getLogger("irrigation_advisor") + logger.setLevel(logging.DEBUG if DEBUG else logging.INFO) + logger.handlers.clear() + + fh = RotatingFileHandler(LOG_FILE, maxBytes=500_000, backupCount=3, encoding="utf-8") + fh.setLevel(logging.DEBUG) + fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s") + fh.setFormatter(fmt) + logger.addHandler(fh) + + if DEBUG: + sh = logging.StreamHandler() + sh.setLevel(logging.DEBUG) + sh.setFormatter(fmt) + logger.addHandler(sh) + + return logger + + +LOGGER = setup_logger() + + +# ============================================================================= +# UTILITIES +# ============================================================================= + +def now_local() -> datetime.datetime: + return datetime.datetime.now(TZINFO) + + +def parse_time_to_local(t: str) -> datetime.datetime: + dt = parser.isoparse(t) + if dt.tzinfo is None: + return dt.replace(tzinfo=TZINFO) + return dt.astimezone(TZINFO) + + +def ensure_parent_dir(path: str) -> None: + parent = os.path.dirname(path) + if parent and not os.path.exists(parent): + os.makedirs(parent, exist_ok=True) + + +def read_text_file(path: str) -> str: + try: + with open(path, "r", encoding="utf-8") as f: + return f.read().strip() + except FileNotFoundError: + return "" + except Exception as e: + LOGGER.debug("Error reading %s: %s", path, e) + return "" + + +def load_bot_token() -> str: + tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() + if tok: + return tok + tok = read_text_file(TOKEN_FILE_HOME) + if tok: + return tok + tok = read_text_file(TOKEN_FILE_ETC) + return tok.strip() if tok else "" + + +# ============================================================================= +# STATE MANAGEMENT +# ============================================================================= + +def load_state() -> Dict: + default = { + "phase": "unknown", # "wakeup", "active", "shutdown", "dormant" + "last_check": None, + "soil_temp_history": [], # Lista di (date, temp_6cm) + "soil_moisture_history": [], # Lista di (date, moisture_3_9cm, moisture_9_27cm) + "high_moisture_streak": 0, # Giorni consecutivi con umidità alta (per fase shutdown) + "auto_reporting_enabled": False, # Se True, i report automatici sono attivi + "wakeup_threshold_reached": False, # Se True, abbiamo superato la soglia di risveglio + "shutdown_confirmed": False, # Se True, la chiusura è stata confermata + } + if os.path.exists(STATE_FILE): + try: + with open(STATE_FILE, "r", encoding="utf-8") as f: + data = json.load(f) or {} + default.update(data) + except Exception as e: + LOGGER.exception("State read error: %s", e) + return default + + +def save_state(state: Dict) -> None: + try: + ensure_parent_dir(STATE_FILE) + with open(STATE_FILE, "w", encoding="utf-8") as f: + json.dump(state, f, ensure_ascii=False, indent=2) + except Exception as e: + LOGGER.exception("State write error: %s", e) + + +# ============================================================================= +# OPEN-METEO API +# ============================================================================= + +def fetch_soil_and_weather(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]: + """ + Recupera dati suolo e meteo da Open-Meteo. + Nota: I parametri del suolo potrebbero non essere disponibili per tutte le località. + In caso di errore, restituisce None. + """ + params = { + "latitude": lat, + "longitude": lon, + "timezone": timezone, + "forecast_days": 5, # 5 giorni per previsioni pioggia + "hourly": ",".join([ + # Parametri suolo ICON Italia + "soil_temperature_0cm", + "soil_temperature_54cm", + "soil_moisture_0_to_1cm", + "soil_moisture_81_to_243cm", + # Meteo base + "precipitation", + "snowfall", + "temperature_2m", + # Evapotraspirazione e stress idrico + "et0_fao_evapotranspiration", + "vapour_pressure_deficit", + # Parametri irraggiamento solare + "direct_radiation", + "diffuse_radiation", + "shortwave_radiation", # GHI - Global Horizontal Irradiance (energia totale per fotosintesi) + "sunshine_duration", + ]), + "daily": ",".join([ + "precipitation_sum", + "snowfall_sum", + "et0_fao_evapotranspiration_sum", + "sunshine_duration", + ]), + "models": MODEL_ICON, # Usa ICON Italia per migliore copertura Europa + } + + try: + r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=30) + if r.status_code == 400: + try: + j = r.json() + reason = j.get("reason", str(j)) + LOGGER.warning("Open-Meteo 400: %s. Parametri suolo potrebbero non essere disponibili per questa località.", reason) + # Prova senza parametri suolo (fallback) + return fetch_weather_only(lat, lon, timezone) + except Exception: + LOGGER.error("Open-Meteo 400: %s", r.text[:500]) + return fetch_weather_only(lat, lon, timezone) + r.raise_for_status() + data = r.json() + + # Verifica che i dati del suolo siano presenti (almeno alcuni valori non-None) + hourly = data.get("hourly", {}) or {} + # ICON Italia usa soil_temperature_0cm e soil_temperature_54cm + soil_temp_0 = hourly.get("soil_temperature_0cm", []) or [] + soil_temp_54 = hourly.get("soil_temperature_54cm", []) or [] + # Controlla se ci sono almeno alcuni valori non-None + has_soil_data = any(v is not None for v in soil_temp_0[:24]) or any(v is not None for v in soil_temp_54[:24]) + if not has_soil_data: + LOGGER.warning("Dati suolo non disponibili (tutti None). Uso fallback meteo-only.") + return fetch_weather_only(lat, lon, timezone) + + return data + except Exception as e: + LOGGER.exception("Open-Meteo request error: %s", e) + return fetch_weather_only(lat, lon, timezone) + + +def fetch_weather_only(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]: + """Fallback: recupera solo dati meteo (senza parametri suolo).""" + params = { + "latitude": lat, + "longitude": lon, + "timezone": timezone, + "forecast_days": 5, + "hourly": ",".join([ + "precipitation", + "snowfall", + "et0_fao_evapotranspiration", + "temperature_2m", + ]), + "daily": ",".join([ + "precipitation_sum", + "snowfall_sum", + "et0_fao_evapotranspiration_sum", + ]), + "models": MODEL_ICON, + } + + try: + r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=30) + r.raise_for_status() + return r.json() + except Exception as e: + LOGGER.exception("Open-Meteo weather-only request error: %s", e) + return None + + +# ============================================================================= +# SEASONAL PHASE DETECTION +# ============================================================================= + +def determine_seasonal_phase( + month: int, + soil_temp_6cm: Optional[float], + soil_moisture_3_9cm: Optional[float], + soil_moisture_9_27cm: Optional[float], + state: Dict +) -> str: + """ + Determina la fase stagionale: "wakeup", "active", "shutdown", "dormant" + """ + # Primavera (Marzo-Maggio): fase risveglio o attiva + if month in [3, 4, 5]: + # Se temperatura suolo > soglia per X giorni consecutivi -> attiva + # Altrimenti -> wakeup + if soil_temp_6cm is not None and soil_temp_6cm >= SOIL_TEMP_WAKEUP_THRESHOLD: + # Verifica persistenza (dai dati storici o corrente) + temp_history = state.get("soil_temp_history", []) + recent_warm_days = 0 + now = now_local() + for date_str, temp in temp_history: + try: + date_obj = datetime.datetime.fromisoformat(date_str).replace(tzinfo=TZINFO) + days_ago = (now - date_obj).days + if days_ago <= SOIL_TEMP_WAKEUP_DAYS_MAX and temp >= SOIL_TEMP_WAKEUP_THRESHOLD: + recent_warm_days += 1 + except Exception: + continue + + if recent_warm_days >= SOIL_TEMP_WAKEUP_DAYS_MIN - 1 or soil_temp_6cm >= SOIL_TEMP_WAKEUP_THRESHOLD: + return "active" + else: + return "wakeup" + else: + return "wakeup" + + # Estate (Giugno-Agosto): sempre attiva + elif month in [6, 7, 8]: + return "active" + + # Autunno (Settembre-Novembre): attiva o shutdown + elif month in [9, 10, 11]: + if soil_temp_6cm is not None and soil_temp_6cm < SOIL_TEMP_SHUTDOWN_THRESHOLD: + return "shutdown" + elif (soil_moisture_9_27cm is not None and + soil_moisture_9_27cm >= SOIL_MOISTURE_AUTUMN_HIGH): + # Verifica giorni consecutivi con umidità alta + high_streak = state.get("high_moisture_streak", 0) + if high_streak >= AUTUMN_HIGH_MOISTURE_DAYS: + return "shutdown" + return "active" + + # Inverno (Dicembre-Febbraio): dormiente + else: + return "dormant" + + +# ============================================================================= +# CLASSIFICATION HELPERS +# ============================================================================= + +def classify_et0(et0: float) -> str: + """Classifica ET₀ in basso, medio/basso, medio, medio/alto, alto""" + if et0 < ET0_LOW: + return "basso" + elif et0 < ET0_MEDIUM_LOW: + return "medio/basso" + elif et0 < ET0_MEDIUM_HIGH: + return "medio" + elif et0 < 7.0: + return "medio/alto" + else: + return "alto" + + +def classify_soil_temp(temp: float) -> str: + """Classifica temperatura suolo in basso, medio/basso, medio, medio/alto, alto""" + if temp < SOIL_TEMP_LOW: + return "basso" + elif temp < SOIL_TEMP_MEDIUM_LOW: + return "medio/basso" + elif temp < SOIL_TEMP_MEDIUM_HIGH: + return "medio" + elif temp < 20.0: + return "medio/alto" + else: + return "alto" + + +def classify_soil_moisture(moisture: float) -> str: + """Classifica umidità suolo in basso, medio/basso, medio, medio/alto, alto""" + if moisture < SOIL_MOISTURE_LOW: + return "basso" + elif moisture < SOIL_MOISTURE_MEDIUM_LOW: + return "medio/basso" + elif moisture < SOIL_MOISTURE_MEDIUM_HIGH: + return "medio" + elif moisture < 0.85: + return "medio/alto" + else: + return "alto" + + +def classify_vpd(vpd: float) -> str: + """Classifica VPD in basso, medio/basso, medio, medio/alto, alto""" + if vpd < VPD_LOW: + return "basso" + elif vpd < VPD_MEDIUM_LOW: + return "medio/basso" + elif vpd < VPD_MEDIUM_HIGH: + return "medio" + elif vpd < 1.8: + return "medio/alto" + else: + return "alto" + + +def classify_sunshine(hours: float) -> str: + """Classifica ore di sole in basso, medio/basso, medio, medio/alto, alto""" + if hours < SUNSHINE_LOW: + return "basso" + elif hours < SUNSHINE_MEDIUM_LOW: + return "medio/basso" + elif hours < SUNSHINE_MEDIUM_HIGH: + return "medio" + elif hours < 10.0: + return "medio/alto" + else: + return "alto" + + +def classify_precip_daily(precip: float) -> str: + """Classifica precipitazione giornaliera in basso, medio/basso, medio, medio/alto, alto""" + if precip < PRECIP_DAILY_LOW: + return "basso" + elif precip < PRECIP_DAILY_MEDIUM_LOW: + return "medio/basso" + elif precip < PRECIP_DAILY_MEDIUM_HIGH: + return "medio" + elif precip < 30.0: + return "medio/alto" + else: + return "alto" + + +# ============================================================================= +# IRRIGATION LOGIC +# ============================================================================= + +def calculate_water_stress_index( + moisture_3_9cm: Optional[float], + moisture_9_27cm: Optional[float], + vpd_avg: Optional[float] = None +) -> Tuple[float, str]: + """ + Calcola Indice di Stress Idrico (0-100%) usando umidità suolo e VPD. + VPD (Vapour Pressure Deficit) è un ottimo indicatore di stress idrico: + - VPD alto (>1.5 kPa) = stress idrico elevato + - VPD medio (0.8-1.5 kPa) = stress moderato + - VPD basso (<0.8 kPa) = condizioni ottimali + + Returns: (index, level_description) + """ + if moisture_3_9cm is None and moisture_9_27cm is None: + # Se non abbiamo dati umidità, usa solo VPD se disponibile + if vpd_avg is not None: + if vpd_avg > 1.5: + return 85.0, "ROSSO_VPD" + elif vpd_avg > 1.0: + return 60.0, "ARANCIONE_VPD" + elif vpd_avg > 0.8: + return 30.0, "GIALLO_VPD" + else: + return 10.0, "VERDE_VPD" + return 50.0, "UNKNOWN" # Dati non disponibili + + # Usa media pesata (superficie più importante) + if moisture_3_9cm is not None and moisture_9_27cm is not None: + effective_moisture = 0.6 * moisture_3_9cm + 0.4 * moisture_9_27cm + elif moisture_3_9cm is not None: + effective_moisture = moisture_3_9cm + else: + effective_moisture = moisture_9_27cm + + # Calcola indice base rispetto a capacità di campo + if effective_moisture >= SOIL_MOISTURE_FIELD_CAPACITY: + index_base = 0.0 + level = "VERDE" + elif effective_moisture <= SOIL_MOISTURE_WILTING_POINT: + index_base = 100.0 + level = "ROSSO" + else: + # Interpolazione lineare + range_moisture = SOIL_MOISTURE_FIELD_CAPACITY - SOIL_MOISTURE_WILTING_POINT + deficit = SOIL_MOISTURE_FIELD_CAPACITY - effective_moisture + index_base = (deficit / range_moisture) * 100.0 + + if index_base >= 70: + level = "ARANCIONE" + elif index_base >= 40: + level = "GIALLO" + else: + level = "VERDE" + + # Aggiusta indice usando VPD se disponibile + # VPD alto aumenta lo stress percepito, VPD basso lo riduce + final_index = index_base + if vpd_avg is not None: + vpd_factor = 1.0 + if vpd_avg > 1.5: + vpd_factor = 1.3 # Aumenta stress del 30% se VPD molto alto + elif vpd_avg > 1.0: + vpd_factor = 1.15 # Aumenta stress del 15% + elif vpd_avg < 0.8: + vpd_factor = 0.9 # Riduce stress del 10% se VPD basso + + final_index = min(100.0, index_base * vpd_factor) + + # Aggiorna livello se VPD modifica significativamente l'indice + if vpd_avg > 1.5 and level != "ROSSO": + if final_index >= 70: + level = "ARANCIONE_VPD" + if final_index >= 85: + level = "ROSSO_VPD" + elif vpd_avg < 0.8 and index_base > 40: + if final_index < 40: + level = "GIALLO_VPD" + + return final_index, level + + +def check_future_rainfall(daily_data: Dict, days_ahead: int = 5, include_snowfall: bool = True) -> Tuple[float, List[str]]: + """ + Controlla pioggia prevista nei prossimi giorni usando precipitation_sum. + precipitation_sum include già pioggia, neve e temporali (è la somma totale). + Returns: (total_mm, list_of_days_with_precip) + """ + daily_times = daily_data.get("time", []) or [] + # Usa precipitation_sum che include già pioggia, neve e temporali + daily_precip = daily_data.get("precipitation_sum", []) or [] + daily_snowfall = daily_data.get("snowfall_sum", []) or [] # Solo per indicare se c'è neve + + total = 0.0 + rainy_days = [] + + now = now_local() + for i, time_str in enumerate(daily_times[:days_ahead]): + try: + day_time = parse_time_to_local(time_str) + if day_time.date() <= now.date(): + continue # Salta giorni passati + + # precipitation_sum include già tutto (pioggia + neve + temporali) + precip = float(daily_precip[i]) if i < len(daily_precip) and daily_precip[i] is not None else 0.0 + snow = float(daily_snowfall[i]) if (include_snowfall and i < len(daily_snowfall) and daily_snowfall[i] is not None) else 0.0 + + # Usa solo precipitation_sum (non sommare snowfall separatamente, è già incluso) + total_precip = precip + + if total_precip > 0.1: # Almeno 0.1 mm + total += total_precip + if snow > 0.5: # Se c'è neve significativa + rainy_days.append(f"{day_time.strftime('%d/%m')} ({total_precip:.1f}mm, di cui {snow/10:.1f}cm neve)") + else: + rainy_days.append(f"{day_time.strftime('%d/%m')} ({precip:.1f}mm)") + except Exception: + continue + + return total, rainy_days + + +# ============================================================================= +# ADVICE GENERATION +# ============================================================================= + +def generate_wakeup_advice( + soil_temp_6cm: Optional[float], + soil_moisture_3_9cm: Optional[float], + soil_moisture_9_27cm: Optional[float], + future_rain_mm: float, + rainy_days: List[str], + shortwave_avg: Optional[float] = None, + sunshine_hours: Optional[float] = None, + state: Optional[Dict] = None +) -> Dict: + """ + FASE RISVEGLIO: "Quando accendere?" + Trigger: Termico + Energetico + Fotoperiodo + Returns: Dict con season_phase, advice_level, human_message, soil_status_summary + """ + state = state or {} + status = "**Fase: Risveglio Primaverile**" + + # TRIGGER 1: Soglia Termica - Soil Temperature (6cm) > 10°C per 3-5 giorni consecutivi + temp_ok = False + temp_avg_24h = None + if soil_temp_6cm is not None: + # Calcola media 24h (se disponibile storico, altrimenti usa valore corrente) + temp_history = state.get("soil_temp_history", []) + now = now_local() + recent_temps = [] + for date_str, temp in temp_history: + try: + date_obj = datetime.datetime.fromisoformat(date_str).replace(tzinfo=TZINFO) + days_ago = (now - date_obj).days + if days_ago <= 7: # Ultimi 7 giorni + recent_temps.append(temp) + except Exception: + continue + recent_temps.append(soil_temp_6cm) + if recent_temps: + temp_avg_24h = sum(recent_temps) / len(recent_temps) + + # Verifica giorni consecutivi sopra soglia + warm_days = 0 + for date_str, temp in temp_history: + try: + date_obj = datetime.datetime.fromisoformat(date_str).replace(tzinfo=TZINFO) + days_ago = (now - date_obj).days + if days_ago <= SOIL_TEMP_WAKEUP_DAYS_MAX and temp >= SOIL_TEMP_WAKEUP_THRESHOLD: + warm_days += 1 + except Exception: + continue + if soil_temp_6cm >= SOIL_TEMP_WAKEUP_THRESHOLD: + warm_days += 1 + + temp_ok = (temp_avg_24h is not None and temp_avg_24h >= SOIL_TEMP_WAKEUP_THRESHOLD and + warm_days >= SOIL_TEMP_WAKEUP_DAYS_MIN) + + # TRIGGER 2: Energetico - Shortwave Radiation GHI in crescita + energy_ok = False + if shortwave_avg is not None: + # Verifica se GHI mostra trend positivo (semplificato: > 150 W/m² indica buon irraggiamento) + energy_ok = shortwave_avg > 150.0 + + # TRIGGER 3: Fotoperiodo - Sunshine Duration in aumento + photoperiod_ok = False + if sunshine_hours is not None: + # Fotoperiodo adeguato per risveglio (almeno 6-7 ore di sole) + photoperiod_ok = sunshine_hours >= 6.0 + + # Trigger combinati: almeno 2 su 3 devono essere OK (termico è obbligatorio) + triggers_active = temp_ok and (energy_ok or photoperiod_ok) + + # Controlla umidità profonda (9-27cm = radici attive) sotto capacità di campo + moisture_deep_low = False + if soil_moisture_9_27cm is not None: + moisture_deep_low = soil_moisture_9_27cm < SOIL_MOISTURE_WILTING_POINT # < 0.30 m³/m³ + + # Genera consiglio + if triggers_active and moisture_deep_low: + advice_level = "CRITICAL" + advice_msg = "🌱 **SVEGLIA IL SISTEMA**\n\n" + advice_msg += "Tutti i trigger di risveglio sono attivi:\n" + if temp_ok: + advice_msg += f"• Temperatura suolo stabile ≥{SOIL_TEMP_WAKEUP_THRESHOLD}°C\n" + if energy_ok: + advice_msg += f"• Irraggiamento solare adeguato ({shortwave_avg:.0f} W/m²)\n" + if photoperiod_ok: + advice_msg += f"• Fotoperiodo sufficiente ({sunshine_hours:.1f}h di sole)\n" + advice_msg += f"\nIl terreno profondo (9-27cm) si sta asciugando ({soil_moisture_9_27cm*100:.0f}% < capacità di campo). " + if future_rain_mm < PRECIP_THRESHOLD_SIGNIFICANT: + advice_msg += "Nessuna pioggia significativa prevista.\n\n" + else: + advice_msg += f"Pioggia prevista: {future_rain_mm:.1f}mm nei prossimi giorni.\n\n" + advice_msg += "**Consigliato**: Primo ciclo di test/attivazione dell'impianto di irrigazione." + elif not temp_ok: + advice_level = "NO_ACTION" + advice_msg = "💤 **DORMI ANCORA**\n\n" + if soil_temp_6cm is not None: + advice_msg += f"Trigger termico non soddisfatto: temperatura suolo {soil_temp_6cm:.1f}°C < {SOIL_TEMP_WAKEUP_THRESHOLD}°C (soglia risveglio). " + else: + advice_msg += "Temperatura suolo non disponibile. " + advice_msg += "Le piante sono ancora in riposo vegetativo. Attendi che il terreno si scaldi stabilmente." + elif not moisture_deep_low: + advice_level = "NO_ACTION" + advice_msg = "💤 **DORMI ANCORA**\n\n" + if soil_moisture_9_27cm is not None: + advice_msg += f"Terreno profondo (9-27cm) ancora sufficientemente umido ({soil_moisture_9_27cm*100:.0f}%). " + advice_msg += "Nessuna necessità di irrigazione al momento." + else: + advice_level = "NO_ACTION" + advice_msg = "💤 **DORMI ANCORA**\n\n" + advice_msg += "Trigger energetici o fotoperiodo non ancora sufficienti. " + if future_rain_mm >= PRECIP_THRESHOLD_SIGNIFICANT: + advice_msg += f"Pioggia prevista: {future_rain_mm:.1f}mm nei prossimi giorni. " + if rainy_days: + advice_msg += f"Giorni: {', '.join(rainy_days)}.\n\n" + advice_msg += "Attendi condizioni più favorevoli prima di attivare l'impianto." + + # Soil status summary + soil_summary_parts = [] + if soil_temp_6cm is not None: + soil_summary_parts.append(f"T.Suolo 6cm: {soil_temp_6cm:.1f}°C") + if soil_moisture_9_27cm is not None: + soil_summary_parts.append(f"Umidità Radici (9-27cm): {soil_moisture_9_27cm*100:.0f}%") + soil_status_summary = " | ".join(soil_summary_parts) if soil_summary_parts else "Dati suolo non disponibili" + + return { + "season_phase": "AWAKENING", + "advice_level": advice_level, + "human_message": advice_msg, + "soil_status_summary": soil_status_summary, + "status_display": status + } + + +def generate_active_advice( + soil_moisture_0_1cm: Optional[float], + soil_moisture_3_9cm: Optional[float], + soil_moisture_9_27cm: Optional[float], + soil_moisture_27_81cm: Optional[float], # Riserva profonda (se disponibile) + future_rain_mm: float, + rainy_days: List[str], + et0_avg: Optional[float], + next_2_days_rain: float, + vpd_avg: Optional[float] = None +) -> Dict: + """ + FASE ATTIVA: "Quanto irrigare?" + Analisi stratificata: ignora 0-1cm, monitora "Cuore" (3-9cm e 9-27cm) e "Riserva" (27-81cm) + Returns: Dict con season_phase, advice_level, human_message, soil_status_summary + """ + status = "**Fase: Piena Stagione (Primavera/Estate)**" + + # Analisi stratificata - ignora fluttuazioni superficiali (0-1cm) + # Calcola media ponderata del "Cuore" del sistema (3-9cm e 9-27cm) + heart_moisture = None + if soil_moisture_3_9cm is not None and soil_moisture_9_27cm is not None: + # Media ponderata: 9-27cm più importante (60%) + heart_moisture = 0.4 * soil_moisture_3_9cm + 0.6 * soil_moisture_9_27cm + elif soil_moisture_9_27cm is not None: + heart_moisture = soil_moisture_9_27cm + elif soil_moisture_3_9cm is not None: + heart_moisture = soil_moisture_3_9cm + + # Monitora la "Riserva" profonda (27-81cm) - se questa cala, è allarme rosso + reserve_depleting = False + if soil_moisture_27_81cm is not None: + # Se la riserva scende sotto 40%, è critico + reserve_depleting = soil_moisture_27_81cm < 0.40 + + # Calcola fabbisogno idrico basato su ET₀ + daily_water_demand = et0_avg if et0_avg is not None else 0.0 + estimated_deficit = daily_water_demand * 2.0 # Fabbisogno stimato 2 giorni (approssimativo) + + # Confronta con precipitazioni previste + rain_covers_demand = next_2_days_rain > estimated_deficit + + # LOGIC DECISIONALE - 4 livelli + + # 🔴 CRITICO (Deep Stress) + is_critical = False + if heart_moisture is not None: + # Umidità 9-27cm vicina al punto di avvizzimento O Riserva in calo + if (soil_moisture_9_27cm is not None and + soil_moisture_9_27cm <= SOIL_MOISTURE_DEEP_STRESS): + is_critical = True + elif reserve_depleting: + is_critical = True + + if is_critical and daily_water_demand > 3.0 and future_rain_mm < PRECIP_THRESHOLD_SIGNIFICANT: + advice_level = "CRITICAL" + advice_msg = "🔴 **LIVELLO CRITICO (Deep Stress)**\n\n" + if soil_moisture_9_27cm is not None and soil_moisture_9_27cm <= SOIL_MOISTURE_DEEP_STRESS: + advice_msg += f"Umidità profonda (9-27cm) critica: {soil_moisture_9_27cm*100:.0f}% (vicina al punto di avvizzimento). " + if reserve_depleting: + advice_msg += f"Riserva profonda (27-81cm) in calo: {soil_moisture_27_81cm*100:.0f}%. " + advice_msg += f"ET₀ elevato ({daily_water_demand:.1f} mm/d). Nessuna pioggia prevista.\n\n" + advice_msg += "**Emergenza**: Irrigazione profonda necessaria **subito**." + + # 🟠 STANDARD (Maintenance) + elif (heart_moisture is not None and + heart_moisture < SOIL_MOISTURE_FIELD_CAPACITY * 0.8 and + (soil_moisture_9_27cm is None or soil_moisture_9_27cm > SOIL_MOISTURE_DEEP_STRESS)): + advice_level = "STANDARD" + advice_msg = "🟠 **LIVELLO STANDARD (Maintenance)**\n\n" + advice_msg += "Umidità superficiale (3-9cm) bassa, ma profonda (9-27cm) ok. " + if et0_avg is not None: + advice_msg += f"ET₀ moderato ({et0_avg:.1f} mm/d). " + if rain_covers_demand: + advice_msg += f"Pioggia prevista domani/dopodomani ({next_2_days_rain:.1f}mm) dovrebbe coprire il fabbisogno.\n\n" + advice_msg += "**Consiglio**: Attendi le precipitazioni, poi valuta." + else: + advice_msg += "Nessuna pioggia sufficiente prevista a breve.\n\n" + advice_msg += "**Routine**: Ciclo standard consigliato stasera o domattina." + + # 🟡 LIGHT (Surface Dry) + elif (soil_moisture_0_1cm is not None and soil_moisture_0_1cm < 0.5 and + heart_moisture is not None and heart_moisture >= SOIL_MOISTURE_FIELD_CAPACITY * 0.8): + advice_level = "LIGHT" + advice_msg = "🟡 **LIVELLO LIGHT (Surface Dry)**\n\n" + advice_msg += "Solo strati superficiali (0-3cm) secchi, radici profonde (9-27cm) ok. " + if future_rain_mm >= PRECIP_THRESHOLD_SIGNIFICANT: + advice_msg += f"Pioggia prevista: {future_rain_mm:.1f}mm.\n\n" + advice_msg += "**Opzionale**: Breve rinfrescata o attendi precipitazioni." + else: + advice_msg += "\n\n**Opzionale**: Breve rinfrescata superficiale o attendi domani." + + # 🟢 STOP (Saturated/Rain) + else: + advice_level = "NO_ACTION" + advice_msg = "🟢 **LIVELLO STOP (Saturated/Rain)**\n\n" + if heart_moisture is not None and heart_moisture >= SOIL_MOISTURE_FIELD_CAPACITY * 0.9: + advice_msg += "Terreno saturo o molto umido. " + if rain_covers_demand or future_rain_mm >= PRECIP_THRESHOLD_SIGNIFICANT: + advice_msg += f"Pioggia prevista ({future_rain_mm:.1f}mm) > fabbisogno calcolato ({estimated_deficit:.1f}mm). " + if rainy_days: + advice_msg += f"Giorni: {', '.join(rainy_days)}. " + advice_msg += "\n\n**Stop**: Non irrigare, ci pensa la natura." + + # Soil status summary + soil_summary_parts = [] + if soil_moisture_3_9cm is not None: + soil_summary_parts.append(f"Umidità 3-9cm: {soil_moisture_3_9cm*100:.0f}%") + if soil_moisture_9_27cm is not None: + soil_summary_parts.append(f"Umidità 9-27cm: {soil_moisture_9_27cm*100:.0f}%") + if soil_moisture_27_81cm is not None: + soil_summary_parts.append(f"Riserva 27-81cm: {soil_moisture_27_81cm*100:.0f}%") + if et0_avg is not None: + soil_summary_parts.append(f"ET₀: {et0_avg:.1f}mm/d") + soil_status_summary = " | ".join(soil_summary_parts) if soil_summary_parts else "Dati limitati" + + return { + "season_phase": "ACTIVE", + "advice_level": advice_level, + "human_message": advice_msg, + "soil_status_summary": soil_status_summary, + "status_display": status + } + + +def generate_shutdown_advice( + soil_temp_6cm: Optional[float], + soil_moisture_9_27cm: Optional[float], + high_moisture_streak: int, + sunshine_hours: Optional[float] = None, + shortwave_avg: Optional[float] = None, + state: Optional[Dict] = None +) -> Dict: + """ + FASE CHIUSURA: "Quando spegnere?" + Trigger: Crollo Termico + Segnale Luce + Saturazione + Returns: Dict con season_phase, advice_level, human_message, soil_status_summary + """ + state = state or {} + status = "**Fase: Chiusura Autunnale**" + + # TRIGGER 1: Crollo Termico - Soil Temperature (6cm) < 10°C stabilmente + temp_below = False + if soil_temp_6cm is not None: + temp_below = soil_temp_6cm < SOIL_TEMP_SHUTDOWN_THRESHOLD + # Verifica se è stabile (controlla storico) + temp_history = state.get("soil_temp_history", []) + now = now_local() + recent_below_count = 0 + for date_str, temp in temp_history: + try: + date_obj = datetime.datetime.fromisoformat(date_str).replace(tzinfo=TZINFO) + days_ago = (now - date_obj).days + if days_ago <= 3 and temp < SOIL_TEMP_SHUTDOWN_THRESHOLD: + recent_below_count += 1 + except Exception: + continue + if temp_below: + recent_below_count += 1 + temp_below = recent_below_count >= 2 # Almeno 2 giorni consecutivi + + # TRIGGER 2: Segnale Luce - Sunshine Duration in calo drastico + light_declining = False + if sunshine_hours is not None: + # Fotoperiodo sotto 6 ore indica calo drastico (inizio dormienza) + light_declining = sunshine_hours < 6.0 + + # TRIGGER 3: Saturazione - Soil Moisture (9-27cm) alta costantemente + saturation_ok = False + if (soil_moisture_9_27cm is not None and + soil_moisture_9_27cm >= SOIL_MOISTURE_AUTUMN_HIGH and + high_moisture_streak >= AUTUMN_HIGH_MOISTURE_DAYS): + saturation_ok = True + + # Genera consiglio + if temp_below or (light_declining and saturation_ok): + advice_level = "NO_ACTION" + advice_msg = "❄️ **CHIUDI TUTTO**\n\n" + if temp_below: + advice_msg += f"Trigger termico attivo: temperatura suolo {soil_temp_6cm:.1f}°C < {SOIL_TEMP_SHUTDOWN_THRESHOLD}°C stabilmente. " + if light_declining: + advice_msg += f"Segnale luce: fotoperiodo in calo drastico ({sunshine_hours:.1f}h di sole). " + if saturation_ok: + advice_msg += f"Umidità alta costante ({soil_moisture_9_27cm*100:.0f}%) per {high_moisture_streak} giorni. " + advice_msg += "\n\nLe piante sono entrate in riposo vegetativo. " + advice_msg += "**Consiglio**: Puoi svuotare l'impianto di irrigazione per l'inverno. " + advice_msg += "Il terreno non richiede più irrigazione artificiale." + else: + advice_level = "STANDARD" + advice_msg = "🟡 **MONITORAGGIO CHIUSURA**\n\n" + advice_msg += "Stagione autunnale avanzata. Monitora attentamente:\n" + if soil_temp_6cm is not None: + advice_msg += f"• Temperatura suolo: {soil_temp_6cm:.1f}°C (soglia: {SOIL_TEMP_SHUTDOWN_THRESHOLD}°C)\n" + if sunshine_hours is not None: + advice_msg += f"• Fotoperiodo: {sunshine_hours:.1f}h (calo drastico se < 6h)\n" + if soil_moisture_9_27cm is not None: + advice_msg += f"• Umidità: {soil_moisture_9_27cm*100:.0f}% (alta se ≥{SOIL_MOISTURE_AUTUMN_HIGH*100}% per {AUTUMN_HIGH_MOISTURE_DAYS} giorni)\n" + advice_msg += "\n**Consiglio**: Continua il monitoraggio. Lo spegnimento è imminente." + + # Soil status summary + soil_summary_parts = [] + if soil_temp_6cm is not None: + soil_summary_parts.append(f"T.Suolo 6cm: {soil_temp_6cm:.1f}°C") + if soil_moisture_9_27cm is not None: + soil_summary_parts.append(f"Umidità 9-27cm: {soil_moisture_9_27cm*100:.0f}%") + if sunshine_hours is not None: + soil_summary_parts.append(f"Fotoperiodo: {sunshine_hours:.1f}h") + soil_status_summary = " | ".join(soil_summary_parts) if soil_summary_parts else "Dati limitati" + + return { + "season_phase": "CLOSING", + "advice_level": advice_level, + "human_message": advice_msg, + "soil_status_summary": soil_status_summary, + "status_display": status + } + + +def generate_dormant_advice() -> Dict: + """ + FASE DORMIENTE (Inverno) + Returns: Dict con season_phase, advice_level, human_message, soil_status_summary + """ + status = "**Fase: Riposo Invernale**" + advice_msg = "❄️ **IMPIANTO SPENTO**\n" + advice_msg += "Stagione invernale. Le piante sono in riposo vegetativo completo.\n" + advice_msg += "**Consiglio**: L'impianto di irrigazione dovrebbe essere già svuotato e spento. " + advice_msg += "Nessuna irrigazione necessaria fino alla prossima primavera." + + return { + "season_phase": "DORMANT", + "advice_level": "NO_ACTION", + "human_message": advice_msg, + "soil_status_summary": "Dormienza invernale", + "status_display": status + } + + +# ============================================================================= +# MAIN ANALYSIS +# ============================================================================= + +def should_send_auto_report( + phase: str, + soil_temp_6cm: Optional[float], + state: Dict, + force_debug: bool = False +) -> Tuple[bool, str]: + """ + Determina se inviare un report automatico basato su indicatori di fase. + Returns: (should_send, reason) + """ + # In modalità debug, invia sempre + if force_debug: + return True, "DEBUG MODE" + + # Se siamo in fase dormiente e non ci sono indicatori di risveglio, silente + if phase == "dormant": + # Controlla se ci sono indicatori di avvicinamento al risveglio + if soil_temp_6cm is not None and soil_temp_6cm >= SOIL_TEMP_WAKEUP_INDICATOR: + # Siamo in inverno ma il terreno si sta scaldando -> si avvicina il momento + if not state.get("wakeup_threshold_reached", False): + # Prima volta che superiamo l'indicatore -> sblocca report e notifica + state["wakeup_threshold_reached"] = True + state["auto_reporting_enabled"] = True # Abilita per monitorare il risveglio + return True, "TERRENO_IN_RISVEGLIO" + # Già notificato, ma continuiamo a monitorare se auto-reporting è attivo + if state.get("auto_reporting_enabled", False): + return True, "MONITORAGGIO_RISVEGLIO" + # Anche se è dormiente, se abbiamo già raggiunto la soglia di risveglio, continua + if state.get("wakeup_threshold_reached", False) and state.get("auto_reporting_enabled", False): + return True, "POST_RISVEGLIO" + # Silente + return False, "DORMANT_SILENT" + + # Fase wakeup: sempre invia (stiamo monitorando l'attivazione) + if phase == "wakeup": + if not state.get("auto_reporting_enabled", False): + # Prima volta che entriamo in wakeup -> abilita auto-reporting + state["auto_reporting_enabled"] = True + state["wakeup_threshold_reached"] = True + return True, "WAKEUP_ENABLED" + return True, "WAKEUP_MONITORING" + + # Fase active: sempre invia (stagione attiva) + if phase == "active": + state["auto_reporting_enabled"] = True + state["wakeup_threshold_reached"] = True + state["shutdown_confirmed"] = False + return True, "ACTIVE_SEASON" + + # Fase shutdown: invia finché non confermiamo la chiusura + if phase == "shutdown": + if state.get("shutdown_confirmed", False): + # Chiusura già confermata -> disabilita auto-reporting + state["auto_reporting_enabled"] = False + return False, "SHUTDOWN_CONFIRMED" + # Prima chiusura -> invia notifica e poi disabilita + state["shutdown_confirmed"] = True + state["auto_reporting_enabled"] = False + return True, "SHUTDOWN_NOTIFICATION" + + return False, "UNKNOWN_PHASE" + + +def analyze_irrigation( + lat: float = DEFAULT_LAT, + lon: float = DEFAULT_LON, + location_name: str = DEFAULT_LOCATION_NAME, + timezone: str = TZ, + debug_mode: bool = False, + force_send: bool = False +) -> Tuple[str, bool]: + """ + Analisi principale e generazione report. + Returns: (report, should_send_auto) + """ + """ + Analisi principale e generazione report. + """ + LOGGER.info("=== Analisi Irrigazione per %s ===", location_name) + + # Carica stato precedente + state = load_state() + + # Recupera dati + data = fetch_soil_and_weather(lat, lon, timezone) + if not data: + return ("❌ **ERRORE**\n\nImpossibile recuperare dati meteorologici. Riprova più tardi.", False) + + hourly = data.get("hourly", {}) or {} + daily = data.get("daily", {}) or {} + + # Estrai dati attuali (primi valori) + times = hourly.get("time", []) or [] + if not times: + return ("❌ **ERRORE**\n\nNessun dato temporale disponibile.", False) + + now = now_local() + current_idx = 0 + for i, t_str in enumerate(times): + try: + t = parse_time_to_local(t_str) + if t >= now: + current_idx = i + break + except Exception: + continue + + # Dati suolo ICON Italia (potrebbero essere None) + soil_temp_0cm_list = hourly.get("soil_temperature_0cm", []) or [] + soil_temp_54cm_list = hourly.get("soil_temperature_54cm", []) or [] + soil_moisture_0_1_list = hourly.get("soil_moisture_0_to_1cm", []) or [] + soil_moisture_81_243_list = hourly.get("soil_moisture_81_to_243cm", []) or [] + precip_list = hourly.get("precipitation", []) or [] + snowfall_list = hourly.get("snowfall", []) or [] + et0_list = hourly.get("et0_fao_evapotranspiration", []) or [] + vpd_list = hourly.get("vapour_pressure_deficit", []) or [] # Stress idrico + sunshine_list = hourly.get("sunshine_duration", []) or [] + humidity_list = hourly.get("relative_humidity_2m", []) or [] # Umidità relativa aria + shortwave_rad_list = hourly.get("shortwave_radiation", []) or [] # GHI - Global Horizontal Irradiance + + # Valori attuali (mappatura: 0cm ≈ 6cm per logica, 54cm ≈ 18cm) + soil_temp_6cm = None # Usa soil_temp_0cm + if current_idx < len(soil_temp_0cm_list) and soil_temp_0cm_list[current_idx] is not None: + soil_temp_6cm = float(soil_temp_0cm_list[current_idx]) + + soil_temp_18cm = None # Usa soil_temp_54cm + if current_idx < len(soil_temp_54cm_list) and soil_temp_54cm_list[current_idx] is not None: + soil_temp_18cm = float(soil_temp_54cm_list[current_idx]) + + # Umidità superficiale (0-1cm da ICON, mappata come 3-9cm nella logica) + soil_moisture_0_1cm = None + if current_idx < len(soil_moisture_0_1_list) and soil_moisture_0_1_list[current_idx] is not None: + soil_moisture_0_1cm = float(soil_moisture_0_1_list[current_idx]) + + # Per retrocompatibilità, usa anche come 3-9cm + soil_moisture_3_9cm = soil_moisture_0_1cm + + soil_moisture_9_27cm = None # Usa soil_moisture_81_to_243cm (profondo) + if current_idx < len(soil_moisture_81_243_list) and soil_moisture_81_243_list[current_idx] is not None: + soil_moisture_9_27cm = float(soil_moisture_81_243_list[current_idx]) + + # Riserva profonda 27-81cm (non disponibile in ICON, potrebbe tornare in estate) + # ICON fornisce solo 81-243cm, quindi lasciamo None + soil_moisture_27_81cm = None + + # Parametri aggiuntivi per calcolo stress idrico + vpd_avg = None # Vapour Pressure Deficit medio (24h) + vpd_values = [] + for i in range(current_idx, min(current_idx + 24, len(vpd_list))): + if i < len(vpd_list) and vpd_list[i] is not None: + try: + vpd_values.append(float(vpd_list[i])) + except Exception: + continue + if vpd_values: + vpd_avg = sum(vpd_values) / len(vpd_values) + + sunshine_hours = None # Ore di sole previste (24h) + sunshine_total = 0.0 + for i in range(current_idx, min(current_idx + 24, len(sunshine_list))): + if i < len(sunshine_list) and sunshine_list[i] is not None: + try: + sunshine_total += float(sunshine_list[i]) + except Exception: + continue + if sunshine_total > 0: + sunshine_hours = sunshine_total / 3600.0 # Converti secondi in ore + + # Umidità relativa aria media (24h) + humidity_avg = None + humidity_values = [] + for i in range(current_idx, min(current_idx + 24, len(humidity_list))): + if i < len(humidity_list) and humidity_list[i] is not None: + try: + humidity_values.append(float(humidity_list[i])) + except Exception: + continue + if humidity_values: + humidity_avg = sum(humidity_values) / len(humidity_values) + + # Shortwave Radiation GHI media (24h) - energia per fotosintesi + shortwave_avg = None + shortwave_values = [] + for i in range(current_idx, min(current_idx + 24, len(shortwave_rad_list))): + if i < len(shortwave_rad_list) and shortwave_rad_list[i] is not None: + try: + shortwave_values.append(float(shortwave_rad_list[i])) + except Exception: + continue + if shortwave_values: + shortwave_avg = sum(shortwave_values) / len(shortwave_values) # W/m² + + # ET₀ medio (calcola su prossime 24h) + et0_avg = None + et0_values = [] + for i in range(current_idx, min(current_idx + 24, len(et0_list))): + if i < len(et0_list) and et0_list[i] is not None: + try: + et0_values.append(float(et0_list[i])) + except Exception: + continue + if et0_values: + et0_avg = sum(et0_values) / len(et0_values) + + # Previsioni pioggia + future_rain_total, rainy_days = check_future_rainfall(daily, days_ahead=5) + next_2_days_rain, _ = check_future_rainfall(daily, days_ahead=2) + + # Determina fase stagionale + month = now.month + phase = determine_seasonal_phase( + month, soil_temp_6cm, soil_moisture_3_9cm, soil_moisture_9_27cm, state + ) + + # Determina se inviare report automatico + should_send, reason = should_send_auto_report(phase, soil_temp_6cm, state, force_debug=debug_mode) + + # Aggiorna stato + state["phase"] = phase + state["last_check"] = now.isoformat() + + # Aggiungi a storico (mantieni ultimi 7 giorni) + # Usa soil_temp_0cm per storico (mappato come 6cm nella logica) + today_str = now.date().isoformat() + state["soil_temp_history"] = [ + (d, t) for d, t in state.get("soil_temp_history", []) + if (now.date() - datetime.date.fromisoformat(d)).days <= 7 + ] + if soil_temp_6cm is not None: # Questa è già mappata da soil_temp_0cm + state["soil_temp_history"].append((today_str, soil_temp_6cm)) + + # Aggiorna streak umidità alta + if (soil_moisture_9_27cm is not None and + soil_moisture_9_27cm >= SOIL_MOISTURE_AUTUMN_HIGH): + state["high_moisture_streak"] = state.get("high_moisture_streak", 0) + 1 + else: + state["high_moisture_streak"] = 0 + + # Genera consiglio in base alla fase (restituisce Dict con JSON structure) + advice_dict = None + if phase == "wakeup": + advice_dict = generate_wakeup_advice( + soil_temp_6cm, soil_moisture_3_9cm, soil_moisture_9_27cm, + future_rain_total, rainy_days, + shortwave_avg=shortwave_avg, + sunshine_hours=sunshine_hours, + state=state + ) + elif phase == "active": + advice_dict = generate_active_advice( + soil_moisture_0_1cm=soil_moisture_3_9cm, # Mappa 0-1cm → 3-9cm per retrocompatibilità + soil_moisture_3_9cm=soil_moisture_3_9cm, + soil_moisture_9_27cm=soil_moisture_9_27cm, + soil_moisture_27_81cm=soil_moisture_27_81cm, # None se non disponibile + future_rain_mm=future_rain_total, + rainy_days=rainy_days, + et0_avg=et0_avg, + next_2_days_rain=next_2_days_rain, + vpd_avg=vpd_avg + ) + elif phase == "shutdown": + advice_dict = generate_shutdown_advice( + soil_temp_6cm, soil_moisture_9_27cm, state.get("high_moisture_streak", 0), + sunshine_hours=sunshine_hours, + shortwave_avg=shortwave_avg, + state=state + ) + else: # dormant + advice_dict = generate_dormant_advice() + + # Estrai status e advice dal dict per retrocompatibilità con report text + status = advice_dict.get("status_display", "**Fase: Sconosciuta**") + advice = advice_dict.get("human_message", "Analisi in corso...") + + # Il dict contiene anche: season_phase, advice_level, soil_status_summary (per JSON output) + + # Calcola trend per temperatura e umidità (ultimi 7 giorni dallo storico) + temp_trend = None + moisture_trend_3_9 = None + moisture_trend_9_27 = None + temp_history = state.get("soil_temp_history", []) + if len(temp_history) >= 2 and soil_temp_6cm is not None: + try: + # Confronta con valore di 7 giorni fa (se disponibile) + week_ago_date = (now.date() - datetime.timedelta(days=7)).isoformat() + old_temp = None + for date_str, temp_val in temp_history: + if date_str == week_ago_date: + old_temp = temp_val + break + + if old_temp is not None: + diff = soil_temp_6cm - old_temp + if abs(diff) > 0.1: + temp_trend = f"{diff:+.1f}°C" if diff > 0 else f"{diff:.1f}°C" + except Exception: + pass + + # Costruisci report completo (senza righe vuote eccessive) + report_parts = [ + f"{status}\n", + f"📍 {location_name}\n", + f"📅 {now.strftime('%d/%m/%Y %H:%M')}\n", + "="*25 + "\n", + advice + ] + + # Aggiungi dettagli tecnici (se disponibili) + details = [] + + # Temperatura suolo con trend + temp_found = False + if soil_temp_6cm is not None: + temp_class = classify_soil_temp(soil_temp_6cm) + temp_str = f"🌡️ T° suolo (0cm): {soil_temp_6cm:.1f}°C ({temp_class})" + if temp_trend: + temp_str += f" | trend 7gg: {temp_trend}" + details.append(temp_str) + temp_found = True + else: + # Prova a vedere se c'è un valore futuro nella lista (ICON: 0cm) + for i in range(current_idx, min(current_idx + 48, len(soil_temp_0cm_list))): + if i < len(soil_temp_0cm_list) and soil_temp_0cm_list[i] is not None: + temp_val = float(soil_temp_0cm_list[i]) + details.append(f"🌡️ T° suolo (0cm): {temp_val:.1f}°C (prossime ore)") + temp_found = True + break + + if soil_temp_18cm is not None: + temp_class_54 = classify_soil_temp(soil_temp_18cm) + temp_str = f"🌡️ T° suolo (54cm): {soil_temp_18cm:.1f}°C ({temp_class_54})" + details.append(temp_str) + temp_found = True + else: + # Prova valore futuro (ICON: 54cm) + for i in range(current_idx, min(current_idx + 48, len(soil_temp_54cm_list))): + if i < len(soil_temp_54cm_list) and soil_temp_54cm_list[i] is not None: + temp_val = float(soil_temp_54cm_list[i]) + details.append(f"🌡️ T° suolo (54cm): {temp_val:.1f}°C (prossime ore)") + temp_found = True + break + + # Umidità suolo (ICON: 0-1cm e 81-243cm) + moisture_found = False + if soil_moisture_3_9cm is not None: + moisture_class = classify_soil_moisture(soil_moisture_3_9cm) + details.append(f"💧 Umidità (0-1cm): {soil_moisture_3_9cm*100:.0f}% ({moisture_class})") + moisture_found = True + else: + # Prova valore futuro + for i in range(current_idx, min(current_idx + 48, len(soil_moisture_0_1_list))): + if i < len(soil_moisture_0_1_list) and soil_moisture_0_1_list[i] is not None: + moisture_val = float(soil_moisture_0_1_list[i]) + details.append(f"💧 Umidità (0-1cm): {moisture_val*100:.0f}% (prossime ore)") + moisture_found = True + break + + if soil_moisture_9_27cm is not None: + moisture_class_deep = classify_soil_moisture(soil_moisture_9_27cm) + details.append(f"💧 Umidità (81-243cm): {soil_moisture_9_27cm*100:.0f}% ({moisture_class_deep})") + moisture_found = True + else: + # Prova valore futuro + for i in range(current_idx, min(current_idx + 48, len(soil_moisture_81_243_list))): + if i < len(soil_moisture_81_243_list) and soil_moisture_81_243_list[i] is not None: + moisture_val = float(soil_moisture_81_243_list[i]) + details.append(f"💧 Umidità (81-243cm): {moisture_val*100:.0f}% (prossime ore)") + moisture_found = True + break + + # Messaggio informativo se dati suolo non disponibili + if not temp_found and not moisture_found: + details.append("ℹ️ Dati suolo non disponibili per questa località") + + # ET₀ e parametri evapotraspirazione + if et0_avg is not None: + et0_class = classify_et0(et0_avg) + details.append(f"☀️ ET₀ medio (24h): {et0_avg:.1f} mm/d ({et0_class})") + + # Vapour Pressure Deficit (stress idrico) + if vpd_avg is not None: + vpd_class = classify_vpd(vpd_avg) + # VPD alto = stress idrico alto + vpd_status = "" + if vpd_avg > 1.5: + vpd_status = " (stress idrico elevato)" + elif vpd_avg > 1.0: + vpd_status = " (stress moderato)" + details.append(f"💨 VPD medio (24h): {vpd_avg:.2f} kPa ({vpd_class}){vpd_status}") + + # Ore di sole previste + if sunshine_hours is not None: + sunshine_class = classify_sunshine(sunshine_hours) + details.append(f"☀️ Ore sole previste (24h): {sunshine_hours:.1f}h ({sunshine_class})") + + # Umidità relativa aria + if humidity_avg is not None: + # Classifica umidità relativa (bassa < 40%, media 40-70%, alta > 70%) + if humidity_avg < 40: + humidity_class = "basso (secco)" + elif humidity_avg < 70: + humidity_class = "medio" + else: + humidity_class = "alto (umido)" + details.append(f"💨 Umidità relativa aria (24h): {humidity_avg:.0f}% ({humidity_class})") + + # Precipitazioni previste (include neve) + if future_rain_total > 0: + # Classifica come totale su 5 giorni (media giornaliera approssimativa) + avg_daily = future_rain_total / 5.0 + precip_class = classify_precip_daily(avg_daily) + precip_str = f"🌧️ Precipitazioni previste (5gg): {future_rain_total:.1f}mm ({precip_class}, media ~{avg_daily:.1f}mm/giorno)" + if rainy_days: + precip_str += f"\n Giorni: {', '.join(rainy_days[:3])}" # Primi 3 giorni + if len(rainy_days) > 3: + precip_str += f" +{len(rainy_days)-3} altri" + details.append(precip_str) + elif len(rainy_days) == 0: + details.append("🌧️ Precipitazioni previste (5gg): 0mm (basso)") + + if details: + report_parts.append("─"*25 + "\n") + report_parts.append("**Dettagli Tecnici:**\n") + report_parts.append("\n".join(details)) + + # Salva stato + save_state(state) + + report = "\n".join(report_parts) + LOGGER.info("Analisi completata. Fase: %s, Auto-send: %s (%s)", phase, should_send, reason) + + return report, should_send if not force_send else True + + +# ============================================================================= +# TELEGRAM INTEGRATION (Optional) +# ============================================================================= + +def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -> bool: + """Invia messaggio Telegram in formato Markdown.""" + token = load_bot_token() + if not token: + LOGGER.warning("Telegram token missing: message not sent.") + return False + + if chat_ids is None: + chat_ids = TELEGRAM_CHAT_IDS + + url = f"https://api.telegram.org/bot{token}/sendMessage" + base_payload = { + "text": message, + "parse_mode": "Markdown", + "disable_web_page_preview": True, + } + + sent_ok = False + import time + with requests.Session() as s: + for chat_id in chat_ids: + payload = dict(base_payload) + payload["chat_id"] = chat_id + try: + resp = s.post(url, json=payload, timeout=15) + if resp.status_code == 200: + sent_ok = True + else: + LOGGER.error("Telegram error chat_id=%s status=%s body=%s", + chat_id, resp.status_code, resp.text[:500]) + time.sleep(0.25) + except Exception as e: + LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e) + + return sent_ok + + +# ============================================================================= +# MAIN +# ============================================================================= + +def main(): + parser = argparse.ArgumentParser( + description="Smart Irrigation Advisor - Consulente Agronomico" + ) + parser.add_argument("--lat", type=float, help="Latitudine (default: Casa)") + parser.add_argument("--lon", type=float, help="Longitudine (default: Casa)") + parser.add_argument("--location", help="Nome località (default: Casa)") + parser.add_argument("--timezone", help="Timezone IANA (default: Europe/Berlin)") + parser.add_argument("--telegram", action="store_true", help="Invia report via Telegram (solo se auto-reporting attivo o --force)") + parser.add_argument("--force", action="store_true", help="Forza invio anche se auto-reporting disabilitato") + parser.add_argument("--chat_id", help="Chat ID Telegram specifico (opzionale)") + parser.add_argument("--debug", action="store_true", help="Modalità debug (invia sempre e bypassa controlli)") + parser.add_argument("--auto", action="store_true", help="Modalità automatica (usa logica auto-reporting, invia via Telegram se attivo)") + + args = parser.parse_args() + + if args.debug: + global DEBUG + DEBUG = True + LOGGER.setLevel(logging.DEBUG) + + lat = args.lat if args.lat is not None else DEFAULT_LAT + lon = args.lon if args.lon is not None else DEFAULT_LON + location = args.location if args.location else DEFAULT_LOCATION_NAME + timezone = args.timezone if args.timezone else TZ + + # Determina modalità operativa + force_send = args.force or args.debug + use_auto_logic = args.auto or (not args.telegram and not args.force) + + # Genera report + report, should_send_auto = analyze_irrigation( + lat, lon, location, timezone, + debug_mode=args.debug, + force_send=force_send + ) + + # Output + send_to_telegram = False + + if args.auto: + # Modalità automatica (cron): usa logica auto-reporting + if should_send_auto: + send_to_telegram = True + LOGGER.info("Auto-reporting attivo: invio via Telegram") + else: + LOGGER.info("Auto-reporting disabilitato: report non inviato (fase: %s)", + load_state().get("phase", "unknown")) + # In modalità auto, se non inviamo, non stampiamo neanche + if not args.debug: + return + + elif args.telegram: + # Modalità manuale (chiamata da Telegram): SEMPRE invia se --telegram è presente + # La logica auto-reporting si applica solo a cron (--auto) + send_to_telegram = True + if force_send: + LOGGER.info("Chiamata manuale da Telegram con --force: invio forzato") + elif should_send_auto: + LOGGER.info("Chiamata manuale da Telegram: invio (auto-reporting attivo)") + else: + LOGGER.info("Chiamata manuale da Telegram: invio (bypass auto-reporting)") + + if send_to_telegram: + chat_ids = None + if args.chat_id: + chat_ids = [args.chat_id.strip()] + success = telegram_send_markdown(report, chat_ids=chat_ids) + if not success: + print(report) # Fallback su stdout + LOGGER.error("Errore invio Telegram, stampato su stdout") + else: + # Stampa sempre su stdout se non in modalità auto e non Telegram + print(report) + + +if __name__ == "__main__": + main() diff --git a/services/telegram-bot/snow_radar.py b/services/telegram-bot/snow_radar.py new file mode 100755 index 0000000..1b4dc88 --- /dev/null +++ b/services/telegram-bot/snow_radar.py @@ -0,0 +1,747 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import argparse +import datetime +import json +import logging +import os +import time +from logging.handlers import RotatingFileHandler +from typing import Dict, List, Optional, Tuple +from zoneinfo import ZoneInfo + +import requests +from dateutil import parser + +# ============================================================================= +# snow_radar.py +# +# Scopo: +# Analizza la nevicata in una griglia di località in un raggio di 40km da San Marino. +# Per ciascuna località mostra: +# - Nome della località +# - Somma dello snowfall orario nelle 12 ore precedenti +# - Somma dello snowfall previsto nelle 12 ore successive +# - Somma dello snowfall previsto nelle 24 ore successive +# +# Modello meteo: +# meteofrance_seamless (AROME) per dati dettagliati +# +# Token Telegram: +# Nessun token in chiaro. Lettura in ordine: +# 1) env TELEGRAM_BOT_TOKEN +# 2) ~/.telegram_dpc_bot_token +# 3) /etc/telegram_dpc_bot_token +# +# Debug: +# python3 snow_radar.py --debug +# +# Log: +# ./snow_radar.log (stessa cartella dello script) +# ============================================================================= + +DEBUG = os.environ.get("DEBUG", "0").strip() == "1" + +# ----------------- TELEGRAM ----------------- +TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"] +TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token") +TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token" + +# ----------------- CONFIGURAZIONE ----------------- +# Elenco località da monitorare +LOCATIONS = [ + {"name": "Casa (Strada Cà Toro)", "lat": 43.9356, "lon": 12.4296}, + {"name": "Cerasolo", "lat": 43.9831, "lon": 12.5355}, # Frazione di San Marino, più a nord-est + {"name": "Rimini", "lat": 44.0678, "lon": 12.5695}, + {"name": "Riccione", "lat": 44.0000, "lon": 12.6500}, + {"name": "Cattolica", "lat": 43.9600, "lon": 12.7400}, + {"name": "Pesaro", "lat": 43.9100, "lon": 12.9100}, + {"name": "Morciano di Romagna", "lat": 43.9200, "lon": 12.6500}, + {"name": "Sassocorvaro", "lat": 43.7800, "lon": 12.5000}, + {"name": "Urbino", "lat": 43.7200, "lon": 12.6400}, + {"name": "Frontino", "lat": 43.7600, "lon": 12.3800}, + {"name": "Carpegna", "lat": 43.7819, "lon": 12.3346}, + {"name": "Pennabilli", "lat": 43.8200, "lon": 12.2600}, + {"name": "Miratoio", "lat": 43.8500, "lon": 12.3000}, # Approssimazione + {"name": "Sant'Agata Feltria", "lat": 43.8600, "lon": 12.2100}, + {"name": "Novafeltria", "lat": 43.9000, "lon": 12.2900}, + {"name": "Mercato Saraceno", "lat": 43.9500, "lon": 12.2000}, + {"name": "Villa Verucchio", "lat": 44.0000, "lon": 12.4300}, + {"name": "Santarcangelo di Romagna", "lat": 44.0600, "lon": 12.4500}, + {"name": "Savignano sul Rubicone", "lat": 44.0900, "lon": 12.4000}, + {"name": "Cesena", "lat": 44.1400, "lon": 12.2400}, + {"name": "Bellaria-Igea Marina", "lat": 44.1400, "lon": 12.4800}, + {"name": "Cervia", "lat": 44.2600, "lon": 12.3600}, + {"name": "Roncofreddo", "lat": 44.0433, "lon": 12.3181}, + {"name": "Torriana", "lat": 44.0400, "lon": 12.3800}, + {"name": "Montescudo", "lat": 43.9167, "lon": 12.5333}, + {"name": "Mercatino Conca", "lat": 43.8686, "lon": 12.4722}, + {"name": "Macerata Feltria", "lat": 43.8033, "lon": 12.4418}, + {"name": "Saludecio", "lat": 43.8750, "lon": 12.6667}, + {"name": "Mondaino", "lat": 43.8500, "lon": 12.6833}, + {"name": "Tavoleto", "lat": 43.8500, "lon": 12.6000}, +] + +# Timezone +TZ = "Europe/Berlin" +TZINFO = ZoneInfo(TZ) + +# Modello meteo +MODEL_AROME = "meteofrance_seamless" + +# File di log +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +LOG_FILE = os.path.join(BASE_DIR, "snow_radar.log") + +# ----------------- OPEN-METEO ----------------- +OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" +HTTP_HEADERS = {"User-Agent": "snow-radar/1.0"} + +# ----------------- REVERSE GEOCODING ----------------- +# Usa Nominatim (OpenStreetMap) per ottenere nomi località +NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse" +NOMINATIM_HEADERS = {"User-Agent": "snow-radar/1.0"} + + +def setup_logger() -> logging.Logger: + logger = logging.getLogger("snow_radar") + logger.setLevel(logging.DEBUG if DEBUG else logging.INFO) + logger.handlers.clear() + + fh = RotatingFileHandler(LOG_FILE, maxBytes=1_000_000, backupCount=5, encoding="utf-8") + fh.setLevel(logging.DEBUG) + fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s") + fh.setFormatter(fmt) + logger.addHandler(fh) + + if DEBUG: + sh = logging.StreamHandler() + sh.setLevel(logging.DEBUG) + sh.setFormatter(fmt) + logger.addHandler(sh) + + return logger + + +LOGGER = setup_logger() + + +# ============================================================================= +# Utility +# ============================================================================= +def now_local() -> datetime.datetime: + return datetime.datetime.now(TZINFO) + + +def read_text_file(path: str) -> str: + try: + with open(path, "r", encoding="utf-8") as f: + return f.read().strip() + except FileNotFoundError: + return "" + except PermissionError: + LOGGER.debug("Permission denied reading %s", path) + return "" + except Exception as e: + LOGGER.exception("Error reading %s: %s", path, e) + return "" + + +def load_bot_token() -> str: + tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() + if tok: + return tok + tok = read_text_file(TOKEN_FILE_HOME) + if tok: + return tok + tok = read_text_file(TOKEN_FILE_ETC) + return tok.strip() if tok else "" + + +def parse_time_to_local(t: str) -> datetime.datetime: + dt = parser.isoparse(t) + if dt.tzinfo is None: + return dt.replace(tzinfo=TZINFO) + return dt.astimezone(TZINFO) + + +# ============================================================================= +# Geografia +# ============================================================================= +def calculate_distance_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Calcola distanza in km tra due punti geografici.""" + from math import radians, cos, sin, asin, sqrt + + # Formula di Haversine + R = 6371 # Raggio Terra in km + dlat = radians(lat2 - lat1) + dlon = radians(lon2 - lon1) + a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2 + c = 2 * asin(sqrt(a)) + return R * c + + +# ============================================================================= +# Open-Meteo +# ============================================================================= +def get_forecast(session: requests.Session, lat: float, lon: float) -> Optional[Dict]: + """ + Recupera previsioni meteo per una località. + """ + params = { + "latitude": lat, + "longitude": lon, + "hourly": "snowfall,weathercode", + "timezone": TZ, + "forecast_days": 2, + "models": MODEL_AROME, + } + + try: + r = session.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25) + if r.status_code == 400: + try: + j = r.json() + LOGGER.warning("Open-Meteo 400 (lat=%.4f lon=%.4f): %s", lat, lon, j.get("reason", j)) + except Exception: + LOGGER.warning("Open-Meteo 400 (lat=%.4f lon=%.4f): %s", lat, lon, r.text[:300]) + return None + r.raise_for_status() + return r.json() + except Exception as e: + LOGGER.warning("Open-Meteo error (lat=%.4f lon=%.4f): %s", lat, lon, str(e)) + return None + + +def analyze_snowfall_for_location(data: Dict, now: datetime.datetime) -> Optional[Dict]: + """ + Analizza snowfall per una località. + + Nota: Open-Meteo fornisce principalmente dati futuri. Per i dati passati, + includiamo anche le ore appena passate se disponibili nei dati hourly. + + Returns: + Dict con: + - snow_past_12h: somma snowfall ultime 12 ore (cm) - se disponibile nei dati + - snow_next_12h: somma snowfall prossime 12 ore (cm) + - snow_next_24h: somma snowfall prossime 24 ore (cm) + """ + hourly = data.get("hourly", {}) or {} + times = hourly.get("time", []) or [] + snowfall = hourly.get("snowfall", []) or [] + + if not times or not snowfall: + return None + + # Converti timestamps + dt_list = [parse_time_to_local(t) for t in times] + + # Calcola finestre temporali + past_12h_start = now - datetime.timedelta(hours=12) + next_12h_end = now + datetime.timedelta(hours=12) + next_24h_end = now + datetime.timedelta(hours=24) + + snow_past_12h = 0.0 + snow_next_12h = 0.0 + snow_next_24h = 0.0 + + for i, dt in enumerate(dt_list): + snow_val = float(snowfall[i]) if i < len(snowfall) and snowfall[i] is not None else 0.0 + + # Ultime 12 ore (passato) - solo se i dati includono il passato + if dt < now and dt >= past_12h_start: + snow_past_12h += snow_val + + # Prossime 12 ore + if now <= dt < next_12h_end: + snow_next_12h += snow_val + + # Prossime 24 ore + if now <= dt < next_24h_end: + snow_next_24h += snow_val + + return { + "snow_past_12h": snow_past_12h, + "snow_next_12h": snow_next_12h, + "snow_next_24h": snow_next_24h, + } + + +# ============================================================================= +# Mappa Grafica +# ============================================================================= +def generate_snow_map(results: List[Dict], center_lat: float, center_lon: float, output_path: str, + data_field: str = "snow_next_24h", title_suffix: str = "") -> bool: + """ + Genera una mappa grafica con punti colorati in base all'accumulo di neve. + + Args: + results: Lista di dict con 'name', 'lat', 'lon', 'snow_past_12h', 'snow_next_12h', 'snow_next_24h' + center_lat: Latitudine centro (San Marino) + center_lon: Longitudine centro (San Marino) + output_path: Percorso file output PNG + data_field: Campo da usare per i colori ('snow_past_12h' o 'snow_next_24h') + title_suffix: Suffisso da aggiungere al titolo + + Returns: + True se generata con successo, False altrimenti + """ + try: + import matplotlib + matplotlib.use('Agg') # Backend senza GUI + import matplotlib.pyplot as plt + import matplotlib.patches as mpatches + from matplotlib.colors import LinearSegmentedColormap + import numpy as np + except ImportError as e: + LOGGER.warning("matplotlib non disponibile: %s. Mappa non generata.", e) + return False + + # Prova a importare contextily per mappa di sfondo + try: + import contextily as ctx + CONTEXTILY_AVAILABLE = True + except ImportError: + CONTEXTILY_AVAILABLE = False + LOGGER.warning("contextily non disponibile. Mappa generata senza sfondo geografico.") + + if not results: + return False + + # Estrai valori dal campo specificato + totals = [r.get(data_field, 0.0) for r in results] + max_total = max(totals) if totals else 1.0 + min_total = min(totals) if totals else 0.0 + + # Estrai coordinate + lats = [r["lat"] for r in results] + lons = [r["lon"] for r in results] + names = [r["name"] for r in results] + + # Crea figura + fig, ax = plt.subplots(figsize=(14, 12)) + fig.patch.set_facecolor('white') + + # Limiti fissi della mappa (più zoomata) + lat_min, lat_max = 43.7, 44.3 + lon_min, lon_max = 12.1, 12.8 + + # Configura assi PRIMA di aggiungere lo sfondo + ax.set_xlim(lon_min, lon_max) + ax.set_ylim(lat_min, lat_max) + ax.set_aspect('equal', adjustable='box') + + # Aggiungi mappa di sfondo OpenStreetMap se disponibile + if CONTEXTILY_AVAILABLE: + try: + # Aggiungi tile OpenStreetMap (contextily gestisce automaticamente la conversione) + ctx.add_basemap(ax, crs='EPSG:4326', source=ctx.providers.OpenStreetMap.Mapnik, + alpha=0.6, attribution_size=6) + LOGGER.debug("Mappa OpenStreetMap aggiunta come sfondo") + except Exception as e: + LOGGER.warning("Errore aggiunta mappa sfondo: %s. Continuo senza sfondo.", e) + # Non reimpostare CONTEXTILY_AVAILABLE qui, solo logga l'errore + + # Disegna punti con colore basato su accumulo totale + # Colori: blu (poco) -> verde -> giallo -> arancione -> rosso (molto) + cmap = LinearSegmentedColormap.from_list('snow', + ['#1E90FF', '#00CED1', '#32CD32', '#FFD700', '#FF8C00', '#FF4500', '#8B0000']) + + scatter = ax.scatter(lons, lats, c=totals, s=250, cmap=cmap, + vmin=min_total, vmax=max_total, + edgecolors='black', linewidths=2, alpha=0.85, zorder=5) + + # Posizionamento personalizzato per etichette specifiche + label_positions = { + "Casa (Strada Cà Toro)": (-20, 20), # Più alto e più a sx + "Cervia": (0, 20), # Più in alto + "Savignano sul Rubicone": (-15, 15), # Alto a sx + "Rimini": (0, 20), # Più in alto + "Santarcangelo di Romagna": (0, -20), # Più in basso + "Riccione": (0, -20), # Più in basso + "Morciano di Romagna": (0, -20), # Più in basso + "Miratoio": (0, -20), # Più in basso + "Carpegna": (-20, -25), # Più in basso e più a sx + "Pennabilli": (0, -20), # Più in basso + "Mercato Saraceno": (0, 20), # Più in alto + "Sant'Agata Feltria": (-20, 15), # Più a sx + "Villa Verucchio": (0, -25), # Più in basso + "Roncofreddo": (-15, 15), # Alto a sx + "Torriana": (-15, 15), # Alto a sx + "Cerasolo": (15, 0), # Più a dx + "Mercatino Conca": (0, -20), # Più in basso + "Novafeltria": (10, 0), # Leggermente più a dx + "Urbino": (0, 20), # Più in alto + "Saludecio": (15, -15), # Più in basso + "Macerata Feltria": (20, 0), # Più a dx + "Mondaino": (15, -15), # Basso a dx + "Tavoleto": (15, -15), # Basso a dx + } + + # Offset di default per altre località + default_offsets = [ + (8, 8), (8, -12), (-12, 8), (-12, -12), # 4 direzioni base + (0, 15), (0, -15), (15, 0), (-15, 0), # 4 direzioni intermedie + (10, 10), (-10, 10), (10, -10), (-10, -10) # Diagonali + ] + + for i, (lon, lat, name, total) in enumerate(zip(lons, lats, names, totals)): + # Usa posizionamento personalizzato se disponibile, altrimenti offset ciclico + if name in label_positions: + xytext = label_positions[name] + else: + offset_idx = i % len(default_offsets) + xytext = default_offsets[offset_idx] + + # Font size basato su importanza + fontsize = 9 if total > 5 or name in ["Cerasolo", "Carpegna", "Rimini", "Pesaro"] else 8 + + # Salta Casa qui, la gestiamo separatamente + if name == "Casa (Strada Cà Toro)": + continue + + ax.annotate(name, (lon, lat), xytext=xytext, textcoords='offset points', + fontsize=fontsize, fontweight='bold', + bbox=dict(boxstyle='round,pad=0.4', facecolor='white', alpha=0.9, + edgecolor='black', linewidth=1), + zorder=6) + + # Aggiungi punto Casa (Strada Cà Toro) - più grande e visibile, etichetta solo "Casa" + casa_lat = 43.9356 + casa_lon = 12.4296 + casa_name = "Casa (Strada Cà Toro)" + casa_value = next((r.get(data_field, 0.0) for r in results if r.get("name") == casa_name), 0.0) + casa_color = cmap((casa_value - min_total) / (max_total - min_total) if max_total > min_total else 0.5) + ax.scatter([casa_lon], [casa_lat], s=350, c=[casa_color], + edgecolors='black', linewidths=2.5, zorder=7, marker='s') # Quadrato per Casa + ax.annotate('Casa', (casa_lon, casa_lat), + xytext=(-20, 20), textcoords='offset points', # Più alto e più a sx + fontsize=11, fontweight='bold', + bbox=dict(boxstyle='round,pad=0.6', facecolor='white', alpha=0.95, + edgecolor='black', linewidth=2), + zorder=8) + + # Colorbar (spostata a destra) - label dinamica in base al campo + label_text = 'Accumulo Neve (cm)' + if data_field == "snow_past_12h": + label_text = 'Accumulo Neve Ultime 12h (cm)' + elif data_field == "snow_next_24h": + label_text = 'Accumulo Neve Prossime 24h (cm)' + + cbar = plt.colorbar(scatter, ax=ax, label=label_text, + shrink=0.7, pad=0.02, location='right') + cbar.ax.set_ylabel(label_text, fontsize=11, fontweight='bold') + cbar.ax.tick_params(labelsize=9) + + # Configura assi (etichette) + ax.set_xlabel('Longitudine (°E)', fontsize=12, fontweight='bold') + ax.set_ylabel('Latitudine (°N)', fontsize=12, fontweight='bold') + title = f'❄️ SNOW RADAR - Analisi Neve 30km da San Marino{title_suffix}' + ax.set_title(title, fontsize=15, fontweight='bold', pad=20) + + # Griglia solo se non c'è mappa di sfondo + if not CONTEXTILY_AVAILABLE: + ax.grid(True, alpha=0.3, linestyle='--', zorder=1) + + # Legenda spostata in basso a sinistra (non si sovrappone ai dati) + legend_elements = [ + mpatches.Patch(facecolor='#1E90FF', label='0-1 cm'), + mpatches.Patch(facecolor='#32CD32', label='1-3 cm'), + mpatches.Patch(facecolor='#FFD700', label='3-5 cm'), + mpatches.Patch(facecolor='#FF8C00', label='5-10 cm'), + mpatches.Patch(facecolor='#FF4500', label='10-20 cm'), + mpatches.Patch(facecolor='#8B0000', label='>20 cm'), + ] + ax.legend(handles=legend_elements, loc='lower left', fontsize=10, + framealpha=0.95, edgecolor='black', fancybox=True, shadow=True) + + # Info timestamp spostata in alto a destra + now = now_local() + info_text = f"Aggiornamento: {now.strftime('%d/%m/%Y %H:%M')}\nLocalità con neve: {len(results)}" + ax.text(0.98, 0.98, info_text, transform=ax.transAxes, + fontsize=9, verticalalignment='top', horizontalalignment='right', + bbox=dict(boxstyle='round,pad=0.5', facecolor='white', alpha=0.9, + edgecolor='gray', linewidth=1.5), + zorder=10) + + plt.tight_layout() + + # Salva + try: + plt.savefig(output_path, dpi=150, bbox_inches='tight', facecolor='white') + plt.close(fig) + LOGGER.info("Mappa salvata: %s", output_path) + return True + except Exception as e: + LOGGER.exception("Errore salvataggio mappa: %s", e) + plt.close(fig) + return False + + +def telegram_send_photo(photo_path: str, caption: str, chat_ids: Optional[List[str]] = None) -> bool: + """ + Invia foto via Telegram API. + + Args: + photo_path: Percorso file immagine + caption: Didascalia foto (max 1024 caratteri) + chat_ids: Lista chat IDs (default: TELEGRAM_CHAT_IDS) + + Returns: + True se inviata con successo, False altrimenti + """ + token = load_bot_token() + if not token: + LOGGER.warning("Telegram token missing: photo not sent.") + return False + + if not os.path.exists(photo_path): + LOGGER.error("File foto non trovato: %s", photo_path) + return False + + if chat_ids is None: + chat_ids = TELEGRAM_CHAT_IDS + + url = f"https://api.telegram.org/bot{token}/sendPhoto" + + # Limite Telegram per caption: 1024 caratteri + if len(caption) > 1024: + caption = caption[:1021] + "..." + + sent_ok = False + with requests.Session() as s: + for chat_id in chat_ids: + try: + with open(photo_path, 'rb') as photo_file: + files = {'photo': photo_file} + data = { + 'chat_id': chat_id, + 'caption': caption, + 'parse_mode': 'Markdown' + } + resp = s.post(url, files=files, data=data, timeout=30) + if resp.status_code == 200: + sent_ok = True + LOGGER.info("Foto inviata a chat_id=%s", chat_id) + else: + LOGGER.error("Telegram error chat_id=%s status=%s body=%s", + chat_id, resp.status_code, resp.text[:500]) + time.sleep(0.5) + except Exception as e: + LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e) + + return sent_ok + + +# ============================================================================= +# Telegram +# ============================================================================= +def telegram_send_markdown(message_md: str, chat_ids: Optional[List[str]] = None) -> bool: + """Invia messaggio Markdown su Telegram. Divide in più messaggi se troppo lungo.""" + token = load_bot_token() + if not token: + LOGGER.warning("Telegram token missing: message not sent.") + return False + + if chat_ids is None: + chat_ids = TELEGRAM_CHAT_IDS + + # Telegram limite: 4096 caratteri per messaggio + MAX_MESSAGE_LENGTH = 4000 # Lascia margine per encoding + + url = f"https://api.telegram.org/bot{token}/sendMessage" + + # Se il messaggio è troppo lungo, dividilo + if len(message_md) <= MAX_MESSAGE_LENGTH: + messages = [message_md] + else: + # Dividi per righe, mantenendo l'header nel primo messaggio + lines = message_md.split('\n') + messages = [] + current_msg = [] + current_len = 0 + + # Header (prime righe fino a "*Riepilogo per località*") + header_lines = [] + header_end_idx = 0 + for i, line in enumerate(lines): + if "*Riepilogo per località" in line: + header_end_idx = i + 1 + break + header_lines.append(line) + + header = '\n'.join(header_lines) + header_len = len(header) + + # Primo messaggio: header + prime località + current_msg = header_lines.copy() + current_len = header_len + + for i in range(header_end_idx, len(lines)): + line = lines[i] + line_len = len(line) + 1 # +1 per \n + + if current_len + line_len > MAX_MESSAGE_LENGTH: + # Chiudi messaggio corrente + messages.append('\n'.join(current_msg)) + # Nuovo messaggio (solo continuazione) + current_msg = [line] + current_len = line_len + else: + current_msg.append(line) + current_len += line_len + + # Aggiungi ultimo messaggio + if current_msg: + messages.append('\n'.join(current_msg)) + + sent_ok = False + with requests.Session() as s: + for chat_id in chat_ids: + for msg_idx, msg_text in enumerate(messages): + payload = { + "chat_id": chat_id, + "text": msg_text, + "parse_mode": "Markdown", + "disable_web_page_preview": True, + } + try: + resp = s.post(url, json=payload, timeout=15) + if resp.status_code == 200: + sent_ok = True + else: + LOGGER.error("Telegram error chat_id=%s status=%s body=%s", + chat_id, resp.status_code, resp.text[:500]) + time.sleep(0.25) + except Exception as e: + LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e) + + return sent_ok + + +# ============================================================================= +# Main +# ============================================================================= +def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, chat_id: Optional[str] = None) -> None: + LOGGER.info("--- Snow Radar ---") + + # Se chat_id è specificato, usa quello (per chiamate da Telegram) + if chat_id: + chat_ids = [chat_id] + elif debug_mode and not chat_ids: + # In debug mode, default al primo chat ID (admin) + chat_ids = [TELEGRAM_CHAT_IDS[0]] + + now = now_local() + + # Centro: San Marino (per calcolo distanze) + CENTER_LAT = 43.9356 + CENTER_LON = 12.4296 + + # Analizza località predefinite + LOGGER.info("Analisi %d località predefinite...", len(LOCATIONS)) + with requests.Session() as session: + results = [] + for i, loc in enumerate(LOCATIONS): + # Calcola distanza da San Marino + distance_km = calculate_distance_km(CENTER_LAT, CENTER_LON, loc["lat"], loc["lon"]) + + LOGGER.debug("Analizzando località %d/%d: %s (%.2f km)", i+1, len(LOCATIONS), loc["name"], distance_km) + + data = get_forecast(session, loc["lat"], loc["lon"]) + if not data: + continue + + snow_analysis = analyze_snowfall_for_location(data, now) + if not snow_analysis: + continue + + # Aggiungi sempre Casa, anche se non c'è neve + # Per le altre località, aggiungi solo se c'è neve (passata o prevista) + is_casa = loc["name"] == "Casa (Strada Cà Toro)" + has_snow = (snow_analysis["snow_past_12h"] > 0.0 or + snow_analysis["snow_next_12h"] > 0.0 or + snow_analysis["snow_next_24h"] > 0.0) + + if is_casa or has_snow: + results.append({ + "name": loc["name"], + "lat": loc["lat"], + "lon": loc["lon"], + "distance_km": distance_km, + **snow_analysis + }) + + # Rate limiting per Open-Meteo + time.sleep(0.1) + + if not results: + LOGGER.info("Nessuna neve rilevata nelle località monitorate") + if debug_mode: + message = "❄️ *SNOW RADAR*\n\nNessuna neve rilevata nelle località monitorate." + telegram_send_markdown(message, chat_ids=chat_ids) + return + + # Genera e invia DUE mappe separate + now_str = now.strftime('%d/%m/%Y %H:%M') + + # 1. Mappa snowfall passato (12h precedenti) + map_path_past = os.path.join(BASE_DIR, "snow_radar_past.png") + map_generated_past = generate_snow_map(results, CENTER_LAT, CENTER_LON, map_path_past, + data_field="snow_past_12h", + title_suffix=" - Ultime 12h") + if map_generated_past: + caption_past = ( + f"❄️ *SNOW RADAR - Ultime 12h*\n" + f"📍 Centro: San Marino\n" + f"🕒 {now_str}\n" + f"📊 Località con neve: {len(results)}/{len(LOCATIONS)}" + ) + telegram_send_photo(map_path_past, caption_past, chat_ids=chat_ids) + # Pulisci file temporaneo + try: + if os.path.exists(map_path_past): + os.remove(map_path_past) + except Exception: + pass + + # 2. Mappa snowfall futuro (24h successive) + map_path_future = os.path.join(BASE_DIR, "snow_radar_future.png") + map_generated_future = generate_snow_map(results, CENTER_LAT, CENTER_LON, map_path_future, + data_field="snow_next_24h", + title_suffix=" - Prossime 24h") + if map_generated_future: + caption_future = ( + f"❄️ *SNOW RADAR - Prossime 24h*\n" + f"📍 Centro: San Marino\n" + f"🕒 {now_str}\n" + f"📊 Località con neve: {len(results)}/{len(LOCATIONS)}" + ) + telegram_send_photo(map_path_future, caption_future, chat_ids=chat_ids) + # Pulisci file temporaneo + try: + if os.path.exists(map_path_future): + os.remove(map_path_future) + except Exception: + pass + + if map_generated_past or map_generated_future: + LOGGER.info("Mappe inviate con successo (%d località, passato: %s, futuro: %s)", + len(results), "sì" if map_generated_past else "no", + "sì" if map_generated_future else "no") + else: + LOGGER.error("Errore generazione mappe") + + +if __name__ == "__main__": + arg_parser = argparse.ArgumentParser(description="Snow Radar - Analisi neve in griglia 30km") + arg_parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0]) + arg_parser.add_argument("--chat_id", type=str, help="Chat ID specifico per invio messaggio (override debug mode)") + args = arg_parser.parse_args() + + # Se --chat_id è specificato, usa quello; altrimenti usa logica debug + chat_id = args.chat_id if args.chat_id else None + chat_ids = None if chat_id else ([TELEGRAM_CHAT_IDS[0]] if args.debug else None) + + main(chat_ids=chat_ids, debug_mode=args.debug, chat_id=chat_id) diff --git a/services/telegram-bot/student_alert.py b/services/telegram-bot/student_alert.py index e1a6b48..d73792d 100644 --- a/services/telegram-bot/student_alert.py +++ b/services/telegram-bot/student_alert.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import argparse import datetime import html import json @@ -36,6 +37,8 @@ SOGLIA_PIOGGIA_3H_MM = 30.0 # mm in 3 ore (rolling) PERSIST_HOURS = 2 # persistenza minima (ore) HOURS_AHEAD = 24 SNOW_HOURLY_EPS_CM = 0.2 # Soglia minima neve cm/h +# Codici meteo che indicano neve (WMO) +SNOW_WEATHER_CODES = [71, 73, 75, 77, 85, 86] # Neve leggera, moderata, forte, granelli, rovesci # File di stato STATE_FILE = "/home/daniely/docker/telegram-bot/student_state.json" @@ -53,10 +56,12 @@ POINTS = [ # ----------------- OPEN-METEO ----------------- OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" -TZ = "Europe/Rome" +TZ = "Europe/Berlin" TZINFO = ZoneInfo(TZ) -HTTP_HEADERS = {"User-Agent": "rpi-student-alert/1.0"} -MODEL = "meteofrance_arome_france_hd" +HTTP_HEADERS = {"User-Agent": "rpi-student-alert/2.0"} +MODEL_AROME = "meteofrance_seamless" +MODEL_ICON_IT = "italia_meteo_arpae_icon_2i" +COMPARISON_THRESHOLD = 0.30 # 30% scostamento per comparazione # ----------------- LOG ----------------- BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -132,12 +137,20 @@ def hhmm(dt: datetime.datetime) -> str: # ============================================================================= # Telegram # ============================================================================= -def telegram_send_html(message_html: str) -> bool: +def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) -> bool: + """ + Args: + message_html: Messaggio HTML da inviare + chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) + """ token = load_bot_token() if not token: LOGGER.warning("Telegram token missing: message not sent.") return False + if chat_ids is None: + chat_ids = TELEGRAM_CHAT_IDS + url = f"https://api.telegram.org/bot{token}/sendMessage" base_payload = { "text": message_html, @@ -147,7 +160,7 @@ def telegram_send_html(message_html: str) -> bool: sent_ok = False with requests.Session() as s: - for chat_id in TELEGRAM_CHAT_IDS: + for chat_id in chat_ids: payload = dict(base_payload) payload["chat_id"] = chat_id try: @@ -193,24 +206,130 @@ def save_state(alert_active: bool, signature: str) -> None: LOGGER.exception("State write error: %s", e) -def get_forecast(session: requests.Session, lat: float, lon: float) -> Optional[Dict]: +def get_forecast(session: requests.Session, lat: float, lon: float, model: str) -> Optional[Dict]: params = { "latitude": lat, "longitude": lon, - "hourly": "precipitation,snowfall", + "hourly": "precipitation,snowfall,weathercode", # Aggiunto weathercode per rilevare neve "timezone": TZ, "forecast_days": 2, "precipitation_unit": "mm", - "models": MODEL, + "models": model, } + + # Aggiungi minutely_15 per AROME Seamless (dettaglio 15 minuti per inizio preciso eventi) + # Se fallisce o ha buchi, riprova senza minutely_15 + if model == MODEL_AROME: + params["minutely_15"] = "precipitation,rain,snowfall,precipitation_probability,temperature_2m" try: r = session.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25) if r.status_code == 400: + # Se 400 e abbiamo minutely_15, riprova senza + if "minutely_15" in params and model == MODEL_AROME: + LOGGER.warning("Open-Meteo 400 con minutely_15 (model=%s), riprovo senza minutely_15", model) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + return None + elif r.status_code == 504: + # Gateway Timeout: se abbiamo minutely_15, riprova senza + if "minutely_15" in params and model == MODEL_AROME: + LOGGER.warning("Open-Meteo 504 Gateway Timeout con minutely_15 (model=%s), riprovo senza minutely_15", model) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass return None r.raise_for_status() - return r.json() + data = r.json() + + # Verifica se minutely_15 ha buchi (anche solo 1 None = fallback a hourly) + if "minutely_15" in params and model == MODEL_AROME: + minutely = data.get("minutely_15", {}) or {} + minutely_times = minutely.get("time", []) or [] + minutely_precip = minutely.get("precipitation", []) or [] + minutely_snow = minutely.get("snowfall", []) or [] + + # Controlla se ci sono buchi (anche solo 1 None) + if minutely_times: + # Controlla tutti i parametri principali per buchi + has_holes = False + # Controlla precipitation + if minutely_precip and any(v is None for v in minutely_precip): + has_holes = True + # Controlla snowfall + if minutely_snow and any(v is None for v in minutely_snow): + has_holes = True + + if has_holes: + LOGGER.warning("minutely_15 ha buchi (valori None rilevati, model=%s), riprovo senza minutely_15", model) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + + return data + except requests.exceptions.Timeout: + # Timeout: se abbiamo minutely_15, riprova senza + if "minutely_15" in params and model == MODEL_AROME: + LOGGER.warning("Open-Meteo Timeout con minutely_15 (model=%s), riprovo senza minutely_15", model) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + LOGGER.exception("Open-Meteo timeout (model=%s)", model) + return None except Exception as e: - LOGGER.exception("Open-Meteo request error: %s", e) + # Altri errori: se abbiamo minutely_15, riprova senza + if "minutely_15" in params and model == MODEL_AROME: + LOGGER.warning("Open-Meteo error con minutely_15 (model=%s): %s, riprovo senza minutely_15", model, str(e)) + params_no_minutely = params.copy() + del params_no_minutely["minutely_15"] + try: + r2 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25) + if r2.status_code == 200: + return r2.json() + except Exception: + pass + LOGGER.exception("Open-Meteo request error (model=%s): %s", model, e) + return None + + +def compare_values(arome_val: float, icon_val: float) -> Optional[Dict]: + """Confronta due valori e ritorna info se scostamento >30%""" + if arome_val == 0 and icon_val == 0: + return None + + if arome_val > 0: + diff_pct = abs(icon_val - arome_val) / arome_val + elif icon_val > 0: + diff_pct = abs(arome_val - icon_val) / icon_val + else: + return None + + if diff_pct > COMPARISON_THRESHOLD: + return { + "diff_pct": diff_pct * 100, + "arome": arome_val, + "icon": icon_val + } return None @@ -272,6 +391,7 @@ def compute_stats(data: Dict) -> Optional[Dict]: times = hourly.get("time", []) or [] precip = hourly.get("precipitation", []) or [] snow = hourly.get("snowfall", []) or [] + weathercode = hourly.get("weathercode", []) or [] # Per rilevare neve anche quando snowfall è basso n = min(len(times), len(precip), len(snow)) if n == 0: return None @@ -289,7 +409,8 @@ def compute_stats(data: Dict) -> Optional[Dict]: times_w = times[start_idx:end_idx] precip_w = precip[start_idx:end_idx] - snow_w = snow[start_idx:end_idx] + snow_w = [float(x) if x is not None else 0.0 for x in snow[start_idx:end_idx]] + weathercode_w = [int(x) if x is not None else None for x in weathercode[start_idx:end_idx]] if len(weathercode) > start_idx else [] dt_w = [parse_time_to_local(t) for t in times_w] rain3 = rolling_sum_3h(precip_w) @@ -302,11 +423,98 @@ def compute_stats(data: Dict) -> Optional[Dict]: ) rain_persist_time = hhmm(dt_w[rain_run_start]) if (rain_persist_ok and rain_run_start < len(dt_w)) else "" + # 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) + + # 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_12h = sum(float(x) for x in snow_w[:min(12, len(snow_w))] if x is not None) - snow_24h = sum(float(x) for x in snow_w[:min(24, len(snow_w))] if x is not None) + # 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) return { "rain3_max": float(rain3_max), @@ -315,10 +523,15 @@ def compute_stats(data: Dict) -> Optional[Dict]: "rain_persist_time": rain_persist_time, "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_12h": float(snow_12h), "snow_24h": float(snow_24h), + "snow_duration_hours": float(snow_duration_hours), + "total_snow_accumulation_cm": float(total_snow_accumulation), } @@ -338,6 +551,9 @@ def point_alerts(point_name: str, stats: Dict) -> Dict: "rain_persist_time": stats["rain_persist_time"], "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), } @@ -354,32 +570,54 @@ def build_signature(bologna: Dict, route: List[Dict]) -> str: return "|".join(parts) -def main() -> None: +def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None: LOGGER.info("--- Student alert Bologna (Neve/Pioggia intensa) ---") state = load_state() was_active = bool(state.get("alert_active", False)) last_sig = state.get("signature", "") + comparisons: Dict[str, Dict] = {} # point_name -> comparison info + with requests.Session() as session: # Trigger: Bologna bo = POINTS[0] - bo_data = get_forecast(session, bo["lat"], bo["lon"]) - if not bo_data: return - bo_stats = compute_stats(bo_data) - if not bo_stats: + bo_data_arome = get_forecast(session, bo["lat"], bo["lon"], MODEL_AROME) + if not bo_data_arome: return + bo_stats_arome = compute_stats(bo_data_arome) + if not bo_stats_arome: LOGGER.error("Impossibile calcolare statistiche Bologna.") return - bo_alerts = point_alerts(bo["name"], bo_stats) + bo_alerts = point_alerts(bo["name"], bo_stats_arome) + + # Recupera ICON Italia per Bologna + bo_data_icon = get_forecast(session, bo["lat"], bo["lon"], MODEL_ICON_IT) + if bo_data_icon: + bo_stats_icon = compute_stats(bo_data_icon) + if bo_stats_icon: + comp_snow = compare_values(bo_stats_arome["snow_24h"], bo_stats_icon["snow_24h"]) + comp_rain = compare_values(bo_stats_arome["rain3_max"], bo_stats_icon["rain3_max"]) + if comp_snow or comp_rain: + comparisons[bo["name"]] = {"snow": comp_snow, "rain": comp_rain, "icon_stats": bo_stats_icon} # Route points route_alerts: List[Dict] = [] for p in POINTS[1:]: - d = get_forecast(session, p["lat"], p["lon"]) - if not d: continue - st = compute_stats(d) - if not st: continue - route_alerts.append(point_alerts(p["name"], st)) + d_arome = get_forecast(session, p["lat"], p["lon"], MODEL_AROME) + if not d_arome: continue + st_arome = compute_stats(d_arome) + if not st_arome: continue + route_alerts.append(point_alerts(p["name"], st_arome)) + + # Recupera ICON Italia per punto + d_icon = get_forecast(session, p["lat"], p["lon"], MODEL_ICON_IT) + if d_icon: + st_icon = compute_stats(d_icon) + if st_icon: + comp_snow = compare_values(st_arome["snow_24h"], st_icon["snow_24h"]) + comp_rain = compare_values(st_arome["rain3_max"], st_icon["rain3_max"]) + if comp_snow or comp_rain: + comparisons[p["name"]] = {"snow": comp_snow, "rain": comp_rain, "icon_stats": st_icon} any_route_alert = any(x["snow_alert"] or x["rain_alert"] for x in route_alerts) any_alert = (bo_alerts["snow_alert"] or bo_alerts["rain_alert"] or any_route_alert) @@ -388,7 +626,10 @@ def main() -> None: # --- Scenario A: Allerta --- if any_alert: - if (not was_active) or (sig != last_sig): + # In modalità debug, bypassa controlli anti-spam + if debug_mode: + LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato") + if debug_mode or (not was_active) or (sig != last_sig): now_str = now_local().strftime("%H:%M") header_icon = "❄️" if (bo_alerts["snow_alert"] or any(x["snow_alert"] for x in route_alerts)) \ else "🌧️" if (bo_alerts["rain_alert"] or any(x["rain_alert"] for x in route_alerts)) \ @@ -397,20 +638,38 @@ def main() -> None: msg: List[str] = [] msg.append(f"{header_icon} ALLERTA METEO (Bologna / Rientro)") msg.append(f"🕒 Aggiornamento ore {html.escape(now_str)}") - msg.append(f"🛰️ Modello: {html.escape(MODEL)}") + model_info = MODEL_AROME + if comparisons: + model_info = f"{MODEL_AROME} + ICON Italia (discordanza rilevata)" + msg.append(f"🛰️ Modello: {html.escape(model_info)}") msg.append(f"⏱️ Finestra: {HOURS_AHEAD} ore | Persistenza: {PERSIST_HOURS} ore") msg.append("") # Bologna 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).") 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"] + icon_s24 = bo_comp["icon_stats"]["snow_24h"] + msg.append(f"⚠️ Discordanza modelli: AROME {comp['arome']:.1f} cm | ICON {icon_s24:.1f} cm (scostamento {comp['diff_pct']:.0f}%)") else: 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") + if bo_comp and bo_comp.get("rain"): + comp = bo_comp["rain"] + icon_r3 = bo_comp["icon_stats"]["rain3_max"] + msg.append(f"⚠️ Discordanza modelli: AROME {comp['arome']:.1f} mm | ICON {icon_r3:.1f} mm (scostamento {comp['diff_pct']:.0f}%)") else: msg.append(f"🌧️ Pioggia: max 3h {bo_alerts['rain3_max']:.1f} mm (picco ~{html.escape(bo_alerts['rain3_max_time'] or '—')}).") @@ -427,15 +686,35 @@ def main() -> None: 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)") 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 '—')}") line += " | ".join(parts) msg.append(line) + + # Aggiungi nota discordanza se presente + point_comp = comparisons.get(x["name"]) + if point_comp: + disc_parts = [] + if point_comp.get("snow"): + comp = point_comp["snow"] + icon_s24 = point_comp["icon_stats"]["snow_24h"] + disc_parts.append(f"Neve: AROME {comp['arome']:.1f} cm | ICON {icon_s24:.1f} cm ({comp['diff_pct']:.0f}%)") + if point_comp.get("rain"): + comp = point_comp["rain"] + icon_r3 = point_comp["icon_stats"]["rain3_max"] + disc_parts.append(f"Pioggia: AROME {comp['arome']:.1f} mm | ICON {icon_r3:.1f} mm ({comp['diff_pct']:.0f}%)") + if disc_parts: + msg.append(f" ⚠️ Discordanza: {' | '.join(disc_parts)}") msg.append("") msg.append("Fonte dati: Open-Meteo") # FIX: usare \n invece di
- ok = telegram_send_html("\n".join(msg)) + ok = telegram_send_html("\n".join(msg), chat_ids=chat_ids) if ok: LOGGER.info("Notifica inviata.") else: @@ -457,7 +736,7 @@ def main() -> None: f"di neve (≥{PERSIST_HOURS}h) o pioggia 3h sopra soglia (≥{PERSIST_HOURS}h).\n" "Fonte dati: Open-Meteo" ) - ok = telegram_send_html(msg) + ok = telegram_send_html(msg, chat_ids=chat_ids) if ok: LOGGER.info("Rientro notificato.") else: @@ -471,4 +750,11 @@ def main() -> None: if __name__ == "__main__": - main() + arg_parser = argparse.ArgumentParser(description="Student alert Bologna") + arg_parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0]) + args = arg_parser.parse_args() + + # In modalità debug, invia solo al primo chat ID (admin) e bypassa anti-spam + chat_ids = [TELEGRAM_CHAT_IDS[0]] if args.debug else None + + main(chat_ids=chat_ids, debug_mode=args.debug) diff --git a/services/telegram-bot/test_snow_chart_show.py b/services/telegram-bot/test_snow_chart_show.py new file mode 100644 index 0000000..7f4b731 --- /dev/null +++ b/services/telegram-bot/test_snow_chart_show.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Script di test per generare e mostrare grafico neve con dati mock +""" + +import datetime +import os +import sys +from zoneinfo import ZoneInfo + +# Aggiungi il percorso dello script principale +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from arome_snow_alert import generate_snow_chart_image, TZINFO, telegram_send_photo, TELEGRAM_CHAT_IDS + +def create_mock_data(): + """Crea dati mock realistici per test del grafico""" + now = datetime.datetime.now(TZINFO) + now = now.replace(minute=0, second=0, microsecond=0) + + # Genera 48 ore di dati orari + times = [] + snowfall_arome = [] + rain_arome = [] + snowfall_icon = [] + rain_icon = [] + snow_depth_icon = [] + + # Scenario realistico: nevicata nelle prime 24h, poi pioggia/neve mista + for i in range(48): + dt = now + datetime.timedelta(hours=i) + times.append(dt.isoformat()) + + # AROME Seamless + # Simula nevicata nelle prime 18 ore + if i < 18: + # Picco di neve intorno alle 8-12 ore + if 8 <= i <= 12: + snowfall_arome.append(0.5 + (i - 8) * 0.2) # 0.5-1.3 cm/h + elif 12 < i <= 15: + snowfall_arome.append(1.3 - (i - 12) * 0.2) # Decresce + else: + snowfall_arome.append(0.2 + (i % 3) * 0.1) # Variazione + rain_arome.append(0.0) + # Transizione a pioggia/neve mista + elif 18 <= i < 24: + snowfall_arome.append(0.1 + (i - 18) * 0.05) # Neve residua + rain_arome.append(0.5 + (i - 18) * 0.3) # Pioggia aumenta + # Pioggia + elif 24 <= i < 36: + snowfall_arome.append(0.0) + rain_arome.append(1.5 + (i - 24) % 4 * 0.5) # Pioggia variabile + # Fine precipitazioni + else: + snowfall_arome.append(0.0) + rain_arome.append(0.0) + + # ICON Italia (leggermente diverso per mostrare discrepanze) + if i < 20: + # Neve più persistente in ICON + if 10 <= i <= 14: + snowfall_icon.append(0.6 + (i - 10) * 0.25) # 0.6-1.6 cm/h + elif 14 < i <= 18: + snowfall_icon.append(1.6 - (i - 14) * 0.15) + else: + snowfall_icon.append(0.3 + (i % 3) * 0.15) + rain_icon.append(0.0) + elif 20 <= i < 28: + snowfall_icon.append(0.05) + rain_icon.append(0.8 + (i - 20) * 0.2) + elif 28 <= i < 38: + snowfall_icon.append(0.0) + rain_icon.append(2.0 + (i - 28) % 3 * 0.4) + else: + snowfall_icon.append(0.0) + rain_icon.append(0.0) + + # Snow depth (ICON Italia) - accumulo progressivo poi scioglimento + if i == 0: + snow_depth_icon.append(0.0) + elif i < 20: + # Accumulo progressivo + prev_depth = snow_depth_icon[-1] if snow_depth_icon else 0.0 + new_snow = snowfall_icon[i] * 0.8 # 80% si accumula (perdite per compattazione) + snow_depth_icon.append(prev_depth + new_snow) + elif 20 <= i < 30: + # Scioglimento lento con pioggia + prev_depth = snow_depth_icon[-1] if snow_depth_icon else 0.0 + melt = rain_icon[i] * 0.3 # Scioglimento proporzionale alla pioggia + snow_depth_icon.append(max(0.0, prev_depth - melt)) + else: + # Scioglimento completo + snow_depth_icon.append(0.0) + + # Costruisci struttura dati come da Open-Meteo + data_arome = { + "hourly": { + "time": times, + "snowfall": snowfall_arome, + "rain": rain_arome + } + } + + data_icon = { + "hourly": { + "time": times, + "snowfall": snowfall_icon, + "rain": rain_icon, + "snow_depth": snow_depth_icon # Già in cm (mock) + } + } + + return data_arome, data_icon + + +def main(): + print("Generazione dati mock...") + data_arome, data_icon = create_mock_data() + + print(f"Dati generati:") + print(f" - AROME: {len(data_arome['hourly']['time'])} ore") + print(f" - ICON: {len(data_icon['hourly']['time'])} ore") + print(f" - Snow depth max: {max(data_icon['hourly']['snow_depth']):.1f} cm") + print(f" - Snowfall AROME max: {max(data_arome['hourly']['snowfall']):.1f} cm/h") + print(f" - Snowfall ICON max: {max(data_icon['hourly']['snowfall']):.1f} cm/h") + + # Percorso output + output_path = "/tmp/snow_chart_test.png" + + print(f"\nGenerazione grafico in {output_path}...") + success = generate_snow_chart_image( + data_arome, + data_icon, + output_path, + location_name="🏠 Casa (Test Mock)" + ) + + if success: + print(f"✅ Grafico generato con successo!") + print(f" File: {output_path}") + print(f" Dimensione: {os.path.getsize(output_path) / 1024:.1f} KB") + + # Invio via Telegram + print(f"\nInvio via Telegram a {len(TELEGRAM_CHAT_IDS)} chat(s)...") + caption = "📊 TEST Grafico Precipitazioni 48h\n🏠 Casa (Test Mock)\n\nGrafico di test con dati mock per verificare la visualizzazione." + photo_ok = telegram_send_photo(output_path, caption, chat_ids=[TELEGRAM_CHAT_IDS[0]]) # Solo al primo chat ID per test + if photo_ok: + print(f"✅ Grafico inviato con successo su Telegram!") + else: + print(f"❌ Errore nell'invio su Telegram (verifica token)") + + # Mantieni il file per visualizzazione locale + print(f"\n💡 File disponibile anche localmente: {output_path}") + + else: + print("❌ Errore nella generazione del grafico") + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())