Backup automatico script del 2026-06-28 07:00
This commit is contained in:
@@ -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"📍 <b>{html.escape(location_name)}</b>\n"
|
||||
f"📊 Score: <b>{max_score:.0f}/100</b> | {threats_str}\n"
|
||||
f"📊 Score: <b>{max_score:.0f}/100</b> | <b>{html.escape(reasons_str)}</b>\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: <b>{max_precip_24h:.1f} mm</b> ⚠️"
|
||||
msg += f"\n💧 <b>Accumulo 24h: {max_precip_24h:.1f} mm</b> ⚠️ 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 = "⛈️ <b>ALLERTA TEMPORALI SEVERI - CIRCONDARIO</b>"
|
||||
if category == "imminent":
|
||||
headline = "⛈️ <b>ALLERTA TEMPORALI SEVERI - CIRCONDARIO (imminente ≤24h)</b>"
|
||||
window_str = f"prossime {IMMINENT_HOURS} ore"
|
||||
else:
|
||||
headline = "⛈️ <b>ALLERTA TEMPORALI SEVERI - CIRCONDARIO (48h)</b>"
|
||||
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"📍 <b>{total_locations} località</b> con rischio temporali severi\n"
|
||||
f"📍 <b>{total_locations} località</b> con temporali significativi\n"
|
||||
f"📊 <b>Storm Severity Score max:</b> <b>{max_score_overall:.0f}/100</b>\n"
|
||||
f"🕒 <b>Periodo:</b> {period_str}\n"
|
||||
f"🕒 <b>Periodo evento:</b> {period_str}\n"
|
||||
f"🔭 <b>Finestra analisi:</b> {window_str}\n"
|
||||
f"🛰️ <b>Modelli:</b> AROME Seamless + ICON Italia\n"
|
||||
)
|
||||
|
||||
@@ -550,76 +705,98 @@ 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)
|
||||
elif debug_mode:
|
||||
# In modalità debug, invia messaggio anche senza rischi
|
||||
msg = (
|
||||
"ℹ️ <b>ANALISI CIRCONDARIO - Nessun Rischio</b>\n"
|
||||
f"📍 Analizzate {len(LOCALITA_CIRCONDARIO)} località\n"
|
||||
f"🕒 Finestra: prossime {HOURS_AHEAD} ore\n"
|
||||
"<i>Nessun temporale severo previsto nel circondario.</i>"
|
||||
)
|
||||
telegram_send_html(msg)
|
||||
LOGGER.info("Messaggio debug inviato (nessun rischio)")
|
||||
|
||||
# All-clear se era attivo e ora non c'è più rischio
|
||||
if was_alert_active and not locations_with_risk:
|
||||
# --- Nessun evento significativo ---
|
||||
if not significant_locations:
|
||||
if was_alert_active:
|
||||
msg = (
|
||||
"🟢 <b>ALLERTA TEMPORALI SEVERI - RIENTRATA</b>\n"
|
||||
"<i>Condizioni rientrate sotto le soglie di guardia per tutte le località del circondario.</i>"
|
||||
)
|
||||
telegram_send_html(msg)
|
||||
LOGGER.info("All-clear inviato")
|
||||
|
||||
elif debug_mode:
|
||||
msg = (
|
||||
"ℹ️ <b>ANALISI CIRCONDARIO - Nessun Rischio</b>\n"
|
||||
f"📍 Analizzate {len(LOCALITA_CIRCONDARIO)} località\n"
|
||||
f"🕒 Finestra: prossime {HOURS_AHEAD} ore\n"
|
||||
"<i>Nessun temporale significativo previsto nel circondario.</i>"
|
||||
)
|
||||
telegram_send_html(msg)
|
||||
LOGGER.info("Messaggio debug inviato (nessun rischio)")
|
||||
state["alert_active"] = False
|
||||
state["locations"] = {}
|
||||
state["last_signature"] = None
|
||||
state["last_signature_date"] = None
|
||||
save_state(state)
|
||||
elif not locations_with_risk:
|
||||
state["alert_active"] = False
|
||||
return
|
||||
|
||||
# --- 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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user