Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb790ed7e | ||
|
|
3a7b867e83 | ||
|
|
7c2efce9df | ||
|
|
a9bbb92090 |
No files matched your search
@@ -1,17 +1,35 @@
|
|||||||
|
global_defs {
|
||||||
|
enable_script_security
|
||||||
|
script_user root
|
||||||
|
}
|
||||||
|
|
||||||
|
vrrp_script chk_ha {
|
||||||
|
script "/usr/local/sbin/keepalived-check-ha.sh"
|
||||||
|
interval 5
|
||||||
|
weight -30
|
||||||
|
fall 3
|
||||||
|
rise 2
|
||||||
|
user root
|
||||||
|
}
|
||||||
|
|
||||||
vrrp_instance VI_1 {
|
vrrp_instance VI_1 {
|
||||||
state MASTER
|
state BACKUP
|
||||||
interface eth0
|
interface eth0
|
||||||
virtual_router_id 51
|
virtual_router_id 51
|
||||||
priority 101 # 101 = Priorità più alta (Master)
|
priority 101
|
||||||
advert_int 1
|
advert_int 1
|
||||||
|
preempt_delay 30
|
||||||
authentication {
|
authentication {
|
||||||
auth_type PASS
|
auth_type PASS
|
||||||
auth_pass @Dedelove1
|
auth_pass @Dedelove1
|
||||||
}
|
}
|
||||||
virtual_ipaddress {
|
virtual_ipaddress {
|
||||||
192.168.128.85 # Il nostro VIP
|
192.168.128.85
|
||||||
}
|
}
|
||||||
notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a"
|
track_script {
|
||||||
notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a"
|
chk_ha
|
||||||
notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault"
|
}
|
||||||
|
notify_master "/usr/local/sbin/keepalived-notify-master.sh"
|
||||||
|
notify_backup "/usr/local/sbin/keepalived-notify-backup.sh"
|
||||||
|
notify_fault "/usr/local/sbin/keepalived-notify-fault.sh"
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
|
global_defs {
|
||||||
|
enable_script_security
|
||||||
|
script_user root
|
||||||
|
}
|
||||||
|
|
||||||
|
vrrp_script chk_ha {
|
||||||
|
script "/usr/local/sbin/keepalived-check-ha.sh"
|
||||||
|
interval 5
|
||||||
|
weight -30
|
||||||
|
fall 3
|
||||||
|
rise 2
|
||||||
|
user root
|
||||||
|
}
|
||||||
|
|
||||||
vrrp_instance VI_1 {
|
vrrp_instance VI_1 {
|
||||||
state BACKUP
|
state BACKUP
|
||||||
interface eth0
|
interface eth0
|
||||||
virtual_router_id 51
|
virtual_router_id 51
|
||||||
priority 100 # 100 = Priorità più bassa (Backup)
|
priority 100
|
||||||
advert_int 1
|
advert_int 1
|
||||||
|
preempt_delay 30
|
||||||
authentication {
|
authentication {
|
||||||
auth_type PASS
|
auth_type PASS
|
||||||
auth_pass @Dedelove1
|
auth_pass @Dedelove1
|
||||||
@@ -11,7 +26,10 @@ vrrp_instance VI_1 {
|
|||||||
virtual_ipaddress {
|
virtual_ipaddress {
|
||||||
192.168.128.85
|
192.168.128.85
|
||||||
}
|
}
|
||||||
notify_master "/home/daniely/rete/scripts/ha-failover.sh assume-tier-a"
|
track_script {
|
||||||
notify_backup "/home/daniely/rete/scripts/ha-failover.sh release-tier-a"
|
chk_ha
|
||||||
notify_fault "/home/daniely/rete/scripts/ha-failover.sh fault"
|
}
|
||||||
|
notify_master "/usr/local/sbin/keepalived-notify-master.sh"
|
||||||
|
notify_backup "/usr/local/sbin/keepalived-notify-backup.sh"
|
||||||
|
notify_fault "/usr/local/sbin/keepalived-notify-fault.sh"
|
||||||
}
|
}
|
||||||
@@ -3,3 +3,5 @@
|
|||||||
# DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
# DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||||
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
|
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
|
||||||
# Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2)
|
# Cron consigliato: 0 4 * * 6 (nessun conflitto irrigazione, che gira su Pi2)
|
||||||
|
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
|
||||||
|
# turni-app:live-latest
|
||||||
@@ -4,3 +4,5 @@
|
|||||||
# CHECK_PIP3=true
|
# CHECK_PIP3=true
|
||||||
# DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
|
# DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
|
||||||
# Cron consigliato: 40 0 * * 6 (tra irrigazione serale ~19:30 e notturna ~02:30)
|
# Cron consigliato: 40 0 * * 6 (tra irrigazione serale ~19:30 e notturna ~02:30)
|
||||||
|
# Immagini locali escluse da Watchtower via DOCKER_IGNORE_IMAGES nello script:
|
||||||
|
# irrigazione, turni-app:beta/alpha, meteo-alert, loogle-casa, ewelink_smart_home
|
||||||
@@ -1,57 +1,57 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# Clona NPM master (Pi1) → backup Pi2 + clone dormiente DS920 (NPM3).
|
||||||
|
# Su DS920: extract solo se extrema-npm NON è running (evita SQLite corrotto in B1).
|
||||||
|
# Synology: SFTP spesso chrootato → verso DS920 si usa stream tar via SSH (non scp).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
# --- CONFIGURAZIONE ---
|
REMOTE_IP="${REMOTE_IP:-192.168.128.81}"
|
||||||
REMOTE_IP="192.168.128.81"
|
REMOTE_USER="${REMOTE_USER:-daniely}"
|
||||||
REMOTE_USER="daniely"
|
DS920_IP="${DS920_IP:-192.168.128.100}"
|
||||||
# Cartella che contiene 'data' e 'letsencrypt'
|
DS920_USER="${DS920_USER:-daniely}"
|
||||||
SOURCE_BASE="/home/daniely/docker/npm"
|
DS920_NPM_ROOT="${DS920_NPM_ROOT:-/volume1/extrema/npm}"
|
||||||
TEMP_FILE="/tmp/npm_full_clone.tar.gz"
|
SOURCE_BASE="${SOURCE_BASE:-/home/daniely/docker/npm}"
|
||||||
|
TEMP_FILE="${TEMP_FILE:-/tmp/npm_full_clone.tar.gz}"
|
||||||
|
|
||||||
echo "[$(date)] Avvio clonazione totale NPM (Master -> Backup)..."
|
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] sync-npm: $*"; }
|
||||||
|
|
||||||
|
log "Avvio clonazione NPM (Master → Pi2 + DS920)..."
|
||||||
|
|
||||||
# 1. Crea un archivio compresso di TUTTO (Database SQL + Certificati)
|
|
||||||
# Usiamo sudo per leggere i file di root senza permessi negati
|
|
||||||
# Escludiamo i log per risparmiare spazio
|
|
||||||
sudo tar --exclude='*.log' -czf "$TEMP_FILE" -C "$SOURCE_BASE" data letsencrypt
|
sudo tar --exclude='*.log' -czf "$TEMP_FILE" -C "$SOURCE_BASE" data letsencrypt
|
||||||
|
sudo chown "${USER}:${USER}" "$TEMP_FILE"
|
||||||
|
|
||||||
# 2. Assegna l'archivio all'utente corrente (per poterlo spedire via SCP)
|
# --- Pi-2 (hot backup: stop/extract/start) ---
|
||||||
sudo chown $USER:$USER "$TEMP_FILE"
|
log "Invio archivio al Pi-2 ($REMOTE_IP)..."
|
||||||
|
if scp -o ConnectTimeout=10 -o BatchMode=yes "$TEMP_FILE" "${REMOTE_USER}@${REMOTE_IP}:/tmp/npm_full_clone.tar.gz"; then
|
||||||
# 3. Spedisce l'archivio al Pi-2
|
ssh -o ConnectTimeout=15 -o BatchMode=yes "${REMOTE_USER}@${REMOTE_IP}" bash -s <<'EOS'
|
||||||
echo "Invio archivio al Pi-2 ($REMOTE_IP)..."
|
set -e
|
||||||
scp -o ConnectTimeout=10 "$TEMP_FILE" "$REMOTE_USER@$REMOTE_IP:/tmp/"
|
echo " ...Stop NPM..."
|
||||||
|
docker stop npm 2>/dev/null || true
|
||||||
if [ $? -eq 0 ]; then
|
echo " ...Estrazione dati..."
|
||||||
echo "Trasferimento riuscito. Applicazione sul Backup..."
|
sudo rm -rf /home/daniely/docker/npm/data/* /home/daniely/docker/npm/letsencrypt/*
|
||||||
|
sudo tar -xzf /tmp/npm_full_clone.tar.gz -C /home/daniely/docker/npm/
|
||||||
# 4. Comanda al Pi-2 di:
|
sudo chown -R root:root /home/daniely/docker/npm/
|
||||||
# a) Arrestare NPM (per sbloccare il database)
|
echo " ...Start NPM..."
|
||||||
# b) Scompattare sovrascrivendo tutto
|
docker start npm
|
||||||
# c) Ripristinare i permessi di root
|
rm -f /tmp/npm_full_clone.tar.gz
|
||||||
# d) Riavviare NPM
|
EOS
|
||||||
ssh "$REMOTE_USER@$REMOTE_IP" "
|
log "Pi-2 sincronizzato OK"
|
||||||
echo ' ...Stop NPM...';
|
|
||||||
docker stop npm;
|
|
||||||
|
|
||||||
echo ' ...Estrazione dati...';
|
|
||||||
# Pulisce la cartella di destinazione prima di estrarre per evitare residui
|
|
||||||
sudo rm -rf /home/daniely/docker/npm/data/*
|
|
||||||
sudo rm -rf /home/daniely/docker/npm/letsencrypt/*
|
|
||||||
sudo tar -xzf /tmp/npm_full_clone.tar.gz -C /home/daniely/docker/npm/;
|
|
||||||
|
|
||||||
echo ' ...Fix Permessi...';
|
|
||||||
sudo chown -R root:root /home/daniely/docker/npm/;
|
|
||||||
|
|
||||||
echo ' ...Start NPM...';
|
|
||||||
docker start npm;
|
|
||||||
|
|
||||||
# Pulizia remota
|
|
||||||
rm /tmp/npm_full_clone.tar.gz
|
|
||||||
"
|
|
||||||
echo "[$(date)] Sincronizzazione Completata con Successo."
|
|
||||||
else
|
else
|
||||||
echo "[$(date)] ERRORE CRITICO: Trasferimento fallito."
|
log "ERRORE: trasferimento Pi-2 fallito"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- DS920 NPM3 (dormiente) ---
|
||||||
|
log "Stream archivio al DS920 ($DS920_IP)..."
|
||||||
|
if ssh -o ConnectTimeout=15 -o BatchMode=yes "${DS920_USER}@${DS920_IP}" \
|
||||||
|
"export PATH=/usr/local/bin:\$PATH; docker ps --format '{{.Names}}' 2>/dev/null | grep -qx extrema-npm"; then
|
||||||
|
log "SKIP DS920: extrema-npm running (B1 armato) — sync al prossimo disarm"
|
||||||
|
else
|
||||||
|
if cat "$TEMP_FILE" | ssh -o ConnectTimeout=180 -o BatchMode=yes "${DS920_USER}@${DS920_IP}" \
|
||||||
|
"export PATH=/usr/local/bin:\$PATH; NPM_ROOT=$(printf %q "$DS920_NPM_ROOT"); mkdir -p \"\$NPM_ROOT/data\" \"\$NPM_ROOT/letsencrypt\"; find \"\$NPM_ROOT/data\" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true; find \"\$NPM_ROOT/letsencrypt\" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true; tar -xzf - -C \"\$NPM_ROOT\"; chmod -R a+rX \"\$NPM_ROOT\" 2>/dev/null || true; echo NPM3_EXTRACT_OK; ls \"\$NPM_ROOT/data/nginx/proxy_host\" 2>/dev/null | head -5"; then
|
||||||
|
log "DS920 NPM3 sincronizzato OK (dormiente)"
|
||||||
|
else
|
||||||
|
log "ERRORE: extract DS920 fallito"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 5. Pulizia locale
|
|
||||||
sudo rm -f "$TEMP_FILE"
|
sudo rm -f "$TEMP_FILE"
|
||||||
|
log "Sincronizzazione NPM completata."
|
||||||
@@ -1,71 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
|
# Wrapper legacy — redirige al modulo infra-monitor
|
||||||
# ================================================
|
exec /home/daniely/rete/infra-monitor/pi2-watchdog.sh "$@"
|
||||||
# SENTINELLA DI BACKUP (Gira su Pi-1 Master)
|
|
||||||
# Controlla Pi-2 e attiva failover bundle IoT su Pi-1 se down.
|
|
||||||
# ================================================
|
|
||||||
|
|
||||||
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
|
||||||
HA_FAILOVER="/home/daniely/rete/scripts/ha-failover.sh"
|
|
||||||
|
|
||||||
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
|
||||||
echo "Errore: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TARGET_IP="192.168.128.81"
|
|
||||||
TARGET_NAME="🍓 Pi-2 (Backup & Monitor)"
|
|
||||||
STATE_FILE="/mnt/ha-apps/.metadata/pi2-watchdog.state"
|
|
||||||
SIM_STATE="/mnt/ha-apps/.metadata/failover-sim.state"
|
|
||||||
LEGACY_STATE_FILE="/tmp/pi2_watchdog.state"
|
|
||||||
SSH_OPTS=(-o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
|
|
||||||
|
|
||||||
if [[ -f "$SIM_STATE" ]] && [[ "$(cat "$SIM_STATE" 2>/dev/null)" != "none" ]]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
send_alert() {
|
|
||||||
local title="$1"
|
|
||||||
local body="$2"
|
|
||||||
local severity="$3"
|
|
||||||
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category watchdog_pi2 \
|
|
||||||
--severity "$severity" &
|
|
||||||
}
|
|
||||||
|
|
||||||
pi2_reachable() {
|
|
||||||
ping -c 3 -W 2 "$TARGET_IP" > /dev/null 2>&1 && return 0
|
|
||||||
ssh "${SSH_OPTS[@]}" pi2 "exit 0" 2>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ ! -f "$STATE_FILE" ]; then
|
|
||||||
if [ -f "$LEGACY_STATE_FILE" ]; then
|
|
||||||
cp "$LEGACY_STATE_FILE" "$STATE_FILE"
|
|
||||||
else
|
|
||||||
echo "UP" > "$STATE_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
LAST_STATE=$(cat "$STATE_FILE")
|
|
||||||
|
|
||||||
if pi2_reachable; then
|
|
||||||
if [ "$LAST_STATE" == "DOWN" ]; then
|
|
||||||
send_alert \
|
|
||||||
"Pi-2 online" \
|
|
||||||
"RISOLTO: $TARGET_NAME è tornato ONLINE! Failback bundle IoT in corso." \
|
|
||||||
"info"
|
|
||||||
echo "UP" > "$STATE_FILE"
|
|
||||||
if [[ -x "$HA_FAILOVER" ]]; then
|
|
||||||
sudo bash -c "\"$HA_FAILOVER\" stop-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [ "$LAST_STATE" == "UP" ]; then
|
|
||||||
send_alert \
|
|
||||||
"Pi-2 offline" \
|
|
||||||
"ALLARME: $TARGET_NAME è OFFLINE! Avvio failover bundle IoT su Pi-1." \
|
|
||||||
"warning"
|
|
||||||
echo "DOWN" > "$STATE_FILE"
|
|
||||||
if [[ -x "$HA_FAILOVER" ]]; then
|
|
||||||
sudo bash -c "\"$HA_FAILOVER\" start-iot-bundle >> /var/log/ha-failover.log 2>&1 &"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
@@ -45,12 +45,21 @@ done
|
|||||||
case "$(hostname -s)" in
|
case "$(hostname -s)" in
|
||||||
pi1)
|
pi1)
|
||||||
HOST_LABEL="Pi-1 (Master)"
|
HOST_LABEL="Pi-1 (Master)"
|
||||||
|
# Immagini build locali (no registry pubblico) → escluse da Watchtower
|
||||||
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||||
;;
|
;;
|
||||||
pi2)
|
pi2)
|
||||||
HOST_LABEL="Pi-2 (Backup)"
|
HOST_LABEL="Pi-2 (Backup)"
|
||||||
CHECK_PIP3=true
|
CHECK_PIP3=true
|
||||||
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
|
# Immagini build locali (no registry pubblico) → escluse da Watchtower
|
||||||
|
DOCKER_IGNORE_IMAGES=(
|
||||||
|
"irrigazione:latest"
|
||||||
|
"turni-app:beta-latest"
|
||||||
|
"turni-app:alpha-latest"
|
||||||
|
"meteo-alert:latest"
|
||||||
|
"loogle-casa:latest"
|
||||||
|
"ewelink_smart_home:1.4.6"
|
||||||
|
)
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -143,34 +152,52 @@ image_is_ignored() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_watchtower_labels() {
|
# Elenco nomi container da escludere (immagini locali / no registry).
|
||||||
command -v docker >/dev/null 2>&1 || return 0
|
# Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
|
||||||
local name image
|
watchtower_disabled_containers() {
|
||||||
|
local name image disabled=()
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
[[ -z "$line" ]] && continue
|
[[ -z "$line" ]] && continue
|
||||||
name=${line%%|*}
|
name=${line%%|*}
|
||||||
image=${line#*|}
|
image=${line#*|}
|
||||||
image_is_ignored "$image" || continue
|
if image_is_ignored "$image"; then
|
||||||
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
|
disabled+=("$name")
|
||||||
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
fi
|
||||||
|
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||||
|
if ((${#disabled[@]} > 0)); then
|
||||||
|
local IFS=,
|
||||||
|
printf '%s' "${disabled[*]}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
run_watchtower() {
|
run_watchtower() {
|
||||||
command -v docker >/dev/null 2>&1 || return 0
|
command -v docker >/dev/null 2>&1 || return 0
|
||||||
|
|
||||||
ensure_watchtower_labels
|
local disable_list
|
||||||
|
disable_list=$(watchtower_disabled_containers)
|
||||||
|
|
||||||
log "▶ Watchtower (run-once, container aggiornabili da registry)"
|
log "▶ Watchtower (run-once, container aggiornabili da registry)"
|
||||||
append_report ""
|
append_report ""
|
||||||
append_report "=== Watchtower run-once ==="
|
append_report "=== Watchtower run-once ==="
|
||||||
|
if [[ -n "$disable_list" ]]; then
|
||||||
|
log "Watchtower: esclusi container locali: $disable_list"
|
||||||
|
append_report "Esclusi (immagini locali): $disable_list"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local -a env_args=(
|
||||||
|
-e WATCHTOWER_CLEANUP=true
|
||||||
|
-e WATCHTOWER_ROLLING_RESTART=false
|
||||||
|
-e "WATCHTOWER_TIMEOUT=${WATCHTOWER_TIMEOUT}"
|
||||||
|
-e TZ=Europe/Rome
|
||||||
|
)
|
||||||
|
if [[ -n "$disable_list" ]]; then
|
||||||
|
env_args+=(-e "WATCHTOWER_DISABLE_CONTAINERS=${disable_list}")
|
||||||
|
fi
|
||||||
|
|
||||||
local output rc=0
|
local output rc=0
|
||||||
output=$(docker run --rm \
|
output=$(docker run --rm \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-e WATCHTOWER_CLEANUP=true \
|
"${env_args[@]}" \
|
||||||
-e WATCHTOWER_ROLLING_RESTART=false \
|
|
||||||
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
|
|
||||||
-e TZ=Europe/Rome \
|
|
||||||
"$WATCHTOWER_IMAGE" \
|
"$WATCHTOWER_IMAGE" \
|
||||||
--run-once 2>&1) || rc=$?
|
--run-once 2>&1) || rc=$?
|
||||||
|
|
||||||
@@ -413,7 +440,7 @@ fi
|
|||||||
# 3. EEPROM firmware
|
# 3. EEPROM firmware
|
||||||
audit_eeprom
|
audit_eeprom
|
||||||
|
|
||||||
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
|
# 4. Aggiornamento container Docker (Watchtower run-once; il daemon è MONITOR_ONLY)
|
||||||
run_watchtower
|
run_watchtower
|
||||||
|
|
||||||
# 5. Audit residui
|
# 5. Audit residui
|
||||||
|
|||||||
@@ -1,142 +1,2 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
|
exec /home/daniely/rete/infra-monitor/perimeter-watch.sh "$@"
|
||||||
# ================================================
|
|
||||||
# 🔍 SUPER WATCHDOG DI RETE (Gira su Pi-2)
|
|
||||||
# ================================================
|
|
||||||
|
|
||||||
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
|
||||||
|
|
||||||
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
|
||||||
echo "ERRORE: loogle-notify non disponibile ($LOOGLE_NOTIFY)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
STATE_DIR="/tmp/watchdog_states"
|
|
||||||
mkdir -p "$STATE_DIR"
|
|
||||||
|
|
||||||
# Riavvio giornaliero AP WiFi: silenzio allarmi per REBOOT_GRACE_MIN minuti
|
|
||||||
# dall'orario programmato. Se ancora DOWN dopo la finestra → allarme.
|
|
||||||
REBOOT_GRACE_MIN=5
|
|
||||||
|
|
||||||
TARGETS=(
|
|
||||||
"🍓 Pi-1 (Master)|192.168.128.80"
|
|
||||||
"🗄️ NAS DS920+|192.168.128.100"
|
|
||||||
"🌍 Internet (Google)|8.8.8.8"
|
|
||||||
"📡 Router Main|192.168.128.1"
|
|
||||||
"🗄️ NAS DS214|192.168.128.90"
|
|
||||||
"🔌 Switch Sala (.105)|192.168.128.105"
|
|
||||||
"🔌 Switch Taverna (.106)|192.168.128.106"
|
|
||||||
"🔌 Switch Lavanderia (.107)|192.168.128.107"
|
|
||||||
"📶 WiFi Sala (.101)|192.168.128.101"
|
|
||||||
"📶 WiFi Luca (.102)|192.168.128.102"
|
|
||||||
"📶 WiFi Taverna (.103)|192.168.128.103"
|
|
||||||
"📶 WiFi Dado (.104)|192.168.128.104"
|
|
||||||
"📶 WiFi Esterno (.108)|192.168.128.108"
|
|
||||||
"📶 WiFi Pozzo (.109)|192.168.128.109"
|
|
||||||
"📷 Cam Matrimoniale|192.168.135.2"
|
|
||||||
"📷 Cam Luca|192.168.135.3"
|
|
||||||
"📷 Cam Ingresso|192.168.135.4"
|
|
||||||
"📷 Cam Sala|192.168.135.5"
|
|
||||||
"📷 Cam Taverna|192.168.135.6"
|
|
||||||
"📷 Cam Retro|192.168.135.7"
|
|
||||||
)
|
|
||||||
|
|
||||||
send_alert() {
|
|
||||||
local title="$1"
|
|
||||||
local body="$2"
|
|
||||||
local severity="$3"
|
|
||||||
"$LOOGLE_NOTIFY" --title "$title" --body "$body" --category super_watchdog \
|
|
||||||
--severity "$severity" &
|
|
||||||
}
|
|
||||||
|
|
||||||
# Restituisce i minuti da mezzanotte dell'inizio riavvio (o vuoto se non programmato).
|
|
||||||
# 04:00 → Dado (.104), Sala (.101)
|
|
||||||
# 04:30 → Luca (.102), Taverna (.103)
|
|
||||||
# 14:00 → Esterno (.108), Pozzo (.109)
|
|
||||||
wifi_reboot_start_min() {
|
|
||||||
case "$1" in
|
|
||||||
192.168.128.101|192.168.128.104) echo 240 ;; # 04:00
|
|
||||||
192.168.128.102|192.168.128.103) echo 270 ;; # 04:30
|
|
||||||
192.168.128.108|192.168.128.109) echo 840 ;; # 14:00
|
|
||||||
*) echo "" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# 0 se siamo nella finestra [start, start+grace) del riavvio programmato.
|
|
||||||
in_wifi_reboot_window() {
|
|
||||||
local start
|
|
||||||
start=$(wifi_reboot_start_min "$1")
|
|
||||||
[[ -n "$start" ]] || return 1
|
|
||||||
local now_min=$((10#$(date +%H) * 60 + 10#$(date +%M)))
|
|
||||||
local end=$((start + REBOOT_GRACE_MIN))
|
|
||||||
[[ $now_min -ge $start && $now_min -lt $end ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "--- Inizio controllo $(date) ---"
|
|
||||||
|
|
||||||
for target_line in "${TARGETS[@]}"; do
|
|
||||||
NAME=$(echo "$target_line" | cut -d'|' -f1)
|
|
||||||
IP=$(echo "$target_line" | cut -d'|' -f2)
|
|
||||||
|
|
||||||
SAFE_IP="${IP//./_}"
|
|
||||||
STATE_FILE="$STATE_DIR/${SAFE_IP}.state"
|
|
||||||
|
|
||||||
if [ -f "$STATE_FILE" ]; then
|
|
||||||
LAST_STATE=$(cat "$STATE_FILE")
|
|
||||||
else
|
|
||||||
LAST_STATE="UP"
|
|
||||||
echo "UP" > "$STATE_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ping -c 2 -W 1 "$IP" > /dev/null 2>&1
|
|
||||||
PING_RESULT=$?
|
|
||||||
|
|
||||||
if [ $PING_RESULT -eq 0 ]; then
|
|
||||||
if [ "$LAST_STATE" == "DOWN" ]; then
|
|
||||||
send_alert \
|
|
||||||
"Dispositivo online" \
|
|
||||||
"RISOLTO: $NAME è tornato ONLINE! IP: $IP" \
|
|
||||||
"info"
|
|
||||||
echo "UP" > "$STATE_FILE"
|
|
||||||
echo "--> $NAME tornato UP. Notifica inviata."
|
|
||||||
elif [ "$LAST_STATE" == "REBOOT_DOWN" ]; then
|
|
||||||
# Ripristino dopo riavvio programmato: nessun allarme off/on
|
|
||||||
echo "UP" > "$STATE_FILE"
|
|
||||||
echo "--> $NAME tornato UP dopo riavvio programmato. Nessuna notifica."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [ "$LAST_STATE" == "UP" ]; then
|
|
||||||
if in_wifi_reboot_window "$IP"; then
|
|
||||||
echo "REBOOT_DOWN" > "$STATE_FILE"
|
|
||||||
echo "--> $NAME DOWN in finestra riavvio programmato. Silenzio."
|
|
||||||
else
|
|
||||||
ICON="⚠️"
|
|
||||||
if [[ "$NAME" == *"🚨"* || "$NAME" == *"🌍"* ]]; then ICON="🚨 CRITICO:"; fi
|
|
||||||
|
|
||||||
send_alert \
|
|
||||||
"Dispositivo offline" \
|
|
||||||
"$ICON ALLARME: $NAME è OFFLINE! IP: $IP non risponde." \
|
|
||||||
"warning"
|
|
||||||
echo "DOWN" > "$STATE_FILE"
|
|
||||||
echo "--> $NAME andato DOWN. Notifica inviata."
|
|
||||||
fi
|
|
||||||
elif [ "$LAST_STATE" == "REBOOT_DOWN" ]; then
|
|
||||||
if in_wifi_reboot_window "$IP"; then
|
|
||||||
echo "$NAME ancora DOWN in finestra riavvio. Silenzio."
|
|
||||||
else
|
|
||||||
# Oltre i 5 minuti dal riavvio programmato: allarme reale
|
|
||||||
ICON="⚠️"
|
|
||||||
send_alert \
|
|
||||||
"Dispositivo offline" \
|
|
||||||
"$ICON ALLARME: $NAME è OFFLINE oltre il riavvio programmato! IP: $IP non risponde." \
|
|
||||||
"warning"
|
|
||||||
echo "DOWN" > "$STATE_FILE"
|
|
||||||
echo "--> $NAME ancora DOWN dopo finestra riavvio. Notifica inviata."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "$NAME ancora DOWN. Nessuna notifica."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "--- Fine controllo ---"
|
|
||||||
@@ -45,12 +45,21 @@ done
|
|||||||
case "$(hostname -s)" in
|
case "$(hostname -s)" in
|
||||||
pi1)
|
pi1)
|
||||||
HOST_LABEL="Pi-1 (Master)"
|
HOST_LABEL="Pi-1 (Master)"
|
||||||
|
# Immagini build locali (no registry pubblico) → escluse da Watchtower
|
||||||
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||||
;;
|
;;
|
||||||
pi2)
|
pi2)
|
||||||
HOST_LABEL="Pi-2 (Backup)"
|
HOST_LABEL="Pi-2 (Backup)"
|
||||||
CHECK_PIP3=true
|
CHECK_PIP3=true
|
||||||
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
|
# Immagini build locali (no registry pubblico) → escluse da Watchtower
|
||||||
|
DOCKER_IGNORE_IMAGES=(
|
||||||
|
"irrigazione:latest"
|
||||||
|
"turni-app:beta-latest"
|
||||||
|
"turni-app:alpha-latest"
|
||||||
|
"meteo-alert:latest"
|
||||||
|
"loogle-casa:latest"
|
||||||
|
"ewelink_smart_home:1.4.6"
|
||||||
|
)
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -143,34 +152,52 @@ image_is_ignored() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_watchtower_labels() {
|
# Elenco nomi container da escludere (immagini locali / no registry).
|
||||||
command -v docker >/dev/null 2>&1 || return 0
|
# Nota: `docker update --label-add` non esiste → usiamo WATCHTOWER_DISABLE_CONTAINERS.
|
||||||
local name image
|
watchtower_disabled_containers() {
|
||||||
|
local name image disabled=()
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
[[ -z "$line" ]] && continue
|
[[ -z "$line" ]] && continue
|
||||||
name=${line%%|*}
|
name=${line%%|*}
|
||||||
image=${line#*|}
|
image=${line#*|}
|
||||||
image_is_ignored "$image" || continue
|
if image_is_ignored "$image"; then
|
||||||
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
|
disabled+=("$name")
|
||||||
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
fi
|
||||||
|
done < <(docker ps -a --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||||
|
if ((${#disabled[@]} > 0)); then
|
||||||
|
local IFS=,
|
||||||
|
printf '%s' "${disabled[*]}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
run_watchtower() {
|
run_watchtower() {
|
||||||
command -v docker >/dev/null 2>&1 || return 0
|
command -v docker >/dev/null 2>&1 || return 0
|
||||||
|
|
||||||
ensure_watchtower_labels
|
local disable_list
|
||||||
|
disable_list=$(watchtower_disabled_containers)
|
||||||
|
|
||||||
log "▶ Watchtower (run-once, container aggiornabili da registry)"
|
log "▶ Watchtower (run-once, container aggiornabili da registry)"
|
||||||
append_report ""
|
append_report ""
|
||||||
append_report "=== Watchtower run-once ==="
|
append_report "=== Watchtower run-once ==="
|
||||||
|
if [[ -n "$disable_list" ]]; then
|
||||||
|
log "Watchtower: esclusi container locali: $disable_list"
|
||||||
|
append_report "Esclusi (immagini locali): $disable_list"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local -a env_args=(
|
||||||
|
-e WATCHTOWER_CLEANUP=true
|
||||||
|
-e WATCHTOWER_ROLLING_RESTART=false
|
||||||
|
-e "WATCHTOWER_TIMEOUT=${WATCHTOWER_TIMEOUT}"
|
||||||
|
-e TZ=Europe/Rome
|
||||||
|
)
|
||||||
|
if [[ -n "$disable_list" ]]; then
|
||||||
|
env_args+=(-e "WATCHTOWER_DISABLE_CONTAINERS=${disable_list}")
|
||||||
|
fi
|
||||||
|
|
||||||
local output rc=0
|
local output rc=0
|
||||||
output=$(docker run --rm \
|
output=$(docker run --rm \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-e WATCHTOWER_CLEANUP=true \
|
"${env_args[@]}" \
|
||||||
-e WATCHTOWER_ROLLING_RESTART=false \
|
|
||||||
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
|
|
||||||
-e TZ=Europe/Rome \
|
|
||||||
"$WATCHTOWER_IMAGE" \
|
"$WATCHTOWER_IMAGE" \
|
||||||
--run-once 2>&1) || rc=$?
|
--run-once 2>&1) || rc=$?
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,50 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# raspiBackup post extension — notifica Loogle Casa (solo WebApp).
|
# raspiBackup post extension — notifica Loogle Casa (solo WebApp).
|
||||||
|
#
|
||||||
|
# RaspiBackup chiama il post hook PRIMA di STARTSERVICES: su Pi2 Casa è giù
|
||||||
|
# (before-stop ferma loogle-casa, STOPSERVICES ferma docker). Non notificare
|
||||||
|
# subito: systemd-run stacca dal cgroup e riprova finché l'API risponde.
|
||||||
|
#
|
||||||
# Sourced da raspiBackup: $1 = return code backup.
|
# Sourced da raspiBackup: $1 = return code backup.
|
||||||
|
# Eseguito da systemd-run: $1 = --wait-send
|
||||||
|
|
||||||
|
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
||||||
|
MAX_TRIES="${LOOGLE_BACKUP_NOTIFY_TRIES:-36}"
|
||||||
|
SLEEP_SECS="${LOOGLE_BACKUP_NOTIFY_SLEEP:-5}"
|
||||||
|
|
||||||
|
wait_send() {
|
||||||
|
local title="$1" body="$2" severity="$3" tag="$4"
|
||||||
|
local i
|
||||||
|
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||||
|
echo "loogle-notify.sh assente" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
for ((i = 1; i <= MAX_TRIES; i++)); do
|
||||||
|
if "$LOOGLE_NOTIFY" --title "$title" --body "$body" --category raspi_backup \
|
||||||
|
--severity "$severity" --tag "$tag"; then
|
||||||
|
echo "notify ok try=$i tag=$tag"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "notify retry $i/$MAX_TRIES tag=$tag" >&2
|
||||||
|
sleep "$SLEEP_SECS"
|
||||||
|
done
|
||||||
|
echo "notify failed after ${MAX_TRIES} tries tag=$tag" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--wait-send" ]]; then
|
||||||
|
wait_send "${2:-}" "${3:-}" "${4:-info}" "${5:-raspi_backup}"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
|
||||||
RC="${1:-1}"
|
RC="${1:-1}"
|
||||||
LOOGLE_NOTIFY="/home/daniely/docker/loogle-casa/scripts/loogle-notify.sh"
|
HOST="${HOSTNAME:-$(hostname)}"
|
||||||
|
HOST="${HOST%%.*}"
|
||||||
|
|
||||||
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
if [[ ! -x "$LOOGLE_NOTIFY" ]]; then
|
||||||
return 0 2>/dev/null || exit 0
|
return 0 2>/dev/null || exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
HOST="${HOSTNAME:-$(hostname)}"
|
|
||||||
|
|
||||||
if (( RC == 0 )); then
|
if (( RC == 0 )); then
|
||||||
TITLE="Backup completato"
|
TITLE="Backup completato"
|
||||||
BODY="Backup di ${HOST} terminato con successo."
|
BODY="Backup di ${HOST} terminato con successo."
|
||||||
@@ -21,7 +55,19 @@ else
|
|||||||
SEVERITY="error"
|
SEVERITY="error"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
"$LOOGLE_NOTIFY" --title "$TITLE" --body "$BODY" --category raspi_backup \
|
TAG="raspi_backup-${HOST}"
|
||||||
--severity "$SEVERITY" &
|
SELF="$(readlink -f "${BASH_SOURCE[0]:-$0}" 2>/dev/null || echo /usr/local/bin/raspiBackup_loogle_post.sh)"
|
||||||
|
|
||||||
|
# Stacca dal servizio raspiBackup (KillMode=control-group) e aspetta Casa.
|
||||||
|
if command -v systemd-run >/dev/null 2>&1; then
|
||||||
|
systemd-run --quiet --collect \
|
||||||
|
--description="Loogle Casa raspiBackup notify ${HOST}" \
|
||||||
|
/bin/bash "$SELF" --wait-send "$TITLE" "$BODY" "$SEVERITY" "$TAG" \
|
||||||
|
|| true
|
||||||
|
else
|
||||||
|
nohup /bin/bash "$SELF" --wait-send "$TITLE" "$BODY" "$SEVERITY" "$TAG" \
|
||||||
|
>/dev/null 2>&1 &
|
||||||
|
disown 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
return 0 2>/dev/null || exit 0
|
return 0 2>/dev/null || exit 0
|
||||||
@@ -337,7 +337,7 @@ def load_state() -> Dict:
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = None, last_notification_utc: Optional[str] = None) -> None:
|
def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = None, last_notification_utc: Optional[str] = None, summary: Optional[str] = None) -> None:
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
||||||
state_data = {
|
state_data = {
|
||||||
@@ -354,6 +354,8 @@ def save_state(alert_active: bool, signature: str, casa_data: Optional[Dict] = N
|
|||||||
"casa_first_thr_time": casa_data.get("first_thr_time", ""),
|
"casa_first_thr_time": casa_data.get("first_thr_time", ""),
|
||||||
"casa_duration_hours": casa_data.get("duration_hours", 0.0),
|
"casa_duration_hours": casa_data.get("duration_hours", 0.0),
|
||||||
})
|
})
|
||||||
|
if summary:
|
||||||
|
state_data["summary"] = summary[:3000]
|
||||||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||||||
json.dump(state_data, f, ensure_ascii=False, indent=2)
|
json.dump(state_data, f, ensure_ascii=False, indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1694,7 +1696,15 @@ def analyze_snow(chat_ids: Optional[List[str]] = None, debug_mode: bool = False)
|
|||||||
msg.append("<i>Fonte dati: Open-Meteo</i>")
|
msg.append("<i>Fonte dati: Open-Meteo</i>")
|
||||||
|
|
||||||
# Unisci con <br> (sarà convertito in \n in telegram_send_html)
|
# Unisci con <br> (sarà convertito in \n in telegram_send_html)
|
||||||
ok = telegram_send_html("<br>".join(msg), chat_ids=chat_ids)
|
html_msg = "<br>".join(msg)
|
||||||
|
ok = telegram_send_html(html_msg, chat_ids=chat_ids)
|
||||||
|
snow_summary = None
|
||||||
|
try:
|
||||||
|
from webapp_alert import publish_web_alert, message_to_plain
|
||||||
|
snow_summary = message_to_plain(html_msg, is_html=True)
|
||||||
|
publish_web_alert(html_msg, "snow", "warning", is_html=True, title="Allerta neve")
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.debug("Web summary failed: %s", e)
|
||||||
|
|
||||||
# Genera e invia grafico (solo se abbiamo dati per Casa)
|
# Genera e invia grafico (solo se abbiamo dati per Casa)
|
||||||
chart_generated = False
|
chart_generated = False
|
||||||
@@ -1731,11 +1741,11 @@ def analyze_snow(chat_ids: Optional[List[str]] = None, debug_mode: bool = False)
|
|||||||
|
|
||||||
# Salva timestamp dell'ultima notifica
|
# Salva timestamp dell'ultima notifica
|
||||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
||||||
save_state(True, sig, casa_data, last_notification_utc=now_utc.isoformat(timespec="seconds"))
|
save_state(True, sig, casa_data, last_notification_utc=now_utc.isoformat(timespec="seconds"), summary=snow_summary)
|
||||||
else:
|
else:
|
||||||
LOGGER.warning("Notifica neve NON inviata (token mancante o errore Telegram).")
|
LOGGER.warning("Notifica neve NON inviata (token mancante o errore Telegram).")
|
||||||
# Salva comunque lo state (senza aggiornare last_notification_utc)
|
# Salva comunque lo state (senza aggiornare last_notification_utc)
|
||||||
save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", ""))
|
save_state(True, sig, casa_data, last_notification_utc=state.get("last_notification_utc", ""), summary=snow_summary)
|
||||||
else:
|
else:
|
||||||
LOGGER.info("Allerta attiva ma nessun cambiamento significativo. Motivo: %s", change_reason)
|
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)
|
# Salva lo state anche se non inviamo (per mantenere alert_active e last_notification_utc)
|
||||||
|
|||||||
@@ -758,6 +758,14 @@ async def meteo_viaggio_command(update: Update, context: ContextTypes.DEFAULT_TY
|
|||||||
|
|
||||||
async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
# Stesso comportamento di `/meteo` senza argomenti: Casa (+ viaggio se attivo) per utente.
|
# Stesso comportamento di `/meteo` senza argomenti: Casa (+ viaggio se attivo) per utente.
|
||||||
|
try:
|
||||||
|
from telegram_gate import telegram_alerts_enabled
|
||||||
|
if not telegram_alerts_enabled():
|
||||||
|
logger.info("Report meteo mattutino sospeso (LOOGLE_TELEGRAM_ALERTS=0).")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
report_casa = call_meteo_script(["--home"])
|
report_casa = call_meteo_script(["--home"])
|
||||||
for uid in ALLOWED_IDS:
|
for uid in ALLOWED_IDS:
|
||||||
chat_id = str(uid)
|
chat_id = str(uid)
|
||||||
@@ -892,7 +900,21 @@ def main():
|
|||||||
application.add_handler(CallbackQueryHandler(button_handler))
|
application.add_handler(CallbackQueryHandler(button_handler))
|
||||||
|
|
||||||
job_queue = application.job_queue
|
job_queue = application.job_queue
|
||||||
job_queue.run_daily(scheduled_morning_report, time=datetime.time(hour=7, minute=15, tzinfo=TZINFO), days=(0, 1, 2, 3, 4, 5, 6))
|
# Report meteo quotidiano 07:15 — sospeso se LOOGLE_TELEGRAM_ALERTS=0 (cron-alerts.env)
|
||||||
|
try:
|
||||||
|
from telegram_gate import telegram_alerts_enabled
|
||||||
|
morning_enabled = telegram_alerts_enabled()
|
||||||
|
except Exception:
|
||||||
|
morning_enabled = True
|
||||||
|
if morning_enabled:
|
||||||
|
job_queue.run_daily(
|
||||||
|
scheduled_morning_report,
|
||||||
|
time=datetime.time(hour=7, minute=15, tzinfo=TZINFO),
|
||||||
|
days=(0, 1, 2, 3, 4, 5, 6),
|
||||||
|
)
|
||||||
|
logger.info("Job scheduled_morning_report attivo (07:15).")
|
||||||
|
else:
|
||||||
|
logger.info("Job scheduled_morning_report NON registrato (Telegram sospeso).")
|
||||||
|
|
||||||
application.run_polling()
|
application.run_polling()
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def get_bot_token():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def save_current_state(state, report_meta=None):
|
def save_current_state(state, report_meta=None, summary=None):
|
||||||
try:
|
try:
|
||||||
# Aggiungi timestamp corrente per tracciare quando è stato salvato lo stato
|
# Aggiungi timestamp corrente per tracciare quando è stato salvato lo stato
|
||||||
if report_meta is None:
|
if report_meta is None:
|
||||||
@@ -87,7 +87,19 @@ def save_current_state(state, report_meta=None):
|
|||||||
"points": state,
|
"points": state,
|
||||||
"last_update": datetime.datetime.now().isoformat(),
|
"last_update": datetime.datetime.now().isoformat(),
|
||||||
"report_meta": report_meta,
|
"report_meta": report_meta,
|
||||||
|
"alert_active": any(int(v or 0) > 0 for v in (state or {}).values()),
|
||||||
}
|
}
|
||||||
|
if summary:
|
||||||
|
state_with_meta["summary"] = str(summary)[:3000]
|
||||||
|
else:
|
||||||
|
# Mantieni l'ultimo summary Telegram se non ci sono nuovi aggiornamenti
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE, "r") as rf:
|
||||||
|
prev = json.load(rf)
|
||||||
|
if isinstance(prev, dict) and prev.get("summary"):
|
||||||
|
state_with_meta["summary"] = str(prev["summary"])[:3000]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
with open(STATE_FILE, 'w') as f:
|
with open(STATE_FILE, 'w') as f:
|
||||||
json.dump(state_with_meta, f)
|
json.dump(state_with_meta, f)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2251,7 +2263,26 @@ def main():
|
|||||||
append_report(new_alerts, improvement_msg, important, report_meta, DEBUG_MODE)
|
append_report(new_alerts, improvement_msg, important, report_meta, DEBUG_MODE)
|
||||||
|
|
||||||
# Genera e invia mappa solo quando ci sono aggiornamenti
|
# Genera e invia mappa solo quando ci sono aggiornamenti
|
||||||
|
ice_summary = None
|
||||||
if new_alerts or solved_alerts:
|
if new_alerts or solved_alerts:
|
||||||
|
parts = []
|
||||||
|
if new_alerts:
|
||||||
|
parts.append("Aggiornamenti rischio:\n" + "\n\n".join(new_alerts[:12]))
|
||||||
|
if solved_alerts:
|
||||||
|
parts.append("Rientri:\n" + "\n".join(solved_alerts[:8]))
|
||||||
|
ice_summary = "\n\n".join(parts)
|
||||||
|
try:
|
||||||
|
from webapp_alert import publish_web_alert
|
||||||
|
publish_web_alert(
|
||||||
|
ice_summary,
|
||||||
|
"ghiaccio",
|
||||||
|
"warning",
|
||||||
|
is_html=True,
|
||||||
|
title="Rischio ghiaccio stradale",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if DEBUG_MODE:
|
||||||
|
print(f"Web summary failed: {e}")
|
||||||
if DEBUG_MODE:
|
if DEBUG_MODE:
|
||||||
print(f"Generazione mappa per {len(map_points_data)} punti...")
|
print(f"Generazione mappa per {len(map_points_data)} punti...")
|
||||||
map_path = os.path.join(SCRIPT_DIR, "ice_risk_map.png")
|
map_path = os.path.join(SCRIPT_DIR, "ice_risk_map.png")
|
||||||
@@ -2265,18 +2296,16 @@ def main():
|
|||||||
f"🕒 {now.strftime('%d/%m/%Y %H:%M')}\n"
|
f"🕒 {now.strftime('%d/%m/%Y %H:%M')}\n"
|
||||||
f"📊 Punti monitorati: {len(map_points_data)}"
|
f"📊 Punti monitorati: {len(map_points_data)}"
|
||||||
)
|
)
|
||||||
|
# Invia anche i report testuali (allineati a Telegram/WebApp)
|
||||||
|
for block in (new_alerts + solved_alerts)[:15]:
|
||||||
|
try:
|
||||||
|
send_telegram_broadcast(token, block, debug_mode=DEBUG_MODE)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
photo_sent = send_telegram_photo(token, map_path, caption, debug_mode=DEBUG_MODE)
|
photo_sent = send_telegram_photo(token, map_path, caption, debug_mode=DEBUG_MODE)
|
||||||
if DEBUG_MODE:
|
if DEBUG_MODE:
|
||||||
print(f"Mappa inviata via Telegram: {photo_sent}")
|
print(f"Mappa inviata via Telegram: {photo_sent}")
|
||||||
# Pulisci file temporaneo solo se non in debug mode (per permettere verifica)
|
# Mantieni la mappa per la WebApp (non cancellare)
|
||||||
if not DEBUG_MODE:
|
|
||||||
try:
|
|
||||||
if os.path.exists(map_path):
|
|
||||||
os.remove(map_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
elif DEBUG_MODE:
|
|
||||||
print(f"File mappa mantenuto per debug: {map_path}")
|
|
||||||
print("Mappa inviata.")
|
print("Mappa inviata.")
|
||||||
else:
|
else:
|
||||||
if DEBUG_MODE:
|
if DEBUG_MODE:
|
||||||
@@ -2288,7 +2317,7 @@ def main():
|
|||||||
print("Nessuna variazione.")
|
print("Nessuna variazione.")
|
||||||
|
|
||||||
if not DEBUG_MODE:
|
if not DEBUG_MODE:
|
||||||
save_current_state(current_state, report_meta=report_meta)
|
save_current_state(current_state, report_meta=report_meta, summary=ice_summary)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
@@ -52,6 +52,12 @@ TARGET_ZONES = {
|
|||||||
"EMR-D1": "Pianura bolognese",
|
"EMR-D1": "Pianura bolognese",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Annotazioni territoriali (San Marino adotta il sistema Emilia-Romagna)
|
||||||
|
ZONE_NOTES = {
|
||||||
|
"Alta collina romagnola": "include Repubblica di San Marino",
|
||||||
|
"Pianura romagnola": "area adiacente a San Marino",
|
||||||
|
}
|
||||||
|
|
||||||
# Mappa codice zona regionale Arpae -> nome leggibile (deriva da TARGET_ZONES,
|
# Mappa codice zona regionale Arpae -> nome leggibile (deriva da TARGET_ZONES,
|
||||||
# togliendo il prefisso "EMR-": es. EMR-D1 -> D1 "Pianura bolognese").
|
# togliendo il prefisso "EMR-": es. EMR-D1 -> D1 "Pianura bolognese").
|
||||||
REGIONAL_TARGET_ZONES = {code.split("-")[-1]: name for code, name in TARGET_ZONES.items()}
|
REGIONAL_TARGET_ZONES = {code.split("-")[-1]: name for code, name in TARGET_ZONES.items()}
|
||||||
@@ -177,15 +183,12 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
|||||||
message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE)
|
message_html = re.sub(r"<\s*br\s*/?\s*>", "\n", message_html, flags=re.IGNORECASE)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
|
from telegram_gate import telegram_alerts_enabled
|
||||||
except ImportError:
|
except ImportError:
|
||||||
telegram_alerts_enabled = lambda: True # type: ignore
|
telegram_alerts_enabled = lambda: True # type: ignore
|
||||||
mirror_alert_to_web = lambda *a, **k: False # type: ignore
|
|
||||||
|
|
||||||
if not telegram_alerts_enabled():
|
if not telegram_alerts_enabled():
|
||||||
LOGGER.info("Telegram sospeso: skip civil_protection")
|
LOGGER.info("Telegram sospeso: skip civil_protection")
|
||||||
if message_html:
|
|
||||||
mirror_alert_to_web(message_html, "civil_protection", "warning", is_html=True)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
token = load_bot_token()
|
token = load_bot_token()
|
||||||
@@ -219,15 +222,6 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
|
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
|
||||||
|
|
||||||
if sent_ok:
|
|
||||||
try:
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, "/home/daniely/docker/shared")
|
|
||||||
from loogle_core.alert_dispatcher import mirror_to_web
|
|
||||||
mirror_to_web(message_html, "civil_protection", "warning", is_html=True)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return sent_ok
|
return sent_ok
|
||||||
|
|
||||||
def load_state() -> dict:
|
def load_state() -> dict:
|
||||||
@@ -441,7 +435,9 @@ def format_message(parsed: dict) -> str:
|
|||||||
|
|
||||||
lines.append(f"📅 <b>{html_lib.escape(day.get('date_label',''))}</b>")
|
lines.append(f"📅 <b>{html_lib.escape(day.get('date_label',''))}</b>")
|
||||||
for zone in sorted(alerts.keys()):
|
for zone in sorted(alerts.keys()):
|
||||||
lines.append(f"📍 <b>{html_lib.escape(zone)}</b>")
|
note = ZONE_NOTES.get(zone)
|
||||||
|
zlabel = f"{zone} · {note}" if note else zone
|
||||||
|
lines.append(f"📍 <b>{html_lib.escape(zlabel)}</b>")
|
||||||
for entry in alerts[zone]:
|
for entry in alerts[zone]:
|
||||||
lines.append(html_lib.escape(entry))
|
lines.append(html_lib.escape(entry))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -452,7 +448,9 @@ def format_message(parsed: dict) -> str:
|
|||||||
lines.append(f"🗺️ <b>{html_lib.escape(titolo)}</b>")
|
lines.append(f"🗺️ <b>{html_lib.escape(titolo)}</b>")
|
||||||
alerts = doc.get("alerts", {})
|
alerts = doc.get("alerts", {})
|
||||||
for zone in sorted(alerts.keys()):
|
for zone in sorted(alerts.keys()):
|
||||||
lines.append(f"📍 <b>{html_lib.escape(zone)}</b>")
|
note = ZONE_NOTES.get(zone)
|
||||||
|
zlabel = f"{zone} · {note}" if note else zone
|
||||||
|
lines.append(f"📍 <b>{html_lib.escape(zlabel)}</b>")
|
||||||
for entry in alerts[zone]:
|
for entry in alerts[zone]:
|
||||||
lines.append(html_lib.escape(entry))
|
lines.append(html_lib.escape(entry))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -462,6 +460,18 @@ def format_message(parsed: dict) -> str:
|
|||||||
lines.append("<i>Fonte: mappe.protezionecivile.gov.it</i>")
|
lines.append("<i>Fonte: mappe.protezionecivile.gov.it</i>")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _web_body_without_header(plain: str) -> str:
|
||||||
|
"""Rimuove la riga titolo 'PROTEZIONE CIVILE…' già usata come title WebApp."""
|
||||||
|
lines = (plain or "").splitlines()
|
||||||
|
if not lines:
|
||||||
|
return ""
|
||||||
|
first = lines[0].strip().upper()
|
||||||
|
if "PROTEZIONE CIVILE" in first:
|
||||||
|
return "\n".join(lines[1:]).strip()
|
||||||
|
return plain.strip()
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Main
|
# Main
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -516,21 +526,50 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False):
|
|||||||
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato")
|
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato")
|
||||||
elif sig == last_sig:
|
elif sig == last_sig:
|
||||||
LOGGER.info("Allerta già notificata e invariata. Nessuna nuova notifica.")
|
LOGGER.info("Allerta già notificata e invariata. Nessuna nuova notifica.")
|
||||||
|
# Aggiorna solo la data di calendario così Casa non tratta il bollettino
|
||||||
|
# come «di ieri» mentre le allerte regionali valgono ancora per oggi.
|
||||||
|
today = today_str_italy()
|
||||||
|
if state.get("date") != today:
|
||||||
|
state = dict(state)
|
||||||
|
state["date"] = today
|
||||||
|
save_state(state)
|
||||||
|
LOGGER.info("Stato DPC: date aggiornata a %s (firma invariata).", today)
|
||||||
return
|
return
|
||||||
|
|
||||||
# A questo punto: ci sono allerte e sono nuove -> prova invio
|
# A questo punto: ci sono allerte e sono nuove -> prova invio
|
||||||
msg = format_message(parsed)
|
msg = format_message(parsed)
|
||||||
sent_ok = telegram_send_html(msg, chat_ids=chat_ids)
|
sent_ok = telegram_send_html(msg, chat_ids=chat_ids)
|
||||||
|
web_ok = False
|
||||||
if sent_ok:
|
st = {
|
||||||
LOGGER.info("Notifica allerta inviata con successo.")
|
|
||||||
save_state({
|
|
||||||
"date": today_str_italy(),
|
"date": today_str_italy(),
|
||||||
"last_alert_signature": sig,
|
"last_alert_signature": sig,
|
||||||
})
|
}
|
||||||
|
try:
|
||||||
|
from webapp_alert import publish_web_alert, remember_summary, message_to_plain
|
||||||
|
plain = message_to_plain(msg, is_html=True)
|
||||||
|
card_body = _web_body_without_header(plain)
|
||||||
|
remember_summary(st, card_body)
|
||||||
|
web_ok = bool(
|
||||||
|
publish_web_alert(
|
||||||
|
card_body,
|
||||||
|
"civil_protection",
|
||||||
|
"warning",
|
||||||
|
is_html=False,
|
||||||
|
title="Allerta Protezione Civile / Arpae",
|
||||||
|
state=st,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.debug("Web summary failed: %s", e)
|
||||||
|
|
||||||
|
if sent_ok or web_ok:
|
||||||
|
LOGGER.info(
|
||||||
|
"Notifica allerta consegnata (%s).",
|
||||||
|
"Telegram+WebApp" if sent_ok and web_ok else ("Telegram" if sent_ok else "WebApp"),
|
||||||
|
)
|
||||||
|
save_state(st)
|
||||||
else:
|
else:
|
||||||
# Non aggiorniamo lo stato: quando risolvi token/rete, reinvierà.
|
LOGGER.warning("Invio non riuscito (Telegram/WebApp). Stato NON aggiornato.")
|
||||||
LOGGER.warning("Invio non riuscito (token mancante o errore Telegram). Stato NON aggiornato.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description="Civil protection alert")
|
parser = argparse.ArgumentParser(description="Civil protection alert")
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
# 0 = sospende Telegram per script meteo/speedtest (WebApp invariata dove configurata)
|
# 0 = sospende Telegram per script meteo/speedtest (canale primario: WebApp Casa)
|
||||||
|
# Le variabili LOOGLE_INTERNAL_TOKEN / LOOGLE_CASA_URL / LOOGLE_NOTIFY_MODE
|
||||||
|
# vengono caricate automaticamente da /home/daniely/docker/loogle-casa/.env
|
||||||
LOOGLE_TELEGRAM_ALERTS=0
|
LOOGLE_TELEGRAM_ALERTS=0
|
||||||
@@ -507,6 +507,13 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
|
|||||||
|
|
||||||
msg = "".join(msg_parts)
|
msg = "".join(msg_parts)
|
||||||
ok = telegram_send_html(msg, chat_ids=chat_ids)
|
ok = telegram_send_html(msg, chat_ids=chat_ids)
|
||||||
|
web_ok = False
|
||||||
|
try:
|
||||||
|
from webapp_alert import publish_web_alert
|
||||||
|
web_ok = bool(publish_web_alert(msg, "freeze", "warning", is_html=True, state=state,
|
||||||
|
title="Allerta gelo"))
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.debug("Web summary failed: %s", e)
|
||||||
if ok:
|
if ok:
|
||||||
LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s, nuove fasce: %d",
|
LOGGER.info("Allerta gelo inviata. Tmin=%.1f°C at %s, nuove fasce: %d",
|
||||||
min_temp_val, min_temp_time.isoformat(), len(new_periods))
|
min_temp_val, min_temp_time.isoformat(), len(new_periods))
|
||||||
@@ -516,8 +523,15 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
|
|||||||
"start": start.isoformat(),
|
"start": start.isoformat(),
|
||||||
"end": end.isoformat(),
|
"end": end.isoformat(),
|
||||||
})
|
})
|
||||||
|
elif web_ok:
|
||||||
|
LOGGER.info("Allerta gelo pubblicata su WebApp. Tmin=%.1f°C", min_temp_val)
|
||||||
|
for start, end in new_periods:
|
||||||
|
notified_periods.append({
|
||||||
|
"start": start.isoformat(),
|
||||||
|
"end": end.isoformat(),
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
LOGGER.warning("Allerta gelo NON inviata (token mancante o errore Telegram).")
|
LOGGER.warning("Allerta gelo NON consegnata (Telegram/WebApp).")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
LOGGER.info("Gelo già notificato (nessuna nuova fascia oraria, peggioramento < 2°C). Tmin=%.1f°C", min_temp_val)
|
LOGGER.info("Gelo già notificato (nessuna nuova fascia oraria, peggioramento < 2°C). Tmin=%.1f°C", min_temp_val)
|
||||||
@@ -528,6 +542,12 @@ def analyze_freeze(chat_ids: Optional[List[str]] = None, debug_mode: bool = Fals
|
|||||||
"min_time": min_temp_time.isoformat(),
|
"min_time": min_temp_time.isoformat(),
|
||||||
"notified_periods": notified_periods,
|
"notified_periods": notified_periods,
|
||||||
})
|
})
|
||||||
|
if not state.get("summary"):
|
||||||
|
state["summary"] = (
|
||||||
|
f"Allerta gelo a {LOCATION_NAME}.\n"
|
||||||
|
f"Minima prevista {min_temp_val:.1f}°C alle {fmt_dt(min_temp_time)} "
|
||||||
|
f"(prossime {HOURS_AHEAD}h)."
|
||||||
|
)
|
||||||
save_state(state)
|
save_state(state)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,45 @@ CATEGORIES = {
|
|||||||
"telegram_error": re.compile(r"Telegram error|Bad Request|chat not found|can't parse entities", re.IGNORECASE),
|
"telegram_error": re.compile(r"Telegram error|Bad Request|chat not found|can't parse entities", re.IGNORECASE),
|
||||||
"traceback": re.compile(r"Traceback", re.IGNORECASE),
|
"traceback": re.compile(r"Traceback", re.IGNORECASE),
|
||||||
"exception": re.compile(r"\bERROR\b|Exception", re.IGNORECASE),
|
"exception": re.compile(r"\bERROR\b|Exception", re.IGNORECASE),
|
||||||
"token_missing": re.compile(r"token missing|Token Telegram assente", re.IGNORECASE),
|
# Solo token realmente assente (non i messaggi ambigui con Telegram sospeso)
|
||||||
|
"token_missing": re.compile(
|
||||||
|
r"Telegram token missing|Token Telegram assente|Token Telegram mancante",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Comportamento atteso con LOOGLE_TELEGRAM_ALERTS=0 / canale WebApp: non sono problemi.
|
||||||
|
IGNORE_ISSUE_PATTERNS = [
|
||||||
|
re.compile(r"Telegram sospeso", re.IGNORECASE),
|
||||||
|
re.compile(r"Alert NON inviato \(token missing o errore Telegram\)", re.IGNORECASE),
|
||||||
|
re.compile(r"NOT sent \(token missing or Telegram error\)", re.IGNORECASE),
|
||||||
|
re.compile(r"Notifica NON inviata \(token/telegram\)", re.IGNORECASE),
|
||||||
|
re.compile(r"token missing o errore Telegram", re.IGNORECASE),
|
||||||
|
re.compile(r"token mancante o errore Telegram", re.IGNORECASE),
|
||||||
|
re.compile(r"Telegram saltato/sospeso", re.IGNORECASE),
|
||||||
|
re.compile(r"All-clear Telegram skip/fail", re.IGNORECASE),
|
||||||
|
re.compile(r"publish_web_alert failed", re.IGNORECASE), # gestito sotto come web_notify se serve
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def telegram_alerts_suspended() -> bool:
|
||||||
|
env_path = os.path.join(BASE_DIR, "cron-alerts.env")
|
||||||
|
try:
|
||||||
|
with open(env_path, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("LOOGLE_TELEGRAM_ALERTS="):
|
||||||
|
val = line.split("=", 1)[1].strip().strip('"').strip("'").lower()
|
||||||
|
return val in ("0", "off", "false", "no", "disabled")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
val = os.environ.get("LOOGLE_TELEGRAM_ALERTS", "1").strip().lower()
|
||||||
|
return val in ("0", "off", "false", "no", "disabled")
|
||||||
|
|
||||||
|
|
||||||
|
def should_ignore_issue_line(line: str) -> bool:
|
||||||
|
return any(p.search(line) for p in IGNORE_ISSUE_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
def load_text_file(path: str) -> str:
|
def load_text_file(path: str) -> str:
|
||||||
try:
|
try:
|
||||||
@@ -126,6 +162,8 @@ def analyze_logs(files: List[str], since: datetime.datetime, max_lines: int) ->
|
|||||||
last_ts = ts
|
last_ts = ts
|
||||||
if not last_ts or last_ts < since:
|
if not last_ts or last_ts < since:
|
||||||
continue
|
continue
|
||||||
|
if should_ignore_issue_line(line):
|
||||||
|
continue
|
||||||
for cat, regex in CATEGORIES.items():
|
for cat, regex in CATEGORIES.items():
|
||||||
if regex.search(line):
|
if regex.search(line):
|
||||||
category_hits[cat].append((last_ts, path, line))
|
category_hits[cat].append((last_ts, path, line))
|
||||||
@@ -169,6 +207,8 @@ def format_report(
|
|||||||
lines.append(f"🧾 Log Monitor - ultimi {days} giorni")
|
lines.append(f"🧾 Log Monitor - ultimi {days} giorni")
|
||||||
lines.append(f"Intervallo: {since.strftime('%Y-%m-%d %H:%M')} → {now.strftime('%Y-%m-%d %H:%M')}")
|
lines.append(f"Intervallo: {since.strftime('%Y-%m-%d %H:%M')} → {now.strftime('%Y-%m-%d %H:%M')}")
|
||||||
lines.append(f"File analizzati: {len(files)}")
|
lines.append(f"File analizzati: {len(files)}")
|
||||||
|
if telegram_alerts_suspended():
|
||||||
|
lines.append("ℹ️ Telegram sospeso (LOOGLE_TELEGRAM_ALERTS=0): canale primario WebApp Casa")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Sezione log non aggiornati
|
# Sezione log non aggiornati
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from open_meteo_precip import (
|
|||||||
CASA_TZ,
|
CASA_TZ,
|
||||||
daily_precip_from_hourly,
|
daily_precip_from_hourly,
|
||||||
hourly_precip_mm,
|
hourly_precip_mm,
|
||||||
|
hourly_table_should_show,
|
||||||
is_casa,
|
is_casa,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -499,16 +500,15 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
|
|||||||
day_date = dt.date()
|
day_date = dt.date()
|
||||||
is_new_day = (current_day is not None and day_date != current_day)
|
is_new_day = (current_day is not None and day_date != current_day)
|
||||||
|
|
||||||
# Determina se mostrare questo timestamp in base alla posizione nelle 48h
|
# Prime 24h: ogni ora. Dalla 25a alla 48a: ogni 2 ore, ma mai nascondere
|
||||||
# Prime 24h: ogni ora (step=1)
|
# un'ora con precipitazione (altrimenti il picco cade sulle ore dispari
|
||||||
# Dalla 25a alla 48a: ogni 2 ore (step=2)
|
# e la tabella 48h mostra 5 mm mentre Meteo7 ha 22.4 mm sul giorno).
|
||||||
if hours_from_start < 24:
|
Pr_early = get_val(l_prec[idx], 0)
|
||||||
step = 1 # Prime 24h: dettaglio 1 ora
|
Rain_early = get_val(l_rain[idx], 0)
|
||||||
else:
|
Showers_early = get_val(l_showers[idx], 0) if idx < len(l_showers) else 0
|
||||||
step = 2 # Dalla 25a alla 48a: dettaglio 2 ore
|
Code_early = int(get_val(l_code[idx], 0))
|
||||||
|
Pr_display = hourly_precip_mm(Pr_early, Rain_early, Showers_early)
|
||||||
# Controlla se questo timestamp deve essere mostrato
|
should_show = hourly_table_should_show(hours_from_start, Pr_display, Code_early)
|
||||||
should_show = (hours_from_start % step == 0)
|
|
||||||
|
|
||||||
# Se è un nuovo giorno, chiudi il blocco precedente
|
# Se è un nuovo giorno, chiudi il blocco precedente
|
||||||
if is_new_day and current_block_lines:
|
if is_new_day and current_block_lines:
|
||||||
@@ -541,12 +541,11 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
|
|||||||
elif diff >= 2.5: t_suffix = "H"
|
elif diff >= 2.5: t_suffix = "H"
|
||||||
t_s = f"{int(round(T))}{t_suffix}"
|
t_s = f"{int(round(T))}{t_suffix}"
|
||||||
|
|
||||||
Pr = get_val(l_prec[idx], 0)
|
Pr = Pr_early
|
||||||
Sn = get_val(l_snow[idx], 0)
|
Sn = get_val(l_snow[idx], 0)
|
||||||
Code = int(get_val(l_code[idx], 0))
|
Code = Code_early
|
||||||
Rain = get_val(l_rain[idx], 0)
|
Rain = Rain_early
|
||||||
Showers = get_val(l_showers[idx], 0) if idx < len(l_showers) else 0
|
Showers = Showers_early
|
||||||
Pr_display = hourly_precip_mm(Pr, Rain, Showers)
|
|
||||||
|
|
||||||
# Determina se è neve
|
# Determina se è neve
|
||||||
is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86])
|
is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86])
|
||||||
@@ -676,7 +675,7 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
|
|||||||
|
|
||||||
legend = {
|
legend = {
|
||||||
"temp": "W=wind chill, H=heat index",
|
"temp": "W=wind chill, H=heat index",
|
||||||
"precip": "G=grandine, Z=ghiacciato, N=neve",
|
"precip": "G=grandine, Z=ghiacciato, N=neve. Dalla 25ª ora: secco ogni 2h, pioggia sempre visibile",
|
||||||
"cloud": "FOG=nebbia",
|
"cloud": "FOG=nebbia",
|
||||||
"sky": "Icona condizioni (☀️🌧️⛈️…)",
|
"sky": "Icona condizioni (☀️🌧️⛈️…)",
|
||||||
"sx": "☃️ neve · 🧊 ghiaccio · ⚡/🌪️ temporali · 🥵 caldo · ☔️ pioggia · 💨 vento forte",
|
"sx": "☃️ neve · 🧊 ghiaccio · ⚡/🌪️ temporali · 🥵 caldo · ☔️ pioggia · 💨 vento forte",
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""DEPRECATED 2026-07-25 (ADR-019).
|
||||||
|
|
||||||
|
Sostituito da ``meteo-alert imminent`` (layer NWP 0–120′ in meteo-alert).
|
||||||
|
Il cron è disabilitato. Per forzare questo script legacy:
|
||||||
|
FORCE_LEGACY_NOWCAST_120M=1 python3 nowcast_120m_alert.py
|
||||||
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
@@ -15,6 +22,14 @@ import requests
|
|||||||
from dateutil import parser
|
from dateutil import parser
|
||||||
from open_meteo_client import open_meteo_get
|
from open_meteo_client import open_meteo_get
|
||||||
|
|
||||||
|
if os.environ.get("FORCE_LEGACY_NOWCAST_120M", "").strip() != "1":
|
||||||
|
print(
|
||||||
|
"DEPRECATED: use `meteo-alert imminent` (ADR-019). "
|
||||||
|
"Set FORCE_LEGACY_NOWCAST_120M=1 to run this script.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
# CONFIG
|
# CONFIG
|
||||||
# =========================
|
# =========================
|
||||||
@@ -129,14 +144,12 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
|
from telegram_gate import telegram_alerts_enabled
|
||||||
except ImportError:
|
except ImportError:
|
||||||
telegram_alerts_enabled = lambda: True # type: ignore
|
telegram_alerts_enabled = lambda: True # type: ignore
|
||||||
mirror_alert_to_web = lambda *a, **k: False # type: ignore
|
|
||||||
|
|
||||||
if not telegram_alerts_enabled():
|
if not telegram_alerts_enabled():
|
||||||
LOGGER.info("Telegram sospeso: skip nowcast_120m")
|
LOGGER.info("Telegram sospeso: skip nowcast_120m")
|
||||||
mirror_alert_to_web(message, "nowcast_120m", "warning", is_html=False)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
token = load_bot_token()
|
token = load_bot_token()
|
||||||
@@ -169,15 +182,6 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOGGER.exception("Errore invio Telegram chat_id=%s: %s", chat_id, e)
|
LOGGER.exception("Errore invio Telegram chat_id=%s: %s", chat_id, e)
|
||||||
|
|
||||||
if ok_any:
|
|
||||||
try:
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, "/home/daniely/docker/shared")
|
|
||||||
from loogle_core.alert_dispatcher import mirror_to_web
|
|
||||||
mirror_to_web(message, "nowcast_120m", "warning", is_html=False)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return ok_any
|
return ok_any
|
||||||
|
|
||||||
|
|
||||||
@@ -991,16 +995,25 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False) -> None
|
|||||||
)
|
)
|
||||||
|
|
||||||
ok = telegram_send_markdown(msg, chat_ids=chat_ids)
|
ok = telegram_send_markdown(msg, chat_ids=chat_ids)
|
||||||
if ok:
|
web_ok = False
|
||||||
LOGGER.info("Notifica inviata.")
|
try:
|
||||||
|
from webapp_alert import publish_web_alert
|
||||||
|
web_ok = bool(publish_web_alert(msg, "nowcast_120m", "warning", is_html=False, state=state))
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.debug("Web summary failed: %s", e)
|
||||||
|
|
||||||
# Salva state con eventi attivi aggiornati
|
# Salva state con eventi attivi aggiornati
|
||||||
state["active_events"] = active_events
|
state["active_events"] = active_events
|
||||||
|
if ok or web_ok:
|
||||||
state["last_sent_utc"] = now_utc.isoformat(timespec="seconds")
|
state["last_sent_utc"] = now_utc.isoformat(timespec="seconds")
|
||||||
save_state(state)
|
LOGGER.info("Notifica consegnata (%s).", "Telegram" if ok else "WebApp")
|
||||||
else:
|
else:
|
||||||
LOGGER.error("Notifica NON inviata (token/telegram).")
|
LOGGER.warning("Notifica NON consegnata (Telegram/WebApp).")
|
||||||
# Salva comunque lo state aggiornato
|
try:
|
||||||
state["active_events"] = active_events
|
from webapp_alert import remember_summary, message_to_plain
|
||||||
|
remember_summary(state, message_to_plain(msg, is_html=False))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
save_state(state)
|
save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ ICON_DAILY_VARS = (
|
|||||||
|
|
||||||
PRECIP_HOURLY_KEYS = ("precipitation", "rain", "showers", "snowfall")
|
PRECIP_HOURLY_KEYS = ("precipitation", "rain", "showers", "snowfall")
|
||||||
PRECIP_DAILY_KEYS = ("precipitation_sum", "rain_sum", "showers_sum", "snowfall_sum", "precipitation_hours")
|
PRECIP_DAILY_KEYS = ("precipitation_sum", "rain_sum", "showers_sum", "snowfall_sum", "precipitation_hours")
|
||||||
|
WMO_PRECIP_CODES = frozenset(
|
||||||
|
list(range(51, 68)) + list(range(71, 78)) + list(range(80, 83)) + [85, 86, 95, 96, 99]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_casa(lat: float, lon: float, tol: float = 0.01) -> bool:
|
def is_casa(lat: float, lon: float, tol: float = 0.01) -> bool:
|
||||||
@@ -84,6 +87,61 @@ def daily_precip_from_hourly(hourly: Dict) -> Dict[str, float]:
|
|||||||
return dict(out)
|
return dict(out)
|
||||||
|
|
||||||
|
|
||||||
|
def daily_component_from_hourly(hourly: Dict, key: str) -> Dict[str, float]:
|
||||||
|
"""Somma un campo orario (rain, showers, snowfall) per data locale YYYY-MM-DD."""
|
||||||
|
times = hourly.get("time") or []
|
||||||
|
arr = hourly.get(key) or []
|
||||||
|
out: Dict[str, float] = defaultdict(float)
|
||||||
|
for i, t in enumerate(times):
|
||||||
|
if not t or i >= len(arr) or arr[i] is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out[str(t)[:10]] += float(arr[i])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return dict(out)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_hourly_daily_precip(daily: Dict, hourly: Dict) -> Dict:
|
||||||
|
"""Allinea i totali daily alla somma delle ore (stessa metrica della tabella oraria)."""
|
||||||
|
daily = dict(daily)
|
||||||
|
times = daily.get("time") or []
|
||||||
|
mapping = {
|
||||||
|
"precipitation_sum": daily_precip_from_hourly(hourly),
|
||||||
|
"rain_sum": daily_component_from_hourly(hourly, "rain"),
|
||||||
|
"showers_sum": daily_component_from_hourly(hourly, "showers"),
|
||||||
|
"snowfall_sum": daily_component_from_hourly(hourly, "snowfall"),
|
||||||
|
}
|
||||||
|
for key, totals in mapping.items():
|
||||||
|
arr = list(daily.get(key) or [])
|
||||||
|
while len(arr) < len(times):
|
||||||
|
arr.append(None)
|
||||||
|
for i, t in enumerate(times):
|
||||||
|
d = str(t)[:10]
|
||||||
|
if d in totals:
|
||||||
|
arr[i] = round(totals[d], 2)
|
||||||
|
daily[key] = arr
|
||||||
|
return daily
|
||||||
|
|
||||||
|
|
||||||
|
def hourly_table_should_show(hours_from_start: int, precip_mm: float, weathercode: int = 0) -> bool:
|
||||||
|
"""
|
||||||
|
Tabella 48h: prime 24 ore tutte; dalle 25 alle 48 ogni 2 ore,
|
||||||
|
ma un'ora con precipitazione (mm o codice WMO) non viene mai omessa.
|
||||||
|
"""
|
||||||
|
if hours_from_start < 24:
|
||||||
|
return True
|
||||||
|
if hours_from_start % 2 == 0:
|
||||||
|
return True
|
||||||
|
if float(precip_mm or 0) >= 0.1:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
code = int(weathercode or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
code = 0
|
||||||
|
return code in WMO_PRECIP_CODES
|
||||||
|
|
||||||
|
|
||||||
def daily_precip_sum(daily: Dict, date_str: str) -> Optional[float]:
|
def daily_precip_sum(daily: Dict, date_str: str) -> Optional[float]:
|
||||||
times = daily.get("time") or []
|
times = daily.get("time") or []
|
||||||
arr = daily.get("precipitation_sum") or []
|
arr = daily.get("precipitation_sum") or []
|
||||||
@@ -125,8 +183,19 @@ def fetch_icon_italia(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _time_key(t) -> str:
|
||||||
|
if not t:
|
||||||
|
return ""
|
||||||
|
return str(t).strip()[:16]
|
||||||
|
|
||||||
|
|
||||||
def _index_by_time(section: Dict) -> Dict[str, int]:
|
def _index_by_time(section: Dict) -> Dict[str, int]:
|
||||||
return {str(t): i for i, t in enumerate(section.get("time") or [])}
|
out: Dict[str, int] = {}
|
||||||
|
for i, t in enumerate(section.get("time") or []):
|
||||||
|
k = _time_key(t)
|
||||||
|
if k:
|
||||||
|
out[k] = i
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def overlay_icon_precip_on_hourly(target: Dict, icon_hourly: Dict) -> Dict:
|
def overlay_icon_precip_on_hourly(target: Dict, icon_hourly: Dict) -> Dict:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from open_meteo_precip import (
|
|||||||
CASA_LAT,
|
CASA_LAT,
|
||||||
CASA_LON,
|
CASA_LON,
|
||||||
CASA_TZ,
|
CASA_TZ,
|
||||||
daily_precip_from_hourly,
|
apply_hourly_daily_precip,
|
||||||
fetch_icon_italia,
|
fetch_icon_italia,
|
||||||
hourly_precip_at_index,
|
hourly_precip_at_index,
|
||||||
hourly_precip_series,
|
hourly_precip_series,
|
||||||
@@ -342,6 +342,38 @@ def _median_or_single(values):
|
|||||||
return median(nums)
|
return median(nums)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_weathercode(values, preferred=None):
|
||||||
|
"""
|
||||||
|
Unisce codici WMO categorici con moda (mai mediana: 61+71 → 66 falso gelicidio).
|
||||||
|
In pareggio preferisce `preferred` se presente tra i valori, altrimenti il primo.
|
||||||
|
"""
|
||||||
|
ints = []
|
||||||
|
for v in values:
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ints.append(int(v))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if not ints:
|
||||||
|
return None
|
||||||
|
if preferred is not None:
|
||||||
|
try:
|
||||||
|
preferred = int(preferred)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
preferred = None
|
||||||
|
counts = {}
|
||||||
|
for c in ints:
|
||||||
|
counts[c] = counts.get(c, 0) + 1
|
||||||
|
max_count = max(counts.values())
|
||||||
|
modes = [c for c, n in counts.items() if n == max_count]
|
||||||
|
if len(modes) == 1:
|
||||||
|
return modes[0]
|
||||||
|
if preferred is not None and preferred in modes:
|
||||||
|
return preferred
|
||||||
|
return ints[0]
|
||||||
|
|
||||||
|
|
||||||
# Chiavi solo ICON Italia (precip 0–2d: niente mediana con AROME HD a San Marino)
|
# Chiavi solo ICON Italia (precip 0–2d: niente mediana con AROME HD a San Marino)
|
||||||
HOURLY_KEYS_ICON_ONLY = [
|
HOURLY_KEYS_ICON_ONLY = [
|
||||||
"snow_depth", "showers", "precipitation", "rain", "snowfall",
|
"snow_depth", "showers", "precipitation", "rain", "snowfall",
|
||||||
@@ -349,6 +381,7 @@ HOURLY_KEYS_ICON_ONLY = [
|
|||||||
DAILY_KEYS_ICON_ONLY = [
|
DAILY_KEYS_ICON_ONLY = [
|
||||||
"showers_sum", "precipitation_sum", "rain_sum", "snowfall_sum", "precipitation_hours",
|
"showers_sum", "precipitation_sum", "rain_sum", "snowfall_sum", "precipitation_hours",
|
||||||
]
|
]
|
||||||
|
PREFERRED_WEATHERCODE_MODEL = "italia_meteo_arpae_icon_2i"
|
||||||
|
|
||||||
|
|
||||||
def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source_model=None):
|
def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source_model=None):
|
||||||
@@ -397,16 +430,25 @@ def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source
|
|||||||
out[key].append(val)
|
out[key].append(val)
|
||||||
else:
|
else:
|
||||||
vals = []
|
vals = []
|
||||||
|
preferred_wc = None
|
||||||
for _m, h in hourly_by_model:
|
for _m, h in hourly_by_model:
|
||||||
times = h.get("time", []) or []
|
times = h.get("time", []) or []
|
||||||
arr = h.get(key, []) or []
|
arr = h.get(key, []) or []
|
||||||
for i, t in enumerate(times):
|
for i, t in enumerate(times):
|
||||||
if _normalize_time_key(str(t)) == ref_k and i < len(arr) and arr[i] is not None:
|
if _normalize_time_key(str(t)) == ref_k and i < len(arr) and arr[i] is not None:
|
||||||
try:
|
try:
|
||||||
|
if key == "weathercode":
|
||||||
|
vals.append(int(arr[i]))
|
||||||
|
if _m == PREFERRED_WEATHERCODE_MODEL:
|
||||||
|
preferred_wc = int(arr[i])
|
||||||
|
else:
|
||||||
vals.append(float(arr[i]))
|
vals.append(float(arr[i]))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
|
if key == "weathercode":
|
||||||
|
out[key].append(_merge_weathercode(vals, preferred=preferred_wc) if vals else None)
|
||||||
|
else:
|
||||||
out[key].append(_median_or_single(vals) if vals else None)
|
out[key].append(_median_or_single(vals) if vals else None)
|
||||||
n = len(out["time"])
|
n = len(out["time"])
|
||||||
if n > 1:
|
if n > 1:
|
||||||
@@ -461,16 +503,25 @@ def _merge_daily_median(daily_by_model, single_source_keys=None, single_source_m
|
|||||||
out[key].append(val)
|
out[key].append(val)
|
||||||
else:
|
else:
|
||||||
vals = []
|
vals = []
|
||||||
|
preferred_wc = None
|
||||||
for _m, d in daily_by_model:
|
for _m, d in daily_by_model:
|
||||||
times = d.get("time", []) or []
|
times = d.get("time", []) or []
|
||||||
arr = d.get(key, []) or []
|
arr = d.get(key, []) or []
|
||||||
for i, t in enumerate(times):
|
for i, t in enumerate(times):
|
||||||
if str(t)[:10] == date_str and i < len(arr) and arr[i] is not None:
|
if str(t)[:10] == date_str and i < len(arr) and arr[i] is not None:
|
||||||
try:
|
try:
|
||||||
|
if key == "weathercode":
|
||||||
|
vals.append(int(arr[i]))
|
||||||
|
if _m == PREFERRED_WEATHERCODE_MODEL:
|
||||||
|
preferred_wc = int(arr[i])
|
||||||
|
else:
|
||||||
vals.append(float(arr[i]))
|
vals.append(float(arr[i]))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
|
if key == "weathercode":
|
||||||
|
out[key].append(_merge_weathercode(vals, preferred=preferred_wc) if vals else None)
|
||||||
|
else:
|
||||||
out[key].append(_median_or_single(vals) if vals else None)
|
out[key].append(_median_or_single(vals) if vals else None)
|
||||||
# Ordina cronologicamente (evita buchi nel report se l'unione non era ordinata)
|
# Ordina cronologicamente (evita buchi nel report se l'unione non era ordinata)
|
||||||
n = len(out["time"])
|
n = len(out["time"])
|
||||||
@@ -509,6 +560,7 @@ def merge_multi_model_forecast(models_data, forecast_days=10):
|
|||||||
"snowfall": [],
|
"snowfall": [],
|
||||||
"snow_depth": [],
|
"snow_depth": [],
|
||||||
"rain": [],
|
"rain": [],
|
||||||
|
"showers": [],
|
||||||
"weathercode": [],
|
"weathercode": [],
|
||||||
"windspeed_10m": [],
|
"windspeed_10m": [],
|
||||||
"winddirection_10m": [],
|
"winddirection_10m": [],
|
||||||
@@ -675,7 +727,7 @@ def format_day_label(day_index: int, daily_time_list, with_relative: bool = True
|
|||||||
return f"giorno {day_index + 1}"
|
return f"giorno {day_index + 1}"
|
||||||
|
|
||||||
def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10):
|
def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10):
|
||||||
"""Analizza trend temperatura per identificare fronti caldi/freddi con dettaglio completo"""
|
"""Analizza trend di Tmax e Tmin (non la media giornaliera) per identificare cali/rialzi."""
|
||||||
if not daily_temps_max or not daily_temps_min:
|
if not daily_temps_max or not daily_temps_min:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -683,93 +735,105 @@ def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10):
|
|||||||
if max_days < 3:
|
if max_days < 3:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Filtra valori None e calcola temperature medie giornaliere
|
tmax_series = []
|
||||||
avg_temps = []
|
tmin_series = []
|
||||||
valid_indices = []
|
|
||||||
for i in range(max_days):
|
for i in range(max_days):
|
||||||
t_max = daily_temps_max[i]
|
t_max = daily_temps_max[i]
|
||||||
t_min = daily_temps_min[i]
|
t_min = daily_temps_min[i]
|
||||||
if t_max is not None and t_min is not None:
|
if t_max is not None and t_min is not None:
|
||||||
avg_temps.append((float(t_max) + float(t_min)) / 2)
|
tmax_series.append(float(t_max))
|
||||||
valid_indices.append(i)
|
tmin_series.append(float(t_min))
|
||||||
else:
|
else:
|
||||||
avg_temps.append(None)
|
tmax_series.append(None)
|
||||||
|
tmin_series.append(None)
|
||||||
|
|
||||||
if len([t for t in avg_temps if t is not None]) < 3:
|
valid_max = [t for t in tmax_series if t is not None]
|
||||||
|
valid_min = [t for t in tmin_series if t is not None]
|
||||||
|
if len(valid_max) < 3 or len(valid_min) < 3:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Analizza tendenza generale (prime 3 giorni vs ultimi 3 giorni validi)
|
first_max = mean(valid_max[:3])
|
||||||
valid_temps = [t for t in avg_temps if t is not None]
|
last_max = mean(valid_max[-3:])
|
||||||
if len(valid_temps) < 3:
|
first_min = mean(valid_min[:3])
|
||||||
return None
|
last_min = mean(valid_min[-3:])
|
||||||
|
delta_max = last_max - first_max
|
||||||
|
delta_min = last_min - first_min
|
||||||
|
# Delta dominante per classificare il tipo (maggiore |Δ|)
|
||||||
|
if abs(delta_max) >= abs(delta_min):
|
||||||
|
primary_delta = delta_max
|
||||||
|
primary_series = "max"
|
||||||
|
else:
|
||||||
|
primary_delta = delta_min
|
||||||
|
primary_series = "min"
|
||||||
|
|
||||||
first_avg = mean(valid_temps[:3])
|
# Contesto: massime finali ancora elevate → niente retorica "fronte freddo" invernale
|
||||||
last_avg = mean(valid_temps[-3:])
|
warm_context = last_max >= 25.0
|
||||||
diff = last_avg - first_avg
|
|
||||||
|
|
||||||
trend_type = None
|
trend_type = None
|
||||||
trend_intensity = "moderato"
|
trend_intensity = "moderato"
|
||||||
|
if primary_delta > 5:
|
||||||
if diff > 5:
|
trend_type = "fronte_caldo" if not warm_context or primary_delta > 8 else "riscaldamento"
|
||||||
trend_type = "fronte_caldo"
|
trend_intensity = "forte" if primary_delta > 8 else "moderato"
|
||||||
trend_intensity = "forte" if diff > 8 else "moderato"
|
elif primary_delta > 2:
|
||||||
elif diff > 2:
|
|
||||||
trend_type = "riscaldamento"
|
trend_type = "riscaldamento"
|
||||||
trend_intensity = "moderato"
|
trend_intensity = "moderato"
|
||||||
elif diff < -5:
|
elif primary_delta < -5:
|
||||||
|
if warm_context:
|
||||||
|
trend_type = "calo_termico"
|
||||||
|
else:
|
||||||
trend_type = "fronte_freddo"
|
trend_type = "fronte_freddo"
|
||||||
trend_intensity = "forte" if diff < -8 else "moderato"
|
trend_intensity = "forte" if primary_delta < -8 else "moderato"
|
||||||
elif diff < -2:
|
elif primary_delta < -2:
|
||||||
trend_type = "raffreddamento"
|
trend_type = "raffreddamento"
|
||||||
trend_intensity = "moderato"
|
trend_intensity = "moderato"
|
||||||
else:
|
else:
|
||||||
trend_type = "stabile"
|
trend_type = "stabile"
|
||||||
|
|
||||||
# Identifica giorni di cambio significativo
|
|
||||||
change_days = []
|
change_days = []
|
||||||
prev_temp = None
|
prev_max = prev_min = None
|
||||||
for i, temp in enumerate(avg_temps):
|
for i in range(max_days):
|
||||||
if temp is not None:
|
tm = tmax_series[i]
|
||||||
if prev_temp is not None:
|
tn = tmin_series[i]
|
||||||
day_diff = temp - prev_temp
|
if tm is not None and tn is not None:
|
||||||
if abs(day_diff) > 3: # Cambio significativo (>3°C)
|
if prev_max is not None and prev_min is not None:
|
||||||
|
d_max = tm - prev_max
|
||||||
|
d_min = tn - prev_min
|
||||||
|
if abs(d_max) > 3:
|
||||||
change_days.append({
|
change_days.append({
|
||||||
"day": i,
|
"day": i,
|
||||||
"delta": round(day_diff, 1),
|
"series": "max",
|
||||||
"from": round(prev_temp, 1),
|
"delta": round(d_max, 1),
|
||||||
"to": round(temp, 1)
|
"from": round(prev_max, 1),
|
||||||
|
"to": round(tm, 1),
|
||||||
})
|
})
|
||||||
prev_temp = temp
|
if abs(d_min) > 3:
|
||||||
|
change_days.append({
|
||||||
# Analisi per periodi (primi 3 giorni, medio termine, lungo termine)
|
"day": i,
|
||||||
period_analysis = {}
|
"series": "min",
|
||||||
if len(valid_temps) >= 7:
|
"delta": round(d_min, 1),
|
||||||
period_analysis["short_term"] = {
|
"from": round(prev_min, 1),
|
||||||
"avg": round(mean(valid_temps[:3]), 1),
|
"to": round(tn, 1),
|
||||||
"range": round(max(valid_temps[:3]) - min(valid_temps[:3]), 1)
|
})
|
||||||
}
|
prev_max, prev_min = tm, tn
|
||||||
mid_start = len(valid_temps) // 3
|
|
||||||
mid_end = (len(valid_temps) * 2) // 3
|
|
||||||
period_analysis["mid_term"] = {
|
|
||||||
"avg": round(mean(valid_temps[mid_start:mid_end]), 1),
|
|
||||||
"range": round(max(valid_temps[mid_start:mid_end]) - min(valid_temps[mid_start:mid_end]), 1)
|
|
||||||
}
|
|
||||||
period_analysis["long_term"] = {
|
|
||||||
"avg": round(mean(valid_temps[-3:]), 1),
|
|
||||||
"range": round(max(valid_temps[-3:]) - min(valid_temps[-3:]), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"type": trend_type,
|
"type": trend_type,
|
||||||
"intensity": trend_intensity,
|
"intensity": trend_intensity,
|
||||||
"delta": round(diff, 1),
|
"delta": round(primary_delta, 1),
|
||||||
|
"delta_max": round(delta_max, 1),
|
||||||
|
"delta_min": round(delta_min, 1),
|
||||||
|
"primary_series": primary_series,
|
||||||
|
"warm_context": warm_context,
|
||||||
"change_days": change_days,
|
"change_days": change_days,
|
||||||
"first_avg": round(first_avg, 1),
|
"first_max": round(first_max, 1),
|
||||||
"last_avg": round(last_avg, 1),
|
"last_max": round(last_max, 1),
|
||||||
"period_analysis": period_analysis,
|
"first_min": round(first_min, 1),
|
||||||
"daily_avg_temps": avg_temps,
|
"last_min": round(last_min, 1),
|
||||||
|
# Compatibilità chiavi legacy (usate da generate_practical_advice)
|
||||||
|
"first_avg": round((first_max + first_min) / 2, 1),
|
||||||
|
"last_avg": round((last_max + last_min) / 2, 1),
|
||||||
"daily_max": daily_temps_max[:max_days],
|
"daily_max": daily_temps_max[:max_days],
|
||||||
"daily_min": daily_temps_min[:max_days]
|
"daily_min": daily_temps_min[:max_days],
|
||||||
}
|
}
|
||||||
|
|
||||||
def analyze_weather_transitions(daily_weathercodes):
|
def analyze_weather_transitions(daily_weathercodes):
|
||||||
@@ -807,13 +871,18 @@ def analyze_weather_transitions(daily_weathercodes):
|
|||||||
|
|
||||||
return transitions
|
return transitions
|
||||||
|
|
||||||
def get_precip_type(code):
|
def get_precip_type(code, temp=None):
|
||||||
"""Definisce il tipo di precipitazione in base al codice WMO."""
|
"""Definisce il tipo di precipitazione in base al codice WMO (gate termico per neve/gelicidio)."""
|
||||||
if (71 <= code <= 77) or code in [85, 86]:
|
try:
|
||||||
|
code = int(code) if code is not None else 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
code = 0
|
||||||
|
cold_enough = temp is None or float(temp) <= 1.0
|
||||||
|
if ((71 <= code <= 77) or code in [85, 86]) and cold_enough:
|
||||||
return "❄️ Neve"
|
return "❄️ Neve"
|
||||||
if code in [96, 99]:
|
if code in [96, 99]:
|
||||||
return "⚡🌨 Grandine"
|
return "⚡🌨 Grandine"
|
||||||
if code in [66, 67]:
|
if code in [66, 67] and cold_enough:
|
||||||
return "🧊☔ Pioggia Congelantesi"
|
return "🧊☔ Pioggia Congelantesi"
|
||||||
return "☔ Pioggia"
|
return "☔ Pioggia"
|
||||||
|
|
||||||
@@ -824,6 +893,40 @@ def get_intensity_label(mm_h):
|
|||||||
return "Moderata"
|
return "Moderata"
|
||||||
return "Forte ⚠️"
|
return "Forte ⚠️"
|
||||||
|
|
||||||
|
|
||||||
|
def _add_one_hour_hhmm(hhmm):
|
||||||
|
"""Somma 1 ora a una stringa HH:MM (mod 24)."""
|
||||||
|
try:
|
||||||
|
h, m = hhmm.split(":")
|
||||||
|
return f"{(int(h) + 1) % 24:02d}:{int(m):02d}"
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return hhmm
|
||||||
|
|
||||||
|
|
||||||
|
def _format_event_hours(times, start_idx, end_idx_inclusive):
|
||||||
|
"""
|
||||||
|
Formato HH:MM-HH:MM per bucket orari Open-Meteo.
|
||||||
|
Una sola ora → 20:00-21:00 (mai 20:00-20:00). Fine = ora successiva all'ultima inclusa.
|
||||||
|
"""
|
||||||
|
if not times or start_idx < 0 or start_idx >= len(times):
|
||||||
|
return "??:??-??:??"
|
||||||
|
end_idx_inclusive = max(start_idx, min(end_idx_inclusive, len(times) - 1))
|
||||||
|
start_time = times[start_idx].split("T")[1][:5] if "T" in str(times[start_idx]) else str(times[start_idx])[:5]
|
||||||
|
next_idx = end_idx_inclusive + 1
|
||||||
|
if next_idx < len(times):
|
||||||
|
end_time = times[next_idx].split("T")[1][:5] if "T" in str(times[next_idx]) else str(times[next_idx])[:5]
|
||||||
|
else:
|
||||||
|
last_t = times[end_idx_inclusive].split("T")[1][:5] if "T" in str(times[end_idx_inclusive]) else str(times[end_idx_inclusive])[:5]
|
||||||
|
end_time = _add_one_hour_hhmm(last_t)
|
||||||
|
if start_time == end_time:
|
||||||
|
end_time = _add_one_hour_hhmm(start_time)
|
||||||
|
return f"{start_time}-{end_time}"
|
||||||
|
|
||||||
|
|
||||||
|
ICE_EVENT_MAX_AIR_TEMP = 2.0 # scarta pericoli invernali se Tmin blocco > questa soglia
|
||||||
|
GELICIDIO_MAX_AIR_TEMP = 1.0
|
||||||
|
|
||||||
|
|
||||||
def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, snowfalls=None, rains=None, soil_temps=None, cloud_covers=None, wind_speeds=None):
|
def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, snowfalls=None, rains=None, soil_temps=None, cloud_covers=None, wind_speeds=None):
|
||||||
"""Scansiona le 24 ore e trova blocchi di eventi continui."""
|
"""Scansiona le 24 ore e trova blocchi di eventi continui."""
|
||||||
events = []
|
events = []
|
||||||
@@ -838,14 +941,13 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
|||||||
if cloud_covers is None:
|
if cloud_covers is None:
|
||||||
cloud_covers = [None] * len(times)
|
cloud_covers = [None] * len(times)
|
||||||
if wind_speeds is None:
|
if wind_speeds is None:
|
||||||
wind_speeds = [None] * len(times)
|
wind_speeds = winds if winds else [None] * len(times)
|
||||||
|
|
||||||
# Calcola precipitazioni cumulative delle 3h precedenti per ogni punto
|
# Calcola precipitazioni cumulate nelle 3h precedenti per ogni ora
|
||||||
precip_3h_sum = []
|
precip_3h_sum = []
|
||||||
rain_3h_sum = []
|
rain_3h_sum = []
|
||||||
snow_3h_sum = []
|
snow_3h_sum = []
|
||||||
for i in range(len(times)):
|
for i in range(len(times)):
|
||||||
# Somma delle 3 ore precedenti (i-3, i-2, i-1)
|
|
||||||
start_idx = max(0, i - 3)
|
start_idx = max(0, i - 3)
|
||||||
precip_sum = sum([float(p) if p is not None else 0.0 for p in precip[start_idx:i]])
|
precip_sum = sum([float(p) if p is not None else 0.0 for p in precip[start_idx:i]])
|
||||||
rain_sum = sum([float(r) if r is not None else 0.0 for r in rains[start_idx:i]])
|
rain_sum = sum([float(r) if r is not None else 0.0 for r in rains[start_idx:i]])
|
||||||
@@ -854,7 +956,7 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
|||||||
rain_3h_sum.append(rain_sum)
|
rain_3h_sum.append(rain_sum)
|
||||||
snow_3h_sum.append(snow_sum)
|
snow_3h_sum.append(snow_sum)
|
||||||
|
|
||||||
# 1. PERICOLI (Ghiaccio, Gelo, Brina) - Logica migliorata allineata a check_ghiaccio.py
|
# 1. PERICOLI (Ghiaccio, Gelo, Brina) - con gate termici anti falsi positivi estivi
|
||||||
in_ice = False
|
in_ice = False
|
||||||
start_ice = 0
|
start_ice = 0
|
||||||
ice_type = ""
|
ice_type = ""
|
||||||
@@ -883,7 +985,7 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
|||||||
try:
|
try:
|
||||||
hour = int(times[i].split("T")[1].split(":")[0]) if "T" in times[i] else 12
|
hour = int(times[i].split("T")[1].split(":")[0]) if "T" in times[i] else 12
|
||||||
is_night = (hour >= 18) or (hour <= 6)
|
is_night = (hour >= 18) or (hour <= 6)
|
||||||
except:
|
except Exception:
|
||||||
is_night = False
|
is_night = False
|
||||||
|
|
||||||
# Calcola temperatura suolo: usa valore misurato se disponibile, altrimenti stima (1-2°C più fredda)
|
# Calcola temperatura suolo: usa valore misurato se disponibile, altrimenti stima (1-2°C più fredda)
|
||||||
@@ -891,48 +993,41 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
|||||||
t_soil = t - 1.5 # Approssimazione conservativa
|
t_soil = t - 1.5 # Approssimazione conservativa
|
||||||
|
|
||||||
# Applica raffreddamento radiativo: cielo sereno + notte + vento debole
|
# Applica raffreddamento radiativo: cielo sereno + notte + vento debole
|
||||||
# Riduce la temperatura del suolo di 0.5-1.5°C (come in check_ghiaccio.py)
|
|
||||||
t_soil_adjusted = t_soil
|
t_soil_adjusted = t_soil
|
||||||
if is_night and cloud is not None and cloud < 20.0:
|
if is_night and cloud is not None and cloud < 20.0:
|
||||||
if wind is None or wind < 5.0:
|
if wind is None or wind < 5.0:
|
||||||
cooling = 1.5 # Vento molto debole = più raffreddamento
|
cooling = 1.5
|
||||||
elif wind < 10.0:
|
elif wind < 10.0:
|
||||||
cooling = 1.0
|
cooling = 1.0
|
||||||
else:
|
else:
|
||||||
cooling = 0.5
|
cooling = 0.5
|
||||||
t_soil_adjusted = t_soil - cooling
|
t_soil_adjusted = t_soil - cooling
|
||||||
|
|
||||||
# Precipitazioni nelle 3h precedenti
|
|
||||||
p_3h = precip_3h_sum[i] if i < len(precip_3h_sum) else 0.0
|
p_3h = precip_3h_sum[i] if i < len(precip_3h_sum) else 0.0
|
||||||
r_3h = rain_3h_sum[i] if i < len(rain_3h_sum) else 0.0
|
r_3h = rain_3h_sum[i] if i < len(rain_3h_sum) else 0.0
|
||||||
s_3h = snow_3h_sum[i] if i < len(snow_3h_sum) else 0.0
|
s_3h = snow_3h_sum[i] if i < len(snow_3h_sum) else 0.0
|
||||||
|
|
||||||
# LOGICA MIGLIORATA (allineata a check_ghiaccio.py):
|
|
||||||
current_ice_condition = None
|
current_ice_condition = None
|
||||||
|
|
||||||
# 1. GELICIDIO (Freezing Rain) - priorità massima
|
# 1. GELICIDIO: codice 66/67 richiede anche T aria <= soglia (evita WMO spurii in estate)
|
||||||
is_raining_code = (50 <= c <= 69) or (80 <= c <= 82)
|
is_raining_code = (50 <= c <= 69) or (80 <= c <= 82)
|
||||||
if c in [66, 67] or (p > 0 and t <= 0 and is_raining_code):
|
if (c in [66, 67] and t <= GELICIDIO_MAX_AIR_TEMP) or (p > 0 and t <= 0 and is_raining_code):
|
||||||
current_ice_condition = "🧊☠️ GELICIDIO"
|
current_ice_condition = "🧊☠️ GELICIDIO"
|
||||||
|
|
||||||
# 2. Black Ice o Neve Ghiacciata - Precipitazione nelle 3h precedenti + suolo gelato
|
# 2. Black Ice o Neve Ghiacciata
|
||||||
elif p_3h > 0.1 and t_soil_adjusted < 0.0:
|
elif p_3h > 0.1 and t_soil_adjusted < 0.0 and t <= ICE_EVENT_MAX_AIR_TEMP:
|
||||||
# Distingue tra neve e pioggia
|
|
||||||
has_snow = (s_3h > 0.1) or (snowfall_curr > 0.1)
|
has_snow = (s_3h > 0.1) or (snowfall_curr > 0.1)
|
||||||
has_rain = (r_3h > 0.1) or (rain_curr > 0.1)
|
|
||||||
if has_snow:
|
if has_snow:
|
||||||
current_ice_condition = "⛸️⚠️ Neve ghiacciata (suolo gelato)"
|
current_ice_condition = "⛸️⚠️ Neve ghiacciata (suolo gelato)"
|
||||||
elif has_rain:
|
|
||||||
current_ice_condition = "⛸️⚠️ Black Ice (strada bagnata + suolo gelato)"
|
|
||||||
else:
|
else:
|
||||||
current_ice_condition = "⛸️⚠️ Black Ice (strada bagnata + suolo gelato)"
|
current_ice_condition = "⛸️⚠️ Black Ice (strada bagnata + suolo gelato)"
|
||||||
|
|
||||||
# 3. BRINA (Hoar Frost) - Suolo <= 0°C e punto di rugiada > suolo ma < 0°C
|
# 3. BRINA
|
||||||
elif p_3h <= 0.1 and t_soil_adjusted <= 0.0 and d is not None:
|
elif p_3h <= 0.1 and t_soil_adjusted <= 0.0 and d is not None and t <= ICE_EVENT_MAX_AIR_TEMP:
|
||||||
if d > t_soil_adjusted and d < 0.0:
|
if d > t_soil_adjusted and d < 0.0:
|
||||||
current_ice_condition = "⛸️⚠️ GHIACCIO/BRINA"
|
current_ice_condition = "⛸️⚠️ GHIACCIO/BRINA"
|
||||||
|
|
||||||
# 4. GELATA - Temperatura aria < 0°C (senza altre condizioni)
|
# 4. GELATA
|
||||||
elif t < 0:
|
elif t < 0:
|
||||||
current_ice_condition = "🧊 Gelata"
|
current_ice_condition = "🧊 Gelata"
|
||||||
|
|
||||||
@@ -941,28 +1036,51 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
|||||||
start_ice = i
|
start_ice = i
|
||||||
ice_type = current_ice_condition
|
ice_type = current_ice_condition
|
||||||
elif (not current_ice_condition and in_ice) or (in_ice and current_ice_condition != ice_type) or (in_ice and i == len(times)-1):
|
elif (not current_ice_condition and in_ice) or (in_ice and current_ice_condition != ice_type) or (in_ice and i == len(times)-1):
|
||||||
end_idx = i if not current_ice_condition else i
|
if not current_ice_condition and in_ice:
|
||||||
if end_idx > start_ice:
|
end_inclusive = i - 1
|
||||||
start_time = times[start_ice].split("T")[1][:5]
|
elif in_ice and current_ice_condition and current_ice_condition != ice_type:
|
||||||
end_time = times[min(end_idx, len(times)-1)].split("T")[1][:5]
|
end_inclusive = i - 1
|
||||||
temp_block = temps[start_ice:min(end_idx+1, len(temps))]
|
|
||||||
temp_block_clean = [t for t in temp_block if t is not None]
|
|
||||||
min_t = min(temp_block_clean) if temp_block_clean else 0
|
|
||||||
|
|
||||||
# Per GHIACCIO/BRINA, verifica che la temperatura minima sia effettivamente sotto/sopra soglia critica
|
|
||||||
# Se la temperatura minima è > 1.5°C, non è un rischio reale
|
|
||||||
if ice_type == "⛸️⚠️ GHIACCIO/BRINA" and min_t > 1.5:
|
|
||||||
# Non segnalare se la temperatura minima è troppo alta
|
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
events.append(f"{ice_type}: {start_time}-{end_time} (Min: {min_t:.0f}°C)")
|
end_inclusive = i
|
||||||
|
if end_inclusive >= start_ice:
|
||||||
|
hours_str = _format_event_hours(times, start_ice, end_inclusive)
|
||||||
|
temp_block = temps[start_ice:end_inclusive + 1]
|
||||||
|
temp_block_clean = [tv for tv in temp_block if tv is not None]
|
||||||
|
min_t = min(temp_block_clean) if temp_block_clean else 0
|
||||||
|
# Gate termico su tutti i pericoli invernali (non solo brina)
|
||||||
|
if min_t <= ICE_EVENT_MAX_AIR_TEMP:
|
||||||
|
events.append(f"{ice_type}: {hours_str} (Min: {min_t:.0f}°C)")
|
||||||
in_ice = False
|
in_ice = False
|
||||||
if current_ice_condition:
|
if current_ice_condition:
|
||||||
in_ice = True
|
in_ice = True
|
||||||
start_ice = i
|
start_ice = i
|
||||||
ice_type = current_ice_condition
|
ice_type = current_ice_condition
|
||||||
|
|
||||||
# 2. PRECIPITAZIONI
|
# 2. PRECIPITAZIONI — tot_mm è sempre la somma del solo blocco orario (mai il totale giorno)
|
||||||
|
def _precip_type_at(idx):
|
||||||
|
code_val = codes[idx] if idx < len(codes) and codes[idx] is not None else 0
|
||||||
|
t_val = temps[idx] if idx < len(temps) and temps[idx] is not None else None
|
||||||
|
try:
|
||||||
|
code_val = int(code_val) if code_val is not None else 0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
code_val = 0
|
||||||
|
return get_precip_type(code_val, temp=t_val)
|
||||||
|
|
||||||
|
def _emit_rain(start, end_inclusive, rain_type):
|
||||||
|
if end_inclusive < start:
|
||||||
|
return
|
||||||
|
block_precip = precip[start:end_inclusive + 1]
|
||||||
|
block_precip_clean = [p for p in block_precip if p is not None]
|
||||||
|
tot_mm = sum(float(p) for p in block_precip_clean)
|
||||||
|
if tot_mm <= 0:
|
||||||
|
return
|
||||||
|
hours_str = _format_event_hours(times, start, end_inclusive)
|
||||||
|
avg_intensity = tot_mm / len(block_precip) if block_precip else 0
|
||||||
|
events.append(
|
||||||
|
f"{rain_type} ({get_intensity_label(avg_intensity)}):\n"
|
||||||
|
f" 🕒 {hours_str} | 💧 {tot_mm:.1f}mm"
|
||||||
|
)
|
||||||
|
|
||||||
in_rain = False
|
in_rain = False
|
||||||
start_idx = 0
|
start_idx = 0
|
||||||
current_rain_type = ""
|
current_rain_type = ""
|
||||||
@@ -970,54 +1088,23 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
|||||||
for i in range(len(times)):
|
for i in range(len(times)):
|
||||||
p_val = precip[i] if i < len(precip) and precip[i] is not None else 0
|
p_val = precip[i] if i < len(precip) and precip[i] is not None else 0
|
||||||
is_raining = p_val >= MIN_MM_PER_EVENTO
|
is_raining = p_val >= MIN_MM_PER_EVENTO
|
||||||
|
is_last = i == len(times) - 1
|
||||||
|
|
||||||
if is_raining and not in_rain:
|
if is_raining and not in_rain:
|
||||||
in_rain = True
|
in_rain = True
|
||||||
start_idx = i
|
start_idx = i
|
||||||
code_val = codes[i] if i < len(codes) and codes[i] is not None else 0
|
current_rain_type = _precip_type_at(i)
|
||||||
try:
|
elif in_rain and is_raining:
|
||||||
code_val = int(code_val) if code_val is not None else 0
|
new_type = _precip_type_at(i)
|
||||||
except (ValueError, TypeError):
|
|
||||||
code_val = 0
|
|
||||||
current_rain_type = get_precip_type(code_val)
|
|
||||||
elif in_rain and is_raining and i < len(codes):
|
|
||||||
code_val = codes[i] if codes[i] is not None else 0
|
|
||||||
try:
|
|
||||||
code_val = int(code_val) if code_val is not None else 0
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
code_val = 0
|
|
||||||
new_type = get_precip_type(code_val)
|
|
||||||
if new_type != current_rain_type:
|
if new_type != current_rain_type:
|
||||||
end_idx = i
|
_emit_rain(start_idx, i - 1, current_rain_type)
|
||||||
block_precip = precip[start_idx:end_idx] if end_idx <= len(precip) else precip[start_idx:]
|
|
||||||
block_precip_clean = [p for p in block_precip if p is not None]
|
|
||||||
tot_mm = sum(block_precip_clean)
|
|
||||||
start_time = times[start_idx].split("T")[1][:5]
|
|
||||||
end_time = times[end_idx].split("T")[1][:5] if end_idx < len(times) else times[-1].split("T")[1][:5]
|
|
||||||
avg_intensity = tot_mm / len(block_precip) if block_precip else 0
|
|
||||||
|
|
||||||
events.append(
|
|
||||||
f"{current_rain_type} ({get_intensity_label(avg_intensity)}):\n"
|
|
||||||
f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm"
|
|
||||||
)
|
|
||||||
start_idx = i
|
start_idx = i
|
||||||
current_rain_type = new_type
|
current_rain_type = new_type
|
||||||
elif (not is_raining and in_rain) or (in_rain and i == len(times)-1):
|
|
||||||
|
if in_rain and (not is_raining or is_last):
|
||||||
|
end_inclusive = i if is_raining else i - 1
|
||||||
|
_emit_rain(start_idx, end_inclusive, current_rain_type)
|
||||||
in_rain = False
|
in_rain = False
|
||||||
end_idx = i if not is_raining else i + 1
|
|
||||||
block_precip = precip[start_idx:end_idx] if end_idx <= len(precip) else precip[start_idx:]
|
|
||||||
block_precip_clean = [p for p in block_precip if p is not None]
|
|
||||||
tot_mm = sum(block_precip_clean)
|
|
||||||
|
|
||||||
if tot_mm > 0:
|
|
||||||
start_time = times[start_idx].split("T")[1][:5]
|
|
||||||
end_time = times[min(end_idx-1, len(times)-1)].split("T")[1][:5]
|
|
||||||
avg_intensity = tot_mm / len(block_precip) if block_precip else 0
|
|
||||||
|
|
||||||
events.append(
|
|
||||||
f"{current_rain_type} ({get_intensity_label(avg_intensity)}):\n"
|
|
||||||
f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. VENTO
|
# 3. VENTO
|
||||||
if winds:
|
if winds:
|
||||||
@@ -1027,10 +1114,10 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s
|
|||||||
if max_wind > SOGLIA_VENTO_KMH:
|
if max_wind > SOGLIA_VENTO_KMH:
|
||||||
try:
|
try:
|
||||||
peak_idx = winds.index(max_wind)
|
peak_idx = winds.index(max_wind)
|
||||||
except ValueError:
|
peak_time = times[peak_idx].split("T")[1][:5]
|
||||||
peak_idx = 0
|
events.append(f"💨 Picco vento: {max_wind:.0f}km/h alle {peak_time}")
|
||||||
peak_time = times[min(peak_idx, len(times)-1)].split("T")[1][:5]
|
except (ValueError, IndexError, AttributeError):
|
||||||
events.append(f"💨 Vento Forte: Picco {max_wind:.0f}km/h alle {peak_time}")
|
events.append(f"💨 Picco vento: {max_wind:.0f}km/h")
|
||||||
|
|
||||||
return events
|
return events
|
||||||
|
|
||||||
@@ -1042,6 +1129,8 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
|
|||||||
if trend:
|
if trend:
|
||||||
if trend["type"] == "fronte_freddo" and trend["intensity"] == "forte":
|
if trend["type"] == "fronte_freddo" and trend["intensity"] == "forte":
|
||||||
advice.append("❄️ <b>Fronte Freddo in Arrivo:</b> Preparati a temperature in calo significativo. Controlla riscaldamento, proteggi piante sensibili.")
|
advice.append("❄️ <b>Fronte Freddo in Arrivo:</b> Preparati a temperature in calo significativo. Controlla riscaldamento, proteggi piante sensibili.")
|
||||||
|
elif trend["type"] == "calo_termico" and trend["intensity"] == "forte":
|
||||||
|
advice.append("📉 <b>Calo termico:</b> Massime e/o minime in netto ribasso rispetto all'inizio periodo, senza necessariamente gelo.")
|
||||||
elif trend["type"] == "fronte_caldo" and trend["intensity"] == "forte":
|
elif trend["type"] == "fronte_caldo" and trend["intensity"] == "forte":
|
||||||
advice.append("🔥 <b>Ondata di Calore:</b> Temperature in aumento. Mantieni case fresche, idratazione importante, attenzione a persone fragili.")
|
advice.append("🔥 <b>Ondata di Calore:</b> Temperature in aumento. Mantieni case fresche, idratazione importante, attenzione a persone fragili.")
|
||||||
elif trend["type"] == "raffreddamento":
|
elif trend["type"] == "raffreddamento":
|
||||||
@@ -1075,55 +1164,58 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
|
|||||||
return advice
|
return advice
|
||||||
|
|
||||||
def format_detailed_trend_explanation(trend, daily_time_list=None, display_days=DISPLAY_FORECAST_DAYS):
|
def format_detailed_trend_explanation(trend, daily_time_list=None, display_days=DISPLAY_FORECAST_DAYS):
|
||||||
"""Genera spiegazione dettagliata del trend temperatura sui giorni in previsione."""
|
"""Genera spiegazione dettagliata del trend su Tmax e Tmin (non temperature medie)."""
|
||||||
if not trend:
|
if not trend:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
explanation = []
|
explanation = []
|
||||||
explanation.append(f"📊 <b>EVOLUZIONE TEMPERATURE ({display_days} GIORNI)</b>\n")
|
explanation.append(f"📊 <b>EVOLUZIONE TEMPERATURE ({display_days} GIORNI)</b>\n")
|
||||||
|
|
||||||
# Trend principale con spiegazione chiara
|
|
||||||
trend_type = trend["type"]
|
trend_type = trend["type"]
|
||||||
intensity = trend["intensity"]
|
intensity = trend["intensity"]
|
||||||
delta = trend['delta']
|
first_max = trend["first_max"]
|
||||||
first_avg = trend['first_avg']
|
last_max = trend["last_max"]
|
||||||
last_avg = trend['last_avg']
|
first_min = trend["first_min"]
|
||||||
|
last_min = trend["last_min"]
|
||||||
|
delta_max = trend["delta_max"]
|
||||||
|
delta_min = trend["delta_min"]
|
||||||
|
|
||||||
if trend_type == "fronte_caldo":
|
if trend_type == "fronte_caldo":
|
||||||
trend_desc = "🔥 <b>Fronte Caldo in Arrivo</b>"
|
trend_desc = "🔥 <b>Fronte Caldo in Arrivo</b>"
|
||||||
desc_text = f"Arrivo di aria più calda: temperatura media passerà da {first_avg:.1f}°C a {last_avg:.1f}°C (+{delta:.1f}°C)."
|
|
||||||
elif trend_type == "fronte_freddo":
|
elif trend_type == "fronte_freddo":
|
||||||
trend_desc = "❄️ <b>Fronte Freddo in Arrivo</b>"
|
trend_desc = "❄️ <b>Fronte Freddo in Arrivo</b>"
|
||||||
desc_text = f"Arrivo di aria più fredda: temperatura media scenderà da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)."
|
elif trend_type == "calo_termico":
|
||||||
|
trend_desc = "📉 <b>Calo Termico</b>"
|
||||||
elif trend_type == "riscaldamento":
|
elif trend_type == "riscaldamento":
|
||||||
trend_desc = "📈 <b>Riscaldamento Progressivo</b>"
|
trend_desc = "📈 <b>Riscaldamento Progressivo</b>"
|
||||||
desc_text = f"Tendenza al rialzo delle temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C (+{delta:.1f}°C)."
|
|
||||||
elif trend_type == "raffreddamento":
|
elif trend_type == "raffreddamento":
|
||||||
trend_desc = "📉 <b>Raffreddamento Progressivo</b>"
|
trend_desc = "📉 <b>Raffreddamento Progressivo</b>"
|
||||||
desc_text = f"Tendenza al ribasso delle temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)."
|
|
||||||
elif trend_type == "stabile":
|
elif trend_type == "stabile":
|
||||||
trend_desc = "➡️ <b>Temperature Stabili</b>"
|
trend_desc = "➡️ <b>Temperature Stabili</b>"
|
||||||
desc_text = f"Temperature medie sostanzialmente stabili: da {first_avg:.1f}°C a {last_avg:.1f}°C (variazione {delta:+.1f}°C)."
|
|
||||||
else:
|
else:
|
||||||
trend_desc = "🌡️ <b>Variazione Termica</b>"
|
trend_desc = "🌡️ <b>Variazione Termica</b>"
|
||||||
desc_text = f"Evoluzione temperature: da {first_avg:.1f}°C a {last_avg:.1f}°C ({delta:+.1f}°C)."
|
|
||||||
|
|
||||||
intensity_text = " (variazione significativa)" if intensity == "forte" else " (variazione moderata)"
|
intensity_text = " (variazione significativa)" if intensity == "forte" else " (variazione moderata)"
|
||||||
explanation.append(f"{trend_desc}{intensity_text}")
|
explanation.append(f"{trend_desc}{intensity_text}")
|
||||||
explanation.append(f"{desc_text}")
|
explanation.append(
|
||||||
|
f"Massime: {first_max:.1f}→{last_max:.1f}°C ({delta_max:+.1f}°C). "
|
||||||
|
f"Minime: {first_min:.1f}→{last_min:.1f}°C ({delta_min:+.1f}°C)."
|
||||||
|
)
|
||||||
|
|
||||||
# Aggiungi solo picchi significativi in modo sintetico (entro i giorni in tabella)
|
|
||||||
if trend.get("change_days"):
|
if trend.get("change_days"):
|
||||||
significant_changes = [
|
significant_changes = [
|
||||||
c for c in trend["change_days"]
|
c for c in trend["change_days"]
|
||||||
if abs(c["delta"]) > 3.0 and c["day"] < display_days
|
if abs(c["delta"]) > 3.0 and c["day"] < display_days
|
||||||
][:3]
|
][:4]
|
||||||
if significant_changes:
|
if significant_changes:
|
||||||
change_texts = []
|
change_texts = []
|
||||||
for change in significant_changes:
|
for change in significant_changes:
|
||||||
day_name = format_day_label(change["day"], daily_time_list or [])
|
day_name = format_day_label(change["day"], daily_time_list or [])
|
||||||
direction = "↑" if change['delta'] > 0 else "↓"
|
direction = "↑" if change["delta"] > 0 else "↓"
|
||||||
change_texts.append(f"{direction} {day_name}: {change['from']:.0f}°→{change['to']:.0f}°C")
|
series_lbl = "max" if change.get("series") == "max" else "min"
|
||||||
|
change_texts.append(
|
||||||
|
f"{direction} {day_name} {series_lbl}: {change['from']:.0f}°→{change['to']:.0f}°C"
|
||||||
|
)
|
||||||
if change_texts:
|
if change_texts:
|
||||||
explanation.append(f"Picchi: {', '.join(change_texts)}")
|
explanation.append(f"Picchi: {', '.join(change_texts)}")
|
||||||
|
|
||||||
@@ -1146,16 +1238,7 @@ def _apply_unified_precip(hourly: Dict, daily: Dict, casa: bool) -> Tuple[Dict,
|
|||||||
if icon_d.get("time"):
|
if icon_d.get("time"):
|
||||||
daily = overlay_icon_precip_on_daily(daily, icon_d)
|
daily = overlay_icon_precip_on_daily(daily, icon_d)
|
||||||
hourly["precipitation"] = hourly_precip_series(hourly)
|
hourly["precipitation"] = hourly_precip_series(hourly)
|
||||||
totals = daily_precip_from_hourly(hourly)
|
daily = apply_hourly_daily_precip(daily, hourly)
|
||||||
times = daily.get("time") or []
|
|
||||||
psum = list(daily.get("precipitation_sum") or [])
|
|
||||||
while len(psum) < len(times):
|
|
||||||
psum.append(None)
|
|
||||||
for i, t in enumerate(times):
|
|
||||||
d = str(t)[:10]
|
|
||||||
if d in totals:
|
|
||||||
psum[i] = round(totals[d], 2)
|
|
||||||
daily["precipitation_sum"] = psum
|
|
||||||
return hourly, daily
|
return hourly, daily
|
||||||
|
|
||||||
|
|
||||||
@@ -1272,8 +1355,10 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
|||||||
threshold_mm = 5.0 # Soglia default per pioggia
|
threshold_mm = 5.0 # Soglia default per pioggia
|
||||||
|
|
||||||
if precip_amount > 0.1:
|
if precip_amount > 0.1:
|
||||||
# Se snowfall è disponibile e positivo, usa quello (più preciso)
|
# Se snowfall è disponibile e positivo, usa quello (più preciso) solo con aria fredda
|
||||||
if snow_sum_day > 0.1:
|
temps_clean_day = [float(t) for t in d_temps_day if t is not None]
|
||||||
|
day_t_min_early = min(temps_clean_day) if temps_clean_day else None
|
||||||
|
if snow_sum_day > 0.1 and day_t_min_early is not None and day_t_min_early <= ICE_EVENT_MAX_AIR_TEMP:
|
||||||
# Se c'è neve (anche poca), il simbolo è sempre ❄️ (priorità alla neve)
|
# Se c'è neve (anche poca), il simbolo è sempre ❄️ (priorità alla neve)
|
||||||
precip_type_symbol = "❄️" # Neve
|
precip_type_symbol = "❄️" # Neve
|
||||||
threshold_mm = 0.5 # Soglia più bassa per neve (anche pochi mm sono significativi)
|
threshold_mm = 0.5 # Soglia più bassa per neve (anche pochi mm sono significativi)
|
||||||
@@ -1284,12 +1369,14 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
|||||||
hail_codes = [96, 99] # Codici WMO per grandine/temporale
|
hail_codes = [96, 99] # Codici WMO per grandine/temporale
|
||||||
snow_count = sum(1 for c in d_codes_day if c is not None and int(c) in snow_codes)
|
snow_count = sum(1 for c in d_codes_day if c is not None and int(c) in snow_codes)
|
||||||
hail_count = sum(1 for c in d_codes_day if c is not None and int(c) in hail_codes)
|
hail_count = sum(1 for c in d_codes_day if c is not None and int(c) in hail_codes)
|
||||||
|
temps_clean = [float(t) for t in d_temps_day if t is not None]
|
||||||
|
day_t_min = min(temps_clean) if temps_clean else None
|
||||||
|
|
||||||
if hail_count > 0:
|
if hail_count > 0:
|
||||||
precip_type_symbol = "⛈️" # Grandine/Temporale
|
precip_type_symbol = "⛈️" # Grandine/Temporale
|
||||||
threshold_mm = 5.0
|
threshold_mm = 5.0
|
||||||
elif snow_count > 0:
|
elif snow_count > 0 and day_t_min is not None and day_t_min <= ICE_EVENT_MAX_AIR_TEMP:
|
||||||
# Solo se weathercode indica esplicitamente neve
|
# Weathercode neve solo se aria vicino allo zero (evita falsi ❄️ estivi)
|
||||||
precip_type_symbol = "❄️" # Neve
|
precip_type_symbol = "❄️" # Neve
|
||||||
threshold_mm = 0.5 # Soglia più bassa per neve
|
threshold_mm = 0.5 # Soglia più bassa per neve
|
||||||
|
|
||||||
@@ -1493,8 +1580,9 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
|||||||
pass # Gestito separatamente per l'icona meteo
|
pass # Gestito separatamente per l'icona meteo
|
||||||
|
|
||||||
if precip_sum > 0.1:
|
if precip_sum > 0.1:
|
||||||
|
cold_enough_day = t_min <= ICE_EVENT_MAX_AIR_TEMP
|
||||||
# Priorità 1: Se sta nevicando (snowfall > 0) e c'è manto nevoso, considera entrambi
|
# Priorità 1: Se sta nevicando (snowfall > 0) e c'è manto nevoso, considera entrambi
|
||||||
if has_snow_depth_data and max_snow_depth > 0:
|
if cold_enough_day and has_snow_depth_data and max_snow_depth > 0:
|
||||||
# C'è sia neve in caduta che manto nevoso persistente
|
# C'è sia neve in caduta che manto nevoso persistente
|
||||||
if rain_sum > 0.1 or showers_sum > 0.1:
|
if rain_sum > 0.1 or showers_sum > 0.1:
|
||||||
precip_type = "mixed" # Neve + pioggia/temporali
|
precip_type = "mixed" # Neve + pioggia/temporali
|
||||||
@@ -1505,7 +1593,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
|||||||
# Il tipo di precipitazione resta quello basato su snowfall/rain
|
# Il tipo di precipitazione resta quello basato su snowfall/rain
|
||||||
pass
|
pass
|
||||||
# Priorità 2: Usa dati daily se disponibili
|
# Priorità 2: Usa dati daily se disponibili
|
||||||
elif snowfall_sum > 0.1:
|
elif cold_enough_day and snowfall_sum > 0.1:
|
||||||
# C'è neve significativa
|
# C'è neve significativa
|
||||||
if snowfall_sum >= precip_sum * 0.5:
|
if snowfall_sum >= precip_sum * 0.5:
|
||||||
precip_type = "snow"
|
precip_type = "snow"
|
||||||
@@ -1527,7 +1615,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
|||||||
else:
|
else:
|
||||||
# Fallback: usa dati hourly se daily non disponibili
|
# Fallback: usa dati hourly se daily non disponibili
|
||||||
snow_sum_day = sum([float(s) for s in d_snow if s is not None]) if d_snow else 0.0
|
snow_sum_day = sum([float(s) for s in d_snow if s is not None]) if d_snow else 0.0
|
||||||
if snow_sum_day > 0.1:
|
if snow_sum_day > 0.1 and t_min <= ICE_EVENT_MAX_AIR_TEMP:
|
||||||
if snow_sum_day >= precip_sum * 0.5:
|
if snow_sum_day >= precip_sum * 0.5:
|
||||||
precip_type = "snow"
|
precip_type = "snow"
|
||||||
else:
|
else:
|
||||||
@@ -1543,7 +1631,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
|||||||
|
|
||||||
if hail_count > 0:
|
if hail_count > 0:
|
||||||
precip_type = "hail"
|
precip_type = "hail"
|
||||||
elif snow_count > rain_count:
|
elif snow_count > rain_count and t_min is not None and float(t_min) <= ICE_EVENT_MAX_AIR_TEMP:
|
||||||
precip_type = "snow"
|
precip_type = "snow"
|
||||||
else:
|
else:
|
||||||
precip_type = "rain"
|
precip_type = "rain"
|
||||||
@@ -1563,8 +1651,8 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
|||||||
weather_icon = "🌨️" # Precipitazione mista
|
weather_icon = "🌨️" # Precipitazione mista
|
||||||
else:
|
else:
|
||||||
weather_icon = "🌧️" # Pioggia
|
weather_icon = "🌧️" # Pioggia
|
||||||
elif has_snow_depth_data and max_snow_depth > 0:
|
elif has_snow_depth_data and max_snow_depth > 0 and t_min <= ICE_EVENT_MAX_AIR_TEMP:
|
||||||
# C'è manto nevoso persistente anche senza precipitazioni
|
# C'è manto nevoso persistente anche senza precipitazioni (solo se aria abbastanza fredda)
|
||||||
# Mostra icona neve anche se non sta nevicando
|
# Mostra icona neve anche se non sta nevicando
|
||||||
weather_icon = "❄️" # Manto nevoso presente
|
weather_icon = "❄️" # Manto nevoso presente
|
||||||
elif t_min < 0:
|
elif t_min < 0:
|
||||||
@@ -1698,23 +1786,36 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
|||||||
if day_info['precip_sum'] > 0.1:
|
if day_info['precip_sum'] > 0.1:
|
||||||
# Caratterizza usando dati daily se disponibili
|
# Caratterizza usando dati daily se disponibili
|
||||||
precip_parts = []
|
precip_parts = []
|
||||||
|
snow_sum = day_info.get("snowfall_sum", 0) or 0
|
||||||
|
rain_sum = day_info.get("rain_sum", 0) or 0
|
||||||
|
showers_sum = day_info.get("showers_sum", 0) or 0
|
||||||
|
precip_sum = day_info.get("precip_sum", 0) or 0
|
||||||
|
|
||||||
# Neve
|
if snow_sum > 0.1 and day_info["t_min"] <= ICE_EVENT_MAX_AIR_TEMP:
|
||||||
if day_info.get('snowfall_sum', 0) > 0.1:
|
precip_parts.append(f"❄️ {snow_sum:.1f}cm")
|
||||||
precip_parts.append(f"❄️ {day_info['snowfall_sum']:.1f}cm")
|
|
||||||
|
|
||||||
# Pioggia
|
overlapping = (
|
||||||
if day_info.get('rain_sum', 0) > 0.1:
|
rain_sum > 0.1 and showers_sum > 0.1 and (
|
||||||
precip_parts.append(f"🌧️ {day_info['rain_sum']:.1f}mm")
|
abs(rain_sum - precip_sum) < 0.25
|
||||||
|
or abs(showers_sum - precip_sum) < 0.25
|
||||||
# Temporali (showers)
|
or (rain_sum + showers_sum) > precip_sum + 0.3
|
||||||
if day_info.get('showers_sum', 0) > 0.1:
|
)
|
||||||
precip_parts.append(f"⛈️ {day_info['showers_sum']:.1f}mm")
|
)
|
||||||
|
if overlapping or (rain_sum <= 0.1 and showers_sum <= 0.1):
|
||||||
# Se non abbiamo dati daily dettagliati, usa il tipo generale
|
if precip_sum > 0.1:
|
||||||
if not precip_parts:
|
precip_symbol = (
|
||||||
precip_symbol = "❄️" if day_info['precip_type'] == "snow" else "⛈️" if day_info['precip_type'] in ("hail", "thunderstorms") else "🌨️" if day_info['precip_type'] == "mixed" else "🌧️"
|
"❄️" if day_info["precip_type"] == "snow"
|
||||||
precip_parts.append(f"{precip_symbol} {day_info['precip_sum']:.1f}mm")
|
else "⛈️" if day_info["precip_type"] in ("hail", "thunderstorms")
|
||||||
|
else "🌨️" if day_info["precip_type"] == "mixed"
|
||||||
|
else "🌧️"
|
||||||
|
)
|
||||||
|
if not (day_info["precip_type"] == "snow" and snow_sum > 0.1 and day_info["t_min"] <= ICE_EVENT_MAX_AIR_TEMP):
|
||||||
|
precip_parts.append(f"{precip_symbol} {precip_sum:.1f}mm")
|
||||||
|
else:
|
||||||
|
if rain_sum > 0.1:
|
||||||
|
precip_parts.append(f"🌧️ {rain_sum:.1f}mm")
|
||||||
|
if showers_sum > 0.1:
|
||||||
|
precip_parts.append(f"⛈️ {showers_sum:.1f}mm")
|
||||||
|
|
||||||
line += f" | {' + '.join(precip_parts)}"
|
line += f" | {' + '.join(precip_parts)}"
|
||||||
|
|
||||||
@@ -1738,7 +1839,7 @@ def format_weather_context_report(models_data, location_name, country_code, as_j
|
|||||||
elif snow_depth_avg is not None and snow_depth_avg > 0:
|
elif snow_depth_avg is not None and snow_depth_avg > 0:
|
||||||
snow_depth_end = snow_depth_avg # Usa la media come fallback
|
snow_depth_end = snow_depth_avg # Usa la media come fallback
|
||||||
|
|
||||||
if snow_depth_end is not None and snow_depth_end > 0:
|
if snow_depth_end is not None and snow_depth_end > 0 and day_info['t_min'] <= ICE_EVENT_MAX_AIR_TEMP:
|
||||||
snow_depth_str = f"❄️ Manto nevoso: {snow_depth_end:.1f} cm"
|
snow_depth_str = f"❄️ Manto nevoso: {snow_depth_end:.1f} cm"
|
||||||
# Mostra evoluzione rispetto al giorno precedente
|
# Mostra evoluzione rispetto al giorno precedente
|
||||||
if prev_snow_depth_end is not None:
|
if prev_snow_depth_end is not None:
|
||||||
|
|||||||
@@ -273,15 +273,12 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
|||||||
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
|
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from telegram_gate import mirror_alert_to_web, telegram_alerts_enabled
|
from telegram_gate import telegram_alerts_enabled
|
||||||
except ImportError:
|
except ImportError:
|
||||||
telegram_alerts_enabled = lambda: True # type: ignore
|
telegram_alerts_enabled = lambda: True # type: ignore
|
||||||
mirror_alert_to_web = lambda *a, **k: False # type: ignore
|
|
||||||
|
|
||||||
if not telegram_alerts_enabled():
|
if not telegram_alerts_enabled():
|
||||||
LOGGER.info("Telegram sospeso: skip severe_weather")
|
LOGGER.info("Telegram sospeso: skip severe_weather")
|
||||||
if message_html:
|
|
||||||
mirror_alert_to_web(message_html, "severe_weather", "warning", is_html=True)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
token = load_bot_token()
|
token = load_bot_token()
|
||||||
@@ -315,15 +312,6 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
|
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
|
||||||
|
|
||||||
if sent_ok:
|
|
||||||
try:
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, "/home/daniely/docker/shared")
|
|
||||||
from loogle_core.alert_dispatcher import mirror_to_web
|
|
||||||
mirror_to_web(message_html, "severe_weather", "warning", is_html=True)
|
|
||||||
except Exception as e:
|
|
||||||
LOGGER.debug("Web dispatch failed: %s", e)
|
|
||||||
|
|
||||||
return sent_ok
|
return sent_ok
|
||||||
|
|
||||||
|
|
||||||
@@ -1859,10 +1847,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
|||||||
msg = f"{headline}\n{meta}\n{body}{footer}"
|
msg = f"{headline}\n{meta}\n{body}{footer}"
|
||||||
|
|
||||||
ok = telegram_send_html(msg, chat_ids=chat_ids)
|
ok = telegram_send_html(msg, chat_ids=chat_ids)
|
||||||
if ok:
|
web_ok = False
|
||||||
LOGGER.info("Alert sent successfully.")
|
|
||||||
else:
|
|
||||||
LOGGER.warning("Alert NOT sent (token missing or Telegram error).")
|
|
||||||
|
|
||||||
# IMPORTANTE: Imposta alert_active = True solo se c'è una vera allerta,
|
# IMPORTANTE: Imposta alert_active = True solo se c'è una vera allerta,
|
||||||
# non se è solo un messaggio informativo in modalità debug
|
# non se è solo un messaggio informativo in modalità debug
|
||||||
@@ -1879,10 +1864,23 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
|||||||
state["alert_active"] = True
|
state["alert_active"] = True
|
||||||
state["last_alert_type"] = alert_types if alert_types else None
|
state["last_alert_type"] = alert_types if alert_types else None
|
||||||
state["last_alert_time"] = now.isoformat()
|
state["last_alert_time"] = now.isoformat()
|
||||||
if ok:
|
try:
|
||||||
|
from webapp_alert import publish_web_alert
|
||||||
|
web_ok = bool(publish_web_alert(msg, "severe_weather", "warning", is_html=True, state=state))
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.debug("Web summary failed: %s", e)
|
||||||
|
if ok or web_ok:
|
||||||
record_notify_message(now, state, alert_signature)
|
record_notify_message(now, state, alert_signature)
|
||||||
save_state(state)
|
save_state(state)
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
LOGGER.info("Alert sent successfully (Telegram).")
|
||||||
|
elif web_ok:
|
||||||
|
LOGGER.info("Alert published on WebApp.")
|
||||||
else:
|
else:
|
||||||
|
LOGGER.warning("Alert NOT delivered (Telegram/WebApp).")
|
||||||
|
|
||||||
|
if debug_message_only:
|
||||||
# In debug mode senza vere allerte, non modificare alert_active
|
# In debug mode senza vere allerte, non modificare alert_active
|
||||||
LOGGER.debug("[DEBUG MODE] Messaggio inviato ma alert_active non modificato (nessuna vera allerta)")
|
LOGGER.debug("[DEBUG MODE] Messaggio inviato ma alert_active non modificato (nessuna vera allerta)")
|
||||||
return
|
return
|
||||||
@@ -1942,7 +1940,7 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
|||||||
if ok:
|
if ok:
|
||||||
LOGGER.info("All-clear sent successfully.")
|
LOGGER.info("All-clear sent successfully.")
|
||||||
else:
|
else:
|
||||||
LOGGER.warning("All-clear NOT sent (token missing or Telegram error).")
|
LOGGER.info("All-clear Telegram skip/fail (WebApp primaria se attiva).")
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
"alert_active": False,
|
"alert_active": False,
|
||||||
|
|||||||
@@ -814,12 +814,30 @@ def analyze_all_locations(debug_mode: bool = False) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
ok = telegram_send_html(msg, chat_ids=[TELEGRAM_CHAT_IDS[0]] if debug_mode else None)
|
ok = telegram_send_html(msg, chat_ids=[TELEGRAM_CHAT_IDS[0]] if debug_mode else None)
|
||||||
if ok:
|
web_ok = False
|
||||||
LOGGER.info("Alert inviato (%s) per %d località significative", category, len(significant_locations))
|
try:
|
||||||
else:
|
from webapp_alert import publish_web_alert
|
||||||
LOGGER.warning("Alert NON inviato (token missing o errore Telegram)")
|
web_ok = bool(publish_web_alert(msg, "severe_circondario", "warning", is_html=True, state=state))
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.debug("Web summary failed: %s", e)
|
||||||
|
|
||||||
if ok and not debug_mode:
|
delivered = bool(ok or web_ok)
|
||||||
|
if ok:
|
||||||
|
LOGGER.info("Alert inviato su Telegram (%s) per %d località significative", category, len(significant_locations))
|
||||||
|
elif web_ok:
|
||||||
|
LOGGER.info("Alert pubblicato su WebApp (%s) per %d località significative", category, len(significant_locations))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from telegram_gate import telegram_alerts_enabled
|
||||||
|
suspended = not telegram_alerts_enabled()
|
||||||
|
except Exception:
|
||||||
|
suspended = False
|
||||||
|
if suspended:
|
||||||
|
LOGGER.warning("Alert NON pubblicato su WebApp (Telegram sospeso)")
|
||||||
|
else:
|
||||||
|
LOGGER.warning("Alert NON inviato (errore Telegram o WebApp)")
|
||||||
|
|
||||||
|
if delivered and not debug_mode:
|
||||||
record_notify(category, now, state)
|
record_notify(category, now, state)
|
||||||
state["last_signature"] = signature
|
state["last_signature"] = signature
|
||||||
state["last_signature_date"] = today
|
state["last_signature_date"] = today
|
||||||
|
|||||||
@@ -135,6 +135,34 @@ def hhmm(dt: datetime.datetime) -> str:
|
|||||||
return dt.strftime("%H:%M")
|
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
|
# Telegram
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -202,20 +230,100 @@ def load_state() -> Dict:
|
|||||||
return default
|
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:
|
try:
|
||||||
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
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:
|
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||||||
json.dump(
|
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||||
{"alert_active": alert_active, "signature": signature, "updated": now_local().isoformat()},
|
|
||||||
f,
|
|
||||||
ensure_ascii=False,
|
|
||||||
indent=2,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOGGER.exception("State write error: %s", 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]:
|
def get_forecast(session: requests.Session, lat: float, lon: float, model: str) -> Optional[Dict]:
|
||||||
params = {
|
params = {
|
||||||
"latitude": lat,
|
"latitude": lat,
|
||||||
@@ -357,10 +465,28 @@ def rolling_sum_3h(values: List[float]) -> List[float]:
|
|||||||
return out
|
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
|
consec = 0
|
||||||
run_start = -1
|
run_start = -1
|
||||||
run_max = 0.0
|
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):
|
for i, v in enumerate(values):
|
||||||
vv = float(v) if v is not None else 0.0
|
vv = float(v) if v is not None else 0.0
|
||||||
if vv >= threshold:
|
if vv >= threshold:
|
||||||
@@ -370,30 +496,19 @@ def first_persistent_run(values: List[float], threshold: float, persist: int) ->
|
|||||||
else:
|
else:
|
||||||
run_max = max(run_max, vv)
|
run_max = max(run_max, vv)
|
||||||
consec += 1
|
consec += 1
|
||||||
if consec >= persist:
|
|
||||||
return True, run_start, consec, run_max
|
|
||||||
else:
|
else:
|
||||||
|
_flush()
|
||||||
|
if found_start >= 0:
|
||||||
|
break
|
||||||
consec = 0
|
consec = 0
|
||||||
return False, -1, 0, 0.0
|
run_start = -1
|
||||||
|
run_max = 0.0
|
||||||
|
|
||||||
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:
|
else:
|
||||||
consec = 0
|
_flush()
|
||||||
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]:
|
def compute_stats(data: Dict) -> Optional[Dict]:
|
||||||
@@ -428,100 +543,48 @@ def compute_stats(data: Dict) -> Optional[Dict]:
|
|||||||
rain3_max_idx = rain3.index(rain3_max) if rain3 else -1
|
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 ""
|
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
|
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
|
# Flag orari neve: accumulo orario sopra eps OPPURE weathercode neve
|
||||||
rain_start_idx = None
|
snow_flags: List[float] = []
|
||||||
rain_end_idx = None
|
for i, s_val in enumerate(snow_w):
|
||||||
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)
|
|
||||||
|
|
||||||
# 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
|
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)
|
is_snow = (s_val > SNOW_HOURLY_EPS_CM) or (
|
||||||
if is_snow:
|
code is not None and code in SNOW_WEATHER_CODES
|
||||||
snow_end_idx = i
|
)
|
||||||
break
|
snow_flags.append(1.0 if is_snow else 0.0)
|
||||||
|
|
||||||
if snow_end_idx is not None:
|
snow_ok, snow_run_start, snow_run_end, _ = first_persistent_run(
|
||||||
# Calcola durata
|
snow_flags, 1.0, PERSIST_HOURS
|
||||||
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)
|
snow_run_len = (snow_run_end - snow_run_start + 1) if snow_ok else 0
|
||||||
total_snow_accumulation = sum(s for s in snow_w[snow_start_idx:snow_end_idx+1] if s > 0.0)
|
snow_persist_dt_start: Optional[datetime.datetime] = None
|
||||||
|
snow_persist_dt_end: Optional[datetime.datetime] = None
|
||||||
# Per compatibilità con logica esistente
|
snow_run_time = ""
|
||||||
snow_run_len, snow_run_start = max_consecutive_gt(snow_w, eps=SNOW_HOURLY_EPS_CM)
|
snow_run_end_time = ""
|
||||||
snow_run_time = hhmm(dt_w[snow_run_start]) if (snow_run_start >= 0 and snow_run_start < len(dt_w)) else ""
|
snow_fascia = ""
|
||||||
|
if snow_ok and 0 <= snow_run_start < len(dt_w) and 0 <= snow_run_end < len(dt_w):
|
||||||
# Se trovato inizio nevicata, usa quello invece del run
|
snow_persist_dt_start = dt_w[snow_run_start]
|
||||||
if snow_start_idx is not None:
|
snow_persist_dt_end = dt_w[snow_run_end]
|
||||||
snow_run_time = hhmm(dt_w[snow_start_idx])
|
snow_run_time = format_clock(snow_persist_dt_start)
|
||||||
# Durata minima per alert: almeno 2 ore
|
snow_run_end_time = format_clock(snow_persist_dt_end)
|
||||||
if snow_duration_hours >= PERSIST_HOURS:
|
snow_fascia = format_fascia(snow_persist_dt_start, snow_persist_dt_end)
|
||||||
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_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_24h = sum(s for s in snow_w[: min(24, len(snow_w))] if s > 0.0)
|
||||||
@@ -531,17 +594,16 @@ def compute_stats(data: Dict) -> Optional[Dict]:
|
|||||||
"rain3_max_time": rain3_max_time,
|
"rain3_max_time": rain3_max_time,
|
||||||
"rain_persist_ok": bool(rain_persist_ok),
|
"rain_persist_ok": bool(rain_persist_ok),
|
||||||
"rain_persist_time": rain_persist_time,
|
"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_max": float(rain_run_max),
|
||||||
"rain_persist_run_len": int(rain_run_len),
|
"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_len": int(snow_run_len),
|
||||||
"snow_run_time": snow_run_time,
|
"snow_run_time": snow_run_time,
|
||||||
|
"snow_run_end_time": snow_run_end_time,
|
||||||
|
"snow_fascia": snow_fascia,
|
||||||
"snow_12h": float(snow_12h),
|
"snow_12h": float(snow_12h),
|
||||||
"snow_24h": float(snow_24h),
|
"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_24h": stats["snow_24h"],
|
||||||
"snow_run_len": stats["snow_run_len"],
|
"snow_run_len": stats["snow_run_len"],
|
||||||
"snow_run_time": stats["snow_run_time"],
|
"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": stats["rain3_max"],
|
||||||
"rain3_max_time": stats["rain3_max_time"],
|
"rain3_max_time": stats["rain3_max_time"],
|
||||||
"rain_persist_time": stats["rain_persist_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_max": stats["rain_persist_run_max"],
|
||||||
"rain_persist_run_len": stats["rain_persist_run_len"],
|
"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>")
|
msg.append("🎓 <b>A BOLOGNA</b>")
|
||||||
bo_comp = comparisons.get(bo["name"])
|
bo_comp = comparisons.get(bo["name"])
|
||||||
if bo_alerts["snow_alert"]:
|
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>")
|
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"):
|
if bo_comp and bo_comp.get("snow"):
|
||||||
comp = bo_comp["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).")
|
msg.append(f"❄️ Neve: nessuna persistenza ≥ {PERSIST_HOURS}h (24h {bo_alerts['snow_24h']:.1f} cm).")
|
||||||
|
|
||||||
if bo_alerts["rain_alert"]:
|
if bo_alerts["rain_alert"]:
|
||||||
rain_duration = bo_alerts.get("rain_duration_hours", 0.0)
|
fascia = bo_alerts.get("rain_fascia") or f"~{bo_alerts.get('rain_persist_time') or '—'}"
|
||||||
total_rain = bo_alerts.get("total_rain_accumulation_mm", 0.0)
|
msg.append(
|
||||||
max_intensity = bo_alerts.get("max_rain_intensity_mm_h", 0.0)
|
f"🌧️ Pioggia molto forte (3h ≥ {SOGLIA_PIOGGIA_3H_MM:.0f} mm, ≥{PERSIST_HOURS}h) "
|
||||||
|
f"<b>{html.escape(fascia)}</b> "
|
||||||
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>.")
|
f"(max 3h <b>{bo_alerts['rain3_max']:.1f} mm</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")
|
|
||||||
if bo_comp and bo_comp.get("rain"):
|
if bo_comp and bo_comp.get("rain"):
|
||||||
comp = bo_comp["rain"]
|
comp = bo_comp["rain"]
|
||||||
icon_r3 = bo_comp["icon_stats"]["rain3_max"]
|
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>: "
|
line = f"• <b>{html.escape(x['name'])}</b>: "
|
||||||
parts: List[str] = []
|
parts: List[str] = []
|
||||||
if x["snow_alert"]:
|
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"]:
|
if x["rain_alert"]:
|
||||||
rain_dur = x.get("rain_duration_hours", 0.0)
|
fascia = x.get("rain_fascia") or f"~{x.get('rain_persist_time') or '—'}"
|
||||||
rain_tot = x.get("total_rain_accumulation_mm", 0.0)
|
parts.append(
|
||||||
if rain_dur > 0:
|
f"🌧️ pioggia forte {html.escape(fascia)} "
|
||||||
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '—')} (durata ~{rain_dur:.0f}h, totale ~{rain_tot:.1f}mm)")
|
f"(max 3h {x['rain3_max']:.1f} mm)"
|
||||||
else:
|
)
|
||||||
parts.append(f"🌧️ pioggia forte da ~{html.escape(x['rain_persist_time'] or '—')}")
|
|
||||||
line += " | ".join(parts)
|
line += " | ".join(parts)
|
||||||
msg.append(line)
|
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>")
|
msg.append("<i>Fonte dati: Open-Meteo</i>")
|
||||||
|
|
||||||
# FIX: usare \n invece di <br/>
|
# 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:
|
if ok:
|
||||||
LOGGER.info("Notifica inviata.")
|
LOGGER.info("Notifica inviata su Telegram.")
|
||||||
else:
|
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:
|
else:
|
||||||
LOGGER.info("Allerta già notificata e invariata.")
|
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
|
return
|
||||||
|
|
||||||
# --- Scenario B: Rientro ---
|
# --- 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"
|
f"di neve (≥{PERSIST_HOURS}h) o pioggia 3h sopra soglia (≥{PERSIST_HOURS}h).\n"
|
||||||
"<i>Fonte dati: Open-Meteo</i>"
|
"<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)
|
ok = telegram_send_html(msg, chat_ids=chat_ids)
|
||||||
if ok:
|
if ok:
|
||||||
LOGGER.info("Rientro notificato.")
|
LOGGER.info("Rientro notificato su Telegram.")
|
||||||
else:
|
else:
|
||||||
LOGGER.warning("Rientro NON inviato.")
|
LOGGER.info("Rientro Telegram saltato/sospeso; consegna via WebApp.")
|
||||||
save_state(False, "")
|
save_state(False, "")
|
||||||
|
notify_webapp("Allerta percorso scuola rientrata", plain, severity="info")
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- Scenario C: Tranquillo ---
|
# --- Scenario C: Tranquillo ---
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regressione visibilità ore nella tabella meteo 48h."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from open_meteo_precip import (
|
||||||
|
apply_hourly_daily_precip,
|
||||||
|
hourly_table_should_show,
|
||||||
|
overlay_icon_precip_on_hourly,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_24h_all_shown():
|
||||||
|
for h in range(24):
|
||||||
|
assert hourly_table_should_show(h, 0.0, 3) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_day_even_hours_shown():
|
||||||
|
assert hourly_table_should_show(24, 0.0, 3) is True # lun 08
|
||||||
|
assert hourly_table_should_show(32, 5.0, 63) is True # lun 16
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_day_odd_dry_hidden():
|
||||||
|
assert hourly_table_should_show(31, 0.0, 3) is False # lun 15 secco
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_day_odd_wet_shown():
|
||||||
|
# Caso reale 17/08/2026: 15:00 = 17.4 mm (code 65) era nascosto → 48h mostrava solo 5 mm alle 16
|
||||||
|
assert hourly_table_should_show(31, 17.4, 65) is True
|
||||||
|
assert hourly_table_should_show(31, 0.0, 65) is True # codice precip anche con mm 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_hourly_daily_precip_matches_hours():
|
||||||
|
hourly = {
|
||||||
|
"time": [f"2026-08-17T{h:02d}:00" for h in range(24)],
|
||||||
|
"precipitation": [0.0] * 24,
|
||||||
|
"rain": [0.0] * 24,
|
||||||
|
"showers": [0.0] * 24,
|
||||||
|
"snowfall": [0.0] * 24,
|
||||||
|
}
|
||||||
|
hourly["rain"][15] = 17.4
|
||||||
|
hourly["rain"][16] = 5.0
|
||||||
|
hourly["precipitation"][15] = 17.4
|
||||||
|
hourly["precipitation"][16] = 5.0
|
||||||
|
daily = {
|
||||||
|
"time": ["2026-08-17"],
|
||||||
|
"precipitation_sum": [18.0],
|
||||||
|
"rain_sum": [18.0],
|
||||||
|
"showers_sum": [20.0],
|
||||||
|
"snowfall_sum": [0.0],
|
||||||
|
}
|
||||||
|
out = apply_hourly_daily_precip(daily, hourly)
|
||||||
|
assert out["precipitation_sum"][0] == 22.4
|
||||||
|
assert out["rain_sum"][0] == 22.4
|
||||||
|
assert out["showers_sum"][0] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_matches_normalized_timestamps():
|
||||||
|
target = {
|
||||||
|
"time": ["2026-08-17T15:00"],
|
||||||
|
"precipitation": [0.0],
|
||||||
|
"rain": [0.0],
|
||||||
|
"showers": [0.0],
|
||||||
|
"snowfall": [0.0],
|
||||||
|
}
|
||||||
|
icon = {
|
||||||
|
"time": ["2026-08-17T15:00:00"],
|
||||||
|
"precipitation": [17.4],
|
||||||
|
"rain": [17.4],
|
||||||
|
"showers": [0.0],
|
||||||
|
"snowfall": [0.0],
|
||||||
|
}
|
||||||
|
out = overlay_icon_precip_on_hourly(target, icon)
|
||||||
|
assert abs(out["precipitation"][0] - 17.4) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_first_24h_all_shown()
|
||||||
|
test_second_day_even_hours_shown()
|
||||||
|
test_second_day_odd_dry_hidden()
|
||||||
|
test_second_day_odd_wet_shown()
|
||||||
|
test_apply_hourly_daily_precip_matches_hours()
|
||||||
|
test_overlay_matches_normalized_timestamps()
|
||||||
|
print("OK hourly_table_should_show")
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression checks for Meteo7 interpretation (weathercode mode, ice gates, hours, trend max/min)."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from previsione7 import (
|
||||||
|
_merge_weathercode,
|
||||||
|
_format_event_hours,
|
||||||
|
analyze_daily_events,
|
||||||
|
analyze_temperature_trend,
|
||||||
|
format_detailed_trend_explanation,
|
||||||
|
get_precip_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_weathercode_mode_not_median():
|
||||||
|
# 61 (rain) + 71 (snow) → mediana numerica sarebbe 66 (FZRA); moda deve evitare 66
|
||||||
|
assert _merge_weathercode([61, 71], preferred=61) == 61
|
||||||
|
assert _merge_weathercode([61, 71], preferred=71) == 71
|
||||||
|
assert _merge_weathercode([61, 61, 71]) == 61
|
||||||
|
assert _merge_weathercode([66, 66, 61]) == 66
|
||||||
|
print("OK weathercode mode")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_precip_type_temp_gate():
|
||||||
|
assert "Congelantesi" not in get_precip_type(67, temp=28.0)
|
||||||
|
assert "Pioggia" in get_precip_type(67, temp=28.0)
|
||||||
|
assert "Congelantesi" in get_precip_type(67, temp=0.5)
|
||||||
|
assert "Neve" not in get_precip_type(71, temp=20.0)
|
||||||
|
assert "Neve" in get_precip_type(71, temp=-1.0)
|
||||||
|
print("OK precip type temp gate")
|
||||||
|
|
||||||
|
|
||||||
|
def test_gelicidio_hot_air_no_event():
|
||||||
|
times = [f"2026-07-21T{h:02d}:00" for h in range(24)]
|
||||||
|
codes = [0] * 24
|
||||||
|
codes[20] = 67 # FZRA spurio
|
||||||
|
precip = [0.0] * 24
|
||||||
|
precip[20] = 1.2
|
||||||
|
temps = [28.0] * 24
|
||||||
|
dewpoints = [18.0] * 24
|
||||||
|
winds = [5.0] * 24
|
||||||
|
events = analyze_daily_events(
|
||||||
|
times, codes, None, precip, winds, temps, dewpoints,
|
||||||
|
snowfalls=[0.0] * 24, rains=precip[:], soil_temps=[25.0] * 24,
|
||||||
|
cloud_covers=[50.0] * 24, wind_speeds=winds,
|
||||||
|
)
|
||||||
|
ice_like = [e for e in events if "GELICIDIO" in e or "BRINA" in e or "Gelata" in e or "Black Ice" in e]
|
||||||
|
assert not ice_like, f"unexpected ice events in heat: {ice_like}"
|
||||||
|
rain_like = [e for e in events if "Pioggia" in e or "Congelantesi" in e]
|
||||||
|
assert rain_like, "expected rain event"
|
||||||
|
assert all("Congelantesi" not in e for e in rain_like)
|
||||||
|
print("OK no gelicidio at 28°C")
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_hour_event_range():
|
||||||
|
times = [f"2026-07-21T{h:02d}:00" for h in range(24)]
|
||||||
|
assert _format_event_hours(times, 20, 20) == "20:00-21:00"
|
||||||
|
assert _format_event_hours(times, 20, 21) == "20:00-22:00"
|
||||||
|
# last hour of day
|
||||||
|
assert _format_event_hours(times, 23, 23) == "23:00-00:00"
|
||||||
|
|
||||||
|
codes = [0] * 24
|
||||||
|
precip = [0.0] * 24
|
||||||
|
precip[20] = 2.0
|
||||||
|
temps = [22.0] * 24
|
||||||
|
events = analyze_daily_events(
|
||||||
|
times, codes, None, precip, [5.0] * 24, temps, [12.0] * 24,
|
||||||
|
snowfalls=[0.0] * 24, rains=precip[:],
|
||||||
|
)
|
||||||
|
rain_ev = [e for e in events if "🕒" in e]
|
||||||
|
assert rain_ev, events
|
||||||
|
assert "20:00-21:00" in rain_ev[0]
|
||||||
|
assert "20:00-20:00" not in rain_ev[0]
|
||||||
|
print("OK single-hour 20:00-21:00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_trend_max_min_not_media():
|
||||||
|
# Heatwave: max stay high, slight drop → calo_termico not fronte_freddo
|
||||||
|
tmax = [34, 35, 36, 33, 30, 29, 28, 27, 26, 25]
|
||||||
|
tmin = [22, 23, 24, 21, 20, 19, 18, 17, 16, 15]
|
||||||
|
trend = analyze_temperature_trend(tmax, tmin, days=10)
|
||||||
|
assert trend is not None
|
||||||
|
assert "first_max" in trend and "first_min" in trend
|
||||||
|
assert trend["type"] == "calo_termico"
|
||||||
|
text = format_detailed_trend_explanation(trend, daily_time_list=[f"2026-07-{21+i:02d}" for i in range(10)])
|
||||||
|
assert "media" not in text.lower()
|
||||||
|
assert "Massime:" in text and "Minime:" in text
|
||||||
|
assert "Fronte Freddo" not in text
|
||||||
|
print("OK trend max/min calo_termico")
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_gelicidio_cold():
|
||||||
|
times = [f"2026-01-10T{h:02d}:00" for h in range(24)]
|
||||||
|
codes = [0] * 24
|
||||||
|
codes[8] = 67
|
||||||
|
precip = [0.0] * 24
|
||||||
|
precip[8] = 0.5
|
||||||
|
temps = [5.0] * 24
|
||||||
|
temps[8] = -0.5
|
||||||
|
events = analyze_daily_events(
|
||||||
|
times, codes, None, precip, [3.0] * 24, temps, [-1.0] * 24,
|
||||||
|
snowfalls=[0.0] * 24, rains=precip[:], soil_temps=[-1.0] * 24,
|
||||||
|
cloud_covers=[80.0] * 24, wind_speeds=[3.0] * 24,
|
||||||
|
)
|
||||||
|
assert any("GELICIDIO" in e for e in events), events
|
||||||
|
print("OK real gelicidio at subzero")
|
||||||
|
|
||||||
|
|
||||||
|
def test_monday_storm_event_mm_is_hourly_sum():
|
||||||
|
"""15:00=17.4 + 16:00=5.0 → un evento 22.4 mm, non 5 mm né 22.4 ripetuto."""
|
||||||
|
times = [f"2026-08-17T{h:02d}:00" for h in range(24)]
|
||||||
|
precip = [0.0] * 24
|
||||||
|
precip[15] = 17.4
|
||||||
|
precip[16] = 5.0
|
||||||
|
codes = [3] * 24
|
||||||
|
codes[15] = 65
|
||||||
|
codes[16] = 63
|
||||||
|
temps = [26.0] * 24
|
||||||
|
events = analyze_daily_events(
|
||||||
|
times, codes, None, precip, [18.0] * 24, temps, [16.0] * 24,
|
||||||
|
snowfalls=[0.0] * 24, rains=precip[:],
|
||||||
|
)
|
||||||
|
rain_ev = [e for e in events if "🕒" in e]
|
||||||
|
assert len(rain_ev) == 1, rain_ev
|
||||||
|
assert "15:00-17:00" in rain_ev[0]
|
||||||
|
assert "22.4mm" in rain_ev[0]
|
||||||
|
assert rain_ev[0].count("22.4mm") == 1
|
||||||
|
print("OK lun 17 15-17h = 22.4mm")
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_pulses_keep_own_mm_not_daily_total():
|
||||||
|
times = [f"2026-08-16T{h:02d}:00" for h in range(24)]
|
||||||
|
precip = [0.0] * 24
|
||||||
|
precip[10] = 18.0
|
||||||
|
precip[14] = 19.0
|
||||||
|
precip[18] = 20.0
|
||||||
|
events = analyze_daily_events(
|
||||||
|
times, [63] * 24, None, precip, [10.0] * 24, [24.0] * 24, [14.0] * 24,
|
||||||
|
snowfalls=[0.0] * 24, rains=precip[:],
|
||||||
|
)
|
||||||
|
rain_ev = [e for e in events if "🕒" in e]
|
||||||
|
assert len(rain_ev) == 3, rain_ev
|
||||||
|
assert "18.0mm" in rain_ev[0]
|
||||||
|
assert "19.0mm" in rain_ev[1]
|
||||||
|
assert "20.0mm" in rain_ev[2]
|
||||||
|
assert all("57.0mm" not in e for e in rain_ev)
|
||||||
|
print("OK tre fasce con mm propri")
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_hour_rain_is_emitted():
|
||||||
|
times = [f"2026-08-17T{h:02d}:00" for h in range(24)]
|
||||||
|
precip = [0.0] * 24
|
||||||
|
precip[23] = 4.2
|
||||||
|
events = analyze_daily_events(
|
||||||
|
times, [61] * 24, None, precip, [8.0] * 24, [20.0] * 24, [12.0] * 24,
|
||||||
|
snowfalls=[0.0] * 24, rains=precip[:],
|
||||||
|
)
|
||||||
|
rain_ev = [e for e in events if "🕒" in e]
|
||||||
|
assert rain_ev, events
|
||||||
|
assert "23:00-00:00" in rain_ev[0]
|
||||||
|
assert "4.2mm" in rain_ev[0]
|
||||||
|
print("OK pioggia ultima ora del giorno")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_weathercode_mode_not_median()
|
||||||
|
test_get_precip_type_temp_gate()
|
||||||
|
test_gelicidio_hot_air_no_event()
|
||||||
|
test_single_hour_event_range()
|
||||||
|
test_trend_max_min_not_media()
|
||||||
|
test_real_gelicidio_cold()
|
||||||
|
test_monday_storm_event_mm_is_hourly_sum()
|
||||||
|
test_three_pulses_keep_own_mm_not_daily_total()
|
||||||
|
test_last_hour_rain_is_emitted()
|
||||||
|
print("\nAll interpretation regression checks passed.")
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Helper: testo plain + summary state + mirror WebApp per allerte meteo."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("webapp_alert")
|
||||||
|
|
||||||
|
|
||||||
|
def message_to_plain(message: str, is_html: bool = False) -> str:
|
||||||
|
text = message or ""
|
||||||
|
if is_html:
|
||||||
|
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
||||||
|
text = re.sub(r"</p\s*>", "\n", text, flags=re.I)
|
||||||
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
|
text = (
|
||||||
|
text.replace("*", "")
|
||||||
|
.replace("_", "")
|
||||||
|
.replace("`", "")
|
||||||
|
.replace(" ", " ")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
)
|
||||||
|
lines = [ln.rstrip() for ln in text.splitlines()]
|
||||||
|
# collassa righe vuote multiple
|
||||||
|
out: list[str] = []
|
||||||
|
blank = False
|
||||||
|
for ln in lines:
|
||||||
|
if not ln.strip():
|
||||||
|
if not blank:
|
||||||
|
out.append("")
|
||||||
|
blank = True
|
||||||
|
else:
|
||||||
|
out.append(ln.strip())
|
||||||
|
blank = False
|
||||||
|
return "\n".join(out).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def remember_summary(state: Optional[Dict[str, Any]], plain: str, limit: int = 3000) -> str:
|
||||||
|
summary = (plain or "").strip()[:limit]
|
||||||
|
if state is not None and summary:
|
||||||
|
state["summary"] = summary
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def publish_web_alert(
|
||||||
|
message: str,
|
||||||
|
category: str,
|
||||||
|
severity: str = "warning",
|
||||||
|
*,
|
||||||
|
is_html: bool = False,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
state: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Salva summary nello state (se passato) e notifica la WebApp (solo canale web)."""
|
||||||
|
plain = message_to_plain(message, is_html=is_html)
|
||||||
|
if not plain:
|
||||||
|
return False
|
||||||
|
remember_summary(state, plain)
|
||||||
|
head = (title or plain.split("\n", 1)[0]).strip()[:80] or category
|
||||||
|
body = plain[:2500]
|
||||||
|
try:
|
||||||
|
shared = "/home/daniely/docker/shared"
|
||||||
|
if shared not in sys.path:
|
||||||
|
sys.path.insert(0, shared)
|
||||||
|
from loogle_core.alert_dispatcher import send_web
|
||||||
|
|
||||||
|
ok = send_web(head, body, category=category, severity=severity)
|
||||||
|
if not ok:
|
||||||
|
LOGGER.debug("send_web returned False for %s", category)
|
||||||
|
return bool(ok)
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("publish_web_alert failed (%s): %s", category, exc)
|
||||||
|
return False
|
||||||
Reference in new issue
Block a user