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" 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" # 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": "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 | 'meteofrance_seamless': AROME Seamless (alta risoluzione, supporta minutely_15) MODELS_TO_CHECK = { "ICON": "icon_eu", "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: if os.path.exists(path): try: with open(path, 'r') as f: return f.read().strip() except IOError: pass 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: 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 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, 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": hourly_params, "models": model_slug, "timezone": "Europe/San_Marino", "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=15) response.raise_for_status() return response.json() except Exception as e: print(f"Errore richiesta API per {model_slug}: {e}") return None # ============================================================================= # 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") try: idx = times.index(current_hour_str) except ValueError: return 0, "" # 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.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.get("precipitation", [])[start_idx : idx+1] precip_sum = sum(p for p in precip_history if p is not None) 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:.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}%" # 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})" # 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 if debug_mode: message = f"🛠 [DEBUG - MULTI MODEL] 🛠\n{message}" for chat_id in recipients: try: requests.post(base_url, data={"chat_id": chat_id, "text": message, "parse_mode": "HTML", "disable_web_page_preview": True}, timeout=5) 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(): 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() current_state = {} 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: pid = point["id"] # Variabili per aggregare i risultati dei modelli max_risk_level = 0 triggered_models = [] alert_messages = [] # 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) 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 # 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 --- 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 (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: # 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 = [] 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) # Genera e invia mappa solo quando ci sono aggiornamenti if new_alerts or solved_alerts: if DEBUG_MODE: 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) if __name__ == "__main__": main()