Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5a2a814e2 | ||
|
|
bd3b4b66cc |
@@ -28,6 +28,10 @@ DEBUG = os.environ.get("DEBUG", "0").strip() == "1"
|
|||||||
|
|
||||||
BULLETIN_URL = "https://mappe.protezionecivile.gov.it/it/mappe-rischi/bollettino-di-criticita/"
|
BULLETIN_URL = "https://mappe.protezionecivile.gov.it/it/mappe-rischi/bollettino-di-criticita/"
|
||||||
|
|
||||||
|
# Allerta meteo regionale Emilia-Romagna (Arpae): copre categorie assenti dal
|
||||||
|
# bollettino nazionale (temperature estreme, vento, neve, mareggiate, ecc.).
|
||||||
|
REGIONAL_ALERT_URL = "https://allertameteo.regione.emilia-romagna.it/o/get-stato-allerta"
|
||||||
|
|
||||||
# Chat IDs
|
# Chat IDs
|
||||||
TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"]
|
TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"]
|
||||||
|
|
||||||
@@ -48,10 +52,20 @@ TARGET_ZONES = {
|
|||||||
"EMR-D1": "Pianura bolognese",
|
"EMR-D1": "Pianura bolognese",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Mappa codice zona regionale Arpae -> nome leggibile (deriva da TARGET_ZONES,
|
||||||
|
# togliendo il prefisso "EMR-": es. EMR-D1 -> D1 "Pianura bolognese").
|
||||||
|
REGIONAL_TARGET_ZONES = {code.split("-")[-1]: name for code, name in TARGET_ZONES.items()}
|
||||||
|
|
||||||
RISK_ICON = {
|
RISK_ICON = {
|
||||||
"IDRAULICO": "💧",
|
"IDRAULICO": "💧",
|
||||||
"IDROGEOLOGICO": "⛰️",
|
"IDROGEOLOGICO": "⛰️",
|
||||||
"TEMPORALI": "⚡",
|
"TEMPORALI": "⚡",
|
||||||
|
"TEMPERATURE ESTREME": "🌡️",
|
||||||
|
"VENTO": "🌬️",
|
||||||
|
"NEVE": "❄️",
|
||||||
|
"STATO DEL MARE": "🌊",
|
||||||
|
"MAREGGIATE": "🌊",
|
||||||
|
"PIOGGIA CHE GELA": "🧊",
|
||||||
}
|
}
|
||||||
|
|
||||||
COLOR_ICON = {
|
COLOR_ICON = {
|
||||||
@@ -60,6 +74,25 @@ COLOR_ICON = {
|
|||||||
"ROSSA": "🔴",
|
"ROSSA": "🔴",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Categorie di rischio prese SOLO dalla fonte regionale (quelle che il bollettino
|
||||||
|
# nazionale non pubblica). Idraulico/idrogeologico/temporali restano gestiti dal
|
||||||
|
# bollettino nazionale per evitare doppioni.
|
||||||
|
REGIONAL_RISK_LABELS = {
|
||||||
|
"temperature_estreme": "TEMPERATURE ESTREME",
|
||||||
|
"vento": "VENTO",
|
||||||
|
"neve": "NEVE",
|
||||||
|
"stato_mare": "STATO DEL MARE",
|
||||||
|
"mareggiate": "MAREGGIATE",
|
||||||
|
"ghiaccio_pioggia_gela": "PIOGGIA CHE GELA",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Codici colore Arpae -> etichette interne (green/null = nessuna allerta).
|
||||||
|
REGIONAL_COLOR_MAP = {
|
||||||
|
"yellow": "GIALLA",
|
||||||
|
"orange": "ARANCIONE",
|
||||||
|
"red": "ROSSA",
|
||||||
|
}
|
||||||
|
|
||||||
HTTP_HEADERS = {"User-Agent": "rpi-dpc-bollettino/1.2"}
|
HTTP_HEADERS = {"User-Agent": "rpi-dpc-bollettino/1.2"}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -222,7 +255,7 @@ def html_to_lines(html_text: str) -> list[str]:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
RISK_HEADER_RE = re.compile(
|
RISK_HEADER_RE = re.compile(
|
||||||
r"^(ORDINARIA|MODERATA|ELEVATA)\s+CRITICITA'\s+PER\s+RISCHIO\s+([A-ZÀ-Ü]+)\s*/\s*ALLERTA\s+(GIALLA|ARANCIONE|ROSSA)\s*:\s*$"
|
r"^(ORDINARIA|MODERATA|ELEVATA)\s+CRITICITA'\s+PER\s+RISCHIO\s+([A-ZÀ-Ü]+(?:\s+[A-ZÀ-Ü]+)*)\s*/\s*ALLERTA\s+(GIALLA|ARANCIONE|ROSSA)\s*:\s*$"
|
||||||
)
|
)
|
||||||
DAY_START_RE = re.compile(r"^Per la giornata di (oggi|domani),\s*(.+?):\s*$", re.IGNORECASE)
|
DAY_START_RE = re.compile(r"^Per la giornata di (oggi|domani),\s*(.+?):\s*$", re.IGNORECASE)
|
||||||
TITLE_RE = re.compile(r"^Bollettino di Criticità del (.+?) ore (\d{1,2}:\d{2})", re.IGNORECASE)
|
TITLE_RE = re.compile(r"^Bollettino di Criticità del (.+?) ore (\d{1,2}:\d{2})", re.IGNORECASE)
|
||||||
@@ -293,8 +326,66 @@ def has_any_alert(parsed: dict) -> bool:
|
|||||||
day = parsed.get("days", {}).get(day_key, {})
|
day = parsed.get("days", {}).get(day_key, {})
|
||||||
if day.get("alerts"):
|
if day.get("alerts"):
|
||||||
return True
|
return True
|
||||||
|
if parsed.get("regional"):
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Allerta regionale Emilia-Romagna (Arpae)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def fetch_regional_state(data: Optional[str] = None) -> dict:
|
||||||
|
"""Stato allertamento regionale. `data` opzionale: 'YYYY-MM-DD HH:mm'."""
|
||||||
|
params = {"data": data} if data else None
|
||||||
|
r = requests.get(REGIONAL_ALERT_URL, params=params, headers=HTTP_HEADERS, timeout=25)
|
||||||
|
r.raise_for_status()
|
||||||
|
r.encoding = "utf-8"
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def parse_regional_doc(data: dict) -> dict:
|
||||||
|
"""Estrae le allerte (solo categorie REGIONAL_RISK_LABELS) per le zone target."""
|
||||||
|
out = {"titolo": (data.get("titolo") or "").strip(), "alerts": {}}
|
||||||
|
for zone_code, zone_name in REGIONAL_TARGET_ZONES.items():
|
||||||
|
zinfo = data.get(zone_code)
|
||||||
|
if not isinstance(zinfo, dict):
|
||||||
|
continue
|
||||||
|
for risk_key, risk_label in REGIONAL_RISK_LABELS.items():
|
||||||
|
color = zinfo.get(risk_key)
|
||||||
|
color_name = REGIONAL_COLOR_MAP.get((color or "").strip().lower())
|
||||||
|
if not color_name:
|
||||||
|
continue
|
||||||
|
icon = COLOR_ICON.get(color_name, "⚪")
|
||||||
|
ricon = RISK_ICON.get(risk_label, "⚠️")
|
||||||
|
entry = f"{icon} {ricon} {risk_label}"
|
||||||
|
out["alerts"].setdefault(zone_name, [])
|
||||||
|
if entry not in out["alerts"][zone_name]:
|
||||||
|
out["alerts"][zone_name].append(entry)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def fetch_regional_documents() -> list:
|
||||||
|
"""Documento valido ora + documento del giorno successivo (preavviso).
|
||||||
|
|
||||||
|
Restituisce solo i documenti che contengono allerte nelle zone target,
|
||||||
|
deduplicati per titolo. Non solleva eccezioni: in caso di errore logga e
|
||||||
|
restituisce la lista dei documenti ottenuti finora.
|
||||||
|
"""
|
||||||
|
docs = []
|
||||||
|
seen_titoli = set()
|
||||||
|
tomorrow = (now_rome() + datetime.timedelta(days=1)).strftime("%Y-%m-%d 12:00")
|
||||||
|
for data in (None, tomorrow):
|
||||||
|
try:
|
||||||
|
j = fetch_regional_state(data)
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.exception("Errore fetch allerta regionale (data=%s): %s", data, e)
|
||||||
|
continue
|
||||||
|
doc = parse_regional_doc(j)
|
||||||
|
if doc["titolo"] in seen_titoli:
|
||||||
|
continue
|
||||||
|
seen_titoli.add(doc["titolo"])
|
||||||
|
if doc["alerts"]:
|
||||||
|
docs.append(doc)
|
||||||
|
return docs
|
||||||
|
|
||||||
def build_signature(parsed: dict) -> str:
|
def build_signature(parsed: dict) -> str:
|
||||||
parts = [parsed.get("issued_date", ""), parsed.get("issued_time", ""), parsed.get("title", "")]
|
parts = [parsed.get("issued_date", ""), parsed.get("issued_time", ""), parsed.get("title", "")]
|
||||||
for day_key in ("oggi", "domani"):
|
for day_key in ("oggi", "domani"):
|
||||||
@@ -303,6 +394,11 @@ def build_signature(parsed: dict) -> str:
|
|||||||
alerts = day.get("alerts", {})
|
alerts = day.get("alerts", {})
|
||||||
for zone in sorted(alerts.keys()):
|
for zone in sorted(alerts.keys()):
|
||||||
parts.append(zone + "=" + ",".join(sorted(alerts[zone])))
|
parts.append(zone + "=" + ",".join(sorted(alerts[zone])))
|
||||||
|
for doc in parsed.get("regional", []):
|
||||||
|
parts.append("REG:" + doc.get("titolo", ""))
|
||||||
|
alerts = doc.get("alerts", {})
|
||||||
|
for zone in sorted(alerts.keys()):
|
||||||
|
parts.append(zone + "=" + ",".join(sorted(alerts[zone])))
|
||||||
return "|".join(parts)
|
return "|".join(parts)
|
||||||
|
|
||||||
def format_message(parsed: dict) -> str:
|
def format_message(parsed: dict) -> str:
|
||||||
@@ -329,6 +425,19 @@ def format_message(parsed: dict) -> str:
|
|||||||
lines.append(html_lib.escape(entry))
|
lines.append(html_lib.escape(entry))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
regional = parsed.get("regional", [])
|
||||||
|
for doc in regional:
|
||||||
|
titolo = doc.get("titolo") or "Allerta meteo Emilia-Romagna"
|
||||||
|
lines.append(f"🗺️ <b>{html_lib.escape(titolo)}</b>")
|
||||||
|
alerts = doc.get("alerts", {})
|
||||||
|
for zone in sorted(alerts.keys()):
|
||||||
|
lines.append(f"📍 <b>{html_lib.escape(zone)}</b>")
|
||||||
|
for entry in alerts[zone]:
|
||||||
|
lines.append(html_lib.escape(entry))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if regional:
|
||||||
|
lines.append("<i>Fonte: allertameteo.regione.emilia-romagna.it</i>")
|
||||||
lines.append("<i>Fonte: mappe.protezionecivile.gov.it</i>")
|
lines.append("<i>Fonte: mappe.protezionecivile.gov.it</i>")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@@ -339,13 +448,17 @@ def format_message(parsed: dict) -> str:
|
|||||||
def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False):
|
def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False):
|
||||||
LOGGER.info("--- Controllo Protezione Civile (Bollettino ufficiale) ---")
|
LOGGER.info("--- Controllo Protezione Civile (Bollettino ufficiale) ---")
|
||||||
|
|
||||||
|
parsed = {"title": "", "issued_date": "", "issued_time": "", "days": {}}
|
||||||
try:
|
try:
|
||||||
lines = fetch_bulletin_text_lines()
|
lines = fetch_bulletin_text_lines()
|
||||||
parsed = parse_bulletin(lines)
|
parsed = parse_bulletin(lines)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# NESSUN Telegram in caso di errori: solo log.
|
# Bollettino nazionale non disponibile: prosegue comunque con la fonte
|
||||||
LOGGER.exception("Errore durante fetch/parse bollettino: %s", e)
|
# regionale (sotto). Nessun Telegram per il solo errore nazionale.
|
||||||
return
|
LOGGER.exception("Errore durante fetch/parse bollettino nazionale: %s", e)
|
||||||
|
|
||||||
|
# Fonte regionale Arpae (indipendente: gestisce internamente i propri errori).
|
||||||
|
parsed["regional"] = fetch_regional_documents()
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
LOGGER.debug("title=%s", parsed.get("title", ""))
|
LOGGER.debug("title=%s", parsed.get("title", ""))
|
||||||
@@ -353,6 +466,8 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False):
|
|||||||
d = parsed.get("days", {}).get(k, {})
|
d = parsed.get("days", {}).get(k, {})
|
||||||
LOGGER.debug("%s label=%s", k, d.get("date_label", ""))
|
LOGGER.debug("%s label=%s", k, d.get("date_label", ""))
|
||||||
LOGGER.debug("%s alerts=%s", k, d.get("alerts", {}))
|
LOGGER.debug("%s alerts=%s", k, d.get("alerts", {}))
|
||||||
|
for doc in parsed.get("regional", []):
|
||||||
|
LOGGER.debug("regionale titolo=%s alerts=%s", doc.get("titolo", ""), doc.get("alerts", {}))
|
||||||
|
|
||||||
# Regola: invia Telegram SOLO se esistono allerte (tranne in debug)
|
# Regola: invia Telegram SOLO se esistono allerte (tranne in debug)
|
||||||
if not has_any_alert(parsed):
|
if not has_any_alert(parsed):
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ EXCLUDED_FILES = {
|
|||||||
"road_weather.log",
|
"road_weather.log",
|
||||||
"snow_radar.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).
|
# 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).
|
# Se non c’è un cron giornaliero, il file può restare “non aggiornato” per giorni (normale in inverno).
|
||||||
STALE_EXCLUDE_BASENAMES = {"irrigation_advisor.log"}
|
STALE_EXCLUDE_BASENAMES = {"irrigation_advisor.log"}
|
||||||
@@ -230,7 +236,10 @@ def main():
|
|||||||
for pat in DEFAULT_PATTERNS:
|
for pat in DEFAULT_PATTERNS:
|
||||||
files.extend(sorted([str(p) for p in Path(BASE_DIR).glob(pat)]))
|
files.extend(sorted([str(p) for p in Path(BASE_DIR).glob(pat)]))
|
||||||
files = sorted(set(files))
|
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)
|
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)
|
category_hits, per_file_counts, timeout_minutes, stale_logs = analyze_logs(files, since, args.max_lines)
|
||||||
|
|||||||
@@ -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
|
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
|
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 -----------------
|
# ----------------- FILES -----------------
|
||||||
STATE_FILE = "/home/daniely/docker/telegram-bot/weather_state.json"
|
STATE_FILE = "/home/daniely/docker/telegram-bot/weather_state.json"
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
@@ -239,6 +266,12 @@ def load_state() -> Dict:
|
|||||||
"last_storm_score": 0.0,
|
"last_storm_score": 0.0,
|
||||||
"last_alert_type": None, # Tipo di allerta: "VENTO", "PIOGGIA", "TEMPORALI", o lista combinata
|
"last_alert_type": None, # Tipo di allerta: "VENTO", "PIOGGIA", "TEMPORALI", o lista combinata
|
||||||
"last_alert_time": None, # Timestamp ISO dell'ultima notifica
|
"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):
|
if os.path.exists(STATE_FILE):
|
||||||
try:
|
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)
|
dynamic_score = min(30.0, ((gusts_val - WIND_GUST_DOWNBURST_THRESHOLD) / 40.0) * 30.0)
|
||||||
score += dynamic_score
|
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
|
# Identifica minacce specifiche
|
||||||
# Fulminazioni
|
# Fulminazioni: CAPE + LPI MA solo se c'è precipitazione convettiva reale
|
||||||
if cape_val > CAPE_LIGHTNING_THRESHOLD and lpi_val > 0:
|
if cape_val > CAPE_LIGHTNING_THRESHOLD and lpi_val > 0 and has_convective_precip:
|
||||||
threats.append("Fulminazioni")
|
threats.append("Fulminazioni")
|
||||||
|
|
||||||
# Downburst/Temporale violento
|
# Downburst/Temporale violento (richiede comunque precipitazione)
|
||||||
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")
|
threats.append("Downburst/Temporale violento")
|
||||||
|
|
||||||
# Nubifragio
|
# Nubifragio
|
||||||
if precip_val > RAIN_INTENSE_THRESHOLD_H or precip_3h_val > RAIN_INTENSE_THRESHOLD_3H:
|
if precip_val > RAIN_INTENSE_THRESHOLD_H or precip_3h_val > RAIN_INTENSE_THRESHOLD_3H:
|
||||||
threats.append("Nubifragio")
|
threats.append("Nubifragio")
|
||||||
|
|
||||||
# Aggiungi risultato solo se supera soglia
|
# Aggiungi risultato solo se c'è una minaccia reale (tutte richiedono precipitazione).
|
||||||
if score >= STORM_SCORE_THRESHOLD or threats:
|
# Il CAPE alto da solo (cielo asciutto) NON genera più un evento.
|
||||||
|
if threats:
|
||||||
results.append({
|
results.append({
|
||||||
"timestamp": times_base[i],
|
"timestamp": times_base[i],
|
||||||
"score": score,
|
"score": score,
|
||||||
@@ -940,6 +979,105 @@ def format_convective_alert(storm_events: List[Dict], times: List[str], start_id
|
|||||||
return "\n".join(msg_parts)
|
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
|
# PERSISTENCE LOGIC
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1280,21 +1418,42 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
|||||||
should_notify = False
|
should_notify = False
|
||||||
|
|
||||||
# 1) Convective storms (temporali severi) - priorità alta
|
# 1) Convective storms (temporali severi) - priorità alta
|
||||||
if storm_events:
|
# Considera SOLO eventi significativi (fulminazioni forti / bombe d'acqua /
|
||||||
prev_storm_active = bool(state.get("convective_storm_active", False))
|
# downburst) e applica anti-spam a finestre + dedup giornaliero.
|
||||||
max_score = max(e["score"] for e in storm_events)
|
sig_storms = filter_significant_storms(storm_events, now)
|
||||||
prev_score = float(state.get("last_storm_score", 0.0) or 0.0)
|
prune_storm_daily(state, now)
|
||||||
|
if sig_storms:
|
||||||
# Notifica se: nuovo evento, o score aumenta significativamente (+15 punti)
|
has_imminent = any(e.get("lead_hours", 0.0) <= IMMINENT_HOURS for e in sig_storms)
|
||||||
if debug_mode or not prev_storm_active or (max_score >= prev_score + 15.0):
|
storm_category = "imminent" if has_imminent else "extended"
|
||||||
if debug_mode:
|
storm_signature = build_storm_signature(storm_category, sig_storms)
|
||||||
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato per temporali severi")
|
today_key = _today_key(now)
|
||||||
convective_msg = format_convective_alert(storm_events, times, start_idx)
|
|
||||||
|
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:
|
if convective_msg:
|
||||||
alerts.append(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["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:
|
else:
|
||||||
state["convective_storm_active"] = False
|
state["convective_storm_active"] = False
|
||||||
state["last_storm_score"] = 0.0
|
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
|
state["last_rain_3h"] = 0.0
|
||||||
|
|
||||||
is_alarm_now = (
|
is_alarm_now = (
|
||||||
(storm_events is not None and len(storm_events) > 0)
|
(len(sig_storms) > 0)
|
||||||
or (wind_level_curr > 0)
|
or (wind_level_curr > 0)
|
||||||
or (rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT)
|
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)"
|
model_info = f"{model_used} + ICON Italia (discordanza rilevata)"
|
||||||
|
|
||||||
# Se ci sono temporali severi, aggiungi informazioni sui modelli usati
|
# 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)"
|
model_info = f"{model_used} + ICON Italia (analisi convettiva combinata)"
|
||||||
|
|
||||||
meta = (
|
meta = (
|
||||||
@@ -1407,7 +1566,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
|||||||
if not debug_message_only:
|
if not debug_message_only:
|
||||||
# Determina il tipo di allerta basandosi sulle condizioni attuali
|
# Determina il tipo di allerta basandosi sulle condizioni attuali
|
||||||
alert_types = []
|
alert_types = []
|
||||||
if storm_events and len(storm_events) > 0:
|
if sig_storms and len(sig_storms) > 0:
|
||||||
alert_types.append("TEMPORALI SEVERI")
|
alert_types.append("TEMPORALI SEVERI")
|
||||||
if wind_level_curr > 0:
|
if wind_level_curr > 0:
|
||||||
wind_labels = {3: "TEMPESTA", 2: "VENTO MOLTO FORTE", 1: "VENTO FORTE"}
|
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_storm_score": 0.0,
|
||||||
"last_alert_type": None,
|
"last_alert_type": None,
|
||||||
"last_alert_time": 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)
|
save_state(state)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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":
|
# Una località viene notificata SOLO se ha almeno un evento "significativo":
|
||||||
# fulminazioni forti, bomba d'acqua/nubifragio forte, alluvione o downburst.
|
# fulminazioni forti, bomba d'acqua/nubifragio forte, alluvione o downburst.
|
||||||
# Soglie volutamente alte per filtrare i temporali marginali estivi.
|
# 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_H = 30.0 # mm/h - bomba d'acqua (nubifragio forte)
|
||||||
SIGNIFICANT_PRECIP_3H = 50.0 # mm/3h - nubifragio forte su 3 ore
|
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
|
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 -----------------
|
# ----------------- RATE LIMITING NOTIFICHE -----------------
|
||||||
# Notifiche "molto contingentate":
|
# Notifiche "molto contingentate":
|
||||||
# - eventi imminenti (entro IMMINENT_HOURS): massimo 2 al giorno
|
# - 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)
|
precip_3h = float(event.get("precip_3h", 0.0) or 0.0)
|
||||||
score = float(event.get("score", 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")
|
reasons.append("Fulminazioni forti")
|
||||||
if precip >= SIGNIFICANT_PRECIP_H or precip_3h >= SIGNIFICANT_PRECIP_3H:
|
if precip >= SIGNIFICANT_PRECIP_H or precip_3h >= SIGNIFICANT_PRECIP_3H:
|
||||||
reasons.append("Bomba d'acqua")
|
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)
|
dynamic_score = min(30.0, ((gusts_val - WIND_GUST_DOWNBURST_THRESHOLD) / 40.0) * 30.0)
|
||||||
score += dynamic_score
|
score += dynamic_score
|
||||||
|
|
||||||
# Identifica minacce
|
# Precipitazione convettiva reale: senza pioggia non c'è temporale.
|
||||||
if cape_val > CAPE_LIGHTNING_THRESHOLD and lpi_val > 0:
|
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")
|
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")
|
threats.append("Downburst/Temporale violento")
|
||||||
|
|
||||||
if precip_val > RAIN_INTENSE_THRESHOLD_H or precip_3h_val > RAIN_INTENSE_THRESHOLD_3H:
|
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)
|
flood_bonus = min(10.0, (precip_24h_val - RAIN_FLOOD_THRESHOLD_24H) / 10.0)
|
||||||
score += flood_bonus
|
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({
|
results.append({
|
||||||
"timestamp": times_base[i],
|
"timestamp": times_base[i],
|
||||||
"score": score,
|
"score": score,
|
||||||
|
|||||||
Reference in New Issue
Block a user