From 812bcd002cbea80b65588a60ba80e33eea3eba03 Mon Sep 17 00:00:00 2001 From: daniele Date: Sun, 8 Feb 2026 07:00:03 +0100 Subject: [PATCH] Backup automatico script del 2026-02-08 07:00 --- .../open_meteo_client.cpython-313.pyc | Bin 0 -> 3335 bytes services/telegram-bot/bot.py | 17 +- services/telegram-bot/fotovoltaico.py | 816 ++++++++++++ services/telegram-bot/log_monitor.py | 5 + services/telegram-bot/meteo.py | 98 +- services/telegram-bot/previsione7.py | 118 +- .../telegram-bot/smart_irrigation_advisor.py | 1180 +++++++++++++---- services/telegram-bot/snow_radar.py | 123 +- 8 files changed, 1911 insertions(+), 446 deletions(-) create mode 100644 services/telegram-bot/__pycache__/open_meteo_client.cpython-313.pyc create mode 100644 services/telegram-bot/fotovoltaico.py diff --git a/services/telegram-bot/__pycache__/open_meteo_client.cpython-313.pyc b/services/telegram-bot/__pycache__/open_meteo_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1aefd5458764ea9ea133a984226fa8a13c249e6e GIT binary patch literal 3335 zcmbVO-A^3X6~D9dwcopd4TKu9!C*58SWF-h!A<1a#4*$mF|2H9gQnx%3v9B?Z12pL zf)sTtRZ1gO3JLLU1F1u3g5Cqhc4h(2v_U6uGVlPyYeRLo!Ho!iX;Dfq7vrzyc}M#d&c~ zV$z(<v;QL+Tgw1dhKeu;lm8@T@NCNq<(GF6q)cQ9Y)|C*_8h^R?coxA_+B zYm(Nr{Xtk?HyO>R7E9C`y-Qtcr`}p#emj*b7O3T>HVf{0icyypsFO0Se9AE`nsN&p z)GoWuSUBU*)T+%=X0e#cfpAi;-RkSc+jqP!SfZQ*LuOiz=eXe_JvBZ7eva`5G=WD< z%d%b5E!dW$iCnr>$hkbYSi%mo$mQj7sYn-aBJT1=-YmJ4K~r9WJ#J}%W4y}a7eG8u zcyqG_%NiV6<1>@$8n{UnLMBJla6sVysJpCCc4@rg$`2%J@Az8t1kZjW?LzaA$HO8=>@*7jW zMWbF6bioquU&{n0#Q~xP79JCbhU{&XD~`=vn&)!Cr5lbWadn-VdCDBF`ZvYZ2WIX) zdv%pZ{2}8#y5&f)S_%rkm0q_uXgY6N1zOxn7j1j3NJkkh*=dKeUlelGNxQU2*O<96 z`oMP6HlV}UKy(;&V2qWvc+_y}p+e`Z8=QB--?;*!LcW}w`TgLZl-{ImATd^OVfsB14Wv?mQcg_-0SL-h3@KE`tV#;G7jVZ;maGKA-0OIYs<8Kbu}YT`MXPzAD9z7HRl9eKO$(6Jnwa> z<9uI+G){!;v_=F|P(~Rbk3$QZT=)w_x9|+bcPRda;@=>+czbsF&1EeFzynSextg;r zivl4f27m(`0#VBjPu6P#@<5Ij3y#YZkO}Nfnn%d2+j)n_eLNbgX3n)4PcXCKP{Xzi z-$xIlP6=Eah~O>5Tm@Pib?hS9H_TspBanr*gX^waa$ZZPw^h_Y(ZU}3SPYKEf*^Qk zmD$_Wa=;m0F%LG_%~hY?T;8zDmdh}1Tq&3Grb{)|YqAUI=OPY>fNU;%d*Z5D&o*53 zVeg=8X z`>c~BdLGgJ_(d!4RjAgYy>sL3mdJEUu$+W5yR5hHx9zVPlSgJ9~_r}J*$PaG6US7q|*jOCSmq# zXvRPQM4}|t{(oVhanJwrt+(7YZn;sKU$|GGr}eJy4`gIrm=?VR*Szh986l9IMLjT$ z-z{-L6VFge3vi`m!gmF}R{k463wQ+KBCtnOxmadJhKtrMUq$UCz8LfK#EZ8?6udBu zq>eK$gMerzNn-Gmdw<>BkKY4cMA|=^`EX{(`SjJ_&g?~I_M{mP>RQlS@p*USxsk=! zN7ixKND9Qtink3e!egSI9pu%bZMs~6{7_~V!?X-XW<5|dq`U8s&0vRjgzKnbW??@@ z@j8g-$wqE!ygVSCdB}PLr+9e#uMQZ_)8cFzDlWoCz2I%fPn}|=#gP|Lq`?pFX}hht zrzObC@D`(52UiUvZ|4kyhYe%33`eGh!4LpkakokZYmH&9U`TI<=wZV+cH+vUPy4=W7m;@vXCPpo%+^%NYuzG3If zMfy6s4Fm8MBaR5NCJKV^&!8ws--d~B`D-$?PlmoCi7$xu1sOQHJX}faym&x{kH#k| z@xA2e0hxHF1O%-XCE94U<3jafcTK${bW~%5H4*BgSh6NVT~kQB!yBmuiPHIP2>X5_ zoEU{V(Wtj3LVX0rGSq0)R#Q<6qB+i~h0#2M+AE~5zdAHheR0fRI2IDFS3}X72=!64 mrzS&>;ONdEz_Nb~Uk literal 0 HcmV?d00001 diff --git a/services/telegram-bot/bot.py b/services/telegram-bot/bot.py index e29b527..cbc6dd6 100644 --- a/services/telegram-bot/bot.py +++ b/services/telegram-bot/bot.py @@ -41,6 +41,7 @@ SEVERE_SCRIPT = os.path.join(SCRIPT_DIR, "severe_weather.py") ICE_CHECK_SCRIPT = os.path.join(SCRIPT_DIR, "check_ghiaccio.py") IRRIGATION_SCRIPT = os.path.join(SCRIPT_DIR, "smart_irrigation_advisor.py") SNOW_RADAR_SCRIPT = os.path.join(SCRIPT_DIR, "snow_radar.py") +FOTOVOLTAICO_SCRIPT = os.path.join(SCRIPT_DIR, "fotovoltaico.py") # FILE STATO VIAGGI VIAGGI_STATE_FILE = os.path.join(SCRIPT_DIR, "viaggi_attivi.json") @@ -237,6 +238,7 @@ def restricted(func): async def wrapped(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs): user_id = update.effective_user.id if user_id not in ALLOWED_IDS: + logger.warning("Comando da utente non in ALLOWED_IDS: user_id=%s", user_id) return return await func(update, context, *args, **kwargs) return wrapped @@ -248,7 +250,7 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: [InlineKeyboardButton("🛡️ Pi-hole", callback_data="menu_pihole"), InlineKeyboardButton("🌐 Rete", callback_data="menu_net")], [InlineKeyboardButton("🌤️ Meteo Casa", callback_data="req_meteo_home"), InlineKeyboardButton("📜 Logs", callback_data="menu_logs")] ] - text = "🎛 **Loogle Control Center v8.1**\nComandi disponibili:\n🔹 `/meteo `\n🔹 `/meteo7 ` (Previsione 7gg)\n🔹 Pulsanti sotto" + text = "🎛 **Loogle Control Center v8.1**\nComandi disponibili:\n🔹 `/meteo `\n🔹 `/meteo7 ` (Previsione 7gg)\n🔹 `/fotovoltaico` (Previsione produzione FV)\n🔹 Pulsanti sotto" if update.message: await update.message.reply_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") else: await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") @@ -365,6 +367,18 @@ async def snowradar_command(update: Update, context: ContextTypes.DEFAULT_TYPE) # Avvia in background subprocess.Popen(cmd, cwd=SCRIPT_DIR) +@restricted +async def fotovoltaico_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Comando /fotovoltaico: previsione produzione 72h future e curva 48h passate (ICON Italia).""" + chat_id = str(update.effective_chat.id) + cmd = ["python3", FOTOVOLTAICO_SCRIPT, "--telegram", "--chat_id", chat_id] + await update.message.reply_text( + "☀️ **Fotovoltaico**\n\n" + "Generazione curve previsione (72h future + 48h passate)... I grafici verranno inviati a breve.", + parse_mode="Markdown" + ) + subprocess.Popen(cmd, cwd=SCRIPT_DIR) + @restricted async def irrigazione_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Comando /irrigazione: consulente agronomico per gestione irrigazione""" @@ -849,6 +863,7 @@ def main(): application.add_handler(CommandHandler("road", road_command)) application.add_handler(CommandHandler("irrigazione", irrigazione_command)) application.add_handler(CommandHandler("snowradar", snowradar_command)) + application.add_handler(CommandHandler("fotovoltaico", fotovoltaico_command)) application.add_handler(CallbackQueryHandler(button_handler)) job_queue = application.job_queue diff --git a/services/telegram-bot/fotovoltaico.py b/services/telegram-bot/fotovoltaico.py new file mode 100644 index 0000000..9edf0d8 --- /dev/null +++ b/services/telegram-bot/fotovoltaico.py @@ -0,0 +1,816 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Fotovoltaico: analisi e previsione produzione con modello ICON Italia (ItaliaMeteo ARPAE). +- Previsione 72h future (Weather Forecast API) +- Previsione teorica 48h passate (Historical Forecast API) per confronto con produzione reale +- Produzione reale (opzionale) da API SolarEdge Monitoring +- Grafici per colpo d'occhio + +Configurazione impianto: +- Inverter: 6 kW (SolarEdge) +- 22 pannelli Longi Solar (415 Wp ciascuno, totale 9,13 kWp) +- Orientamenti: vedi PANEL_GROUPS + +SolarEdge: imposta SOLAREDGE_API_KEY e SOLAREDGE_SITE_ID (env o file .env / ~/.solaredge_fotovoltaico) +per mostrare la produzione reale sul grafico 48h passate. + +""" + +from __future__ import annotations + +import argparse +import glob +import logging +import os +import sys +from datetime import datetime, timedelta +from io import BytesIO +from typing import Any, Dict, List, Optional, Tuple + +import requests +from zoneinfo import ZoneInfo + +# Grafici +import matplotlib +matplotlib.use("Agg") +import matplotlib.dates as mdates +import matplotlib.pyplot as plt +import numpy as np + +from open_meteo_client import open_meteo_get + +# ----------------------------------------------------------------------------- +# Configurazione +# ----------------------------------------------------------------------------- +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +TZ = "Europe/Rome" +TZINFO = ZoneInfo(TZ) + +# Casa (stessa di meteo.py) +HOME_LAT = 43.9356 +HOME_LON = 12.4296 + +# Impianto +INVERTER_KW = 6.0 +PANEL_WP = 415.0 # Longi Solar, 415 Wp per modulo +NUM_PANELS = 22 +P_PEAK_TOTAL_W = NUM_PANELS * PANEL_WP # 9130 Wp = 9,13 kWp +# Fisica termica (PVLib-style): i pannelli producono di più quando fa freddo (Wp @ 25°C). +TEMP_COEFF_POWER = -0.0035 # Longi Hi-MO ~ -0.35%/°C +NOCT = 45.0 # Nominal Operating Cell Temperature (°C) +SYSTEM_LOSSES = 0.10 # 10% perdite fisse (cavi, inverter, sporco) → PR eff. ~0.90 +# Correzione empirica: impostabile via env PRODUCTION_CORRECTION_FACTOR o file. +# Se previsti < reali in modo sistematico, provare 1.2–1.35 (es. in ~/.solaredge_fotovoltaico). +PRODUCTION_CORRECTION_FACTOR = 1.0 + +# Gruppi pannelli: (tilt_deg, azimuth_compass_reale_deg, numero_pannelli). +# Azimut = bussola reale osservata (0=N, 90=E, 180=S, 270=W). Non usare i valori di monitoring.solaredge (spesso sbagliati). +# 1.1.1 a 72°; 1.1.2-10 a 158°; 1.2.1-2 a 253°; 1.2.3-10 + 1.2.12 a 72°; 1.2.11 a 190°. Due gruppi a 72° accorpati in uno. +PANEL_GROUPS = [ + (34, 72, 10), # 1.1.1 + 1.2.3-10 + 1.2.12 (Est) + (34, 158, 9), # 1.1.2 - 1.1.10 (Sud-Sud-Est) + (34, 253, 2), # 1.2.1, 1.2.2 (Ovest-Sud-Ovest) + (34, 190, 1), # 1.2.11 (Sud-Sud-Ovest) +] + +# API Open-Meteo: usiamo solo global_tilted_irradiance (W/m²) con &tilt= e &azimuth=. +# È l'irradianza reale sul piano del pannello (diretta+diffusa inclinate); non servono +# direct_radiation/diffuse_radiation separati. shortwave_radiation=GHI orizzontale non usato. +FORECAST_URL = "https://api.open-meteo.com/v1/forecast" +HISTORICAL_FORECAST_URL = "https://historical-forecast-api.open-meteo.com/v1/forecast" +MODEL_ICON_ITALIA = "italia_meteo_arpae_icon_2i" +MODEL_AROME_HD = "meteofrance_arome_france_hd" # Francia + limitrofi; fuori area può non restituire dati +HTTP_HEADERS = {"User-Agent": "loogle-bot-fotovoltaico/2.0"} + +# Telegram +TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token") +TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token" + +# SolarEdge Monitoring API (opzionale: produzione reale sul grafico 48h) +# Chiave da https://monitoring.solaredge.com (Account → API Access); Site ID dal portale +SOLAREDGE_BASE = "https://monitoringapi.solaredge.com" +SOLAREDGE_SITE_ENERGY_PATH = "/site/{site_id}/energy" +SOLAREDGE_SITE_POWER_PATH = "/site/{site_id}/power" +DEFAULT_SOLAREDGE_SITE_ID = "3079750" # Usato se SOLAREDGE_SITE_ID non è impostato + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +def get_production_correction_factor() -> float: + """Fattore di correzione produzione (default 1.0). Env: PRODUCTION_CORRECTION_FACTOR.""" + s = (os.environ.get("PRODUCTION_CORRECTION_FACTOR") or "").strip() + if s: + try: + return max(0.1, min(3.0, float(s))) + except ValueError: + pass + for path in ( + os.path.expanduser("~/.solaredge_fotovoltaico"), + os.path.join(SCRIPT_DIR, ".env"), + ): + if not os.path.isfile(path): + continue + try: + with open(path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line.startswith("PRODUCTION_CORRECTION_FACTOR="): + v = line.split("=", 1)[1].strip().strip("'\"") + if v: + return max(0.1, min(3.0, float(v))) + except Exception: + pass + return PRODUCTION_CORRECTION_FACTOR + + +def compass_to_open_meteo_azimuth(compass_deg: float) -> float: + """Converte azimut compass (0=N, 90=E, 180=S, 270=W) in Open-Meteo (0=S, -90=E, 90=W, 180=N). Formula corretta: compass - 180 (non 180 - compass, altrimenti Est/Ovest si invertono).""" + return compass_deg - 180.0 + + +def calculate_pv_power(gti: float, temp_air: float, n_panels: int) -> float: + """Potenza DC (kW) del gruppo con fisica termica: freddo = più efficienza rispetto a STC (25°C).""" + if gti <= 0: + return 0.0 + t_cell = temp_air + (gti / 800.0) * (NOCT - 20.0) + delta_t = t_cell - 25.0 + temp_factor = 1.0 + (TEMP_COEFF_POWER * delta_t) + p_peak_group_kw = (n_panels * PANEL_WP) / 1000.0 + p_dc = p_peak_group_kw * (gti / 1000.0) * temp_factor * (1.0 - SYSTEM_LOSSES) + return max(0.0, p_dc) + + +def fetch_gti_forecast( + lat: float, + lon: float, + tilt: float, + azimuth_om: float, + forecast_hours: int = 72, + past_hours: int = 0, + model: str = MODEL_ICON_ITALIA, +) -> Optional[Dict[str, Any]]: + """Recupera GTI e temperature_2m dalla Weather Forecast API (modello specificabile).""" + params = { + "latitude": lat, + "longitude": lon, + "timezone": "auto", + "models": model, + "hourly": "global_tilted_irradiance,temperature_2m", + "tilt": tilt, + "azimuth": azimuth_om, + "forecast_hours": forecast_hours, + "past_hours": past_hours, + } + try: + r = open_meteo_get( + FORECAST_URL, + params=params, + headers=HTTP_HEADERS, + timeout=(8, 30), + ) + if r.status_code != 200: + logger.warning("Forecast API status %s: %s", r.status_code, r.text[:300]) + return None + return r.json() + except Exception as e: + logger.exception("Forecast API error: %s", e) + return None + + +def fetch_gti_historical( + lat: float, + lon: float, + tilt: float, + azimuth_om: float, + start_date: str, + end_date: str, +) -> Optional[Dict[str, Any]]: + """Recupera GTI e temperature_2m dalla Historical Forecast API (previsione teorica passata).""" + params = { + "latitude": lat, + "longitude": lon, + "start_date": start_date, + "end_date": end_date, + "timezone": "auto", + "models": MODEL_ICON_ITALIA, + "hourly": "global_tilted_irradiance,temperature_2m", + "tilt": tilt, + "azimuth": azimuth_om, + } + try: + r = open_meteo_get( + HISTORICAL_FORECAST_URL, + params=params, + headers=HTTP_HEADERS, + timeout=(8, 45), + ) + if r.status_code != 200: + logger.warning("Historical Forecast API status %s: %s", r.status_code, r.text[:300]) + return None + return r.json() + except Exception as e: + logger.exception("Historical Forecast API error: %s", e) + return None + + +def production_from_groups( + hourly_times: List[str], + gti_per_group: List[List[Optional[float]]], + temp_per_hour: List[Optional[float]], + panel_counts: List[int], +) -> Tuple[List[float], List[float]]: + """ + Calcola la produzione per ora: ogni gruppo con GTI + temperatura aria; fisica termica + (freddo = più efficienza). Produzione totale = SOMMA gruppi, cap inverter, poi fattore correzione. + """ + total_panels = sum(panel_counts) + if total_panels != NUM_PANELS: + logger.warning( + "Somma pannelli per gruppo = %s (attesi %s); verifica PANEL_GROUPS", + total_panels, NUM_PANELS, + ) + n = len(hourly_times) + gti_equivalent = [] + power_kw = [] + correction = get_production_correction_factor() + for i in range(n): + temp = temp_per_hour[i] if i < len(temp_per_hour) and temp_per_hour[i] is not None else 15.0 + p_dc_total = 0.0 + gti_sum_weighted = 0.0 + for gti_list, n_panels in zip(gti_per_group, panel_counts): + if gti_list is not None and i < len(gti_list) and gti_list[i] is not None: + gti_w = gti_list[i] + p_dc_total += calculate_pv_power(gti_w, temp, n_panels) + gti_sum_weighted += gti_w * (n_panels / total_panels) + p_ac_kw = min(p_dc_total, INVERTER_KW) * correction + power_kw.append(round(p_ac_kw, 3)) + gti_equivalent.append(gti_sum_weighted) + return gti_equivalent, power_kw + + +def fetch_forecast_72h(lat: float, lon: float) -> Optional[Tuple[List[str], List[float], List[float]]]: + """ + Ottiene previsione 72h come curve intere: oggi, domani, dopodomani (sempre 3 giorni pieni). + Indipendentemente dall'ora di chiamata, si richiedono dati da mezzanotte di oggi per 72 ore. + """ + now = datetime.now(TZINFO) + midnight_today = now.replace(hour=0, minute=0, second=0, microsecond=0) + past_hours = int((now - midnight_today).total_seconds() / 3600) + # Richiedi da mezzanotte (past_hours) + 72h future + total_hours = past_hours + 72 + gti_per_orientation = [] + panel_weights = [] + times_ref = None + temp_per_hour = None + for tilt, azimuth_compass, count in PANEL_GROUPS: + az_om = compass_to_open_meteo_azimuth(azimuth_compass) + data = fetch_gti_forecast( + lat, lon, tilt, az_om, + forecast_hours=72, + past_hours=past_hours, + ) + if not data or "hourly" not in data: + logger.warning("Forecast mancante per tilt=%s az=%s", tilt, azimuth_compass) + gti_per_orientation.append([None] * total_hours) + else: + if times_ref is None: + times_ref = data["hourly"].get("time", []) + raw = data["hourly"].get("temperature_2m") + temp_per_hour = raw if raw else [15.0] * len(times_ref) + gti = data["hourly"].get("global_tilted_irradiance") + if not gti: + gti = data["hourly"].get("global_tilted_irradiance_sum") + gti_per_orientation.append(gti or [None] * total_hours) + panel_weights.append(count) + times = times_ref or [] + if not times: + return None + if temp_per_hour is None: + temp_per_hour = [15.0] * len(times) + gti_w, power_kw = production_from_groups(times, gti_per_orientation, temp_per_hour, panel_weights) + # Filtra: solo le 72 ore da mezzanotte di oggi (oggi, domani, dopodomani interi) + end_window = midnight_today + timedelta(hours=72) + dt_list = parse_times_to_datetime(times) + filtered_times = [] + filtered_gti = [] + filtered_power = [] + for i, dt in enumerate(dt_list): + if midnight_today <= dt < end_window and len(filtered_times) < 72: + filtered_times.append(times[i]) + filtered_gti.append(gti_w[i] if i < len(gti_w) else 0) + filtered_power.append(power_kw[i] if i < len(power_kw) else 0) + if not filtered_times: + return None + return filtered_times, filtered_gti, filtered_power + + +def _fetch_forecast_72h_for_model( + lat: float, lon: float, model: str +) -> Optional[Tuple[List[str], List[float]]]: + """ + Come fetch_forecast_72h ma per un singolo modello; ritorna (filtered_times, filtered_power) + o None se il modello non restituisce dati (es. AROME HD fuori copertura). + """ + now = datetime.now(TZINFO) + midnight_today = now.replace(hour=0, minute=0, second=0, microsecond=0) + past_hours = int((now - midnight_today).total_seconds() / 3600) + total_hours = past_hours + 72 + gti_per_orientation = [] + panel_weights = [] + times_ref = None + temp_per_hour = None + for tilt, azimuth_compass, count in PANEL_GROUPS: + az_om = compass_to_open_meteo_azimuth(azimuth_compass) + data = fetch_gti_forecast( + lat, lon, tilt, az_om, + forecast_hours=72, + past_hours=past_hours, + model=model, + ) + if not data or "hourly" not in data: + logger.warning("Forecast (%s) mancante per tilt=%s az=%s", model, tilt, azimuth_compass) + gti_per_orientation.append([None] * total_hours) + else: + if times_ref is None: + times_ref = data["hourly"].get("time", []) + raw = data["hourly"].get("temperature_2m") + temp_per_hour = raw if raw else [15.0] * len(times_ref) + gti = data["hourly"].get("global_tilted_irradiance") + if not gti: + gti = data["hourly"].get("global_tilted_irradiance_sum") + gti_per_orientation.append(gti or [None] * total_hours) + panel_weights.append(count) + times = times_ref or [] + if not times: + return None + if temp_per_hour is None: + temp_per_hour = [15.0] * len(times) + gti_w, power_kw = production_from_groups(times, gti_per_orientation, temp_per_hour, panel_weights) + end_window = midnight_today + timedelta(hours=72) + dt_list = parse_times_to_datetime(times) + filtered_times = [] + filtered_power = [] + for i, dt in enumerate(dt_list): + if midnight_today <= dt < end_window and len(filtered_times) < 72: + filtered_times.append(times[i]) + filtered_power.append(power_kw[i] if i < len(power_kw) else 0) + if not filtered_times: + return None + return filtered_times, filtered_power + + +def fetch_forecast_72h_multi( + lat: float, lon: float +) -> Optional[Tuple[List[str], List[Tuple[str, List[float]]]]]: + """ + Previsione 72h da più modelli (ICON Italia + AROME HD se disponibile). + Ritorna (times_72, [(label, power_list), ...]). AROME HD copre Francia e limitrofi; + fuori area può non restituire dati e viene omesso. + """ + result_icon = _fetch_forecast_72h_for_model(lat, lon, MODEL_ICON_ITALIA) + if not result_icon: + return None + ref_times, power_icon = result_icon + n = len(ref_times) + series: List[Tuple[str, List[float]]] = [("ICON Italia", power_icon)] + + result_arome = _fetch_forecast_72h_for_model(lat, lon, MODEL_AROME_HD) + if result_arome: + times_arome, power_arome = result_arome + # Allinea alla griglia oraria di ref_times (AROME può avere meno ore, es. 48) + power_aligned = [] + for t in ref_times: + if t in times_arome: + idx = times_arome.index(t) + power_aligned.append(power_arome[idx] if idx < len(power_arome) else 0.0) + else: + power_aligned.append(0.0) + # Includi AROME solo se ha dati utili (fuori copertura restituisce spesso tutti zero) + if sum(power_aligned) >= 0.1: + series.append(("AROME HD", power_aligned)) + logger.info("AROME HD disponibile per confronto previsioni") + else: + logger.info("AROME HD senza dati utili per questa località (copertura Francia/limitrofi)") + else: + logger.info("AROME HD non disponibile per questa località (copertura Francia/limitrofi)") + + return ref_times, series + + +def fetch_historical_48h(lat: float, lon: float) -> Optional[Tuple[List[str], List[float], List[float]]]: + """ + Ottiene previsione teorica per ieri l'altro e ieri (2 giorni pieni, senza oggi). + Usa orientamenti reali (bussola osservata) e temperature_2m per la fisica termica. + Finestra: solo ieri l'altro + ieri, così il grafico "Ultime 48h" non include la giornata odierna. + """ + today = datetime.now(TZINFO).date() + start_d = datetime.combine(today - timedelta(days=2), datetime.min.time()).replace(tzinfo=TZINFO) + end_d = datetime.combine(today - timedelta(days=1), datetime.min.time()).replace(tzinfo=TZINFO) + start_date = start_d.strftime("%Y-%m-%d") + end_date = end_d.strftime("%Y-%m-%d") + gti_per_orientation = [] + panel_weights = [] + times_ref = None + temp_per_hour = None + for tilt, azimuth_compass, count in PANEL_GROUPS: + az_om = compass_to_open_meteo_azimuth(azimuth_compass) + data = fetch_gti_historical(lat, lon, tilt, az_om, start_date, end_date) + if not data or "hourly" not in data: + logger.warning("Historical mancante per tilt=%s az=%s", tilt, azimuth_compass) + gti_per_orientation.append([]) + else: + if times_ref is None: + times_ref = data["hourly"].get("time", []) + raw = data["hourly"].get("temperature_2m") + temp_per_hour = raw if raw else [15.0] * len(times_ref) + gti = data["hourly"].get("global_tilted_irradiance") + if not gti: + gti = data["hourly"].get("global_tilted_irradiance_sum") + gti_per_orientation.append(gti or []) + panel_weights.append(count) + times = times_ref or [] + if not times or not gti_per_orientation: + return None + if temp_per_hour is None: + temp_per_hour = [15.0] * len(times) + n = len(times) + for i, gti_list in enumerate(gti_per_orientation): + if len(gti_list) < n: + gti_per_orientation[i] = gti_list + [None] * (n - len(gti_list)) + elif len(gti_list) > n: + gti_per_orientation[i] = gti_list[:n] + gti_w, power_kw = production_from_groups(times, gti_per_orientation, temp_per_hour, panel_weights) + return times, gti_w, power_kw + + +def load_solaredge_config() -> Tuple[str, str]: + """Ritorna (api_key, site_id). site_id usa DEFAULT_SOLAREDGE_SITE_ID se non impostato.""" + api_key = (os.environ.get("SOLAREDGE_API_KEY") or "").strip() + site_id = (os.environ.get("SOLAREDGE_SITE_ID") or "").strip() or DEFAULT_SOLAREDGE_SITE_ID + if api_key and site_id: + return api_key, site_id + for path in ( + os.path.expanduser("~/.solaredge_fotovoltaico"), + os.path.join(SCRIPT_DIR, ".env"), + ): + if not os.path.isfile(path): + continue + try: + with open(path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + k, v = line.split("=", 1) + k, v = k.strip(), v.strip().strip('"\'') + if k == "SOLAREDGE_API_KEY": + api_key = api_key or v + elif k == "SOLAREDGE_SITE_ID": + site_id = site_id or v + except Exception as e: + logger.debug("Lettura %s: %s", path, e) + if api_key and site_id: + break + return api_key or "", site_id or DEFAULT_SOLAREDGE_SITE_ID + + +def fetch_solaredge_energy_48h(api_key: str, site_id: str) -> Optional[Tuple[List[datetime], List[float]]]: + """Recupera produzione reale per ieri l'altro e ieri (stessa finestra del grafico 48h, senza oggi).""" + today = datetime.now(TZINFO).date() + start_date = (today - timedelta(days=2)).strftime("%Y-%m-%d") + end_date = (today - timedelta(days=1)).strftime("%Y-%m-%d") + url = SOLAREDGE_BASE + SOLAREDGE_SITE_ENERGY_PATH.format(site_id=site_id) + params = { + "api_key": api_key, + "startDate": start_date, + "endDate": end_date, + "timeUnit": "HOUR", + } + try: + r = requests.get(url, params=params, headers=HTTP_HEADERS, timeout=(8, 25)) + if r.status_code != 200: + logger.warning("SolarEdge API status %s: %s", r.status_code, r.text[:300]) + return None + data = r.json() + energy_block = data.get("energy", {}) or data.get("siteEnergy", {}) + values = energy_block.get("values") or data.get("values") + if not values: + logger.warning("SolarEdge: nessun 'energy.values' in risposta") + return None + times_out: List[datetime] = [] + power_kw_out: List[float] = [] + for item in values: + val = item.get("value") + if val is None: + continue + date_str = item.get("date") or item.get("time") or "" + try: + if "T" in date_str or " " in date_str: + dt = datetime.fromisoformat(date_str.replace("Z", "+00:00").strip()) + else: + dt = datetime.strptime(date_str[:10], "%Y-%m-%d").replace(tzinfo=TZINFO) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=TZINFO) + except Exception: + continue + power_kw = float(val) / 1000.0 + times_out.append(dt) + power_kw_out.append(round(power_kw, 3)) + if not times_out: + return None + return times_out, power_kw_out + except Exception as e: + logger.exception("SolarEdge API error: %s", e) + return None + + +def kwh_per_day_from_series( + times: List[str], + power_kw: List[float], +) -> List[Tuple[str, float]]: + """Raggruppa power_kw (kWh/ora) per data e ritorna [(data dd/mm, kwh), ...].""" + from collections import defaultdict + dt_list = parse_times_to_datetime(times) + by_date: Dict[str, float] = defaultdict(float) + for i, dt in enumerate(dt_list): + if i < len(power_kw): + key = dt.strftime("%Y-%m-%d") + by_date[key] += power_kw[i] + out: List[Tuple[str, float]] = [] + for d_str in sorted(by_date.keys()): + d = datetime.strptime(d_str, "%Y-%m-%d").date() + label = d.strftime("%d/%m") + out.append((label, round(by_date[d_str], 1))) + return out + + +def parse_times_to_datetime(times: List[str]) -> List[datetime]: + out = [] + for t in times: + try: + if "T" in t: + dt = datetime.fromisoformat(t.replace("Z", "+00:00")) + else: + dt = datetime.strptime(t, "%Y-%m-%d").replace(tzinfo=TZINFO) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=TZINFO) + out.append(dt) + except Exception: + out.append(datetime.now(TZINFO)) + return out + + +def plot_past_48h( + times: List[str], + power_kw: List[float], + title_suffix: str = "", + real_times: Optional[List[datetime]] = None, + real_power_kw: Optional[List[float]] = None, +) -> bytes: + """Genera il grafico in memoria e ritorna i byte PNG (non salva su disco).""" + fig, ax = plt.subplots(figsize=(10, 4.5)) + dt_list = parse_times_to_datetime(times) + ax.fill_between(dt_list, 0, power_kw, alpha=0.5, color="steelblue") + ax.plot(dt_list, power_kw, color="navy", linewidth=1.2, label="Previsione (ICON Italia)") + if real_times and real_power_kw and len(real_times) == len(real_power_kw): + ax.plot(real_times, real_power_kw, color="darkorange", linewidth=1.4, label="Produzione reale (SolarEdge)") + ax.axhline(y=INVERTER_KW, color="red", linestyle="--", alpha=0.7, label=f"Limite inverter {INVERTER_KW} kW") + ax.set_ylabel("Potenza AC (kW)") + ax.set_xlabel("Data / Ora") + ax.set_title(f"Produzione – Ieri l'altro e Ieri{title_suffix}") + ax.legend(loc="upper right") + all_vals = list(power_kw) + (list(real_power_kw) if real_power_kw else []) + ax.set_ylim(0, max(INVERTER_KW * 1.05, max(all_vals) * 1.1) if all_vals else 7) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%d/%m %H:%M", tz=TZINFO)) + ax.xaxis.set_major_locator(mdates.HourLocator(interval=6)) + plt.xticks(rotation=25) + plt.tight_layout() + buf = BytesIO() + plt.savefig(buf, format="png", dpi=120) + plt.close() + return buf.getvalue() + + +def plot_future_72h( + times: List[str], + power_series: List[Tuple[str, List[float]]], + title_suffix: str = "", +) -> bytes: + """ + Genera il grafico in memoria. power_series: [(label, power_kw_list), ...]; + la prima serie ha anche fill_between, le altre solo linea (confronto modelli). + """ + if not power_series: + plt.close("all") + return b"" + fig, ax = plt.subplots(figsize=(10, 4.5)) + dt_list = parse_times_to_datetime(times) + colors = ["green", "blue", "purple"] + all_vals = [] + for i, (label, power_kw) in enumerate(power_series): + if len(power_kw) != len(dt_list): + power_kw = (power_kw + [0.0] * len(dt_list))[: len(dt_list)] + all_vals.extend(power_kw) + color = colors[i % len(colors)] + if i == 0: + ax.fill_between(dt_list, 0, power_kw, alpha=0.4, color=color) + ax.plot(dt_list, power_kw, color=color, linewidth=1.2, label=label) + ax.axhline(y=INVERTER_KW, color="red", linestyle="--", alpha=0.7, label=f"Limite inverter {INVERTER_KW} kW") + ax.set_ylabel("Potenza AC (kW)") + ax.set_xlabel("Data / Ora") + ax.set_title(f"Previsione produzione – Oggi, Domani, Dopodomani{title_suffix}") + ax.legend(loc="upper right") + ax.set_ylim(0, max(INVERTER_KW * 1.05, max(all_vals) * 1.1) if all_vals else 7) + ax.xaxis.set_major_formatter(mdates.DateFormatter("%d/%m %H:%M", tz=TZINFO)) + ax.xaxis.set_major_locator(mdates.HourLocator(interval=6)) + plt.xticks(rotation=25) + plt.tight_layout() + buf = BytesIO() + plt.savefig(buf, format="png", dpi=120) + plt.close() + return buf.getvalue() + + +def load_bot_token() -> str: + tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() or os.environ.get("BOT_TOKEN", "").strip() + if tok: + return tok + for path in (TOKEN_FILE_HOME, TOKEN_FILE_ETC): + try: + with open(path, "r", encoding="utf-8") as f: + t = f.read().strip() + if t: + return t + except FileNotFoundError: + continue + return "" + + +def telegram_send_photo(photo_bytes: bytes, caption: str, chat_id: str) -> bool: + """Invia una foto a Telegram da bytes (nessun file su disco).""" + token = load_bot_token() + if not token: + logger.warning("Token Telegram mancante") + return False + url = f"https://api.telegram.org/bot{token}/sendPhoto" + try: + r = requests.post( + url, + data={"chat_id": chat_id, "caption": caption, "parse_mode": "Markdown"}, + files={"photo": ("grafico.png", photo_bytes, "image/png")}, + timeout=20, + ) + if r.status_code != 200: + logger.error("Telegram sendPhoto %s: %s", r.status_code, r.text[:400]) + return False + return True + except Exception as e: + logger.exception("Telegram sendPhoto: %s", e) + return False + + +def telegram_send_message(text: str, chat_id: str) -> bool: + token = load_bot_token() + if not token: + return False + url = f"https://api.telegram.org/bot{token}/sendMessage" + try: + r = requests.post( + url, + json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"}, + timeout=15, + ) + return r.status_code == 200 + except Exception as e: + logger.exception("Telegram sendMessage: %s", e) + return False + + +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") + parser.add_argument("--lon", type=float, default=HOME_LON, help="Longitudine") + parser.add_argument("--telegram", action="store_true", help="Invia grafici e messaggio a Telegram") + 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") + args = parser.parse_args() + lat, lon = args.lat, args.lon + send_telegram = args.telegram and args.chat_id.strip() + + # Rimuovi eventuali grafici fotovoltaico salvati in precedenza (non ne salviamo più) + for old in glob.glob(os.path.join(SCRIPT_DIR, "fotovoltaico_*.png")): + try: + os.remove(old) + logger.info("Rimosso %s", old) + except OSError as e: + logger.warning("Impossibile rimuovere %s: %s", old, e) + hist: Optional[Tuple[List[str], List[float], List[float]]] = None + fore: Optional[Tuple[List[str], List[float], List[float]]] = None + + 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 and not args.no_past: + logger.info("Recupero produzione reale SolarEdge (48h)...") + real_48h = fetch_solaredge_energy_48h(solaredge_api_key, solaredge_site_id) + if not real_48h: + logger.warning("SolarEdge: dati 48h non disponibili (controlla API key e Site ID)") + + past_ok = False + if not args.no_past: + logger.info( + "Pannelli: %s gruppi (tilt, azimut°, n) = %s", + len(PANEL_GROUPS), + [(t, a, n) for t, a, n in PANEL_GROUPS], + ) + logger.info("Recupero dati Historical Forecast (48h passate)...") + hist = fetch_historical_48h(lat, lon) + if hist: + times_past, _, power_past = hist + past_days_debug = kwh_per_day_from_series(hist[0], hist[2]) + logger.info("Historical 48h kWh per giorno (previsione): %s", past_days_debug) + real_t = (real_48h[0], real_48h[1]) if real_48h else (None, None) + img_past = plot_past_48h(times_past, power_past, real_times=real_t[0], real_power_kw=real_t[1]) + past_ok = True + if send_telegram: + caption = "📊 *Produzione – Ieri l'altro e Ieri* (ICON Italia" + (" + SolarEdge reale" if real_48h else "") + ")" + telegram_send_photo(img_past, caption, args.chat_id) + else: + logger.warning("Nessun dato Historical Forecast per le 48h passate") + + future_ok = False + fore_multi: Optional[Tuple[List[str], List[Tuple[str, List[float]]]]] = None + if not args.no_future: + logger.info("Recupero dati Forecast (72h future, ICON + AROME HD)...") + fore_multi = fetch_forecast_72h_multi(lat, lon) + if fore_multi: + times_fut, power_series = fore_multi + img_fut = plot_future_72h(times_fut, power_series) + future_ok = True + if send_telegram: + models_label = " / ".join(s[0] for s in power_series) + telegram_send_photo( + img_fut, + f"☀️ *Previsione – Oggi, Domani, Dopodomani* ({models_label})", + args.chat_id, + ) + else: + logger.warning("Nessun dato Forecast per le 72h future") + + if send_telegram: + lines = ["🖥 *Fotovoltaico*", ""] + if past_ok and hist: + past_days = kwh_per_day_from_series(hist[0], hist[2]) + real_days = None + 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 or [])} + lines.append("📊 *Ultimi 2 giorni*") + for label, kwh in past_days: + part = f" {label} · prev. {kwh} kWh" + if label in real_by_date: + part += f" · _reale_ {real_by_date[label]} kWh" + lines.append(part) + lines.append("") + if future_ok and fore_multi: + times_fut, power_series = fore_multi + fut_days_per_model = [ + (name, kwh_per_day_from_series(times_fut, power_list)) + for name, power_list in power_series + ] + lines.append("☀️ *Prossimi 3 giorni*") + if len(fut_days_per_model) == 1: + for label, kwh in fut_days_per_model[0][1]: + lines.append(f" {label} · {kwh} kWh") + else: + dates = sorted({d for _, days_list in fut_days_per_model for d, _ in days_list}) + for d in dates: + parts = [f" {d}"] + 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: + short = "ICON" if "ICON" in name else "AROME" + parts.append(f"{short} {val}") + lines.append(" · ".join(parts) + " kWh") + lines.append("") + models_footer = " · ".join(s[0] for s in power_series) + lines.append(f"_Modello: {models_footer}_") + telegram_send_message("\n".join(lines), args.chat_id) + + if not past_ok and not future_ok: + logger.error("Nessun dato disponibile") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/services/telegram-bot/log_monitor.py b/services/telegram-bot/log_monitor.py index d2ebf73..2fcc5b3 100644 --- a/services/telegram-bot/log_monitor.py +++ b/services/telegram-bot/log_monitor.py @@ -19,6 +19,9 @@ EXCLUDED_FILES = { "road_weather.log", "snow_radar.log", } +# Log irrigazione: aggiornato solo quando lo script viene eseguito (cron --auto o /irrigazione). +# Se non c’è un cron giornaliero, il file può restare “non aggiornato” per giorni (normale in inverno). +STALE_EXCLUDE_BASENAMES = {"irrigation_advisor.log"} TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token") TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token" @@ -125,6 +128,8 @@ def analyze_logs(files: List[str], since: datetime.datetime, max_lines: int) -> break # Verifica se il log è "stale" (non aggiornato da più di 24 ore) + if os.path.basename(path) in STALE_EXCLUDE_BASENAMES: + continue # Non segnalare come stale (es. irrigazione: aggiornato solo a ogni run) if last_ts: hours_since = (now - last_ts).total_seconds() / 3600.0 if hours_since > 24: diff --git a/services/telegram-bot/meteo.py b/services/telegram-bot/meteo.py index c233764..5fdc78d 100644 --- a/services/telegram-bot/meteo.py +++ b/services/telegram-bot/meteo.py @@ -18,14 +18,10 @@ logger = logging.getLogger(__name__) # --- CONFIGURAZIONE METEO --- HOME_LAT = 43.9356 HOME_LON = 12.4296 -HOME_NAME = "🏠 Casa (Wide View ±12km)" +HOME_NAME = "🏠 Casa" TZ = "Europe/Berlin" TZINFO = ZoneInfo(TZ) -# Offset ~12-15km per i 5 punti -OFFSET_LAT = 0.12 -OFFSET_LON = 0.16 - OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search" HTTP_HEADERS = {"User-Agent": "loogle-bot-v10.5"} @@ -176,12 +172,12 @@ def get_coordinates(city_name: str): def choose_best_model(lat, lon, cc, is_home=False): """ Sceglie il modello meteo. - - Per Casa: usa AROME Seamless (ha snowfall) + - Per Casa: usa ICON Italia (ARPAE 2i) - migliore risoluzione spaziale per Italia/San Marino. - Per altre località: usa best match di Open-Meteo (senza specificare models) """ if is_home: - # Per Casa, usa AROME Seamless (ha snowfall e dati dettagliati) - return "meteofrance_seamless", "AROME HD" + # Per Casa, usa ICON Italia (risoluzione spaziale migliore per Italia/San Marino) + return "italia_meteo_arpae_icon_2i", "ICON Italia" else: # Per query worldwide, usa best match (Open-Meteo sceglie automaticamente) return None, "Best Match" @@ -189,7 +185,7 @@ def choose_best_model(lat, lon, cc, is_home=False): def get_forecast(lat, lon, model=None, is_home=False, timezone=None, retry_after_60s=False): """ Recupera forecast. Se model è None, usa best match di Open-Meteo. - Per Casa (is_home=True), usa AROME Seamless. + Per Casa (is_home=True), usa ICON Italia. Args: retry_after_60s: Se True, attende 10 secondi prima di riprovare (per retry) @@ -202,21 +198,15 @@ def get_forecast(lat, lon, model=None, is_home=False, timezone=None, retry_after logger.info("Attendo 10 secondi prima del retry...") time.sleep(10) - # Generiamo 5 punti: Centro, N, S, E, W - lats = [lat, lat + OFFSET_LAT, lat - OFFSET_LAT, lat, lat] - lons = [lon, lon, lon, lon + OFFSET_LON, lon - OFFSET_LON] - - lat_str = ",".join(map(str, lats)) - lon_str = ",".join(map(str, lons)) - + # Singola coordinata: cielo sopra il punto richiesto (casa o località). params = { - "latitude": lat_str, "longitude": lon_str, "timezone": tz_to_use, + "latitude": lat, "longitude": lon, "timezone": tz_to_use, "forecast_days": 3, "wind_speed_unit": "kmh", "precipitation_unit": "mm", - "hourly": "temperature_2m,apparent_temperature,relative_humidity_2m,cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,windspeed_10m,winddirection_10m,windgusts_10m,precipitation,rain,snowfall,weathercode,is_day,cape,visibility,uv_index" + "hourly": "temperature_2m,apparent_temperature,relative_humidity_2m,cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,windspeed_10m,winddirection_10m,windgusts_10m,precipitation,rain,showers,snowfall,weathercode,is_day,cape,visibility,uv_index" } - # Aggiungi models solo se specificato (per Casa usa AROME, per altre località best match) + # Aggiungi models solo se specificato (per Casa usa ICON Italia, per altre località best match) if model: params["models"] = model @@ -241,11 +231,7 @@ def get_forecast(lat, lon, model=None, is_home=False, timezone=None, retry_after logger.error(f"API Error {error_details}") return None, error_details # Restituisce anche i dettagli dell'errore response_data = r.json() - logger.info("get_forecast ok model=%s points=5 elapsed=%.2fs", model or "best_match", time.time() - t0) - # Open-Meteo per multiple locations (lat/lon separati da virgola) restituisce - # direttamente un dict con "hourly", "daily", etc. che contiene liste di valori - # per ogni location. Per semplicità, restituiamo il dict così com'è - # e lo gestiamo nel codice chiamante + logger.info("get_forecast ok model=%s elapsed=%.2fs", model or "best_match", time.time() - t0) return response_data, None except requests.exceptions.Timeout as e: error_details = f"Timeout dopo 20s: {str(e)}" @@ -323,14 +309,14 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", # Tentativo 1: Richiesta iniziale data_list, error_details = get_forecast(lat, lon, model_id, is_home=is_home, timezone=tz_to_use, retry_after_60s=False) - # Se fallisce e siamo a Casa con AROME, prova retry dopo 10 secondi - if not data_list and is_home and model_id == "meteofrance_seamless": - logger.warning(f"Primo tentativo AROME fallito: {error_details}. Retry dopo 10 secondi...") + # Se fallisce e siamo a Casa con ICON Italia, prova retry dopo 10 secondi + if not data_list and is_home and model_id == "italia_meteo_arpae_icon_2i": + logger.warning(f"Primo tentativo ICON Italia fallito: {error_details}. Retry dopo 10 secondi...") data_list, error_details = get_forecast(lat, lon, model_id, is_home=is_home, timezone=tz_to_use, retry_after_60s=True) # Se ancora fallisce e siamo a Casa, fallback a best match if not data_list and is_home: - logger.warning(f"AROME fallito anche dopo retry: {error_details}. Fallback a best match...") + logger.warning(f"ICON Italia fallito anche dopo retry: {error_details}. Fallback a best match...") data_list, error_details = get_forecast(lat, lon, None, is_home=False, timezone=tz_to_use, retry_after_60s=False) if data_list: model_name = "Best Match (fallback)" @@ -345,7 +331,6 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", if not isinstance(data_list, list): data_list = [data_list] - # Punto centrale (Casa) per dati specifici data_center = data_list[0] hourly_c = data_center.get("hourly", {}) times = hourly_c.get("time", []) @@ -353,12 +338,13 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", L = len(times) - # --- DATI LOCALI (CASA) --- + # --- DATI LOCALI --- l_temp = safe_get_list(hourly_c, "temperature_2m", L, 0) l_app = safe_get_list(hourly_c, "apparent_temperature", L, 0) l_rh = safe_get_list(hourly_c, "relative_humidity_2m", L, 50) l_prec = safe_get_list(hourly_c, "precipitation", L, 0) l_rain = safe_get_list(hourly_c, "rain", L, 0) + l_showers = safe_get_list(hourly_c, "showers", L, 0) l_snow = safe_get_list(hourly_c, "snowfall", L, 0) l_wspd = safe_get_list(hourly_c, "windspeed_10m", L, 0) l_gust = safe_get_list(hourly_c, "windgusts_10m", L, 0) @@ -369,8 +355,8 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", l_vis = safe_get_list(hourly_c, "visibility", L, 10000) l_uv = safe_get_list(hourly_c, "uv_index", L, 0) - # Se è Casa e AROME non fornisce visibilità (tutti None), recuperala da best match - if is_home and model_id == "meteofrance_seamless": + # Se è Casa e ICON Italia non fornisce visibilità (tutti None), recuperala da best match + if is_home and model_id == "italia_meteo_arpae_icon_2i": vis_check = [v for v in l_vis if v is not None] if not vis_check: # Tutti None, recupera da best match vis_data = get_visibility_forecast(lat, lon) @@ -383,26 +369,14 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", l_cl_mid_loc = safe_get_list(hourly_c, "cloud_cover_mid", L, 0) l_cl_hig_loc = safe_get_list(hourly_c, "cloud_cover_high", L, 0) - # --- DATI GLOBALI (MEDIA 5 PUNTI) --- - acc_cl_tot = [0.0] * L - points_cl_tot = [ [] for _ in range(L) ] - - for d in data_list: - h = d.get("hourly", {}) - for i in range(L): - cc = get_val(safe_get_list(h, "cloud_cover", L)[i]) - cl = get_val(safe_get_list(h, "cloud_cover_low", L)[i]) - cm = get_val(safe_get_list(h, "cloud_cover_mid", L)[i]) - ch = get_val(safe_get_list(h, "cloud_cover_high", L)[i]) - - # Calcolo robusto del totale per singolo punto - real_point_total = max(cc, cl, cm, ch) - - acc_cl_tot[i] += real_point_total - points_cl_tot[i].append(real_point_total) - - num_points = len(data_list) - avg_cl_tot = [x / num_points for x in acc_cl_tot] + # Nuvolosità (stesso punto della località) + avg_cl_tot = [] + for i in range(L): + cc = get_val(l_cl_tot_loc[i], 0) + cl = get_val(l_cl_low_loc[i], 0) + cm = get_val(l_cl_mid_loc[i], 0) + ch = get_val(l_cl_hig_loc[i], 0) + avg_cl_tot.append(max(cc, cl, cm, ch)) # --- DEBUG MODE --- if debug_mode: @@ -420,8 +394,8 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", code_now = int(get_val(l_code[idx])) output += f"Ora: {parse_time(times[idx]).strftime('%H:%M')}\n" - output += f"📍 **LOCALE (Casa)**: L:{int(loc_L)}% | H:{int(loc_H)}%\n" - output += f"🌍 **MEDIA GLOBALE**: {int(avg_cl_tot[idx])}%\n" + output += f"📍 **LOCALE**: L:{int(loc_L)}% | H:{int(loc_H)}%\n" + output += f"☁️ **Nv%**: {int(avg_cl_tot[idx])}%\n" output += f"❄️ **NEVE**: Codice={code_now}, Accumulo={get_val(l_snow[idx])}cm\n" decision = "H" @@ -521,17 +495,19 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", Sn = get_val(l_snow[idx], 0) 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) # Determina se è neve is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86]) - # Formattazione MM + # Formattazione MM: 0 se nulla, altrimenti il valore (i modelli danno 0 o il valore orario) p_suffix = "" if Code in [96, 99]: p_suffix = "G" elif Code in [66, 67]: p_suffix = "Z" - elif is_snowing and Pr >= 0.2: p_suffix = "N" - - p_s = "--" if Pr < 0.2 else f"{int(round(Pr))}{p_suffix}" + elif is_snowing and Pr_display > 0: p_suffix = "N" + p_s = "0" if Pr_display <= 0 else f"{int(round(Pr_display))}{p_suffix}" # --- CLOUD LOGIC --- Cl = int(get_val(l_cl_tot_loc[idx], 0)) @@ -587,10 +563,10 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", w_fmt = f"{w_txt:<5}" # --- ICONE --- - sky, sgx = get_icon_set(Pr, Sn, Code, IsDay, Cl, Vis, T, Rain, Gust, Cape, dominant_type) + sky, sgx = get_icon_set(Pr_display, Sn, Code, IsDay, Cl, Vis, T, Rain, Gust, Cape, dominant_type) # Se c'è precipitazione nevosa, mostra ❄️ nella colonna Sk (invece di 🌨️) - if is_snowing and Pr >= 0.2: + if is_snowing and Pr_display > 0: sky = "❄️" sky_fmt = f"{sky}{uv_suffix}" @@ -615,7 +591,7 @@ if __name__ == "__main__": args_parser = argparse.ArgumentParser() args_parser.add_argument("--query", help="Nome città") args_parser.add_argument("--home", action="store_true", help="Usa coordinate casa") - args_parser.add_argument("--debug", action="store_true", help="Mostra i valori dei 5 punti") + 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 = args_parser.parse_args() diff --git a/services/telegram-bot/previsione7.py b/services/telegram-bot/previsione7.py index fc0ba69..706bafc 100755 --- a/services/telegram-bot/previsione7.py +++ b/services/telegram-bot/previsione7.py @@ -47,6 +47,7 @@ MODEL_NAMES = { "icon_d2": "ICON-D2", "gfs_global": "GFS", "ecmwf_ifs04": "ECMWF", + "ecmwf_ifs": "ECMWF IFS", "jma_msm": "JMA MSM", "metno_nordic": "Yr.no", "ukmo_global": "UK MetOffice", @@ -57,26 +58,18 @@ MODEL_NAMES = { def choose_models_by_country(cc, is_home=False): """ Seleziona modelli meteo ottimali. - - Per Casa: usa AROME Seamless e ICON-D2 (alta risoluzione) - - Per Italia: usa italia_meteo_arpae_icon_2i (include snow_depth quando > 0) - - Per altre località: usa best match di Open-Meteo (senza specificare models) + - Per Casa e Italia: solo ICON Italia (ARPAE 2i); AROME HD non copre San Marino. + - Per altre località: usa best match di Open-Meteo (senza specificare models). Ritorna (short_term_models, long_term_models) """ cc = cc.upper() if cc else "UNKNOWN" - # Modelli a lungo termine (sempre globali, funzionano ovunque) long_term_default = ["gfs_global", "ecmwf_ifs04"] - if is_home: - # Per Casa, usa AROME Seamless, ICON-D2 e ICON Italia (alta risoluzione europea) - # ICON Italia include snow_depth quando disponibile (> 0) - return ["meteofrance_seamless", "icon_d2", "italia_meteo_arpae_icon_2i"], long_term_default - elif cc == "IT": - # Per Italia, usa ICON Italia (ARPAE 2i) che include snow_depth quando disponibile - return ["italia_meteo_arpae_icon_2i"], long_term_default + if is_home or cc == "IT": + # ICON Italia (0–72h) + ECMWF IFS per i giorni successivi (dove Icon Italia non arriva) + return ["italia_meteo_arpae_icon_2i"], ["ecmwf_ifs"] else: - # Per query worldwide, usa best match (Open-Meteo sceglie automaticamente) - # Ritorna None per indicare best match return None, long_term_default def get_bot_token(): @@ -127,9 +120,9 @@ def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forec url = "https://api.open-meteo.com/v1/forecast" params = { "latitude": lat, "longitude": lon, - "hourly": "temperature_2m,precipitation_probability,precipitation,snowfall,rain,snow_depth,weathercode,windspeed_10m,windgusts_10m,winddirection_10m,dewpoint_2m,relative_humidity_2m,cloud_cover,soil_temperature_0cm", - "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_hours,precipitation_probability_max,snowfall_sum,showers_sum,rain_sum,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max", - "timezone": timezone if timezone else TZ_STR, "forecast_days": min(forecast_days, 3) # Limita a 3 giorni per modelli ad alta risoluzione + "hourly": "temperature_2m,precipitation,snowfall,rain,snow_depth,weathercode,windspeed_10m,windgusts_10m,winddirection_10m,dewpoint_2m,relative_humidity_2m,cloud_cover,soil_temperature_0cm", + "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_hours,snowfall_sum,showers_sum,rain_sum,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max", + "timezone": timezone if timezone else TZ_STR, "forecast_days": min(forecast_days, 3) } try: resp = open_meteo_get(url, params=params, timeout=(5, 20)) @@ -163,14 +156,19 @@ def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forec # Modelli specifici (per Casa: AROME + ICON, per Italia: ICON ARPAE) for model in short_term_models: url = "https://api.open-meteo.com/v1/forecast" - # Per italia_meteo_arpae_icon_2i, includi sempre snow_depth (supportato quando > 0) - hourly_params = "temperature_2m,precipitation_probability,precipitation,snowfall,rain,snow_depth,weathercode,windspeed_10m,windgusts_10m,winddirection_10m,dewpoint_2m,relative_humidity_2m,cloud_cover,soil_temperature_0cm" - + # ICON Italia (ARPAE 2i): parametri come da API, senza precipitation_probability + if model == "italia_meteo_arpae_icon_2i": + hourly_params = "rain,showers,snowfall,snow_depth,precipitation,temperature_2m,weathercode,windspeed_10m,winddirection_10m" + daily_params = "temperature_2m_max,temperature_2m_min,showers_sum,rain_sum,snowfall_sum,precipitation_sum,precipitation_hours,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max" + else: + hourly_params = "temperature_2m,precipitation,snowfall,rain,snow_depth,weathercode,windspeed_10m,windgusts_10m,winddirection_10m,dewpoint_2m,relative_humidity_2m,cloud_cover,soil_temperature_0cm" + daily_params = "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_hours,snowfall_sum,showers_sum,rain_sum,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max" params = { "latitude": lat, "longitude": lon, "hourly": hourly_params, - "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_hours,precipitation_probability_max,snowfall_sum,showers_sum,rain_sum,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max", - "timezone": timezone if timezone else TZ_STR, "models": model, "forecast_days": min(forecast_days, 3) # Limita a 3 giorni per modelli ad alta risoluzione + "daily": daily_params, + "timezone": timezone if timezone else TZ_STR, "models": model, + "forecast_days": min(forecast_days, 7) if model == "italia_meteo_arpae_icon_2i" else min(forecast_days, 3) } try: resp = open_meteo_get(url, params=params, timeout=(5, 20)) @@ -220,13 +218,20 @@ def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forec except: results[model] = None - # Recupera modelli a lungo termine (globale, fino a 10 giorni) - for model in long_term_models: + # Recupera modelli a lungo termine (dopo 72h, dove Icon Italia non arriva) + for model in (long_term_models or []): url = "https://api.open-meteo.com/v1/forecast" + # ECMWF IFS: parametri come da API (rain, showers, snowfall) + campi necessari per il report + if model == "ecmwf_ifs": + hourly_params = "rain,showers,snowfall,precipitation,temperature_2m,weathercode,windspeed_10m,winddirection_10m" + daily_params = "temperature_2m_max,temperature_2m_min,showers_sum,rain_sum,snowfall_sum,precipitation_sum,precipitation_hours,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max" + else: + hourly_params = "temperature_2m,precipitation,snowfall,rain,snow_depth,weathercode,windspeed_10m,windgusts_10m,winddirection_10m,dewpoint_2m,relative_humidity_2m,cloud_cover,soil_temperature_0cm" + daily_params = "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_hours,snowfall_sum,showers_sum,rain_sum,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max" params = { "latitude": lat, "longitude": lon, - "hourly": "temperature_2m,precipitation_probability,precipitation,snowfall,rain,snow_depth,weathercode,windspeed_10m,windgusts_10m,winddirection_10m,dewpoint_2m,relative_humidity_2m,cloud_cover,soil_temperature_0cm", - "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_hours,precipitation_probability_max,snowfall_sum,showers_sum,rain_sum,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max", + "hourly": hourly_params, + "daily": daily_params, "timezone": timezone if timezone else TZ_STR, "models": model, "forecast_days": forecast_days } try: @@ -269,11 +274,11 @@ def merge_multi_model_forecast(models_data, forecast_days=10): "temperature_2m_min": [], "precipitation_sum": [], "precipitation_hours": [], - "precipitation_probability_max": [], "snowfall_sum": [], "showers_sum": [], "rain_sum": [], "weathercode": [], + "winddirection_10m_dominant": [], "windspeed_10m_max": [], "windgusts_10m_max": [] }, @@ -288,7 +293,6 @@ def merge_multi_model_forecast(models_data, forecast_days=10): "windspeed_10m": [], "winddirection_10m": [], "dewpoint_2m": [], - "precipitation_probability": [], "cloud_cover": [], "soil_temperature_0cm": [] }, @@ -358,36 +362,37 @@ def merge_multi_model_forecast(models_data, forecast_days=10): model_display = "Best Match" else: model_display = MODEL_NAMES.get(short_term_model, short_term_model) + short_daily = short_term_data.get("daily", {}) + short_hourly = short_term_data.get("hourly", {}) + # Prendi dati daily: tutti i giorni se è l'unico modello, altrimenti primi cutoff_day+1 + short_daily_times_all = short_daily.get("time", []) + short_daily_times = short_daily_times_all[:cutoff_day+1] if long_term_data else short_daily_times_all + # Verifica se ICON Italia ha dati di snow_depth (controllo diretto, non solo il flag) has_icon_snow_depth = False if icon_italia_data: icon_hourly = icon_italia_data.get("hourly", {}) icon_snow_depth = icon_hourly.get("snow_depth", []) if icon_hourly else [] - # Verifica se ci sono dati non-null di snow_depth if icon_snow_depth: for sd in icon_snow_depth[:72]: # Controlla prime 72h if sd is not None: try: - if float(sd) > 0: # Anche valori piccoli + if float(sd) > 0: has_icon_snow_depth = True break except (ValueError, TypeError): continue - # Se ICON Italia ha dati di snow_depth, aggiungilo ai modelli usati + num_days = len(short_daily_times) if has_icon_snow_depth or (icon_italia_data and icon_italia_data.get("has_snow_depth_data")): icon_display = MODEL_NAMES.get("italia_meteo_arpae_icon_2i", "ICON Italia") - merged["models_used"].append(f"{model_display} + {icon_display} (snow_depth) (0-{cutoff_day+1}d)") + merged["models_used"].append(f"{model_display} + {icon_display} (snow_depth) (0-{num_days}d)") else: - merged["models_used"].append(f"{model_display} (0-{cutoff_day+1}d)") - short_daily = short_term_data.get("daily", {}) - short_hourly = short_term_data.get("hourly", {}) + merged["models_used"].append(f"{model_display} (0-{num_days}d)") - # Prendi dati daily dai primi giorni del modello a breve termine - short_daily_times = short_daily.get("time", [])[:cutoff_day+1] for i, day_time in enumerate(short_daily_times): merged["daily"]["time"].append(day_time) - for key in ["temperature_2m_max", "temperature_2m_min", "precipitation_sum", "precipitation_hours", "precipitation_probability_max", "snowfall_sum", "showers_sum", "rain_sum", "weathercode", "windspeed_10m_max", "windgusts_10m_max"]: + for key in ["temperature_2m_max", "temperature_2m_min", "precipitation_sum", "precipitation_hours", "snowfall_sum", "showers_sum", "rain_sum", "weathercode", "winddirection_10m_dominant", "windspeed_10m_max", "windgusts_10m_max"]: val = short_daily.get(key, [])[i] if i < len(short_daily.get(key, [])) else None merged["daily"][key].append(val) @@ -409,10 +414,10 @@ def merge_multi_model_forecast(models_data, forecast_days=10): except (ValueError, TypeError): continue - cutoff_hour = (cutoff_day + 1) * 24 + cutoff_hour = (cutoff_day + 1) * 24 if long_term_data else len(short_hourly_times) for i, hour_time in enumerate(short_hourly_times[:cutoff_hour]): merged["hourly"]["time"].append(hour_time) - for key in ["temperature_2m", "precipitation", "snowfall", "rain", "weathercode", "windspeed_10m", "winddirection_10m", "dewpoint_2m", "precipitation_probability", "cloud_cover", "soil_temperature_0cm"]: + for key in ["temperature_2m", "precipitation", "snowfall", "rain", "weathercode", "windspeed_10m", "winddirection_10m", "dewpoint_2m", "cloud_cover", "soil_temperature_0cm"]: val = short_hourly.get(key, [])[i] if i < len(short_hourly.get(key, [])) else None merged["hourly"][key].append(val) # Per snow_depth: usa ICON Italia se disponibile (corrispondenza per timestamp), altrimenti modello principale @@ -455,7 +460,7 @@ def merge_multi_model_forecast(models_data, forecast_days=10): for i in range(start_idx, min(len(long_daily_times), forecast_days)): merged["daily"]["time"].append(long_daily_times[i]) - for key in ["temperature_2m_max", "temperature_2m_min", "precipitation_sum", "precipitation_hours", "precipitation_probability_max", "snowfall_sum", "showers_sum", "rain_sum", "weathercode", "windspeed_10m_max", "windgusts_10m_max"]: + for key in ["temperature_2m_max", "temperature_2m_min", "precipitation_sum", "precipitation_hours", "snowfall_sum", "showers_sum", "rain_sum", "weathercode", "winddirection_10m_dominant", "windspeed_10m_max", "windgusts_10m_max"]: val = long_daily.get(key, [])[i] if i < len(long_daily.get(key, [])) else None merged["daily"][key].append(val) @@ -468,7 +473,7 @@ def merge_multi_model_forecast(models_data, forecast_days=10): start_hour_idx = current_hourly_count for i in range(start_hour_idx, min(len(long_hourly_times), needed_hours)): merged["hourly"]["time"].append(long_hourly_times[i]) - for key in ["temperature_2m", "precipitation", "snowfall", "snow_depth", "rain", "weathercode", "windspeed_10m", "winddirection_10m", "dewpoint_2m", "precipitation_probability", "cloud_cover", "soil_temperature_0cm"]: + for key in ["temperature_2m", "precipitation", "snowfall", "snow_depth", "rain", "weathercode", "windspeed_10m", "winddirection_10m", "dewpoint_2m", "cloud_cover", "soil_temperature_0cm"]: val = long_hourly.get(key, [])[i] if i < len(long_hourly.get(key, [])) else None merged["hourly"][key].append(val) @@ -792,16 +797,13 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s block_precip = precip[start_idx:end_idx] if end_idx <= len(precip) else precip[start_idx:] block_precip_clean = [p for p in block_precip if p is not None] tot_mm = sum(block_precip_clean) - prob_block = probs[start_idx:end_idx] if end_idx <= len(probs) else probs[start_idx:] - prob_block_clean = [p for p in prob_block if p is not None] - max_prob = max(prob_block_clean) if prob_block_clean else 0 start_time = times[start_idx].split("T")[1][:5] end_time = times[end_idx].split("T")[1][:5] if end_idx < len(times) else times[-1].split("T")[1][:5] avg_intensity = tot_mm / len(block_precip) if block_precip else 0 events.append( f"{current_rain_type} ({get_intensity_label(avg_intensity)}):\n" - f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm | Prob: {max_prob}%" + f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm" ) start_idx = i current_rain_type = new_type @@ -813,16 +815,13 @@ def analyze_daily_events(times, codes, probs, precip, winds, temps, dewpoints, s tot_mm = sum(block_precip_clean) if tot_mm > 0: - prob_block = probs[start_idx:end_idx] if end_idx <= len(probs) else probs[start_idx:] - prob_block_clean = [p for p in prob_block if p is not None] - max_prob = max(prob_block_clean) if prob_block_clean else 0 start_time = times[start_idx].split("T")[1][:5] end_time = times[min(end_idx-1, len(times)-1)].split("T")[1][:5] avg_intensity = tot_mm / len(block_precip) if block_precip else 0 events.append( f"{current_rain_type} ({get_intensity_label(avg_intensity)}):\n" - f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm | Prob: {max_prob}%" + f" 🕒 {start_time}-{end_time} | 💧 {tot_mm:.1f}mm" ) # 3. VENTO @@ -1225,12 +1224,7 @@ def format_weather_context_report(models_data, location_name, country_code): wcode = int(wcode) if wcode is not None else 0 # Calcola probabilità e tipo precipitazione per il giorno (PRIMA di determinare icona) - precip_prob = 0 precip_type = None - if d_probs and any(p is not None for p in d_probs): - prob_values = [p for p in d_probs if p is not None] - precip_prob = max(prob_values) if prob_values else 0 - # Determina tipo precipitazione usando dati daily (più affidabili) # Usa snowfall_sum, rain_sum, showers_sum per caratterizzazione precisa # PRIORITÀ: Se snow_depth > 0 (modello italia_meteo_arpae_icon_2i), usa questi dati per determinare neve @@ -1369,14 +1363,6 @@ def format_weather_context_report(models_data, location_name, country_code): else: weather_icon = "☁️" # Nuvoloso - # Recupera probabilità max daily se disponibile - prob_max_list = daily.get('precipitation_probability_max', []) - precip_prob_max = None - if count < len(prob_max_list) and prob_max_list[count] is not None: - precip_prob_max = int(prob_max_list[count]) - elif precip_prob > 0: - precip_prob_max = int(precip_prob) - # Calcola spessore manto nevoso (snow_depth) per questo giorno # Nota: snow_depth è disponibile solo per alcuni modelli (es. ICON Italia, ICON-D2) # Se il modello non supporta snow_depth, tutti i valori saranno None o mancanti @@ -1449,7 +1435,6 @@ def format_weather_context_report(models_data, location_name, country_code): "t_min": t_min, "t_max": t_max, "precip_sum": precip_sum, - "precip_prob": precip_prob_max if precip_prob_max is not None else precip_prob, "precip_type": precip_type, "snowfall_sum": snowfall_sum, "rain_sum": rain_sum, @@ -1496,13 +1481,6 @@ def format_weather_context_report(models_data, location_name, country_code): precip_parts.append(f"{precip_symbol} {day_info['precip_sum']:.1f}mm") line += f" | {' + '.join(precip_parts)}" - - # Aggiungi probabilità se disponibile - if day_info['precip_prob'] and day_info['precip_prob'] > 0: - line += f" ({int(day_info['precip_prob'])}%)" - elif day_info['precip_prob'] > 50: - # Probabilità alta ma nessuna precipitazione prevista (può essere un errore del modello) - line += f" | 💧 Possibile ({int(day_info['precip_prob'])}%)" # Aggiungi vento (sempre se disponibile, formattato come direzione intensità) if day_info['wind_max'] > 0: diff --git a/services/telegram-bot/smart_irrigation_advisor.py b/services/telegram-bot/smart_irrigation_advisor.py index fe0cf45..d5baa11 100755 --- a/services/telegram-bot/smart_irrigation_advisor.py +++ b/services/telegram-bot/smart_irrigation_advisor.py @@ -5,6 +5,10 @@ Smart Irrigation Advisor - Consulente Agronomico "Smart Season" Fornisce consigli pragmatici per la gestione stagionale dell'irrigazione del giardino basati su dati meteo e stato del suolo. + +Pianificazione irrigazione: logica allineata a "A Guide to Soil Moisture" (ConnectedCrops) +e tabelle OMAFRA: FC, PWP, TAW; mantenere TAW > 50% (trigger a PAW); evitare saturazione +e percolazione profonda; suoli argillosi: irrigazioni lente e lunghe. """ import argparse @@ -36,11 +40,14 @@ DEFAULT_LOCATION_NAME = "🏠 Casa" TZ = "Europe/Berlin" TZINFO = ZoneInfo(TZ) -# Open-Meteo +# Open-Meteo: due fonti +# - Suolo (tutti i layer): ICON Seamless (DWD) - copertura Europa centrale +# - Meteo (ET₀, precipitazioni, T°): ICON Italia - risoluzione spaziale migliore per Italia/San Marino OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" -MODEL_AROME = "meteofrance_seamless" -MODEL_ICON = "italia_meteo_arpae_icon_2i" -HTTP_HEADERS = {"User-Agent": "Smart-Irrigation-Advisor/1.0"} +MODEL_SOIL = "icon_seamless" # Dati suolo (0-1, 1-3, 3-9, 9-27, 27-81 cm) e T° suolo; forecast_days=8 +MODEL_WEATHER = "italia_meteo_arpae_icon_2i" # ET₀, precipitazioni, temperatura, radiazione +MODEL_ICON = MODEL_WEATHER # Retrocompatibilità +HTTP_HEADERS = {"User-Agent": "Smart-Irrigation-Advisor/2.0"} # Files BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -58,12 +65,60 @@ SOIL_TEMP_WAKEUP_DAYS_MIN = 3 # Giorni consecutivi minimi per risveglio SOIL_TEMP_WAKEUP_DAYS_MAX = 5 # Giorni consecutivi massimi per risveglio SOIL_TEMP_SHUTDOWN_THRESHOLD = 10.0 # °C - Soglia di chiusura autunnale SOIL_TEMP_WAKEUP_INDICATOR = 8.0 # °C - Soglia indicatore di avvicinamento al risveglio (sblocca report) -SOIL_MOISTURE_FIELD_CAPACITY = 0.6 # Capacità di campo (60% - valore tipico per terreno medio) -SOIL_MOISTURE_WILTING_POINT = 0.3 # Punto di avvizzimento (30%) -SOIL_MOISTURE_AUTUMN_HIGH = 0.8 # 80% - Umidità alta in autunno -SOIL_MOISTURE_DEEP_STRESS = 0.35 # 35% - Umidità profonda critica (vicina a punto di avvizzimento) + +# Tipo di suolo: "clay_loam" (default), "clay", "loam", "sandy", "medium". Imposta IRRIGATION_SOIL_TYPE in env. +# Parametri in m³/m³ (VWC). Franco Argilloso (Clay Loam): CC 32-36%, PA 18-20%, AWC 14-16%, Infiltrazione 5-10 mm/h. +# FC = Field Capacity, PWP = Punto Appassimento, AWC = Acqua Disponibile. Trigger = FC - 0.5*AWC (MAD 50%). +_SOIL_TYPE = os.environ.get("IRRIGATION_SOIL_TYPE", "clay_loam").strip().lower().replace(" ", "_") +if _SOIL_TYPE == "clay_loam": + # Franco Argilloso (Clay Loam): CC 32-36%, PA 18-20%, AWC 14-16% (tabella tessiture) + SOIL_MOISTURE_FIELD_CAPACITY = 0.34 # CC 34% (centro intervallo 32-36) + SOIL_MOISTURE_WILTING_POINT = 0.19 # PA 19% (18-20) + SOIL_MOISTURE_DEEP_STRESS = 0.265 # Trigger irrigazione: FC - 0.5*AWC (AWC 15% → 34 - 7.5 ≈ 26.5%) + SOIL_MOISTURE_AUTUMN_HIGH = 0.34 # A FC = suolo bagnato, ok chiusura autunnale + SOIL_INFILTRATION_MMH = 7.5 # Infiltrazione 5-10 mm/h (riferimento per irrigazione lenta) +elif _SOIL_TYPE == "clay": + # ARGILLA pura: FC 37.5%, PWP 24% (OMAFRA). Sotto 10°C = blocco totale. + SOIL_MOISTURE_FIELD_CAPACITY = 0.38 + SOIL_MOISTURE_WILTING_POINT = 0.22 + SOIL_MOISTURE_DEEP_STRESS = 0.28 + SOIL_MOISTURE_AUTUMN_HIGH = 0.38 + SOIL_INFILTRATION_MMH = 5.0 +elif _SOIL_TYPE == "loam": + # Loam (OMAFRA): PWP 12.5%, FC 25%, TAW 12.5% + SOIL_MOISTURE_FIELD_CAPACITY = 0.25 + SOIL_MOISTURE_WILTING_POINT = 0.125 + SOIL_MOISTURE_DEEP_STRESS = 0.19 # FC - 0.5*TAW + SOIL_MOISTURE_AUTUMN_HIGH = 0.25 + SOIL_INFILTRATION_MMH = 10.0 +elif _SOIL_TYPE == "sandy": + SOIL_MOISTURE_FIELD_CAPACITY = 0.35 + SOIL_MOISTURE_WILTING_POINT = 0.10 + SOIL_MOISTURE_DEEP_STRESS = 0.18 + SOIL_MOISTURE_AUTUMN_HIGH = 0.45 + SOIL_INFILTRATION_MMH = 15.0 +else: + # Medium / silt loam (default generico) + SOIL_MOISTURE_FIELD_CAPACITY = 0.6 + SOIL_MOISTURE_WILTING_POINT = 0.3 + SOIL_MOISTURE_DEEP_STRESS = 0.35 + SOIL_MOISTURE_AUTUMN_HIGH = 0.55 + SOIL_INFILTRATION_MMH = 10.0 PRECIP_THRESHOLD_SIGNIFICANT = 5.0 # mm - Pioggia significativa -AUTUMN_HIGH_MOISTURE_DAYS = 10 # Giorni consecutivi con umidità alta per chiusura +PRECIP_VETO_MM_24H = 4.0 # Veto avvio: se pioggia ultime 24h >= questo valore, non irrigare (dato da sensore/API se disponibile) +AUTUMN_HIGH_MOISTURE_DAYS = 5 # Giorni consecutivi con umidità alta per chiusura (path saturazione) +# Suoli a base argilla: irrigazioni lente e lunghe, evitare brevi rinfrescate (runoff, crusting) +SOIL_IS_CLAY = _SOIL_TYPE in ("clay", "clay_loam") + +# Veto e sicurezza +AIR_TEMP_FREEZE_VETO = 4.0 # °C - Temperatura aria min: sotto questa soglia non irrigare (rischio ghiaccio) +SHORTWAVE_DAYTIME_LOCK_WM2 = 400.0 # W/m² - Evitare irrigazione pesante in pieno sole (alta evaporazione) + +# Modello a serbatoio (bucket) per quantità / minuti suggeriti (ET0 da Open-Meteo; in futuro SolarEdge) +WATER_BALANCE_MAX_MM = 20.0 # mm - Capacità serbatoio (acqua disponibile prima di stress) +WATER_BALANCE_CRITICAL_MM = 10.0 # mm - Sotto questa soglia si suggerisce irrigazione +KC_LAWN = 0.8 # Coefficiente colturale prato/giardino +APPLIED_MM_PER_HOUR = 8.0 # mm/h - Portata irrigatore tipica (adattare al proprio impianto) # ============================================================================= # CLASSIFICAZIONE VALORI PARAMETRI @@ -82,11 +137,8 @@ SOIL_TEMP_MEDIUM_LOW = 10.0 # 5-10°C = medio/basso SOIL_TEMP_MEDIUM_HIGH = 15.0 # 10-15°C = medio/alto # > 15°C = alto -# Umidità suolo - frazione (0-1) -SOIL_MOISTURE_LOW = 0.3 # < 0.3 (30%) = basso (punto di avvizzimento) -SOIL_MOISTURE_MEDIUM_LOW = 0.5 # 0.3-0.5 (30-50%) = medio/basso -SOIL_MOISTURE_MEDIUM_HIGH = 0.7 # 0.5-0.7 (50-70%) = medio/alto -# > 0.7 (70%) = alto (vicino a capacità di campo) +# Umidità suolo: classificazione espositiva usa le soglie agronomiche per tipo di suolo +# (SOIL_MOISTURE_WILTING_POINT, DEEP_STRESS, FIELD_CAPACITY) → vedi classify_soil_moisture() # VPD - kPa VPD_LOW = 0.5 # < 0.5 kPa = basso (umido) @@ -211,10 +263,18 @@ def load_state() -> Dict: "last_check": None, "soil_temp_history": [], # Lista di (date, temp_6cm) "soil_moisture_history": [], # Lista di (date, moisture_3_9cm, moisture_9_27cm) - "high_moisture_streak": 0, # Giorni consecutivi con umidità alta (per fase shutdown) - "auto_reporting_enabled": False, # Se True, i report automatici sono attivi - "wakeup_threshold_reached": False, # Se True, abbiamo superato la soglia di risveglio - "shutdown_confirmed": False, # Se True, la chiusura è stata confermata + "high_moisture_streak": 0, + "auto_reporting_enabled": False, + "wakeup_threshold_reached": False, + "shutdown_confirmed": False, + "last_auto_report_date": None, + "wakeup_notified_for_month": None, + "last_irrigation_need": None, + # Modello a serbatoio (bucket) e grafico + "water_balance_mm": WATER_BALANCE_MAX_MM, # Bilancio idrico stimato (mm) + "last_balance_date": None, # Ultima data di aggiornamento bilancio (ISO) + "daily_history": [], # Ultimi 7 giorni: [{date, temp_6, moist_9_27, moist_27_81, et0, precip}] + "last_24h_precip_mm": None, # Pioggia ultime 24h (da sensore/API esterna; se None veto non applicato) } if os.path.exists(STATE_FILE): try: @@ -236,110 +296,220 @@ def save_state(state: Dict) -> None: # ============================================================================= -# OPEN-METEO API +# OPEN-METEO API (doppia fonte: suolo ICON Seamless, meteo ICON Italia) # ============================================================================= -def fetch_soil_and_weather(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]: +def fetch_soil_icon_seamless(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]: """ - Recupera dati suolo e meteo da Open-Meteo. - Nota: I parametri del suolo potrebbero non essere disponibili per tutte le località. - In caso di errore, restituisce None. + Recupera solo dati suolo e temperatura suolo da ICON Seamless (Open-Meteo). + Fornisce tutti i layer: 0-1, 1-3, 3-9, 9-27, 27-81 cm e T° 0, 6, 18, 54 cm (8 giorni). """ params = { "latitude": lat, "longitude": lon, "timezone": timezone, - "forecast_days": 5, # 5 giorni per previsioni pioggia + "forecast_days": 8, "hourly": ",".join([ - # Parametri suolo ICON Italia "soil_temperature_0cm", + "soil_temperature_6cm", + "soil_temperature_18cm", "soil_temperature_54cm", "soil_moisture_0_to_1cm", - "soil_moisture_81_to_243cm", - # Meteo base - "precipitation", - "snowfall", - "temperature_2m", - # Evapotraspirazione e stress idrico - "et0_fao_evapotranspiration", - "vapour_pressure_deficit", - # Parametri irraggiamento solare - "direct_radiation", - "diffuse_radiation", - "shortwave_radiation", # GHI - Global Horizontal Irradiance (energia totale per fotosintesi) - "sunshine_duration", + "soil_moisture_1_to_3cm", + "soil_moisture_3_to_9cm", + "soil_moisture_9_to_27cm", + "soil_moisture_27_to_81cm", ]), - "daily": ",".join([ - "precipitation_sum", - "snowfall_sum", - "et0_fao_evapotranspiration_sum", - "sunshine_duration", - ]), - "models": MODEL_ICON, # Usa ICON Italia per migliore copertura Europa + "models": MODEL_SOIL, } - try: r = open_meteo_get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=(5, 30)) - if r.status_code == 400: - try: - j = r.json() - reason = j.get("reason", str(j)) - LOGGER.warning("Open-Meteo 400: %s. Parametri suolo potrebbero non essere disponibili per questa località.", reason) - # Prova senza parametri suolo (fallback) - return fetch_weather_only(lat, lon, timezone) - except Exception: - LOGGER.error("Open-Meteo 400: %s", r.text[:500]) - return fetch_weather_only(lat, lon, timezone) - r.raise_for_status() - data = r.json() - - # Verifica che i dati del suolo siano presenti (almeno alcuni valori non-None) - hourly = data.get("hourly", {}) or {} - # ICON Italia usa soil_temperature_0cm e soil_temperature_54cm - soil_temp_0 = hourly.get("soil_temperature_0cm", []) or [] - soil_temp_54 = hourly.get("soil_temperature_54cm", []) or [] - # Controlla se ci sono almeno alcuni valori non-None - has_soil_data = any(v is not None for v in soil_temp_0[:24]) or any(v is not None for v in soil_temp_54[:24]) - if not has_soil_data: - LOGGER.warning("Dati suolo non disponibili (tutti None). Uso fallback meteo-only.") - return fetch_weather_only(lat, lon, timezone) - - return data + if r.status_code != 200: + LOGGER.warning("Soil fetch %s: %s", r.status_code, r.text[:200]) + return None + return r.json() except Exception as e: - LOGGER.exception("Open-Meteo request error: %s", e) - return fetch_weather_only(lat, lon, timezone) + LOGGER.warning("Soil (ICON Seamless) request error: %s", e) + return None -def fetch_weather_only(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]: - """Fallback: recupera solo dati meteo (senza parametri suolo).""" +def fetch_weather_icon_italia(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]: + """ + Recupera dati meteo da ICON Italia (risoluzione spaziale migliore per Italia/San Marino): + ET₀, precipitazioni, temperatura, radiazione, umidità, VPD, sunshine. + """ params = { "latitude": lat, "longitude": lon, "timezone": timezone, - "forecast_days": 5, + "forecast_days": 10, "hourly": ",".join([ "precipitation", "snowfall", - "et0_fao_evapotranspiration", "temperature_2m", + "relative_humidity_2m", + "et0_fao_evapotranspiration", + "vapour_pressure_deficit", + "direct_radiation", + "diffuse_radiation", + "shortwave_radiation", + "sunshine_duration", ]), "daily": ",".join([ "precipitation_sum", "snowfall_sum", "et0_fao_evapotranspiration_sum", + "sunshine_duration", ]), - "models": MODEL_ICON, + "models": MODEL_WEATHER, } - try: r = open_meteo_get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=(5, 30)) r.raise_for_status() return r.json() except Exception as e: - LOGGER.exception("Open-Meteo weather-only request error: %s", e) + LOGGER.exception("Open-Meteo weather (ICON Italia) request error: %s", e) return None +def _normalize_time_key(t: str) -> str: + """Normalizza timestamp per confronto (es. '2026-02-05T16:00' e '2026-02-05T16:00:00' → stesso key).""" + if not t or not isinstance(t, str): + return str(t) if t else "" + return t.strip()[:16] # YYYY-MM-DDTHH:MM + +def _merge_hourly_by_time(soil_hourly: Dict, weather_hourly: Dict, weather_daily: Dict) -> Dict: + """ + Unisce hourly da soil (ICON Seamless, es. 8 giorni) e weather (ICON Italia, es. 10 giorni). + Usa i tempi del SOIL come riferimento così i layer 9-27 e 27-81 non si perdono; allinea + il meteo a questi tempi. Se il soil non ha 'time', fallback su weather come riferimento. + """ + soil_times = soil_hourly.get("time", []) or [] + weather_times = weather_hourly.get("time", []) or [] + if not soil_times and not weather_times: + return weather_hourly + # Riferimento: soil se disponibile (preserva 8 giorni e tutti i layer suolo) + use_soil_as_ref = len(soil_times) > 0 + ref_times = soil_times if use_soil_as_ref else weather_times + out = {"time": ref_times} + # Indice weather per ogni timestamp (normalizzato per evitare mismatch formato) + weather_idx_by_key = {} + for i, t in enumerate(weather_times): + k = _normalize_time_key(t) + if k and k not in weather_idx_by_key: + weather_idx_by_key[k] = i + soil_idx_by_key = {} + for i, t in enumerate(soil_times): + k = _normalize_time_key(t) + if k and k not in soil_idx_by_key: + soil_idx_by_key[k] = i + # Copia serie soil: allineate per ref_times (se ref=soil, copia diretta; altrimenti lookup) + for key, values in soil_hourly.items(): + if key == "time": + continue + if use_soil_as_ref and values is not None and len(values) == len(ref_times): + out[key] = list(values) + else: + out[key] = [] + for i, t in enumerate(ref_times): + k = _normalize_time_key(t) + idx = soil_idx_by_key.get(k, i) if soil_times else i + if values and idx < len(values) and values[idx] is not None: + out[key].append(values[idx]) + else: + out[key].append(None) + # Copia serie weather allineate a ref_times + for key, values in weather_hourly.items(): + if key == "time": + continue + if key in out: + continue + out[key] = [] + for i, t in enumerate(ref_times): + k = _normalize_time_key(t) + idx = weather_idx_by_key.get(k, i) if weather_times else i + if values and idx < len(values) and values[idx] is not None: + out[key].append(values[idx]) + else: + out[key].append(None) + return out + + +def fetch_soil_and_weather(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]: + """ + Recupera dati combinati: suolo da ICON Seamless (tutti i layer), meteo da ICON Italia. + In caso di fallimento suolo, prova fallback con singola fonte (solo ICON Italia). + """ + soil_data = fetch_soil_icon_seamless(lat, lon, timezone) + weather_data = fetch_weather_icon_italia(lat, lon, timezone) + if not weather_data: + return None + hourly_w = weather_data.get("hourly", {}) or {} + daily_w = weather_data.get("daily", {}) or {} + if not soil_data or not soil_data.get("hourly"): + # Fallback: chiedi a ICON Italia anche i parametri suolo (meno layer) + LOGGER.info("Soil da ICON Seamless non disponibile; uso solo ICON Italia.") + return fetch_soil_and_weather_fallback(lat, lon, timezone) + hourly_s = soil_data.get("hourly", {}) or {} + hourly_merged = _merge_hourly_by_time(hourly_s, hourly_w, daily_w) + return { + "latitude": weather_data.get("latitude"), + "longitude": weather_data.get("longitude"), + "timezone": weather_data.get("timezone"), + "hourly": hourly_merged, + "daily": daily_w, + } + + +def fetch_soil_and_weather_fallback(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]: + """Fallback: singola chiamata a ICON Italia con suolo (layer limitati) + meteo.""" + params = { + "latitude": lat, + "longitude": lon, + "timezone": timezone, + "forecast_days": 10, + "hourly": ",".join([ + "soil_temperature_0cm", + "soil_temperature_6cm", + "soil_temperature_18cm", + "soil_temperature_54cm", + "soil_moisture_0_to_1cm", + "soil_moisture_3_to_9cm", + "soil_moisture_9_to_27cm", + "soil_moisture_27_to_81cm", + "precipitation", + "snowfall", + "temperature_2m", + "relative_humidity_2m", + "et0_fao_evapotranspiration", + "vapour_pressure_deficit", + "shortwave_radiation", + "sunshine_duration", + ]), + "daily": ",".join([ + "precipitation_sum", + "snowfall_sum", + "et0_fao_evapotranspiration_sum", + "sunshine_duration", + ]), + "models": MODEL_WEATHER, + } + try: + r = open_meteo_get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=(5, 30)) + if r.status_code == 400: + return None + r.raise_for_status() + return r.json() + except Exception as e: + LOGGER.exception("Fallback fetch error: %s", e) + return None + + +def fetch_weather_only(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]: + """Recupera solo dati meteo (senza suolo).""" + return fetch_weather_icon_italia(lat, lon, timezone) + + # ============================================================================= # SEASONAL PHASE DETECTION # ============================================================================= @@ -433,17 +603,19 @@ def classify_soil_temp(temp: float) -> str: def classify_soil_moisture(moisture: float) -> str: - """Classifica umidità suolo in basso, medio/basso, medio, medio/alto, alto""" - if moisture < SOIL_MOISTURE_LOW: + """ + Classifica umidità suolo (VWC) in base al tipo di suolo configurato. + Usa PWP, trigger irrigazione e capacità di campo: così 39% su clay loam = "alto" (pieno), non "medio/basso". + """ + if moisture < SOIL_MOISTURE_WILTING_POINT: return "basso" - elif moisture < SOIL_MOISTURE_MEDIUM_LOW: + if moisture < SOIL_MOISTURE_DEEP_STRESS: return "medio/basso" - elif moisture < SOIL_MOISTURE_MEDIUM_HIGH: + if moisture < SOIL_MOISTURE_FIELD_CAPACITY * 0.95: return "medio" - elif moisture < 0.85: + if moisture < SOIL_MOISTURE_FIELD_CAPACITY: return "medio/alto" - else: - return "alto" + return "alto" def classify_vpd(vpd: float) -> str: @@ -614,6 +786,92 @@ def check_future_rainfall(daily_data: Dict, days_ahead: int = 5, include_snowfal return total, rainy_days +def _irrigation_need_index( + heart_moisture: Optional[float], + et0_avg: Optional[float], + vpd_avg: Optional[float] = None +) -> float: + """ + Indice 0-100 di necessità di irrigazione (alta = serve acqua). + Usato per rilevare variazioni significative e decidere invio notifiche. + """ + if heart_moisture is None and et0_avg is None: + return 50.0 + # Fattore umidità: bassa umidità = alto bisogno + m = heart_moisture if heart_moisture is not None else 0.5 + moisture_factor = max(0, 1.0 - m) # 0 se saturo, 1 se secco + # Fattore ET0: alto ET0 = alto bisogno (normalizzato ~0-6 mm/d) + e = et0_avg if et0_avg is not None else 3.0 + et0_factor = min(1.0, e / 6.0) + need = (moisture_factor * 50.0 + et0_factor * 50.0) + if vpd_avg is not None and vpd_avg > 1.0: + need = min(100.0, need * 1.1) + return round(need, 1) + + +def _will_exit_dormant_in_forecast(hourly: Dict, times: List[str], now: datetime.datetime) -> bool: + """ + True se nei prossimi 10 giorni la temperatura suolo prevista supera la soglia di risveglio + (uscita dallo stato dormiente invernale). + """ + soil_temps = hourly.get("soil_temperature_6cm", []) or hourly.get("soil_temperature_0cm", []) or [] + if not times or not soil_temps: + return False + # Raggruppa per giorno e calcola media T suolo per ogni giorno + day_temps: Dict[str, List[float]] = {} + for i, t_str in enumerate(times[: min(10 * 24, len(times))]): + try: + t = parse_time_to_local(t_str) + if t.date() <= now.date(): + continue + key = t.date().isoformat() + if key not in day_temps: + day_temps[key] = [] + if i < len(soil_temps) and soil_temps[i] is not None: + try: + day_temps[key].append(float(soil_temps[i])) + except (TypeError, ValueError): + pass + except Exception: + continue + for _date_str, vals in day_temps.items(): + if vals and (sum(vals) / len(vals)) >= SOIL_TEMP_WAKEUP_THRESHOLD: + return True + return False + + +def _irrigation_need_next_days( + daily: Dict, + current_heart_moisture: Optional[float], + days: int = 3 +) -> List[float]: + """ + Stima indice di necessità irrigazione (0-100) per i prossimi giorni + usando ET0 e precipitazioni giornaliere (umidità stimata per giorno). + """ + daily_times = daily.get("time", []) or [] + et0_sum = daily.get("et0_fao_evapotranspiration_sum", []) or [] + precip_sum = daily.get("precipitation_sum", []) or [] + now = now_local() + need_list = [] + moisture = current_heart_moisture if current_heart_moisture is not None else 0.5 + for i in range(len(daily_times)): + if len(need_list) >= days: + break + try: + day_time = parse_time_to_local(daily_times[i]) + if day_time.date() <= now.date(): + continue + except Exception: + continue + et0 = float(et0_sum[i]) if i < len(et0_sum) and et0_sum[i] is not None else 3.0 + precip = float(precip_sum[i]) if i < len(precip_sum) and precip_sum[i] is not None else 0.0 + moisture = moisture - et0 * 0.03 + precip * 0.04 + moisture = max(0.2, min(0.9, moisture)) + need_list.append(_irrigation_need_index(moisture, et0, None)) + return need_list + + # ============================================================================= # ADVICE GENERATION # ============================================================================= @@ -687,10 +945,11 @@ def generate_wakeup_advice( # Trigger combinati: almeno 2 su 3 devono essere OK (termico è obbligatorio) triggers_active = temp_ok and (energy_ok or photoperiod_ok) - # Controlla umidità profonda (9-27cm = radici attive) sotto capacità di campo + # Umidità profonda sotto trigger irrigazione (sotto FC = "serbatoio vuoto" → serve acqua) + # Per argilla: trigger 28%; sotto 10°C si ignora (dormienza, rischio marciumi/ghiaccio) moisture_deep_low = False if soil_moisture_9_27cm is not None: - moisture_deep_low = soil_moisture_9_27cm < SOIL_MOISTURE_WILTING_POINT # < 0.30 m³/m³ + moisture_deep_low = soil_moisture_9_27cm < SOIL_MOISTURE_DEEP_STRESS # Genera consiglio if triggers_active and moisture_deep_low: @@ -703,12 +962,13 @@ def generate_wakeup_advice( advice_msg += f"• Irraggiamento solare adeguato ({shortwave_avg:.0f} W/m²)\n" if photoperiod_ok: advice_msg += f"• Fotoperiodo sufficiente ({sunshine_hours:.1f}h di sole)\n" - advice_msg += f"\nIl terreno profondo (9-27cm) si sta asciugando ({soil_moisture_9_27cm*100:.0f}% < capacità di campo). " + advice_msg += f"\nIl terreno profondo (9-27cm) sotto trigger irrigazione ({soil_moisture_9_27cm*100:.0f}% < {SOIL_MOISTURE_DEEP_STRESS*100:.0f}%). " if future_rain_mm < PRECIP_THRESHOLD_SIGNIFICANT: advice_msg += "Nessuna pioggia significativa prevista.\n\n" else: advice_msg += f"Pioggia prevista: {future_rain_mm:.1f}mm nei prossimi giorni.\n\n" - advice_msg += "**Consigliato**: Primo ciclo di test/attivazione dell'impianto di irrigazione." + advice_msg += "**Consigliato**: Primo ciclo di test/attivazione dell'impianto di irrigazione.\n\n" + advice_msg += f"**Quando innaffiare le prime volte**: Quando T° suolo ≥10°C e umidità 9-27cm scende sotto ~{SOIL_MOISTURE_DEEP_STRESS*100:.0f}% (trigger), irriga abbondantemente fino a ~{SOIL_MOISTURE_FIELD_CAPACITY*100:.0f}% (capacità di campo). Con argilla: cicli lunghi e lenti, meno frequenti. Sotto 10°C: blocco totale, non irrigare (dormienza, rischio marciumi/ghiaccio)." elif not temp_ok: advice_level = "NO_ACTION" advice_msg = "💤 **DORMI ANCORA**\n\n" @@ -763,8 +1023,9 @@ def generate_active_advice( ) -> Dict: """ FASE ATTIVA: "Quanto irrigare?" - Analisi stratificata: ignora 0-1cm, monitora "Cuore" (3-9cm e 9-27cm) e "Riserva" (27-81cm) - Returns: Dict con season_phase, advice_level, human_message, soil_status_summary + Logica allineata a "A Guide to Soil Moisture" (ConnectedCrops/OMAFRA): + - Zona ideale: tra PAW (50% TAW) e FC. Sotto PAW → stress, sopra FC → saturazione/percolazione. + - Cuore (3-9 + 9-27cm) e Riserva (27-81cm); ignora 0-1cm (troppo variabile). """ status = "**Fase: Piena Stagione (Primavera/Estate)**" @@ -779,11 +1040,10 @@ def generate_active_advice( elif soil_moisture_3_9cm is not None: heart_moisture = soil_moisture_3_9cm - # Monitora la "Riserva" profonda (27-81cm) - se questa cala, è allarme rosso + # Monitora la "Riserva" profonda (27-81cm) - sotto capacità di campo = allarme reserve_depleting = False if soil_moisture_27_81cm is not None: - # Se la riserva scende sotto 40%, è critico - reserve_depleting = soil_moisture_27_81cm < 0.40 + reserve_depleting = soil_moisture_27_81cm < SOIL_MOISTURE_FIELD_CAPACITY # Calcola fabbisogno idrico basato su ET₀ daily_water_demand = et0_avg if et0_avg is not None else 0.0 @@ -808,19 +1068,19 @@ def generate_active_advice( advice_level = "CRITICAL" advice_msg = "🔴 **LIVELLO CRITICO (Deep Stress)**\n\n" if soil_moisture_9_27cm is not None and soil_moisture_9_27cm <= SOIL_MOISTURE_DEEP_STRESS: - advice_msg += f"Umidità profonda (9-27cm) critica: {soil_moisture_9_27cm*100:.0f}% (vicina al punto di avvizzimento). " + advice_msg += f"Umidità profonda (9-27cm) sotto trigger ({soil_moisture_9_27cm*100:.0f}% ≤ {SOIL_MOISTURE_DEEP_STRESS*100:.0f}%). " if reserve_depleting: advice_msg += f"Riserva profonda (27-81cm) in calo: {soil_moisture_27_81cm*100:.0f}%. " advice_msg += f"ET₀ elevato ({daily_water_demand:.1f} mm/d). Nessuna pioggia prevista.\n\n" - advice_msg += "**Emergenza**: Irrigazione profonda necessaria **subito**." + advice_msg += "**Emergenza**: Irrigazione profonda necessaria **subito**. Portare umidità verso capacità di campo (~{:.0f}%) senza superarla (evitare saturazione e percolazione). Con argilla: ciclo lungo e lento, niente brevi rinfrescate.".format(SOIL_MOISTURE_FIELD_CAPACITY * 100) - # 🟠 STANDARD (Maintenance) + # 🟠 STANDARD (Maintenance) - tra trigger e capacità di campo elif (heart_moisture is not None and heart_moisture < SOIL_MOISTURE_FIELD_CAPACITY * 0.8 and (soil_moisture_9_27cm is None or soil_moisture_9_27cm > SOIL_MOISTURE_DEEP_STRESS)): advice_level = "STANDARD" advice_msg = "🟠 **LIVELLO STANDARD (Maintenance)**\n\n" - advice_msg += "Umidità superficiale (3-9cm) bassa, ma profonda (9-27cm) ok. " + advice_msg += "Umidità in calo verso il trigger, profondo (9-27cm) ancora ok. " if et0_avg is not None: advice_msg += f"ET₀ moderato ({et0_avg:.1f} mm/d). " if rain_covers_demand: @@ -828,26 +1088,28 @@ def generate_active_advice( advice_msg += "**Consiglio**: Attendi le precipitazioni, poi valuta." else: advice_msg += "Nessuna pioggia sufficiente prevista a breve.\n\n" - advice_msg += "**Routine**: Ciclo standard consigliato stasera o domattina." + advice_msg += "**Routine**: Ciclo lungo e lento consigliato (argilla: bagnare in profondità, meno frequente)." - # 🟡 LIGHT (Surface Dry) + # 🟡 LIGHT (Surface Dry) - su argilla 0-3cm si secca e crepa; non indicativo, evitare brevi rinfrescate elif (soil_moisture_0_1cm is not None and soil_moisture_0_1cm < 0.5 and heart_moisture is not None and heart_moisture >= SOIL_MOISTURE_FIELD_CAPACITY * 0.8): advice_level = "LIGHT" advice_msg = "🟡 **LIVELLO LIGHT (Surface Dry)**\n\n" advice_msg += "Solo strati superficiali (0-3cm) secchi, radici profonde (9-27cm) ok. " + if SOIL_IS_CLAY: + advice_msg += "Su argilla lo strato superficiale si secca e crepa in fretta: non è indicativo. " if future_rain_mm >= PRECIP_THRESHOLD_SIGNIFICANT: advice_msg += f"Pioggia prevista: {future_rain_mm:.1f}mm.\n\n" - advice_msg += "**Opzionale**: Breve rinfrescata o attendi precipitazioni." + advice_msg += "**Opzionale**: Attendere precipitazioni." + (" Con argilla evitare brevi rinfrescate (preferire cicli lunghi quando serve)." if SOIL_IS_CLAY else " Breve rinfrescata o attendi precipitazioni.") else: - advice_msg += "\n\n**Opzionale**: Breve rinfrescata superficiale o attendi domani." + advice_msg += "\n\n**Opzionale**: " + ("Attendi prossimo ciclo completo (argilla: niente brevi rinfrescate)." if SOIL_IS_CLAY else "Breve rinfrescata superficiale o attendi domani.") - # 🟢 STOP (Saturated/Rain) + # 🟢 STOP (a capacità di campo o oltre, oppure pioggia copre fabbisogno) else: advice_level = "NO_ACTION" advice_msg = "🟢 **LIVELLO STOP (Saturated/Rain)**\n\n" if heart_moisture is not None and heart_moisture >= SOIL_MOISTURE_FIELD_CAPACITY * 0.9: - advice_msg += "Terreno saturo o molto umido. " + advice_msg += "Terreno a capacità di campo o oltre (evitare saturazione: perdita nutrienti, asfissia radicale). " if rain_covers_demand or future_rain_mm >= PRECIP_THRESHOLD_SIGNIFICANT: advice_msg += f"Pioggia prevista ({future_rain_mm:.1f}mm) > fabbisogno calcolato ({estimated_deficit:.1f}mm). " if rainy_days: @@ -927,16 +1189,15 @@ def generate_shutdown_advice( # Genera consiglio if temp_below or (light_declining and saturation_ok): advice_level = "NO_ACTION" - advice_msg = "❄️ **CHIUDI TUTTO**\n\n" + advice_msg = "❄️ **CESSATA NECESSITÀ DI IRRIGAZIONE**\n\n" + advice_msg += "Precipitazioni e temperature in calo rendono superfluo irrigare. " if temp_below: - advice_msg += f"Trigger termico attivo: temperatura suolo {soil_temp_6cm:.1f}°C < {SOIL_TEMP_SHUTDOWN_THRESHOLD}°C stabilmente. " + advice_msg += f"Temperatura suolo {soil_temp_6cm:.1f}°C < {SOIL_TEMP_SHUTDOWN_THRESHOLD}°C stabilmente. " if light_declining: - advice_msg += f"Segnale luce: fotoperiodo in calo drastico ({sunshine_hours:.1f}h di sole). " + advice_msg += f"Fotoperiodo in calo ({sunshine_hours:.1f}h di sole). " if saturation_ok: - advice_msg += f"Umidità alta costante ({soil_moisture_9_27cm*100:.0f}%) per {high_moisture_streak} giorni. " - advice_msg += "\n\nLe piante sono entrate in riposo vegetativo. " - advice_msg += "**Consiglio**: Puoi svuotare l'impianto di irrigazione per l'inverno. " - advice_msg += "Il terreno non richiede più irrigazione artificiale." + advice_msg += f"Umidità alta ({soil_moisture_9_27cm*100:.0f}%) da {high_moisture_streak} giorni. " + advice_msg += "\n\nLe piante sono in riposo vegetativo. **Puoi spegnere e svuotare l'impianto di irrigazione per l'inverno.** Il terreno non richiederà più irrigazione artificiale fino alla prossima primavera." else: advice_level = "STANDARD" advice_msg = "🟡 **MONITORAGGIO CHIUSURA**\n\n" @@ -946,7 +1207,7 @@ def generate_shutdown_advice( if sunshine_hours is not None: advice_msg += f"• Fotoperiodo: {sunshine_hours:.1f}h (calo drastico se < 6h)\n" if soil_moisture_9_27cm is not None: - advice_msg += f"• Umidità: {soil_moisture_9_27cm*100:.0f}% (alta se ≥{SOIL_MOISTURE_AUTUMN_HIGH*100}% per {AUTUMN_HIGH_MOISTURE_DAYS} giorni)\n" + advice_msg += f"• Umidità: {soil_moisture_9_27cm*100:.0f}% (soglia saturazione ≥{SOIL_MOISTURE_AUTUMN_HIGH*100:.0f}% per {AUTUMN_HIGH_MOISTURE_DAYS} giorni)\n" advice_msg += "\n**Consiglio**: Continua il monitoraggio. Lo spegnimento è imminente." # Soil status summary @@ -988,70 +1249,213 @@ def generate_dormant_advice() -> Dict: } +# ============================================================================= +# CHART (grafico giorni precedenti e successivi) +# ============================================================================= + +def _build_chart_data( + daily_history: List[Dict], + hourly: Dict, + daily: Dict, + times: List, + current_idx: int, + now: datetime.datetime, +) -> Tuple[List[str], List[Optional[float]], List[Optional[float]], List[Optional[float]], List[Optional[float]], List[Optional[float]]]: + """Costruisce serie giornaliere: date, temp_6, moist_9_27, moist_27_81, et0, precip.""" + dates: List[str] = [] + temp_6: List[Optional[float]] = [] + moist_9_27: List[Optional[float]] = [] + moist_27_81: List[Optional[float]] = [] + et0: List[Optional[float]] = [] + precip: List[Optional[float]] = [] + # Passato: da daily_history (max 7) + seen_dates: set = set() + for h in daily_history: + d = h.get("date", "") + if d: + seen_dates.add(d) + dates.append(d) + temp_6.append(h.get("temp_6")) + moist_9_27.append(h.get("moist_9_27")) + moist_27_81.append(h.get("moist_27_81")) + et0.append(h.get("et0")) + precip.append(h.get("precip")) + # Futuro: da hourly (aggregato per giorno) e daily — salta date già presenti (evita duplicato "oggi") + daily_times = daily.get("time", []) or [] + et0_sum = daily.get("et0_fao_evapotranspiration_sum", []) or [] + precip_sum = daily.get("precipitation_sum", []) or [] + soil_temp_6 = hourly.get("soil_temperature_6cm", []) or hourly.get("soil_temperature_0cm", []) or [] + soil_moist_9_27 = hourly.get("soil_moisture_9_to_27cm", []) or [] + soil_moist_27_81 = hourly.get("soil_moisture_27_to_81cm", []) or [] + for day_offset in range(8): + start = current_idx + day_offset * 24 + end = min(start + 24, len(times)) + if start >= len(times): + break + try: + day_time = parse_time_to_local(times[start]) + date_str = day_time.date().isoformat() + if date_str in seen_dates: + continue + seen_dates.add(date_str) + dates.append(date_str) + vals_t = [float(soil_temp_6[i]) for i in range(start, end) if i < len(soil_temp_6) and soil_temp_6[i] is not None] + vals_9 = [float(soil_moist_9_27[i]) for i in range(start, end) if i < len(soil_moist_9_27) and soil_moist_9_27[i] is not None] + vals_27 = [float(soil_moist_27_81[i]) for i in range(start, end) if i < len(soil_moist_27_81) and soil_moist_27_81[i] is not None] + temp_6.append(sum(vals_t) / len(vals_t) if vals_t else None) + moist_9_27.append(sum(vals_9) / len(vals_9) if vals_9 else None) + moist_27_81.append(sum(vals_27) / len(vals_27) if vals_27 else None) + et0_val = None + precip_val = None + for i, d in enumerate(daily_times): + if d and str(d).startswith(date_str[:10]): + et0_val = float(et0_sum[i]) if i < len(et0_sum) and et0_sum[i] is not None else None + precip_val = float(precip_sum[i]) if i < len(precip_sum) and precip_sum[i] is not None else None + break + et0.append(et0_val) + precip.append(precip_val) + except Exception: + continue + return dates, temp_6, moist_9_27, moist_27_81, et0, precip + + +def build_irrigation_chart_bytes( + daily_history: List[Dict], + hourly: Dict, + daily: Dict, + times: List, + current_idx: int, + now: datetime.datetime, +) -> Optional[bytes]: + """Genera grafico PNG (temperatura e umidità suolo 9-27/27-81, ET0, precipitazioni). Ritorna bytes o None se matplotlib assente.""" + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import matplotlib.dates as mdates + except ImportError: + return None + dates, temp_6, moist_9_27, moist_27_81, et0_list, precip_list = _build_chart_data( + daily_history, hourly, daily, times, current_idx, now + ) + if not dates: + return None + x = list(range(len(dates))) + labels = [d[8:10] + "/" + d[5:7] if len(d) >= 10 else d for d in dates] + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 6), sharex=True, gridspec_kw={"height_ratios": [1.2, 1]}) + # Subplot 1: T suolo 6cm + Umidità 9-27 e 27-81 + ax1.set_ylabel("T° suolo (°C) / Umidità (%)") + t_vals = [float(t) if t is not None else float("nan") for t in temp_6] + ax1.plot(x, t_vals, "o-", color="C0", label="T suolo 6cm", markersize=4) + m9 = [float(m)*100 if m is not None else float("nan") for m in moist_9_27] + m27 = [float(m)*100 if m is not None else float("nan") for m in moist_27_81] + ax1.plot(x, m9, "s-", color="C2", label="Umidità 9-27cm", markersize=4) + ax1.plot(x, m27, "^-", color="C3", label="Umidità 27-81cm", markersize=4) + ax1.axhline(y=SOIL_MOISTURE_DEEP_STRESS * 100, color="gray", linestyle="--", alpha=0.7, label=f"Trigger {SOIL_MOISTURE_DEEP_STRESS*100:.0f}%") + ax1.axhline(y=SOIL_MOISTURE_WILTING_POINT * 100, color="brown", linestyle="--", alpha=0.7, label=f"Appassimento {SOIL_MOISTURE_WILTING_POINT*100:.0f}%") + ax1.legend(loc="upper right", fontsize=7) + ax1.grid(True, alpha=0.3) + ax1.set_ylim(bottom=0) + # Subplot 2: ET0 e Precipitazioni + ax2.set_ylabel("ET₀ (mm) / Precip (mm)") + et0_vals = [float(e) if e is not None else 0.0 for e in et0_list] + precip_vals = [float(p) if p is not None else 0.0 for p in precip_list] + ax2.bar([i - 0.2 for i in x], et0_vals, 0.35, label="ET₀", color="C0", alpha=0.8) + ax2.bar([i + 0.2 for i in x], precip_vals, 0.35, label="Precip", color="C1", alpha=0.8) + ax2.legend(loc="upper right", fontsize=7) + ax2.grid(True, alpha=0.3) + ax2.set_ylim(bottom=0) + plt.xticks(x, labels, rotation=45, ha="right") + plt.tight_layout() + import io + buf = io.BytesIO() + plt.savefig(buf, format="png", dpi=100, bbox_inches="tight") + plt.close() + buf.seek(0) + return buf.read() + + # ============================================================================= # MAIN ANALYSIS # ============================================================================= +IRRIGATION_NEED_DELTA_SIGNIFICANT = 25 # Variazione indice 0-100 per "cambio significativo" +DAYS_WEEKLY_SUMMER = 7 # Cadenza settimanale in fase attiva + def should_send_auto_report( phase: str, soil_temp_6cm: Optional[float], state: Dict, - force_debug: bool = False + force_debug: bool = False, + context: Optional[Dict] = None ) -> Tuple[bool, str]: """ - Determina se inviare un report automatico basato su indicatori di fase. - Returns: (should_send, reason) + Determina se inviare un report automatico: + a) Una tantum: prima uscita da dormiente prevista nel mese. + b) Estate: cadenza settimanale o variazioni significative del fabbisogno. + c) Una tantum: cessata necessità irrigazione a fine stagione. + context: will_exit_dormant_in_forecast, current_month_iso (YYYY-MM), + irrigation_need_today, irrigation_need_next_days, days_since_last_report. """ - # In modalità debug, invia sempre if force_debug: return True, "DEBUG MODE" - - # Se siamo in fase dormiente e non ci sono indicatori di risveglio, silente + ctx = context or {} + now = now_local() + current_month_iso = now.strftime("%Y-%m") + will_exit = ctx.get("will_exit_dormant_in_forecast", False) + need_today = ctx.get("irrigation_need_today") + need_next = ctx.get("irrigation_need_next_days") or [] + last_report = state.get("last_auto_report_date") + days_since_last = 999 + if last_report: + try: + d = datetime.date.fromisoformat(last_report) + days_since_last = (now.date() - d).days + except Exception: + pass + if phase == "dormant": - # Controlla se ci sono indicatori di avvicinamento al risveglio - if soil_temp_6cm is not None and soil_temp_6cm >= SOIL_TEMP_WAKEUP_INDICATOR: - # Siamo in inverno ma il terreno si sta scaldando -> si avvicina il momento - if not state.get("wakeup_threshold_reached", False): - # Prima volta che superiamo l'indicatore -> sblocca report e notifica - state["wakeup_threshold_reached"] = True - state["auto_reporting_enabled"] = True # Abilita per monitorare il risveglio - return True, "TERRENO_IN_RISVEGLIO" - # Già notificato, ma continuiamo a monitorare se auto-reporting è attivo - if state.get("auto_reporting_enabled", False): - return True, "MONITORAGGIO_RISVEGLIO" - # Anche se è dormiente, se abbiamo già raggiunto la soglia di risveglio, continua - if state.get("wakeup_threshold_reached", False) and state.get("auto_reporting_enabled", False): - return True, "POST_RISVEGLIO" - # Silente + if will_exit and state.get("wakeup_notified_for_month") != current_month_iso: + state["wakeup_notified_for_month"] = current_month_iso + state["last_auto_report_date"] = now.date().isoformat() + return True, "PRIMO_RISVEGLIO_MESE" return False, "DORMANT_SILENT" - - # Fase wakeup: sempre invia (stiamo monitorando l'attivazione) + if phase == "wakeup": - if not state.get("auto_reporting_enabled", False): - # Prima volta che entriamo in wakeup -> abilita auto-reporting - state["auto_reporting_enabled"] = True - state["wakeup_threshold_reached"] = True - return True, "WAKEUP_ENABLED" - return True, "WAKEUP_MONITORING" - - # Fase active: sempre invia (stagione attiva) + if state.get("wakeup_notified_for_month") != current_month_iso: + state["wakeup_notified_for_month"] = current_month_iso + state["last_auto_report_date"] = now.date().isoformat() + return True, "PRIMO_RISVEGLIO_MESE" + return False, "WAKEUP_GIÀ_NOTIFICATO" + if phase == "active": - state["auto_reporting_enabled"] = True - state["wakeup_threshold_reached"] = True state["shutdown_confirmed"] = False - return True, "ACTIVE_SEASON" - - # Fase shutdown: invia finché non confermiamo la chiusura + if days_since_last >= DAYS_WEEKLY_SUMMER: + state["last_auto_report_date"] = now.date().isoformat() + if need_today is not None: + state["last_irrigation_need"] = need_today + return True, "CADENZA_SETTIMANALE" + if need_next and need_today is not None: + need_min = min([need_today] + need_next) + need_max = max([need_today] + need_next) + if (need_max - need_min) >= IRRIGATION_NEED_DELTA_SIGNIFICANT: + state["last_auto_report_date"] = now.date().isoformat() + state["last_irrigation_need"] = need_today + return True, "VARIAZIONE_FABBISOGNO" + last_need = state.get("last_irrigation_need") + if last_need is not None and need_today is not None and abs(need_today - last_need) >= IRRIGATION_NEED_DELTA_SIGNIFICANT: + state["last_auto_report_date"] = now.date().isoformat() + state["last_irrigation_need"] = need_today + return True, "VARIAZIONE_FABBISOGNO" + return False, "ACTIVE_NESSUNA_VARIAZIONE" + if phase == "shutdown": if state.get("shutdown_confirmed", False): - # Chiusura già confermata -> disabilita auto-reporting - state["auto_reporting_enabled"] = False - return False, "SHUTDOWN_CONFIRMED" - # Prima chiusura -> invia notifica e poi disabilita + return False, "SHUTDOWN_GIÀ_NOTIFICATO" state["shutdown_confirmed"] = True - state["auto_reporting_enabled"] = False - return True, "SHUTDOWN_NOTIFICATION" - + state["last_auto_report_date"] = now.date().isoformat() + return True, "SHUTDOWN_CESSATA_NECESSITÀ" + return False, "UNKNOWN_PHASE" @@ -1065,10 +1469,7 @@ def analyze_irrigation( ) -> Tuple[str, bool]: """ Analisi principale e generazione report. - Returns: (report, should_send_auto) - """ - """ - Analisi principale e generazione report. + Returns: (report, should_send_auto, chart_bytes o None) """ LOGGER.info("=== Analisi Irrigazione per %s ===", location_name) @@ -1078,7 +1479,8 @@ def analyze_irrigation( # Recupera dati data = fetch_soil_and_weather(lat, lon, timezone) if not data: - return ("❌ **ERRORE**\n\nImpossibile recuperare dati meteorologici. Riprova più tardi.", False) + LOGGER.warning("Fetch dati meteo/suolo fallito: nessun dato disponibile") + return ("❌ **ERRORE**\n\nImpossibile recuperare dati meteorologici. Riprova più tardi.", False, None) hourly = data.get("hourly", {}) or {} daily = data.get("daily", {}) or {} @@ -1086,7 +1488,8 @@ def analyze_irrigation( # Estrai dati attuali (primi valori) times = hourly.get("time", []) or [] if not times: - return ("❌ **ERRORE**\n\nNessun dato temporale disponibile.", False) + LOGGER.warning("Nessun dato temporale nelle risposte API") + return ("❌ **ERRORE**\n\nNessun dato temporale disponibile.", False, None) now = now_local() current_idx = 0 @@ -1099,11 +1502,16 @@ def analyze_irrigation( except Exception: continue - # Dati suolo ICON Italia (potrebbero essere None) + # Dati suolo (ICON Seamless: tutti i layer; fallback ICON Italia: 0-1, 3-9, 9-27, 27-81) soil_temp_0cm_list = hourly.get("soil_temperature_0cm", []) or [] + soil_temp_6cm_list = hourly.get("soil_temperature_6cm", []) or [] + soil_temp_18cm_list = hourly.get("soil_temperature_18cm", []) or [] soil_temp_54cm_list = hourly.get("soil_temperature_54cm", []) or [] soil_moisture_0_1_list = hourly.get("soil_moisture_0_to_1cm", []) or [] + soil_moisture_3_9_list = hourly.get("soil_moisture_3_to_9cm", []) or [] + soil_moisture_9_27_list = hourly.get("soil_moisture_9_to_27cm", []) or [] soil_moisture_81_243_list = hourly.get("soil_moisture_81_to_243cm", []) or [] + soil_moisture_27_81_list = hourly.get("soil_moisture_27_to_81cm", []) or [] precip_list = hourly.get("precipitation", []) or [] snowfall_list = hourly.get("snowfall", []) or [] et0_list = hourly.get("et0_fao_evapotranspiration", []) or [] @@ -1111,31 +1519,25 @@ def analyze_irrigation( sunshine_list = hourly.get("sunshine_duration", []) or [] humidity_list = hourly.get("relative_humidity_2m", []) or [] # Umidità relativa aria shortwave_rad_list = hourly.get("shortwave_radiation", []) or [] # GHI - Global Horizontal Irradiance + temp_2m_list = hourly.get("temperature_2m", []) or [] # Temperatura aria (per veto gelo) - # Valori attuali (mappatura: 0cm ≈ 6cm per logica, 54cm ≈ 18cm) - soil_temp_6cm = None # Usa soil_temp_0cm - if current_idx < len(soil_temp_0cm_list) and soil_temp_0cm_list[current_idx] is not None: - soil_temp_6cm = float(soil_temp_0cm_list[current_idx]) - - soil_temp_18cm = None # Usa soil_temp_54cm - if current_idx < len(soil_temp_54cm_list) and soil_temp_54cm_list[current_idx] is not None: - soil_temp_18cm = float(soil_temp_54cm_list[current_idx]) - - # Umidità superficiale (0-1cm da ICON, mappata come 3-9cm nella logica) - soil_moisture_0_1cm = None - if current_idx < len(soil_moisture_0_1_list) and soil_moisture_0_1_list[current_idx] is not None: - soil_moisture_0_1cm = float(soil_moisture_0_1_list[current_idx]) - - # Per retrocompatibilità, usa anche come 3-9cm - soil_moisture_3_9cm = soil_moisture_0_1cm - - soil_moisture_9_27cm = None # Usa soil_moisture_81_to_243cm (profondo) - if current_idx < len(soil_moisture_81_243_list) and soil_moisture_81_243_list[current_idx] is not None: - soil_moisture_9_27cm = float(soil_moisture_81_243_list[current_idx]) - - # Riserva profonda 27-81cm (non disponibile in ICON, potrebbe tornare in estate) - # ICON fornisce solo 81-243cm, quindi lasciamo None - soil_moisture_27_81cm = None + def _at(idx, lst): + if lst and idx < len(lst) and lst[idx] is not None: + try: + return float(lst[idx]) + except (TypeError, ValueError): + pass + return None + + # Temperatura suolo: preferisci 6cm/18cm se presenti (ICON Seamless), altrimenti 0cm/54cm + soil_temp_6cm = _at(current_idx, soil_temp_6cm_list) or _at(current_idx, soil_temp_0cm_list) + soil_temp_18cm = _at(current_idx, soil_temp_18cm_list) or _at(current_idx, soil_temp_54cm_list) + + # Umidità: layer 0-1, 3-9, 9-27, 27-81 (ICON Seamless o Italia) + soil_moisture_0_1cm = _at(current_idx, soil_moisture_0_1_list) + soil_moisture_3_9cm = _at(current_idx, soil_moisture_3_9_list) or soil_moisture_0_1cm + soil_moisture_9_27cm = _at(current_idx, soil_moisture_9_27_list) or _at(current_idx, soil_moisture_81_243_list) + soil_moisture_27_81cm = _at(current_idx, soil_moisture_27_81_list) # Parametri aggiuntivi per calcolo stress idrico vpd_avg = None # Vapour Pressure Deficit medio (24h) @@ -1200,14 +1602,103 @@ def analyze_irrigation( future_rain_total, rainy_days = check_future_rainfall(daily, days_ahead=5) next_2_days_rain, _ = check_future_rainfall(daily, days_ahead=2) + # Temperatura aria min (prossime 24h) per veto gelo + air_temp_min_24h = None + for i in range(current_idx, min(current_idx + 24, len(temp_2m_list))): + if i < len(temp_2m_list) and temp_2m_list[i] is not None: + try: + v = float(temp_2m_list[i]) + air_temp_min_24h = v if air_temp_min_24h is None else min(air_temp_min_24h, v) + except (TypeError, ValueError): + pass + freeze_veto = air_temp_min_24h is not None and air_temp_min_24h < AIR_TEMP_FREEZE_VETO + rain_veto = ( + state.get("last_24h_precip_mm") is not None + and float(state["last_24h_precip_mm"]) >= PRECIP_VETO_MM_24H + ) + + # Modello a serbatoio (bucket): aggiorna bilancio una volta per giorno con ET0 e pioggia di oggi + today_iso = now.date().isoformat() + daily_times = daily.get("time", []) or [] + et0_sum = daily.get("et0_fao_evapotranspiration_sum", []) or [] + precip_sum = daily.get("precipitation_sum", []) or [] + balance = float(state.get("water_balance_mm", WATER_BALANCE_MAX_MM)) + last_balance_date = state.get("last_balance_date") + if last_balance_date != today_iso and daily_times: + day_idx = None + for i, d in enumerate(daily_times): + if d and str(d).startswith(today_iso[:10]): + day_idx = i + break + if day_idx is not None: + et0_day = float(et0_sum[day_idx]) if day_idx < len(et0_sum) and et0_sum[day_idx] is not None else 0.0 + precip_day = float(precip_sum[day_idx]) if day_idx < len(precip_sum) and precip_sum[day_idx] is not None else 0.0 + balance = balance - et0_day * KC_LAWN + precip_day + balance = max(0.0, min(WATER_BALANCE_MAX_MM, balance)) + state["water_balance_mm"] = balance + state["last_balance_date"] = today_iso + suggested_minutes = None + if balance < WATER_BALANCE_CRITICAL_MM: + deficit_mm = WATER_BALANCE_MAX_MM - balance + suggested_minutes = int(round(deficit_mm * 60.0 / APPLIED_MM_PER_HOUR)) + suggested_minutes = max(5, min(120, suggested_minutes)) # Clamp 5-120 min + + # Storico giornaliero per grafico (ultimi 7 giorni): aggiungi oggi e tronca + day_et0 = None + day_precip = None + if daily_times and et0_sum and precip_sum: + for i, d in enumerate(daily_times): + if d and str(d).startswith(today_iso[:10]): + day_et0 = float(et0_sum[i]) if i < len(et0_sum) and et0_sum[i] is not None else None + day_precip = float(precip_sum[i]) if i < len(precip_sum) and precip_sum[i] is not None else None + break + today_snapshot = { + "date": today_iso, + "temp_6": soil_temp_6cm, + "moist_9_27": soil_moisture_9_27cm, + "moist_27_81": soil_moisture_27_81cm, + "et0": day_et0, + "precip": day_precip, + } + daily_history = list(state.get("daily_history", []) or []) + # Rimuovi eventuale entry duplicata per oggi + daily_history = [h for h in daily_history if h.get("date") != today_iso] + daily_history.append(today_snapshot) + daily_history = daily_history[-7:] + state["daily_history"] = daily_history + # Determina fase stagionale month = now.month phase = determine_seasonal_phase( month, soil_temp_6cm, soil_moisture_3_9cm, soil_moisture_9_27cm, state ) + # Contesto per notifiche automatiche (uscita dormiente, fabbisogno, cadenza) + heart_moisture = None + if soil_moisture_3_9cm is not None and soil_moisture_9_27cm is not None: + heart_moisture = 0.4 * soil_moisture_3_9cm + 0.6 * soil_moisture_9_27cm + elif soil_moisture_9_27cm is not None: + heart_moisture = soil_moisture_9_27cm + elif soil_moisture_3_9cm is not None: + heart_moisture = soil_moisture_3_9cm + report_ctx = { + "will_exit_dormant_in_forecast": _will_exit_dormant_in_forecast(hourly, times, now), + "irrigation_need_today": _irrigation_need_index(heart_moisture, et0_avg, vpd_avg), + "irrigation_need_next_days": _irrigation_need_next_days(daily, heart_moisture, 3), + } + last_rep = state.get("last_auto_report_date") + if last_rep: + try: + report_ctx["days_since_last_report"] = (now.date() - datetime.date.fromisoformat(last_rep)).days + except Exception: + report_ctx["days_since_last_report"] = 999 + else: + report_ctx["days_since_last_report"] = 999 + # Determina se inviare report automatico - should_send, reason = should_send_auto_report(phase, soil_temp_6cm, state, force_debug=debug_mode) + should_send, reason = should_send_auto_report( + phase, soil_temp_6cm, state, force_debug=debug_mode, context=report_ctx + ) # Aggiorna stato state["phase"] = phase @@ -1290,79 +1781,137 @@ def analyze_irrigation( except Exception: pass - # Costruisci report completo (senza righe vuote eccessive) - report_parts = [ - f"{status}\n", - f"📍 {location_name}\n", - f"📅 {now.strftime('%d/%m/%Y %H:%M')}\n", - "="*25 + "\n", - advice - ] + # Riga umidità 9-27 e 27-81 in evidenza (cuore e riserva); se mancano mostriamo "—" + moisture_summary_parts = [] + moisture_summary_parts.append(f"9-27cm: {soil_moisture_9_27cm*100:.0f}%" if soil_moisture_9_27cm is not None else "9-27cm: —") + moisture_summary_parts.append(f"27-81cm: {soil_moisture_27_81cm*100:.0f}%" if soil_moisture_27_81cm is not None else "27-81cm: —") + moisture_summary_line = "💧 " + " | ".join(moisture_summary_parts) + "\n" + + # Pianificazione prossimi 8 giorni (da hourly: medie giornaliere 9-27cm, prima necessità sotto trigger) + planning_8d_line = "" + if times and soil_moisture_9_27_list and len(soil_moisture_9_27_list) >= current_idx + 24: + day_avgs = [] + first_below_trigger_day = None + for day_offset in range(8): + start = current_idx + day_offset * 24 + end = min(start + 24, len(soil_moisture_9_27_list)) + vals = [] + for i in range(start, end): + if i < len(soil_moisture_9_27_list) and soil_moisture_9_27_list[i] is not None: + try: + vals.append(float(soil_moisture_9_27_list[i])) + except (TypeError, ValueError): + pass + if vals: + avg = sum(vals) / len(vals) + day_avgs.append((day_offset, avg)) + if first_below_trigger_day is None and avg < SOIL_MOISTURE_DEEP_STRESS: + first_below_trigger_day = day_offset + if day_avgs: + parts = [f"G{d+1}:{a*100:.0f}%" for d, a in day_avgs[:8]] + planning_8d_line = "📅 Prossimi 8 gg (9-27cm): " + " ".join(parts) + if first_below_trigger_day is not None and phase == "active": + planning_8d_line += f" — sotto trigger (~{SOIL_MOISTURE_DEEP_STRESS*100:.0f}%) stimato tra {first_below_trigger_day + 1} gg" + planning_8d_line += "\n" + + # Consigli orario e daytime lock (E, F) + timing_advice = [] + if phase == "active": + timing_advice.append("Preferire irrigazione in mattinata (alba) se le notti sono calde (minor rischio fungino).") + if shortwave_avg is not None and shortwave_avg > SHORTWAVE_DAYTIME_LOCK_WM2: + timing_advice.append(f"Evitare irrigazione in pieno sole (radiazione >{SHORTWAVE_DAYTIME_LOCK_WM2:.0f} W/m²); preferire alba o tardo pomeriggio.") - # Aggiungi dettagli tecnici (se disponibili) + # Veti in evidenza + veto_lines = [] + if freeze_veto: + veto_lines.append(f"❄️ **VETO GELO**: T aria min 24h = {air_temp_min_24h:.1f}°C < {AIR_TEMP_FREEZE_VETO:.0f}°C — non irrigare.") + if rain_veto: + veto_lines.append(f"🌧️ **VETO PIOGGIA**: Ultime 24h ≥ {PRECIP_VETO_MM_24H:.0f} mm — non avviare irrigazione.") + + # Colpo d'occhio (box informazioni chiave) + glance = [ + status.strip(), + f"📍 {location_name} · {now.strftime('%d/%m/%Y %H:%M')}", + "", + "**Umidità (layer irrigazione)**", + moisture_summary_line.strip(), + "", + ] + if veto_lines: + glance.append("**Veti**") + glance.extend(veto_lines) + glance.append("") + if suggested_minutes is not None: + glance.append(f"**Irrigazione suggerita**: ~{suggested_minutes} min (ciclo lungo e lento, argilla)") + glance.append("") + + # Costruisci report completo (strutturato) + report_parts = [ + "\n".join(glance), + "─"*30, + "", + "**📋 CONSIGLIO**", + "", + advice, + "", + ] + if timing_advice: + report_parts.append("**⏰ Orario / Radiazione**\n") + report_parts.append("\n".join("• " + t for t in timing_advice)) + report_parts.append("") + if planning_8d_line: + report_parts.append(planning_8d_line) + + # Dettagli tecnici (organizzati) details = [] - # Temperatura suolo con trend + # Temperatura suolo: 0, 6, 18, 54 cm (ICON Seamless / EU) + soil_temp_0cm = _at(current_idx, soil_temp_0cm_list) + soil_temp_54cm = _at(current_idx, soil_temp_54cm_list) temp_found = False - if soil_temp_6cm is not None: - temp_class = classify_soil_temp(soil_temp_6cm) - temp_str = f"🌡️ T° suolo (0cm): {soil_temp_6cm:.1f}°C ({temp_class})" - if temp_trend: - temp_str += f" | trend 7gg: {temp_trend}" - details.append(temp_str) - temp_found = True - else: - # Prova a vedere se c'è un valore futuro nella lista (ICON: 0cm) - for i in range(current_idx, min(current_idx + 48, len(soil_temp_0cm_list))): + for label, val in [ + ("0cm", soil_temp_0cm), + ("6cm", soil_temp_6cm), + ("18cm", soil_temp_18cm), + ("54cm", soil_temp_54cm), + ]: + if val is not None: + temp_class = classify_soil_temp(val) + trend_str = f" | trend 7gg: {temp_trend}" if (temp_trend and label == "6cm") else "" + details.append(f"🌡️ T° suolo ({label}): {val:.1f}°C ({temp_class}){trend_str}") + temp_found = True + if not temp_found: + for i in range(current_idx, min(current_idx + 48, len(soil_temp_0cm_list or []))): if i < len(soil_temp_0cm_list) and soil_temp_0cm_list[i] is not None: - temp_val = float(soil_temp_0cm_list[i]) - details.append(f"🌡️ T° suolo (0cm): {temp_val:.1f}°C (prossime ore)") + details.append(f"🌡️ T° suolo (0cm): {float(soil_temp_0cm_list[i]):.1f}°C (prossime ore)") temp_found = True break - if soil_temp_18cm is not None: - temp_class_54 = classify_soil_temp(soil_temp_18cm) - temp_str = f"🌡️ T° suolo (54cm): {soil_temp_18cm:.1f}°C ({temp_class_54})" - details.append(temp_str) - temp_found = True - else: - # Prova valore futuro (ICON: 54cm) - for i in range(current_idx, min(current_idx + 48, len(soil_temp_54cm_list))): - if i < len(soil_temp_54cm_list) and soil_temp_54cm_list[i] is not None: - temp_val = float(soil_temp_54cm_list[i]) - details.append(f"🌡️ T° suolo (54cm): {temp_val:.1f}°C (prossime ore)") - temp_found = True - break - - # Umidità suolo (ICON: 0-1cm e 81-243cm) + # Umidità suolo: 0-1, 3-9, 9-27, 27-81 cm (ICON Seamless / EU). Mostriamo sempre tutti i layer. moisture_found = False - if soil_moisture_3_9cm is not None: - moisture_class = classify_soil_moisture(soil_moisture_3_9cm) - details.append(f"💧 Umidità (0-1cm): {soil_moisture_3_9cm*100:.0f}% ({moisture_class})") - moisture_found = True - else: - # Prova valore futuro - for i in range(current_idx, min(current_idx + 48, len(soil_moisture_0_1_list))): + for label, val in [ + ("0-1cm", soil_moisture_0_1cm), + ("3-9cm", soil_moisture_3_9cm), + ("9-27cm", soil_moisture_9_27cm), + ("27-81cm", soil_moisture_27_81cm), + ]: + if val is not None: + moisture_class = classify_soil_moisture(val) + line = f"💧 Umidità ({label}): {val*100:.0f}% ({moisture_class})" + if val >= SOIL_MOISTURE_FIELD_CAPACITY: + line += " — terreno pieno, possibile fanghiglia/ristagno" + details.append(line) + moisture_found = True + else: + details.append(f"💧 Umidità ({label}): — (non disponibile)") + if not moisture_found: + for i in range(current_idx, min(current_idx + 48, len(soil_moisture_0_1_list or []))): if i < len(soil_moisture_0_1_list) and soil_moisture_0_1_list[i] is not None: - moisture_val = float(soil_moisture_0_1_list[i]) - details.append(f"💧 Umidità (0-1cm): {moisture_val*100:.0f}% (prossime ore)") + v = float(soil_moisture_0_1_list[i]) + details.append(f"💧 Umidità (0-1cm): {v*100:.0f}% (prossime ore)") moisture_found = True break - if soil_moisture_9_27cm is not None: - moisture_class_deep = classify_soil_moisture(soil_moisture_9_27cm) - details.append(f"💧 Umidità (81-243cm): {soil_moisture_9_27cm*100:.0f}% ({moisture_class_deep})") - moisture_found = True - else: - # Prova valore futuro - for i in range(current_idx, min(current_idx + 48, len(soil_moisture_81_243_list))): - if i < len(soil_moisture_81_243_list) and soil_moisture_81_243_list[i] is not None: - moisture_val = float(soil_moisture_81_243_list[i]) - details.append(f"💧 Umidità (81-243cm): {moisture_val*100:.0f}% (prossime ore)") - moisture_found = True - break - - # Messaggio informativo se dati suolo non disponibili if not temp_found and not moisture_found: details.append("ℹ️ Dati suolo non disponibili per questa località") @@ -1413,57 +1962,134 @@ def analyze_irrigation( details.append("🌧️ Precipitazioni previste (5gg): 0mm (basso)") if details: - report_parts.append("─"*25 + "\n") - report_parts.append("**Dettagli Tecnici:**\n") + report_parts.append("─"*30) + report_parts.append("**📊 Dettagli tecnici**") + report_parts.append("") report_parts.append("\n".join(details)) # Salva stato save_state(state) + # Grafico (giorni precedenti + successivi) + chart_bytes = None + try: + chart_bytes = build_irrigation_chart_bytes( + state.get("daily_history", []) or [], + hourly, daily, times, current_idx, now, + ) + except Exception as e: + LOGGER.warning("Chart generation failed: %s", e) + report = "\n".join(report_parts) LOGGER.info("Analisi completata. Fase: %s, Auto-send: %s (%s)", phase, should_send, reason) - return report, should_send if not force_send else True + return report, should_send if not force_send else True, chart_bytes # ============================================================================= # TELEGRAM INTEGRATION (Optional) # ============================================================================= -def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -> bool: - """Invia messaggio Telegram in formato Markdown.""" +def telegram_send_photo(photo_bytes: bytes, caption: str, chat_ids: Optional[List[str]] = None) -> bool: + """Invia foto (PNG) a Telegram. caption in Markdown. Ritorna True se almeno un invio ok.""" token = load_bot_token() if not token: - LOGGER.warning("Telegram token missing: message not sent.") + LOGGER.warning("Telegram token missing: photo not sent.") return False - if chat_ids is None: chat_ids = TELEGRAM_CHAT_IDS - - url = f"https://api.telegram.org/bot{token}/sendMessage" - base_payload = { - "text": message, - "parse_mode": "Markdown", - "disable_web_page_preview": True, - } - + url = "https://api.telegram.org/bot{}/sendPhoto".format(token) sent_ok = False import time with requests.Session() as s: for chat_id in chat_ids: - payload = dict(base_payload) - payload["chat_id"] = chat_id try: - resp = s.post(url, json=payload, timeout=15) - if resp.status_code == 200: + r = s.post( + url, + data={"chat_id": chat_id, "caption": caption, "parse_mode": "Markdown"}, + files={"photo": ("irrigazione.png", photo_bytes, "image/png")}, + timeout=20, + ) + if r.status_code == 200: sent_ok = True else: - LOGGER.error("Telegram error chat_id=%s status=%s body=%s", - chat_id, resp.status_code, resp.text[:500]) + LOGGER.error("Telegram sendPhoto chat_id=%s status=%s %s", chat_id, r.status_code, r.text[:200]) time.sleep(0.25) except Exception as e: - LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e) - + LOGGER.exception("Telegram sendPhoto chat_id=%s err=%s", chat_id, e) + return sent_ok + + +TELEGRAM_MAX_MESSAGE_LENGTH = 4096 # Limite Telegram per messaggio + +def _split_message_for_telegram(text: str, max_len: int = TELEGRAM_MAX_MESSAGE_LENGTH - 100) -> List[str]: + """Spezza un messaggio in chunk sotto il limite Telegram (lascia margine per Markdown).""" + if len(text) <= max_len: + return [text] if text else [] + chunks = [] + while text: + if len(text) <= max_len: + chunks.append(text) + break + cut = text.rfind("\n", 0, max_len + 1) + if cut <= 0: + cut = max_len + chunks.append(text[:cut].strip()) + text = text[cut:].lstrip() + return chunks + + +def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -> bool: + """Invia messaggio Telegram. Prova con Markdown; se Telegram risponde 400 (Markdown non valido), ritenta in testo piano.""" + token = load_bot_token() + if not token: + LOGGER.warning("Telegram token missing: message not sent.") + return False + + if chat_ids is None: + chat_ids = TELEGRAM_CHAT_IDS + + chunks = _split_message_for_telegram(message) + if not chunks: + return True + + url = f"https://api.telegram.org/bot{token}/sendMessage" + sent_ok = False + import time + with requests.Session() as s: + for chat_id in chat_ids: + for chunk in chunks: + payload = { + "chat_id": chat_id, + "text": chunk, + "parse_mode": "Markdown", + "disable_web_page_preview": True, + } + try: + resp = s.post(url, json=payload, timeout=15) + if resp.status_code == 200: + sent_ok = True + elif resp.status_code == 400: + # Markdown non valido (es. underscore in NO_ACTION): ritenta senza formattazione + payload_plain = { + "chat_id": chat_id, + "text": chunk, + "disable_web_page_preview": True, + } + resp2 = s.post(url, json=payload_plain, timeout=15) + if resp2.status_code == 200: + sent_ok = True + LOGGER.debug("Inviato come testo piano dopo 400 Markdown") + else: + LOGGER.error("Telegram error chat_id=%s anche senza Markdown status=%s %s", + chat_id, resp2.status_code, resp2.text[:300]) + else: + LOGGER.error("Telegram error chat_id=%s status=%s body=%s", + chat_id, resp.status_code, resp.text[:500]) + time.sleep(0.2) + except Exception as e: + LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e) + time.sleep(0.25) return sent_ok @@ -1507,8 +2133,8 @@ def main(): force_send = args.force or args.debug use_auto_logic = args.auto or (not args.telegram and not args.force) - # Genera report - report, should_send_auto = analyze_irrigation( + # Genera report (e eventuale grafico) + report, should_send_auto, chart_bytes = analyze_irrigation( lat, lon, location, timezone, debug_mode=args.debug, force_send=force_send @@ -1544,10 +2170,16 @@ def main(): chat_ids = None if args.chat_id: chat_ids = [args.chat_id.strip()] + else: + chat_ids = TELEGRAM_CHAT_IDS + # Invia prima il report testuale (così arriva anche se dopo c'è timeout), poi il grafico success = telegram_send_markdown(report, chat_ids=chat_ids) if not success: print(report) # Fallback su stdout LOGGER.error("Errore invio Telegram, stampato su stdout") + if chart_bytes: + caption = f"💧 *Irrigazione* · {location} · {datetime.datetime.now().strftime('%d/%m/%Y %H:%M')}" + _ = telegram_send_photo(chart_bytes, caption, chat_ids) else: # Stampa sempre su stdout se non in modalità auto e non Telegram print(report) diff --git a/services/telegram-bot/snow_radar.py b/services/telegram-bot/snow_radar.py index 1585731..b390e3c 100755 --- a/services/telegram-bot/snow_radar.py +++ b/services/telegram-bot/snow_radar.py @@ -19,15 +19,14 @@ from open_meteo_client import configure_open_meteo_session # snow_radar.py # # Scopo: -# Analizza la nevicata in una griglia di località in un raggio di 40km da San Marino. -# Per ciascuna località mostra: -# - Nome della località -# - Somma dello snowfall orario nelle 12 ore precedenti -# - Somma dello snowfall previsto nelle 12 ore successive -# - Somma dello snowfall previsto nelle 24 ore successive +# Analizza la neve in una griglia di località in un raggio di 40km da San Marino. +# Combina due parametri Open-Meteo: +# - snowfall: precipitazione nevosa (cm/h) - neve che cade +# - snow_depth: spessore manto al suolo (m) - neve accumulata, incluso residuo +# senza precipitazione (es. giorni successivi a nevicata) # # Modello meteo: -# meteofrance_seamless (AROME) per dati dettagliati +# italia_meteo_arpae_icon_2i (supporta snowfall e snow_depth) # # Token Telegram: # Nessun token in chiaro. Lettura in ordine: @@ -88,8 +87,14 @@ LOCATIONS = [ TZ = "Europe/Berlin" TZINFO = ZoneInfo(TZ) -# Modello meteo -MODEL_AROME = "meteofrance_seamless" +# Modello meteo: italia_meteo_arpae_icon_2i supporta snowfall e snow_depth +# - snowfall: precipitazione nevosa (cm/h) +# - snow_depth: spessore manto al suolo (m), include neve residua anche senza precipitazione +MODEL_SNOW = "italia_meteo_arpae_icon_2i" + +# Soglia minima (cm) per considerare "neve presente" - evita falsi positivi da rumore/modello +# Valori < 1 cm sono tracce trascurabili (dew, frost, errori numerici) - non neve reale +SNOW_THRESHOLD_CM = 1.0 # File di log BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -189,14 +194,16 @@ def calculate_distance_km(lat1: float, lon1: float, lat2: float, lon2: float) -> def get_forecast(session: requests.Session, lat: float, lon: float) -> Optional[Dict]: """ Recupera previsioni meteo per una località. + Inclusi: snowfall (precipitazione nevosa cm/h), snow_depth (manto al suolo m). """ params = { "latitude": lat, "longitude": lon, - "hourly": "snowfall,weathercode", + "hourly": "snowfall,snow_depth,weathercode", "timezone": TZ, - "forecast_days": 2, - "models": MODEL_AROME, + "past_days": 7, + "forecast_days": 7, + "models": MODEL_SNOW, } try: @@ -217,22 +224,25 @@ def get_forecast(session: requests.Session, lat: float, lon: float) -> Optional[ def analyze_snowfall_for_location(data: Dict, now: datetime.datetime) -> Optional[Dict]: """ - Analizza snowfall per una località. + Analizza snowfall e snow_depth per una località. - Nota: Open-Meteo fornisce principalmente dati futuri. Per i dati passati, - includiamo anche le ore appena passate se disponibili nei dati hourly. + Combina: + - snowfall: precipitazione nevosa (cm/h) - neve che cade + - snow_depth: spessore manto al suolo (m) - neve accumulata, incluso residuo senza precipitazione Returns: - Dict con: - - snow_past_12h: somma snowfall ultime 12 ore (cm) - se disponibile nei dati - - snow_next_12h: somma snowfall prossime 12 ore (cm) - - snow_next_24h: somma snowfall prossime 24 ore (cm) + Dict con valori in cm: + - snow_past_12h: max(somma snowfall ultime 12h, snow_depth attuale) + - snow_next_12h: somma snowfall prossime 12h + - snow_next_24h: max(somma snowfall prossime 24h, snow_depth max previsto) + - snow_depth_now_cm: manto attuale al suolo (cm) """ hourly = data.get("hourly", {}) or {} times = hourly.get("time", []) or [] snowfall = hourly.get("snowfall", []) or [] + snow_depth_raw = hourly.get("snow_depth", []) or [] # in metri (m) - if not times or not snowfall: + if not times: return None # Converti timestamps @@ -243,29 +253,54 @@ def analyze_snowfall_for_location(data: Dict, now: datetime.datetime) -> Optiona next_12h_end = now + datetime.timedelta(hours=12) next_24h_end = now + datetime.timedelta(hours=24) - snow_past_12h = 0.0 - snow_next_12h = 0.0 - snow_next_24h = 0.0 + snowfall_past_12h = 0.0 + snowfall_next_12h = 0.0 + snowfall_next_24h = 0.0 + snow_depth_now_m = 0.0 + snow_depth_max_past_12h_m = 0.0 + snow_depth_max_next_24h_m = 0.0 + + # Rumore numerico: valori < 0.01 cm (snowfall) o < 0.0001 m (snow_depth) → 0 + NOISE_FLOOR_SNOWFALL_CM = 0.01 + NOISE_FLOOR_SNOW_DEPTH_M = 0.0001 for i, dt in enumerate(dt_list): snow_val = float(snowfall[i]) if i < len(snowfall) and snowfall[i] is not None else 0.0 + depth_val = float(snow_depth_raw[i]) if i < len(snow_depth_raw) and snow_depth_raw[i] is not None else 0.0 + if snow_val < NOISE_FLOOR_SNOWFALL_CM: + snow_val = 0.0 + if depth_val < NOISE_FLOOR_SNOW_DEPTH_M: + depth_val = 0.0 - # Ultime 12 ore (passato) - solo se i dati includono il passato + # Snowfall nelle finestre temporali if dt < now and dt >= past_12h_start: - snow_past_12h += snow_val - - # Prossime 12 ore + snowfall_past_12h += snow_val + snow_depth_max_past_12h_m = max(snow_depth_max_past_12h_m, depth_val) if now <= dt < next_12h_end: - snow_next_12h += snow_val - - # Prossime 24 ore + snowfall_next_12h += snow_val if now <= dt < next_24h_end: - snow_next_24h += snow_val + snowfall_next_24h += snow_val + snow_depth_max_next_24h_m = max(snow_depth_max_next_24h_m, depth_val) + + # snow_depth attuale: usa valore più vicino a "now" (ultima ora passata o prima futura) + if dt <= now: + snow_depth_now_m = depth_val + + # snow_depth da m a cm + snow_depth_now_cm = snow_depth_now_m * 100.0 + snow_depth_max_past_12h_cm = snow_depth_max_past_12h_m * 100.0 + snow_depth_max_next_24h_cm = snow_depth_max_next_24h_m * 100.0 + + # Combina precipitazione + manto: per passato usa max(somma precipitazione, manto attuale) + # per futuro usa max(somma precipitazione, manto max previsto) + snow_past_12h = max(snowfall_past_12h, snow_depth_now_cm) + snow_next_24h = max(snowfall_next_24h, snow_depth_max_next_24h_cm) return { "snow_past_12h": snow_past_12h, - "snow_next_12h": snow_next_12h, + "snow_next_12h": snowfall_next_12h, "snow_next_24h": snow_next_24h, + "snow_depth_now_cm": snow_depth_now_cm, } @@ -314,6 +349,9 @@ def generate_snow_map(results: List[Dict], center_lat: float, center_lon: float, totals = [r.get(data_field, 0.0) for r in results] max_total = max(totals) if totals else 1.0 min_total = min(totals) if totals else 0.0 + # Evita vmin==vmax (divisione per zero nel colormap) - tutti 0 → scala 0..1 + if max_total <= min_total: + max_total = max(min_total + 0.1, 1.0) # Estrai coordinate lats = [r["lat"] for r in results] @@ -457,9 +495,10 @@ def generate_snow_map(results: List[Dict], center_lat: float, center_lon: float, ax.legend(handles=legend_elements, loc='lower left', fontsize=10, framealpha=0.95, edgecolor='black', fancybox=True, shadow=True) - # Info timestamp spostata in alto a destra + # Info timestamp spostata in alto a destra (Località con neve = solo quelle con neve sopra soglia) now = now_local() - info_text = f"Aggiornamento: {now.strftime('%d/%m/%Y %H:%M')}\nLocalità con neve: {len(results)}" + num_with_snow = sum(1 for r in results if r.get("has_snow", False)) + info_text = f"Aggiornamento: {now.strftime('%d/%m/%Y %H:%M')}\nLocalità con neve: {num_with_snow}" ax.text(0.98, 0.98, info_text, transform=ax.transAxes, fontsize=9, verticalalignment='top', horizontalalignment='right', bbox=dict(boxstyle='round,pad=0.5', facecolor='white', alpha=0.9, @@ -660,11 +699,13 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, chat_id continue # Aggiungi sempre Casa, anche se non c'è neve - # Per le altre località, aggiungi solo se c'è neve (passata o prevista) + # Per le altre località, aggiungi solo se c'è neve sopra soglia (precipitazione o manto residuo) is_casa = loc["name"] == "Casa (Strada Cà Toro)" - has_snow = (snow_analysis["snow_past_12h"] > 0.0 or - snow_analysis["snow_next_12h"] > 0.0 or - snow_analysis["snow_next_24h"] > 0.0) + 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 + ) if is_casa or has_snow: results.append({ @@ -672,6 +713,7 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, chat_id "lat": loc["lat"], "lon": loc["lon"], "distance_km": distance_km, + "has_snow": has_snow, **snow_analysis }) @@ -687,6 +729,7 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, chat_id # Genera e invia DUE mappe separate now_str = now.strftime('%d/%m/%Y %H:%M') + num_with_snow = sum(1 for r in results if r.get("has_snow", False)) # 1. Mappa snowfall passato (12h precedenti) map_path_past = os.path.join(BASE_DIR, "snow_radar_past.png") @@ -698,7 +741,7 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, chat_id f"❄️ *SNOW RADAR - Ultime 12h*\n" f"📍 Centro: San Marino\n" f"🕒 {now_str}\n" - f"📊 Località con neve: {len(results)}/{len(LOCATIONS)}" + f"📊 Località con neve: {num_with_snow}/{len(LOCATIONS)}" ) telegram_send_photo(map_path_past, caption_past, chat_ids=chat_ids) # Pulisci file temporaneo @@ -718,7 +761,7 @@ def main(chat_ids: Optional[List[str]] = None, debug_mode: bool = False, chat_id f"❄️ *SNOW RADAR - Prossime 24h*\n" f"📍 Centro: San Marino\n" f"🕒 {now_str}\n" - f"📊 Località con neve: {len(results)}/{len(LOCATIONS)}" + f"📊 Località con neve: {num_with_snow}/{len(LOCATIONS)}" ) telegram_send_photo(map_path_future, caption_future, chat_ids=chat_ids) # Pulisci file temporaneo