Files
loogle-scripts/services/telegram-bot/arome_snow_alert.py

1773 lines
79 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import datetime
import html
import json
import logging
import os
import tempfile
import time
from logging.handlers import RotatingFileHandler
from typing import Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
import requests
from dateutil import parser
# =============================================================================
# arome_snow_alert.py
#
# Scopo:
# Monitorare neve prevista nelle prossime 48 ore su più punti (Casa/Titano/Dogana/Carpegna)
# e notificare su Telegram se:
# - esiste almeno 1 ora nelle prossime 48h con snowfall > 0.2 cm
# (nessuna persistenza richiesta)
#
# Modello meteo:
# meteofrance_seamless (fornisce snowfall, rain, weathercode)
# Comparazione con italia_meteo_arpae_icon_2i quando scostamento >30%
#
# Token Telegram:
# Nessun token in chiaro. Lettura in ordine:
# 1) env TELEGRAM_BOT_TOKEN
# 2) ~/.telegram_dpc_bot_token
# 3) /etc/telegram_dpc_bot_token
#
# Debug:
# DEBUG=1 python3 arome_snow_alert.py
#
# Log:
# ./arome_snow_alert.log (stessa cartella dello script)
# =============================================================================
DEBUG = os.environ.get("DEBUG", "0").strip() == "1"
# ----------------- TELEGRAM -----------------
TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"]
TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token")
TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token"
# ----------------- PUNTI DI MONITORAGGIO -----------------
POINTS = [
{"name": "🏠 Casa", "lat": 43.9356, "lon": 12.4296},
{"name": "⛰️ Titano", "lat": 43.9360, "lon": 12.4460},
{"name": "🏢 Dogana", "lat": 43.9800, "lon": 12.4900},
{"name": "🏔️ Carpegna", "lat": 43.7819, "lon": 12.3346},
]
# ----------------- LOGICA ALLERTA -----------------
TZ = "Europe/Berlin"
TZINFO = ZoneInfo(TZ)
HOURS_AHEAD = 48
# Soglia precipitazione neve oraria (cm/h): NOTIFICA se qualsiasi ora > soglia
SNOW_HOURLY_THRESHOLD_CM = 0.2
# Codici meteo che indicano neve (WMO)
SNOW_WEATHER_CODES = [71, 73, 75, 77, 85, 86] # Neve leggera, moderata, forte, granelli, rovesci
# Soglie per notifiche incrementali (cambiamenti significativi)
SNOW_QUANTITY_CHANGE_THRESHOLD_PCT = 20.0 # 20% variazione
SNOW_QUANTITY_CHANGE_THRESHOLD_CM = 1.0 # O almeno 1 cm
START_TIME_CHANGE_THRESHOLD_HOURS = 2.0 # ±2 ore
# Aggiornamenti periodici per eventi prolungati
PERIODIC_UPDATE_INTERVAL_HOURS = 6.0 # Invia aggiornamento ogni 6 ore anche senza cambiamenti significativi
# Stagione invernale: 1 Nov -> 15 Apr
WINTER_START_MONTH = 11
WINTER_END_MONTH = 4
WINTER_END_DAY = 15
# File di stato
STATE_FILE = "/home/daniely/docker/telegram-bot/snow_state.json"
# ----------------- OPEN-METEO -----------------
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
HTTP_HEADERS = {"User-Agent": "rpi-arome-snow-alert/3.0"}
MODEL_AROME = "meteofrance_seamless"
MODEL_ICON_IT = "italia_meteo_arpae_icon_2i"
COMPARISON_THRESHOLD = 0.30 # 30% scostamento per comparazione
# ----------------- LOG FILE -----------------
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
LOG_FILE = os.path.join(BASE_DIR, "arome_snow_alert.log")
def setup_logger() -> logging.Logger:
logger = logging.getLogger("arome_snow_alert")
logger.setLevel(logging.DEBUG if DEBUG else logging.INFO)
logger.handlers.clear()
fh = RotatingFileHandler(LOG_FILE, maxBytes=1_000_000, backupCount=5, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
fh.setFormatter(fmt)
logger.addHandler(fh)
if DEBUG:
sh = logging.StreamHandler()
sh.setLevel(logging.DEBUG)
sh.setFormatter(fmt)
logger.addHandler(sh)
return logger
LOGGER = setup_logger()
# =============================================================================
# Utility
# =============================================================================
def now_local() -> datetime.datetime:
return datetime.datetime.now(TZINFO)
def is_winter_season() -> bool:
"""True se oggi è tra 1 Novembre e 15 Aprile (in TZ locale)."""
now = now_local()
m = now.month
d = now.day
if m >= WINTER_START_MONTH:
return True
if m <= 3:
return True
if m == WINTER_END_MONTH and d <= WINTER_END_DAY:
return True
return False
def read_text_file(path: str) -> str:
try:
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except FileNotFoundError:
return ""
except PermissionError:
LOGGER.debug("Permission denied reading %s", path)
return ""
except Exception as e:
LOGGER.exception("Error reading %s: %s", path, e)
return ""
def load_bot_token() -> str:
tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
if tok:
return tok
tok = read_text_file(TOKEN_FILE_HOME)
if tok:
return tok
tok = read_text_file(TOKEN_FILE_ETC)
return tok.strip() if tok else ""
def parse_time_to_local(t: str) -> datetime.datetime:
dt = parser.isoparse(t)
if dt.tzinfo is None:
return dt.replace(tzinfo=TZINFO)
return dt.astimezone(TZINFO)
def hhmm(dt: datetime.datetime) -> str:
return dt.strftime("%H:%M")
def ddmmyy_hhmm(dt: datetime.datetime) -> str:
"""Formato: DD/MM HH:MM"""
return dt.strftime("%d/%m %H:%M")
# =============================================================================
# Telegram
# =============================================================================
def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None) -> bool:
"""Invia solo in caso di allerta (mai per errori).
Args:
message_html: Messaggio HTML da inviare
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
"""
token = load_bot_token()
if not token:
LOGGER.warning("Telegram token missing: message not sent.")
return False
# Telegram HTML non supporta <br> come tag self-closing
# Sostituiamo <br> con \n e usiamo Markdown
message_md = message_html.replace("<br>", "\n").replace("<br/>", "\n")
# Manteniamo i tag HTML base (b, i, code, pre) convertendoli in Markdown
message_md = message_md.replace("<b>", "*").replace("</b>", "*")
message_md = message_md.replace("<i>", "_").replace("</i>", "_")
message_md = message_md.replace("<code>", "`").replace("</code>", "`")
message_md = message_md.replace("&gt;", ">").replace("&lt;", "<")
message_md = message_md.replace("&amp;", "&")
# <pre> rimane come codice monospazio in Markdown
message_md = message_md.replace("<pre>", "```\n").replace("</pre>", "\n```")
# Correggi doppio %% (HTML entity) -> % (Markdown non richiede escape)
message_md = message_md.replace("%%", "%")
if chat_ids is None:
chat_ids = TELEGRAM_CHAT_IDS
url = f"https://api.telegram.org/bot{token}/sendMessage"
base_payload = {
"text": message_md,
"parse_mode": "Markdown",
"disable_web_page_preview": True,
}
sent_ok = False
with requests.Session() as s:
for chat_id in chat_ids:
payload = dict(base_payload)
payload["chat_id"] = chat_id
try:
resp = s.post(url, json=payload, timeout=15)
if resp.status_code == 200:
sent_ok = True
else:
LOGGER.error("Telegram error chat_id=%s status=%s body=%s",
chat_id, resp.status_code, resp.text[:500])
time.sleep(0.25)
except Exception as e:
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
return sent_ok
def telegram_send_photo(photo_path: str, caption: str, chat_ids: Optional[List[str]] = None) -> bool:
"""
Invia foto via Telegram API.
Args:
photo_path: Percorso file immagine
caption: Didascalia foto (max 1024 caratteri)
chat_ids: Lista chat IDs (default: TELEGRAM_CHAT_IDS)
Returns:
True se inviata con successo, False altrimenti
"""
token = load_bot_token()
if not token:
LOGGER.warning("Telegram token missing: photo not sent.")
return False
if not os.path.exists(photo_path):
LOGGER.error("File foto non trovato: %s", photo_path)
return False
if chat_ids is None:
chat_ids = TELEGRAM_CHAT_IDS
url = f"https://api.telegram.org/bot{token}/sendPhoto"
# Limite Telegram per caption: 1024 caratteri
if len(caption) > 1024:
caption = caption[:1021] + "..."
sent_ok = False
with requests.Session() as s:
for chat_id in chat_ids:
try:
with open(photo_path, 'rb') as photo_file:
files = {'photo': photo_file}
data = {
'chat_id': chat_id,
'caption': caption,
'parse_mode': 'HTML'
}
resp = s.post(url, files=files, data=data, timeout=30)
if resp.status_code == 200:
sent_ok = True
LOGGER.info("Foto inviata a chat_id=%s", chat_id)
else:
LOGGER.error("Telegram error chat_id=%s status=%s body=%s",
chat_id, resp.status_code, resp.text[:500])
time.sleep(0.5)
except Exception as e:
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
return sent_ok
# =============================================================================
# State
# =============================================================================
def load_state() -> Dict:
default = {
"alert_active": False,
"signature": "",
"updated": "",
"last_notification_utc": "", # Timestamp UTC dell'ultima notifica inviata
"casa_snow_24h": 0.0,
"casa_peak_hourly": 0.0,
"casa_first_thr_time": "",
"casa_duration_hours": 0.0,
}
if os.path.exists(STATE_FILE):
try:
with open(STATE_FILE, "r", encoding="utf-8") as f:
data = json.load(f) or {}
default.update(data)
except Exception as e:
LOGGER.exception("State read error: %s", e)
return default
def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = None, last_notification_utc: Optional[str] = None) -> None:
try:
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
state_data = {
"alert_active": alert_active,
"signature": signature,
"updated": now_local().isoformat(),
}
if last_notification_utc:
state_data["last_notification_utc"] = last_notification_utc
if casa_data:
state_data.update({
"casa_snow_24h": casa_data.get("snow_24h", 0.0),
"casa_peak_hourly": casa_data.get("peak_hourly", 0.0),
"casa_first_thr_time": casa_data.get("first_thr_time", ""),
"casa_duration_hours": casa_data.get("duration_hours", 0.0),
})
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state_data, f, ensure_ascii=False, indent=2)
except Exception as e:
LOGGER.exception("State write error: %s", e)
# =============================================================================
# Open-Meteo
# =============================================================================
def get_forecast(session: requests.Session, lat: float, lon: float, model: str, extended: bool = False) -> Optional[Dict]:
"""
Recupera previsioni meteo.
Args:
session: Session HTTP
lat: Latitudine
lon: Longitudine
model: Modello meteo
extended: Se True, richiede dati estesi (fino a 10 giorni) senza minutely_15 per analisi a lungo termine
"""
# Parametri hourly: aggiungi rain e snow_depth quando necessario
hourly_params = "snowfall,weathercode"
if model == MODEL_AROME:
hourly_params += ",rain" # AROME fornisce rain
elif model == MODEL_ICON_IT:
hourly_params += ",rain,snow_depth" # ICON Italia fornisce rain e snow_depth
params = {
"latitude": lat,
"longitude": lon,
"hourly": hourly_params,
"daily": "snowfall_sum", # Aggiungi daily per colpo d'occhio 24/48h
"timezone": TZ,
"models": model,
}
if extended:
# Per analisi estesa, richiedi fino a 10 giorni (solo hourly, no minutely_15)
params["forecast_days"] = 10
else:
params["forecast_days"] = 2
# Aggiungi minutely_15 per AROME Seamless (dettaglio 15 minuti per inizio preciso)
# Se fallisce, riprova senza minutely_15
if model == MODEL_AROME:
params["minutely_15"] = "snowfall,precipitation_probability,precipitation,rain,temperature_2m"
try:
r = session.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, 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 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25)
if r2.status_code == 200:
return r2.json()
except Exception:
pass
try:
j = r.json()
LOGGER.error("Open-Meteo 400 (model=%s lat=%.4f lon=%.4f): %s", model, lat, lon, j.get("reason", j))
except Exception:
LOGGER.error("Open-Meteo 400 (model=%s lat=%.4f lon=%.4f): %s", model, lat, lon, r.text[:500])
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 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25)
if r2.status_code == 200:
return r2.json()
except Exception:
pass
LOGGER.error("Open-Meteo 504 Gateway Timeout (model=%s lat=%.4f lon=%.4f)", model, lat, lon)
return None
r.raise_for_status()
data = r.json()
# Converti snow_depth da metri a cm per ICON Italia (Open-Meteo restituisce in metri)
hourly_data = data.get("hourly", {})
if hourly_data and "snow_depth" in hourly_data and model == MODEL_ICON_IT:
snow_depth_values = hourly_data.get("snow_depth", [])
# Converti da metri a cm (moltiplica per 100)
snow_depth_cm = []
for sd in snow_depth_values:
if sd is not None:
try:
val_m = float(sd)
val_cm = val_m * 100.0 # Converti da metri a cm
snow_depth_cm.append(val_cm)
except (ValueError, TypeError):
snow_depth_cm.append(None)
else:
snow_depth_cm.append(None)
hourly_data["snow_depth"] = snow_depth_cm
data["hourly"] = hourly_data
# 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 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, 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 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25)
if r2.status_code == 200:
return r2.json()
except Exception:
pass
LOGGER.exception("Open-Meteo timeout (model=%s lat=%.4f lon=%.4f)", model, lat, lon)
return None
except Exception as 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 = session.get(OPEN_METEO_URL, params=params_no_minutely, headers=HTTP_HEADERS, timeout=25)
if r2.status_code == 200:
return r2.json()
except Exception:
pass
LOGGER.exception("Open-Meteo request error (model=%s lat=%.4f lon=%.4f): %s", model, lat, lon, e)
return None
def compare_models(arome_snow_24h: float, icon_snow_24h: float) -> Optional[Dict]:
"""Confronta i due modelli e ritorna info se scostamento >30%"""
if arome_snow_24h == 0 and icon_snow_24h == 0:
return None
# Calcola scostamento percentuale
if arome_snow_24h > 0:
diff_pct = abs(icon_snow_24h - arome_snow_24h) / arome_snow_24h
elif icon_snow_24h > 0:
diff_pct = abs(arome_snow_24h - icon_snow_24h) / icon_snow_24h
else:
return None
if diff_pct > COMPARISON_THRESHOLD:
return {
"diff_pct": diff_pct * 100,
"arome": arome_snow_24h,
"icon": icon_snow_24h
}
return None
def find_extended_end_time(session: requests.Session, lat: float, lon: float, model: str, first_thr_dt: datetime.datetime, now: datetime.datetime) -> Optional[str]:
"""
Trova la fine prevista del fenomeno nevoso usando dati estesi (hourly fino a 10 giorni).
Args:
session: Session HTTP
lat: Latitudine
lon: Longitudine
model: Modello meteo
first_thr_dt: Data/ora di inizio del fenomeno
now: Data/ora corrente
Returns:
Stringa con data/ora di fine prevista (formato DD/MM HH:MM) o None se non trovata
"""
try:
# Richiedi dati estesi (hourly, no minutely_15, fino a 10 giorni)
data_extended = get_forecast(session, lat, lon, model, extended=True)
if not data_extended:
return None
hourly = data_extended.get("hourly", {}) or {}
times_ext = hourly.get("time", []) or []
snow_ext = hourly.get("snowfall", []) or []
if not times_ext or not snow_ext:
return None
# Cerca l'ultima ora sopra soglia nei dati estesi
last_thr_dt_ext = None
for i in range(len(times_ext) - 1, -1, -1):
try:
dt_ext = parse_time_to_local(times_ext[i])
if dt_ext < first_thr_dt:
break # Non andare prima dell'inizio
if dt_ext < now:
continue # Salta il passato
s_val = float(snow_ext[i]) if snow_ext[i] is not None else 0.0
if s_val > SNOW_HOURLY_THRESHOLD_CM:
last_thr_dt_ext = dt_ext
break
except Exception:
continue
if last_thr_dt_ext:
return ddmmyy_hhmm(last_thr_dt_ext)
return None
except Exception as e:
LOGGER.exception("Errore nel calcolo fine estesa: %s", e)
return None
# =============================================================================
# Analytics
# =============================================================================
def compute_snow_stats(data: Dict) -> Optional[Dict]:
hourly = data.get("hourly", {}) or {}
daily = data.get("daily", {}) or {}
times = hourly.get("time", []) or []
snow = hourly.get("snowfall", []) or []
weathercode = hourly.get("weathercode", []) or [] # Per rilevare neve anche quando snowfall è basso
# Recupera snowfall_sum dai dati daily (solo per riferimento, non usato per calcoli)
# NOTA: I calcoli di accumulo (3h, 6h, 12h, 24h, 48h) vengono fatti da dati hourly
# perché daily rappresenta l'accumulo del giorno solare, non delle prossime N ore
n = min(len(times), len(snow))
if n == 0:
return None
times = times[:n]
snow = snow[:n]
now = now_local()
start_idx = -1
for i, t in enumerate(times):
try:
if parse_time_to_local(t) >= now:
start_idx = i
break
except Exception:
continue
if start_idx == -1:
return None
# Analizza sempre le prossime 48 ore
end_idx = min(start_idx + HOURS_AHEAD, n)
if end_idx <= start_idx:
return None
times_w = times[start_idx:end_idx]
snow_w = [float(x) if x is not None else 0.0 for x in snow[start_idx:end_idx]]
weathercode_w = [int(x) if x is not None else None for x in weathercode[start_idx:end_idx]] if len(weathercode) > start_idx else []
dt_w = [parse_time_to_local(t) for t in times_w]
# Recupera snow_depth se disponibile (solo per ICON Italia)
snow_depth_w = []
snow_depth_max = 0.0
if "snow_depth" in hourly:
snow_depth_raw = hourly.get("snow_depth", []) or []
if len(snow_depth_raw) > start_idx:
snow_depth_w = [float(x) if x is not None else None for x in snow_depth_raw[start_idx:end_idx]]
# Calcola massimo snow_depth nelle 48 ore (solo valori validi)
valid_sd = [sd for sd in snow_depth_w if sd is not None and sd >= 0]
if valid_sd:
snow_depth_max = max(valid_sd)
# Verifica se c'è un fenomeno nevoso nelle 48 ore
# Considera neve se: snowfall > soglia OPPURE weather_code indica neve
has_snow_event = any(
(s > SNOW_HOURLY_THRESHOLD_CM) or
(i < len(weathercode_w) and weathercode_w[i] in SNOW_WEATHER_CODES)
for i, s in enumerate(snow_w)
)
# Accumuli informativi (solo ore con neve effettiva)
def sum_h(h: int) -> float:
upto = min(h, len(snow_w))
# Somma solo snowfall > 0 (accumulo totale previsto)
return float(sum(s for s in snow_w[:upto] if s > 0.0))
s3 = sum_h(3)
s6 = sum_h(6)
s12 = sum_h(12)
s24 = sum_h(24) # Sempre calcolato da hourly (prossime 24h)
s48 = sum_h(48) # Calcolato da hourly (prossime 48h)
# Accumulo totale previsto (somma di tutti i snowfall orari > 0 nelle 48h)
total_accumulation_cm = float(sum(s for s in snow_w if s > 0.0))
# NOTA: NON usiamo snow_24h_daily[0] perché è l'accumulo di OGGI, non delle prossime 24h
# snow_48h viene calcolato correttamente da hourly (prossime 48h), non da daily[1]
# Picco orario e prima occorrenza di neve (snowfall > 0 OPPURE weathercode neve)
peak = max(snow_w) if snow_w else 0.0
peak_time = ""
first_snow_time = "" # Inizio nevicata (snowfall > 0 o weathercode)
first_snow_val = 0.0
first_thr_time = "" # Prima occorrenza sopra soglia (per compatibilità)
first_thr_val = 0.0
# Usa minutely_15 per trovare inizio preciso se disponibile
minutely = data.get("minutely_15", {}) or {}
minutely_times = minutely.get("time", []) or []
minutely_snow = minutely.get("snowfall", []) or []
minutely_available = bool(minutely_times) and len(minutely_times) > 0
if minutely_available:
# Trova prima occorrenza precisa (risoluzione 15 minuti)
# Analizza sempre le 48 ore
minutely_weathercode = hourly.get("weathercode", []) or [] # Minutely non ha weathercode, usiamo hourly come fallback
for i, (t_str, s_val) in enumerate(zip(minutely_times, minutely_snow)):
try:
dt_min = parse_time_to_local(t_str)
if dt_min < now or dt_min > (now + datetime.timedelta(hours=HOURS_AHEAD)):
continue
s_float = float(s_val) if s_val is not None else 0.0
# Rileva inizio neve: snowfall > 0 (anche se sotto soglia)
if s_float > 0.0:
if not first_snow_time:
first_snow_time = ddmmyy_hhmm(dt_min)
first_snow_val = s_float
first_thr_time = first_snow_time # Per compatibilità
first_thr_val = s_float
# Rileva picco sopra soglia
if s_float > SNOW_HOURLY_THRESHOLD_CM:
if s_float > peak:
peak = s_float
peak_time = hhmm(dt_min)
except Exception:
continue
# Fallback a dati hourly se minutely non disponibile o non ha trovato nulla
if not first_snow_time:
for i, (v, code) in enumerate(zip(snow_w, weathercode_w if len(weathercode_w) == len(snow_w) else [None] * len(snow_w))):
# Rileva inizio neve: snowfall > 0 OPPURE weathercode indica neve
is_snow = (v > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
if is_snow and not first_snow_time:
first_snow_time = ddmmyy_hhmm(dt_w[i])
first_snow_val = v if v > 0.0 else 0.0
first_thr_time = first_snow_time # Per compatibilità
first_thr_val = first_snow_val
# Rileva picco
if v > peak:
peak = v
peak_time = hhmm(dt_w[i])
if not peak_time and peak > 0 and dt_w:
try:
peak_i = snow_w.index(peak)
peak_time = hhmm(dt_w[peak_i])
except Exception:
peak_time = ""
# Calcola durata del fenomeno e andamento
duration_hours = 0.0
snow_timeline = [] # Lista di (datetime, valore) per grafico
first_snow_dt = None
last_snow_dt = None
# Usa first_snow_time se disponibile, altrimenti first_thr_time
start_time_to_use = first_snow_time if first_snow_time else first_thr_time
if start_time_to_use:
# Trova datetime di inizio (prima occorrenza di neve)
for i, dt in enumerate(dt_w):
snow_val = snow_w[i]
code = weathercode_w[i] if i < len(weathercode_w) else None
is_snow = (snow_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
if ddmmyy_hhmm(dt) == start_time_to_use or (not first_snow_dt and is_snow):
first_snow_dt = dt
break
# Trova fine del fenomeno (ultima ora con neve: snowfall > 0 OPPURE weathercode)
if first_snow_dt:
for i in range(len(dt_w) - 1, -1, -1):
snow_val = snow_w[i]
code = weathercode_w[i] if i < len(weathercode_w) else None
is_snow = (snow_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
if is_snow:
last_snow_dt = dt_w[i]
break
if last_snow_dt:
duration_hours = (last_snow_dt - first_snow_dt).total_seconds() / 3600.0
# Costruisci timeline per grafico (usa minutely se disponibile, altrimenti hourly)
# La timeline include tutto il fenomeno nelle 48 ore
if minutely_available:
for i, (t_str, s_val) in enumerate(zip(minutely_times, minutely_snow)):
try:
dt_min = parse_time_to_local(t_str)
if dt_min < first_snow_dt or dt_min > last_snow_dt:
continue
# Limita la timeline alle 48 ore
if dt_min > (now + datetime.timedelta(hours=HOURS_AHEAD)):
continue
s_float = float(s_val) if s_val is not None else 0.0
snow_timeline.append((dt_min, s_float))
except Exception:
continue
else:
# Usa hourly - include tutto il fenomeno fino alla fine
for i, dt in enumerate(dt_w):
if dt >= first_snow_dt and dt <= last_snow_dt:
snow_timeline.append((dt, snow_w[i]))
# Calcola orario di fine precipitazioni
last_thr_time = ""
is_ongoing = False # Indica se il fenomeno continua oltre la finestra analizzata
extended_end_time = "" # Fine prevista se estesa oltre 48h
if last_snow_dt:
last_thr_time = ddmmyy_hhmm(last_snow_dt)
# Verifica se il fenomeno continua oltre le 48 ore analizzate
# Questo accade se l'ultimo punto analizzato (dopo 48 ore) ha ancora neve
if has_snow_event:
# Controlla se siamo al limite delle 48 ore e l'ultimo punto ha ancora neve
hours_analyzed = (end_idx - start_idx)
if hours_analyzed >= HOURS_AHEAD and len(snow_w) > 0:
last_snow_val = snow_w[-1]
last_code = weathercode_w[-1] if len(weathercode_w) > 0 else None
last_has_snow = (last_snow_val > 0.0) or (last_code is not None and last_code in SNOW_WEATHER_CODES)
if last_has_snow:
is_ongoing = True
# L'ultimo punto ha ancora neve, quindi il fenomeno continua oltre le 48h
# La fine effettiva sarà calcolata con dati estesi se disponibili
return {
"snow_3h": s3,
"snow_6h": s6,
"snow_12h": s12,
"snow_24h": s24,
"snow_48h": s48, # Calcolato da hourly (prossime 48h), non da daily
"total_accumulation_cm": total_accumulation_cm, # Accumulo totale previsto (somma di tutti i snowfall > 0)
"peak_hourly": float(peak),
"peak_time": peak_time,
"first_thr_time": first_thr_time, # Per compatibilità
"first_thr_val": float(first_thr_val), # Per compatibilità
"first_snow_time": first_snow_time if first_snow_time else first_thr_time, # Inizio nevicata
"first_snow_val": float(first_snow_val),
"last_thr_time": last_thr_time,
"duration_hours": duration_hours,
"snow_timeline": snow_timeline,
"triggered": bool(first_snow_time or first_thr_time),
"is_ongoing": is_ongoing, # Indica se continua oltre 48h
"extended_end_time": extended_end_time, # Fine prevista se estesa
"snow_depth_max": float(snow_depth_max), # Massimo snow_depth nelle 48 ore (cm)
}
def point_summary(name: str, st: Dict) -> Dict:
return {
"name": name,
"triggered": bool(st["triggered"]),
"snow_3h": st["snow_3h"],
"snow_6h": st["snow_6h"],
"snow_12h": st["snow_12h"],
"snow_24h": st["snow_24h"],
"snow_48h": st.get("snow_48h"),
"snow_depth_max": st.get("snow_depth_max", 0.0),
"peak_hourly": st["peak_hourly"],
"peak_time": st["peak_time"],
"first_thr_time": st["first_thr_time"],
"first_thr_val": st["first_thr_val"],
"last_thr_time": st.get("last_thr_time", ""),
"duration_hours": st.get("duration_hours", 0.0),
"snow_timeline": st.get("snow_timeline", []),
"is_ongoing": st.get("is_ongoing", False),
"extended_end_time": st.get("extended_end_time", ""),
}
def build_signature(summaries: List[Dict]) -> str:
# Firma per evitare spam: arrotondiamo a 0.1 cm
parts = []
for s in summaries:
parts.append(
f"{s['name']}:t{int(s['triggered'])}"
f":24={s['snow_24h']:.1f}"
f":pk={s['peak_hourly']:.1f}"
f":ft={s['first_thr_time'] or '-'}"
)
return "|".join(parts)
def generate_snow_chart(timeline: List[Tuple[datetime.datetime, float]], max_width: int = 50) -> str:
"""Genera grafico ASCII orizzontale dell'andamento nevoso (barre affiancate)"""
if not timeline:
return ""
if len(timeline) == 0:
return ""
# Estrai valori
values = [v for _, v in timeline]
if not values or max(values) == 0:
return ""
max_val = max(values)
# Caratteri per barre verticali (8 livelli)
bars = "▁▂▃▄▅▆▇█"
# Determina se i dati sono a 15 minuti o orari
is_15min = len(timeline) > 20
# Frequenza etichette temporali: ogni ora (4 barre se 15min, 1 barra se hourly)
label_freq = 4 if is_15min else 1
# Crea grafico orizzontale: barre affiancate
# Ogni riga può contenere max_width barre, poi va a capo
result_lines = []
# Processa timeline in blocchi di max_width
for start_idx in range(0, len(timeline), max_width):
end_idx = min(start_idx + max_width, len(timeline))
block = timeline[start_idx:end_idx]
# Crea riga etichette temporali (solo ogni ora)
# Usa una lista di caratteri per allineare perfettamente con le barre
# Ogni carattere corrisponde esattamente a una barra
time_line_chars = [" "] * len(block)
bar_line_parts = []
for i, (dt, val) in enumerate(block):
global_idx = start_idx + i
# Calcola livello barra
if max_val > 0:
level = int((val / max_val) * 7)
level = min(level, 7)
else:
level = 0
bar = bars[level]
bar_line_parts.append(bar)
# Aggiungi etichetta temporale solo ogni ora, posizionata sopra la prima barra dell'ora
# Mostra anche l'etichetta se siamo all'ultimo punto e corrisponde a un'ora intera
is_hour_start = (global_idx == 0 or global_idx % label_freq == 0)
is_last_point = (global_idx == len(timeline) - 1)
# Se è l'ultimo punto e l'ora è intera (minuti == 0), mostra l'etichetta
if is_hour_start or (is_last_point and dt.minute == 0):
time_str = dt.strftime("%H:%M")
hour_str = time_str[:2] # Prendi solo "HH" (es. "07")
# Posiziona l'etichetta sopra le prime due barre dell'ora
# Ogni carattere dell'etichetta corrisponde esattamente a una barra
# IMPORTANTE: se il blocco ha solo 1 elemento, estendi time_line_chars per accogliere entrambe le cifre
if i + 1 >= len(time_line_chars) and len(hour_str) > 1:
# Estendi time_line_chars se necessario per la seconda cifra
time_line_chars.extend([" "] * (i + 2 - len(time_line_chars)))
if i < len(time_line_chars):
time_line_chars[i] = hour_str[0] if len(hour_str) > 0 else " "
if i + 1 < len(time_line_chars) and len(hour_str) > 1:
time_line_chars[i + 1] = hour_str[1]
# Le posizioni i+2 e i+3 (se esistono) rimangono spazi per le altre barre dell'ora
# Tronca time_line_chars alla lunghezza effettiva delle barre per allineamento
# Ma assicurati che sia almeno lunga quanto le barre
time_line_chars = time_line_chars[:max(len(bar_line_parts), len(time_line_chars))]
# Crea le righe - ogni carattere dell'etichetta corrisponde a una barra
time_line = "".join(time_line_chars)
bar_line = "".join(bar_line_parts)
# Assicurati che le righe abbiano la stessa lunghezza
max_len = max(len(time_line), len(bar_line))
time_line = time_line.ljust(max_len)
bar_line = bar_line.ljust(max_len)
# Aggiungi al risultato (etichetta sopra barre)
result_lines.append(time_line)
result_lines.append(bar_line)
# Aggiungi riga vuota tra i blocchi (tranne l'ultimo)
if end_idx < len(timeline):
result_lines.append("")
return "\n".join(result_lines)
def generate_snow_chart_image(
data_arome: Dict,
data_icon: Optional[Dict],
output_path: str,
location_name: str = "Casa"
) -> bool:
"""
Genera un grafico orario di 48 ore con linee parallele per:
- snow_depth da ICON Italia (☃️)
- snowfall da AROME seamless (❄️)
- rain da AROME seamless (💧)
- snowfall da ICON Italia (❄️)
- rain da ICON Italia (💧)
Args:
data_arome: Dati AROME seamless
data_icon: Dati ICON Italia (opzionale)
output_path: Percorso file immagine output
location_name: Nome località per titolo
Returns:
True se generato con successo, False altrimenti
"""
try:
import matplotlib
matplotlib.use('Agg') # Backend non-interattivo
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from matplotlib.lines import Line2D
# Configura font per supportare emoji
# Nota: matplotlib potrebbe non supportare completamente emoji colorati,
# ma proveremo a usare caratteri Unicode alternativi se il font emoji non è disponibile
try:
import matplotlib.font_manager as fm
import os
# Cerca direttamente il percorso del font Noto Color Emoji
possible_paths = [
'/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf',
'/usr/share/fonts/opentype/noto/NotoColorEmoji.ttf',
'/usr/share/fonts/NotoColorEmoji.ttf',
]
emoji_font_path = None
for path in possible_paths:
if os.path.exists(path):
emoji_font_path = path
break
if emoji_font_path:
# Carica il font direttamente e registralo
try:
# Registra il font con matplotlib
fm.fontManager.addfont(emoji_font_path)
prop = fm.FontProperties(fname=emoji_font_path)
# Configura matplotlib per usare questo font
plt.rcParams['font.family'] = 'sans-serif'
# Aggiungi alla lista dei font di fallback
if 'Noto Color Emoji' not in plt.rcParams['font.sans-serif']:
plt.rcParams['font.sans-serif'].insert(0, 'Noto Color Emoji')
LOGGER.debug("Font emoji caricato: %s", emoji_font_path)
except Exception as e:
LOGGER.debug("Errore caricamento font emoji: %s", e)
except Exception as e:
LOGGER.debug("Errore configurazione font emoji: %s", e)
pass # Se fallisce, usa il font di default
except ImportError:
LOGGER.warning("matplotlib non disponibile: grafico non generato")
return False
try:
# Estrai dati hourly da AROME
hourly_arome = data_arome.get("hourly", {}) or {}
times_arome = hourly_arome.get("time", []) or []
snowfall_arome = hourly_arome.get("snowfall", []) or []
rain_arome = hourly_arome.get("rain", []) or []
# Estrai dati hourly da ICON Italia (se disponibile)
snowfall_icon = []
rain_icon = []
snow_depth_icon = []
times_icon = []
if data_icon:
hourly_icon = data_icon.get("hourly", {}) or {}
times_icon = hourly_icon.get("time", []) or []
snowfall_icon = hourly_icon.get("snowfall", []) or []
rain_icon = hourly_icon.get("rain", []) or []
# snow_depth è già convertito in cm da get_forecast
snow_depth_icon = hourly_icon.get("snow_depth", []) or []
# Prendi solo le prossime 48 ore dall'ora corrente
now = now_local()
times_parsed = []
for t_str in times_arome:
try:
dt = parse_time_to_local(t_str)
if dt >= now and (dt - now).total_seconds() <= HOURS_AHEAD * 3600:
times_parsed.append(dt)
except Exception:
continue
if not times_parsed:
LOGGER.warning("Nessun dato valido per grafico nelle prossime 48 ore")
return False
# Filtra i dati per le ore valide
start_idx_arome = len(times_arome) - len(times_parsed)
if start_idx_arome < 0:
start_idx_arome = 0
times_plot = times_parsed[:48] # Massimo 48 ore
snowfall_arome_plot = []
rain_arome_plot = []
for i, dt in enumerate(times_plot):
idx_arome = start_idx_arome + i
if idx_arome < len(snowfall_arome):
snowfall_arome_plot.append(float(snowfall_arome[idx_arome]) if snowfall_arome[idx_arome] is not None else 0.0)
else:
snowfall_arome_plot.append(0.0)
if idx_arome < len(rain_arome):
rain_arome_plot.append(float(rain_arome[idx_arome]) if rain_arome[idx_arome] is not None else 0.0)
else:
rain_arome_plot.append(0.0)
# Allinea dati ICON Italia ai timestamp di AROME
snowfall_icon_plot = []
rain_icon_plot = []
snow_depth_icon_plot = []
if times_icon and data_icon:
# Crea mappa timestamp -> valori per ICON (con tolleranza per allineamento)
icon_map = {}
for idx, t_str in enumerate(times_icon):
try:
dt = parse_time_to_local(t_str)
if dt >= now and (dt - now).total_seconds() <= HOURS_AHEAD * 3600:
# Converti snow_depth se necessario (dovrebbe già essere in cm da get_forecast)
sd_val = snow_depth_icon[idx] if idx < len(snow_depth_icon) else None
if sd_val is not None:
try:
sd_val = float(sd_val)
except (ValueError, TypeError):
sd_val = None
icon_map[dt] = {
'snowfall': float(snowfall_icon[idx]) if idx < len(snowfall_icon) and snowfall_icon[idx] is not None else 0.0,
'rain': float(rain_icon[idx]) if idx < len(rain_icon) and rain_icon[idx] is not None else 0.0,
'snow_depth': sd_val
}
except Exception:
continue
# Allinea ai timestamp di AROME (cerca corrispondenza esatta o più vicina entro 30 minuti)
for dt in times_plot:
# Cerca corrispondenza esatta
if dt in icon_map:
val = icon_map[dt]
snowfall_icon_plot.append(val['snowfall'])
rain_icon_plot.append(val['rain'])
snow_depth_icon_plot.append(val['snow_depth'])
else:
# Cerca corrispondenza più vicina (entro 30 minuti)
found = False
best_dt = None
best_diff = None
for icon_dt, val in icon_map.items():
diff_seconds = abs((icon_dt - dt).total_seconds())
if diff_seconds <= 1800: # Entro 30 minuti
if best_diff is None or diff_seconds < best_diff:
best_diff = diff_seconds
best_dt = icon_dt
found = True
if found and best_dt:
val = icon_map[best_dt]
snowfall_icon_plot.append(val['snowfall'])
rain_icon_plot.append(val['rain'])
snow_depth_icon_plot.append(val['snow_depth'])
else:
# Nessuna corrispondenza trovata: usa valori zero/None
snowfall_icon_plot.append(0.0)
rain_icon_plot.append(0.0)
snow_depth_icon_plot.append(None)
else:
# Se ICON non disponibile, riempi con None/0
snowfall_icon_plot = [0.0] * len(times_plot)
rain_icon_plot = [0.0] * len(times_plot)
snow_depth_icon_plot = [None] * len(times_plot)
# Crea figura e assi
fig, ax = plt.subplots(figsize=(14, 8), facecolor='white')
fig.patch.set_facecolor('white')
# Colori e stili per le linee (accattivanti e ben distinguibili)
colors = {
'snow_depth_icon': '#1E90FF', # Dodger blue per snow_depth (☃️)
'snowfall_arome': '#DC143C', # Crimson per snowfall AROME (❄️)
'rain_arome': '#00AA00', # Dark green per rain AROME (💧)
'snowfall_icon': '#FF69B4', # Hot pink per snowfall ICON (❄️)
'rain_icon': '#20B2AA' # Light sea green per rain ICON (💧)
}
markers = {
'snow_depth_icon': 'D', # Diamond (☃️)
'snowfall_arome': 's', # Square (❄️)
'rain_arome': 'o', # Circle (💧)
'snowfall_icon': '^', # Triangle up (❄️)
'rain_icon': 'v' # Triangle down (💧)
}
# Traccia le linee
lines_handles = []
lines_labels = []
# 1. snow_depth da ICON Italia - scala a sinistra (cm)
if any(sd is not None and sd > 0 for sd in snow_depth_icon_plot):
valid_data = [(t, sd) for t, sd in zip(times_plot, snow_depth_icon_plot) if sd is not None and sd >= 0]
if valid_data:
times_sd, values_sd = zip(*valid_data)
line_sd = ax.plot(times_sd, values_sd, color=colors['snow_depth_icon'],
marker=markers['snow_depth_icon'], markersize=7, linewidth=3.0,
label='Manto nevoso (ICON Italia) [cm]', linestyle='-', alpha=0.95,
markeredgecolor='white', markeredgewidth=1.5)
lines_handles.append(line_sd[0])
lines_labels.append('Manto nevoso (ICON Italia) [cm]')
# 2. snowfall da AROME seamless - scala a sinistra (cm)
if any(s > 0 for s in snowfall_arome_plot):
line_sf_arome = ax.plot(times_plot, snowfall_arome_plot, color=colors['snowfall_arome'],
marker=markers['snowfall_arome'], markersize=6, linewidth=2.5,
label='Neve (AROME) [cm]', linestyle='-', alpha=0.9,
markeredgecolor='white', markeredgewidth=1.2)
lines_handles.append(line_sf_arome[0])
lines_labels.append('Neve (AROME) [cm]')
# 3. rain da AROME seamless - scala a sinistra (mm)
if any(r > 0 for r in rain_arome_plot):
line_r_arome = ax.plot(times_plot, rain_arome_plot, color=colors['rain_arome'],
marker=markers['rain_arome'], markersize=6, linewidth=2.5,
label='Pioggia (AROME) [mm]', linestyle='-', alpha=0.9,
markeredgecolor='white', markeredgewidth=1.2)
lines_handles.append(line_r_arome[0])
lines_labels.append('Pioggia (AROME) [mm]')
# 4. snowfall da ICON Italia - scala a sinistra (cm)
if any(s > 0 for s in snowfall_icon_plot):
line_sf_icon = ax.plot(times_plot, snowfall_icon_plot, color=colors['snowfall_icon'],
marker=markers['snowfall_icon'], markersize=6, linewidth=2.5,
label='Neve (ICON Italia) [cm]', linestyle='--', alpha=0.9,
markeredgecolor='white', markeredgewidth=1.2, dashes=(5, 3))
lines_handles.append(line_sf_icon[0])
lines_labels.append('Neve (ICON Italia) [cm]')
# 5. rain da ICON Italia - scala a sinistra (mm)
if any(r > 0 for r in rain_icon_plot):
line_r_icon = ax.plot(times_plot, rain_icon_plot, color=colors['rain_icon'],
marker=markers['rain_icon'], markersize=6, linewidth=2.5,
label='Pioggia (ICON Italia) [mm]', linestyle='--', alpha=0.9,
markeredgecolor='white', markeredgewidth=1.2, dashes=(5, 3))
lines_handles.append(line_r_icon[0])
lines_labels.append('Pioggia (ICON Italia) [mm]')
if not lines_handles:
LOGGER.warning("Nessun dato da visualizzare nel grafico")
plt.close(fig)
return False
# Configurazione assi (grafica accattivante e ben leggibile)
ax.set_xlabel('Ora', fontsize=13, fontweight='bold', color='#333333')
ax.set_ylabel('Precipitazioni (cm per neve, mm per pioggia)', fontsize=13, fontweight='bold', color='#333333')
# Rimuovi emoji dal titolo per evitare problemi con font (emoji nella legenda sono OK)
location_clean = location_name.replace('🏠 ', '').replace('💧', '').replace('❄️', '').strip()
ax.set_title(f'Previsioni Neve e Precipitazioni - {location_clean}\n48 ore',
fontsize=15, fontweight='bold', pad=25, color='#1a1a1a')
ax.grid(True, alpha=0.4, linestyle='--', linewidth=0.8, color='#888888')
ax.set_facecolor('#F8F8F8')
# Colora gli assi
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['left'].set_color('#666666')
ax.spines['bottom'].set_color('#666666')
# Formatta asse X (date/ore)
ax.xaxis.set_major_formatter(mdates.DateFormatter('%d/%m %H:%M'))
ax.xaxis.set_major_locator(mdates.HourLocator(interval=6)) # Ogni 6 ore
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right', fontsize=10)
plt.setp(ax.yaxis.get_majorticklabels(), fontsize=10)
# Legenda migliorata (con testo descrittivo e marker colorati distinti)
legend = ax.legend(handles=lines_handles, labels=lines_labels, loc='upper left',
framealpha=0.98, fontsize=11, ncol=1, frameon=True,
shadow=True, fancybox=True, edgecolor='#CCCCCC')
legend.get_frame().set_facecolor('#FFFFFF')
legend.get_frame().set_linewidth(1.5)
# Migliora layout (sopprimi warning emoji nel font)
import warnings
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=UserWarning, message='.*Glyph.*missing from font.*')
plt.tight_layout()
# Salva immagine
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=UserWarning, message='.*Glyph.*missing from font.*')
plt.savefig(output_path, dpi=150, facecolor='white', bbox_inches='tight')
plt.close(fig)
LOGGER.info("Grafico generato: %s", output_path)
return True
except Exception as e:
LOGGER.exception("Errore generazione grafico: %s", e)
return False
def is_significant_change(current: Dict, previous: Dict) -> Tuple[bool, str]:
"""Verifica se ci sono cambiamenti significativi. Ritorna (is_significant, reason)"""
if not previous.get("alert_active", False):
return True, "nuova_allerta"
prev_snow_24h = previous.get("casa_snow_24h", 0.0)
prev_peak = previous.get("casa_peak_hourly", 0.0)
prev_first_time = previous.get("casa_first_thr_time", "")
curr_snow_24h = current.get("snow_24h", 0.0)
curr_peak = current.get("peak_hourly", 0.0)
curr_first_time = current.get("first_thr_time", "")
# Annullamento
if not current.get("triggered", False):
return True, "annullamento"
# Variazione quantità (20% o 1 cm)
if prev_snow_24h > 0:
pct_change = abs(curr_snow_24h - prev_snow_24h) / prev_snow_24h * 100
abs_change = abs(curr_snow_24h - prev_snow_24h)
if pct_change >= SNOW_QUANTITY_CHANGE_THRESHOLD_PCT or abs_change >= SNOW_QUANTITY_CHANGE_THRESHOLD_CM:
return True, f"variazione_quantita_{'+' if curr_snow_24h > prev_snow_24h else '-'}"
# Variazione picco
if prev_peak > 0:
pct_change = abs(curr_peak - prev_peak) / prev_peak * 100
if pct_change >= SNOW_QUANTITY_CHANGE_THRESHOLD_PCT:
return True, f"variazione_picco_{'+' if curr_peak > prev_peak else '-'}"
# Variazione orario inizio (±2 ore)
if prev_first_time and curr_first_time:
try:
# Parse date from "DD/MM HH:MM"
prev_parts = prev_first_time.split()
curr_parts = curr_first_time.split()
if len(prev_parts) == 2 and len(curr_parts) == 2:
prev_date_str, prev_time_str = prev_parts
curr_date_str, curr_time_str = curr_parts
# Determina l'anno prima del parsing per evitare warning di deprecazione
now = now_local()
# Estrai mese e giorno dalla stringa senza parsing
prev_day, prev_month = map(int, prev_date_str.split("/"))
curr_day, curr_month = map(int, curr_date_str.split("/"))
# Determina l'anno appropriato
if prev_month > now.month or (prev_month == now.month and prev_day > now.day):
prev_year = now.year - 1
else:
prev_year = now.year
if curr_month > now.month or (curr_month == now.month and curr_day > now.day):
curr_year = now.year - 1
else:
curr_year = now.year
# Parse completo con anno incluso
prev_dt = datetime.datetime.strptime(f"{prev_date_str}/{prev_year} {prev_time_str}", "%d/%m/%Y %H:%M")
curr_dt = datetime.datetime.strptime(f"{curr_date_str}/{curr_year} {curr_time_str}", "%d/%m/%Y %H:%M")
hours_diff = abs((curr_dt - prev_dt).total_seconds() / 3600.0)
if hours_diff >= START_TIME_CHANGE_THRESHOLD_HOURS:
return True, f"variazione_orario_{hours_diff:.1f}h"
except Exception:
pass
return False, ""
def is_periodic_update_due(previous: Dict) -> bool:
"""
Verifica se è il momento di inviare un aggiornamento periodico.
Ritorna True se:
- L'allerta è attiva
- È passato almeno PERIODIC_UPDATE_INTERVAL_HOURS dall'ultima notifica
"""
if not previous.get("alert_active", False):
return False
last_notification_str = previous.get("last_notification_utc", "")
if not last_notification_str:
# Se non c'è timestamp, considera che sia dovuto (primo aggiornamento periodico)
return True
try:
last_notification = datetime.datetime.fromisoformat(last_notification_str)
if last_notification.tzinfo is None:
last_notification = last_notification.replace(tzinfo=datetime.timezone.utc)
else:
last_notification = last_notification.astimezone(datetime.timezone.utc)
now_utc = datetime.datetime.now(datetime.timezone.utc)
hours_since_last = (now_utc - last_notification).total_seconds() / 3600.0
return hours_since_last >= PERIODIC_UPDATE_INTERVAL_HOURS
except Exception as e:
LOGGER.debug("Errore parsing last_notification_utc: %s", e)
return True # In caso di errore, considera dovuto
# =============================================================================
# Main
# =============================================================================
def analyze_snow(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None:
if not is_winter_season():
LOGGER.info("Fuori stagione invernale: nessuna verifica/notify.")
save_state(False, "", None)
return
now_str = now_local().strftime("%H:%M")
LOGGER.info("--- Check Neve AROME Seamless %s ---", now_str)
state = load_state()
was_active = bool(state.get("alert_active", False))
last_sig = state.get("signature", "")
summaries: List[Dict] = []
summaries_icon: Dict[str, Dict] = {} # point_name -> icon summary (solo se scostamento significativo)
comparisons: Dict[str, Dict] = {} # point_name -> comparison info
# Conserva dati raw per Casa per generare grafico
casa_data_arome_raw: Optional[Dict] = None
casa_data_icon_raw: Optional[Dict] = None
casa_location_name = "🏠 Casa"
with requests.Session() as session:
for p in POINTS:
# Recupera AROME seamless
data_arome = get_forecast(session, p["lat"], p["lon"], MODEL_AROME)
if not data_arome:
LOGGER.warning("Forecast AROME non disponibile per %s (skip).", p["name"])
continue
# Conserva dati raw per Casa per generare grafico
if p["name"] == "🏠 Casa":
casa_data_arome_raw = data_arome
st_arome = compute_snow_stats(data_arome)
if not st_arome:
LOGGER.warning("Statistiche AROME non calcolabili per %s (skip).", p["name"])
continue
# Se il fenomeno continua oltre le 48 ore, cerca la fine nei dati estesi
if st_arome.get("is_ongoing", False) and st_arome.get("first_thr_time"):
try:
# Parse first_thr_time per ottenere first_thr_dt
first_thr_parts = st_arome["first_thr_time"].split()
if len(first_thr_parts) == 2:
first_date_str, first_time_str = first_thr_parts
first_day, first_month = map(int, first_date_str.split("/"))
now = now_local()
if first_month > now.month or (first_month == now.month and first_day > now.day):
first_year = now.year - 1
else:
first_year = now.year
first_thr_dt = datetime.datetime.strptime(f"{first_date_str}/{first_year} {first_time_str}", "%d/%m/%Y %H:%M")
# Cerca fine estesa (prova prima AROME, poi ICON se AROME non disponibile)
extended_end = find_extended_end_time(session, p["lat"], p["lon"], MODEL_AROME, first_thr_dt, now)
if not extended_end:
# Fallback a ICON Italia se AROME non ha dati estesi
extended_end = find_extended_end_time(session, p["lat"], p["lon"], MODEL_ICON_IT, first_thr_dt, now)
if extended_end:
st_arome["extended_end_time"] = extended_end
LOGGER.info("Fine estesa trovata per %s: %s", p["name"], extended_end)
except Exception as e:
LOGGER.exception("Errore nel calcolo fine estesa per %s: %s", p["name"], e)
# Recupera ICON Italia per comparazione (sempre per Casa, opzionale per altri)
# Per Casa recuperiamo sempre ICON per mostrare nel grafico e nel messaggio
if p["name"] == "🏠 Casa":
data_icon = get_forecast(session, p["lat"], p["lon"], MODEL_ICON_IT)
if data_icon:
casa_data_icon_raw = data_icon
st_icon = compute_snow_stats(data_icon)
if st_icon:
summaries_icon[p["name"]] = point_summary(p["name"], st_icon)
# Calcola comparazione per logging (ma mostra sempre ICON nel messaggio per Casa)
comp = compare_models(st_arome["snow_24h"], st_icon["snow_24h"])
if comp:
comparisons[p["name"]] = comp
LOGGER.info("Scostamento >30%% per %s: AROME=%.1f cm, ICON=%.1f cm (diff=%.1f%%)",
p["name"], comp["arome"], comp["icon"], comp["diff_pct"])
else:
# Anche senza scostamento significativo, mostra ICON per Casa se ha dati interessanti
# (snow_depth > 0 o snow_48h > 0 anche se snow_24h non supera soglia)
if st_icon.get("snow_depth_max", 0) > 0 or st_icon.get("snow_48h", 0) > 0:
LOGGER.info("ICON disponibile per Casa: snow_depth_max=%.1f cm, snow_48h=%.1f cm",
st_icon.get("snow_depth_max", 0), st_icon.get("snow_48h", 0))
else:
# Per altri punti, recupera ICON solo se necessario per comparazione
data_icon = get_forecast(session, p["lat"], p["lon"], MODEL_ICON_IT)
if data_icon:
st_icon = compute_snow_stats(data_icon)
if st_icon:
comp = compare_models(st_arome["snow_24h"], st_icon["snow_24h"])
if comp:
comparisons[p["name"]] = comp
summaries_icon[p["name"]] = point_summary(p["name"], st_icon)
LOGGER.info("Scostamento >30%% per %s: AROME=%.1f cm, ICON=%.1f cm (diff=%.1f%%)",
p["name"], comp["arome"], comp["icon"], comp["diff_pct"])
summaries.append(point_summary(p["name"], st_arome))
time.sleep(0.2)
if not summaries:
LOGGER.error("Nessun punto ha restituito statistiche valide.")
return
# Calcola any_trigger escludendo Carpegna (solo Casa, Titano, Dogana possono attivare l'allerta)
# Carpegna viene comunque mostrato nel report se ha trigger, ma non attiva l'allerta
any_trigger = any(s["triggered"] for s in summaries if s["name"] != "🏔️ Carpegna")
# Verifica se solo Carpegna ha trigger (per logging)
carpegna_triggered = any(s["triggered"] for s in summaries if s["name"] == "🏔️ Carpegna")
if carpegna_triggered and not any_trigger:
LOGGER.info("Neve prevista solo a Carpegna: allerta non inviata (Carpegna escluso dal trigger principale)")
sig = build_signature(summaries)
# Prepara dati Casa per verifica cambiamenti
casa = next((s for s in summaries if s["name"] == "🏠 Casa"), None)
casa_data = None
if casa:
casa_data = {
"snow_24h": casa["snow_24h"],
"peak_hourly": casa["peak_hourly"],
"first_thr_time": casa["first_thr_time"],
"duration_hours": casa.get("duration_hours", 0.0),
"triggered": casa["triggered"],
}
# Verifica cambiamenti significativi
is_significant, change_reason = is_significant_change(
casa_data or {},
state
) if casa_data else (True, "nuova_allerta")
# Verifica se è il momento di un aggiornamento periodico
is_periodic_due = is_periodic_update_due(state)
# --- Scenario A: soglia superata ---
if any_trigger:
# Se change_reason è "annullamento" ma any_trigger è True, significa che Casa non ha più trigger
# ma altri punti sì. Non è un annullamento generale, quindi trattiamolo come aggiornamento normale.
if change_reason == "annullamento":
change_reason = "variazione_punti_monitorati"
is_significant = True
# Invia notifica se:
# - Prima allerta (not was_active)
# - Cambiamento significativo
# - Aggiornamento periodico dovuto (ogni 6 ore)
# - Modalità debug (bypassa controlli)
if debug_mode or (not was_active) or is_significant or is_periodic_due:
if debug_mode:
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato")
msg: List[str] = []
# Titolo con indicazione tipo notifica
# Nota: "annullamento" non può verificarsi nello Scenario A (any_trigger=True)
if not was_active:
title = "❄️ <b>ALLERTA NEVE</b>"
elif is_periodic_due and not is_significant:
title = "⏱️ <b>AGGIORNAMENTO PERIODICO ALLERTA NEVE</b>"
change_reason = "aggiornamento_periodico"
elif "variazione_quantita" in change_reason:
direction = "📈" if "+" in change_reason else "📉"
title = f"{direction} <b>AGGIORNAMENTO ALLERTA NEVE</b>"
elif "variazione_orario" in change_reason:
title = "⏰ <b>AGGIORNAMENTO ALLERTA NEVE</b>"
else:
title = "🔄 <b>AGGIORNAMENTO ALLERTA NEVE</b>"
msg.append(title)
msg.append(f"🕒 <i>Aggiornamento ore {html.escape(now_str)}</i>")
if change_reason and change_reason != "nuova_allerta" and change_reason != "annullamento":
msg.append(f"📊 <i>Motivo: {html.escape(change_reason.replace('_', ' '))}</i>")
# La finestra di analisi è sempre 48 ore
window_text = f"prossime {HOURS_AHEAD} ore"
msg.append(f"⏱️ <code>Finestra: {window_text} | Trigger: snowfall &gt; {SNOW_HOURLY_THRESHOLD_CM:.1f} cm/h</code>")
msg.append("")
# Sezione AROME Seamless
msg.append("🛰️ <b>AROME SEAMLESS</b>")
msg.append(f"<code>Modello: {html.escape(MODEL_AROME)}</code>")
msg.append("")
casa = next((s for s in summaries if s["name"] == "🏠 Casa"), None)
if casa:
msg.append("🏠 <b>CASA</b>")
msg.append(f"• 03h: <b>{casa['snow_3h']:.1f}</b> cm | 06h: <b>{casa['snow_6h']:.1f}</b> cm")
msg.append(f"• 12h: <b>{casa['snow_12h']:.1f}</b> cm | 24h: <b>{casa['snow_24h']:.1f}</b> cm")
if casa.get("snow_48h") is not None:
msg.append(f"• 📊 Totale previsto 48h: <b>{casa['snow_48h']:.1f}</b> cm")
if casa["triggered"]:
msg.append(
f"• Primo superamento soglia: <b>{html.escape(casa['first_thr_time'] or '')}</b> "
f"({casa['first_thr_val']:.1f} cm/h)"
)
if casa["peak_hourly"] > 0:
msg.append(f"• Picco orario: <b>{casa['peak_hourly']:.1f}</b> cm/h (~{html.escape(casa['peak_time'] or '')})")
if casa.get("duration_hours", 0) > 0:
msg.append(f"• Durata stimata: <b>{casa['duration_hours']:.1f}</b> ore")
if casa.get("last_thr_time"):
msg.append(f"• Fine precipitazioni nevose: <b>{html.escape(casa['last_thr_time'])}</b>")
# Se il fenomeno continua oltre le 48 ore, segnalalo
if casa.get("is_ongoing", False):
if casa.get("extended_end_time"):
msg.append(f"• ⚠️ <b>Fenomeno in corso oltre 48h</b> - Fine prevista: <b>{html.escape(casa['extended_end_time'])}</b>")
else:
msg.append(f"• ⚠️ <b>Fenomeno in corso oltre 48h</b> - Fine non ancora determinabile")
# Aggiungi grafico andamento
timeline = casa.get("snow_timeline", [])
if timeline:
chart = generate_snow_chart(timeline, max_width=30)
if chart:
msg.append("")
msg.append("📊 <b>Andamento previsto:</b>")
msg.append("<pre>" + html.escape(chart) + "</pre>")
msg.append("")
msg.append("🌍 <b>NEL CIRCONDARIO (AROME)</b>")
lines_arome = []
for s in summaries:
if not s["triggered"]:
continue
lines_arome.append(
f"{s['name']}: primo > soglia alle {s['first_thr_time'] or ''} "
f"({s['first_thr_val']:.1f} cm/h), picco {s['peak_hourly']:.1f} cm/h, 24h {s['snow_24h']:.1f} cm"
)
if lines_arome:
msg.append("<pre>" + html.escape("\n".join(lines_arome)) + "</pre>")
else:
msg.append("Nessun punto ha superato la soglia (anomalia).")
# Sezione ICON Italia (sempre mostrata per Casa se disponibile, altrimenti solo se scostamento significativo)
casa_icon_available = "🏠 Casa" in summaries_icon
if casa_icon_available or (comparisons and summaries_icon):
msg.append("")
msg.append("" * 15) # Linea corta per iPhone
msg.append("")
msg.append("🇮🇹 <b>ICON ITALIA (ARPAE 2i)</b>")
msg.append(f"<code>Modello: {html.escape(MODEL_ICON_IT)}</code>")
msg.append("")
# Mostra avviso scostamento solo se c'è effettivo scostamento (non per Casa se mostrato sempre)
if comparisons and ("🏠 Casa" not in comparisons or not casa_icon_available):
msg.append("⚠️ <b>Scostamento significativo rilevato (&gt;30%%)</b>")
msg.append("")
# Casa ICON
casa_icon = summaries_icon.get("🏠 Casa")
if casa_icon:
msg.append("🏠 <b>CASA</b>")
msg.append(f"• 03h: <b>{casa_icon['snow_3h']:.1f}</b> cm | 06h: <b>{casa_icon['snow_6h']:.1f}</b> cm")
msg.append(f"• 12h: <b>{casa_icon['snow_12h']:.1f}</b> cm | 24h: <b>{casa_icon['snow_24h']:.1f}</b> cm")
if casa_icon.get("snow_48h") is not None:
msg.append(f"• 📊 Totale previsto 48h: <b>{casa_icon['snow_48h']:.1f}</b> cm")
# Mostra snow_depth se > 0 (manto nevoso persistente)
if casa_icon.get("snow_depth_max", 0) > 0:
msg.append(f"• ⛄ Spessore manto nevoso massimo: <b>{casa_icon['snow_depth_max']:.1f}</b> cm")
if casa_icon["triggered"]:
msg.append(
f"• Primo superamento soglia: <b>{html.escape(casa_icon['first_thr_time'] or '')}</b> "
f"({casa_icon['first_thr_val']:.1f} cm/h)"
)
if casa_icon["peak_hourly"] > 0:
msg.append(f"• Picco orario: <b>{casa_icon['peak_hourly']:.1f}</b> cm/h (~{html.escape(casa_icon['peak_time'] or '')})")
msg.append("")
# Circondario ICON
msg.append("🌍 <b>NEL CIRCONDARIO (ICON ITALIA)</b>")
lines_icon = []
for point_name, comp in comparisons.items():
s_icon = summaries_icon.get(point_name)
if s_icon and s_icon["triggered"]:
lines_icon.append(
f"{point_name}: primo > soglia alle {s_icon['first_thr_time'] or ''} "
f"({s_icon['first_thr_val']:.1f} cm/h), picco {s_icon['peak_hourly']:.1f} cm/h, 24h {s_icon['snow_24h']:.1f} cm"
)
if lines_icon:
msg.append("<pre>" + html.escape("\n".join(lines_icon)) + "</pre>")
else:
# Mostra comunque i punti con scostamento anche se non hanno trigger
for point_name, comp in comparisons.items():
s_icon = summaries_icon.get(point_name)
if s_icon:
msg.append(
f"{html.escape(point_name)}: 24h <b>{s_icon['snow_24h']:.1f}</b> cm "
f"(scostamento {comp['diff_pct']:.0f}%%)"
)
msg.append("")
msg.append("<i>Fonte dati: Open-Meteo</i>")
# Unisci con <br> (sarà convertito in \n in telegram_send_html)
ok = telegram_send_html("<br>".join(msg), chat_ids=chat_ids)
# Genera e invia grafico (solo se abbiamo dati per Casa)
chart_generated = False
if casa_data_arome_raw:
try:
# Crea file temporaneo per il grafico
chart_path = os.path.join(tempfile.gettempdir(), f"snow_chart_{int(time.time())}.png")
chart_ok = generate_snow_chart_image(
casa_data_arome_raw,
casa_data_icon_raw,
chart_path,
location_name=casa_location_name
)
if chart_ok:
chart_caption = f"📊 <b>Grafico Precipitazioni 48h</b>\n🏠 {casa_location_name}\n🕒 Aggiornamento ore {html.escape(now_str)}"
photo_ok = telegram_send_photo(chart_path, chart_caption, chat_ids=chat_ids)
if photo_ok:
chart_generated = True
LOGGER.info("Grafico inviato con successo")
# Rimuovi file temporaneo dopo l'invio
try:
if os.path.exists(chart_path):
os.remove(chart_path)
except Exception:
pass
else:
LOGGER.warning("Generazione grafico fallita")
except Exception as e:
LOGGER.exception("Errore generazione/invio grafico: %s", e)
if ok:
reason_text = change_reason if change_reason else "aggiornamento_periodico" if is_periodic_due else "sconosciuto"
LOGGER.info("Notifica neve inviata. Motivo: %s", reason_text)
# Salva timestamp dell'ultima notifica
now_utc = datetime.datetime.now(datetime.timezone.utc)
save_state(True, sig, casa_data, last_notification_utc=now_utc.isoformat(timespec="seconds"))
else:
LOGGER.warning("Notifica neve NON inviata (token mancante o errore Telegram).")
# Salva comunque lo state (senza aggiornare last_notification_utc)
save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", ""))
else:
LOGGER.info("Allerta attiva ma nessun cambiamento significativo. Motivo: %s", change_reason)
# Salva lo state anche se non inviamo (per mantenere alert_active e last_notification_utc)
save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", ""))
return
# --- Scenario B: rientro (nessun superamento) ---
# Invia notifica di annullamento SOLO se:
# 1. C'era un'allerta attiva (was_active)
# 2. È stata effettivamente inviata una notifica precedente (last_notification_utc presente)
# Questo evita di inviare "annullamento" quando lo stato è attivo ma non è mai stata inviata una notifica
if was_active and not any_trigger:
last_notification_str = state.get("last_notification_utc", "")
has_previous_notification = bool(last_notification_str and last_notification_str.strip())
if has_previous_notification:
# C'è stata una notifica precedente, quindi possiamo inviare l'annullamento
msg = (
"🟢 <b>PREVISIONE NEVE ANNULLATA</b><br>"
f"🕒 <i>Aggiornamento ore {html.escape(now_str)}</i><br>"
f"Nelle prossime {HOURS_AHEAD} ore non risultano ore con snowfall &gt; {SNOW_HOURLY_THRESHOLD_CM:.1f} cm/h.<br>"
"<i>Fonte dati: Open-Meteo</i>"
)
ok = telegram_send_html(msg, chat_ids=chat_ids)
if ok:
LOGGER.info("Rientro neve notificato.")
else:
LOGGER.warning("Rientro neve NON inviato (token mancante o errore Telegram).")
save_state(False, "", None)
else:
# Non c'è stata una notifica precedente, quindi non inviare l'annullamento
# ma resetta comunque lo stato per evitare loop
LOGGER.info("Allerta attiva ma nessuna notifica precedente trovata. Resetto stato senza inviare annullamento.")
save_state(False, "", None)
return
# --- Scenario C: tranquillo ---
save_state(False, "", None)
top = sorted(summaries, key=lambda x: x["snow_24h"], reverse=True)[:3]
LOGGER.info(
"Nessuna neve sopra soglia. Top accumuli 24h: %s",
" | ".join(f"{t['name']}={t['snow_24h']:.1f}cm (pk {t['peak_hourly']:.1f}cm/h)" for t in top)
)
if __name__ == "__main__":
arg_parser = argparse.ArgumentParser(description="Monitor neve AROME Seamless")
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
analyze_snow(chat_ids=chat_ids, debug_mode=args.debug)