#!/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
from open_meteo_client import open_meteo_get
# =============================================================================
# SEVERE WEATHER ALERT (next 24h) - 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:
# 1) env TELEGRAM_BOT_TOKEN
# 2) ~/.telegram_dpc_bot_token
# 3) /etc/telegram_dpc_bot_token
#
# Debug:
# DEBUG=1 python3 severe_weather.py
#
# Log:
# ./weather_alert.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"
# ----------------- LOCATION -----------------
DEFAULT_LAT = 43.9356
DEFAULT_LON = 12.4296
# ----------------- THRESHOLDS -----------------
# Vento (km/h) - soglie come da tuo set
WIND_YELLOW = 62.0
WIND_ORANGE = 75.0
WIND_RED = 88.0
# Pioggia: mm in 3 ore
RAIN_3H_LIMIT = 25.0
# Persistenza minima richiesta (ore)
PERSIST_HOURS = 2 # richiesta utente: >=2 ore
# ----------------- HORIZON -----------------
HOURS_AHEAD = 24 # Finestra di analisi e notifica: prossime 24 ore
# ----------------- ANTI-GLITCH (previsioni spurie fuori scala) -----------------
# Limiti fisici oltre i quali un valore orario viene scartato come non plausibile.
GLITCH_MAX_PRECIP_H = 100.0 # mm/h
GLITCH_MAX_GUSTS_KMH = 160.0 # km/h
GLITCH_MAX_CAPE = 5500.0 # J/kg
# Picco isolato: valore >> ore adiacenti (tipico artefatto numerico del modello).
GLITCH_SPIKE_RATIO = 4.0
GLITCH_SPIKE_MIN = {"precip": 8.0, "gusts": 35.0, "cape": 600.0}
# Discordanza estrema AROME vs ICON sulla stessa ora → probabile glitch.
GLITCH_CROSS_MODEL_RATIO = 4.0
GLITCH_CROSS_MODEL_MIN = {"precip": 10.0, "gusts": 40.0, "cape": 700.0}
# Temporali: almeno 2 ore significative consecutive (salvo bomba d'acqua estrema).
STORM_MIN_CLUSTER_HOURS = 2
STORM_ISOLATED_EXTREME_PRECIP_H = 40.0 # mm/h
STORM_ISOLATED_EXTREME_PRECIP_3H = 60.0 # mm/3h
# ----------------- RATE LIMITING NOTIFICHE (tutti i tipi) -----------------
MAX_ALERT_MESSAGES_PER_DAY = 1 # massimo 1 messaggio Telegram/giorno
MIN_GAP_HOURS_BETWEEN_ALERTS = 8.0 # distanza minima tra due messaggi
# Escalation intra-giorno: ri-notifica solo se peggioramento netto
RAIN_ESCALATION_MM_3H = 15.0 # +15 mm sul max 3h precedente
# ----------------- 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
# Pioggia minima per considerare un evento REALMENTE convettivo.
# Il CAPE è solo energia potenziale: senza precipitazione non c'è temporale
# (evita falsi allarmi "fulminazioni" con cielo asciutto).
CONVECTIVE_MIN_PRECIP_H = 0.5 # mm/h
CONVECTIVE_MIN_PRECIP_3H = 2.0 # mm/3h
# ----------------- SOGLIE DI SIGNIFICATIVITÀ (per NOTIFICA temporali) -----------------
# Base (fascia "near"): si notifica solo se c'è almeno un evento "significativo".
SIGNIFICANT_CAPE_LIGHTNING = 1200.0 # J/kg - fulminazioni rilevanti
SIGNIFICANT_LIGHTNING_PRECIP_H = 2.0 # mm/h
SIGNIFICANT_LIGHTNING_PRECIP_3H = 5.0 # mm/3h
SIGNIFICANT_PRECIP_H = 30.0 # mm/h - bomba d'acqua
SIGNIFICANT_PRECIP_3H = 50.0 # mm/3h
SIGNIFICANT_STORM_SCORE = 55.0
# ----------------- SOGLIE PER ANTICIPO (lead-time) -----------------
# Concetto: avvisa 24h prima solo se è un "finimondo"; eventi moderati bastano
# poche ore prima (le previsioni lontane sono spesso fuori scala e poi si smussano).
# near: < 6h → soglie base (sensibili)
# mid: 6–12h → soglie elevate
# far: ≥ 12h → soglie estreme
LEAD_NEAR_H = 6.0
LEAD_MID_H = 12.0
LEAD_STORM = {
"near": {
"cape": SIGNIFICANT_CAPE_LIGHTNING,
"lightning_precip_h": SIGNIFICANT_LIGHTNING_PRECIP_H,
"lightning_precip_3h": SIGNIFICANT_LIGHTNING_PRECIP_3H,
"precip_h": SIGNIFICANT_PRECIP_H,
"precip_3h": SIGNIFICANT_PRECIP_3H,
"score": SIGNIFICANT_STORM_SCORE,
"downburst_gusts": WIND_GUST_DOWNBURST_THRESHOLD,
},
"mid": {
"cape": 1800.0,
"lightning_precip_h": 5.0,
"lightning_precip_3h": 12.0,
"precip_h": 40.0,
"precip_3h": 65.0,
"score": 70.0,
"downburst_gusts": 75.0,
},
"far": {
"cape": 2500.0,
"lightning_precip_h": 10.0,
"lightning_precip_3h": 25.0,
"precip_h": 55.0,
"precip_3h": 80.0,
"score": 85.0,
"downburst_gusts": 90.0,
},
}
# Vento: livello minimo notificato per fascia (1=giallo, 2=arancione, 3=rosso)
LEAD_WIND_MIN_LEVEL = {"near": 1, "mid": 2, "far": 3}
# Pioggia: mm/3h minimi per fascia
LEAD_RAIN_3H = {"near": RAIN_3H_LIMIT, "mid": 40.0, "far": 55.0}
# ----------------- FILES -----------------
STATE_FILE = "/home/daniely/docker/telegram-bot/weather_state.json"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
LOG_FILE = os.path.join(BASE_DIR, "weather_alert.log")
# ----------------- OPEN-METEO -----------------
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
TZ = "Europe/Berlin"
TZINFO = ZoneInfo(TZ)
HTTP_HEADERS = {"User-Agent": "rpi-severe-weather/2.0"}
# Force model: AROME Seamless (fornisce rain/snowfall/weathercode)
MODEL_PRIMARY = "meteofrance_seamless"
# Fallback (stessa famiglia Meteo-France) per continuità operativa
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
# =============================================================================
# LOGGING
# =============================================================================
def setup_logger() -> logging.Logger:
logger = logging.getLogger("severe_weather")
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:
- If timestamps are naive (common when timezone=Europe/Rome), interpret as Europe/Rome.
- If timestamps include offset, convert to Europe/Rome.
"""
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 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, 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)
"""
try:
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
except ImportError:
telegram_alerts_enabled = lambda: True # type: ignore
mirror_alert_to_web = lambda *a, **k: False # type: ignore
if not telegram_alerts_enabled():
LOGGER.info("Telegram sospeso: skip severe_weather")
if message_html:
mirror_alert_to_web(message_html, "severe_weather", "warning", is_html=True)
return False
token = load_bot_token()
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)
if sent_ok:
try:
import sys
sys.path.insert(0, "/home/daniely/docker/shared")
from loogle_core.alert_dispatcher import mirror_to_web
mirror_to_web(message_html, "severe_weather", "warning", is_html=True)
except Exception as e:
LOGGER.debug("Web dispatch failed: %s", e)
return sent_ok
# =============================================================================
# STATE
# =============================================================================
def load_state() -> Dict:
default = {
"alert_active": False,
"wind_level": 0,
"last_wind_peak": 0.0,
"last_rain_3h": 0.0,
"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
# Anti-spam globale (tutti i tipi di allerta)
"alert_daily": {}, # {"YYYY-MM-DD": count messaggi inviati}
"last_alert_sent": None, # ISO timestamp ultimo messaggio
"last_alert_signature": None,
"last_alert_signature_date": None,
# Legacy temporali (mantenuto per compatibilità stato)
"storm_daily": {},
"storm_last_sent_imminent": None,
"storm_last_sent_extended": None,
"storm_last_signature": None,
"storm_last_signature_date": 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)
# =============================================================================
# ANTI-GLITCH (filtra previsioni spurie / picchi isolati)
# =============================================================================
def _to_float(v, default: float = 0.0) -> float:
try:
if v is None:
return default
return float(v)
except (ValueError, TypeError):
return default
def _physical_cap(field: str) -> float:
return {"precip": GLITCH_MAX_PRECIP_H, "gusts": GLITCH_MAX_GUSTS_KMH, "cape": GLITCH_MAX_CAPE}.get(
field, float("inf")
)
def exceeds_physical_cap(field: str, value: float) -> bool:
return value > _physical_cap(field)
def is_isolated_spike(series: List[float], idx: int, field: str) -> bool:
"""Picco isolato: valore molto superiore alle ore adiacenti."""
if idx < 1 or idx >= len(series) - 1:
return False
cur = series[idx]
min_abs = GLITCH_SPIKE_MIN.get(field, 5.0)
if cur < min_abs:
return False
prev_v = series[idx - 1]
next_v = series[idx + 1]
nb_avg = (prev_v + next_v) / 2.0
if nb_avg < 0.05 and cur >= min_abs:
return True
if nb_avg > 0 and cur >= GLITCH_SPIKE_RATIO * nb_avg:
return True
return False
def cross_model_outlier(arome_val: float, icon_val: float, field: str) -> bool:
"""Discordanza estrema tra modelli sulla stessa ora."""
mn = GLITCH_CROSS_MODEL_MIN.get(field, 0.0)
hi = max(arome_val, icon_val)
lo = min(arome_val, icon_val)
if hi < mn:
return False
if lo < 0.1 and hi >= mn:
return True
if lo > 0 and hi / lo >= GLITCH_CROSS_MODEL_RATIO:
return True
return False
def sanitize_hourly_values(
arome_vals: List,
icon_vals: Optional[List],
field: str,
start_idx: int,
end_idx: int,
) -> Tuple[List[float], int]:
"""Pulizia serie oraria AROME; valori glitch → 0. Ritorna (serie, n_glitch)."""
n = len(arome_vals)
floats = [_to_float(arome_vals[i] if i < n else 0) for i in range(n)]
glitches = 0
for i in range(start_idx, min(end_idx, n)):
v = floats[i]
if v <= 0:
continue
icon_v = _to_float(icon_vals[i] if icon_vals and i < len(icon_vals) else None, default=-1.0)
bad = exceeds_physical_cap(field, v)
if not bad:
bad = is_isolated_spike(floats, i, field)
if not bad and icon_vals is not None and icon_v >= 0:
bad = cross_model_outlier(v, icon_v, field)
if bad:
LOGGER.info(
"Anti-glitch: %s scartato idx=%d (AROME=%.1f%s)",
field, i, v,
f" ICON={icon_v:.1f}" if icon_v >= 0 else "",
)
floats[i] = 0.0
glitches += 1
return floats, glitches
def hour_passes_convective_glitch(
idx: int,
cape: float,
precip: float,
gusts: float,
icon_precip: float,
icon_cape: float,
arome_precip_series: List[float],
) -> bool:
"""True se l'ora convettiva supera i controlli anti-glitch."""
if exceeds_physical_cap("cape", cape) or exceeds_physical_cap("precip", precip) or exceeds_physical_cap("gusts", gusts):
return False
if precip > 0 and is_isolated_spike(arome_precip_series, idx, "precip"):
return False
if precip > 0 and icon_precip >= 0 and cross_model_outlier(precip, icon_precip, "precip"):
return False
if cape >= GLITCH_SPIKE_MIN["cape"] and icon_cape >= 0 and cross_model_outlier(cape, icon_cape, "cape"):
return False
return True
def filter_storm_cluster(sig_events: List[Dict]) -> List[Dict]:
"""Richiede cluster temporale minimo (ore consecutive); isolati soppressi salvo estremi."""
if not sig_events:
return []
events = sorted(sig_events, key=lambda e: e.get("timestamp", ""))
max_consec = 1
run = 1
for i in range(1, len(events)):
try:
prev = parse_time_to_local(events[i - 1]["timestamp"])
cur = parse_time_to_local(events[i]["timestamp"])
gap_h = (cur - prev).total_seconds() / 3600.0
if gap_h <= 1.5:
run += 1
else:
max_consec = max(max_consec, run)
run = 1
except Exception:
run = 1
max_consec = max(max_consec, run)
if max_consec >= STORM_MIN_CLUSTER_HOURS:
return events
if len(events) == 1 or max_consec == 1:
ev = max(events, key=lambda e: float(e.get("precip", 0) or 0))
if (ev.get("precip", 0) >= STORM_ISOLATED_EXTREME_PRECIP_H
or ev.get("precip_3h", 0) >= STORM_ISOLATED_EXTREME_PRECIP_3H):
return events
LOGGER.info("Anti-glitch: evento temporale isolato (%dh) soppresso", max_consec)
return []
return events
# =============================================================================
# OPEN-METEO
# =============================================================================
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": 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 = open_meteo_get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=(5, 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 = open_meteo_get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=(5, 25))
if r2.status_code == 200:
return r2.json()
except Exception:
pass
# Log reason if present; no Telegram on errors
try:
j = r.json()
LOGGER.error("Open-Meteo 400 (models=%s): %s", models_value, j.get("reason", j))
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 = open_meteo_get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=(5, 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()
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 = open_meteo_get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=(5, 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 = open_meteo_get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=(5, 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(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, 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, 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 []
icon_precip = icon_hourly.get("precipitation", []) or []
icon_gusts = icon_hourly.get("wind_gusts_10m", []) 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 []
arome_precip_floats = [_to_float(v) for v in arome_precip]
# 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
icon_precip_i = _to_float(icon_precip[i] if i < len(icon_precip) else None, default=-1.0)
icon_cape_i = _to_float(icon_cape[i] if i < len(icon_cape) else None, default=-1.0)
if not hour_passes_convective_glitch(
i, cape_val, precip_val, gusts_val,
icon_precip_i, icon_cape_i, arome_precip_floats,
):
continue
# 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
# Precipitazione convettiva reale: senza pioggia non c'è temporale.
has_convective_precip = (precip_val >= CONVECTIVE_MIN_PRECIP_H or
precip_3h_val >= CONVECTIVE_MIN_PRECIP_3H)
# Identifica minacce specifiche
# Fulminazioni: CAPE + LPI MA solo se c'è precipitazione convettiva reale
if cape_val > CAPE_LIGHTNING_THRESHOLD and lpi_val > 0 and has_convective_precip:
threats.append("Fulminazioni")
# Downburst/Temporale violento (richiede comunque precipitazione)
if (cape_val > CAPE_SEVERE_THRESHOLD and gusts_val > WIND_GUST_DOWNBURST_THRESHOLD
and has_convective_precip):
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 c'è una minaccia reale (tutte richiedono precipitazione).
# Il CAPE alto da solo (cielo asciutto) NON genera più un evento.
if 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 finestra configurata
storm_analysis = analyze_convective_storm_event(storm_events, times, start_idx, max_hours=HOURS_AHEAD)
# 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)
# =============================================================================
# SIGNIFICATIVITÀ E ANTI-SPAM (temporali severi)
# =============================================================================
def lead_tier(lead_hours: float) -> str:
"""Fascia di anticipo: near (<6h), mid (6–12h), far (≥12h)."""
if lead_hours < LEAD_NEAR_H:
return "near"
if lead_hours < LEAD_MID_H:
return "mid"
return "far"
def lead_hours_from_start_str(start_str: str, now: datetime.datetime) -> float:
"""Calcola ore di anticipo da stringa 'dd/mm HH:MM' (o vuota → 0)."""
if not start_str:
return 0.0
try:
# Formato ddmmyy_hhmm: "dd/mm HH:MM" — anno corrente / prossimo
dt = datetime.datetime.strptime(start_str.strip(), "%d/%m %H:%M")
dt = dt.replace(year=now.year, tzinfo=TZINFO)
if dt < now - datetime.timedelta(days=1):
dt = dt.replace(year=now.year + 1)
return max(0.0, (dt - now).total_seconds() / 3600.0)
except Exception:
return 0.0
def storm_event_significance(event: Dict, lead_hours: float = 0.0) -> List[str]:
"""Ritorna i motivi di significatività; soglie scalate per anticipo (lead_hours)."""
reasons: List[str] = []
threats = event.get("threats", []) or []
cape = float(event.get("cape", 0.0) or 0.0)
lpi = float(event.get("lpi", 0.0) or 0.0)
precip = float(event.get("precip", 0.0) or 0.0)
precip_3h = float(event.get("precip_3h", 0.0) or 0.0)
score = float(event.get("score", 0.0) or 0.0)
gusts = float(event.get("gusts", 0.0) or 0.0)
thr = LEAD_STORM[lead_tier(lead_hours)]
lightning_precip = (precip >= thr["lightning_precip_h"] or
precip_3h >= thr["lightning_precip_3h"])
if ("Fulminazioni" in threats and cape >= thr["cape"]
and lpi > 0 and lightning_precip):
reasons.append("Fulminazioni forti")
if precip >= thr["precip_h"] or precip_3h >= thr["precip_3h"]:
reasons.append("Bomba d'acqua")
if ("Downburst/Temporale violento" in threats
and gusts >= thr["downburst_gusts"]
and cape >= thr["cape"]):
reasons.append("Downburst")
if score >= thr["score"] and not reasons:
reasons.append("Temporale severo")
return reasons
def filter_significant_storms(storm_events: List[Dict], now: datetime.datetime) -> List[Dict]:
"""Mantiene solo eventi significativi per la loro fascia di anticipo + cluster."""
out: List[Dict] = []
for ev in storm_events or []:
try:
lead = (parse_time_to_local(ev["timestamp"]) - now).total_seconds() / 3600.0
except Exception:
lead = 0.0
if lead < 0:
lead = 0.0
reasons = storm_event_significance(ev, lead_hours=lead)
if not reasons:
continue
e2 = dict(ev)
e2["lead_hours"] = lead
e2["lead_tier"] = lead_tier(lead)
e2["significance"] = reasons
out.append(e2)
return filter_storm_cluster(out)
def wind_meets_lead_threshold(level: int, lead_hours: float) -> bool:
"""Vento notificato solo se il livello raggiunge il minimo della fascia di anticipo."""
if level <= 0:
return False
return level >= LEAD_WIND_MIN_LEVEL[lead_tier(lead_hours)]
def rain_meets_lead_threshold(rain_max_3h: float, lead_hours: float) -> bool:
"""Pioggia notificata solo se mm/3h raggiungono la soglia della fascia di anticipo."""
return rain_max_3h >= LEAD_RAIN_3H[lead_tier(lead_hours)]
def _today_key(now: datetime.datetime) -> str:
return now.strftime("%Y-%m-%d")
def _parse_iso_local(s: Optional[str]) -> Optional[datetime.datetime]:
if not s:
return None
try:
dt = parser.isoparse(s)
if dt.tzinfo is None:
return dt.replace(tzinfo=TZINFO)
return dt.astimezone(TZINFO)
except Exception:
return None
def prune_alert_daily(state: Dict, now: datetime.datetime) -> None:
keep = {_today_key(now), _today_key(now - datetime.timedelta(days=1))}
daily = state.get("alert_daily", {}) or {}
state["alert_daily"] = {k: v for k, v in daily.items() if k in keep}
def build_alert_signature(alerts: List[str], wind_level: int, rain_max_3h: float, sig_storms: List[Dict]) -> str:
parts = []
if sig_storms:
reasons = set()
for ev in sig_storms:
reasons.update(ev.get("significance", []))
parts.append("storm:" + "+".join(sorted(reasons)))
if wind_level > 0:
parts.append(f"wind:{wind_level}")
if rain_max_3h >= RAIN_3H_LIMIT:
parts.append(f"rain:{rain_max_3h:.0f}")
return "|".join(parts) if parts else "none"
def is_alert_escalation(
state: Dict,
signature: str,
wind_level: int,
rain_max_3h: float,
) -> bool:
"""Permette un secondo messaggio nello stesso giorno solo se peggioramento netto."""
prev_wind = int(state.get("wind_level", 0) or 0)
prev_rain = float(state.get("last_rain_3h", 0.0) or 0.0)
prev_sig = state.get("last_alert_signature") or ""
if wind_level > prev_wind:
return True
if rain_max_3h >= prev_rain + RAIN_ESCALATION_MM_3H:
return True
if signature != prev_sig and signature != "none" and prev_sig:
return True
return False
def can_notify_message(now: datetime.datetime, state: Dict, allow_escalation: bool = False) -> Tuple[bool, str]:
key = _today_key(now)
count = int((state.get("alert_daily", {}) or {}).get(key, 0))
if count >= MAX_ALERT_MESSAGES_PER_DAY and not allow_escalation:
return False, "cap giornaliero messaggi raggiunto"
if count >= MAX_ALERT_MESSAGES_PER_DAY + 1:
return False, "cap escalation giornaliero raggiunto"
last = _parse_iso_local(state.get("last_alert_sent"))
if last and (now - last).total_seconds() < MIN_GAP_HOURS_BETWEEN_ALERTS * 3600:
return False, "cooldown alert attivo"
return True, ""
def record_notify_message(now: datetime.datetime, state: Dict, signature: str) -> None:
key = _today_key(now)
daily = state.setdefault("alert_daily", {})
daily[key] = int(daily.get(key, 0)) + 1
state["last_alert_sent"] = now.isoformat()
state["last_alert_signature"] = signature
state["last_alert_signature_date"] = key
# =============================================================================
# PERSISTENCE LOGIC
# =============================================================================
def best_wind_persistent_level(
gusts: List[float],
times: List[str],
start_idx: int,
end_idx: int,
persist_hours: int
) -> Tuple[int, float, str, int]:
"""
Returns:
(level, peak_gust_within_level, first_start_hhmm, run_length_hours)
Level 0 means not persistent above yellow.
Persistence means >= threshold for >= persist_hours consecutive hourly points.
"""
thresholds = [
(3, WIND_RED),
(2, WIND_ORANGE),
(1, WIND_YELLOW),
]
# Convert times to local datetimes once for speed/readability
dt_list = [parse_time_to_local(t) for t in times]
def find_run(threshold: float) -> Tuple[bool, float, str, int]:
consec = 0
run_start = None
run_peak = 0.0
best_start = ""
best_peak = 0.0
best_len = 0
for i in range(start_idx, end_idx):
try:
v = float(gusts[i])
except Exception:
v = 0.0
if v >= threshold:
if consec == 0:
run_start = i
run_peak = v
else:
run_peak = max(run_peak, v)
consec += 1
# record first qualifying run of required length
if consec >= persist_hours and run_start is not None:
# lock the first time a qualifying run appears, but keep peak within that run length
# 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 = ddmmyy_hhmm(dt_list[run_start])
best_peak = run_peak
best_len = consec
else:
# If same threshold continues longer, update peak and length
if run_start == (run_start if best_len else run_start):
best_peak = max(best_peak, run_peak)
best_len = max(best_len, consec)
else:
consec = 0
run_start = None
run_peak = 0.0
if best_len >= persist_hours:
return True, best_peak, best_start, best_len
return False, 0.0, "", 0
for lvl, thr in thresholds:
ok, peak, start_hhmm, run_len = find_run(thr)
if ok:
return lvl, peak, start_hhmm, run_len
return 0, 0.0, "", 0
def best_rain_persistent_3h(
rain: List[float],
times: List[str],
start_idx: int,
end_idx: int,
limit_3h: float,
persist_hours: int
) -> Tuple[float, str, int]:
"""
Returns:
(max_3h_sum, first_start_hhmm_of_persistent_exceedance, persistence_hours)
Compute rolling 3h sums (hourly precipitation totals).
Persistence >=2h means: at least `persist_hours` consecutive rolling windows exceed the limit.
Each shift by 1 hour -> 'persistence_hours' approximates how long intense conditions persist.
"""
if end_idx - start_idx < 3:
return 0.0, "", 0
dt_list = [parse_time_to_local(t) for t in times]
# rolling sums for each window start i (covers i, i+1, i+2)
window_starts = list(range(start_idx, end_idx - 2))
sums = []
for i in window_starts:
try:
s = float(rain[i]) + float(rain[i + 1]) + float(rain[i + 2])
except Exception:
s = 0.0
sums.append(s)
# Find persistent exceedance: sums[j] >= limit for >= persist_hours consecutive j
best_max = 0.0
best_start = ""
best_persist = 0
consec = 0
run_start_j = None
run_max = 0.0
for j, s in enumerate(sums):
if s >= limit_3h:
if consec == 0:
run_start_j = j
run_max = s
else:
run_max = max(run_max, s)
consec += 1
if consec >= persist_hours and run_start_j is not None:
# 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 = ddmmyy_hhmm(dt_list[start_i])
best_persist = consec
best_max = run_max
else:
# if same run continues, update
best_persist = max(best_persist, consec)
best_max = max(best_max, run_max)
else:
consec = 0
run_start_j = None
run_max = 0.0
return best_max, best_start, best_persist
# =============================================================================
# MESSAGE BUILDERS
# =============================================================================
def wind_message(level: int, peak: float, start_hhmm: str, run_len: int) -> str:
# run_len is in hours (number of consecutive hourly points)
if level == 3:
icon = "🔴"
title = "TEMPESTA (Burrasca fortissima)"
thr = WIND_RED
elif level == 2:
icon = "🟠"
title = "VENTO MOLTO FORTE"
thr = WIND_ORANGE
else:
icon = "🟡"
title = "VENTO FORTE"
thr = WIND_YELLOW
return (
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, 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 ({HOURS_AHEAD}h): ~{duration_h} ore (fino alle {end_str})")
else:
msg_parts.append(f"⏱️ Durata totale evento ({HOURS_AHEAD}h): ~{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(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_arome, model_used = get_forecast(lat=lat, lon=lon, timezone=timezone)
if not data_arome:
# No Telegram on errors
return
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 []
# 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]
wcode = wcode_arome[:n]
now = now_local()
state = load_state()
was_alarm = bool(state.get("alert_active", False))
# Find starting index: first timestep >= now
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.error("Could not locate current time index in forecast timeline.")
return
end_idx = min(start_idx + HOURS_AHEAD, n)
if end_idx <= start_idx:
LOGGER.error("Invalid horizon window (start=%s end=%s).", start_idx, end_idx)
return
# Anti-glitch su serie vento/pioggia prima dell'analisi di persistenza
rain, rain_glitch = sanitize_hourly_values(rain_arome[:n], rain_icon, "precip", start_idx, end_idx)
gusts, gust_glitch = sanitize_hourly_values(gusts_arome[:n], gusts_icon, "gusts", start_idx, end_idx)
if rain_glitch or gust_glitch:
LOGGER.info("Anti-glitch: %d ore pioggia, %d ore vento corrette", rain_glitch, gust_glitch)
if DEBUG:
LOGGER.debug("model=%s start_idx=%s end_idx=%s (hours=%s)",
model_used, start_idx, end_idx, end_idx - start_idx)
# --- 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(
gusts=gusts,
times=times,
start_idx=start_idx,
end_idx=end_idx,
persist_hours=PERSIST_HOURS
)
# --- Rain persistence (3h windows) ---
rain_max_3h, rain_start, rain_persist = best_rain_persistent_3h(
rain=rain,
times=times,
start_idx=start_idx,
end_idx=end_idx,
limit_3h=RAIN_3H_LIMIT,
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 (contenuto) ---
alerts: List[str] = []
sig_storms = filter_significant_storms(storm_events, now)
prune_alert_daily(state, now)
# 1) Temporali severi significativi
if sig_storms:
convective_msg = format_convective_alert(sig_storms, times, start_idx)
if convective_msg:
alerts.append(convective_msg)
state["convective_storm_active"] = True
state["last_storm_score"] = float(max(e["score"] for e in sig_storms))
else:
state["convective_storm_active"] = False
state["last_storm_score"] = 0.0
# 2) Vento persistente — soglia livello scalata per anticipo
wind_lead = lead_hours_from_start_str(wind_start, now) if wind_level_curr > 0 else 0.0
wind_notify = wind_meets_lead_threshold(wind_level_curr, wind_lead)
if wind_notify:
wind_msg = wind_message(wind_level_curr, wind_peak, wind_start, wind_run_len)
tier = lead_tier(wind_lead)
if tier != "near":
wind_msg += f"\n🔭 Anticipo ~{wind_lead:.0f}h (fascia {tier}: soglia elevata)"
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)
state["wind_level"] = wind_level_curr
state["last_wind_peak"] = float(wind_peak)
elif wind_level_curr > 0:
LOGGER.info(
"Vento livello %s soppresso (anticipo ~%.0fh, fascia %s richiede livello ≥%s)",
wind_level_curr, wind_lead, lead_tier(wind_lead),
LEAD_WIND_MIN_LEVEL[lead_tier(wind_lead)],
)
state["wind_level"] = 0
state["last_wind_peak"] = 0.0
else:
state["wind_level"] = 0
state["last_wind_peak"] = 0.0
# 3) Pioggia persistente — mm/3h scalati per anticipo
rain_lead = lead_hours_from_start_str(rain_start, now) if rain_persist >= PERSIST_HOURS else 0.0
rain_base_ok = rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT
rain_notify = rain_base_ok and rain_meets_lead_threshold(rain_max_3h, rain_lead)
if rain_notify:
rain_analysis = None
if rain_start:
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):
rain_analysis = analyze_rainfall_event(
times=times,
precipitation=rain,
weathercode=wcode,
start_idx=rain_start_idx,
max_hours=HOURS_AHEAD,
threshold_mm_h=8.0,
)
rain_msg = rain_message(rain_max_3h, rain_start, rain_persist, rain_analysis=rain_analysis)
tier = lead_tier(rain_lead)
if tier != "near":
rain_msg += (
f"\n🔭 Anticipo ~{rain_lead:.0f}h (fascia {tier}: "
f"soglia {LEAD_RAIN_3H[tier]:.0f} mm/3h)"
)
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)
state["last_rain_3h"] = float(rain_max_3h)
elif rain_base_ok:
LOGGER.info(
"Pioggia %.1f mm/3h soppressa (anticipo ~%.0fh, fascia %s richiede ≥%.0f mm/3h)",
rain_max_3h, rain_lead, lead_tier(rain_lead),
LEAD_RAIN_3H[lead_tier(rain_lead)],
)
state["last_rain_3h"] = 0.0
else:
state["last_rain_3h"] = 0.0
is_alarm_now = (
(len(sig_storms) > 0)
or wind_notify
or rain_notify
)
should_notify = bool(alerts)
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
alert_signature = build_alert_signature(
alerts,
wind_level_curr if wind_notify else 0,
rain_max_3h if rain_notify else 0.0,
sig_storms,
)
# --- Gate anti-spam globale (max 1/giorno + cooldown, escalation se peggioramento) ---
if should_notify and alerts and not debug_message_only:
today_key = _today_key(now)
if (state.get("last_alert_signature") == alert_signature
and state.get("last_alert_signature_date") == today_key):
LOGGER.info("Alert soppresso (dedup): situazione invariata già notificata oggi")
should_notify = False
elif not debug_mode:
escalation = is_alert_escalation(state, alert_signature, wind_level_curr, rain_max_3h)
allowed, reason = can_notify_message(now, state, allow_escalation=escalation)
if not allowed:
LOGGER.info("Alert soppresso (rate-limit): %s", reason)
should_notify = False
# --- 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 sig_storms:
model_info = f"{model_used} + ICON Italia (analisi convettiva combinata)"
meta = (
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 = "\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, chat_ids=chat_ids)
if ok:
LOGGER.info("Alert sent successfully.")
else:
LOGGER.warning("Alert NOT sent (token missing or Telegram error).")
# 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:
alert_types = []
if sig_storms and len(sig_storms) > 0:
alert_types.append("TEMPORALI SEVERI")
if wind_notify:
wind_labels = {3: "TEMPESTA", 2: "VENTO MOLTO FORTE", 1: "VENTO FORTE"}
alert_types.append(wind_labels.get(wind_level_curr, "VENTO FORTE"))
if rain_notify:
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()
if ok:
record_notify_message(now, state, alert_signature)
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):
# 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"
])
msg = "\n".join(msg_parts)
ok = telegram_send_html(msg, chat_ids=chat_ids)
if ok:
LOGGER.info("All-clear sent successfully.")
else:
LOGGER.warning("All-clear NOT sent (token missing or Telegram error).")
state = {
"alert_active": False,
"wind_level": 0,
"last_wind_peak": 0.0,
"last_rain_3h": 0.0,
"convective_storm_active": False,
"last_storm_score": 0.0,
"last_alert_type": None,
"last_alert_time": None,
"alert_daily": state.get("alert_daily", {}),
"last_alert_sent": state.get("last_alert_sent"),
"last_alert_signature": None,
"last_alert_signature_date": None,
}
save_state(state)
return
# 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) 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__":
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)