From b5a2a814e20295a5f01379acc6086c6b21c5c7be Mon Sep 17 00:00:00 2001 From: daniele Date: Sun, 5 Jul 2026 07:00:03 +0200 Subject: [PATCH] Backup automatico script del 2026-07-05 07:00 --- services/telegram-bot/log_monitor.py | 11 +- services/telegram-bot/severe_weather.py | 208 ++++++++++++++++-- .../severe_weather_circondario.py | 30 ++- 3 files changed, 221 insertions(+), 28 deletions(-) diff --git a/services/telegram-bot/log_monitor.py b/services/telegram-bot/log_monitor.py index 164a360..04049a7 100644 --- a/services/telegram-bot/log_monitor.py +++ b/services/telegram-bot/log_monitor.py @@ -20,6 +20,12 @@ EXCLUDED_FILES = { "road_weather.log", "snow_radar.log", } +SEASONAL_EXCLUDED_FILES = { + "freeze_alert.log", + "arome_snow_alert.log", + "meteo.log", +} +SEASONAL_EXCLUDED_MONTHS = {5, 6, 7, 8, 9} # maggio-settembre # Log irrigazione: aggiornato solo quando lo script viene eseguito (cron --auto o /irrigazione). # Se non c’è un cron giornaliero, il file può restare “non aggiornato” per giorni (normale in inverno). STALE_EXCLUDE_BASENAMES = {"irrigation_advisor.log"} @@ -230,7 +236,10 @@ def main(): for pat in DEFAULT_PATTERNS: files.extend(sorted([str(p) for p in Path(BASE_DIR).glob(pat)])) files = sorted(set(files)) - files = [p for p in files if os.path.basename(p) not in EXCLUDED_FILES] + now_month = datetime.datetime.now().month + seasonal_exclusions = SEASONAL_EXCLUDED_FILES if now_month in SEASONAL_EXCLUDED_MONTHS else set() + excluded_names = EXCLUDED_FILES | seasonal_exclusions + files = [p for p in files if os.path.basename(p) not in excluded_names] since = datetime.datetime.now() - datetime.timedelta(days=args.days) category_hits, per_file_counts, timeout_minutes, stale_logs = analyze_logs(files, since, args.max_lines) diff --git a/services/telegram-bot/severe_weather.py b/services/telegram-bot/severe_weather.py index dc1a856..54eef8f 100644 --- a/services/telegram-bot/severe_weather.py +++ b/services/telegram-bot/severe_weather.py @@ -72,6 +72,33 @@ 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) ----------------- +# Si notifica solo se c'è almeno un evento "significativo": fulminazioni forti, +# bomba d'acqua o downburst. Soglie alte per filtrare i temporali marginali estivi. +SIGNIFICANT_CAPE_LIGHTNING = 1200.0 # J/kg - fulminazioni rilevanti (instabilità moderata: scelta orientata alla sicurezza) +# La fulminazione "forte" deve corrispondere a una cella temporalesca prevista sul posto: +# oltre a CAPE+LPI richiede precipitazione convettiva consistente (non semplice pioggerella). +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 (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__)) @@ -239,6 +266,12 @@ 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}} + "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: @@ -761,21 +794,27 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[ 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 - if cape_val > CAPE_LIGHTNING_THRESHOLD and lpi_val > 0: + # 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 - if cape_val > CAPE_SEVERE_THRESHOLD and gusts_val > WIND_GUST_DOWNBURST_THRESHOLD: + # 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 supera soglia - if score >= STORM_SCORE_THRESHOLD or threats: + # 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, @@ -940,6 +979,105 @@ def format_convective_alert(storm_events: List[Dict], times: List[str], start_id return "\n".join(msg_parts) +# ============================================================================= +# SIGNIFICATIVITÀ E ANTI-SPAM (temporali severi) +# ============================================================================= +def storm_event_significance(event: Dict) -> List[str]: + """Ritorna i motivi per cui un evento convettivo è 'significativo' (degno di + notifica). Lista vuota = evento marginale, da NON notificare.""" + 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) + + lightning_precip = (precip >= SIGNIFICANT_LIGHTNING_PRECIP_H or + precip_3h >= SIGNIFICANT_LIGHTNING_PRECIP_3H) + if ("Fulminazioni" in threats and cape >= SIGNIFICANT_CAPE_LIGHTNING + and lpi > 0 and lightning_precip): + reasons.append("Fulminazioni forti") + if precip >= SIGNIFICANT_PRECIP_H or precip_3h >= SIGNIFICANT_PRECIP_3H: + reasons.append("Bomba d'acqua") + if "Downburst/Temporale violento" in threats: + reasons.append("Downburst") + if score >= SIGNIFICANT_STORM_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 gli eventi significativi, arricchendoli con 'lead_hours' e + 'significance'.""" + out: List[Dict] = [] + for ev in storm_events or []: + reasons = storm_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 + e2 = dict(ev) + e2["lead_hours"] = lead + e2["significance"] = reasons + out.append(e2) + return out + + +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_storm_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} + + +def can_notify_storm(category: str, now: datetime.datetime, state: Dict) -> 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" + return True, "" + + +def record_notify_storm(category: str, now: datetime.datetime, state: Dict) -> 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)) + + # ============================================================================= # PERSISTENCE LOGIC # ============================================================================= @@ -1280,21 +1418,42 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat: should_notify = False # 1) Convective storms (temporali severi) - priorità alta - if storm_events: - prev_storm_active = bool(state.get("convective_storm_active", False)) - max_score = max(e["score"] for e in storm_events) - prev_score = float(state.get("last_storm_score", 0.0) or 0.0) - - # Notifica se: nuovo evento, o score aumenta significativamente (+15 punti) - if debug_mode or not prev_storm_active or (max_score >= prev_score + 15.0): - if debug_mode: - LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato per temporali severi") - convective_msg = format_convective_alert(storm_events, times, start_idx) + # 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) + 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 + 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 + state["convective_storm_active"] = True - state["last_storm_score"] = float(max_score) + 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 @@ -1362,7 +1521,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat: state["last_rain_3h"] = 0.0 is_alarm_now = ( - (storm_events is not None and len(storm_events) > 0) + (len(sig_storms) > 0) or (wind_level_curr > 0) or (rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT) ) @@ -1383,7 +1542,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat: model_info = f"{model_used} + ICON Italia (discordanza rilevata)" # Se ci sono temporali severi, aggiungi informazioni sui modelli usati - if storm_events: + if sig_storms: model_info = f"{model_used} + ICON Italia (analisi convettiva combinata)" meta = ( @@ -1407,7 +1566,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat: if not debug_message_only: # Determina il tipo di allerta basandosi sulle condizioni attuali alert_types = [] - if storm_events and len(storm_events) > 0: + if sig_storms and len(sig_storms) > 0: alert_types.append("TEMPORALI SEVERI") if wind_level_curr > 0: wind_labels = {3: "TEMPESTA", 2: "VENTO MOLTO FORTE", 1: "VENTO FORTE"} @@ -1490,6 +1649,13 @@ 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, } save_state(state) return diff --git a/services/telegram-bot/severe_weather_circondario.py b/services/telegram-bot/severe_weather_circondario.py index 5ac71b1..7fa1716 100755 --- a/services/telegram-bot/severe_weather_circondario.py +++ b/services/telegram-bot/severe_weather_circondario.py @@ -76,11 +76,19 @@ STORM_SCORE_THRESHOLD = 40.0 # Storm Severity Score minimo per allerta # 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_CAPE_LIGHTNING = 1200.0 # J/kg - fulminazioni rilevanti (instabilità moderata: scelta orientata alla sicurezza) +# La fulminazione "forte" deve corrispondere a una cella temporalesca prevista sul posto: +# oltre a CAPE+LPI richiede precipitazione convettiva consistente. +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 (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 +# Pioggia minima per considerare un evento REALMENTE convettivo (no pioggia = no temporale). +CONVECTIVE_MIN_PRECIP_H = 0.5 # mm/h +CONVECTIVE_MIN_PRECIP_3H = 2.0 # mm/3h + # ----------------- RATE LIMITING NOTIFICHE ----------------- # Notifiche "molto contingentate": # - eventi imminenti (entro IMMINENT_HOURS): massimo 2 al giorno @@ -282,7 +290,10 @@ def event_significance(event: Dict) -> List[str]: 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: + lightning_precip = (precip >= SIGNIFICANT_LIGHTNING_PRECIP_H or + precip_3h >= SIGNIFICANT_LIGHTNING_PRECIP_3H) + if ("Fulminazioni" in threats and cape >= SIGNIFICANT_CAPE_LIGHTNING + and lpi > 0 and lightning_precip): reasons.append("Fulminazioni forti") if precip >= SIGNIFICANT_PRECIP_H or precip_3h >= SIGNIFICANT_PRECIP_3H: reasons.append("Bomba d'acqua") @@ -535,11 +546,16 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[ dynamic_score = min(30.0, ((gusts_val - WIND_GUST_DOWNBURST_THRESHOLD) / 40.0) * 30.0) score += dynamic_score - # Identifica minacce - if cape_val > CAPE_LIGHTNING_THRESHOLD and lpi_val > 0: + # 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 (fulminazioni e downburst richiedono precipitazione reale) + if cape_val > CAPE_LIGHTNING_THRESHOLD and lpi_val > 0 and has_convective_precip: threats.append("Fulminazioni") - if cape_val > CAPE_SEVERE_THRESHOLD and gusts_val > WIND_GUST_DOWNBURST_THRESHOLD: + if (cape_val > CAPE_SEVERE_THRESHOLD and gusts_val > WIND_GUST_DOWNBURST_THRESHOLD + and has_convective_precip): threats.append("Downburst/Temporale violento") if precip_val > RAIN_INTENSE_THRESHOLD_H or precip_3h_val > RAIN_INTENSE_THRESHOLD_3H: @@ -552,7 +568,9 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[ flood_bonus = min(10.0, (precip_24h_val - RAIN_FLOOD_THRESHOLD_24H) / 10.0) score += flood_bonus - if score >= STORM_SCORE_THRESHOLD or threats: + # Registra solo eventi con 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,