Backup automatico script del 2026-07-12 07:00
This commit is contained in:
@@ -17,7 +17,7 @@ from dateutil import parser
|
||||
from open_meteo_client import open_meteo_get
|
||||
|
||||
# =============================================================================
|
||||
# SEVERE WEATHER ALERT (next 48h) - Casa (LAT/LON)
|
||||
# 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
|
||||
@@ -62,7 +62,29 @@ RAIN_3H_LIMIT = 25.0
|
||||
PERSIST_HOURS = 2 # richiesta utente: >=2 ore
|
||||
|
||||
# ----------------- HORIZON -----------------
|
||||
HOURS_AHEAD = 48 # Esteso a 48h per analisi temporali severi
|
||||
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
|
||||
@@ -90,15 +112,6 @@ SIGNIFICANT_PRECIP_H = 30.0 # mm/h - bomba d'acqua (nubifragio forte)
|
||||
SIGNIFICANT_PRECIP_3H = 50.0 # mm/3h - nubifragio forte su 3 ore
|
||||
SIGNIFICANT_STORM_SCORE = 55.0 # Storm Severity Score minimo per significativo
|
||||
|
||||
# ----------------- RATE LIMITING NOTIFICHE TEMPORALI -----------------
|
||||
# Notifiche temporali "molto contingentate":
|
||||
# - eventi imminenti (entro IMMINENT_HOURS): massimo 2 al giorno (cooldown 6h)
|
||||
# - eventi solo nella finestra estesa (24-48h): massimo 1 al giorno
|
||||
IMMINENT_HOURS = 24
|
||||
MAX_STORM_ALERTS_IMMINENT_PER_DAY = 2
|
||||
MAX_STORM_ALERTS_EXTENDED_PER_DAY = 1
|
||||
MIN_GAP_HOURS_IMMINENT = 6.0
|
||||
|
||||
# ----------------- FILES -----------------
|
||||
STATE_FILE = "/home/daniely/docker/telegram-bot/weather_state.json"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -250,6 +263,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
||||
except Exception as e:
|
||||
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
|
||||
|
||||
if sent_ok:
|
||||
try:
|
||||
import sys
|
||||
sys.path.insert(0, "/home/daniely/docker/shared")
|
||||
from loogle_core.alert_dispatcher import mirror_to_web
|
||||
mirror_to_web(message_html, "severe_weather", "warning", is_html=True)
|
||||
except Exception as e:
|
||||
LOGGER.debug("Web dispatch failed: %s", e)
|
||||
|
||||
return sent_ok
|
||||
|
||||
|
||||
@@ -266,8 +288,13 @@ def load_state() -> Dict:
|
||||
"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 temporali: conteggi giornalieri per finestra + dedup
|
||||
"storm_daily": {}, # {"YYYY-MM-DD": {"imminent": int, "extended": int}}
|
||||
# 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,
|
||||
@@ -292,6 +319,145 @@ def save_state(state: Dict) -> None:
|
||||
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
|
||||
# =============================================================================
|
||||
@@ -722,6 +888,8 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[
|
||||
|
||||
# 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
|
||||
@@ -730,6 +898,7 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[
|
||||
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
|
||||
@@ -775,6 +944,14 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[
|
||||
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 = []
|
||||
@@ -834,8 +1011,8 @@ def format_convective_alert(storm_events: List[Dict], times: List[str], start_id
|
||||
if not storm_events:
|
||||
return ""
|
||||
|
||||
# Analisi estesa su 48 ore
|
||||
storm_analysis = analyze_convective_storm_event(storm_events, times, start_idx, max_hours=48)
|
||||
# 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)
|
||||
@@ -1008,8 +1185,7 @@ def storm_event_significance(event: Dict) -> List[str]:
|
||||
|
||||
|
||||
def filter_significant_storms(storm_events: List[Dict], now: datetime.datetime) -> List[Dict]:
|
||||
"""Mantiene solo gli eventi significativi, arricchendoli con 'lead_hours' e
|
||||
'significance'."""
|
||||
"""Mantiene solo gli eventi significativi, con cluster minimo e anti-glitch."""
|
||||
out: List[Dict] = []
|
||||
for ev in storm_events or []:
|
||||
reasons = storm_event_significance(ev)
|
||||
@@ -1023,7 +1199,7 @@ def filter_significant_storms(storm_events: List[Dict], now: datetime.datetime)
|
||||
e2["lead_hours"] = lead
|
||||
e2["significance"] = reasons
|
||||
out.append(e2)
|
||||
return out
|
||||
return filter_storm_cluster(out)
|
||||
|
||||
|
||||
def _today_key(now: datetime.datetime) -> str:
|
||||
@@ -1042,40 +1218,65 @@ def _parse_iso_local(s: Optional[str]) -> Optional[datetime.datetime]:
|
||||
return None
|
||||
|
||||
|
||||
def prune_storm_daily(state: Dict, now: datetime.datetime) -> 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("storm_daily", {}) or {}
|
||||
state["storm_daily"] = {k: v for k, v in daily.items() if k in keep}
|
||||
daily = state.get("alert_daily", {}) or {}
|
||||
state["alert_daily"] = {k: v for k, v in daily.items() if k in keep}
|
||||
|
||||
|
||||
def can_notify_storm(category: str, now: datetime.datetime, state: Dict) -> Tuple[bool, str]:
|
||||
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)
|
||||
daily = state.setdefault("storm_daily", {})
|
||||
day = daily.setdefault(key, {"imminent": 0, "extended": 0})
|
||||
if category == "imminent":
|
||||
if int(day.get("imminent", 0)) >= MAX_STORM_ALERTS_IMMINENT_PER_DAY:
|
||||
return False, "cap giornaliero imminenti raggiunto"
|
||||
last = _parse_iso_local(state.get("storm_last_sent_imminent"))
|
||||
if last and (now - last).total_seconds() < MIN_GAP_HOURS_IMMINENT * 3600:
|
||||
return False, "cooldown imminenti attivo"
|
||||
return True, ""
|
||||
if int(day.get("extended", 0)) >= MAX_STORM_ALERTS_EXTENDED_PER_DAY:
|
||||
return False, "cap giornaliero estesi raggiunto"
|
||||
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_storm(category: str, now: datetime.datetime, state: Dict) -> None:
|
||||
def record_notify_message(now: datetime.datetime, state: Dict, signature: str) -> None:
|
||||
key = _today_key(now)
|
||||
day = state.setdefault("storm_daily", {}).setdefault(key, {"imminent": 0, "extended": 0})
|
||||
day[category] = int(day.get(category, 0)) + 1
|
||||
state["storm_last_sent_" + category] = now.isoformat()
|
||||
|
||||
|
||||
def build_storm_signature(category: str, sig_events: List[Dict]) -> str:
|
||||
reasons = set()
|
||||
for ev in sig_events:
|
||||
reasons.update(ev.get("significance", []))
|
||||
return f"{category}|" + "+".join(sorted(reasons))
|
||||
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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1276,9 +1477,9 @@ def rain_message(max_3h: float, start_hhmm: str, persist_h: int, rain_analysis:
|
||||
|
||||
if end_time:
|
||||
end_str = end_time.strftime("%d/%m %H:%M")
|
||||
msg_parts.append(f"⏱️ <b>Durata totale evento (48h):</b> ~{duration_h} ore (fino alle {end_str})")
|
||||
msg_parts.append(f"⏱️ <b>Durata totale evento ({HOURS_AHEAD}h):</b> ~{duration_h} ore (fino alle {end_str})")
|
||||
else:
|
||||
msg_parts.append(f"⏱️ <b>Durata totale evento (48h):</b> ~{duration_h} ore (in corso)")
|
||||
msg_parts.append(f"⏱️ <b>Durata totale evento ({HOURS_AHEAD}h):</b> ~{duration_h} ore (in corso)")
|
||||
|
||||
msg_parts.append(f"💧 <b>Accumulo totale previsto:</b> ~{total_mm:.1f} mm")
|
||||
msg_parts.append(f"🌧️ <b>Intensità massima oraria:</b> {max_intensity:.1f} mm/h")
|
||||
@@ -1323,8 +1524,6 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
return
|
||||
|
||||
times = times[:n]
|
||||
gusts = gusts_arome[:n]
|
||||
rain = rain_arome[:n]
|
||||
wcode = wcode_arome[:n]
|
||||
|
||||
now = now_local()
|
||||
@@ -1346,6 +1545,12 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
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)
|
||||
@@ -1413,109 +1618,62 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
if comp_rain:
|
||||
comparisons["rain"] = comp_rain
|
||||
|
||||
# --- Decide notifications ---
|
||||
# --- Decide notifications (contenuto) ---
|
||||
alerts: List[str] = []
|
||||
should_notify = False
|
||||
|
||||
# 1) Convective storms (temporali severi) - priorità alta
|
||||
# Considera SOLO eventi significativi (fulminazioni forti / bombe d'acqua /
|
||||
# downburst) e applica anti-spam a finestre + dedup giornaliero.
|
||||
sig_storms = filter_significant_storms(storm_events, now)
|
||||
prune_storm_daily(state, now)
|
||||
prune_alert_daily(state, now)
|
||||
|
||||
# 1) Temporali severi significativi
|
||||
if sig_storms:
|
||||
has_imminent = any(e.get("lead_hours", 0.0) <= IMMINENT_HOURS for e in sig_storms)
|
||||
storm_category = "imminent" if has_imminent else "extended"
|
||||
storm_signature = build_storm_signature(storm_category, sig_storms)
|
||||
today_key = _today_key(now)
|
||||
|
||||
send_storm = False
|
||||
if debug_mode:
|
||||
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato per temporali severi")
|
||||
send_storm = True
|
||||
elif (state.get("storm_last_signature") == storm_signature
|
||||
and state.get("storm_last_signature_date") == today_key):
|
||||
LOGGER.info("Temporali: alert soppresso (dedup): situazione invariata già notificata oggi [%s]", storm_category)
|
||||
else:
|
||||
allowed, reason = can_notify_storm(storm_category, now, state)
|
||||
if allowed:
|
||||
send_storm = True
|
||||
else:
|
||||
LOGGER.info("Temporali: alert soppresso (rate-limit %s): %s", storm_category, reason)
|
||||
|
||||
if send_storm:
|
||||
convective_msg = format_convective_alert(sig_storms, times, start_idx)
|
||||
if convective_msg:
|
||||
alerts.append(convective_msg)
|
||||
should_notify = True
|
||||
if not debug_mode:
|
||||
record_notify_storm(storm_category, now, state)
|
||||
state["storm_last_signature"] = storm_signature
|
||||
state["storm_last_signature_date"] = today_key
|
||||
|
||||
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) Wind (persistent)
|
||||
# 2) Vento persistente
|
||||
if wind_level_curr > 0:
|
||||
prev_level = int(state.get("wind_level", 0) or 0)
|
||||
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⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.0f} km/h | ICON {comp['icon']:.0f} km/h (scostamento {comp['diff_pct']:.0f}%)"
|
||||
alerts.append(wind_msg)
|
||||
should_notify = True
|
||||
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⚠️ <b>Discordanza modelli</b>: 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)
|
||||
else:
|
||||
state["wind_level"] = 0
|
||||
state["last_wind_peak"] = 0.0
|
||||
|
||||
# 3) Rain (persistent)
|
||||
# 3) Pioggia persistente
|
||||
if rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT:
|
||||
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 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⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.1f} mm | ICON {comp['icon']:.1f} mm (scostamento {comp['diff_pct']:.0f}%)"
|
||||
alerts.append(rain_msg)
|
||||
should_notify = True
|
||||
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)
|
||||
if "rain" in comparisons:
|
||||
comp = comparisons["rain"]
|
||||
rain_msg += f"\n⚠️ <b>Discordanza modelli</b>: 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)
|
||||
else:
|
||||
state["last_rain_3h"] = 0.0
|
||||
@@ -1526,13 +1684,29 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
or (rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT)
|
||||
)
|
||||
|
||||
# In modalità debug, forza invio anche se non ci sono allerte
|
||||
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("ℹ️ <i>Nessuna condizione meteo severa rilevata nelle prossime %s ore.</i>" % HOURS_AHEAD)
|
||||
should_notify = True
|
||||
debug_message_only = True # Segnala che è solo un messaggio debug, non una vera allerta
|
||||
debug_message_only = True
|
||||
|
||||
alert_signature = build_alert_signature(alerts, wind_level_curr, rain_max_3h, 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:
|
||||
@@ -1564,7 +1738,6 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
# 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 sig_storms and len(sig_storms) > 0:
|
||||
alert_types.append("TEMPORALI SEVERI")
|
||||
@@ -1577,6 +1750,8 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
state["alert_active"] = True
|
||||
state["last_alert_type"] = alert_types if alert_types else None
|
||||
state["last_alert_time"] = now.isoformat()
|
||||
if ok:
|
||||
record_notify_message(now, state, alert_signature)
|
||||
save_state(state)
|
||||
else:
|
||||
# In debug mode senza vere allerte, non modificare alert_active
|
||||
@@ -1649,13 +1824,10 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
"last_storm_score": 0.0,
|
||||
"last_alert_type": None,
|
||||
"last_alert_time": None,
|
||||
# Preserva i contatori giornalieri/cooldown temporali (i cap valgono
|
||||
# per l'intera giornata anche dopo un all-clear); azzera solo la firma.
|
||||
"storm_daily": state.get("storm_daily", {}),
|
||||
"storm_last_sent_imminent": state.get("storm_last_sent_imminent"),
|
||||
"storm_last_sent_extended": state.get("storm_last_sent_extended"),
|
||||
"storm_last_signature": None,
|
||||
"storm_last_signature_date": 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
|
||||
|
||||
Reference in New Issue
Block a user