From 4377b8c9ea99ec3a02a0d07c92a42126489153a7 Mon Sep 17 00:00:00 2001 From: daniele Date: Sun, 28 Jun 2026 07:00:02 +0200 Subject: [PATCH] Backup automatico script del 2026-06-28 07:00 --- .../severe_weather_circondario.py | 309 ++++++++++++++---- 1 file changed, 243 insertions(+), 66 deletions(-) diff --git a/services/telegram-bot/severe_weather_circondario.py b/services/telegram-bot/severe_weather_circondario.py index 4cdb41b..5ac71b1 100755 --- a/services/telegram-bot/severe_weather_circondario.py +++ b/services/telegram-bot/severe_weather_circondario.py @@ -59,9 +59,11 @@ LOCALITA_CIRCONDARIO = [ ] # ----------------- THRESHOLDS ----------------- -HOURS_AHEAD = 24 # Analisi 24 ore +HOURS_AHEAD = 48 # Analisi estesa a 48 ore (finestra previsionale completa) +IMMINENT_HOURS = 24 # Finestra "imminente": eventi entro le prossime 24 ore # ----------------- CONVECTIVE STORM THRESHOLDS ----------------- +# Soglie di RILEVAMENTO (per la scansione interna degli eventi convettivi) 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 @@ -70,6 +72,23 @@ RAIN_INTENSE_THRESHOLD_3H = 40.0 # mm/3h - Soglia per nubifragio su 3 ore RAIN_FLOOD_THRESHOLD_24H = 100.0 # mm/24h - Soglia per rischio alluvioni STORM_SCORE_THRESHOLD = 40.0 # Storm Severity Score minimo per allerta +# ----------------- SOGLIE DI SIGNIFICATIVITÀ (per NOTIFICA) ----------------- +# Una località viene notificata SOLO se ha almeno un evento "significativo": +# fulminazioni forti, bomba d'acqua/nubifragio forte, alluvione o downburst. +# Soglie volutamente alte per filtrare i temporali marginali estivi. +SIGNIFICANT_CAPE_LIGHTNING = 1500.0 # J/kg - fulminazioni rilevanti (oltre il marginale) +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_SCORE = 55.0 # Storm Severity Score minimo per considerare l'evento significativo + +# ----------------- RATE LIMITING NOTIFICHE ----------------- +# Notifiche "molto contingentate": +# - eventi imminenti (entro IMMINENT_HOURS): massimo 2 al giorno +# - eventi solo nella finestra estesa (24-48h): massimo 1 al giorno +MAX_ALERTS_IMMINENT_PER_DAY = 2 +MAX_ALERTS_EXTENDED_PER_DAY = 1 +MIN_GAP_HOURS_IMMINENT = 6.0 # distanza minima (ore) fra i due alert imminenti giornalieri + # ----------------- FILES ----------------- BASE_DIR = os.path.dirname(os.path.abspath(__file__)) STATE_FILE = os.path.join(BASE_DIR, "weather_state_circondario.json") @@ -213,7 +232,13 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) def load_state() -> Dict: default = { "alert_active": False, - "locations": {}, # {location_name: {"last_score": 0.0, "last_storm_time": None}} + # Conteggi giornalieri delle notifiche inviate, per finestra: + # {"YYYY-MM-DD": {"imminent": int, "extended": int}} + "daily": {}, + "last_sent_imminent": None, # ISO timestamp ultimo alert imminente + "last_sent_extended": None, # ISO timestamp ultimo alert esteso + "last_signature": None, # firma del contenuto dell'ultimo alert inviato + "last_signature_date": None, # data (YYYY-MM-DD) della firma } if os.path.exists(STATE_FILE): try: @@ -234,6 +259,130 @@ def save_state(state: Dict) -> None: LOGGER.exception("State write error: %s", e) +# ============================================================================= +# SIGNIFICATIVITÀ EVENTI (gating notifiche) +# ============================================================================= +def event_significance(event: Dict) -> List[str]: + """ + Ritorna la lista dei motivi per cui un evento è "significativo" (degno di notifica). + Lista vuota = evento marginale, da NON notificare. + + Significativo se almeno uno tra: + - Fulminazioni forti: CAPE >= SIGNIFICANT_CAPE_LIGHTNING e LPI > 0 + - Bomba d'acqua: precip oraria >= SIGNIFICANT_PRECIP_H o 3h >= SIGNIFICANT_PRECIP_3H + - Rischio Alluvioni: già marcato in threats (accumulo 24h > soglia) + - Downburst/Temporale violento: già marcato in threats + - Storm Severity Score molto elevato (>= SIGNIFICANT_SCORE) + """ + 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) + + if "Fulminazioni" in threats and cape >= SIGNIFICANT_CAPE_LIGHTNING and lpi > 0: + reasons.append("Fulminazioni forti") + if precip >= SIGNIFICANT_PRECIP_H or precip_3h >= SIGNIFICANT_PRECIP_3H: + reasons.append("Bomba d'acqua") + if "Rischio Alluvioni" in threats: + reasons.append("Rischio Alluvioni") + if "Downburst/Temporale violento" in threats: + reasons.append("Downburst") + if score >= SIGNIFICANT_SCORE and not reasons: + reasons.append("Temporale severo") + + return reasons + + +def filter_significant_events(storm_events: List[Dict], now: datetime.datetime) -> List[Dict]: + """Filtra gli eventi mantenendo solo quelli significativi e arricchendoli con + 'lead_hours' (ore di anticipo rispetto a ora) e 'significance' (motivi).""" + significant: List[Dict] = [] + for ev in storm_events or []: + reasons = event_significance(ev) + if not reasons: + continue + try: + lead = (parse_time_to_local(ev["timestamp"]) - now).total_seconds() / 3600.0 + except Exception: + lead = 0.0 + enriched = dict(ev) + enriched["lead_hours"] = lead + enriched["significance"] = reasons + significant.append(enriched) + return significant + + +# ============================================================================= +# RATE LIMITING NOTIFICHE +# ============================================================================= +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_daily(state: Dict, now: datetime.datetime) -> None: + """Mantiene solo i conteggi di oggi e ieri per evitare crescita illimitata.""" + keep = { + _today_key(now), + _today_key(now - datetime.timedelta(days=1)), + } + daily = state.get("daily", {}) or {} + state["daily"] = {k: v for k, v in daily.items() if k in keep} + + +def can_notify(category: str, now: datetime.datetime, state: Dict) -> Tuple[bool, str]: + """Verifica i limiti giornalieri per categoria ('imminent' | 'extended').""" + key = _today_key(now) + daily = state.setdefault("daily", {}) + day = daily.setdefault(key, {"imminent": 0, "extended": 0}) + + if category == "imminent": + if int(day.get("imminent", 0)) >= MAX_ALERTS_IMMINENT_PER_DAY: + return False, "cap giornaliero imminenti raggiunto" + last = _parse_iso_local(state.get("last_sent_imminent")) + if last and (now - last).total_seconds() < MIN_GAP_HOURS_IMMINENT * 3600: + return False, "cooldown imminenti attivo" + return True, "" + + # extended + if int(day.get("extended", 0)) >= MAX_ALERTS_EXTENDED_PER_DAY: + return False, "cap giornaliero estesi raggiunto" + return True, "" + + +def record_notify(category: str, now: datetime.datetime, state: Dict) -> None: + key = _today_key(now) + day = state.setdefault("daily", {}).setdefault(key, {"imminent": 0, "extended": 0}) + day[category] = int(day.get(category, 0)) + 1 + state["last_sent_" + category] = now.isoformat() + + +def build_signature(category: str, significant_locations: Dict[str, List[Dict]]) -> str: + """Firma del contenuto: categoria + (località, set motivi). Cambia se la + situazione peggiora (nuova località o nuovo tipo di minaccia).""" + parts = [] + for name in sorted(significant_locations.keys()): + reasons = set() + for ev in significant_locations[name]: + reasons.update(ev.get("significance", [])) + parts.append(f"{name}:{'+'.join(sorted(reasons))}") + return f"{category}|" + ";".join(parts) + + # ============================================================================= # OPEN-METEO # ============================================================================= @@ -423,7 +572,7 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[ # MESSAGE FORMATTING # ============================================================================= def format_location_alert(location_name: str, storm_events: List[Dict]) -> str: - """Formatta alert per una singola località.""" + """Formatta alert per una singola località (riceve solo eventi significativi).""" if not storm_events: return "" @@ -432,35 +581,40 @@ def format_location_alert(location_name: str, storm_events: List[Dict]) -> str: last_time = parse_time_to_local(storm_events[-1]["timestamp"]) duration_hours = len(storm_events) - # Raggruppa minacce - all_threats = set() + # Motivi di significatività (perché è scattata la notifica) + reasons = set() for event in storm_events: - all_threats.update(event.get("threats", [])) - - threats_str = ", ".join(all_threats) if all_threats else "Temporali severi" + reasons.update(event.get("significance", [])) + reasons_str = ", ".join(sorted(reasons)) if reasons else "Temporale severo" max_cape = max(e["cape"] for e in storm_events) + max_precip_h = max((e.get("precip", 0) for e in storm_events), default=0) max_precip_24h = max((e.get("precip_24h", 0) for e in storm_events), default=0) msg = ( f"📍 {html.escape(location_name)}\n" - f"📊 Score: {max_score:.0f}/100 | {threats_str}\n" + f"📊 Score: {max_score:.0f}/100 | {html.escape(reasons_str)}\n" f"🕒 {ddmmyyhhmm(first_time)} - {ddmmyyhhmm(last_time)} (~{duration_hours}h)\n" - f"⚡ CAPE max: {max_cape:.0f} J/kg" + f"⚡ CAPE max: {max_cape:.0f} J/kg | 🌧️ Pioggia max: {max_precip_h:.1f} mm/h" ) if max_precip_24h > RAIN_FLOOD_THRESHOLD_24H: - msg += f" | 💧 Accumulo 24h: {max_precip_24h:.1f} mm ⚠️" + msg += f"\n💧 Accumulo 24h: {max_precip_24h:.1f} mm ⚠️ rischio alluvioni" return msg -def format_circondario_alert(locations_data: Dict[str, List[Dict]]) -> str: - """Formatta alert aggregato per tutto il circondario.""" +def format_circondario_alert(locations_data: Dict[str, List[Dict]], category: str = "imminent") -> str: + """Formatta alert aggregato per tutto il circondario (solo eventi significativi).""" if not locations_data: return "" - headline = "⛈️ ALLERTA TEMPORALI SEVERI - CIRCONDARIO" + if category == "imminent": + headline = "⛈️ ALLERTA TEMPORALI SEVERI - CIRCONDARIO (imminente ≤24h)" + window_str = f"prossime {IMMINENT_HOURS} ore" + else: + headline = "⛈️ ALLERTA TEMPORALI SEVERI - CIRCONDARIO (48h)" + window_str = f"prossime {HOURS_AHEAD} ore" # Statistiche aggregate total_locations = len(locations_data) @@ -483,9 +637,10 @@ def format_circondario_alert(locations_data: Dict[str, List[Dict]]) -> str: period_str = "N/A" meta = ( - f"📍 {total_locations} località con rischio temporali severi\n" + f"📍 {total_locations} località con temporali significativi\n" f"📊 Storm Severity Score max: {max_score_overall:.0f}/100\n" - f"🕒 Periodo: {period_str}\n" + f"🕒 Periodo evento: {period_str}\n" + f"🔭 Finestra analisi: {window_str}\n" f"🛰️ Modelli: AROME Seamless + ICON Italia\n" ) @@ -550,77 +705,99 @@ def analyze_location(location: Dict) -> Optional[List[Dict]]: def analyze_all_locations(debug_mode: bool = False) -> None: - """Analizza tutte le località del circondario.""" + """Analizza tutte le località del circondario con anti-spam a finestre. + + Notifica SOLO eventi significativi (fulminazioni forti / bombe d'acqua / + alluvioni / downburst) e in modo contingentato: + - eventi imminenti (≤24h): max 2/giorno (con cooldown minimo) + - eventi solo estesi (24-48h): max 1/giorno + """ LOGGER.info("=== Analisi Temporali Severi - Circondario ===") + now = now_local() state = load_state() + prune_daily(state, now) was_alert_active = bool(state.get("alert_active", False)) - locations_with_risk = {} + # Raccoglie SOLO eventi significativi per località + significant_locations: Dict[str, List[Dict]] = {} for location in LOCALITA_CIRCONDARIO: name = location["name"] storm_events = analyze_location(location) - - if storm_events: - locations_with_risk[name] = storm_events - max_score = max(e["score"] for e in storm_events) - - # Controlla se è un nuovo evento o peggioramento - loc_state = state.get("locations", {}).get(name, {}) - prev_score = float(loc_state.get("last_score", 0.0) or 0.0) - - if debug_mode or not loc_state.get("alert_sent", False) or (max_score >= prev_score + 15.0): - # Aggiorna stato - if "locations" not in state: - state["locations"] = {} - state["locations"][name] = { - "last_score": float(max_score), - "alert_sent": True, - "last_storm_time": storm_events[0]["timestamp"] - } - + sig_events = filter_significant_events(storm_events, now) + if sig_events: + significant_locations[name] = sig_events time.sleep(0.5) # Rate limiting per API - # Invia alert se ci sono località a rischio - if locations_with_risk or debug_mode: - if locations_with_risk: - msg = format_circondario_alert(locations_with_risk) - if msg: - ok = telegram_send_html(msg) - if ok: - LOGGER.info("Alert inviato per %d località", len(locations_with_risk)) - else: - LOGGER.warning("Alert NON inviato (token missing o errore Telegram)") - - state["alert_active"] = True - save_state(state) + # --- Nessun evento significativo --- + if not significant_locations: + if was_alert_active: + msg = ( + "🟢 ALLERTA TEMPORALI SEVERI - RIENTRATA\n" + "Condizioni rientrate sotto le soglie di guardia per tutte le località del circondario." + ) + telegram_send_html(msg) + LOGGER.info("All-clear inviato") elif debug_mode: - # In modalità debug, invia messaggio anche senza rischi msg = ( "ℹ️ ANALISI CIRCONDARIO - Nessun Rischio\n" f"📍 Analizzate {len(LOCALITA_CIRCONDARIO)} località\n" f"🕒 Finestra: prossime {HOURS_AHEAD} ore\n" - "Nessun temporale severo previsto nel circondario." + "Nessun temporale significativo previsto nel circondario." ) telegram_send_html(msg) LOGGER.info("Messaggio debug inviato (nessun rischio)") + state["alert_active"] = False + state["last_signature"] = None + state["last_signature_date"] = None + save_state(state) + return - # All-clear se era attivo e ora non c'è più rischio - if was_alert_active and not locations_with_risk: - msg = ( - "🟢 ALLERTA TEMPORALI SEVERI - RIENTRATA\n" - "Condizioni rientrate sotto le soglie di guardia per tutte le località del circondario." - ) - telegram_send_html(msg) - LOGGER.info("All-clear inviato") - - state["alert_active"] = False - state["locations"] = {} - save_state(state) - elif not locations_with_risk: - state["alert_active"] = False + # --- Determina la categoria (imminente vs esteso) --- + has_imminent = any( + any(ev.get("lead_hours", 0.0) <= IMMINENT_HOURS for ev in events) + for events in significant_locations.values() + ) + category = "imminent" if has_imminent else "extended" + + # --- Dedup: stessa situazione già notificata oggi -> non re-inviare --- + signature = build_signature(category, significant_locations) + today = _today_key(now) + if not debug_mode: + if state.get("last_signature") == signature and state.get("last_signature_date") == today: + LOGGER.info("Alert soppresso (dedup): situazione invariata già notificata oggi [%s]", category) + state["alert_active"] = True + save_state(state) + return + + # --- Rate limiting per finestra --- + if not debug_mode: + allowed, reason = can_notify(category, now, state) + if not allowed: + LOGGER.info("Alert soppresso (rate-limit %s): %s", category, reason) + state["alert_active"] = True + save_state(state) + return + + # --- Invio --- + msg = format_circondario_alert(significant_locations, category=category) + if not msg: save_state(state) + return + + ok = telegram_send_html(msg, chat_ids=[TELEGRAM_CHAT_IDS[0]] if debug_mode else None) + if ok: + LOGGER.info("Alert inviato (%s) per %d località significative", category, len(significant_locations)) + else: + LOGGER.warning("Alert NON inviato (token missing o errore Telegram)") + + if ok and not debug_mode: + record_notify(category, now, state) + state["last_signature"] = signature + state["last_signature_date"] = today + state["alert_active"] = True + save_state(state) if __name__ == "__main__":