1773 lines
79 KiB
Python
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(">", ">").replace("<", "<")
|
|
message_md = message_md.replace("&", "&")
|
|
# <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 > {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 (>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 > {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)
|