Backup automatico script del 2026-07-12 07:00
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Override opzionali per weekly-maintenance.sh (Pi-1 Master)
|
||||
# HOST_LABEL="Pi-1 (Master)"
|
||||
# DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||
# 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)
|
||||
@@ -0,0 +1,6 @@
|
||||
# Override opzionali per weekly-maintenance.sh (Pi-2 Backup)
|
||||
# HOST_LABEL="Pi-2 (Backup)"
|
||||
# REBOOT_ON_SUCCESS=false # unico modo per saltare il reboot fisso di fine manutenzione
|
||||
# CHECK_PIP3=true
|
||||
# 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)
|
||||
@@ -54,4 +54,4 @@ else
|
||||
fi
|
||||
|
||||
# 5. Pulizia locale
|
||||
rm "$TEMP_FILE"
|
||||
sudo rm -f "$TEMP_FILE"
|
||||
|
||||
@@ -5,11 +5,20 @@
|
||||
# Controlla solo che il Pi-2 sia vivo.
|
||||
# ================================================
|
||||
|
||||
# --- CONFIGURAZIONE ---
|
||||
# 👇👇 INSERISCI I TUOI DATI VERI QUI 👇👇
|
||||
BOT_TOKEN="8155587974:AAF9OekvBpixtk8ZH6KoIc0L8edbhdXt7A4"
|
||||
CHAT_ID="64463169"
|
||||
# 👆👆 FINE MODIFICHE 👆👆
|
||||
ENV_FILE="/home/daniely/.config/watchdog_pi2.env"
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "Errore: file credenziali non trovato ($ENV_FILE)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$ENV_FILE"
|
||||
|
||||
if [ -z "$BOT_TOKEN" ] || [ -z "$CHAT_ID" ]; then
|
||||
echo "Errore: BOT_TOKEN o CHAT_ID mancanti in $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Il bersaglio da controllare (Pi-2 Backup)
|
||||
TARGET_IP="192.168.128.81"
|
||||
|
||||
Executable
+454
@@ -0,0 +1,454 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================
|
||||
# Manutenzione settimanale Raspberry Pi
|
||||
# Aggiorna OS, Pi-hole, EEPROM; verifica Docker/pip; report + Telegram.
|
||||
# Conclude sempre con reboot host (salvo --no-reboot o REBOOT_ON_SUCCESS=false).
|
||||
# Eseguire come root (cron root).
|
||||
# ==============================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
CONF_FILE="/etc/weekly-maintenance.conf"
|
||||
LOG_FILE="/var/log/weekly-maintenance.log"
|
||||
REPORT_FILE="/var/log/weekly-maintenance-report.txt"
|
||||
TOKEN_FILE="/etc/telegram_dpc_bot_token"
|
||||
CHAT_ID="64463169"
|
||||
PIHOLE_BIN="/usr/local/bin/pihole"
|
||||
REBOOT_DELAY_MIN=2
|
||||
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
|
||||
WATCHTOWER_TIMEOUT=900
|
||||
|
||||
HOST_LABEL="$(hostname -s)"
|
||||
REBOOT_ON_SUCCESS=true
|
||||
TELEGRAM_ENABLED=true
|
||||
CHECK_PIP3=false
|
||||
DOCKER_IGNORE_IMAGES=()
|
||||
DOCKER_PINNED_IMAGES=()
|
||||
|
||||
declare -a ERRORS=()
|
||||
declare -a WARNINGS=()
|
||||
declare -a NOTES=()
|
||||
REBOOT_RECOMMENDED=false
|
||||
APT_CHANGED=false
|
||||
DOCKER_UPDATED=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-reboot) REBOOT_ON_SUCCESS=false ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Configurazione host (override via /etc/weekly-maintenance.conf) ---
|
||||
case "$(hostname -s)" in
|
||||
pi1)
|
||||
HOST_LABEL="Pi-1 (Master)"
|
||||
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||
;;
|
||||
pi2)
|
||||
HOST_LABEL="Pi-2 (Backup)"
|
||||
CHECK_PIP3=true
|
||||
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -f "$CONF_FILE" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$CONF_FILE"
|
||||
fi
|
||||
|
||||
# --- Utility ---
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
append_report() {
|
||||
printf '%s\n' "$*" >> "$REPORT_FILE"
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local title="$1"
|
||||
shift
|
||||
local output
|
||||
local rc=0
|
||||
|
||||
log "▶ $title"
|
||||
append_report ""
|
||||
append_report "=== $title ==="
|
||||
|
||||
output="$("$@" 2>&1)" || rc=$?
|
||||
if [[ -n "$output" ]]; then
|
||||
append_report "$output"
|
||||
log "$output"
|
||||
fi
|
||||
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
NOTES+=("OK: $title")
|
||||
log "✓ $title completato"
|
||||
else
|
||||
ERRORS+=("$title (exit $rc)")
|
||||
log "✗ $title fallito (exit $rc)"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
load_telegram_token() {
|
||||
if [[ ! -f "$TOKEN_FILE" ]]; then
|
||||
WARNINGS+=("Token Telegram assente ($TOKEN_FILE): notifiche disabilitate")
|
||||
TELEGRAM_ENABLED=false
|
||||
return 1
|
||||
fi
|
||||
BOT_TOKEN=$(tr -d '\n\r' < "$TOKEN_FILE")
|
||||
if [[ -z "$BOT_TOKEN" ]]; then
|
||||
WARNINGS+=("Token Telegram vuoto: notifiche disabilitate")
|
||||
TELEGRAM_ENABLED=false
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
send_telegram() {
|
||||
local msg="$1"
|
||||
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
|
||||
curl -s --max-time 15 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
||||
-d "chat_id=${CHAT_ID}" \
|
||||
--data-urlencode "text=${msg}" \
|
||||
-d "parse_mode=Markdown" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
send_telegram_document() {
|
||||
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
|
||||
[[ -f "$REPORT_FILE" ]] || return 0
|
||||
curl -s --max-time 30 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \
|
||||
-F "chat_id=${CHAT_ID}" \
|
||||
-F "document=@${REPORT_FILE}" \
|
||||
-F "caption=Report completo ${HOST_LABEL}" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
require_root() {
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "Eseguire come root (es. cron root)." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
image_is_ignored() {
|
||||
local image="$1"
|
||||
local ignored
|
||||
for ignored in "${DOCKER_IGNORE_IMAGES[@]}"; do
|
||||
[[ "$image" == "$ignored" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_watchtower_labels() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
local name image
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
name=${line%%|*}
|
||||
image=${line#*|}
|
||||
image_is_ignored "$image" || continue
|
||||
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
|
||||
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
}
|
||||
|
||||
run_watchtower() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
|
||||
ensure_watchtower_labels
|
||||
|
||||
log "▶ Watchtower (run-once, container aggiornabili da registry)"
|
||||
append_report ""
|
||||
append_report "=== Watchtower run-once ==="
|
||||
|
||||
local output rc=0
|
||||
output=$(docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e WATCHTOWER_CLEANUP=true \
|
||||
-e WATCHTOWER_ROLLING_RESTART=true \
|
||||
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
|
||||
-e TZ=Europe/Rome \
|
||||
"$WATCHTOWER_IMAGE" \
|
||||
--run-once 2>&1) || rc=$?
|
||||
|
||||
if [[ -n "$output" ]]; then
|
||||
append_report "$output"
|
||||
printf '%s\n' "$output" | tail -30 | while IFS= read -r line; do log "$line"; done
|
||||
fi
|
||||
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
NOTES+=("Watchtower run-once OK")
|
||||
if grep -q "updated=[1-9]" <<< "$output"; then
|
||||
DOCKER_UPDATED=true
|
||||
REBOOT_RECOMMENDED=true
|
||||
NOTES+=("Watchtower: container aggiornati")
|
||||
fi
|
||||
else
|
||||
ERRORS+=("Watchtower run-once (exit $rc)")
|
||||
fi
|
||||
}
|
||||
|
||||
audit_apt() {
|
||||
local holds
|
||||
holds=$(apt-mark showhold 2>/dev/null || true)
|
||||
if [[ -n "$holds" ]]; then
|
||||
WARNINGS+=("Pacchetti apt bloccati (hold): ${holds//$'\n'/, }")
|
||||
append_report "$holds"
|
||||
fi
|
||||
|
||||
local upgradable
|
||||
upgradable=$(apt list --upgradable 2>/dev/null | tail -n +2 || true)
|
||||
if [[ -n "$upgradable" ]]; then
|
||||
WARNINGS+=("Restano pacchetti non aggiornati dopo full-upgrade")
|
||||
append_report "$upgradable"
|
||||
fi
|
||||
}
|
||||
|
||||
audit_pihole_versions() {
|
||||
local out
|
||||
out=$("$PIHOLE_BIN" -v 2>&1 || true)
|
||||
append_report "$out"
|
||||
if grep -qiE 'version is .*\(Latest: .*\)' <<< "$out"; then
|
||||
while IFS= read -r line; do
|
||||
local current latest
|
||||
current=$(sed -n 's/.*Version is \([^ ]*\).*/\1/p' <<< "$line")
|
||||
latest=$(sed -n 's/.*Latest: \([^)]*\).*/\1/p' <<< "$line")
|
||||
if [[ -n "$current" && -n "$latest" && "$current" != "$latest" && "$current" != "N/A" ]]; then
|
||||
WARNINGS+=("Pi-hole non allineato: $line")
|
||||
fi
|
||||
done <<< "$out"
|
||||
fi
|
||||
}
|
||||
|
||||
audit_eeprom() {
|
||||
command -v rpi-eeprom-update >/dev/null 2>&1 || return 0
|
||||
local out
|
||||
out=$(rpi-eeprom-update 2>&1 || true)
|
||||
append_report "$out"
|
||||
if grep -qiE 'UPDATE REQUIRED|update available|NEW EEPROM' <<< "$out"; then
|
||||
log "EEPROM: aggiornamento disponibile, applico..."
|
||||
append_report ""
|
||||
append_report "=== rpi-eeprom-update -a ==="
|
||||
if rpi-eeprom-update -a >> "$REPORT_FILE" 2>&1; then
|
||||
NOTES+=("EEPROM aggiornato")
|
||||
REBOOT_RECOMMENDED=true
|
||||
else
|
||||
ERRORS+=("rpi-eeprom-update -a")
|
||||
fi
|
||||
else
|
||||
NOTES+=("EEPROM: aggiornato")
|
||||
fi
|
||||
}
|
||||
|
||||
audit_docker() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
|
||||
append_report ""
|
||||
append_report "=== Docker audit ==="
|
||||
|
||||
local exited
|
||||
exited=$(docker ps -a --filter "status=exited" --format '{{.Names}} ({{.Image}})' 2>/dev/null || true)
|
||||
if [[ -n "$exited" ]]; then
|
||||
WARNINGS+=("Container Docker fermati (Exited): ${exited//$'\n'/, }")
|
||||
append_report "Exited:"
|
||||
append_report "$exited"
|
||||
fi
|
||||
|
||||
local name image
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
name=${line%%|*}
|
||||
image=${line#*|}
|
||||
|
||||
image_is_ignored "$image" && continue
|
||||
|
||||
if [[ "$image" =~ ^[0-9a-f]{12}$ ]]; then
|
||||
WARNINGS+=("Container '$name' usa immagine per ID ($image): preferire un tag versionato")
|
||||
fi
|
||||
|
||||
local pinned
|
||||
for pinned in "${DOCKER_PINNED_IMAGES[@]}"; do
|
||||
if [[ "$image" == "$pinned" ]]; then
|
||||
WARNINGS+=("Container '$name' usa tag bloccato ($pinned): Watchtower non passerà a :latest")
|
||||
fi
|
||||
done
|
||||
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
}
|
||||
|
||||
audit_pip3() {
|
||||
[[ "$CHECK_PIP3" == true ]] || return 0
|
||||
command -v pip3 >/dev/null 2>&1 || return 0
|
||||
|
||||
append_report ""
|
||||
append_report "=== pip3 outdated (sistema) ==="
|
||||
local outdated count
|
||||
outdated=$(pip3 list --outdated --format=columns 2>/dev/null | tail -n +3 || true)
|
||||
if [[ -n "$outdated" ]]; then
|
||||
count=$(wc -l <<< "$outdated")
|
||||
append_report "$outdated"
|
||||
WARNINGS+=("pip3: $count pacchetti Python di sistema non aggiornati (aggiornamento manuale/consigliato in venv)")
|
||||
else
|
||||
NOTES+=("pip3: tutti aggiornati")
|
||||
fi
|
||||
}
|
||||
|
||||
build_telegram_summary() {
|
||||
local icon status
|
||||
if ((${#ERRORS[@]} > 0)); then
|
||||
icon="🚨"
|
||||
status="ERRORI"
|
||||
elif ((${#WARNINGS[@]} > 0)); then
|
||||
icon="⚠️"
|
||||
status="WARNINGS"
|
||||
else
|
||||
icon="✅"
|
||||
status="OK"
|
||||
fi
|
||||
|
||||
local msg="${icon} *Manutenzione settimanale ${HOST_LABEL}*
|
||||
Stato: *${status}*
|
||||
Data: $(date '+%d/%m/%Y %H:%M')"
|
||||
|
||||
if [[ "$APT_CHANGED" == true ]]; then
|
||||
msg+="
|
||||
📦 apt: pacchetti aggiornati"
|
||||
fi
|
||||
if [[ "$DOCKER_UPDATED" == true ]]; then
|
||||
msg+="
|
||||
🐳 Docker: container aggiornati"
|
||||
fi
|
||||
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
|
||||
msg+="
|
||||
🔄 Reboot host programmato tra ${REBOOT_DELAY_MIN} min (fine manutenzione)"
|
||||
fi
|
||||
|
||||
if ((${#ERRORS[@]} > 0)); then
|
||||
msg+="
|
||||
*Errori (${#ERRORS[@]}):*"
|
||||
local e
|
||||
for e in "${ERRORS[@]}"; do
|
||||
msg+="
|
||||
• ${e}"
|
||||
done
|
||||
fi
|
||||
|
||||
if ((${#WARNINGS[@]} > 0)); then
|
||||
msg+="
|
||||
*Avvisi (${#WARNINGS[@]}):*"
|
||||
local w
|
||||
local n=0
|
||||
for w in "${WARNINGS[@]}"; do
|
||||
((n++)) || true
|
||||
[[ $n -le 8 ]] && msg+="
|
||||
• ${w}"
|
||||
done
|
||||
if ((${#WARNINGS[@]} > 8)); then
|
||||
msg+="
|
||||
• … +$((${#WARNINGS[@]} - 8)) avvisi (vedi report)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ((${#ERRORS[@]} == 0 && ${#WARNINGS[@]} == 0)); then
|
||||
msg+="
|
||||
Tutto aggiornato. Log: \`${LOG_FILE}\`"
|
||||
else
|
||||
msg+="
|
||||
Report completo allegato o in \`${REPORT_FILE}\`"
|
||||
fi
|
||||
|
||||
send_telegram "$msg"
|
||||
if ((${#ERRORS[@]} > 0 || ${#WARNINGS[@]} > 0)); then
|
||||
send_telegram_document
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
require_root
|
||||
|
||||
: > "$REPORT_FILE"
|
||||
log "========== Inizio manutenzione settimanale ($HOST_LABEL) =========="
|
||||
append_report "Manutenzione settimanale - $HOST_LABEL"
|
||||
append_report "Avviata: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
append_report "Host: $(hostname -f) ($(hostname -I | awk '{print $1}'))"
|
||||
|
||||
load_telegram_token || true
|
||||
|
||||
# 1. Aggiornamento pacchetti sistema
|
||||
if apt update >> "$REPORT_FILE" 2>&1; then
|
||||
NOTES+=("apt update OK")
|
||||
log "✓ apt update"
|
||||
else
|
||||
ERRORS+=("apt update")
|
||||
log "✗ apt update fallito"
|
||||
fi
|
||||
|
||||
BEFORE_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
|
||||
if apt full-upgrade -y >> "$REPORT_FILE" 2>&1; then
|
||||
NOTES+=("apt full-upgrade OK")
|
||||
log "✓ apt full-upgrade"
|
||||
AFTER_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
|
||||
if [[ "$BEFORE_UPGRADES" -gt "$AFTER_UPGRADES" ]] || [[ "$BEFORE_UPGRADES" -gt 0 ]]; then
|
||||
APT_CHANGED=true
|
||||
REBOOT_RECOMMENDED=true
|
||||
fi
|
||||
else
|
||||
ERRORS+=("apt full-upgrade")
|
||||
log "✗ apt full-upgrade fallito"
|
||||
fi
|
||||
|
||||
run_step "apt autoremove" apt autoremove -y
|
||||
audit_apt
|
||||
|
||||
# 2. Pi-hole
|
||||
if [[ -x "$PIHOLE_BIN" ]]; then
|
||||
run_step "pihole -up" "$PIHOLE_BIN" -up
|
||||
audit_pihole_versions
|
||||
else
|
||||
ERRORS+=("pihole non trovato in $PIHOLE_BIN")
|
||||
fi
|
||||
|
||||
# 3. EEPROM firmware
|
||||
audit_eeprom
|
||||
|
||||
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
|
||||
run_watchtower
|
||||
|
||||
# 5. Audit residui
|
||||
audit_docker
|
||||
audit_pip3
|
||||
|
||||
append_report ""
|
||||
append_report "=== Riepilogo ==="
|
||||
append_report "Errori: ${#ERRORS[@]}"
|
||||
append_report "Avvisi: ${#WARNINGS[@]}"
|
||||
if ((${#ERRORS[@]} > 0)); then
|
||||
append_report "Dettaglio errori:"
|
||||
printf '%s\n' "${ERRORS[@]}" >> "$REPORT_FILE"
|
||||
fi
|
||||
if ((${#WARNINGS[@]} > 0)); then
|
||||
append_report "Dettaglio avvisi:"
|
||||
printf '%s\n' "${WARNINGS[@]}" >> "$REPORT_FILE"
|
||||
fi
|
||||
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
|
||||
build_telegram_summary
|
||||
|
||||
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
|
||||
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
|
||||
log "Reboot host programmato tra ${REBOOT_DELAY_MIN} minuti (fine manutenzione settimanale)"
|
||||
append_report ""
|
||||
append_report "=== Reboot ==="
|
||||
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
|
||||
shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
|
||||
ERRORS+=("shutdown -r (reboot programmato)")
|
||||
log "✗ Impossibile programmare il reboot"
|
||||
}
|
||||
else
|
||||
log "Reboot disabilitato (--no-reboot o REBOOT_ON_SUCCESS=false)"
|
||||
append_report ""
|
||||
append_report "=== Reboot ==="
|
||||
append_report "Saltato (disabilitato)"
|
||||
fi
|
||||
|
||||
log "========== Fine manutenzione settimanale =========="
|
||||
exit $(( ${#ERRORS[@]} > 0 ? 1 : 0 ))
|
||||
@@ -44,6 +44,7 @@ TARGETS=(
|
||||
"📶 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"
|
||||
|
||||
# CAMERE 📷 (Sottorete .135)
|
||||
"📷 Cam Matrimoniale|192.168.135.2"
|
||||
|
||||
Executable
+454
@@ -0,0 +1,454 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================
|
||||
# Manutenzione settimanale Raspberry Pi
|
||||
# Aggiorna OS, Pi-hole, EEPROM; verifica Docker/pip; report + Telegram.
|
||||
# Conclude sempre con reboot host (salvo --no-reboot o REBOOT_ON_SUCCESS=false).
|
||||
# Eseguire come root (cron root).
|
||||
# ==============================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
CONF_FILE="/etc/weekly-maintenance.conf"
|
||||
LOG_FILE="/var/log/weekly-maintenance.log"
|
||||
REPORT_FILE="/var/log/weekly-maintenance-report.txt"
|
||||
TOKEN_FILE="/etc/telegram_dpc_bot_token"
|
||||
CHAT_ID="64463169"
|
||||
PIHOLE_BIN="/usr/local/bin/pihole"
|
||||
REBOOT_DELAY_MIN=2
|
||||
WATCHTOWER_IMAGE="nickfedor/watchtower:latest"
|
||||
WATCHTOWER_TIMEOUT=900
|
||||
|
||||
HOST_LABEL="$(hostname -s)"
|
||||
REBOOT_ON_SUCCESS=true
|
||||
TELEGRAM_ENABLED=true
|
||||
CHECK_PIP3=false
|
||||
DOCKER_IGNORE_IMAGES=()
|
||||
DOCKER_PINNED_IMAGES=()
|
||||
|
||||
declare -a ERRORS=()
|
||||
declare -a WARNINGS=()
|
||||
declare -a NOTES=()
|
||||
REBOOT_RECOMMENDED=false
|
||||
APT_CHANGED=false
|
||||
DOCKER_UPDATED=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-reboot) REBOOT_ON_SUCCESS=false ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Configurazione host (override via /etc/weekly-maintenance.conf) ---
|
||||
case "$(hostname -s)" in
|
||||
pi1)
|
||||
HOST_LABEL="Pi-1 (Master)"
|
||||
DOCKER_IGNORE_IMAGES=("turni-app:live-latest")
|
||||
;;
|
||||
pi2)
|
||||
HOST_LABEL="Pi-2 (Backup)"
|
||||
CHECK_PIP3=true
|
||||
DOCKER_IGNORE_IMAGES=("irrigazione:latest" "turni-app:beta-latest" "turni-app:alpha-latest")
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -f "$CONF_FILE" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$CONF_FILE"
|
||||
fi
|
||||
|
||||
# --- Utility ---
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
append_report() {
|
||||
printf '%s\n' "$*" >> "$REPORT_FILE"
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local title="$1"
|
||||
shift
|
||||
local output
|
||||
local rc=0
|
||||
|
||||
log "▶ $title"
|
||||
append_report ""
|
||||
append_report "=== $title ==="
|
||||
|
||||
output="$("$@" 2>&1)" || rc=$?
|
||||
if [[ -n "$output" ]]; then
|
||||
append_report "$output"
|
||||
log "$output"
|
||||
fi
|
||||
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
NOTES+=("OK: $title")
|
||||
log "✓ $title completato"
|
||||
else
|
||||
ERRORS+=("$title (exit $rc)")
|
||||
log "✗ $title fallito (exit $rc)"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
load_telegram_token() {
|
||||
if [[ ! -f "$TOKEN_FILE" ]]; then
|
||||
WARNINGS+=("Token Telegram assente ($TOKEN_FILE): notifiche disabilitate")
|
||||
TELEGRAM_ENABLED=false
|
||||
return 1
|
||||
fi
|
||||
BOT_TOKEN=$(tr -d '\n\r' < "$TOKEN_FILE")
|
||||
if [[ -z "$BOT_TOKEN" ]]; then
|
||||
WARNINGS+=("Token Telegram vuoto: notifiche disabilitate")
|
||||
TELEGRAM_ENABLED=false
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
send_telegram() {
|
||||
local msg="$1"
|
||||
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
|
||||
curl -s --max-time 15 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
||||
-d "chat_id=${CHAT_ID}" \
|
||||
--data-urlencode "text=${msg}" \
|
||||
-d "parse_mode=Markdown" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
send_telegram_document() {
|
||||
[[ "$TELEGRAM_ENABLED" == true ]] || return 0
|
||||
[[ -f "$REPORT_FILE" ]] || return 0
|
||||
curl -s --max-time 30 -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \
|
||||
-F "chat_id=${CHAT_ID}" \
|
||||
-F "document=@${REPORT_FILE}" \
|
||||
-F "caption=Report completo ${HOST_LABEL}" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
require_root() {
|
||||
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||
echo "Eseguire come root (es. cron root)." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
image_is_ignored() {
|
||||
local image="$1"
|
||||
local ignored
|
||||
for ignored in "${DOCKER_IGNORE_IMAGES[@]}"; do
|
||||
[[ "$image" == "$ignored" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_watchtower_labels() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
local name image
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
name=${line%%|*}
|
||||
image=${line#*|}
|
||||
image_is_ignored "$image" || continue
|
||||
docker update --label-add com.centurylinklabs.watchtower.enable=false "$name" >/dev/null 2>&1 || true
|
||||
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
}
|
||||
|
||||
run_watchtower() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
|
||||
ensure_watchtower_labels
|
||||
|
||||
log "▶ Watchtower (run-once, container aggiornabili da registry)"
|
||||
append_report ""
|
||||
append_report "=== Watchtower run-once ==="
|
||||
|
||||
local output rc=0
|
||||
output=$(docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e WATCHTOWER_CLEANUP=true \
|
||||
-e WATCHTOWER_ROLLING_RESTART=true \
|
||||
-e WATCHTOWER_TIMEOUT="${WATCHTOWER_TIMEOUT}" \
|
||||
-e TZ=Europe/Rome \
|
||||
"$WATCHTOWER_IMAGE" \
|
||||
--run-once 2>&1) || rc=$?
|
||||
|
||||
if [[ -n "$output" ]]; then
|
||||
append_report "$output"
|
||||
printf '%s\n' "$output" | tail -30 | while IFS= read -r line; do log "$line"; done
|
||||
fi
|
||||
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
NOTES+=("Watchtower run-once OK")
|
||||
if grep -q "updated=[1-9]" <<< "$output"; then
|
||||
DOCKER_UPDATED=true
|
||||
REBOOT_RECOMMENDED=true
|
||||
NOTES+=("Watchtower: container aggiornati")
|
||||
fi
|
||||
else
|
||||
ERRORS+=("Watchtower run-once (exit $rc)")
|
||||
fi
|
||||
}
|
||||
|
||||
audit_apt() {
|
||||
local holds
|
||||
holds=$(apt-mark showhold 2>/dev/null || true)
|
||||
if [[ -n "$holds" ]]; then
|
||||
WARNINGS+=("Pacchetti apt bloccati (hold): ${holds//$'\n'/, }")
|
||||
append_report "$holds"
|
||||
fi
|
||||
|
||||
local upgradable
|
||||
upgradable=$(apt list --upgradable 2>/dev/null | tail -n +2 || true)
|
||||
if [[ -n "$upgradable" ]]; then
|
||||
WARNINGS+=("Restano pacchetti non aggiornati dopo full-upgrade")
|
||||
append_report "$upgradable"
|
||||
fi
|
||||
}
|
||||
|
||||
audit_pihole_versions() {
|
||||
local out
|
||||
out=$("$PIHOLE_BIN" -v 2>&1 || true)
|
||||
append_report "$out"
|
||||
if grep -qiE 'version is .*\(Latest: .*\)' <<< "$out"; then
|
||||
while IFS= read -r line; do
|
||||
local current latest
|
||||
current=$(sed -n 's/.*Version is \([^ ]*\).*/\1/p' <<< "$line")
|
||||
latest=$(sed -n 's/.*Latest: \([^)]*\).*/\1/p' <<< "$line")
|
||||
if [[ -n "$current" && -n "$latest" && "$current" != "$latest" && "$current" != "N/A" ]]; then
|
||||
WARNINGS+=("Pi-hole non allineato: $line")
|
||||
fi
|
||||
done <<< "$out"
|
||||
fi
|
||||
}
|
||||
|
||||
audit_eeprom() {
|
||||
command -v rpi-eeprom-update >/dev/null 2>&1 || return 0
|
||||
local out
|
||||
out=$(rpi-eeprom-update 2>&1 || true)
|
||||
append_report "$out"
|
||||
if grep -qiE 'UPDATE REQUIRED|update available|NEW EEPROM' <<< "$out"; then
|
||||
log "EEPROM: aggiornamento disponibile, applico..."
|
||||
append_report ""
|
||||
append_report "=== rpi-eeprom-update -a ==="
|
||||
if rpi-eeprom-update -a >> "$REPORT_FILE" 2>&1; then
|
||||
NOTES+=("EEPROM aggiornato")
|
||||
REBOOT_RECOMMENDED=true
|
||||
else
|
||||
ERRORS+=("rpi-eeprom-update -a")
|
||||
fi
|
||||
else
|
||||
NOTES+=("EEPROM: aggiornato")
|
||||
fi
|
||||
}
|
||||
|
||||
audit_docker() {
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
|
||||
append_report ""
|
||||
append_report "=== Docker audit ==="
|
||||
|
||||
local exited
|
||||
exited=$(docker ps -a --filter "status=exited" --format '{{.Names}} ({{.Image}})' 2>/dev/null || true)
|
||||
if [[ -n "$exited" ]]; then
|
||||
WARNINGS+=("Container Docker fermati (Exited): ${exited//$'\n'/, }")
|
||||
append_report "Exited:"
|
||||
append_report "$exited"
|
||||
fi
|
||||
|
||||
local name image
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
name=${line%%|*}
|
||||
image=${line#*|}
|
||||
|
||||
image_is_ignored "$image" && continue
|
||||
|
||||
if [[ "$image" =~ ^[0-9a-f]{12}$ ]]; then
|
||||
WARNINGS+=("Container '$name' usa immagine per ID ($image): preferire un tag versionato")
|
||||
fi
|
||||
|
||||
local pinned
|
||||
for pinned in "${DOCKER_PINNED_IMAGES[@]}"; do
|
||||
if [[ "$image" == "$pinned" ]]; then
|
||||
WARNINGS+=("Container '$name' usa tag bloccato ($pinned): Watchtower non passerà a :latest")
|
||||
fi
|
||||
done
|
||||
done < <(docker ps --format '{{.Names}}|{{.Image}}' 2>/dev/null || true)
|
||||
}
|
||||
|
||||
audit_pip3() {
|
||||
[[ "$CHECK_PIP3" == true ]] || return 0
|
||||
command -v pip3 >/dev/null 2>&1 || return 0
|
||||
|
||||
append_report ""
|
||||
append_report "=== pip3 outdated (sistema) ==="
|
||||
local outdated count
|
||||
outdated=$(pip3 list --outdated --format=columns 2>/dev/null | tail -n +3 || true)
|
||||
if [[ -n "$outdated" ]]; then
|
||||
count=$(wc -l <<< "$outdated")
|
||||
append_report "$outdated"
|
||||
WARNINGS+=("pip3: $count pacchetti Python di sistema non aggiornati (aggiornamento manuale/consigliato in venv)")
|
||||
else
|
||||
NOTES+=("pip3: tutti aggiornati")
|
||||
fi
|
||||
}
|
||||
|
||||
build_telegram_summary() {
|
||||
local icon status
|
||||
if ((${#ERRORS[@]} > 0)); then
|
||||
icon="🚨"
|
||||
status="ERRORI"
|
||||
elif ((${#WARNINGS[@]} > 0)); then
|
||||
icon="⚠️"
|
||||
status="WARNINGS"
|
||||
else
|
||||
icon="✅"
|
||||
status="OK"
|
||||
fi
|
||||
|
||||
local msg="${icon} *Manutenzione settimanale ${HOST_LABEL}*
|
||||
Stato: *${status}*
|
||||
Data: $(date '+%d/%m/%Y %H:%M')"
|
||||
|
||||
if [[ "$APT_CHANGED" == true ]]; then
|
||||
msg+="
|
||||
📦 apt: pacchetti aggiornati"
|
||||
fi
|
||||
if [[ "$DOCKER_UPDATED" == true ]]; then
|
||||
msg+="
|
||||
🐳 Docker: container aggiornati"
|
||||
fi
|
||||
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
|
||||
msg+="
|
||||
🔄 Reboot host programmato tra ${REBOOT_DELAY_MIN} min (fine manutenzione)"
|
||||
fi
|
||||
|
||||
if ((${#ERRORS[@]} > 0)); then
|
||||
msg+="
|
||||
*Errori (${#ERRORS[@]}):*"
|
||||
local e
|
||||
for e in "${ERRORS[@]}"; do
|
||||
msg+="
|
||||
• ${e}"
|
||||
done
|
||||
fi
|
||||
|
||||
if ((${#WARNINGS[@]} > 0)); then
|
||||
msg+="
|
||||
*Avvisi (${#WARNINGS[@]}):*"
|
||||
local w
|
||||
local n=0
|
||||
for w in "${WARNINGS[@]}"; do
|
||||
((n++)) || true
|
||||
[[ $n -le 8 ]] && msg+="
|
||||
• ${w}"
|
||||
done
|
||||
if ((${#WARNINGS[@]} > 8)); then
|
||||
msg+="
|
||||
• … +$((${#WARNINGS[@]} - 8)) avvisi (vedi report)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ((${#ERRORS[@]} == 0 && ${#WARNINGS[@]} == 0)); then
|
||||
msg+="
|
||||
Tutto aggiornato. Log: \`${LOG_FILE}\`"
|
||||
else
|
||||
msg+="
|
||||
Report completo allegato o in \`${REPORT_FILE}\`"
|
||||
fi
|
||||
|
||||
send_telegram "$msg"
|
||||
if ((${#ERRORS[@]} > 0 || ${#WARNINGS[@]} > 0)); then
|
||||
send_telegram_document
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
require_root
|
||||
|
||||
: > "$REPORT_FILE"
|
||||
log "========== Inizio manutenzione settimanale ($HOST_LABEL) =========="
|
||||
append_report "Manutenzione settimanale - $HOST_LABEL"
|
||||
append_report "Avviata: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
append_report "Host: $(hostname -f) ($(hostname -I | awk '{print $1}'))"
|
||||
|
||||
load_telegram_token || true
|
||||
|
||||
# 1. Aggiornamento pacchetti sistema
|
||||
if apt update >> "$REPORT_FILE" 2>&1; then
|
||||
NOTES+=("apt update OK")
|
||||
log "✓ apt update"
|
||||
else
|
||||
ERRORS+=("apt update")
|
||||
log "✗ apt update fallito"
|
||||
fi
|
||||
|
||||
BEFORE_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
|
||||
if apt full-upgrade -y >> "$REPORT_FILE" 2>&1; then
|
||||
NOTES+=("apt full-upgrade OK")
|
||||
log "✓ apt full-upgrade"
|
||||
AFTER_UPGRADES=$(apt list --upgradable 2>/dev/null | tail -n +2 | wc -l || echo 0)
|
||||
if [[ "$BEFORE_UPGRADES" -gt "$AFTER_UPGRADES" ]] || [[ "$BEFORE_UPGRADES" -gt 0 ]]; then
|
||||
APT_CHANGED=true
|
||||
REBOOT_RECOMMENDED=true
|
||||
fi
|
||||
else
|
||||
ERRORS+=("apt full-upgrade")
|
||||
log "✗ apt full-upgrade fallito"
|
||||
fi
|
||||
|
||||
run_step "apt autoremove" apt autoremove -y
|
||||
audit_apt
|
||||
|
||||
# 2. Pi-hole
|
||||
if [[ -x "$PIHOLE_BIN" ]]; then
|
||||
run_step "pihole -up" "$PIHOLE_BIN" -up
|
||||
audit_pihole_versions
|
||||
else
|
||||
ERRORS+=("pihole non trovato in $PIHOLE_BIN")
|
||||
fi
|
||||
|
||||
# 3. EEPROM firmware
|
||||
audit_eeprom
|
||||
|
||||
# 4. Aggiornamento container Docker (Watchtower run-once, sostituisce il daemon schedulato)
|
||||
run_watchtower
|
||||
|
||||
# 5. Audit residui
|
||||
audit_docker
|
||||
audit_pip3
|
||||
|
||||
append_report ""
|
||||
append_report "=== Riepilogo ==="
|
||||
append_report "Errori: ${#ERRORS[@]}"
|
||||
append_report "Avvisi: ${#WARNINGS[@]}"
|
||||
if ((${#ERRORS[@]} > 0)); then
|
||||
append_report "Dettaglio errori:"
|
||||
printf '%s\n' "${ERRORS[@]}" >> "$REPORT_FILE"
|
||||
fi
|
||||
if ((${#WARNINGS[@]} > 0)); then
|
||||
append_report "Dettaglio avvisi:"
|
||||
printf '%s\n' "${WARNINGS[@]}" >> "$REPORT_FILE"
|
||||
fi
|
||||
append_report "Fine: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
|
||||
build_telegram_summary
|
||||
|
||||
# 6. Reboot fisso di conclusione (salvo --no-reboot o REBOOT_ON_SUCCESS=false in conf)
|
||||
if [[ "$REBOOT_ON_SUCCESS" == true ]]; then
|
||||
log "Reboot host programmato tra ${REBOOT_DELAY_MIN} minuti (fine manutenzione settimanale)"
|
||||
append_report ""
|
||||
append_report "=== Reboot ==="
|
||||
append_report "Programmato tra ${REBOOT_DELAY_MIN} minuti"
|
||||
shutdown -r "+${REBOOT_DELAY_MIN}" "Manutenzione settimanale ${HOST_LABEL}" || {
|
||||
ERRORS+=("shutdown -r (reboot programmato)")
|
||||
log "✗ Impossibile programmare il reboot"
|
||||
}
|
||||
else
|
||||
log "Reboot disabilitato (--no-reboot o REBOOT_ON_SUCCESS=false)"
|
||||
append_report ""
|
||||
append_report "=== Reboot ==="
|
||||
append_report "Saltato (disabilitato)"
|
||||
fi
|
||||
|
||||
log "========== Fine manutenzione settimanale =========="
|
||||
exit $(( ${#ERRORS[@]} > 0 ? 1 : 0 ))
|
||||
@@ -207,6 +207,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
||||
except Exception as 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
|
||||
|
||||
def load_state() -> dict:
|
||||
|
||||
@@ -12,12 +12,15 @@ import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Optional, List, Tuple
|
||||
from typing import Any, Dict, Optional, List, Tuple
|
||||
|
||||
DEBUG = os.environ.get("DEBUG", "0").strip() == "1"
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
LOG_FILE = os.path.join(SCRIPT_DIR, "daily_report.log")
|
||||
_default_log = os.path.join(SCRIPT_DIR, "daily_report.log")
|
||||
if not os.access(SCRIPT_DIR, os.W_OK):
|
||||
_default_log = "/data/daily_report.log"
|
||||
LOG_FILE = os.environ.get("DAILY_REPORT_LOG", _default_log)
|
||||
|
||||
TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"]
|
||||
|
||||
@@ -215,24 +218,24 @@ def docker_copy_db_to_temp() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def generate_report(db_path: str) -> Optional[str]:
|
||||
def generate_report_data(db_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Aggrega gli speedtest completati nelle ultime 24 ore."""
|
||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
||||
window_start_utc = now_utc - datetime.timedelta(hours=24)
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# NOTA: non filtriamo su created_at (string compare fragile).
|
||||
# Prendiamo le ultime MAX_ROWS righe completate e filtriamo in Python.
|
||||
query = """
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT download, upload, ping, created_at
|
||||
FROM results
|
||||
WHERE status = 'completed'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
cursor.execute(query, (MAX_ROWS,))
|
||||
""",
|
||||
(MAX_ROWS,),
|
||||
)
|
||||
raw_rows = cursor.fetchall()
|
||||
except Exception as e:
|
||||
LOGGER.exception("Errore DB (%s): %s", db_path, e)
|
||||
@@ -247,13 +250,14 @@ def generate_report(db_path: str) -> Optional[str]:
|
||||
LOGGER.info("Nessun test trovato.")
|
||||
return None
|
||||
|
||||
# Filtra realmente per datetime (ultime 24h) e ordina crescente
|
||||
rows: List[Tuple[datetime.datetime, float, float, float]] = []
|
||||
rows: List[Dict[str, Any]] = []
|
||||
total_down = 0.0
|
||||
total_up = 0.0
|
||||
issues = 0
|
||||
|
||||
for d_raw, u_raw, ping_raw, created_at in raw_rows:
|
||||
dt_utc = _parse_created_at_utc(created_at)
|
||||
if not dt_utc:
|
||||
continue
|
||||
if dt_utc < window_start_utc or dt_utc > now_utc:
|
||||
if not dt_utc or dt_utc < window_start_utc or dt_utc > now_utc:
|
||||
continue
|
||||
|
||||
d_mbps = _to_mbps(d_raw)
|
||||
@@ -263,60 +267,75 @@ def generate_report(db_path: str) -> Optional[str]:
|
||||
except Exception:
|
||||
ping_ms = 0.0
|
||||
|
||||
rows.append((dt_utc, d_mbps, u_mbps, ping_ms))
|
||||
below = d_mbps < WARN_DOWN or u_mbps < WARN_UP
|
||||
if below:
|
||||
issues += 1
|
||||
|
||||
rows.sort(key=lambda x: x[0])
|
||||
total_down += d_mbps
|
||||
total_up += u_mbps
|
||||
rows.append({
|
||||
"time": dt_utc.astimezone().strftime("%H:%M"),
|
||||
"created_at": dt_utc.astimezone().isoformat(),
|
||||
"download_mbps": round(d_mbps, 1),
|
||||
"upload_mbps": round(u_mbps, 1),
|
||||
"ping_ms": round(ping_ms, 1),
|
||||
"below_threshold": below,
|
||||
})
|
||||
|
||||
LOGGER.debug("DB rows read=%s filtered_24h=%s (start=%s now=%s)",
|
||||
len(raw_rows), len(rows),
|
||||
window_start_utc.isoformat(timespec="seconds"),
|
||||
now_utc.isoformat(timespec="seconds"))
|
||||
rows.sort(key=lambda r: r["created_at"])
|
||||
|
||||
if not rows:
|
||||
LOGGER.info("Nessun test nelle ultime 24h dopo filtro datetime.")
|
||||
return None
|
||||
|
||||
header = "ORA | Dn | Up | Pg |!"
|
||||
sep = "-----+-----+-----+----+-"
|
||||
|
||||
total_down = 0.0
|
||||
total_up = 0.0
|
||||
count = 0
|
||||
issues = 0
|
||||
|
||||
count = len(rows)
|
||||
avg_d = total_down / count
|
||||
avg_u = total_up / count
|
||||
now_local = datetime.datetime.now()
|
||||
msg = f"📊 **REPORT VELOCITÀ 24H**\n📅 {now_local.strftime('%d/%m/%Y')}\n\n"
|
||||
|
||||
return {
|
||||
"date": now_local.strftime("%d/%m/%Y"),
|
||||
"window_hours": 24,
|
||||
"count": count,
|
||||
"issues": issues,
|
||||
"warn_download_mbps": WARN_DOWN,
|
||||
"warn_upload_mbps": WARN_UP,
|
||||
"avg_download_mbps": round(avg_d, 1),
|
||||
"avg_upload_mbps": round(avg_u, 1),
|
||||
"avg_download_ok": avg_d >= WARN_DOWN,
|
||||
"avg_upload_ok": avg_u >= WARN_UP,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def generate_report(db_path: str) -> Optional[str]:
|
||||
data = generate_report_data(db_path)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
header = "ORA | Dn | Up | Pg |!"
|
||||
sep = "-----+-----+-----+----+-"
|
||||
|
||||
msg = f"📊 **REPORT VELOCITÀ 24H**\n📅 {data['date']}\n\n"
|
||||
msg += "```text\n"
|
||||
msg += header + "\n"
|
||||
msg += sep + "\n"
|
||||
|
||||
for dt_utc, d_mbps, u_mbps, ping_ms in rows:
|
||||
total_down += d_mbps
|
||||
total_up += u_mbps
|
||||
count += 1
|
||||
|
||||
flag = " "
|
||||
if d_mbps < WARN_DOWN or u_mbps < WARN_UP:
|
||||
issues += 1
|
||||
flag = "!"
|
||||
|
||||
time_str = dt_utc.astimezone().strftime("%H:%M")
|
||||
|
||||
msg += f"{time_str:<5}|{int(round(d_mbps)):>5}|{int(round(u_mbps)):>5}|{int(round(ping_ms)):>4}|{flag}\n"
|
||||
for row in data["rows"]:
|
||||
flag = "!" if row["below_threshold"] else " "
|
||||
msg += (
|
||||
f"{row['time']:<5}|{int(round(row['download_mbps'])):>5}|"
|
||||
f"{int(round(row['upload_mbps'])):>5}|{int(round(row['ping_ms'])):>4}|{flag}\n"
|
||||
)
|
||||
|
||||
msg += "```\n"
|
||||
|
||||
avg_d = total_down / count
|
||||
avg_u = total_up / count
|
||||
icon_d = "✅" if data["avg_download_ok"] else "⚠️"
|
||||
icon_u = "✅" if data["avg_upload_ok"] else "⚠️"
|
||||
msg += f"Ø ⬇️{icon_d}`{data['avg_download_mbps']:.0f} Mbps` ⬆️{icon_u}`{data['avg_upload_mbps']:.0f} Mbps`"
|
||||
|
||||
icon_d = "✅" if avg_d >= WARN_DOWN else "⚠️"
|
||||
icon_u = "✅" if avg_u >= WARN_UP else "⚠️"
|
||||
|
||||
msg += f"Ø ⬇️{icon_d}`{avg_d:.0f} Mbps` ⬆️{icon_u}`{avg_u:.0f} Mbps`"
|
||||
|
||||
if issues > 0:
|
||||
msg += f"\n\n⚠️ **{issues}** test sotto soglia (!)"
|
||||
if data["issues"] > 0:
|
||||
msg += f"\n\n⚠️ **{data['issues']}** test sotto soglia (!)"
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@@ -692,6 +693,85 @@ def telegram_send_message(text: str, chat_id: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def build_fotovoltaico_json(
|
||||
lat: float = HOME_LAT,
|
||||
lon: float = HOME_LON,
|
||||
maps_dir: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Analisi completa per WebApp (senza Telegram)."""
|
||||
now = datetime.now(TZINFO)
|
||||
payload: Dict[str, Any] = {
|
||||
"updated_at": now.strftime("%d/%m/%Y %H:%M"),
|
||||
"plant": {
|
||||
"kwp": round(P_PEAK_TOTAL_W / 1000, 2),
|
||||
"inverter_kw": INVERTER_KW,
|
||||
"panels": NUM_PANELS,
|
||||
},
|
||||
"maps": {"past": None, "future": None},
|
||||
"past_days": [],
|
||||
"future_days": [],
|
||||
"models": [],
|
||||
"has_real": False,
|
||||
}
|
||||
|
||||
solaredge_api_key, solaredge_site_id = load_solaredge_config()
|
||||
real_48h: Optional[Tuple[List[datetime], List[float]]] = None
|
||||
if solaredge_api_key and solaredge_site_id:
|
||||
real_48h = fetch_solaredge_energy_48h(solaredge_api_key, solaredge_site_id)
|
||||
payload["has_real"] = bool(real_48h)
|
||||
|
||||
hist = fetch_historical_48h(lat, lon)
|
||||
if hist:
|
||||
times_past, _, power_past = hist
|
||||
past_days = kwh_per_day_from_series(times_past, power_past)
|
||||
real_by_date: Dict[str, float] = {}
|
||||
if real_48h:
|
||||
real_days = kwh_per_day_from_series(
|
||||
[t.isoformat() for t in real_48h[0]],
|
||||
real_48h[1],
|
||||
)
|
||||
real_by_date = {d: k for d, k in real_days}
|
||||
for label, kwh in past_days:
|
||||
row: Dict[str, Any] = {"date": label, "forecast_kwh": kwh}
|
||||
if label in real_by_date:
|
||||
row["real_kwh"] = real_by_date[label]
|
||||
payload["past_days"].append(row)
|
||||
if maps_dir:
|
||||
os.makedirs(maps_dir, exist_ok=True)
|
||||
rt, rp = (real_48h[0], real_48h[1]) if real_48h else (None, None)
|
||||
img_past = plot_past_48h(times_past, power_past, real_times=rt, real_power_kw=rp)
|
||||
with open(os.path.join(maps_dir, "fotovoltaico_past.png"), "wb") as f:
|
||||
f.write(img_past)
|
||||
payload["maps"]["past"] = "past"
|
||||
|
||||
fore_multi = fetch_forecast_72h_multi(lat, lon)
|
||||
if fore_multi:
|
||||
times_fut, power_series = fore_multi
|
||||
payload["models"] = [s[0] for s in power_series]
|
||||
fut_days_per_model = [
|
||||
(name, kwh_per_day_from_series(times_fut, power_list))
|
||||
for name, power_list in power_series
|
||||
]
|
||||
dates = sorted({d for _, days_list in fut_days_per_model for d, _ in days_list})
|
||||
for d in dates:
|
||||
row = {"date": d, "models": []}
|
||||
for name, days_list in fut_days_per_model:
|
||||
val = next((k for lbl, k in days_list if lbl == d), None)
|
||||
if val is not None:
|
||||
row["models"].append({"name": name, "kwh": val})
|
||||
payload["future_days"].append(row)
|
||||
if maps_dir:
|
||||
os.makedirs(maps_dir, exist_ok=True)
|
||||
img_fut = plot_future_72h(times_fut, power_series)
|
||||
with open(os.path.join(maps_dir, "fotovoltaico_future.png"), "wb") as f:
|
||||
f.write(img_fut)
|
||||
payload["maps"]["future"] = "future"
|
||||
|
||||
if not payload["past_days"] and not payload["future_days"]:
|
||||
payload["error"] = "Nessun dato disponibile"
|
||||
return payload
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Previsione e analisi produzione fotovoltaico (ICON Italia)")
|
||||
parser.add_argument("--lat", type=float, default=HOME_LAT, help="Latitudine")
|
||||
@@ -700,7 +780,15 @@ def main() -> int:
|
||||
parser.add_argument("--chat_id", type=str, default="", help="Chat ID per invio Telegram")
|
||||
parser.add_argument("--no-past", action="store_true", help="Salta grafico 48h passate")
|
||||
parser.add_argument("--no-future", action="store_true", help="Salta grafico 72h future")
|
||||
parser.add_argument("--json", action="store_true", help="Output JSON per WebApp (no Telegram)")
|
||||
parser.add_argument("--maps-dir", type=str, default="", help="Directory per salvare grafici PNG (con --json)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.json:
|
||||
maps_dir = args.maps_dir.strip() or None
|
||||
print(json.dumps(build_fotovoltaico_json(args.lat, args.lon, maps_dir=maps_dir), ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
lat, lon = args.lat, args.lon
|
||||
send_telegram = args.telegram and args.chat_id.strip()
|
||||
|
||||
|
||||
+107
-12
@@ -6,20 +6,29 @@ import sys
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional, List
|
||||
import json
|
||||
from typing import Optional, List, Dict, Any, Union
|
||||
from zoneinfo import ZoneInfo
|
||||
from dateutil import parser as date_parser
|
||||
from open_meteo_client import open_meteo_get
|
||||
from open_meteo_precip import (
|
||||
CASA_LAT,
|
||||
CASA_LON,
|
||||
CASA_TZ,
|
||||
daily_precip_from_hourly,
|
||||
hourly_precip_mm,
|
||||
is_casa,
|
||||
)
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- CONFIGURAZIONE METEO ---
|
||||
HOME_LAT = 43.9356
|
||||
HOME_LON = 12.4296
|
||||
HOME_LAT = CASA_LAT
|
||||
HOME_LON = CASA_LON
|
||||
HOME_NAME = "🏠 Casa"
|
||||
TZ = "Europe/Berlin"
|
||||
TZ = CASA_TZ
|
||||
TZINFO = ZoneInfo(TZ)
|
||||
|
||||
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
@@ -300,10 +309,10 @@ def get_visibility_forecast(lat, lon):
|
||||
logger.error("Visibility request error: %s elapsed=%.2fs", e, time.time() - t0)
|
||||
return None
|
||||
|
||||
def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", timezone=None) -> str:
|
||||
def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", timezone=None, as_json=False) -> Union[str, Dict[str, Any]]:
|
||||
t_total = time.time()
|
||||
# Determina se è Casa
|
||||
is_home = (abs(lat - HOME_LAT) < 0.01 and abs(lon - HOME_LON) < 0.01)
|
||||
is_home = is_casa(lat, lon)
|
||||
|
||||
# Fuso per l'API: Casa = TZ; località = timezone esplicito/geocoding, altrimenti "auto" (Open-Meteo risolve da lat/lon)
|
||||
tz_for_api = timezone if timezone else (TZ if is_home else "auto")
|
||||
@@ -446,6 +455,8 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
|
||||
|
||||
# Separa in blocchi per giorno: cambia intestazione quando passa da 23 a 00
|
||||
blocks = []
|
||||
json_blocks: List[Dict[str, Any]] = []
|
||||
json_block_map: Dict[str, Dict[str, Any]] = {}
|
||||
header = f"{'LT':<2} {'T°':>4} {'h%':>3} {'mm':<3} {'Vento':<5} {'Nv%':>5} {'Sk':<2} {'Sx':<2}"
|
||||
separator = "-" * 31
|
||||
|
||||
@@ -506,8 +517,7 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
|
||||
Code = int(get_val(l_code[idx], 0))
|
||||
Rain = get_val(l_rain[idx], 0)
|
||||
Showers = get_val(l_showers[idx], 0) if idx < len(l_showers) else 0
|
||||
# Per modelli che espongono rain+showers (es. ICON Italia), usa il totale se precipitation è assente/zero
|
||||
Pr_display = max(Pr, Rain + Showers)
|
||||
Pr_display = hourly_precip_mm(Pr, Rain, Showers)
|
||||
|
||||
# Determina se è neve
|
||||
is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86])
|
||||
@@ -583,6 +593,39 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
|
||||
|
||||
current_block_lines.append(f"{dt.strftime('%H'):<2} {t_s:>4} {Rh:>3} {p_s:>3} {w_fmt} {cl_str:>5} {sky_fmt:<2} {sgx:<2}")
|
||||
|
||||
day_key = day_date.isoformat()
|
||||
if day_key not in json_block_map:
|
||||
day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][day_date.weekday()]} {day_date.day}"
|
||||
json_block_map[day_key] = {"day_label": day_label, "date": day_key, "rows": []}
|
||||
json_blocks.append(json_block_map[day_key])
|
||||
json_block_map[day_key]["rows"].append({
|
||||
"hour": dt.strftime("%H"),
|
||||
"datetime": dt.isoformat(),
|
||||
"temp_c": round(T, 1),
|
||||
"temp_display": t_s,
|
||||
"feels_offset": t_suffix,
|
||||
"humidity": Rh,
|
||||
"precip_mm": round(Pr_display, 1),
|
||||
"precip_display": p_s,
|
||||
"snow_cm": round(Sn, 1),
|
||||
"weathercode": Code,
|
||||
"wind_speed_kmh": round(Wspd, 0),
|
||||
"wind_gust_kmh": round(Gust, 0),
|
||||
"wind_display": w_txt.strip(),
|
||||
"wind_cardinal": card,
|
||||
"cloud_pct": cl_str,
|
||||
"cloud_type": dominant_type,
|
||||
"visibility_m": round(Vis, 0),
|
||||
"uv_index": round(UV, 1),
|
||||
"uv_suffix": uv_suffix,
|
||||
"cape": round(Cape, 0),
|
||||
"is_day": bool(IsDay),
|
||||
"sky_icon": sky_fmt,
|
||||
"side_icon": sgx,
|
||||
"is_fog": is_fog,
|
||||
"is_snow": is_snowing,
|
||||
})
|
||||
|
||||
hours_from_start += 1
|
||||
|
||||
# Chiudi ultimo blocco (solo se ha contenuto oltre header e separator)
|
||||
@@ -593,7 +636,49 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT",
|
||||
if not blocks:
|
||||
return f"❌ Nessun dato da mostrare nelle prossime 48 ore (da {current_hour.strftime('%H:%M')})."
|
||||
|
||||
report = f"🌤️ *METEO REPORT*\n📍 {location_name}\n🧠 Fonte: {model_name}\n\n" + "\n\n".join(blocks)
|
||||
daily_totals = daily_precip_from_hourly(hourly_c)
|
||||
today_str = datetime.datetime.now(tz_to_use_info).date().isoformat()
|
||||
tomorrow_str = (datetime.datetime.now(tz_to_use_info).date() + datetime.timedelta(days=1)).isoformat()
|
||||
today_mm = daily_totals.get(today_str, 0.0)
|
||||
tomorrow_mm = daily_totals.get(tomorrow_str, 0.0)
|
||||
totals_line = (
|
||||
f"\n\n💧 *Precip cumulata:* oggi {today_mm:.1f} mm | domani {tomorrow_mm:.1f} mm"
|
||||
)
|
||||
|
||||
legend = {
|
||||
"temp": "W=wind chill, H=heat index",
|
||||
"precip": "G=grandine, Z=ghiacciato, N=neve",
|
||||
"cloud": "FOG=nebbia",
|
||||
"sky": "Icona condizioni (☀️🌧️⛈️…)",
|
||||
"sx": "☃️ neve · 🧊 ghiaccio · ⚡/🌪️ temporali · 🥵 caldo · ☔️ pioggia · 💨 vento forte",
|
||||
"uv": "E=UV estremo, H=UV alto",
|
||||
}
|
||||
|
||||
if as_json:
|
||||
return {
|
||||
"location": location_name,
|
||||
"model": model_name,
|
||||
"timezone": tz_to_use,
|
||||
"blocks": json_blocks,
|
||||
"precip_totals": {"today_mm": round(today_mm, 1), "tomorrow_mm": round(tomorrow_mm, 1)},
|
||||
"legend": legend,
|
||||
"columns": [
|
||||
{"key": "hour", "label": "LT", "title": "Ora locale"},
|
||||
{"key": "temp_display", "label": "T°", "title": "Temperatura"},
|
||||
{"key": "humidity", "label": "h%", "title": "Umidità"},
|
||||
{"key": "precip_display", "label": "mm", "title": "Precipitazioni orarie"},
|
||||
{"key": "wind_display", "label": "Vento", "title": "Direzione e intensità (km/h)"},
|
||||
{"key": "cloud_pct", "label": "Nv%", "title": "Copertura nuvolosa"},
|
||||
{"key": "sky_icon", "label": "Sk", "title": "Cielo"},
|
||||
{"key": "side_icon", "label": "Sx", "title": "Indicatori secondari"},
|
||||
],
|
||||
}
|
||||
|
||||
report = (
|
||||
f"🌤️ *METEO REPORT*\n📍 {location_name}\n🧠 Fonte: {model_name}\n\n"
|
||||
+ "\n\n".join(blocks)
|
||||
+ totals_line
|
||||
)
|
||||
logger.info("generate_weather_report ok elapsed=%.2fs", time.time() - t_total)
|
||||
return report
|
||||
|
||||
@@ -604,6 +689,7 @@ if __name__ == "__main__":
|
||||
args_parser.add_argument("--debug", action="store_true", help="Mostra dettaglio debug (nuvole, neve)")
|
||||
args_parser.add_argument("--chat_id", help="Chat ID Telegram per invio diretto (opzionale, può essere multiplo separato da virgola)")
|
||||
args_parser.add_argument("--timezone", help="Timezone IANA (es: Europe/Rome, America/New_York)")
|
||||
args_parser.add_argument("--json", action="store_true", help="Output JSON strutturato (WebApp)")
|
||||
args = args_parser.parse_args()
|
||||
|
||||
# Determina chat_ids se specificato
|
||||
@@ -613,14 +699,21 @@ if __name__ == "__main__":
|
||||
|
||||
# Genera report
|
||||
report = None
|
||||
json_out = None
|
||||
if args.home:
|
||||
report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM")
|
||||
if args.json:
|
||||
json_out = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM", as_json=True)
|
||||
else:
|
||||
report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM")
|
||||
elif args.query:
|
||||
coords = get_coordinates(args.query)
|
||||
if coords:
|
||||
lat, lon, name, cc, geo_tz = coords
|
||||
tz = args.timezone or geo_tz
|
||||
report = generate_weather_report(lat, lon, name, args.debug, cc, timezone=tz)
|
||||
if args.json:
|
||||
json_out = generate_weather_report(lat, lon, name, args.debug, cc, timezone=tz, as_json=True)
|
||||
else:
|
||||
report = generate_weather_report(lat, lon, name, args.debug, cc, timezone=tz)
|
||||
else:
|
||||
error_msg = f"❌ Città '{args.query}' non trovata."
|
||||
if chat_ids:
|
||||
@@ -637,7 +730,9 @@ if __name__ == "__main__":
|
||||
sys.exit(1)
|
||||
|
||||
# Invia o stampa
|
||||
if chat_ids:
|
||||
if args.json and json_out:
|
||||
print(json.dumps(json_out, ensure_ascii=False))
|
||||
elif chat_ids:
|
||||
telegram_send_markdown(report, chat_ids)
|
||||
else:
|
||||
print(report)
|
||||
@@ -34,12 +34,24 @@ def log_line(message: str) -> None:
|
||||
pass
|
||||
|
||||
def send_telegram(msg, chat_ids: Optional[List[str]] = None):
|
||||
"""
|
||||
Args:
|
||||
msg: Messaggio da inviare
|
||||
chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS)
|
||||
"""
|
||||
if not BOT_TOKEN or "INSERISCI" in BOT_TOKEN: return
|
||||
"""Invia alert su Telegram e/o WebApp via alert_dispatcher."""
|
||||
try:
|
||||
import sys
|
||||
sys.path.insert(0, "/home/daniely/docker/shared")
|
||||
from loogle_core.alert_dispatcher import dispatch_alert
|
||||
plain = msg.replace("*", "").replace("_", "").replace("`", "")
|
||||
dispatch_alert(
|
||||
"Degrado qualità linea",
|
||||
plain,
|
||||
category="net_quality",
|
||||
severity="warning",
|
||||
telegram_text=msg,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
if not BOT_TOKEN or "INSERISCI" in BOT_TOKEN:
|
||||
return
|
||||
if chat_ids is None:
|
||||
chat_ids = TELEGRAM_CHAT_IDS
|
||||
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
|
||||
|
||||
@@ -158,6 +158,15 @@ def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -
|
||||
except Exception as 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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Precipitazione unificata Open-Meteo per Casa (San Marino).
|
||||
|
||||
Reference 0–72 h: ICON Italia (ARPAE 2i). Totale orario = max(precipitation, rain + showers).
|
||||
|
||||
Copia autosufficiente per il container loogle-bot (/app); la sorgente condivisa
|
||||
resta in docker/shared/open_meteo/open_meteo_precip.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
try:
|
||||
from open_meteo_client import open_meteo_get
|
||||
except ImportError:
|
||||
import requests
|
||||
|
||||
def open_meteo_get(url, params=None, headers=None, timeout=(5, 25), retries=3, backoff=0.8):
|
||||
return requests.get(url, params=params, headers=headers or {}, timeout=timeout)
|
||||
|
||||
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
DEFAULT_HEADERS = {"User-Agent": "open-meteo-precip/1.0"}
|
||||
|
||||
CASA_LAT = 43.9356
|
||||
CASA_LON = 12.4296
|
||||
CASA_TZ = "Europe/Rome"
|
||||
ICON_ITALIA_MODEL = "italia_meteo_arpae_icon_2i"
|
||||
|
||||
ICON_HOURLY_VARS = (
|
||||
"precipitation,rain,showers,snowfall,weathercode,"
|
||||
"temperature_2m,windspeed_10m,winddirection_10m"
|
||||
)
|
||||
ICON_DAILY_VARS = (
|
||||
"precipitation_sum,rain_sum,showers_sum,snowfall_sum,weathercode,"
|
||||
"temperature_2m_min,temperature_2m_max"
|
||||
)
|
||||
|
||||
PRECIP_HOURLY_KEYS = ("precipitation", "rain", "showers", "snowfall")
|
||||
PRECIP_DAILY_KEYS = ("precipitation_sum", "rain_sum", "showers_sum", "snowfall_sum", "precipitation_hours")
|
||||
|
||||
|
||||
def is_casa(lat: float, lon: float, tol: float = 0.01) -> bool:
|
||||
return abs(lat - CASA_LAT) < tol and abs(lon - CASA_LON) < tol
|
||||
|
||||
|
||||
def hourly_precip_mm(
|
||||
precipitation: Optional[float] = None,
|
||||
rain: Optional[float] = None,
|
||||
showers: Optional[float] = None,
|
||||
) -> float:
|
||||
p = float(precipitation or 0)
|
||||
r = float(rain or 0)
|
||||
s = float(showers or 0)
|
||||
return max(p, r + s)
|
||||
|
||||
|
||||
def hourly_precip_at_index(hourly: Dict, index: int) -> float:
|
||||
def g(key: str) -> Optional[float]:
|
||||
arr = hourly.get(key) or []
|
||||
if index >= len(arr):
|
||||
return None
|
||||
v = arr[index]
|
||||
return float(v) if v is not None else None
|
||||
|
||||
return hourly_precip_mm(g("precipitation"), g("rain"), g("showers"))
|
||||
|
||||
|
||||
def hourly_precip_series(hourly: Dict) -> List[float]:
|
||||
times = hourly.get("time") or []
|
||||
return [hourly_precip_at_index(hourly, i) for i in range(len(times))]
|
||||
|
||||
|
||||
def daily_precip_from_hourly(hourly: Dict) -> Dict[str, float]:
|
||||
times = hourly.get("time") or []
|
||||
out: Dict[str, float] = defaultdict(float)
|
||||
for i, t in enumerate(times):
|
||||
if not t:
|
||||
continue
|
||||
out[str(t)[:10]] += hourly_precip_at_index(hourly, i)
|
||||
return dict(out)
|
||||
|
||||
|
||||
def daily_precip_sum(daily: Dict, date_str: str) -> Optional[float]:
|
||||
times = daily.get("time") or []
|
||||
arr = daily.get("precipitation_sum") or []
|
||||
for i, t in enumerate(times):
|
||||
if str(t)[:10] == date_str[:10] and i < len(arr) and arr[i] is not None:
|
||||
return float(arr[i])
|
||||
return None
|
||||
|
||||
|
||||
def fetch_icon_italia(
|
||||
lat: float,
|
||||
lon: float,
|
||||
tz: str = CASA_TZ,
|
||||
forecast_days: int = 10,
|
||||
past_days: int = 0,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Optional[Dict]:
|
||||
params = {
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"timezone": tz,
|
||||
"forecast_days": forecast_days,
|
||||
"models": ICON_ITALIA_MODEL,
|
||||
"hourly": ICON_HOURLY_VARS,
|
||||
"daily": ICON_DAILY_VARS,
|
||||
}
|
||||
if past_days:
|
||||
params["past_days"] = past_days
|
||||
try:
|
||||
resp = open_meteo_get(
|
||||
OPEN_METEO_URL,
|
||||
params=params,
|
||||
headers=headers or DEFAULT_HEADERS,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _index_by_time(section: Dict) -> Dict[str, int]:
|
||||
return {str(t): i for i, t in enumerate(section.get("time") or [])}
|
||||
|
||||
|
||||
def overlay_icon_precip_on_hourly(target: Dict, icon_hourly: Dict) -> Dict:
|
||||
if not target or not icon_hourly:
|
||||
return target
|
||||
tgt_idx = _index_by_time(target)
|
||||
icon_idx = _index_by_time(icon_hourly)
|
||||
out = dict(target)
|
||||
for key in PRECIP_HOURLY_KEYS:
|
||||
arr = list(out.get(key) or [None] * len(out.get("time") or []))
|
||||
while len(arr) < len(out.get("time") or []):
|
||||
arr.append(None)
|
||||
icon_arr = icon_hourly.get(key) or []
|
||||
for t, i in tgt_idx.items():
|
||||
j = icon_idx.get(t)
|
||||
if j is not None and j < len(icon_arr):
|
||||
arr[i] = icon_arr[j]
|
||||
out[key] = arr
|
||||
times = out.get("time") or []
|
||||
out["precipitation"] = [hourly_precip_at_index(out, i) for i in range(len(times))]
|
||||
return out
|
||||
|
||||
|
||||
def overlay_icon_precip_on_daily(target: Dict, icon_daily: Dict) -> Dict:
|
||||
if not target or not icon_daily:
|
||||
return target
|
||||
tgt_idx = {str(t)[:10]: i for i, t in enumerate(target.get("time") or [])}
|
||||
icon_idx = {str(t)[:10]: i for i, t in enumerate(icon_daily.get("time") or [])}
|
||||
out = dict(target)
|
||||
for key in PRECIP_DAILY_KEYS:
|
||||
arr = list(out.get(key) or [None] * len(out.get("time") or []))
|
||||
while len(arr) < len(out.get("time") or []):
|
||||
arr.append(None)
|
||||
icon_arr = icon_daily.get(key) or []
|
||||
for d, i in tgt_idx.items():
|
||||
j = icon_idx.get(d)
|
||||
if j is not None and j < len(icon_arr):
|
||||
arr[i] = icon_arr[j]
|
||||
out[key] = arr
|
||||
times = out.get("time") or []
|
||||
psum = list(out.get("precipitation_sum") or [None] * len(times))
|
||||
while len(psum) < len(times):
|
||||
psum.append(None)
|
||||
for d, j in icon_idx.items():
|
||||
i = tgt_idx.get(d)
|
||||
if i is None:
|
||||
continue
|
||||
arr = icon_daily.get("precipitation_sum") or []
|
||||
if j < len(arr) and arr[j] is not None:
|
||||
psum[i] = float(arr[j])
|
||||
out["precipitation_sum"] = psum
|
||||
return out
|
||||
|
||||
|
||||
def icon_precip_daily_totals(icon_data: Optional[Dict]) -> Dict[str, float]:
|
||||
if not icon_data:
|
||||
return {}
|
||||
hourly = icon_data.get("hourly") or {}
|
||||
if hourly.get("time"):
|
||||
totals = daily_precip_from_hourly(hourly)
|
||||
if totals:
|
||||
return totals
|
||||
daily = icon_data.get("daily") or {}
|
||||
out: Dict[str, float] = {}
|
||||
for i, t in enumerate(daily.get("time") or []):
|
||||
arr = daily.get("precipitation_sum") or []
|
||||
if i < len(arr) and arr[i] is not None:
|
||||
out[str(t)[:10]] = float(arr[i])
|
||||
return out
|
||||
@@ -8,19 +8,32 @@ import argparse
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from zoneinfo import ZoneInfo
|
||||
from collections import defaultdict, Counter, Counter
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from statistics import mean, median
|
||||
from open_meteo_client import open_meteo_get
|
||||
from open_meteo_precip import (
|
||||
CASA_LAT,
|
||||
CASA_LON,
|
||||
CASA_TZ,
|
||||
daily_precip_from_hourly,
|
||||
fetch_icon_italia,
|
||||
hourly_precip_at_index,
|
||||
hourly_precip_series,
|
||||
is_casa,
|
||||
overlay_icon_precip_on_daily,
|
||||
overlay_icon_precip_on_hourly,
|
||||
)
|
||||
|
||||
# --- CONFIGURAZIONE DEFAULT ---
|
||||
DEFAULT_LAT = 43.9356
|
||||
DEFAULT_LON = 12.4296
|
||||
DEFAULT_LAT = CASA_LAT
|
||||
DEFAULT_LON = CASA_LON
|
||||
DEFAULT_NAME = "🏠 Casa (Strada Cà Toro,12 - San Marino)"
|
||||
|
||||
# --- TIMEZONE ---
|
||||
TZ_STR = "Europe/Berlin"
|
||||
TZ_STR = CASA_TZ
|
||||
TZINFO = ZoneInfo(TZ_STR)
|
||||
|
||||
# --- TELEGRAM CONFIG ---
|
||||
@@ -34,6 +47,10 @@ TOKEN_FILE_VOLUME = "/Volumes/Pi2/etc/telegram_dpc_bot_token"
|
||||
SOGLIA_VENTO_KMH = 40.0
|
||||
MIN_MM_PER_EVENTO = 0.1
|
||||
|
||||
# Giorni mostrati in tabella / WebApp (previsione 7 giorni)
|
||||
DISPLAY_FORECAST_DAYS = 7
|
||||
GIORNI_ITA_SHORT = ["Lun", "Mar", "Mer", "Gio", "Ven", "Sab", "Dom"]
|
||||
|
||||
# --- MODELLI METEO ---
|
||||
# Modelli a breve termine (alta risoluzione, 48-72h)
|
||||
SHORT_TERM_MODELS = ["meteofrance_seamless", "icon_d2"] # Usa seamless invece di arome_france_hd
|
||||
@@ -235,6 +252,7 @@ def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forec
|
||||
results[model] = None
|
||||
|
||||
# Recupera modelli a lungo termine (3-10d): tre modelli per mediana (come Agent Irrigazione)
|
||||
short_set = set(short_term_models or [])
|
||||
for model in (long_term_models or []):
|
||||
url = "https://api.open-meteo.com/v1/forecast"
|
||||
fd_long = LONG_TERM_FORECAST_DAYS.get(model, forecast_days)
|
||||
@@ -276,12 +294,15 @@ def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forec
|
||||
snow_depth_cm.append(None)
|
||||
hourly_data["snow_depth"] = snow_depth_cm
|
||||
data["hourly"] = hourly_data
|
||||
results[model] = data
|
||||
results[model]["model_type"] = "long_term"
|
||||
lt_key = f"{model}__long" if model in short_set else model
|
||||
results[lt_key] = data
|
||||
results[lt_key]["model_type"] = "long_term"
|
||||
else:
|
||||
results[model] = None
|
||||
lt_key = f"{model}__long" if model in short_set else model
|
||||
results[lt_key] = None
|
||||
except Exception:
|
||||
results[model] = None
|
||||
lt_key = f"{model}__long" if model in short_set else model
|
||||
results[lt_key] = None
|
||||
|
||||
return results
|
||||
|
||||
@@ -303,9 +324,13 @@ def _median_or_single(values):
|
||||
return median(nums)
|
||||
|
||||
|
||||
# Chiavi che esistono solo su ICON Italia (no merge, si tiene il valore da quel modello)
|
||||
HOURLY_KEYS_ICON_ONLY = ["snow_depth", "showers"]
|
||||
DAILY_KEYS_ICON_ONLY = ["showers_sum"]
|
||||
# Chiavi solo ICON Italia (precip 0–2d: niente mediana con AROME HD a San Marino)
|
||||
HOURLY_KEYS_ICON_ONLY = [
|
||||
"snow_depth", "showers", "precipitation", "rain", "snowfall",
|
||||
]
|
||||
DAILY_KEYS_ICON_ONLY = [
|
||||
"showers_sum", "precipitation_sum", "rain_sum", "snowfall_sum", "precipitation_hours",
|
||||
]
|
||||
|
||||
|
||||
def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source_model=None):
|
||||
@@ -614,6 +639,23 @@ def merge_multi_model_forecast(models_data, forecast_days=10):
|
||||
|
||||
return merged
|
||||
|
||||
def format_day_label(day_index: int, daily_time_list, with_relative: bool = True) -> str:
|
||||
"""Etichetta calendario per indice giorno 0-based (es. Lun 12/07)."""
|
||||
if daily_time_list and 0 <= day_index < len(daily_time_list):
|
||||
raw = str(daily_time_list[day_index]).split("T")[0]
|
||||
try:
|
||||
dt = datetime.datetime.strptime(raw, "%Y-%m-%d")
|
||||
cal = f"{GIORNI_ITA_SHORT[dt.weekday()]} {dt.strftime('%d/%m')}"
|
||||
if with_relative:
|
||||
if day_index == 0:
|
||||
return f"oggi ({cal})"
|
||||
if day_index == 1:
|
||||
return f"domani ({cal})"
|
||||
return cal
|
||||
except ValueError:
|
||||
pass
|
||||
return f"giorno {day_index + 1}"
|
||||
|
||||
def analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10):
|
||||
"""Analizza trend temperatura per identificare fronti caldi/freddi con dettaglio completo"""
|
||||
if not daily_temps_max or not daily_temps_min:
|
||||
@@ -731,7 +773,7 @@ def analyze_weather_transitions(daily_weathercodes):
|
||||
if code in (95, 96, 99): return "temporale"
|
||||
return "variabile"
|
||||
|
||||
for i in range(1, min(len(daily_weathercodes), 8)):
|
||||
for i in range(1, min(len(daily_weathercodes), DISPLAY_FORECAST_DAYS)):
|
||||
prev_code = daily_weathercodes[i-1] if i-1 < len(daily_weathercodes) else None
|
||||
curr_code = daily_weathercodes[i] if i < len(daily_weathercodes) else None
|
||||
prev_cat = get_category(prev_code)
|
||||
@@ -1014,13 +1056,13 @@ def generate_practical_advice(trend, transitions, events_summary, daily_data):
|
||||
|
||||
return advice
|
||||
|
||||
def format_detailed_trend_explanation(trend, daily_data_list):
|
||||
"""Genera spiegazione dettagliata del trend temperatura su 10 giorni"""
|
||||
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."""
|
||||
if not trend:
|
||||
return ""
|
||||
|
||||
explanation = []
|
||||
explanation.append(f"📊 <b>EVOLUZIONE TEMPERATURE (10 GIORNI)</b>\n")
|
||||
explanation.append(f"📊 <b>EVOLUZIONE TEMPERATURE ({display_days} GIORNI)</b>\n")
|
||||
|
||||
# Trend principale con spiegazione chiara
|
||||
trend_type = trend["type"]
|
||||
@@ -1052,13 +1094,16 @@ def format_detailed_trend_explanation(trend, daily_data_list):
|
||||
explanation.append(f"{trend_desc}{intensity_text}")
|
||||
explanation.append(f"{desc_text}")
|
||||
|
||||
# Aggiungi solo picchi significativi in modo sintetico
|
||||
# Aggiungi solo picchi significativi in modo sintetico (entro i giorni in tabella)
|
||||
if trend.get("change_days"):
|
||||
significant_changes = [c for c in trend["change_days"] if abs(c['delta']) > 3.0][:3]
|
||||
significant_changes = [
|
||||
c for c in trend["change_days"]
|
||||
if abs(c["delta"]) > 3.0 and c["day"] < display_days
|
||||
][:3]
|
||||
if significant_changes:
|
||||
change_texts = []
|
||||
for change in significant_changes:
|
||||
day_name = f"Giorno {change['day']+1}"
|
||||
day_name = format_day_label(change["day"], daily_time_list or [])
|
||||
direction = "↑" if change['delta'] > 0 else "↓"
|
||||
change_texts.append(f"{direction} {day_name}: {change['from']:.0f}°→{change['to']:.0f}°C")
|
||||
if change_texts:
|
||||
@@ -1068,7 +1113,35 @@ def format_detailed_trend_explanation(trend, daily_data_list):
|
||||
|
||||
return "\n".join(explanation)
|
||||
|
||||
def format_weather_context_report(models_data, location_name, country_code):
|
||||
def _apply_unified_precip(hourly: Dict, daily: Dict, casa: bool) -> Tuple[Dict, Dict]:
|
||||
"""Precip oraria/giornaliera da ICON Italia (ARPAE 2i) per Casa."""
|
||||
if not casa or not hourly.get("time"):
|
||||
return hourly, daily
|
||||
hourly = dict(hourly)
|
||||
daily = dict(daily)
|
||||
icon = fetch_icon_italia(CASA_LAT, CASA_LON, CASA_TZ, forecast_days=10)
|
||||
if icon:
|
||||
icon_h = icon.get("hourly") or {}
|
||||
icon_d = icon.get("daily") or {}
|
||||
if icon_h.get("time"):
|
||||
hourly = overlay_icon_precip_on_hourly(hourly, icon_h)
|
||||
if icon_d.get("time"):
|
||||
daily = overlay_icon_precip_on_daily(daily, icon_d)
|
||||
hourly["precipitation"] = hourly_precip_series(hourly)
|
||||
totals = daily_precip_from_hourly(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
|
||||
|
||||
|
||||
def format_weather_context_report(models_data, location_name, country_code, as_json=False):
|
||||
"""Genera report contestuale intelligente con ensemble multi-modello"""
|
||||
# Combina modelli a breve e lungo termine
|
||||
merged_data = merge_multi_model_forecast(models_data, forecast_days=10)
|
||||
@@ -1079,6 +1152,10 @@ def format_weather_context_report(models_data, location_name, country_code):
|
||||
hourly = merged_data.get('hourly', {})
|
||||
daily = merged_data.get('daily', {})
|
||||
models_used = merged_data.get('models_used', [])
|
||||
casa = country_code in ("SM", "IT")
|
||||
hourly, daily = _apply_unified_precip(hourly, daily, casa)
|
||||
merged_data["hourly"] = hourly
|
||||
merged_data["daily"] = daily
|
||||
|
||||
if not daily or not daily.get('time'):
|
||||
return "❌ Errore: Dati meteo incompleti"
|
||||
@@ -1089,21 +1166,30 @@ def format_weather_context_report(models_data, location_name, country_code):
|
||||
models_text = " + ".join(models_used) if models_used else "Multi-modello"
|
||||
msg_parts.append(f"🌍 <b>METEO FORECAST</b>")
|
||||
msg_parts.append(f"{location_name.upper()}")
|
||||
msg_parts.append(f"📡 <i>Ensemble: {models_text}</i>\n")
|
||||
msg_parts.append(f"📡 <i>Ensemble: {models_text}</i>")
|
||||
if casa:
|
||||
msg_parts.append(f"💧 <i>Precipitazioni 0–2g: ICON Italia (ARPAE 2i)</i>\n")
|
||||
else:
|
||||
msg_parts.append("")
|
||||
|
||||
# ANALISI TREND TEMPERATURA (Fronti) - Completa su 10 giorni
|
||||
# ANALISI TREND TEMPERATURA (Fronti) — allineato ai giorni mostrati in tabella
|
||||
daily_temps_max = daily.get('temperature_2m_max', [])
|
||||
daily_temps_min = daily.get('temperature_2m_min', [])
|
||||
trend = analyze_temperature_trend(daily_temps_max, daily_temps_min, days=10)
|
||||
daily_time_list = daily.get('time', [])
|
||||
trend = analyze_temperature_trend(
|
||||
daily_temps_max, daily_temps_min, days=DISPLAY_FORECAST_DAYS
|
||||
)
|
||||
trend_explanation = ""
|
||||
|
||||
# Spiegazione dettagliata trend (sempre, anche se stabile)
|
||||
if trend:
|
||||
trend_explanation = format_detailed_trend_explanation(trend, daily_data_list=[])
|
||||
trend_explanation = format_detailed_trend_explanation(
|
||||
trend, daily_time_list=daily_time_list, display_days=DISPLAY_FORECAST_DAYS
|
||||
)
|
||||
if trend_explanation:
|
||||
msg_parts.append(trend_explanation)
|
||||
|
||||
# ANALISI TRANSIZIONI METEO - Include anche precipitazioni prossimi giorni
|
||||
daily_time_list = daily.get('time', []) # Definito qui per uso successivo
|
||||
daily_weathercodes = daily.get('weathercode', [])
|
||||
transitions = analyze_weather_transitions(daily_weathercodes)
|
||||
|
||||
@@ -1115,12 +1201,10 @@ def format_weather_context_report(models_data, location_name, country_code):
|
||||
if transitions:
|
||||
significant_trans = [t for t in transitions if t.get("significant", False)]
|
||||
for trans in significant_trans[:5]:
|
||||
day_names = ["oggi", "domani", "dopodomani", "fra 3 giorni", "fra 4 giorni", "fra 5 giorni", "fra 6 giorni"]
|
||||
day_idx = trans["day"] - 1
|
||||
if day_idx < len(day_names):
|
||||
day_ref = day_names[day_idx]
|
||||
else:
|
||||
day_ref = f"fra {trans['day']} giorni"
|
||||
day_idx = trans["day"]
|
||||
if day_idx >= DISPLAY_FORECAST_DAYS:
|
||||
continue
|
||||
day_ref = format_day_label(day_idx, daily_time_list)
|
||||
weather_changes.append({
|
||||
"day": trans["day"],
|
||||
"day_ref": day_ref,
|
||||
@@ -1193,17 +1277,15 @@ def format_weather_context_report(models_data, location_name, country_code):
|
||||
|
||||
# Aggiungi solo se supera la soglia appropriata
|
||||
if precip_amount > threshold_mm:
|
||||
day_names = ["oggi", "domani", "dopodomani"]
|
||||
if day_idx < len(day_names):
|
||||
weather_changes.append({
|
||||
"day": day_num,
|
||||
"day_ref": day_names[day_idx],
|
||||
"from": "variabile",
|
||||
"to": "precipitazioni",
|
||||
"type": "precip",
|
||||
"amount": precip_amount,
|
||||
"precip_symbol": precip_type_symbol
|
||||
})
|
||||
weather_changes.append({
|
||||
"day": day_num,
|
||||
"day_ref": format_day_label(day_idx, daily_time_list),
|
||||
"from": "variabile",
|
||||
"to": "precipitazioni",
|
||||
"type": "precip",
|
||||
"amount": precip_amount,
|
||||
"precip_symbol": precip_type_symbol,
|
||||
})
|
||||
|
||||
if weather_changes:
|
||||
# Ordina per giorno
|
||||
@@ -1227,7 +1309,7 @@ def format_weather_context_report(models_data, location_name, country_code):
|
||||
temp_max_list = daily.get('temperature_2m_max', [])
|
||||
|
||||
# Limita ai giorni per cui abbiamo dati daily validi
|
||||
max_days = min(len(daily_time_list), len(temp_min_list), len(temp_max_list), 10)
|
||||
max_days = min(len(daily_time_list), len(temp_min_list), len(temp_max_list), DISPLAY_FORECAST_DAYS)
|
||||
|
||||
# Mappa hourly per eventi dettagliati
|
||||
daily_map = defaultdict(list)
|
||||
@@ -1250,7 +1332,10 @@ def format_weather_context_report(models_data, location_name, country_code):
|
||||
d_times = [hourly['time'][i] for i in indices if i < len(hourly.get('time', []))]
|
||||
d_codes = [hourly.get('weathercode', [])[i] for i in indices if i < len(hourly.get('weathercode', []))]
|
||||
d_probs = [hourly.get('precipitation_probability', [])[i] for i in indices if i < len(hourly.get('precipitation_probability', []))]
|
||||
d_precip = [hourly.get('precipitation', [])[i] for i in indices if i < len(hourly.get('precipitation', []))]
|
||||
d_precip = [
|
||||
hourly_precip_at_index(hourly, i)
|
||||
for i in indices if i < len(hourly.get('time', []))
|
||||
]
|
||||
d_snow = [hourly.get('snowfall', [])[i] for i in indices if i < len(hourly.get('snowfall', []))]
|
||||
d_winds = [hourly.get('windspeed_10m', [])[i] for i in indices if i < len(hourly.get('windspeed_10m', []))]
|
||||
d_winddir = [hourly.get('winddirection_10m', [])[i] for i in indices if i < len(hourly.get('winddirection_10m', []))]
|
||||
@@ -1342,9 +1427,7 @@ def format_weather_context_report(models_data, location_name, country_code):
|
||||
events_summary.append(events_list)
|
||||
|
||||
dt = datetime.datetime.strptime(day_date, "%Y-%m-%d")
|
||||
# Nomi giorni in italiano
|
||||
giorni_ita = ["Lun", "Mar", "Mer", "Gio", "Ven", "Sab", "Dom"]
|
||||
day_str = f"{giorni_ita[dt.weekday()]} {dt.strftime('%d/%m')}"
|
||||
day_str = f"{GIORNI_ITA_SHORT[dt.weekday()]} {dt.strftime('%d/%m')}"
|
||||
|
||||
# Icona meteo principale basata sul weathercode del giorno
|
||||
wcode = daily.get('weathercode', [])[count] if count < len(daily.get('weathercode', [])) else None
|
||||
@@ -1663,6 +1746,37 @@ def format_weather_context_report(models_data, location_name, country_code):
|
||||
prev_snow_depth_end = snow_depth_end if snow_depth_end is not None else prev_snow_depth_end
|
||||
msg_parts.append("")
|
||||
|
||||
if as_json:
|
||||
def _serialize_day(d):
|
||||
out = dict(d)
|
||||
out["events"] = list(d.get("events") or [])
|
||||
for k in ("t_min", "t_max", "precip_sum", "wind_max", "snowfall_sum", "rain_sum", "showers_sum"):
|
||||
if out.get(k) is not None:
|
||||
out[k] = round(float(out[k]), 1)
|
||||
for k in ("snow_depth_min", "snow_depth_max", "snow_depth_avg", "snow_depth_end"):
|
||||
if out.get(k) is not None:
|
||||
out[k] = round(float(out[k]), 1)
|
||||
return out
|
||||
|
||||
return {
|
||||
"location": location_name,
|
||||
"country_code": country_code,
|
||||
"models": models_used,
|
||||
"trend_html": trend_explanation if trend else "",
|
||||
"weather_changes": weather_changes,
|
||||
"days": [_serialize_day(d) for d in daily_details],
|
||||
"columns": [
|
||||
{"key": "day_str", "label": "Giorno"},
|
||||
{"key": "weather_icon", "label": ""},
|
||||
{"key": "t_min", "label": "Min°C"},
|
||||
{"key": "t_max", "label": "Max°C"},
|
||||
{"key": "precip_sum", "label": "Precip"},
|
||||
{"key": "precip_detail", "label": "Tipo"},
|
||||
{"key": "wind", "label": "Vento"},
|
||||
{"key": "snow_depth_end", "label": "Manto cm"},
|
||||
{"key": "events", "label": "Eventi"},
|
||||
],
|
||||
}
|
||||
|
||||
return "\n".join(msg_parts)
|
||||
|
||||
@@ -1689,6 +1803,8 @@ def main():
|
||||
parser.add_argument("--debug", action="store_true")
|
||||
parser.add_argument("--home", action="store_true")
|
||||
parser.add_argument("--timezone", help="Timezone IANA (es: Europe/Rome, America/New_York)")
|
||||
parser.add_argument("--stdout", action="store_true", help="Stampa report su stdout invece di Telegram")
|
||||
parser.add_argument("--json", action="store_true", help="Output JSON strutturato (WebApp)")
|
||||
args = parser.parse_args()
|
||||
|
||||
token = get_bot_token()
|
||||
@@ -1719,7 +1835,7 @@ def main():
|
||||
|
||||
# Recupera dati multi-modello (breve + lungo termine) - selezione intelligente basata su country code
|
||||
# Determina se è Casa
|
||||
is_home = (abs(lat - DEFAULT_LAT) < 0.01 and abs(lon - DEFAULT_LON) < 0.01)
|
||||
is_home = is_casa(lat, lon)
|
||||
|
||||
# Recupera dati multi-modello (breve + lungo termine)
|
||||
# - Per Casa: usa AROME Seamless e ICON-D2
|
||||
@@ -1741,13 +1857,20 @@ def main():
|
||||
return
|
||||
|
||||
# Genera report
|
||||
if args.json:
|
||||
payload = format_weather_context_report(models_data, name, cc, as_json=True)
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
return
|
||||
|
||||
report = format_weather_context_report(models_data, name, cc)
|
||||
|
||||
if debug_mode:
|
||||
report = f"🛠 <b>[DEBUG MODE]</b> 🛠\n\n{report}"
|
||||
|
||||
# Invia
|
||||
if token:
|
||||
if args.stdout:
|
||||
print(report)
|
||||
elif token:
|
||||
success = False
|
||||
for chat_id in recipients:
|
||||
if send_telegram(report, chat_id, token, debug_mode):
|
||||
|
||||
@@ -25,11 +25,17 @@ def setup_logger() -> logging.Logger:
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.handlers.clear()
|
||||
|
||||
fh = RotatingFileHandler(LOG_FILE, maxBytes=1_000_000, backupCount=5, encoding="utf-8")
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
||||
fh.setFormatter(fmt)
|
||||
logger.addHandler(fh)
|
||||
log_path = os.environ.get("ROAD_WEATHER_LOG", LOG_FILE)
|
||||
try:
|
||||
fh = RotatingFileHandler(log_path, maxBytes=1_000_000, backupCount=5, encoding="utf-8")
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.setFormatter(fmt)
|
||||
logger.addHandler(fh)
|
||||
except OSError:
|
||||
sh = logging.StreamHandler()
|
||||
sh.setFormatter(fmt)
|
||||
logger.addHandler(sh)
|
||||
|
||||
return logger
|
||||
|
||||
@@ -121,14 +127,43 @@ WEATHER_CODES = {
|
||||
# =============================================================================
|
||||
|
||||
def get_google_maps_api_key() -> Optional[str]:
|
||||
"""Ottiene la chiave API di Google Maps da variabile d'ambiente."""
|
||||
"""Ottiene la chiave API di Google Maps da variabile d'ambiente o file condiviso."""
|
||||
api_key = os.environ.get('GOOGLE_MAPS_API_KEY', '').strip()
|
||||
if api_key:
|
||||
return api_key
|
||||
api_key = os.environ.get('GOOGLE_API_KEY', '').strip()
|
||||
if api_key:
|
||||
return api_key
|
||||
# Debug: verifica tutte le variabili d'ambiente che contengono GOOGLE
|
||||
|
||||
for path in (
|
||||
os.environ.get("GOOGLE_MAPS_API_KEY_FILE", ""),
|
||||
"/etc/google_maps_api_key",
|
||||
os.path.expanduser("~/.google_maps_api_key"),
|
||||
os.path.join(SCRIPT_DIR, ".env"),
|
||||
):
|
||||
if not path or not os.path.isfile(path):
|
||||
continue
|
||||
try:
|
||||
if path.endswith(".env"):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith("GOOGLE_MAPS_API_KEY="):
|
||||
v = line.split("=", 1)[1].strip().strip("'\"")
|
||||
if v:
|
||||
return v
|
||||
elif line.startswith("GOOGLE_API_KEY="):
|
||||
v = line.split("=", 1)[1].strip().strip("'\"")
|
||||
if v:
|
||||
return v
|
||||
else:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
v = f.read().strip()
|
||||
if v:
|
||||
return v
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if os.environ.get('DEBUG_GOOGLE_MAPS', ''):
|
||||
google_vars = {k: v[:10] + '...' if len(v) > 10 else v for k, v in os.environ.items() if 'GOOGLE' in k.upper()}
|
||||
LOGGER.debug(f"Variabili GOOGLE trovate: {google_vars}")
|
||||
@@ -433,7 +468,7 @@ def get_weather_data(lat: float, lon: float, model_slug: str) -> Optional[Dict]:
|
||||
url = f"https://api.open-meteo.com/v1/forecast"
|
||||
|
||||
# Parametri base (aggiunto soil_temperature_0cm per analisi ghiaccio più accurata)
|
||||
hourly_params = "temperature_2m,relative_humidity_2m,precipitation,rain,showers,snowfall,weathercode,visibility,wind_speed_10m,wind_gusts_10m,soil_temperature_0cm,dew_point_2m"
|
||||
hourly_params = "temperature_2m,relative_humidity_2m,precipitation,rain,showers,snowfall,snow_depth,weathercode,visibility,wind_speed_10m,wind_gusts_10m,soil_temperature_0cm,dew_point_2m"
|
||||
|
||||
# Aggiungi CAPE se disponibile (AROME Seamless o ICON)
|
||||
if model_slug in ["meteofrance_seamless", "italia_meteo_arpae_icon_2i", "icon_eu"]:
|
||||
@@ -627,6 +662,242 @@ def analyze_past_24h_conditions(weather_data: Dict) -> Dict:
|
||||
}
|
||||
|
||||
|
||||
def summarize_point_weather(weather_data: Dict) -> Dict:
|
||||
"""Manto nevoso attuale e precipitazioni previste 12h/24h per un punto."""
|
||||
result = {
|
||||
"snow_depth_cm": None,
|
||||
"rain_12h_mm": 0.0,
|
||||
"rain_24h_mm": 0.0,
|
||||
"snow_12h_cm": 0.0,
|
||||
"snow_24h_cm": 0.0,
|
||||
}
|
||||
if not weather_data or "hourly" not in weather_data:
|
||||
return result
|
||||
|
||||
hourly = weather_data["hourly"]
|
||||
times = hourly.get("time", [])
|
||||
if not times:
|
||||
return result
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
rain = hourly.get("rain", [])
|
||||
snowfall = hourly.get("snowfall", [])
|
||||
snow_depth = hourly.get("snow_depth", [])
|
||||
|
||||
timestamps = []
|
||||
for ts_str in times:
|
||||
try:
|
||||
if "Z" in ts_str:
|
||||
ts = datetime.datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
else:
|
||||
ts = datetime.datetime.fromisoformat(ts_str)
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=datetime.timezone.utc)
|
||||
timestamps.append(ts)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
latest_depth_cm = None
|
||||
for i, ts in enumerate(timestamps):
|
||||
if i < len(snow_depth) and snow_depth[i] is not None and ts <= now:
|
||||
latest_depth_cm = float(snow_depth[i]) * 100.0
|
||||
|
||||
if ts < now:
|
||||
continue
|
||||
hours_ahead = (ts - now).total_seconds() / 3600.0
|
||||
if hours_ahead >= 24:
|
||||
continue
|
||||
r = rain[i] if i < len(rain) and rain[i] is not None else 0.0
|
||||
snow = snowfall[i] if i < len(snowfall) and snowfall[i] is not None else 0.0
|
||||
if hours_ahead < 12:
|
||||
result["rain_12h_mm"] += float(r)
|
||||
result["snow_12h_cm"] += float(snow)
|
||||
result["rain_24h_mm"] += float(r)
|
||||
result["snow_24h_cm"] += float(snow)
|
||||
|
||||
if latest_depth_cm is not None and latest_depth_cm >= 0.05:
|
||||
result["snow_depth_cm"] = round(latest_depth_cm, 1)
|
||||
result["rain_12h_mm"] = round(result["rain_12h_mm"], 1)
|
||||
result["rain_24h_mm"] = round(result["rain_24h_mm"], 1)
|
||||
result["snow_12h_cm"] = round(result["snow_12h_cm"], 1)
|
||||
result["snow_24h_cm"] = round(result["snow_24h_cm"], 1)
|
||||
return result
|
||||
|
||||
|
||||
RISK_BADGE_MAP = {
|
||||
"neve": ("❄️", "Neve"),
|
||||
"gelicidio": ("🔴🔴", "Gelicidio"),
|
||||
"ghiaccio": ("🔴", "Ghiaccio"),
|
||||
"brina": ("🟡", "Brina"),
|
||||
"pioggia": ("🌧️", "Pioggia"),
|
||||
"temporale": ("⛈️", "Temporale"),
|
||||
"vento": ("💨", "Vento"),
|
||||
"nebbia": ("🌫️", "Nebbia"),
|
||||
"grandine": ("🌨️", "Grandine"),
|
||||
"nessuno": ("✅", "Nessun rischio"),
|
||||
}
|
||||
|
||||
|
||||
def _first_dict(series):
|
||||
"""Prende il primo valore non-nullo (dict) da una serie pandas."""
|
||||
for val in series:
|
||||
if val is not None and (isinstance(val, dict) or (isinstance(val, str) and val != "")):
|
||||
return val
|
||||
return {}
|
||||
|
||||
|
||||
def _aggregate_route_points(df: pd.DataFrame):
|
||||
"""Raggruppa punti percorso con rischi effettivi e meteo riassuntivo."""
|
||||
max_risk_per_point = df.groupby("point_index").agg({
|
||||
"max_risk_level": "max",
|
||||
"point_name": "first",
|
||||
"past_24h": _first_dict,
|
||||
"weather_summary": _first_dict,
|
||||
}).sort_values("point_index")
|
||||
|
||||
seen_names = {}
|
||||
unique_indices = []
|
||||
for idx, row in max_risk_per_point.iterrows():
|
||||
point_name = row["point_name"]
|
||||
name_key = point_name.split("(")[0].strip()
|
||||
past_24h = row.get("past_24h", {}) if isinstance(row.get("past_24h"), dict) else {}
|
||||
has_snow_ice = past_24h.get("snow_present") or past_24h.get("ice_persistence_likely")
|
||||
|
||||
if name_key not in seen_names:
|
||||
seen_names[name_key] = idx
|
||||
unique_indices.append(idx)
|
||||
else:
|
||||
existing_idx = seen_names[name_key]
|
||||
existing_row = max_risk_per_point.loc[existing_idx]
|
||||
existing_past_24h = existing_row.get("past_24h", {}) if isinstance(existing_row.get("past_24h"), dict) else {}
|
||||
existing_has_snow_ice = existing_past_24h.get("snow_present") or existing_past_24h.get("ice_persistence_likely")
|
||||
if row["max_risk_level"] > existing_row["max_risk_level"]:
|
||||
unique_indices.remove(existing_idx)
|
||||
seen_names[name_key] = idx
|
||||
unique_indices.append(idx)
|
||||
elif row["max_risk_level"] == existing_row["max_risk_level"] and has_snow_ice and not existing_has_snow_ice:
|
||||
unique_indices.remove(existing_idx)
|
||||
seen_names[name_key] = idx
|
||||
unique_indices.append(idx)
|
||||
|
||||
max_risk_per_point = max_risk_per_point.loc[unique_indices]
|
||||
|
||||
effective_risk_levels_dict = {}
|
||||
for idx, row in max_risk_per_point.iterrows():
|
||||
level = int(row["max_risk_level"])
|
||||
past_24h = row.get("past_24h", {}) if isinstance(row.get("past_24h"), dict) else {}
|
||||
if level == 0 and past_24h:
|
||||
if past_24h.get("snow_present"):
|
||||
level = 4
|
||||
elif past_24h.get("ice_persistence_likely"):
|
||||
level = 2
|
||||
effective_risk_levels_dict[idx] = level
|
||||
|
||||
max_risk_per_point = max_risk_per_point.copy()
|
||||
max_risk_per_point["effective_risk_level"] = max_risk_per_point.index.map(effective_risk_levels_dict)
|
||||
|
||||
risks_per_point = {}
|
||||
for _, row in df[df["max_risk_level"] > 0].iterrows():
|
||||
point_idx = row["point_index"]
|
||||
if point_idx not in risks_per_point:
|
||||
risks_per_point[point_idx] = {}
|
||||
risk_type = row["risk_type"]
|
||||
risk_level = row["risk_level"]
|
||||
risk_desc = row["risk_description"]
|
||||
if risk_type not in risks_per_point[point_idx] or risks_per_point[point_idx][risk_type]["level"] < risk_level:
|
||||
risks_per_point[point_idx][risk_type] = {
|
||||
"type": risk_type,
|
||||
"desc": risk_desc,
|
||||
"level": risk_level,
|
||||
}
|
||||
|
||||
for idx, row in max_risk_per_point.iterrows():
|
||||
effective_risk = row.get("effective_risk_level", 0)
|
||||
max_risk = int(row["max_risk_level"])
|
||||
if effective_risk > 0 and max_risk == 0:
|
||||
if idx not in risks_per_point:
|
||||
risks_per_point[idx] = {}
|
||||
past_24h = row.get("past_24h", {}) if isinstance(row.get("past_24h"), dict) else {}
|
||||
if effective_risk >= 4:
|
||||
risk_type, risk_desc = "neve", "Neve presente"
|
||||
elif effective_risk == 2:
|
||||
risk_type = "ghiaccio"
|
||||
min_temp = past_24h.get("min_temp_2m")
|
||||
hours_below_2c = past_24h.get("hours_below_2c", 0)
|
||||
if min_temp is not None:
|
||||
risk_desc = f"Ghiaccio persistente (Tmin: {min_temp:.1f}°C, {hours_below_2c}h <2°C)"
|
||||
else:
|
||||
risk_desc = "Ghiaccio persistente"
|
||||
elif effective_risk == 1:
|
||||
risk_type = "brina"
|
||||
min_temp = past_24h.get("min_temp_2m")
|
||||
risk_desc = f"Brina possibile (Tmin: {min_temp:.1f}°C)" if min_temp is not None else "Brina possibile"
|
||||
else:
|
||||
continue
|
||||
risks_per_point[idx][risk_type] = {"type": risk_type, "desc": risk_desc, "level": effective_risk}
|
||||
|
||||
return max_risk_per_point, risks_per_point
|
||||
|
||||
|
||||
def _primary_risk_for_point(risks: Dict, effective_risk: int) -> Tuple[str, str, List[str]]:
|
||||
"""Ritorna (badge, label, descrizioni) per il punto."""
|
||||
type_order = ["neve", "gelicidio", "ghiaccio", "brina", "temporale", "pioggia", "vento", "nebbia", "grandine"]
|
||||
risk_list = sorted(risks.values(), key=lambda x: x.get("level", 0), reverse=True) if risks else []
|
||||
descriptions = [r.get("desc", "") for r in risk_list if r.get("desc")]
|
||||
|
||||
primary_type = risk_list[0]["type"] if risk_list else "nessuno"
|
||||
for t in type_order:
|
||||
if t in risks:
|
||||
primary_type = t
|
||||
break
|
||||
|
||||
if effective_risk >= 4 and "neve" not in risks:
|
||||
primary_type = "neve"
|
||||
elif effective_risk == 3 and primary_type == "nessuno":
|
||||
primary_type = "gelicidio"
|
||||
elif effective_risk == 2 and primary_type in ("nessuno", "brina"):
|
||||
primary_type = "ghiaccio"
|
||||
elif effective_risk == 1 and primary_type == "nessuno":
|
||||
primary_type = "brina"
|
||||
|
||||
badge, label = RISK_BADGE_MAP.get(primary_type, ("⚠️", primary_type.capitalize()))
|
||||
return badge, label, descriptions
|
||||
|
||||
|
||||
def build_route_points_table(df: pd.DataFrame) -> List[Dict]:
|
||||
"""Tabella punti notevoli per WebApp."""
|
||||
if df.empty:
|
||||
return []
|
||||
|
||||
max_risk_per_point, risks_per_point = _aggregate_route_points(df)
|
||||
points = []
|
||||
|
||||
for idx, row in max_risk_per_point.iterrows():
|
||||
effective_risk = int(row.get("effective_risk_level", 0))
|
||||
risks = risks_per_point.get(idx, {})
|
||||
badge, label, descriptions = _primary_risk_for_point(risks, effective_risk)
|
||||
weather = row.get("weather_summary", {}) if isinstance(row.get("weather_summary"), dict) else {}
|
||||
|
||||
points.append({
|
||||
"name": row["point_name"],
|
||||
"risk": {
|
||||
"badge": badge,
|
||||
"label": label,
|
||||
"descriptions": descriptions[:4],
|
||||
"level": effective_risk,
|
||||
},
|
||||
"snow_depth_cm": weather.get("snow_depth_cm"),
|
||||
"precip": {
|
||||
"rain_12h_mm": weather.get("rain_12h_mm", 0.0),
|
||||
"rain_24h_mm": weather.get("rain_24h_mm", 0.0),
|
||||
"snow_12h_cm": weather.get("snow_12h_cm", 0.0),
|
||||
"snow_24h_cm": weather.get("snow_24h_cm", 0.0),
|
||||
},
|
||||
})
|
||||
|
||||
return points
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ANALISI RISCHI METEO
|
||||
# =============================================================================
|
||||
@@ -1098,12 +1369,14 @@ def analyze_route_weather_risks(city1: str, city2: str, model_slug: Optional[str
|
||||
'risk_description': 'Dati meteo non disponibili',
|
||||
'risk_value': 0.0,
|
||||
'max_risk_level': 0,
|
||||
'point_name': point_name
|
||||
'point_name': point_name,
|
||||
'weather_summary': {},
|
||||
})
|
||||
continue
|
||||
|
||||
# Analizza condizioni 24h precedenti
|
||||
past_24h = analyze_past_24h_conditions(weather_data)
|
||||
weather_summary = summarize_point_weather(weather_data)
|
||||
|
||||
# Analizza rischi (passa anche past_24h per analisi temporale evolutiva)
|
||||
risk_analysis = analyze_weather_risks(weather_data, model_slug, hours_ahead=24, past_24h_info=past_24h)
|
||||
@@ -1121,7 +1394,8 @@ def analyze_route_weather_risks(city1: str, city2: str, model_slug: Optional[str
|
||||
'risk_value': 0.0,
|
||||
'max_risk_level': 0,
|
||||
'point_name': point_name,
|
||||
'past_24h': past_24h # Aggiungi analisi 24h precedenti anche se nessun rischio
|
||||
'past_24h': past_24h,
|
||||
'weather_summary': weather_summary,
|
||||
})
|
||||
continue
|
||||
|
||||
@@ -1161,7 +1435,8 @@ def analyze_route_weather_risks(city1: str, city2: str, model_slug: Optional[str
|
||||
'risk_value': risk.get("value", 0.0),
|
||||
'max_risk_level': hour_data["max_risk_level"],
|
||||
'point_name': point_name,
|
||||
'past_24h': past_24h
|
||||
'past_24h': past_24h,
|
||||
'weather_summary': weather_summary,
|
||||
})
|
||||
else:
|
||||
all_results.append({
|
||||
@@ -1175,7 +1450,8 @@ def analyze_route_weather_risks(city1: str, city2: str, model_slug: Optional[str
|
||||
'risk_value': 0.0,
|
||||
'max_risk_level': 0,
|
||||
'point_name': point_name,
|
||||
'past_24h': past_24h
|
||||
'past_24h': past_24h,
|
||||
'weather_summary': weather_summary,
|
||||
})
|
||||
|
||||
if not all_results:
|
||||
@@ -1194,139 +1470,11 @@ def format_route_weather_report(df: pd.DataFrame, city1: str, city2: str) -> str
|
||||
if df.empty:
|
||||
return "❌ Nessun dato disponibile per il percorso."
|
||||
|
||||
# Raggruppa per punto e trova rischio massimo + analisi 24h
|
||||
# Usa funzione custom per past_24h per assicurarsi che venga preservato correttamente
|
||||
def first_dict(series):
|
||||
"""Prende il primo valore non-nullo, utile per dict."""
|
||||
for val in series:
|
||||
if val is not None and (isinstance(val, dict) or (isinstance(val, str) and val != '')):
|
||||
return val
|
||||
return {}
|
||||
|
||||
max_risk_per_point = df.groupby('point_index').agg({
|
||||
'max_risk_level': 'max',
|
||||
'point_name': 'first',
|
||||
'past_24h': first_dict # Usa funzione custom per preservare dict
|
||||
}).sort_values('point_index')
|
||||
|
||||
# Rimuovi duplicati per nome (punti con stesso nome ma indici diversi)
|
||||
# Considera anche neve/ghiaccio persistente nella scelta
|
||||
seen_names = {}
|
||||
unique_indices = []
|
||||
for idx, row in max_risk_per_point.iterrows():
|
||||
point_name = row['point_name']
|
||||
# Normalizza nome (rimuovi suffissi tra parentesi)
|
||||
name_key = point_name.split('(')[0].strip()
|
||||
past_24h = row.get('past_24h', {}) if isinstance(row.get('past_24h'), dict) else {}
|
||||
has_snow_ice = past_24h.get('snow_present') or past_24h.get('ice_persistence_likely')
|
||||
|
||||
if name_key not in seen_names:
|
||||
seen_names[name_key] = idx
|
||||
unique_indices.append(idx)
|
||||
else:
|
||||
# Se duplicato, mantieni quello con rischio maggiore O con neve/ghiaccio
|
||||
existing_idx = seen_names[name_key]
|
||||
existing_row = max_risk_per_point.loc[existing_idx]
|
||||
existing_past_24h = existing_row.get('past_24h', {}) if isinstance(existing_row.get('past_24h'), dict) else {}
|
||||
existing_has_snow_ice = existing_past_24h.get('snow_present') or existing_past_24h.get('ice_persistence_likely')
|
||||
|
||||
# Priorità: rischio maggiore, oppure neve/ghiaccio se rischio uguale
|
||||
if row['max_risk_level'] > existing_row['max_risk_level']:
|
||||
unique_indices.remove(existing_idx)
|
||||
seen_names[name_key] = idx
|
||||
unique_indices.append(idx)
|
||||
elif row['max_risk_level'] == existing_row['max_risk_level'] and has_snow_ice and not existing_has_snow_ice:
|
||||
# Stesso rischio, ma questo ha neve/ghiaccio
|
||||
unique_indices.remove(existing_idx)
|
||||
seen_names[name_key] = idx
|
||||
unique_indices.append(idx)
|
||||
|
||||
# Filtra solo punti unici
|
||||
max_risk_per_point = max_risk_per_point.loc[unique_indices]
|
||||
|
||||
# Calcola effective_risk_level per ogni punto UNICO (considerando persistenza)
|
||||
effective_risk_levels_dict = {}
|
||||
for idx, row in max_risk_per_point.iterrows():
|
||||
level = int(row['max_risk_level'])
|
||||
past_24h = row.get('past_24h', {}) if isinstance(row.get('past_24h'), dict) else {}
|
||||
|
||||
# Se livello è 0, verifica persistenza per assegnare livello appropriato
|
||||
if level == 0 and past_24h:
|
||||
if past_24h.get('snow_present'):
|
||||
level = 4 # Neve presente
|
||||
elif past_24h.get('ice_persistence_likely'):
|
||||
# Se ice_persistence_likely è True, significa che c'è ghiaccio persistente
|
||||
# (calcolato in analyze_past_24h_conditions basandosi su suolo gelato,
|
||||
# precipitazioni con temperature basse, o neve presente)
|
||||
# Quindi deve essere classificato come ghiaccio (livello 2), non brina
|
||||
level = 2 # Ghiaccio persistente
|
||||
|
||||
effective_risk_levels_dict[idx] = level
|
||||
|
||||
# Aggiungi effective_risk_level al DataFrame
|
||||
max_risk_per_point['effective_risk_level'] = max_risk_per_point.index.map(effective_risk_levels_dict)
|
||||
|
||||
# Trova rischi unici per ogni punto (raggruppa per tipo, mantieni solo il più grave)
|
||||
risks_per_point = {}
|
||||
# Prima aggiungi rischi futuri (max_risk_level > 0)
|
||||
for idx, row in df[df['max_risk_level'] > 0].iterrows():
|
||||
point_idx = row['point_index']
|
||||
if point_idx not in risks_per_point:
|
||||
risks_per_point[point_idx] = {}
|
||||
|
||||
risk_type = row['risk_type']
|
||||
risk_level = row['risk_level']
|
||||
risk_desc = row['risk_description']
|
||||
|
||||
# Raggruppa per tipo di rischio, mantieni solo quello con livello più alto
|
||||
if risk_type not in risks_per_point[point_idx] or risks_per_point[point_idx][risk_type]['level'] < risk_level:
|
||||
risks_per_point[point_idx][risk_type] = {
|
||||
'type': risk_type,
|
||||
'desc': risk_desc,
|
||||
'level': risk_level
|
||||
}
|
||||
|
||||
# Poi aggiungi punti con persistenza ma senza rischi futuri (max_risk_level == 0 ma effective_risk > 0)
|
||||
for idx, row in max_risk_per_point.iterrows():
|
||||
effective_risk = row.get('effective_risk_level', 0)
|
||||
max_risk = int(row['max_risk_level'])
|
||||
|
||||
# Se ha persistenza ma non rischi futuri, aggiungi rischio basato su persistenza
|
||||
if effective_risk > 0 and max_risk == 0:
|
||||
if idx not in risks_per_point:
|
||||
risks_per_point[idx] = {}
|
||||
|
||||
past_24h = row.get('past_24h', {}) if isinstance(row.get('past_24h'), dict) else {}
|
||||
|
||||
# Determina tipo di rischio basandosi su effective_risk_level
|
||||
if effective_risk >= 4:
|
||||
risk_type = 'neve'
|
||||
risk_desc = "Neve presente"
|
||||
elif effective_risk == 2:
|
||||
risk_type = 'ghiaccio'
|
||||
# Determina descrizione basandosi su condizioni
|
||||
min_temp = past_24h.get('min_temp_2m')
|
||||
hours_below_2c = past_24h.get('hours_below_2c', 0)
|
||||
if min_temp is not None:
|
||||
risk_desc = f"Ghiaccio persistente (Tmin: {min_temp:.1f}°C, {hours_below_2c}h <2°C)"
|
||||
else:
|
||||
risk_desc = "Ghiaccio persistente"
|
||||
elif effective_risk == 1:
|
||||
risk_type = 'brina'
|
||||
min_temp = past_24h.get('min_temp_2m')
|
||||
if min_temp is not None:
|
||||
risk_desc = f"Brina possibile (Tmin: {min_temp:.1f}°C)"
|
||||
else:
|
||||
risk_desc = "Brina possibile"
|
||||
else:
|
||||
continue # Skip se non abbiamo un tipo valido
|
||||
|
||||
# Aggiungi al dict rischi (usa idx come chiave, non point_idx)
|
||||
risks_per_point[idx][risk_type] = {
|
||||
'type': risk_type,
|
||||
'desc': risk_desc,
|
||||
'level': effective_risk
|
||||
}
|
||||
max_risk_per_point, risks_per_point = _aggregate_route_points(df)
|
||||
effective_risk_levels_dict = {
|
||||
idx: int(row["effective_risk_level"])
|
||||
for idx, row in max_risk_per_point.iterrows()
|
||||
}
|
||||
|
||||
# Verifica se la chiave Google Maps è disponibile
|
||||
api_key_available = get_google_maps_api_key() is not None
|
||||
@@ -1820,3 +1968,46 @@ def generate_route_weather_map(df: pd.DataFrame, city1: str, city2: str, output_
|
||||
LOGGER.error(f"Errore salvataggio mappa: {e}")
|
||||
plt.close(fig)
|
||||
return False
|
||||
|
||||
|
||||
def build_road_json(city1: str, city2: str, maps_dir: str) -> Dict:
|
||||
"""Analisi percorso stradale per WebApp."""
|
||||
if not PANDAS_AVAILABLE:
|
||||
return {"error": "pandas/numpy non disponibili sul server"}
|
||||
|
||||
df = analyze_route_weather_risks(city1, city2, model_slug=None)
|
||||
if df is None or df.empty:
|
||||
return {"error": f"Impossibile analizzare il percorso {city1} → {city2}"}
|
||||
|
||||
report = format_route_weather_report(df, city1, city2)
|
||||
os.makedirs(maps_dir, exist_ok=True)
|
||||
import hashlib
|
||||
key = hashlib.sha256(f"{city1}|{city2}".encode()).hexdigest()[:16]
|
||||
map_name = f"road_{key}.png"
|
||||
map_path = os.path.join(maps_dir, map_name)
|
||||
map_ok = generate_route_weather_map(df, city1, city2, map_path)
|
||||
|
||||
plain = report.replace("*", "").replace("_", "").replace("`", "")
|
||||
return {
|
||||
"from": city1,
|
||||
"to": city2,
|
||||
"report": plain,
|
||||
"map_id": map_name if map_ok else None,
|
||||
"points": build_route_points_table(df),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Road weather analysis")
|
||||
parser.add_argument("city1")
|
||||
parser.add_argument("city2")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
parser.add_argument("--maps-dir", default="/tmp")
|
||||
args = parser.parse_args()
|
||||
if args.json:
|
||||
print(json.dumps(build_road_json(args.city1, args.city2, args.maps_dir), ensure_ascii=False))
|
||||
else:
|
||||
if not PANDAS_AVAILABLE:
|
||||
raise SystemExit("pandas richiesto")
|
||||
df = analyze_route_weather_risks(args.city1, args.city2)
|
||||
print(format_route_weather_report(df, args.city1, args.city2))
|
||||
|
||||
@@ -17,7 +17,7 @@ from dateutil import parser
|
||||
from open_meteo_client import open_meteo_get
|
||||
|
||||
# =============================================================================
|
||||
# SEVERE WEATHER ALERT (next 48h) - Casa (LAT/LON)
|
||||
# SEVERE WEATHER ALERT (next 24h) - Casa (LAT/LON)
|
||||
# - Wind gusts persistence: >= soglia per almeno 2 ore consecutive
|
||||
# - Rain persistence: soglia (mm/3h) superata per almeno 2 ore (2 finestre 3h consecutive)
|
||||
# - Convective storms (temporali severi): analisi combinata ICON Italia + AROME Seamless
|
||||
@@ -62,7 +62,29 @@ RAIN_3H_LIMIT = 25.0
|
||||
PERSIST_HOURS = 2 # richiesta utente: >=2 ore
|
||||
|
||||
# ----------------- HORIZON -----------------
|
||||
HOURS_AHEAD = 48 # Esteso a 48h per analisi temporali severi
|
||||
HOURS_AHEAD = 24 # Finestra di analisi e notifica: prossime 24 ore
|
||||
|
||||
# ----------------- ANTI-GLITCH (previsioni spurie fuori scala) -----------------
|
||||
# Limiti fisici oltre i quali un valore orario viene scartato come non plausibile.
|
||||
GLITCH_MAX_PRECIP_H = 100.0 # mm/h
|
||||
GLITCH_MAX_GUSTS_KMH = 160.0 # km/h
|
||||
GLITCH_MAX_CAPE = 5500.0 # J/kg
|
||||
# Picco isolato: valore >> ore adiacenti (tipico artefatto numerico del modello).
|
||||
GLITCH_SPIKE_RATIO = 4.0
|
||||
GLITCH_SPIKE_MIN = {"precip": 8.0, "gusts": 35.0, "cape": 600.0}
|
||||
# Discordanza estrema AROME vs ICON sulla stessa ora → probabile glitch.
|
||||
GLITCH_CROSS_MODEL_RATIO = 4.0
|
||||
GLITCH_CROSS_MODEL_MIN = {"precip": 10.0, "gusts": 40.0, "cape": 700.0}
|
||||
# Temporali: almeno 2 ore significative consecutive (salvo bomba d'acqua estrema).
|
||||
STORM_MIN_CLUSTER_HOURS = 2
|
||||
STORM_ISOLATED_EXTREME_PRECIP_H = 40.0 # mm/h
|
||||
STORM_ISOLATED_EXTREME_PRECIP_3H = 60.0 # mm/3h
|
||||
|
||||
# ----------------- RATE LIMITING NOTIFICHE (tutti i tipi) -----------------
|
||||
MAX_ALERT_MESSAGES_PER_DAY = 1 # massimo 1 messaggio Telegram/giorno
|
||||
MIN_GAP_HOURS_BETWEEN_ALERTS = 8.0 # distanza minima tra due messaggi
|
||||
# Escalation intra-giorno: ri-notifica solo se peggioramento netto
|
||||
RAIN_ESCALATION_MM_3H = 15.0 # +15 mm sul max 3h precedente
|
||||
|
||||
# ----------------- CONVECTIVE STORM THRESHOLDS -----------------
|
||||
CAPE_LIGHTNING_THRESHOLD = 800.0 # J/kg - Soglia per rischio fulminazioni
|
||||
@@ -90,15 +112,6 @@ SIGNIFICANT_PRECIP_H = 30.0 # mm/h - bomba d'acqua (nubifragio forte)
|
||||
SIGNIFICANT_PRECIP_3H = 50.0 # mm/3h - nubifragio forte su 3 ore
|
||||
SIGNIFICANT_STORM_SCORE = 55.0 # Storm Severity Score minimo per significativo
|
||||
|
||||
# ----------------- RATE LIMITING NOTIFICHE TEMPORALI -----------------
|
||||
# Notifiche temporali "molto contingentate":
|
||||
# - eventi imminenti (entro IMMINENT_HOURS): massimo 2 al giorno (cooldown 6h)
|
||||
# - eventi solo nella finestra estesa (24-48h): massimo 1 al giorno
|
||||
IMMINENT_HOURS = 24
|
||||
MAX_STORM_ALERTS_IMMINENT_PER_DAY = 2
|
||||
MAX_STORM_ALERTS_EXTENDED_PER_DAY = 1
|
||||
MIN_GAP_HOURS_IMMINENT = 6.0
|
||||
|
||||
# ----------------- FILES -----------------
|
||||
STATE_FILE = "/home/daniely/docker/telegram-bot/weather_state.json"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -250,6 +263,15 @@ def telegram_send_html(message_html: str, chat_ids: Optional[List[str]] = None)
|
||||
except Exception as 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
|
||||
|
||||
|
||||
@@ -266,8 +288,13 @@ def load_state() -> Dict:
|
||||
"last_storm_score": 0.0,
|
||||
"last_alert_type": None, # Tipo di allerta: "VENTO", "PIOGGIA", "TEMPORALI", o lista combinata
|
||||
"last_alert_time": None, # Timestamp ISO dell'ultima notifica
|
||||
# Anti-spam temporali: conteggi giornalieri per finestra + dedup
|
||||
"storm_daily": {}, # {"YYYY-MM-DD": {"imminent": int, "extended": int}}
|
||||
# Anti-spam globale (tutti i tipi di allerta)
|
||||
"alert_daily": {}, # {"YYYY-MM-DD": count messaggi inviati}
|
||||
"last_alert_sent": None, # ISO timestamp ultimo messaggio
|
||||
"last_alert_signature": None,
|
||||
"last_alert_signature_date": None,
|
||||
# Legacy temporali (mantenuto per compatibilità stato)
|
||||
"storm_daily": {},
|
||||
"storm_last_sent_imminent": None,
|
||||
"storm_last_sent_extended": None,
|
||||
"storm_last_signature": None,
|
||||
@@ -292,6 +319,145 @@ def save_state(state: Dict) -> None:
|
||||
LOGGER.exception("State write error: %s", e)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ANTI-GLITCH (filtra previsioni spurie / picchi isolati)
|
||||
# =============================================================================
|
||||
def _to_float(v, default: float = 0.0) -> float:
|
||||
try:
|
||||
if v is None:
|
||||
return default
|
||||
return float(v)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _physical_cap(field: str) -> float:
|
||||
return {"precip": GLITCH_MAX_PRECIP_H, "gusts": GLITCH_MAX_GUSTS_KMH, "cape": GLITCH_MAX_CAPE}.get(
|
||||
field, float("inf")
|
||||
)
|
||||
|
||||
|
||||
def exceeds_physical_cap(field: str, value: float) -> bool:
|
||||
return value > _physical_cap(field)
|
||||
|
||||
|
||||
def is_isolated_spike(series: List[float], idx: int, field: str) -> bool:
|
||||
"""Picco isolato: valore molto superiore alle ore adiacenti."""
|
||||
if idx < 1 or idx >= len(series) - 1:
|
||||
return False
|
||||
cur = series[idx]
|
||||
min_abs = GLITCH_SPIKE_MIN.get(field, 5.0)
|
||||
if cur < min_abs:
|
||||
return False
|
||||
prev_v = series[idx - 1]
|
||||
next_v = series[idx + 1]
|
||||
nb_avg = (prev_v + next_v) / 2.0
|
||||
if nb_avg < 0.05 and cur >= min_abs:
|
||||
return True
|
||||
if nb_avg > 0 and cur >= GLITCH_SPIKE_RATIO * nb_avg:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def cross_model_outlier(arome_val: float, icon_val: float, field: str) -> bool:
|
||||
"""Discordanza estrema tra modelli sulla stessa ora."""
|
||||
mn = GLITCH_CROSS_MODEL_MIN.get(field, 0.0)
|
||||
hi = max(arome_val, icon_val)
|
||||
lo = min(arome_val, icon_val)
|
||||
if hi < mn:
|
||||
return False
|
||||
if lo < 0.1 and hi >= mn:
|
||||
return True
|
||||
if lo > 0 and hi / lo >= GLITCH_CROSS_MODEL_RATIO:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def sanitize_hourly_values(
|
||||
arome_vals: List,
|
||||
icon_vals: Optional[List],
|
||||
field: str,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
) -> Tuple[List[float], int]:
|
||||
"""Pulizia serie oraria AROME; valori glitch → 0. Ritorna (serie, n_glitch)."""
|
||||
n = len(arome_vals)
|
||||
floats = [_to_float(arome_vals[i] if i < n else 0) for i in range(n)]
|
||||
glitches = 0
|
||||
for i in range(start_idx, min(end_idx, n)):
|
||||
v = floats[i]
|
||||
if v <= 0:
|
||||
continue
|
||||
icon_v = _to_float(icon_vals[i] if icon_vals and i < len(icon_vals) else None, default=-1.0)
|
||||
bad = exceeds_physical_cap(field, v)
|
||||
if not bad:
|
||||
bad = is_isolated_spike(floats, i, field)
|
||||
if not bad and icon_vals is not None and icon_v >= 0:
|
||||
bad = cross_model_outlier(v, icon_v, field)
|
||||
if bad:
|
||||
LOGGER.info(
|
||||
"Anti-glitch: %s scartato idx=%d (AROME=%.1f%s)",
|
||||
field, i, v,
|
||||
f" ICON={icon_v:.1f}" if icon_v >= 0 else "",
|
||||
)
|
||||
floats[i] = 0.0
|
||||
glitches += 1
|
||||
return floats, glitches
|
||||
|
||||
|
||||
def hour_passes_convective_glitch(
|
||||
idx: int,
|
||||
cape: float,
|
||||
precip: float,
|
||||
gusts: float,
|
||||
icon_precip: float,
|
||||
icon_cape: float,
|
||||
arome_precip_series: List[float],
|
||||
) -> bool:
|
||||
"""True se l'ora convettiva supera i controlli anti-glitch."""
|
||||
if exceeds_physical_cap("cape", cape) or exceeds_physical_cap("precip", precip) or exceeds_physical_cap("gusts", gusts):
|
||||
return False
|
||||
if precip > 0 and is_isolated_spike(arome_precip_series, idx, "precip"):
|
||||
return False
|
||||
if precip > 0 and icon_precip >= 0 and cross_model_outlier(precip, icon_precip, "precip"):
|
||||
return False
|
||||
if cape >= GLITCH_SPIKE_MIN["cape"] and icon_cape >= 0 and cross_model_outlier(cape, icon_cape, "cape"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def filter_storm_cluster(sig_events: List[Dict]) -> List[Dict]:
|
||||
"""Richiede cluster temporale minimo (ore consecutive); isolati soppressi salvo estremi."""
|
||||
if not sig_events:
|
||||
return []
|
||||
events = sorted(sig_events, key=lambda e: e.get("timestamp", ""))
|
||||
max_consec = 1
|
||||
run = 1
|
||||
for i in range(1, len(events)):
|
||||
try:
|
||||
prev = parse_time_to_local(events[i - 1]["timestamp"])
|
||||
cur = parse_time_to_local(events[i]["timestamp"])
|
||||
gap_h = (cur - prev).total_seconds() / 3600.0
|
||||
if gap_h <= 1.5:
|
||||
run += 1
|
||||
else:
|
||||
max_consec = max(max_consec, run)
|
||||
run = 1
|
||||
except Exception:
|
||||
run = 1
|
||||
max_consec = max(max_consec, run)
|
||||
if max_consec >= STORM_MIN_CLUSTER_HOURS:
|
||||
return events
|
||||
if len(events) == 1 or max_consec == 1:
|
||||
ev = max(events, key=lambda e: float(e.get("precip", 0) or 0))
|
||||
if (ev.get("precip", 0) >= STORM_ISOLATED_EXTREME_PRECIP_H
|
||||
or ev.get("precip_3h", 0) >= STORM_ISOLATED_EXTREME_PRECIP_3H):
|
||||
return events
|
||||
LOGGER.info("Anti-glitch: evento temporale isolato (%dh) soppresso", max_consec)
|
||||
return []
|
||||
return events
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OPEN-METEO
|
||||
# =============================================================================
|
||||
@@ -722,6 +888,8 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[
|
||||
|
||||
# Se LPI non disponibile, usa CAPE da ICON come proxy (CAPE alto può indicare attività convettiva)
|
||||
icon_cape = icon_hourly.get("cape", []) or []
|
||||
icon_precip = icon_hourly.get("precipitation", []) or []
|
||||
icon_gusts = icon_hourly.get("wind_gusts_10m", []) or []
|
||||
# Se abbiamo CAPE da ICON ma non LPI, usiamo CAPE > 800 come indicatore di possibile attività elettrica
|
||||
if not icon_lpi and icon_cape:
|
||||
# Convertiamo CAPE in LPI proxy: CAPE > 800 = LPI > 0
|
||||
@@ -730,6 +898,7 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[
|
||||
arome_cape = arome_hourly.get("cape", []) or []
|
||||
arome_gusts = arome_hourly.get("wind_gusts_10m", []) or []
|
||||
arome_precip = arome_hourly.get("precipitation", []) or []
|
||||
arome_precip_floats = [_to_float(v) for v in arome_precip]
|
||||
|
||||
# Allineamento: sincronizza timestamp (ICON e AROME possono avere risoluzioni diverse)
|
||||
# Per semplicità, assumiamo che abbiano la stessa risoluzione oraria e li allineiamo per indice
|
||||
@@ -775,6 +944,14 @@ def analyze_convective_risk(icon_data: Dict, arome_data: Dict, times_base: List[
|
||||
except (ValueError, TypeError, IndexError):
|
||||
pass
|
||||
|
||||
icon_precip_i = _to_float(icon_precip[i] if i < len(icon_precip) else None, default=-1.0)
|
||||
icon_cape_i = _to_float(icon_cape[i] if i < len(icon_cape) else None, default=-1.0)
|
||||
if not hour_passes_convective_glitch(
|
||||
i, cape_val, precip_val, gusts_val,
|
||||
icon_precip_i, icon_cape_i, arome_precip_floats,
|
||||
):
|
||||
continue
|
||||
|
||||
# Calcola Storm Severity Score (0-100)
|
||||
score = 0.0
|
||||
threats = []
|
||||
@@ -834,8 +1011,8 @@ def format_convective_alert(storm_events: List[Dict], times: List[str], start_id
|
||||
if not storm_events:
|
||||
return ""
|
||||
|
||||
# Analisi estesa su 48 ore
|
||||
storm_analysis = analyze_convective_storm_event(storm_events, times, start_idx, max_hours=48)
|
||||
# Analisi estesa su finestra configurata
|
||||
storm_analysis = analyze_convective_storm_event(storm_events, times, start_idx, max_hours=HOURS_AHEAD)
|
||||
|
||||
# Calcola statistiche aggregate
|
||||
max_score = max(e["score"] for e in storm_events)
|
||||
@@ -1008,8 +1185,7 @@ def storm_event_significance(event: Dict) -> List[str]:
|
||||
|
||||
|
||||
def filter_significant_storms(storm_events: List[Dict], now: datetime.datetime) -> List[Dict]:
|
||||
"""Mantiene solo gli eventi significativi, arricchendoli con 'lead_hours' e
|
||||
'significance'."""
|
||||
"""Mantiene solo gli eventi significativi, con cluster minimo e anti-glitch."""
|
||||
out: List[Dict] = []
|
||||
for ev in storm_events or []:
|
||||
reasons = storm_event_significance(ev)
|
||||
@@ -1023,7 +1199,7 @@ def filter_significant_storms(storm_events: List[Dict], now: datetime.datetime)
|
||||
e2["lead_hours"] = lead
|
||||
e2["significance"] = reasons
|
||||
out.append(e2)
|
||||
return out
|
||||
return filter_storm_cluster(out)
|
||||
|
||||
|
||||
def _today_key(now: datetime.datetime) -> str:
|
||||
@@ -1042,40 +1218,65 @@ def _parse_iso_local(s: Optional[str]) -> Optional[datetime.datetime]:
|
||||
return None
|
||||
|
||||
|
||||
def prune_storm_daily(state: Dict, now: datetime.datetime) -> None:
|
||||
def prune_alert_daily(state: Dict, now: datetime.datetime) -> None:
|
||||
keep = {_today_key(now), _today_key(now - datetime.timedelta(days=1))}
|
||||
daily = state.get("storm_daily", {}) or {}
|
||||
state["storm_daily"] = {k: v for k, v in daily.items() if k in keep}
|
||||
daily = state.get("alert_daily", {}) or {}
|
||||
state["alert_daily"] = {k: v for k, v in daily.items() if k in keep}
|
||||
|
||||
|
||||
def can_notify_storm(category: str, now: datetime.datetime, state: Dict) -> Tuple[bool, str]:
|
||||
def build_alert_signature(alerts: List[str], wind_level: int, rain_max_3h: float, sig_storms: List[Dict]) -> str:
|
||||
parts = []
|
||||
if sig_storms:
|
||||
reasons = set()
|
||||
for ev in sig_storms:
|
||||
reasons.update(ev.get("significance", []))
|
||||
parts.append("storm:" + "+".join(sorted(reasons)))
|
||||
if wind_level > 0:
|
||||
parts.append(f"wind:{wind_level}")
|
||||
if rain_max_3h >= RAIN_3H_LIMIT:
|
||||
parts.append(f"rain:{rain_max_3h:.0f}")
|
||||
return "|".join(parts) if parts else "none"
|
||||
|
||||
|
||||
def is_alert_escalation(
|
||||
state: Dict,
|
||||
signature: str,
|
||||
wind_level: int,
|
||||
rain_max_3h: float,
|
||||
) -> bool:
|
||||
"""Permette un secondo messaggio nello stesso giorno solo se peggioramento netto."""
|
||||
prev_wind = int(state.get("wind_level", 0) or 0)
|
||||
prev_rain = float(state.get("last_rain_3h", 0.0) or 0.0)
|
||||
prev_sig = state.get("last_alert_signature") or ""
|
||||
if wind_level > prev_wind:
|
||||
return True
|
||||
if rain_max_3h >= prev_rain + RAIN_ESCALATION_MM_3H:
|
||||
return True
|
||||
if signature != prev_sig and signature != "none" and prev_sig:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def can_notify_message(now: datetime.datetime, state: Dict, allow_escalation: bool = False) -> Tuple[bool, str]:
|
||||
key = _today_key(now)
|
||||
daily = state.setdefault("storm_daily", {})
|
||||
day = daily.setdefault(key, {"imminent": 0, "extended": 0})
|
||||
if category == "imminent":
|
||||
if int(day.get("imminent", 0)) >= MAX_STORM_ALERTS_IMMINENT_PER_DAY:
|
||||
return False, "cap giornaliero imminenti raggiunto"
|
||||
last = _parse_iso_local(state.get("storm_last_sent_imminent"))
|
||||
if last and (now - last).total_seconds() < MIN_GAP_HOURS_IMMINENT * 3600:
|
||||
return False, "cooldown imminenti attivo"
|
||||
return True, ""
|
||||
if int(day.get("extended", 0)) >= MAX_STORM_ALERTS_EXTENDED_PER_DAY:
|
||||
return False, "cap giornaliero estesi raggiunto"
|
||||
count = int((state.get("alert_daily", {}) or {}).get(key, 0))
|
||||
if count >= MAX_ALERT_MESSAGES_PER_DAY and not allow_escalation:
|
||||
return False, "cap giornaliero messaggi raggiunto"
|
||||
if count >= MAX_ALERT_MESSAGES_PER_DAY + 1:
|
||||
return False, "cap escalation giornaliero raggiunto"
|
||||
last = _parse_iso_local(state.get("last_alert_sent"))
|
||||
if last and (now - last).total_seconds() < MIN_GAP_HOURS_BETWEEN_ALERTS * 3600:
|
||||
return False, "cooldown alert attivo"
|
||||
return True, ""
|
||||
|
||||
|
||||
def record_notify_storm(category: str, now: datetime.datetime, state: Dict) -> None:
|
||||
def record_notify_message(now: datetime.datetime, state: Dict, signature: str) -> None:
|
||||
key = _today_key(now)
|
||||
day = state.setdefault("storm_daily", {}).setdefault(key, {"imminent": 0, "extended": 0})
|
||||
day[category] = int(day.get(category, 0)) + 1
|
||||
state["storm_last_sent_" + category] = now.isoformat()
|
||||
|
||||
|
||||
def build_storm_signature(category: str, sig_events: List[Dict]) -> str:
|
||||
reasons = set()
|
||||
for ev in sig_events:
|
||||
reasons.update(ev.get("significance", []))
|
||||
return f"{category}|" + "+".join(sorted(reasons))
|
||||
daily = state.setdefault("alert_daily", {})
|
||||
daily[key] = int(daily.get(key, 0)) + 1
|
||||
state["last_alert_sent"] = now.isoformat()
|
||||
state["last_alert_signature"] = signature
|
||||
state["last_alert_signature_date"] = key
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1276,9 +1477,9 @@ def rain_message(max_3h: float, start_hhmm: str, persist_h: int, rain_analysis:
|
||||
|
||||
if end_time:
|
||||
end_str = end_time.strftime("%d/%m %H:%M")
|
||||
msg_parts.append(f"⏱️ <b>Durata totale evento (48h):</b> ~{duration_h} ore (fino alle {end_str})")
|
||||
msg_parts.append(f"⏱️ <b>Durata totale evento ({HOURS_AHEAD}h):</b> ~{duration_h} ore (fino alle {end_str})")
|
||||
else:
|
||||
msg_parts.append(f"⏱️ <b>Durata totale evento (48h):</b> ~{duration_h} ore (in corso)")
|
||||
msg_parts.append(f"⏱️ <b>Durata totale evento ({HOURS_AHEAD}h):</b> ~{duration_h} ore (in corso)")
|
||||
|
||||
msg_parts.append(f"💧 <b>Accumulo totale previsto:</b> ~{total_mm:.1f} mm")
|
||||
msg_parts.append(f"🌧️ <b>Intensità massima oraria:</b> {max_intensity:.1f} mm/h")
|
||||
@@ -1323,8 +1524,6 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
return
|
||||
|
||||
times = times[:n]
|
||||
gusts = gusts_arome[:n]
|
||||
rain = rain_arome[:n]
|
||||
wcode = wcode_arome[:n]
|
||||
|
||||
now = now_local()
|
||||
@@ -1346,6 +1545,12 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
LOGGER.error("Invalid horizon window (start=%s end=%s).", start_idx, end_idx)
|
||||
return
|
||||
|
||||
# Anti-glitch su serie vento/pioggia prima dell'analisi di persistenza
|
||||
rain, rain_glitch = sanitize_hourly_values(rain_arome[:n], rain_icon, "precip", start_idx, end_idx)
|
||||
gusts, gust_glitch = sanitize_hourly_values(gusts_arome[:n], gusts_icon, "gusts", start_idx, end_idx)
|
||||
if rain_glitch or gust_glitch:
|
||||
LOGGER.info("Anti-glitch: %d ore pioggia, %d ore vento corrette", rain_glitch, gust_glitch)
|
||||
|
||||
if DEBUG:
|
||||
LOGGER.debug("model=%s start_idx=%s end_idx=%s (hours=%s)",
|
||||
model_used, start_idx, end_idx, end_idx - start_idx)
|
||||
@@ -1413,109 +1618,62 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
if comp_rain:
|
||||
comparisons["rain"] = comp_rain
|
||||
|
||||
# --- Decide notifications ---
|
||||
# --- Decide notifications (contenuto) ---
|
||||
alerts: List[str] = []
|
||||
should_notify = False
|
||||
|
||||
# 1) Convective storms (temporali severi) - priorità alta
|
||||
# Considera SOLO eventi significativi (fulminazioni forti / bombe d'acqua /
|
||||
# downburst) e applica anti-spam a finestre + dedup giornaliero.
|
||||
sig_storms = filter_significant_storms(storm_events, now)
|
||||
prune_storm_daily(state, now)
|
||||
prune_alert_daily(state, now)
|
||||
|
||||
# 1) Temporali severi significativi
|
||||
if sig_storms:
|
||||
has_imminent = any(e.get("lead_hours", 0.0) <= IMMINENT_HOURS for e in sig_storms)
|
||||
storm_category = "imminent" if has_imminent else "extended"
|
||||
storm_signature = build_storm_signature(storm_category, sig_storms)
|
||||
today_key = _today_key(now)
|
||||
|
||||
send_storm = False
|
||||
if debug_mode:
|
||||
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato per temporali severi")
|
||||
send_storm = True
|
||||
elif (state.get("storm_last_signature") == storm_signature
|
||||
and state.get("storm_last_signature_date") == today_key):
|
||||
LOGGER.info("Temporali: alert soppresso (dedup): situazione invariata già notificata oggi [%s]", storm_category)
|
||||
else:
|
||||
allowed, reason = can_notify_storm(storm_category, now, state)
|
||||
if allowed:
|
||||
send_storm = True
|
||||
else:
|
||||
LOGGER.info("Temporali: alert soppresso (rate-limit %s): %s", storm_category, reason)
|
||||
|
||||
if send_storm:
|
||||
convective_msg = format_convective_alert(sig_storms, times, start_idx)
|
||||
if convective_msg:
|
||||
alerts.append(convective_msg)
|
||||
should_notify = True
|
||||
if not debug_mode:
|
||||
record_notify_storm(storm_category, now, state)
|
||||
state["storm_last_signature"] = storm_signature
|
||||
state["storm_last_signature_date"] = today_key
|
||||
|
||||
convective_msg = format_convective_alert(sig_storms, times, start_idx)
|
||||
if convective_msg:
|
||||
alerts.append(convective_msg)
|
||||
state["convective_storm_active"] = True
|
||||
state["last_storm_score"] = float(max(e["score"] for e in sig_storms))
|
||||
else:
|
||||
state["convective_storm_active"] = False
|
||||
state["last_storm_score"] = 0.0
|
||||
|
||||
# 2) Wind (persistent)
|
||||
# 2) Vento persistente
|
||||
if wind_level_curr > 0:
|
||||
prev_level = int(state.get("wind_level", 0) or 0)
|
||||
if debug_mode or (not was_alarm) or (wind_level_curr > prev_level):
|
||||
if debug_mode:
|
||||
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato per vento")
|
||||
wind_msg = wind_message(wind_level_curr, wind_peak, wind_start, wind_run_len)
|
||||
if "wind" in comparisons:
|
||||
comp = comparisons["wind"]
|
||||
wind_msg += f"\n⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.0f} km/h | ICON {comp['icon']:.0f} km/h (scostamento {comp['diff_pct']:.0f}%)"
|
||||
alerts.append(wind_msg)
|
||||
should_notify = True
|
||||
wind_msg = wind_message(wind_level_curr, wind_peak, wind_start, wind_run_len)
|
||||
if "wind" in comparisons:
|
||||
comp = comparisons["wind"]
|
||||
wind_msg += f"\n⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.0f} km/h | ICON {comp['icon']:.0f} km/h (scostamento {comp['diff_pct']:.0f}%)"
|
||||
alerts.append(wind_msg)
|
||||
state["wind_level"] = wind_level_curr
|
||||
state["last_wind_peak"] = float(wind_peak)
|
||||
else:
|
||||
state["wind_level"] = 0
|
||||
state["last_wind_peak"] = 0.0
|
||||
|
||||
# 3) Rain (persistent)
|
||||
# 3) Pioggia persistente
|
||||
if rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT:
|
||||
prev_rain = float(state.get("last_rain_3h", 0.0) or 0.0)
|
||||
# "Meglio uno in più": notifica anche al primo superamento persistente,
|
||||
# e ri-notifica se peggiora di >= +10mm sul massimo 3h
|
||||
if debug_mode or (not was_alarm) or (rain_max_3h >= prev_rain + 10.0):
|
||||
if debug_mode:
|
||||
LOGGER.info("[DEBUG MODE] Bypass anti-spam: invio forzato per pioggia")
|
||||
|
||||
# Analisi estesa su 48 ore per pioggia intensa
|
||||
rain_analysis = None
|
||||
if rain_start:
|
||||
# Trova l'indice di inizio dell'evento cercando il timestamp corrispondente
|
||||
rain_start_idx = -1
|
||||
for i, t in enumerate(times):
|
||||
try:
|
||||
t_dt = parse_time_to_local(t)
|
||||
if ddmmyy_hhmm(t_dt) == rain_start:
|
||||
rain_start_idx = i
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if rain_start_idx >= 0 and rain_start_idx < len(times):
|
||||
# Usa soglia minima per considerare pioggia significativa (8 mm/h, coerente con RAIN_INTENSE_THRESHOLD_H)
|
||||
rain_analysis = analyze_rainfall_event(
|
||||
times=times,
|
||||
precipitation=rain,
|
||||
weathercode=wcode,
|
||||
start_idx=rain_start_idx,
|
||||
max_hours=48,
|
||||
threshold_mm_h=8.0 # Soglia per pioggia intensa
|
||||
)
|
||||
|
||||
rain_msg = rain_message(rain_max_3h, rain_start, rain_persist, rain_analysis=rain_analysis)
|
||||
if "rain" in comparisons:
|
||||
comp = comparisons["rain"]
|
||||
rain_msg += f"\n⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.1f} mm | ICON {comp['icon']:.1f} mm (scostamento {comp['diff_pct']:.0f}%)"
|
||||
alerts.append(rain_msg)
|
||||
should_notify = True
|
||||
rain_analysis = None
|
||||
if rain_start:
|
||||
rain_start_idx = -1
|
||||
for i, t in enumerate(times):
|
||||
try:
|
||||
t_dt = parse_time_to_local(t)
|
||||
if ddmmyy_hhmm(t_dt) == rain_start:
|
||||
rain_start_idx = i
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if rain_start_idx >= 0 and rain_start_idx < len(times):
|
||||
rain_analysis = analyze_rainfall_event(
|
||||
times=times,
|
||||
precipitation=rain,
|
||||
weathercode=wcode,
|
||||
start_idx=rain_start_idx,
|
||||
max_hours=HOURS_AHEAD,
|
||||
threshold_mm_h=8.0,
|
||||
)
|
||||
rain_msg = rain_message(rain_max_3h, rain_start, rain_persist, rain_analysis=rain_analysis)
|
||||
if "rain" in comparisons:
|
||||
comp = comparisons["rain"]
|
||||
rain_msg += f"\n⚠️ <b>Discordanza modelli</b>: AROME {comp['arome']:.1f} mm | ICON {comp['icon']:.1f} mm (scostamento {comp['diff_pct']:.0f}%)"
|
||||
alerts.append(rain_msg)
|
||||
state["last_rain_3h"] = float(rain_max_3h)
|
||||
else:
|
||||
state["last_rain_3h"] = 0.0
|
||||
@@ -1526,13 +1684,29 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
or (rain_persist >= PERSIST_HOURS and rain_max_3h >= RAIN_3H_LIMIT)
|
||||
)
|
||||
|
||||
# In modalità debug, forza invio anche se non ci sono allerte
|
||||
should_notify = bool(alerts)
|
||||
debug_message_only = False
|
||||
if debug_mode and not alerts:
|
||||
LOGGER.info("[DEBUG MODE] Nessuna allerta, ma creo messaggio informativo")
|
||||
alerts.append("ℹ️ <i>Nessuna condizione meteo severa rilevata nelle prossime %s ore.</i>" % HOURS_AHEAD)
|
||||
should_notify = True
|
||||
debug_message_only = True # Segnala che è solo un messaggio debug, non una vera allerta
|
||||
debug_message_only = True
|
||||
|
||||
alert_signature = build_alert_signature(alerts, wind_level_curr, rain_max_3h, sig_storms)
|
||||
|
||||
# --- Gate anti-spam globale (max 1/giorno + cooldown, escalation se peggioramento) ---
|
||||
if should_notify and alerts and not debug_message_only:
|
||||
today_key = _today_key(now)
|
||||
if (state.get("last_alert_signature") == alert_signature
|
||||
and state.get("last_alert_signature_date") == today_key):
|
||||
LOGGER.info("Alert soppresso (dedup): situazione invariata già notificata oggi")
|
||||
should_notify = False
|
||||
elif not debug_mode:
|
||||
escalation = is_alert_escalation(state, alert_signature, wind_level_curr, rain_max_3h)
|
||||
allowed, reason = can_notify_message(now, state, allow_escalation=escalation)
|
||||
if not allowed:
|
||||
LOGGER.info("Alert soppresso (rate-limit): %s", reason)
|
||||
should_notify = False
|
||||
|
||||
# --- Send only on alerts (never on errors) ---
|
||||
if should_notify and alerts:
|
||||
@@ -1564,7 +1738,6 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
# IMPORTANTE: Imposta alert_active = True solo se c'è una vera allerta,
|
||||
# non se è solo un messaggio informativo in modalità debug
|
||||
if not debug_message_only:
|
||||
# Determina il tipo di allerta basandosi sulle condizioni attuali
|
||||
alert_types = []
|
||||
if sig_storms and len(sig_storms) > 0:
|
||||
alert_types.append("TEMPORALI SEVERI")
|
||||
@@ -1577,6 +1750,8 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
state["alert_active"] = True
|
||||
state["last_alert_type"] = alert_types if alert_types else None
|
||||
state["last_alert_time"] = now.isoformat()
|
||||
if ok:
|
||||
record_notify_message(now, state, alert_signature)
|
||||
save_state(state)
|
||||
else:
|
||||
# In debug mode senza vere allerte, non modificare alert_active
|
||||
@@ -1649,13 +1824,10 @@ def analyze(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, lat:
|
||||
"last_storm_score": 0.0,
|
||||
"last_alert_type": None,
|
||||
"last_alert_time": None,
|
||||
# Preserva i contatori giornalieri/cooldown temporali (i cap valgono
|
||||
# per l'intera giornata anche dopo un all-clear); azzera solo la firma.
|
||||
"storm_daily": state.get("storm_daily", {}),
|
||||
"storm_last_sent_imminent": state.get("storm_last_sent_imminent"),
|
||||
"storm_last_sent_extended": state.get("storm_last_sent_extended"),
|
||||
"storm_last_signature": None,
|
||||
"storm_last_signature_date": None,
|
||||
"alert_daily": state.get("alert_daily", {}),
|
||||
"last_alert_sent": state.get("last_alert_sent"),
|
||||
"last_alert_signature": None,
|
||||
"last_alert_signature_date": None,
|
||||
}
|
||||
save_state(state)
|
||||
return
|
||||
|
||||
@@ -115,13 +115,19 @@ def setup_logger() -> logging.Logger:
|
||||
logger.setLevel(logging.DEBUG if DEBUG else logging.INFO)
|
||||
logger.handlers.clear()
|
||||
|
||||
fh = RotatingFileHandler(LOG_FILE, maxBytes=1_000_000, backupCount=5, encoding="utf-8")
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
||||
fh.setFormatter(fmt)
|
||||
logger.addHandler(fh)
|
||||
log_path = os.environ.get("SNOW_RADAR_LOG", LOG_FILE)
|
||||
try:
|
||||
fh = RotatingFileHandler(log_path, maxBytes=1_000_000, backupCount=5, encoding="utf-8")
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.setFormatter(fmt)
|
||||
logger.addHandler(fh)
|
||||
except OSError:
|
||||
sh = logging.StreamHandler()
|
||||
sh.setFormatter(fmt)
|
||||
logger.addHandler(sh)
|
||||
|
||||
if DEBUG:
|
||||
if DEBUG and not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
|
||||
sh = logging.StreamHandler()
|
||||
sh.setLevel(logging.DEBUG)
|
||||
sh.setFormatter(fmt)
|
||||
@@ -779,12 +785,104 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, chat_id
|
||||
LOGGER.error("Errore generazione mappe")
|
||||
|
||||
|
||||
def collect_snow_radar_results(session) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""Analizza tutte le località; ritorna (tutte le analisi, subset per mappe)."""
|
||||
now = now_local()
|
||||
center_lat, center_lon = 43.9356, 12.4296
|
||||
all_rows: List[Dict] = []
|
||||
map_results: List[Dict] = []
|
||||
|
||||
for i, loc in enumerate(LOCATIONS):
|
||||
distance_km = calculate_distance_km(center_lat, center_lon, loc["lat"], loc["lon"])
|
||||
data = get_forecast(session, loc["lat"], loc["lon"])
|
||||
if not data:
|
||||
continue
|
||||
snow_analysis = analyze_snowfall_for_location(data, now)
|
||||
if not snow_analysis:
|
||||
continue
|
||||
|
||||
is_casa = loc["name"] == "Casa (Strada Cà Toro)"
|
||||
has_snow = (
|
||||
snow_analysis["snow_past_12h"] >= SNOW_THRESHOLD_CM
|
||||
or snow_analysis["snow_next_12h"] >= SNOW_THRESHOLD_CM
|
||||
or snow_analysis["snow_next_24h"] >= SNOW_THRESHOLD_CM
|
||||
)
|
||||
row = {
|
||||
"name": loc["name"],
|
||||
"lat": loc["lat"],
|
||||
"lon": loc["lon"],
|
||||
"distance_km": distance_km,
|
||||
"has_snow": has_snow,
|
||||
**snow_analysis,
|
||||
}
|
||||
all_rows.append(row)
|
||||
if is_casa or has_snow:
|
||||
map_results.append(row)
|
||||
time.sleep(0.1)
|
||||
|
||||
return all_rows, map_results
|
||||
|
||||
|
||||
def build_snow_radar_json(maps_dir: Optional[str] = None) -> Dict:
|
||||
"""Analisi completa per WebApp (senza Telegram)."""
|
||||
now = now_local()
|
||||
center_lat, center_lon = 43.9356, 12.4296
|
||||
total = len(LOCATIONS)
|
||||
|
||||
with requests.Session() as session:
|
||||
configure_open_meteo_session(session, headers=HTTP_HEADERS)
|
||||
all_rows, map_results = collect_snow_radar_results(session)
|
||||
|
||||
count_past = sum(1 for r in all_rows if r.get("snow_past_12h", 0) >= SNOW_THRESHOLD_CM)
|
||||
count_future = sum(1 for r in all_rows if r.get("snow_next_24h", 0) >= SNOW_THRESHOLD_CM)
|
||||
active = count_past > 0 or count_future > 0
|
||||
|
||||
payload: Dict = {
|
||||
"active": active,
|
||||
"updated_at": now.strftime("%d/%m/%Y %H:%M"),
|
||||
"total_locations": total,
|
||||
"count_past_12h": count_past,
|
||||
"count_next_24h": count_future,
|
||||
"locations": [
|
||||
{
|
||||
"name": r["name"],
|
||||
"snow_past_12h": round(float(r.get("snow_past_12h", 0)), 1),
|
||||
"snow_next_24h": round(float(r.get("snow_next_24h", 0)), 1),
|
||||
"has_snow": bool(r.get("has_snow")),
|
||||
}
|
||||
for r in sorted(all_rows, key=lambda x: (-x.get("snow_next_24h", 0), x["name"]))
|
||||
if r.get("has_snow")
|
||||
],
|
||||
"maps": {"past": None, "future": None},
|
||||
}
|
||||
|
||||
if active and maps_dir:
|
||||
os.makedirs(maps_dir, exist_ok=True)
|
||||
past_path = os.path.join(maps_dir, "snow_radar_past.png")
|
||||
future_path = os.path.join(maps_dir, "snow_radar_future.png")
|
||||
if generate_snow_map(map_results, center_lat, center_lon, past_path,
|
||||
data_field="snow_past_12h", title_suffix=" - Ultime 12h"):
|
||||
payload["maps"]["past"] = "past"
|
||||
if generate_snow_map(map_results, center_lat, center_lon, future_path,
|
||||
data_field="snow_next_24h", title_suffix=" - Prossime 24h"):
|
||||
payload["maps"]["future"] = "future"
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
arg_parser = argparse.ArgumentParser(description="Snow Radar - Analisi neve in griglia 30km")
|
||||
arg_parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0])
|
||||
arg_parser.add_argument("--chat_id", type=str, help="Chat ID specifico per invio messaggio (override debug mode)")
|
||||
arg_parser.add_argument("--json", action="store_true", help="Output JSON per WebApp (no Telegram)")
|
||||
arg_parser.add_argument("--maps-dir", type=str, default="", help="Directory per salvare mappe PNG (con --json)")
|
||||
args = arg_parser.parse_args()
|
||||
|
||||
if args.json:
|
||||
maps_dir = args.maps_dir.strip() or None
|
||||
print(json.dumps(build_snow_radar_json(maps_dir=maps_dir), ensure_ascii=False))
|
||||
raise SystemExit(0)
|
||||
|
||||
# Se --chat_id è specificato, usa quello; altrimenti usa logica debug
|
||||
chat_id = args.chat_id if args.chat_id else None
|
||||
chat_ids = None if chat_id else ([TELEGRAM_CHAT_IDS[0]] if args.debug else None)
|
||||
|
||||
Reference in New Issue
Block a user