Backup automatico script del 2026-01-11 07:00
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
@@ -22,7 +23,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
LOG_FILE = os.path.join(BASE_DIR, "nowcast_120m_alert.log")
|
||||
STATE_FILE = os.path.join(BASE_DIR, "nowcast_120m_state.json")
|
||||
|
||||
TZ = "Europe/Rome"
|
||||
TZ = "Europe/Berlin"
|
||||
TZINFO = ZoneInfo(TZ)
|
||||
|
||||
# Casa (San Marino)
|
||||
@@ -37,7 +38,9 @@ TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token")
|
||||
|
||||
# Open-Meteo
|
||||
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
MODEL = "meteofrance_arome_france_hd"
|
||||
MODEL_AROME = "meteofrance_seamless"
|
||||
MODEL_ICON_IT = "italia_meteo_arpae_icon_2i"
|
||||
COMPARISON_THRESHOLD = 0.30 # 30% scostamento per comparazione
|
||||
|
||||
# Finestra di valutazione
|
||||
WINDOW_MINUTES = 120
|
||||
@@ -51,8 +54,14 @@ RAIN_CONFIRM_HOURS = 2 # "confermato": almeno 2 ore consecutive
|
||||
WIND_GUST_STRONG_KMH = 62.0
|
||||
WIND_CONFIRM_HOURS = 2 # almeno 2 ore consecutive
|
||||
|
||||
# Neve: accumulo nelle prossime 2 ore >= 2 cm
|
||||
# Neve: accumulo nelle prossime 2 ore >= 2 cm (eventi significativi)
|
||||
SNOW_ACCUM_2H_CM = 2.0
|
||||
# Soglia più bassa per rilevare l'inizio della neve (anche leggera)
|
||||
SNOW_ACCUM_2H_LIGHT_CM = 0.3 # 0.3 cm in 2 ore per rilevare inizio neve
|
||||
# Soglia per neve persistente: accumulo totale su 6 ore (anche se distribuito)
|
||||
SNOW_ACCUM_6H_PERSISTENT_CM = 0.15 # 0.15 cm in 6 ore per neve persistente
|
||||
# Codici meteo che indicano neve (WMO)
|
||||
SNOW_WEATHER_CODES = [71, 73, 75, 77, 85, 86] # Neve leggera, moderata, forte, granelli, rovesci
|
||||
|
||||
# Anti-spam: minimo intervallo tra invii uguali (in minuti)
|
||||
MIN_RESEND_MINUTES = 180
|
||||
@@ -107,9 +116,13 @@ def load_bot_token() -> str:
|
||||
return tok.strip() if tok else ""
|
||||
|
||||
|
||||
def telegram_send_markdown(message: str) -> bool:
|
||||
def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -> bool:
|
||||
"""
|
||||
Invia SOLO se message presente. Errori solo su log.
|
||||
|
||||
Args:
|
||||
message: Messaggio Markdown da inviare
|
||||
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
|
||||
"""
|
||||
if not message:
|
||||
return False
|
||||
@@ -119,6 +132,9 @@ def telegram_send_markdown(message: str) -> bool:
|
||||
LOGGER.error("Token Telegram mancante. Messaggio NON inviato.")
|
||||
return False
|
||||
|
||||
if chat_ids is None:
|
||||
chat_ids = TELEGRAM_CHAT_IDS
|
||||
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
payload_base = {
|
||||
"text": message,
|
||||
@@ -128,7 +144,7 @@ def telegram_send_markdown(message: str) -> bool:
|
||||
|
||||
ok_any = False
|
||||
with requests.Session() as s:
|
||||
for chat_id in TELEGRAM_CHAT_IDS:
|
||||
for chat_id in chat_ids:
|
||||
payload = dict(payload_base)
|
||||
payload["chat_id"] = chat_id
|
||||
try:
|
||||
@@ -151,13 +167,23 @@ def parse_time_local(t: str) -> datetime.datetime:
|
||||
return dt.astimezone(TZINFO)
|
||||
|
||||
|
||||
def get_forecast() -> Optional[Dict]:
|
||||
def get_forecast(model: str, use_minutely: bool = True, forecast_days: int = 2) -> Optional[Dict]:
|
||||
"""
|
||||
Recupera forecast. Se use_minutely=True e model è AROME, include anche minutely_15
|
||||
per dettaglio 15 minuti nelle prossime 48 ore.
|
||||
Se minutely_15 fallisce o ha troppi buchi, riprova automaticamente senza minutely_15.
|
||||
|
||||
Args:
|
||||
model: Modello meteo da usare
|
||||
use_minutely: Se True, include dati minutely_15 per AROME
|
||||
forecast_days: Numero di giorni di previsione (default: 2 per 48h)
|
||||
"""
|
||||
params = {
|
||||
"latitude": LAT,
|
||||
"longitude": LON,
|
||||
"timezone": TZ,
|
||||
"forecast_days": 2,
|
||||
"models": MODEL,
|
||||
"forecast_days": forecast_days,
|
||||
"models": model,
|
||||
"wind_speed_unit": "kmh",
|
||||
"precipitation_unit": "mm",
|
||||
"hourly": ",".join([
|
||||
@@ -165,21 +191,306 @@ def get_forecast() -> Optional[Dict]:
|
||||
"windspeed_10m",
|
||||
"windgusts_10m",
|
||||
"snowfall",
|
||||
"weathercode", # Aggiunto per rilevare neve anche quando snowfall è basso
|
||||
]),
|
||||
}
|
||||
|
||||
# Aggiungi minutely_15 per AROME Seamless (dettaglio 15 minuti)
|
||||
# Se fallisce, riprova senza minutely_15
|
||||
if use_minutely and model == MODEL_AROME:
|
||||
params["minutely_15"] = "snowfall,precipitation_probability,precipitation,rain,temperature_2m,wind_speed_10m,wind_direction_10m"
|
||||
|
||||
try:
|
||||
r = requests.get(OPEN_METEO_URL, params=params, timeout=25)
|
||||
if r.status_code == 400:
|
||||
# Se 400 e abbiamo minutely_15, riprova senza
|
||||
if "minutely_15" in params and model == MODEL_AROME:
|
||||
LOGGER.warning("Open-Meteo 400 con minutely_15 (model=%s), riprovo senza minutely_15", model)
|
||||
params_no_minutely = params.copy()
|
||||
del params_no_minutely["minutely_15"]
|
||||
try:
|
||||
r2 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=25)
|
||||
if r2.status_code == 200:
|
||||
return r2.json()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
j = r.json()
|
||||
LOGGER.error("Open-Meteo 400: %s", j.get("reason", j))
|
||||
LOGGER.error("Open-Meteo 400 (model=%s): %s", model, j.get("reason", j))
|
||||
except Exception:
|
||||
LOGGER.error("Open-Meteo 400: %s", r.text[:300])
|
||||
LOGGER.error("Open-Meteo 400 (model=%s): %s", model, r.text[:300])
|
||||
return None
|
||||
elif r.status_code == 504:
|
||||
# Gateway Timeout: se abbiamo minutely_15, riprova senza
|
||||
if "minutely_15" in params and model == MODEL_AROME:
|
||||
LOGGER.warning("Open-Meteo 504 Gateway Timeout con minutely_15 (model=%s), riprovo senza minutely_15", model)
|
||||
params_no_minutely = params.copy()
|
||||
del params_no_minutely["minutely_15"]
|
||||
try:
|
||||
r2 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=25)
|
||||
if r2.status_code == 200:
|
||||
return r2.json()
|
||||
except Exception:
|
||||
pass
|
||||
LOGGER.error("Open-Meteo 504 Gateway Timeout (model=%s)", model)
|
||||
return None
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
data = r.json()
|
||||
|
||||
# Verifica se minutely_15 ha buchi (anche solo 1 None = fallback a hourly)
|
||||
if "minutely_15" in params and model == MODEL_AROME:
|
||||
minutely = data.get("minutely_15", {}) or {}
|
||||
minutely_times = minutely.get("time", []) or []
|
||||
minutely_precip = minutely.get("precipitation", []) or []
|
||||
minutely_snow = minutely.get("snowfall", []) or []
|
||||
|
||||
# Controlla se ci sono buchi (anche solo 1 None)
|
||||
if minutely_times:
|
||||
# Controlla tutti i parametri principali per buchi
|
||||
has_holes = False
|
||||
# Controlla precipitation
|
||||
if minutely_precip and any(v is None for v in minutely_precip):
|
||||
has_holes = True
|
||||
# Controlla snowfall
|
||||
if minutely_snow and any(v is None for v in minutely_snow):
|
||||
has_holes = True
|
||||
|
||||
if has_holes:
|
||||
LOGGER.warning("minutely_15 ha buchi (valori None rilevati, model=%s), riprovo senza minutely_15", model)
|
||||
params_no_minutely = params.copy()
|
||||
del params_no_minutely["minutely_15"]
|
||||
try:
|
||||
r2 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=25)
|
||||
if r2.status_code == 200:
|
||||
return r2.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return data
|
||||
except requests.exceptions.Timeout:
|
||||
# Timeout: se abbiamo minutely_15, riprova senza
|
||||
if "minutely_15" in params and model == MODEL_AROME:
|
||||
LOGGER.warning("Open-Meteo Timeout con minutely_15 (model=%s), riprovo senza minutely_15", model)
|
||||
params_no_minutely = params.copy()
|
||||
del params_no_minutely["minutely_15"]
|
||||
try:
|
||||
r2 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=25)
|
||||
if r2.status_code == 200:
|
||||
return r2.json()
|
||||
except Exception:
|
||||
pass
|
||||
LOGGER.exception("Open-Meteo timeout (model=%s)", model)
|
||||
return None
|
||||
except Exception as e:
|
||||
LOGGER.exception("Errore chiamata Open-Meteo: %s", e)
|
||||
# Altri errori: se abbiamo minutely_15, riprova senza
|
||||
if "minutely_15" in params and model == MODEL_AROME:
|
||||
LOGGER.warning("Open-Meteo error con minutely_15 (model=%s): %s, riprovo senza minutely_15", model, str(e))
|
||||
params_no_minutely = params.copy()
|
||||
del params_no_minutely["minutely_15"]
|
||||
try:
|
||||
r2 = requests.get(OPEN_METEO_URL, params=params_no_minutely, timeout=25)
|
||||
if r2.status_code == 200:
|
||||
return r2.json()
|
||||
except Exception:
|
||||
pass
|
||||
LOGGER.exception("Errore chiamata Open-Meteo (model=%s): %s", model, e)
|
||||
return None
|
||||
|
||||
|
||||
def find_precise_start_minutely(
|
||||
minutely_data: Dict,
|
||||
param_name: str,
|
||||
threshold: float,
|
||||
window_start: datetime.datetime,
|
||||
window_end: datetime.datetime,
|
||||
confirm_intervals: int = 2 # 2 intervalli da 15 min = 30 min conferma
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Trova inizio preciso usando dati minutely_15 (risoluzione 15 minuti)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"start": datetime,
|
||||
"start_precise": str (HH:MM),
|
||||
"value_at_start": float,
|
||||
"confirmed": bool
|
||||
} or None
|
||||
"""
|
||||
minutely = minutely_data.get("minutely_15", {}) or {}
|
||||
times = minutely.get("time", []) or []
|
||||
values = minutely.get(param_name, []) or []
|
||||
|
||||
if not times or not values:
|
||||
return None
|
||||
|
||||
for i, (t_str, val) in enumerate(zip(times, values)):
|
||||
try:
|
||||
dt = parse_time_local(t_str)
|
||||
if dt < window_start or dt > window_end:
|
||||
continue
|
||||
|
||||
val_float = float(val) if val is not None else 0.0
|
||||
|
||||
if val_float >= threshold:
|
||||
# Verifica conferma (almeno confirm_intervals consecutivi)
|
||||
confirmed = True
|
||||
if i + confirm_intervals - 1 < len(values):
|
||||
for k in range(1, confirm_intervals):
|
||||
next_val = float(values[i + k]) if i + k < len(values) and values[i + k] is not None else 0.0
|
||||
if next_val < threshold:
|
||||
confirmed = False
|
||||
break
|
||||
else:
|
||||
confirmed = False
|
||||
|
||||
if confirmed:
|
||||
return {
|
||||
"start": dt,
|
||||
"start_precise": dt.strftime("%H:%M"),
|
||||
"value_at_start": val_float,
|
||||
"confirmed": confirmed
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def analyze_snowfall_event(
|
||||
times: List[str],
|
||||
snowfall: List[float],
|
||||
weathercode: List[int],
|
||||
start_idx: int,
|
||||
max_hours: int = 48
|
||||
) -> Dict:
|
||||
"""
|
||||
Analizza una nevicata completa partendo da start_idx.
|
||||
|
||||
Calcola:
|
||||
- Durata totale (ore consecutive con neve)
|
||||
- Accumulo totale (somma di tutti i snowfall > 0)
|
||||
- Ore di inizio e fine
|
||||
|
||||
Args:
|
||||
times: Lista di timestamp
|
||||
snowfall: Lista di valori snowfall (già in cm)
|
||||
weathercode: Lista di weather codes
|
||||
start_idx: Indice di inizio della nevicata
|
||||
max_hours: Massimo numero di ore da analizzare (default: 48)
|
||||
|
||||
Returns:
|
||||
Dict con:
|
||||
- duration_hours: durata in ore
|
||||
- total_accumulation_cm: accumulo totale in cm
|
||||
- start_time: datetime di inizio
|
||||
- end_time: datetime di fine (o None se continua oltre max_hours)
|
||||
- is_ongoing: True se continua oltre max_hours
|
||||
"""
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
if start_idx >= len(times):
|
||||
return None
|
||||
|
||||
start_dt = parse_time_local(times[start_idx])
|
||||
end_idx = start_idx
|
||||
total_accum = 0.0
|
||||
duration = 0
|
||||
|
||||
# Analizza fino a max_hours in avanti o fino alla fine dei dati
|
||||
max_idx = min(start_idx + max_hours, len(times))
|
||||
|
||||
for i in range(start_idx, max_idx):
|
||||
snow_val = snowfall[i] if i < len(snowfall) and snowfall[i] is not None else 0.0
|
||||
code = weathercode[i] if i < len(weathercode) and weathercode[i] is not None else None
|
||||
|
||||
# Considera neve se: snowfall > 0 OPPURE weather_code indica neve
|
||||
is_snow = (snow_val > 0.0) or (code in SNOW_WEATHER_CODES)
|
||||
|
||||
if is_snow:
|
||||
duration += 1
|
||||
total_accum += snow_val
|
||||
end_idx = i
|
||||
else:
|
||||
# Se c'è una pausa, continua comunque a cercare (potrebbe essere una pausa temporanea)
|
||||
# Ma se la pausa è > 2 ore, considera la nevicata terminata
|
||||
pause_hours = 0
|
||||
for j in range(i, min(i + 3, max_idx)):
|
||||
next_snow = snowfall[j] if j < len(snowfall) and snowfall[j] is not None else 0.0
|
||||
next_code = weathercode[j] if j < len(weathercode) and weathercode[j] is not None else None
|
||||
if (next_snow > 0.0) or (next_code in SNOW_WEATHER_CODES):
|
||||
break
|
||||
pause_hours += 1
|
||||
|
||||
# Se pausa > 2 ore, termina l'analisi
|
||||
if pause_hours >= 2:
|
||||
break
|
||||
|
||||
end_dt = parse_time_local(times[end_idx]) if end_idx < len(times) else None
|
||||
is_ongoing = (end_idx >= max_idx - 1) and (end_idx < len(times) - 1)
|
||||
|
||||
return {
|
||||
"duration_hours": duration,
|
||||
"total_accumulation_cm": total_accum,
|
||||
"start_time": start_dt,
|
||||
"end_time": end_dt,
|
||||
"is_ongoing": is_ongoing,
|
||||
"start_idx": start_idx,
|
||||
"end_idx": end_idx
|
||||
}
|
||||
|
||||
|
||||
def find_snowfall_start(
|
||||
times: List[str],
|
||||
snowfall: List[float],
|
||||
weathercode: List[int],
|
||||
window_start: datetime.datetime,
|
||||
window_end: datetime.datetime
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Trova l'inizio di una nevicata nella finestra temporale.
|
||||
|
||||
Una nevicata inizia quando:
|
||||
- snowfall > 0 OPPURE weather_code indica neve (71, 73, 75, 77, 85, 86)
|
||||
|
||||
Returns:
|
||||
Indice del primo timestamp con neve, o None
|
||||
"""
|
||||
for i, t_str in enumerate(times):
|
||||
try:
|
||||
dt = parse_time_local(t_str)
|
||||
if dt < window_start or dt > window_end:
|
||||
continue
|
||||
|
||||
snow_val = snowfall[i] if i < len(snowfall) and snowfall[i] is not None else 0.0
|
||||
code = weathercode[i] if i < len(weathercode) and weathercode[i] is not None else None
|
||||
|
||||
# Rileva inizio neve
|
||||
if (snow_val > 0.0) or (code in SNOW_WEATHER_CODES):
|
||||
return i
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def compare_values(arome_val: float, icon_val: float) -> Optional[Dict]:
|
||||
"""Confronta due valori e ritorna info se scostamento >30%"""
|
||||
if arome_val == 0 and icon_val == 0:
|
||||
return None
|
||||
|
||||
if arome_val > 0:
|
||||
diff_pct = abs(icon_val - arome_val) / arome_val
|
||||
elif icon_val > 0:
|
||||
diff_pct = abs(arome_val - icon_val) / icon_val
|
||||
else:
|
||||
return None
|
||||
|
||||
if diff_pct > COMPARISON_THRESHOLD:
|
||||
return {
|
||||
"diff_pct": diff_pct * 100,
|
||||
"arome": arome_val,
|
||||
"icon": icon_val
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
@@ -190,7 +501,10 @@ def load_state() -> Dict:
|
||||
return json.load(f) or {}
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
return {
|
||||
"active_events": {},
|
||||
"last_sent_utc": ""
|
||||
}
|
||||
|
||||
|
||||
def save_state(state: Dict) -> None:
|
||||
@@ -201,6 +515,154 @@ def save_state(state: Dict) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def is_event_already_active(
|
||||
active_events: Dict,
|
||||
event_type: str,
|
||||
start_time: datetime.datetime,
|
||||
tolerance_hours: float = 2.0
|
||||
) -> bool:
|
||||
"""
|
||||
Verifica se un evento con lo stesso inizio è già attivo.
|
||||
|
||||
Args:
|
||||
active_events: Dict con eventi attivi per tipo
|
||||
event_type: Tipo evento ("SNOW", "RAIN", "WIND")
|
||||
start_time: Timestamp di inizio dell'evento
|
||||
tolerance_hours: Tolleranza in ore per considerare lo stesso evento
|
||||
|
||||
Returns:
|
||||
True se l'evento è già attivo
|
||||
"""
|
||||
events_of_type = active_events.get(event_type, [])
|
||||
|
||||
for event in events_of_type:
|
||||
try:
|
||||
event_start_str = event.get("start_time", "")
|
||||
if not event_start_str:
|
||||
continue
|
||||
|
||||
event_start = datetime.datetime.fromisoformat(event_start_str)
|
||||
# Normalizza timezone
|
||||
if event_start.tzinfo is None:
|
||||
event_start = event_start.replace(tzinfo=TZINFO)
|
||||
else:
|
||||
event_start = event_start.astimezone(TZINFO)
|
||||
|
||||
# Normalizza start_time
|
||||
if start_time.tzinfo is None:
|
||||
start_time = start_time.replace(tzinfo=TZINFO)
|
||||
else:
|
||||
start_time = start_time.astimezone(TZINFO)
|
||||
|
||||
# Verifica se l'inizio è entro la tolleranza
|
||||
time_diff = abs((start_time - event_start).total_seconds() / 3600.0)
|
||||
if time_diff <= tolerance_hours:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def add_active_event(
|
||||
active_events: Dict,
|
||||
event_type: str,
|
||||
start_time: datetime.datetime,
|
||||
end_time: Optional[datetime.datetime] = None,
|
||||
is_ongoing: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Aggiunge un evento attivo allo state.
|
||||
"""
|
||||
if event_type not in active_events:
|
||||
active_events[event_type] = []
|
||||
|
||||
# Normalizza timezone
|
||||
if start_time.tzinfo is None:
|
||||
start_time = start_time.replace(tzinfo=TZINFO)
|
||||
else:
|
||||
start_time = start_time.astimezone(TZINFO)
|
||||
|
||||
event = {
|
||||
"start_time": start_time.isoformat(),
|
||||
"first_alerted": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"type": event_type,
|
||||
"is_ongoing": is_ongoing
|
||||
}
|
||||
|
||||
if end_time:
|
||||
if end_time.tzinfo is None:
|
||||
end_time = end_time.replace(tzinfo=TZINFO)
|
||||
else:
|
||||
end_time = end_time.astimezone(TZINFO)
|
||||
event["end_time"] = end_time.isoformat()
|
||||
|
||||
active_events[event_type].append(event)
|
||||
|
||||
|
||||
def cleanup_ended_events(
|
||||
active_events: Dict,
|
||||
now: datetime.datetime
|
||||
) -> None:
|
||||
"""
|
||||
Rimuove eventi terminati dallo state (ma non invia notifiche di fine).
|
||||
|
||||
Un evento è considerato terminato se:
|
||||
- Ha un end_time nel passato E non è ongoing
|
||||
- O se l'evento è più vecchio di 48 ore (safety cleanup)
|
||||
"""
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=TZINFO)
|
||||
else:
|
||||
now = now.astimezone(TZINFO)
|
||||
|
||||
for event_type in list(active_events.keys()):
|
||||
events = active_events[event_type]
|
||||
kept_events = []
|
||||
|
||||
for event in events:
|
||||
try:
|
||||
start_time_str = event.get("start_time", "")
|
||||
if not start_time_str:
|
||||
continue
|
||||
|
||||
start_time = datetime.datetime.fromisoformat(start_time_str)
|
||||
if start_time.tzinfo is None:
|
||||
start_time = start_time.replace(tzinfo=TZINFO)
|
||||
else:
|
||||
start_time = start_time.astimezone(TZINFO)
|
||||
|
||||
# Safety cleanup: rimuovi eventi più vecchi di 48 ore
|
||||
age_hours = (now - start_time).total_seconds() / 3600.0
|
||||
if age_hours > 48:
|
||||
LOGGER.debug("Rimosso evento %s vecchio di %.1f ore (cleanup)", event_type, age_hours)
|
||||
continue
|
||||
|
||||
# Verifica se l'evento è terminato
|
||||
end_time_str = event.get("end_time")
|
||||
is_ongoing = event.get("is_ongoing", False)
|
||||
|
||||
if end_time_str and not is_ongoing:
|
||||
end_time = datetime.datetime.fromisoformat(end_time_str)
|
||||
if end_time.tzinfo is None:
|
||||
end_time = end_time.replace(tzinfo=TZINFO)
|
||||
else:
|
||||
end_time = end_time.astimezone(TZINFO)
|
||||
|
||||
# Se end_time è nel passato, rimuovi l'evento
|
||||
if end_time < now:
|
||||
LOGGER.debug("Rimosso evento %s terminato alle %s", event_type, end_time_str)
|
||||
continue
|
||||
|
||||
# Mantieni l'evento
|
||||
kept_events.append(event)
|
||||
except Exception as e:
|
||||
LOGGER.debug("Errore cleanup evento %s: %s", event_type, e)
|
||||
continue
|
||||
|
||||
active_events[event_type] = kept_events
|
||||
|
||||
|
||||
def find_confirmed_start(
|
||||
times: List[str],
|
||||
cond: List[bool],
|
||||
@@ -233,24 +695,36 @@ def find_confirmed_start(
|
||||
return None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None:
|
||||
LOGGER.info("--- Nowcast 120m alert ---")
|
||||
|
||||
data = get_forecast()
|
||||
if not data:
|
||||
# Estendi forecast a 3 giorni per avere 48h di analisi neve completa
|
||||
data_arome = get_forecast(MODEL_AROME, forecast_days=3)
|
||||
if not data_arome:
|
||||
return
|
||||
|
||||
hourly = data.get("hourly", {}) or {}
|
||||
times = hourly.get("time", []) or []
|
||||
precip = hourly.get("precipitation", []) or []
|
||||
gust = hourly.get("windgusts_10m", []) or []
|
||||
snow = hourly.get("snowfall", []) or []
|
||||
hourly_arome = data_arome.get("hourly", {}) or {}
|
||||
times = hourly_arome.get("time", []) or []
|
||||
precip_arome = hourly_arome.get("precipitation", []) or []
|
||||
gust_arome = hourly_arome.get("windgusts_10m", []) or []
|
||||
snow_arome = hourly_arome.get("snowfall", []) or []
|
||||
weathercode_arome = hourly_arome.get("weathercode", []) or [] # Per rilevare neve anche con snowfall basso
|
||||
|
||||
# Recupera dati ICON Italia per comparazione (48h)
|
||||
data_icon = get_forecast(MODEL_ICON_IT, use_minutely=False, forecast_days=3)
|
||||
hourly_icon = data_icon.get("hourly", {}) or {} if data_icon else {}
|
||||
precip_icon = hourly_icon.get("precipitation", []) or []
|
||||
gust_icon = hourly_icon.get("windgusts_10m", []) or []
|
||||
snow_icon = hourly_icon.get("snowfall", []) or []
|
||||
weathercode_icon = hourly_icon.get("weathercode", []) or []
|
||||
|
||||
if not times:
|
||||
LOGGER.error("Open-Meteo: hourly.time mancante/vuoto")
|
||||
return
|
||||
|
||||
now = now_local()
|
||||
# Finestra per rilevare inizio neve: prossime 2 ore
|
||||
window_start = now
|
||||
window_end = now + datetime.timedelta(minutes=WINDOW_MINUTES)
|
||||
|
||||
# Normalizza array a lunghezza times
|
||||
@@ -262,82 +736,199 @@ def main() -> None:
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
rain_cond = [(val(precip, i) >= RAIN_INTENSE_MM_H) for i in range(n)]
|
||||
wind_cond = [(val(gust, i) >= WIND_GUST_STRONG_KMH) for i in range(n)]
|
||||
rain_cond = [(val(precip_arome, i) >= RAIN_INTENSE_MM_H) for i in range(n)]
|
||||
wind_cond = [(val(gust_arome, i) >= WIND_GUST_STRONG_KMH) for i in range(n)]
|
||||
|
||||
# Per neve: accumulo su 2 ore consecutive (i e i+1) >= soglia
|
||||
snow2_cond = []
|
||||
for i in range(n):
|
||||
if i + 1 < n:
|
||||
snow2 = val(snow, i) + val(snow, i + 1)
|
||||
snow2_cond.append(snow2 >= SNOW_ACCUM_2H_CM)
|
||||
else:
|
||||
snow2_cond.append(False)
|
||||
# Per neve: nuova logica - rileva inizio nevicata e analizza evento completo (48h)
|
||||
snow_start_i = find_snowfall_start(times, snow_arome, weathercode_arome, window_start, window_end)
|
||||
|
||||
rain_i = find_confirmed_start(times, rain_cond, RAIN_CONFIRM_HOURS, now, window_end)
|
||||
wind_i = find_confirmed_start(times, wind_cond, WIND_CONFIRM_HOURS, now, window_end)
|
||||
snow_i = find_confirmed_start(times, snow2_cond, 1, now, window_end) # già condensa su 2h
|
||||
rain_i = find_confirmed_start(times, rain_cond, RAIN_CONFIRM_HOURS, window_start, window_end)
|
||||
wind_i = find_confirmed_start(times, wind_cond, WIND_CONFIRM_HOURS, window_start, window_end)
|
||||
|
||||
if DEBUG:
|
||||
LOGGER.debug("window now=%s end=%s model=%s", now.isoformat(timespec="minutes"), window_end.isoformat(timespec="minutes"), MODEL)
|
||||
LOGGER.debug("window now=%s end=%s model=%s", now.isoformat(timespec="minutes"), window_end.isoformat(timespec="minutes"), MODEL_AROME)
|
||||
LOGGER.debug("rain_start=%s wind_start=%s snow_start=%s", rain_i, wind_i, snow_i)
|
||||
|
||||
alerts: List[str] = []
|
||||
sig_parts: List[str] = []
|
||||
comparisons: Dict[str, Dict] = {} # tipo_allerta -> comparison info
|
||||
|
||||
# Usa minutely_15 per trovare inizio preciso (se disponibile)
|
||||
minutely_arome = data_arome.get("minutely_15", {}) or {}
|
||||
minutely_available = bool(minutely_arome.get("time"))
|
||||
|
||||
# Pioggia intensa
|
||||
if rain_i is not None:
|
||||
start_dt = parse_time_local(times[rain_i])
|
||||
|
||||
# Se minutely_15 disponibile, trova inizio preciso (risoluzione 15 minuti)
|
||||
precise_start = None
|
||||
if minutely_available:
|
||||
precise_start = find_precise_start_minutely(
|
||||
minutely_arome, "precipitation", RAIN_INTENSE_MM_H, window_start, window_end, confirm_intervals=2
|
||||
)
|
||||
if precise_start:
|
||||
start_dt = precise_start["start"]
|
||||
|
||||
# picco entro finestra
|
||||
max_r = 0.0
|
||||
max_r_arome = 0.0
|
||||
for i in range(n):
|
||||
dt = parse_time_local(times[i])
|
||||
if dt < now or dt > window_end:
|
||||
if dt < window_start or dt > window_end:
|
||||
continue
|
||||
max_r = max(max_r, val(precip, i))
|
||||
alerts.append(
|
||||
max_r_arome = max(max_r_arome, val(precip_arome, i))
|
||||
|
||||
# Calcola picco ICON se disponibile
|
||||
max_r_icon = 0.0
|
||||
if len(precip_icon) >= n:
|
||||
for i in range(n):
|
||||
dt = parse_time_local(times[i])
|
||||
if dt < window_start or dt > window_end:
|
||||
continue
|
||||
max_r_icon = max(max_r_icon, val(precip_icon, i))
|
||||
|
||||
# Comparazione
|
||||
comp_rain = compare_values(max_r_arome, max_r_icon) if max_r_icon > 0 else None
|
||||
if comp_rain:
|
||||
comparisons["rain"] = comp_rain
|
||||
|
||||
start_time_str = precise_start["start_precise"] if precise_start else start_dt.strftime('%H:%M')
|
||||
detail_note = f" (dettaglio 15 min)" if precise_start else ""
|
||||
|
||||
alert_text = (
|
||||
f"🌧️ *PIOGGIA INTENSA*\n"
|
||||
f"Inizio confermato: `{start_dt.strftime('%H:%M')}` (≥ {RAIN_INTENSE_MM_H:.0f} mm/h per {RAIN_CONFIRM_HOURS}h)\n"
|
||||
f"Picco stimato (entro {WINDOW_MINUTES}m): `{max_r:.1f} mm/h`"
|
||||
f"Inizio confermato: `{start_time_str}`{detail_note} (≥ {RAIN_INTENSE_MM_H:.0f} mm/h per {RAIN_CONFIRM_HOURS}h)\n"
|
||||
f"Picco stimato (entro {WINDOW_MINUTES}m): `{max_r_arome:.1f} mm/h`"
|
||||
)
|
||||
sig_parts.append(f"RAIN@{start_dt.strftime('%Y%m%d%H%M')}/peak{max_r:.1f}")
|
||||
if comp_rain:
|
||||
alert_text += f"\n⚠️ *Discordanza modelli*: AROME `{max_r_arome:.1f}` mm/h | ICON `{max_r_icon:.1f}` mm/h (scostamento {comp_rain['diff_pct']:.0f}%)"
|
||||
alerts.append(alert_text)
|
||||
sig_parts.append(f"RAIN@{start_dt.strftime('%Y%m%d%H%M')}/peak{max_r_arome:.1f}")
|
||||
|
||||
# Vento forte (raffiche)
|
||||
if wind_i is not None:
|
||||
start_dt = parse_time_local(times[wind_i])
|
||||
max_g = 0.0
|
||||
|
||||
# Verifica se l'evento è già attivo
|
||||
is_already_active = is_event_already_active(active_events, "WIND", start_dt, tolerance_hours=2.0)
|
||||
|
||||
if is_already_active:
|
||||
LOGGER.info("Evento vento già attivo (inizio: %s), non invio notifica", start_dt.strftime('%Y-%m-%d %H:%M'))
|
||||
else:
|
||||
max_g_arome = 0.0
|
||||
for i in range(n):
|
||||
dt = parse_time_local(times[i])
|
||||
if dt < window_start or dt > window_end:
|
||||
continue
|
||||
max_g_arome = max(max_g_arome, val(gust_arome, i))
|
||||
|
||||
# Calcola picco ICON se disponibile
|
||||
max_g_icon = 0.0
|
||||
if len(gust_icon) >= n:
|
||||
for i in range(n):
|
||||
dt = parse_time_local(times[i])
|
||||
if dt < now or dt > window_end:
|
||||
if dt < window_start or dt > window_end:
|
||||
continue
|
||||
max_g = max(max_g, val(gust, i))
|
||||
alerts.append(
|
||||
max_g_icon = max(max_g_icon, val(gust_icon, i))
|
||||
|
||||
# Comparazione
|
||||
comp_wind = compare_values(max_g_arome, max_g_icon) if max_g_icon > 0 else None
|
||||
if comp_wind:
|
||||
comparisons["wind"] = comp_wind
|
||||
|
||||
alert_text = (
|
||||
f"💨 *VENTO FORTE*\n"
|
||||
f"Inizio confermato: `{start_dt.strftime('%H:%M')}` (raffiche ≥ {WIND_GUST_STRONG_KMH:.0f} km/h per {WIND_CONFIRM_HOURS}h)\n"
|
||||
f"Raffica max stimata (entro {WINDOW_MINUTES}m): `{max_g:.0f} km/h`"
|
||||
f"Raffica max stimata (entro {WINDOW_MINUTES}m): `{max_g_arome:.0f} km/h`"
|
||||
)
|
||||
sig_parts.append(f"WIND@{start_dt.strftime('%Y%m%d%H%M')}/peak{max_g:.0f}")
|
||||
if comp_wind:
|
||||
alert_text += f"\n⚠️ *Discordanza modelli*: AROME `{max_g_arome:.0f}` km/h | ICON `{max_g_icon:.0f}` km/h (scostamento {comp_wind['diff_pct']:.0f}%)"
|
||||
alerts.append(alert_text)
|
||||
sig_parts.append(f"WIND@{start_dt.strftime('%Y%m%d%H%M')}")
|
||||
|
||||
# Aggiungi evento attivo allo state (stima fine: 6 ore dopo inizio)
|
||||
estimated_end = start_dt + datetime.timedelta(hours=6)
|
||||
add_active_event(active_events, "WIND", start_dt, estimated_end, is_ongoing=False)
|
||||
|
||||
# Neve (accumulo 2h)
|
||||
if snow_i is not None:
|
||||
start_dt = parse_time_local(times[snow_i])
|
||||
snow2 = val(snow, snow_i) + val(snow, snow_i + 1)
|
||||
alerts.append(
|
||||
f"❄️ *NEVE*\n"
|
||||
f"Inizio stimato: `{start_dt.strftime('%H:%M')}`\n"
|
||||
f"Accumulo 2h stimato: `{snow2:.1f} cm` (soglia ≥ {SNOW_ACCUM_2H_CM:.1f} cm)"
|
||||
)
|
||||
sig_parts.append(f"SNOW@{start_dt.strftime('%Y%m%d%H%M')}/acc{snow2:.1f}")
|
||||
# Neve: analizza evento completo (48h)
|
||||
if snow_start_i is not None:
|
||||
# Analizza la nevicata completa
|
||||
snow_event = analyze_snowfall_event(times, snow_arome, weathercode_arome, snow_start_i, max_hours=48)
|
||||
|
||||
if snow_event:
|
||||
start_dt = snow_event["start_time"]
|
||||
total_accum_cm = snow_event["total_accumulation_cm"]
|
||||
duration_hours = snow_event["duration_hours"]
|
||||
end_dt = snow_event["end_time"]
|
||||
is_ongoing = snow_event["is_ongoing"]
|
||||
|
||||
# Determina severità in base all'accumulo totale
|
||||
is_significant = total_accum_cm >= SNOW_ACCUM_2H_CM
|
||||
severity_emoji = "❄️" if is_significant else "🌨️"
|
||||
severity_text = "NEVE SIGNIFICATIVA" if is_significant else "NEVE"
|
||||
|
||||
# Se minutely_15 disponibile, trova inizio preciso
|
||||
precise_start_snow = None
|
||||
if minutely_available:
|
||||
precise_start_snow = find_precise_start_minutely(
|
||||
minutely_arome, "snowfall", 0.01, window_start, window_end, confirm_intervals=1
|
||||
)
|
||||
|
||||
start_time_str = precise_start_snow["start_precise"] if precise_start_snow else start_dt.strftime('%H:%M')
|
||||
detail_note = f" (dettaglio 15 min)" if precise_start_snow else ""
|
||||
|
||||
# Calcola accumulo ICON per comparazione
|
||||
if data_icon and snow_start_i < len(snow_icon):
|
||||
icon_event = analyze_snowfall_event(times, snow_icon, weathercode_icon, snow_start_i, max_hours=48)
|
||||
icon_accum_cm = icon_event["total_accumulation_cm"] if icon_event else 0.0
|
||||
comp_snow = compare_values(total_accum_cm, icon_accum_cm) if icon_accum_cm > 0 else None
|
||||
else:
|
||||
comp_snow = None
|
||||
|
||||
if comp_snow:
|
||||
comparisons["snow"] = comp_snow
|
||||
|
||||
# Costruisci messaggio con durata e accumulo totale
|
||||
end_time_str = end_dt.strftime('%H:%M') if end_dt and not is_ongoing else "in corso"
|
||||
duration_text = f"{duration_hours}h" if duration_hours > 0 else "<1h"
|
||||
|
||||
alert_text = (
|
||||
f"{severity_emoji} *{severity_text}*\n"
|
||||
f"Inizio: `{start_time_str}`{detail_note}\n"
|
||||
f"Durata prevista: `{duration_text}`"
|
||||
)
|
||||
|
||||
if end_dt and not is_ongoing:
|
||||
alert_text += f" (fino alle `{end_time_str}`)"
|
||||
elif is_ongoing:
|
||||
alert_text += f" (continua oltre 48h)"
|
||||
|
||||
alert_text += f"\nAccumulo totale previsto: `{total_accum_cm:.2f} cm`"
|
||||
|
||||
if comp_snow:
|
||||
alert_text += f"\n⚠️ *Discordanza modelli*: AROME `{total_accum_cm:.2f}` cm | ICON `{icon_accum_cm:.2f}` cm (scostamento {comp_snow['diff_pct']:.0f}%)"
|
||||
|
||||
alerts.append(alert_text)
|
||||
sig_parts.append(f"SNOW@{start_dt.strftime('%Y%m%d%H%M')}/dur{duration_hours}h/acc{total_accum_cm:.1f}")
|
||||
|
||||
if not alerts:
|
||||
LOGGER.info("Nessuna allerta confermata entro %s minuti.", WINDOW_MINUTES)
|
||||
return
|
||||
|
||||
signature = "|".join(sig_parts)
|
||||
|
||||
# Anti-spam
|
||||
state = load_state()
|
||||
last_sig = str(state.get("signature", ""))
|
||||
# Se non ci sono nuovi eventi, non inviare nulla (non inviare notifiche di fine evento)
|
||||
if not alerts:
|
||||
if debug_mode:
|
||||
# In modalità debug, crea un messaggio informativo anche se non ci sono allerte
|
||||
LOGGER.info("[DEBUG MODE] Nessuna allerta, ma creo messaggio informativo")
|
||||
alerts.append("ℹ️ <i>Nessuna allerta confermata entro %s minuti.</i>" % WINDOW_MINUTES)
|
||||
sig_parts.append("NO_ALERT")
|
||||
else:
|
||||
LOGGER.info("Nessuna allerta confermata entro %s minuti.", WINDOW_MINUTES)
|
||||
# Salva state aggiornato (con eventi puliti) anche se non inviamo notifiche
|
||||
state["active_events"] = active_events
|
||||
save_state(state)
|
||||
return
|
||||
|
||||
# Anti-spam: controlla solo se ci sono nuovi eventi
|
||||
last_sent = state.get("last_sent_utc", "")
|
||||
last_sent_dt = None
|
||||
if last_sent:
|
||||
@@ -352,28 +943,48 @@ def main() -> None:
|
||||
delta_min = (now_utc - last_sent_dt).total_seconds() / 60.0
|
||||
too_soon = delta_min < MIN_RESEND_MINUTES
|
||||
|
||||
if signature == last_sig and too_soon:
|
||||
LOGGER.info("Allerta già inviata di recente (signature invariata).")
|
||||
# In modalità debug, bypassa controlli anti-spam
|
||||
if debug_mode:
|
||||
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato")
|
||||
elif too_soon:
|
||||
LOGGER.info("Allerta già inviata di recente (troppo presto).")
|
||||
# Salva state aggiornato anche se non inviamo
|
||||
state["active_events"] = active_events
|
||||
save_state(state)
|
||||
return
|
||||
|
||||
model_info = MODEL_AROME
|
||||
if comparisons:
|
||||
model_info = f"{MODEL_AROME} + ICON Italia (discordanza rilevata)"
|
||||
|
||||
msg = (
|
||||
f"⚠️ *ALLERTA METEO (entro {WINDOW_MINUTES} minuti)*\n"
|
||||
f"📍 {LOCATION_NAME}\n"
|
||||
f"🕒 Agg. `{now.strftime('%d/%m %H:%M')}` (modello: `{MODEL}`)\n\n"
|
||||
f"🕒 Agg. `{now.strftime('%d/%m %H:%M')}` (modello: `{model_info}`)\n\n"
|
||||
+ "\n\n".join(alerts)
|
||||
+ "\n\n_Fonte: Open-Meteo (AROME HD 1.5km)_"
|
||||
+ "\n\n_Fonte: Open-Meteo_"
|
||||
)
|
||||
|
||||
ok = telegram_send_markdown(msg)
|
||||
ok = telegram_send_markdown(msg, chat_ids=chat_ids)
|
||||
if ok:
|
||||
LOGGER.info("Notifica inviata.")
|
||||
save_state({
|
||||
"signature": signature,
|
||||
"last_sent_utc": now_utc.isoformat(timespec="seconds"),
|
||||
})
|
||||
# Salva state con eventi attivi aggiornati
|
||||
state["active_events"] = active_events
|
||||
state["last_sent_utc"] = now_utc.isoformat(timespec="seconds")
|
||||
save_state(state)
|
||||
else:
|
||||
LOGGER.error("Notifica NON inviata (token/telegram).")
|
||||
# Salva comunque lo state aggiornato
|
||||
state["active_events"] = active_events
|
||||
save_state(state)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
arg_parser = argparse.ArgumentParser(description="Nowcast 120m alert")
|
||||
arg_parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0])
|
||||
args = arg_parser.parse_args()
|
||||
|
||||
# In modalità debug, invia solo al primo chat ID (admin) e bypassa anti-spam
|
||||
chat_ids = [TELEGRAM_CHAT_IDS[0]] if args.debug else None
|
||||
|
||||
main(chat_ids=chat_ids, debug_mode=args.debug)
|
||||
|
||||
Reference in New Issue
Block a user