diff --git a/services/telegram-bot/._bot.py b/services/telegram-bot/._bot.py
index 4b0e3e2..2e655ad 100755
Binary files a/services/telegram-bot/._bot.py and b/services/telegram-bot/._bot.py differ
diff --git a/services/telegram-bot/._meteo.py b/services/telegram-bot/._meteo.py
index 753c063..2e655ad 100755
Binary files a/services/telegram-bot/._meteo.py and b/services/telegram-bot/._meteo.py differ
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())