Backup automatico script del 2026-07-26 07:00
This commit is contained in:
1 parent
4802b021fe
commit
a9bbb92090
19 files changed
+1086
-550
No files matched your search
@@ -135,6 +135,34 @@ def hhmm(dt: datetime.datetime) -> str:
|
||||
return dt.strftime("%H:%M")
|
||||
|
||||
|
||||
_WEEKDAYS_IT = ("lun", "mar", "mer", "gio", "ven", "sab", "dom")
|
||||
|
||||
|
||||
def format_clock(dt: datetime.datetime, ref: Optional[datetime.datetime] = None) -> str:
|
||||
"""HH:MM, con giorno corto se diverso da ref (default: oggi locale)."""
|
||||
ref = ref or now_local()
|
||||
label = hhmm(dt)
|
||||
if dt.date() != ref.date():
|
||||
return f"{_WEEKDAYS_IT[dt.weekday()]} {label}"
|
||||
return label
|
||||
|
||||
|
||||
def format_fascia(
|
||||
start: Optional[datetime.datetime],
|
||||
end: Optional[datetime.datetime],
|
||||
ref: Optional[datetime.datetime] = None,
|
||||
) -> str:
|
||||
"""Fascia leggibile ~HH:MM–~HH:MM (con giorno se serve)."""
|
||||
if start is None:
|
||||
return "—"
|
||||
ref = ref or now_local()
|
||||
a = format_clock(start, ref)
|
||||
if end is None or end == start:
|
||||
return f"~{a}"
|
||||
b = format_clock(end, ref)
|
||||
return f"~{a}–~{b}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Telegram
|
||||
# =============================================================================
|
||||
@@ -202,20 +230,100 @@ def load_state() -> Dict:
|
||||
return default
|
||||
|
||||
|
||||
def save_state(alert_active: bool, signature: str) -> None:
|
||||
def save_state(alert_active: bool, signature: str, detail: Optional[Dict] = None) -> None:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
||||
payload: Dict = {
|
||||
"alert_active": alert_active,
|
||||
"signature": signature,
|
||||
"updated": now_local().isoformat(),
|
||||
}
|
||||
if detail:
|
||||
payload.update(detail)
|
||||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{"alert_active": alert_active, "signature": signature, "updated": now_local().isoformat()},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
LOGGER.exception("State write error: %s", e)
|
||||
|
||||
|
||||
def build_plain_summary(bo_alerts: Dict, route_alerts: List[Dict]) -> str:
|
||||
"""Testo leggibile per WebApp (dove / eventi / quando)."""
|
||||
any_snow = bool(bo_alerts.get("snow_alert")) or any(x.get("snow_alert") for x in route_alerts)
|
||||
any_rain = bool(bo_alerts.get("rain_alert")) or any(x.get("rain_alert") for x in route_alerts)
|
||||
soglie: List[str] = []
|
||||
if any_snow:
|
||||
soglie.append(f"neve ≥{PERSIST_HOURS}h consecutive")
|
||||
if any_rain:
|
||||
soglie.append(
|
||||
f"pioggia 3h ≥{SOGLIA_PIOGGIA_3H_MM:.0f} mm per ≥{PERSIST_HOURS}h"
|
||||
)
|
||||
|
||||
lines: List[str] = [
|
||||
f"Percorso scuola Bologna ↔ rientro · prossime {HOURS_AHEAD}h",
|
||||
]
|
||||
if soglie:
|
||||
lines.append("Soglie: " + " · ".join(soglie))
|
||||
lines.extend([
|
||||
"",
|
||||
"A Bologna:",
|
||||
])
|
||||
if bo_alerts.get("snow_alert"):
|
||||
fascia = bo_alerts.get("snow_fascia") or f"~{bo_alerts.get('snow_run_time') or '—'}"
|
||||
lines.append(
|
||||
f"• Neve {fascia} "
|
||||
f"(12h {bo_alerts.get('snow_12h', 0):.1f} cm · 24h {bo_alerts.get('snow_24h', 0):.1f} cm)"
|
||||
)
|
||||
if bo_alerts.get("rain_alert"):
|
||||
r3 = float(bo_alerts.get("rain3_max") or 0)
|
||||
fascia = bo_alerts.get("rain_fascia") or f"~{bo_alerts.get('rain_persist_time') or '—'}"
|
||||
lines.append(f"• Pioggia forte {fascia} (max 3h {r3:.1f} mm)")
|
||||
if not bo_alerts.get("snow_alert") and not bo_alerts.get("rain_alert"):
|
||||
lines.append("• Nessuna criticità persistente (trigger Bologna assente nel dettaglio punti)")
|
||||
|
||||
issues = [x for x in route_alerts if x.get("snow_alert") or x.get("rain_alert")]
|
||||
lines.append("")
|
||||
lines.append("Caselli A14 / tratto:")
|
||||
if not issues:
|
||||
lines.append("• Nessuna criticità lungo il percorso")
|
||||
else:
|
||||
for x in issues:
|
||||
parts: List[str] = []
|
||||
if x.get("snow_alert"):
|
||||
fascia = x.get("snow_fascia") or f"~{x.get('snow_run_time') or '—'}"
|
||||
parts.append(f"neve {fascia} (24h {x.get('snow_24h', 0):.1f} cm)")
|
||||
if x.get("rain_alert"):
|
||||
r3 = float(x.get("rain3_max") or 0)
|
||||
fascia = x.get("rain_fascia") or f"~{x.get('rain_persist_time') or '—'}"
|
||||
parts.append(f"pioggia {fascia} (max 3h {r3:.1f} mm)")
|
||||
lines.append(f"• {x.get('name', '?')}: " + " | ".join(parts))
|
||||
|
||||
lines.append("")
|
||||
lines.append("Fonte: Open-Meteo (AROME / confronto ICON IT)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def first_event_time(bo_alerts: Dict, route_alerts: List[Dict]) -> Optional[str]:
|
||||
candidates: List[str] = []
|
||||
for src in [bo_alerts, *route_alerts]:
|
||||
if src.get("snow_alert") and src.get("snow_run_time"):
|
||||
candidates.append(str(src["snow_run_time"]))
|
||||
if src.get("rain_alert") and src.get("rain_persist_time"):
|
||||
candidates.append(str(src["rain_persist_time"]))
|
||||
return min(candidates) if candidates else None
|
||||
|
||||
|
||||
def notify_webapp(title: str, body: str, severity: str = "warning") -> None:
|
||||
try:
|
||||
sys_path = "/home/daniely/docker/shared"
|
||||
if sys_path not in __import__("sys").path:
|
||||
__import__("sys").path.insert(0, sys_path)
|
||||
from loogle_core.alert_dispatcher import dispatch_alert
|
||||
|
||||
dispatch_alert(title, body, category="student", severity=severity)
|
||||
except Exception as e:
|
||||
LOGGER.warning("WebApp notify fallita: %s", e)
|
||||
|
||||
|
||||
def get_forecast(session: requests.Session, lat: float, lon: float, model: str) -> Optional[Dict]:
|
||||
params = {
|
||||
"latitude": lat,
|
||||
@@ -357,10 +465,28 @@ def rolling_sum_3h(values: List[float]) -> List[float]:
|
||||
return out
|
||||
|
||||
|
||||
def first_persistent_run(values: List[float], threshold: float, persist: int) -> Tuple[bool, int, int, float]:
|
||||
def first_persistent_run(
|
||||
values: List[float], threshold: float, persist: int
|
||||
) -> Tuple[bool, int, int, float]:
|
||||
"""Prima run continua con valori >= soglia e lunghezza >= persist.
|
||||
|
||||
Ritorna (ok, start_idx, end_idx inclusivo, run_max). La run viene
|
||||
estesa fino alla fine (non si ferma al minimo persist).
|
||||
"""
|
||||
consec = 0
|
||||
run_start = -1
|
||||
run_max = 0.0
|
||||
found_start = -1
|
||||
found_end = -1
|
||||
found_max = 0.0
|
||||
|
||||
def _flush() -> None:
|
||||
nonlocal found_start, found_end, found_max
|
||||
if consec >= persist and found_start < 0:
|
||||
found_start = run_start
|
||||
found_end = run_start + consec - 1
|
||||
found_max = run_max
|
||||
|
||||
for i, v in enumerate(values):
|
||||
vv = float(v) if v is not None else 0.0
|
||||
if vv >= threshold:
|
||||
@@ -370,30 +496,19 @@ def first_persistent_run(values: List[float], threshold: float, persist: int) ->
|
||||
else:
|
||||
run_max = max(run_max, vv)
|
||||
consec += 1
|
||||
if consec >= persist:
|
||||
return True, run_start, consec, run_max
|
||||
else:
|
||||
_flush()
|
||||
if found_start >= 0:
|
||||
break
|
||||
consec = 0
|
||||
return False, -1, 0, 0.0
|
||||
run_start = -1
|
||||
run_max = 0.0
|
||||
else:
|
||||
_flush()
|
||||
|
||||
|
||||
def max_consecutive_gt(values: List[float], eps: float) -> Tuple[int, int]:
|
||||
best_len = 0
|
||||
best_start = -1
|
||||
consec = 0
|
||||
start = -1
|
||||
for i, v in enumerate(values):
|
||||
vv = float(v) if v is not None else 0.0
|
||||
if vv > eps:
|
||||
if consec == 0:
|
||||
start = i
|
||||
consec += 1
|
||||
if consec > best_len:
|
||||
best_len = consec
|
||||
best_start = start
|
||||
else:
|
||||
consec = 0
|
||||
return best_len, best_start
|
||||
if found_start >= 0:
|
||||
return True, found_start, found_end, found_max
|
||||
return False, -1, -1, 0.0
|
||||
|
||||
|
||||
def compute_stats(data: Dict) -> Optional[Dict]:
|
||||
@@ -428,120 +543,67 @@ def compute_stats(data: Dict) -> Optional[Dict]:
|
||||
rain3_max_idx = rain3.index(rain3_max) if rain3 else -1
|
||||
rain3_max_time = hhmm(dt_w[rain3_max_idx]) if (rain3_max_idx >= 0 and rain3_max_idx < len(dt_w)) else ""
|
||||
|
||||
rain_persist_ok, rain_run_start, rain_run_len, rain_run_max = first_persistent_run(
|
||||
rain_persist_ok, rain3_start, rain3_end, rain_run_max = first_persistent_run(
|
||||
rain3, SOGLIA_PIOGGIA_3H_MM, PERSIST_HOURS
|
||||
)
|
||||
rain_persist_time = hhmm(dt_w[rain_run_start]) if (rain_persist_ok and rain_run_start < len(dt_w)) else ""
|
||||
rain_run_len = (rain3_end - rain3_start + 1) if rain_persist_ok else 0
|
||||
rain_persist_dt_start: Optional[datetime.datetime] = None
|
||||
rain_persist_dt_end: Optional[datetime.datetime] = None
|
||||
rain_persist_time = ""
|
||||
rain_persist_end_time = ""
|
||||
rain_fascia = ""
|
||||
if rain_persist_ok and 0 <= rain3_start < len(dt_w):
|
||||
# Fascia = ore di calendario coperte dalle finestre rolling (ultima = end+2)
|
||||
covered_end_idx = min(rain3_end + 2, len(dt_w) - 1)
|
||||
rain_persist_dt_start = dt_w[rain3_start]
|
||||
rain_persist_dt_end = dt_w[covered_end_idx]
|
||||
rain_persist_time = format_clock(rain_persist_dt_start)
|
||||
rain_persist_end_time = format_clock(rain_persist_dt_end)
|
||||
rain_fascia = format_fascia(rain_persist_dt_start, rain_persist_dt_end)
|
||||
|
||||
# Analizza evento pioggia completa (48h): rileva inizio e calcola durata e accumulo totale
|
||||
rain_start_idx = None
|
||||
rain_end_idx = None
|
||||
total_rain_accumulation = 0.0
|
||||
rain_duration_hours = 0.0
|
||||
max_rain_intensity = 0.0
|
||||
|
||||
# Codici meteo che indicano pioggia (WMO)
|
||||
RAIN_WEATHER_CODES = [61, 63, 65, 66, 67, 80, 81, 82]
|
||||
|
||||
# Trova inizio evento pioggia (prima occorrenza con precipitation > 0 OPPURE weathercode pioggia)
|
||||
# Estendi l'analisi a 48 ore se disponibile
|
||||
extended_end_idx = min(start_idx + 48, n) # Estendi a 48 ore
|
||||
precip_extended = precip[start_idx:extended_end_idx]
|
||||
weathercode_extended = [int(x) if x is not None else None for x in weathercode[start_idx:extended_end_idx]] if len(weathercode) > start_idx else []
|
||||
|
||||
for i, (p_val, code) in enumerate(zip(precip_extended, weathercode_extended if len(weathercode_extended) == len(precip_extended) else [None] * len(precip_extended))):
|
||||
p_val_float = float(p_val) if p_val is not None else 0.0
|
||||
is_rain = (p_val_float > 0.0) or (code is not None and code in RAIN_WEATHER_CODES)
|
||||
if is_rain and rain_start_idx is None:
|
||||
rain_start_idx = i
|
||||
break
|
||||
|
||||
# Se trovato inizio, calcola durata e accumulo totale su 48 ore
|
||||
if rain_start_idx is not None:
|
||||
# Trova fine evento pioggia (ultima occorrenza con pioggia)
|
||||
for i in range(len(precip_extended) - 1, rain_start_idx - 1, -1):
|
||||
p_val = precip_extended[i] if i < len(precip_extended) else None
|
||||
code = weathercode_extended[i] if i < len(weathercode_extended) else None
|
||||
p_val_float = float(p_val) if p_val is not None else 0.0
|
||||
is_rain = (p_val_float > 0.0) or (code is not None and code in RAIN_WEATHER_CODES)
|
||||
if is_rain:
|
||||
rain_end_idx = i
|
||||
break
|
||||
|
||||
if rain_end_idx is not None:
|
||||
# Calcola durata
|
||||
times_extended = times[start_idx:extended_end_idx]
|
||||
dt_extended = [parse_time_to_local(t) for t in times_extended]
|
||||
if rain_end_idx < len(dt_extended) and rain_start_idx < len(dt_extended):
|
||||
rain_duration_hours = (dt_extended[rain_end_idx] - dt_extended[rain_start_idx]).total_seconds() / 3600.0
|
||||
# Calcola accumulo totale (somma di tutti i precipitation > 0)
|
||||
total_rain_accumulation = sum(float(p) for p in precip_extended[rain_start_idx:rain_end_idx+1] if p is not None and float(p) > 0.0)
|
||||
# Calcola intensità massima oraria
|
||||
max_rain_intensity = max((float(p) for p in precip_extended[rain_start_idx:rain_end_idx+1] if p is not None), default=0.0)
|
||||
# Flag orari neve: accumulo orario sopra eps OPPURE weathercode neve
|
||||
snow_flags: List[float] = []
|
||||
for i, s_val in enumerate(snow_w):
|
||||
code = weathercode_w[i] if i < len(weathercode_w) else None
|
||||
is_snow = (s_val > SNOW_HOURLY_EPS_CM) or (
|
||||
code is not None and code in SNOW_WEATHER_CODES
|
||||
)
|
||||
snow_flags.append(1.0 if is_snow else 0.0)
|
||||
|
||||
# Analizza nevicata completa (48h): rileva inizio usando snowfall > 0 OPPURE weathercode
|
||||
# Calcola durata e accumulo totale
|
||||
snow_start_idx = None
|
||||
snow_end_idx = None
|
||||
total_snow_accumulation = 0.0
|
||||
snow_duration_hours = 0.0
|
||||
|
||||
# Trova inizio nevicata (prima occorrenza con snowfall > 0 OPPURE weathercode neve)
|
||||
for i, (s_val, code) in enumerate(zip(snow_w, weathercode_w if len(weathercode_w) == len(snow_w) else [None] * len(snow_w))):
|
||||
is_snow = (s_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
|
||||
if is_snow and snow_start_idx is None:
|
||||
snow_start_idx = i
|
||||
break
|
||||
|
||||
# Se trovato inizio, calcola durata e accumulo totale
|
||||
if snow_start_idx is not None:
|
||||
# Trova fine nevicata (ultima occorrenza con neve)
|
||||
for i in range(len(snow_w) - 1, snow_start_idx - 1, -1):
|
||||
s_val = snow_w[i]
|
||||
code = weathercode_w[i] if i < len(weathercode_w) else None
|
||||
is_snow = (s_val > 0.0) or (code is not None and code in SNOW_WEATHER_CODES)
|
||||
if is_snow:
|
||||
snow_end_idx = i
|
||||
break
|
||||
|
||||
if snow_end_idx is not None:
|
||||
# Calcola durata
|
||||
snow_duration_hours = (dt_w[snow_end_idx] - dt_w[snow_start_idx]).total_seconds() / 3600.0
|
||||
# Calcola accumulo totale (somma di tutti i snowfall > 0)
|
||||
total_snow_accumulation = sum(s for s in snow_w[snow_start_idx:snow_end_idx+1] if s > 0.0)
|
||||
|
||||
# Per compatibilità con logica esistente
|
||||
snow_run_len, snow_run_start = max_consecutive_gt(snow_w, eps=SNOW_HOURLY_EPS_CM)
|
||||
snow_run_time = hhmm(dt_w[snow_run_start]) if (snow_run_start >= 0 and snow_run_start < len(dt_w)) else ""
|
||||
snow_ok, snow_run_start, snow_run_end, _ = first_persistent_run(
|
||||
snow_flags, 1.0, PERSIST_HOURS
|
||||
)
|
||||
snow_run_len = (snow_run_end - snow_run_start + 1) if snow_ok else 0
|
||||
snow_persist_dt_start: Optional[datetime.datetime] = None
|
||||
snow_persist_dt_end: Optional[datetime.datetime] = None
|
||||
snow_run_time = ""
|
||||
snow_run_end_time = ""
|
||||
snow_fascia = ""
|
||||
if snow_ok and 0 <= snow_run_start < len(dt_w) and 0 <= snow_run_end < len(dt_w):
|
||||
snow_persist_dt_start = dt_w[snow_run_start]
|
||||
snow_persist_dt_end = dt_w[snow_run_end]
|
||||
snow_run_time = format_clock(snow_persist_dt_start)
|
||||
snow_run_end_time = format_clock(snow_persist_dt_end)
|
||||
snow_fascia = format_fascia(snow_persist_dt_start, snow_persist_dt_end)
|
||||
|
||||
# Se trovato inizio nevicata, usa quello invece del run
|
||||
if snow_start_idx is not None:
|
||||
snow_run_time = hhmm(dt_w[snow_start_idx])
|
||||
# Durata minima per alert: almeno 2 ore
|
||||
if snow_duration_hours >= PERSIST_HOURS:
|
||||
snow_run_len = int(snow_duration_hours)
|
||||
else:
|
||||
snow_run_len = 0 # Durata troppo breve
|
||||
|
||||
snow_12h = sum(s for s in snow_w[:min(12, len(snow_w))] if s > 0.0)
|
||||
snow_24h = sum(s for s in snow_w[:min(24, len(snow_w))] if s > 0.0)
|
||||
snow_12h = sum(s for s in snow_w[: min(12, len(snow_w))] if s > 0.0)
|
||||
snow_24h = sum(s for s in snow_w[: min(24, len(snow_w))] if s > 0.0)
|
||||
|
||||
return {
|
||||
"rain3_max": float(rain3_max),
|
||||
"rain3_max_time": rain3_max_time,
|
||||
"rain_persist_ok": bool(rain_persist_ok),
|
||||
"rain_persist_time": rain_persist_time,
|
||||
"rain_persist_end_time": rain_persist_end_time,
|
||||
"rain_fascia": rain_fascia,
|
||||
"rain_persist_run_max": float(rain_run_max),
|
||||
"rain_persist_run_len": int(rain_run_len),
|
||||
"rain_duration_hours": float(rain_duration_hours),
|
||||
"total_rain_accumulation_mm": float(total_rain_accumulation),
|
||||
"max_rain_intensity_mm_h": float(max_rain_intensity),
|
||||
"snow_run_len": int(snow_run_len),
|
||||
"snow_run_time": snow_run_time,
|
||||
"snow_run_end_time": snow_run_end_time,
|
||||
"snow_fascia": snow_fascia,
|
||||
"snow_12h": float(snow_12h),
|
||||
"snow_24h": float(snow_24h),
|
||||
"snow_duration_hours": float(snow_duration_hours),
|
||||
"total_snow_accumulation_cm": float(total_snow_accumulation),
|
||||
}
|
||||
|
||||
|
||||
@@ -556,14 +618,15 @@ def point_alerts(point_name: str, stats: Dict) -> Dict:
|
||||
"snow_24h": stats["snow_24h"],
|
||||
"snow_run_len": stats["snow_run_len"],
|
||||
"snow_run_time": stats["snow_run_time"],
|
||||
"snow_run_end_time": stats.get("snow_run_end_time", ""),
|
||||
"snow_fascia": stats.get("snow_fascia", ""),
|
||||
"rain3_max": stats["rain3_max"],
|
||||
"rain3_max_time": stats["rain3_max_time"],
|
||||
"rain_persist_time": stats["rain_persist_time"],
|
||||
"rain_persist_end_time": stats.get("rain_persist_end_time", ""),
|
||||
"rain_fascia": stats.get("rain_fascia", ""),
|
||||
"rain_persist_run_max": stats["rain_persist_run_max"],
|
||||
"rain_persist_run_len": stats["rain_persist_run_len"],
|
||||
"rain_duration_hours": stats.get("rain_duration_hours", 0.0),
|
||||
"total_rain_accumulation_mm": stats.get("total_rain_accumulation_mm", 0.0),
|
||||
"max_rain_intensity_mm_h": stats.get("max_rain_intensity_mm_h", 0.0),
|
||||
}
|
||||
|
||||
|
||||
@@ -660,7 +723,10 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
|
||||
msg.append("🎓 <b>A BOLOGNA</b>")
|
||||
bo_comp = comparisons.get(bo["name"])
|
||||
if bo_alerts["snow_alert"]:
|
||||
msg.append(f"❄️ Neve (≥{PERSIST_HOURS}h) da ~<b>{html.escape(bo_alerts['snow_run_time'] or '—')}</b> (run ~{bo_alerts['snow_run_len']}h).")
|
||||
fascia = bo_alerts.get("snow_fascia") or f"~{bo_alerts.get('snow_run_time') or '—'}"
|
||||
msg.append(
|
||||
f"❄️ Neve (≥{PERSIST_HOURS}h) <b>{html.escape(fascia)}</b>."
|
||||
)
|
||||
msg.append(f"• Accumulo: 12h <b>{bo_alerts['snow_12h']:.1f} cm</b> | 24h <b>{bo_alerts['snow_24h']:.1f} cm</b>")
|
||||
if bo_comp and bo_comp.get("snow"):
|
||||
comp = bo_comp["snow"]
|
||||
@@ -670,13 +736,12 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
|
||||
msg.append(f"❄️ Neve: nessuna persistenza ≥ {PERSIST_HOURS}h (24h {bo_alerts['snow_24h']:.1f} cm).")
|
||||
|
||||
if bo_alerts["rain_alert"]:
|
||||
rain_duration = bo_alerts.get("rain_duration_hours", 0.0)
|
||||
total_rain = bo_alerts.get("total_rain_accumulation_mm", 0.0)
|
||||
max_intensity = bo_alerts.get("max_rain_intensity_mm_h", 0.0)
|
||||
|
||||
msg.append(f"🌧️ Pioggia molto forte (3h ≥ {SOGLIA_PIOGGIA_3H_MM:.0f} mm, ≥{PERSIST_HOURS}h) da ~<b>{html.escape(bo_alerts['rain_persist_time'] or '—')}</b>.")
|
||||
if rain_duration > 0:
|
||||
msg.append(f"⏱️ <b>Durata totale evento (48h):</b> ~{rain_duration:.0f} ore | <b>Accumulo totale:</b> ~{total_rain:.1f} mm | <b>Intensità max:</b> {max_intensity:.1f} mm/h")
|
||||
fascia = bo_alerts.get("rain_fascia") or f"~{bo_alerts.get('rain_persist_time') or '—'}"
|
||||
msg.append(
|
||||
f"🌧️ Pioggia molto forte (3h ≥ {SOGLIA_PIOGGIA_3H_MM:.0f} mm, ≥{PERSIST_HOURS}h) "
|
||||
f"<b>{html.escape(fascia)}</b> "
|
||||
f"(max 3h <b>{bo_alerts['rain3_max']:.1f} mm</b>)."
|
||||
)
|
||||
if bo_comp and bo_comp.get("rain"):
|
||||
comp = bo_comp["rain"]
|
||||
icon_r3 = bo_comp["icon_stats"]["rain3_max"]
|
||||
@@ -695,14 +760,17 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
|
||||
line = f"• <b>{html.escape(x['name'])}</b>: "
|
||||
parts: List[str] = []
|
||||
if x["snow_alert"]:
|
||||
parts.append(f"❄️ neve (≥{PERSIST_HOURS}h) da ~{html.escape(x['snow_run_time'] or '—')} (24h {x['snow_24h']:.1f} cm)")
|
||||
fascia = x.get("snow_fascia") or f"~{x.get('snow_run_time') or '—'}"
|
||||
parts.append(
|
||||
f"❄️ neve (≥{PERSIST_HOURS}h) {html.escape(fascia)} "
|
||||
f"(24h {x['snow_24h']:.1f} cm)"
|
||||
)
|
||||
if x["rain_alert"]:
|
||||
rain_dur = x.get("rain_duration_hours", 0.0)
|
||||
rain_tot = x.get("total_rain_accumulation_mm", 0.0)
|
||||
if rain_dur > 0:
|
||||
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '—')} (durata ~{rain_dur:.0f}h, totale ~{rain_tot:.1f}mm)")
|
||||
else:
|
||||
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '—')}")
|
||||
fascia = x.get("rain_fascia") or f"~{x.get('rain_persist_time') or '—'}"
|
||||
parts.append(
|
||||
f"🌧️ pioggia forte {html.escape(fascia)} "
|
||||
f"(max 3h {x['rain3_max']:.1f} mm)"
|
||||
)
|
||||
line += " | ".join(parts)
|
||||
msg.append(line)
|
||||
|
||||
@@ -725,15 +793,65 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
|
||||
msg.append("<i>Fonte dati: Open-Meteo</i>")
|
||||
|
||||
# FIX: usare \n invece di <br/>
|
||||
ok = telegram_send_html("\n".join(msg), chat_ids=chat_ids)
|
||||
html_msg = "\n".join(msg)
|
||||
plain = build_plain_summary(bo_alerts, route_alerts)
|
||||
ok = telegram_send_html(html_msg, chat_ids=chat_ids)
|
||||
if ok:
|
||||
LOGGER.info("Notifica inviata.")
|
||||
LOGGER.info("Notifica inviata su Telegram.")
|
||||
else:
|
||||
LOGGER.warning("Notifica NON inviata.")
|
||||
LOGGER.info("Telegram saltato/sospeso; consegna via WebApp.")
|
||||
|
||||
save_state(True, sig)
|
||||
detail = {
|
||||
"summary": plain,
|
||||
"window_hours": HOURS_AHEAD,
|
||||
"persist_hours": PERSIST_HOURS,
|
||||
"rain_threshold_mm_3h": SOGLIA_PIOGGIA_3H_MM,
|
||||
"first_event_time": first_event_time(bo_alerts, route_alerts),
|
||||
"bologna": {
|
||||
"snow_alert": bool(bo_alerts.get("snow_alert")),
|
||||
"rain_alert": bool(bo_alerts.get("rain_alert")),
|
||||
"snow_run_time": bo_alerts.get("snow_run_time"),
|
||||
"snow_run_end_time": bo_alerts.get("snow_run_end_time"),
|
||||
"snow_fascia": bo_alerts.get("snow_fascia"),
|
||||
"rain_persist_time": bo_alerts.get("rain_persist_time"),
|
||||
"rain_persist_end_time": bo_alerts.get("rain_persist_end_time"),
|
||||
"rain_fascia": bo_alerts.get("rain_fascia"),
|
||||
"snow_24h": bo_alerts.get("snow_24h"),
|
||||
"rain3_max": bo_alerts.get("rain3_max"),
|
||||
},
|
||||
"route_issues": [
|
||||
{
|
||||
"name": x.get("name"),
|
||||
"snow_alert": bool(x.get("snow_alert")),
|
||||
"rain_alert": bool(x.get("rain_alert")),
|
||||
"snow_run_time": x.get("snow_run_time"),
|
||||
"snow_run_end_time": x.get("snow_run_end_time"),
|
||||
"snow_fascia": x.get("snow_fascia"),
|
||||
"rain_persist_time": x.get("rain_persist_time"),
|
||||
"rain_persist_end_time": x.get("rain_persist_end_time"),
|
||||
"rain_fascia": x.get("rain_fascia"),
|
||||
"snow_24h": x.get("snow_24h"),
|
||||
"rain3_max": x.get("rain3_max"),
|
||||
"rain_persist_run_len": x.get("rain_persist_run_len"),
|
||||
}
|
||||
for x in route_alerts
|
||||
if x.get("snow_alert") or x.get("rain_alert")
|
||||
],
|
||||
}
|
||||
save_state(True, sig, detail)
|
||||
notify_webapp("Allerta percorso scuola", plain, severity="warning")
|
||||
else:
|
||||
LOGGER.info("Allerta già notificata e invariata.")
|
||||
# Aggiorna comunque summary nello state se manca (card WebApp)
|
||||
if not state.get("summary"):
|
||||
plain = build_plain_summary(bo_alerts, route_alerts)
|
||||
save_state(True, sig, {
|
||||
"summary": plain,
|
||||
"window_hours": HOURS_AHEAD,
|
||||
"persist_hours": PERSIST_HOURS,
|
||||
"rain_threshold_mm_3h": SOGLIA_PIOGGIA_3H_MM,
|
||||
"first_event_time": first_event_time(bo_alerts, route_alerts),
|
||||
})
|
||||
return
|
||||
|
||||
# --- Scenario B: Rientro ---
|
||||
@@ -747,12 +865,18 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
|
||||
f"di neve (≥{PERSIST_HOURS}h) o pioggia 3h sopra soglia (≥{PERSIST_HOURS}h).\n"
|
||||
"<i>Fonte dati: Open-Meteo</i>"
|
||||
)
|
||||
plain = (
|
||||
f"Allerta rientrata (Bologna / percorso scuola).\n"
|
||||
f"Nelle prossime {HOURS_AHEAD} ore non risultano più neve persistente "
|
||||
f"(≥{PERSIST_HOURS}h) né pioggia 3h sopra soglia (≥{PERSIST_HOURS}h)."
|
||||
)
|
||||
ok = telegram_send_html(msg, chat_ids=chat_ids)
|
||||
if ok:
|
||||
LOGGER.info("Rientro notificato.")
|
||||
LOGGER.info("Rientro notificato su Telegram.")
|
||||
else:
|
||||
LOGGER.warning("Rientro NON inviato.")
|
||||
LOGGER.info("Rientro Telegram saltato/sospeso; consegna via WebApp.")
|
||||
save_state(False, "")
|
||||
notify_webapp("Allerta percorso scuola rientrata", plain, severity="info")
|
||||
return
|
||||
|
||||
# --- Scenario C: Tranquillo ---
|
||||
|
||||
Reference in new issue
Block a user