#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 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 import datetime import json import logging import os import sys from logging.handlers import RotatingFileHandler from statistics import median as _median from typing import Dict, List, Optional, Tuple from zoneinfo import ZoneInfo import requests from dateutil import parser from open_meteo_client import open_meteo_get # ============================================================================= # CONFIGURATION # ============================================================================= DEBUG = os.environ.get("DEBUG", "0").strip() == "1" # Location DEFAULT_LAT = 43.9356 DEFAULT_LON = 12.4296 DEFAULT_LOCATION_NAME = "🏠 Casa" # Timezone TZ = "Europe/Berlin" TZINFO = ZoneInfo(TZ) # Open-Meteo: due fonti # - Suolo (tutti i layer): ICON Seamless (DWD) - copertura Europa centrale # - Meteo (ET₀, precipitazioni, T°): analisi a tre modelli con mediana (vedi OPEN_METEO_MODELS.md) OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" 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" # Retrocompatibilità / primo modello MODEL_ICON = MODEL_WEATHER # Retrocompatibilità # Tre modelli per mediana (Europa/Italia: ICON Italia + ECMWF IFS + ARPEGE/Météo-France; ARPEGE preferito a GFS) WEATHER_MODELS_THREE = [ "italia_meteo_arpae_icon_2i", # ~3 d utili, 2 km Italia/SM "ecmwf_ifs", # 15 d, ~9 km "meteofrance_seamless", # ARPEGE+AROME, 4 d, 0.1° Europa ] WEATHER_MODELS_FORECAST_DAYS = { "italia_meteo_arpae_icon_2i": 10, "ecmwf_ifs": 10, "meteofrance_seamless": 4, } HTTP_HEADERS = {"User-Agent": "Smart-Irrigation-Advisor/2.0"} # Files BASE_DIR = os.path.dirname(os.path.abspath(__file__)) LOG_FILE = os.path.join(BASE_DIR, "irrigation_advisor.log") STATE_FILE = os.path.join(BASE_DIR, "irrigation_state.json") # Telegram (opzionale, per integrazione bot) TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"] TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token") TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token" # Soglie Agronomiche SOIL_TEMP_WAKEUP_THRESHOLD = 10.0 # °C - Soglia di risveglio vegetativo 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) # 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 # Soglia "saturazione" per chiusura autunnale: SEPARATA da FC (a FC pieno è di fatto # irraggiungibile per 5 giorni). Usiamo FC*0.9 così il ramo saturazione dello shutdown # è effettivamente raggiungibile. (Supera i valori per-tipo qui sopra.) SOIL_MOISTURE_AUTUMN_HIGH = round(SOIL_MOISTURE_FIELD_CAPACITY * 0.9, 3) PRECIP_THRESHOLD_SIGNIFICANT = 5.0 # mm - Pioggia significativa 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 # ============================================================================= # Soglie per classificare i parametri come bassi, medio/bassi, medi, alti, medio/alti # Evapotraspirazione (ET₀) - mm/d ET0_LOW = 2.0 # < 2.0 mm/d = basso ET0_MEDIUM_LOW = 3.5 # 2.0-3.5 mm/d = medio/basso ET0_MEDIUM_HIGH = 5.0 # 3.5-5.0 mm/d = medio/alto # > 5.0 mm/d = alto # Temperatura suolo - °C SOIL_TEMP_LOW = 5.0 # < 5°C = basso 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: 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) VPD_MEDIUM_LOW = 0.8 # 0.5-0.8 kPa = medio/basso VPD_MEDIUM_HIGH = 1.2 # 0.8-1.2 kPa = medio/alto # > 1.2 kPa = alto (secco, stress idrico) # ============================================================================= # LOGGING # ============================================================================= def setup_logger() -> logging.Logger: logger = logging.getLogger("irrigation_advisor") logger.setLevel(logging.DEBUG if DEBUG else logging.INFO) logger.handlers.clear() fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s") parent_dir = os.path.dirname(LOG_FILE) if parent_dir and not os.path.exists(parent_dir): os.makedirs(parent_dir, exist_ok=True) try: fh = RotatingFileHandler(LOG_FILE, maxBytes=500_000, backupCount=3, encoding="utf-8") fh.setLevel(logging.DEBUG) fh.setFormatter(fmt) logger.addHandler(fh) except PermissionError: fallback_log = "/tmp/irrigation_advisor.log" try: fh = RotatingFileHandler(fallback_log, maxBytes=500_000, backupCount=3, encoding="utf-8") fh.setLevel(logging.DEBUG) fh.setFormatter(fmt) logger.addHandler(fh) logger.warning("Permesso negato su %s, uso fallback %s", LOG_FILE, fallback_log) except Exception: sh = logging.StreamHandler() sh.setLevel(logging.DEBUG) sh.setFormatter(fmt) logger.addHandler(sh) logger.warning("Permesso negato su %s, fallback su stderr", LOG_FILE) except Exception: sh = logging.StreamHandler() sh.setLevel(logging.DEBUG) sh.setFormatter(fmt) logger.addHandler(sh) logger.warning("Errore logger file %s, fallback su stderr", LOG_FILE) if DEBUG: sh = logging.StreamHandler() sh.setLevel(logging.DEBUG) sh.setFormatter(fmt) logger.addHandler(sh) return logger LOGGER = setup_logger() # ============================================================================= # UTILITIES # ============================================================================= def now_local() -> datetime.datetime: return datetime.datetime.now(TZINFO) def parse_time_to_local(t: str) -> datetime.datetime: dt = parser.isoparse(t) if dt.tzinfo is None: return dt.replace(tzinfo=TZINFO) return dt.astimezone(TZINFO) def ensure_parent_dir(path: str) -> None: parent = os.path.dirname(path) if parent and not os.path.exists(parent): os.makedirs(parent, exist_ok=True) def first_non_none(*vals): """Primo valore non None (a differenza di `a or b`, NON scarta lo 0.0 legittimo, es. temperatura suolo invernale = 0°C).""" for v in vals: if v is not None: return v return None def heart_moisture(m3_9: Optional[float], m9_27: Optional[float]) -> Optional[float]: """Umidità del 'cuore' radicale (VWC): media ponderata 3-9cm (0.3) e 9-27cm (0.7). Formula UNICA usata da advice, indice di stress e contesto notifiche (prima incoerente).""" if m3_9 is not None and m9_27 is not None: return 0.3 * m3_9 + 0.7 * m9_27 if m9_27 is not None: return m9_27 if m3_9 is not None: return m3_9 return None def read_text_file(path: str) -> str: try: with open(path, "r", encoding="utf-8") as f: return f.read().strip() except FileNotFoundError: return "" except Exception as e: LOGGER.debug("Error reading %s: %s", path, e) return "" def load_bot_token() -> str: tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() if tok: return tok tok = read_text_file(TOKEN_FILE_HOME) if tok: return tok tok = read_text_file(TOKEN_FILE_ETC) return tok.strip() if tok else "" # ============================================================================= # STATE MANAGEMENT # ============================================================================= def load_state() -> Dict: default = { "phase": "unknown", # "wakeup", "active", "shutdown", "dormant" "last_check": None, "soil_temp_history": [], # Lista di (date, temp_6cm) "high_moisture_streak": 0, "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), ancorato ai sensori "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) "manual_irrigation_mm": 0.0, # Irrigazione manuale dichiarata (mm): aggiunta al bilancio e azzerata } if os.path.exists(STATE_FILE): try: with open(STATE_FILE, "r", encoding="utf-8") as f: data = json.load(f) or {} default.update(data) except Exception as e: LOGGER.exception("State read error: %s", e) return default def save_state(state: Dict) -> None: try: ensure_parent_dir(STATE_FILE) with open(STATE_FILE, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) except Exception as e: LOGGER.exception("State write error: %s", e) # ============================================================================= # OPEN-METEO API (doppia fonte: suolo ICON Seamless, meteo ICON Italia) # ============================================================================= def fetch_soil_icon_seamless(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]: """ 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": 8, "hourly": ",".join([ "soil_temperature_0cm", "soil_temperature_6cm", "soil_temperature_18cm", "soil_temperature_54cm", "soil_moisture_0_to_1cm", "soil_moisture_1_to_3cm", "soil_moisture_3_to_9cm", "soil_moisture_9_to_27cm", "soil_moisture_27_to_81cm", ]), "models": MODEL_SOIL, } try: r = open_meteo_get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=(5, 30)) 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.warning("Soil (ICON Seamless) request error: %s", e) return None 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": 10, "hourly": ",".join([ "precipitation", "snowfall", "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_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 (ICON Italia) request error: %s", e) return None def _weather_params_common(lat: float, lon: float, timezone: str) -> Dict: """Parametri comuni hourly/daily per fetch meteo (ET₀, precipitazioni, ecc.).""" return { "latitude": lat, "longitude": lon, "timezone": timezone, "hourly": ",".join([ "precipitation", "snowfall", "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", ]), } def fetch_weather_single_model( lat: float, lon: float, timezone: str, model: str, forecast_days: int = 10 ) -> Optional[Dict]: """ Recupera dati meteo per un singolo modello Open-Meteo (stessa struttura di fetch_weather_icon_italia). """ params = _weather_params_common(lat, lon, timezone) params["forecast_days"] = forecast_days params["models"] = model 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.debug("Open-Meteo single model %s error: %s", model, e) return None def _median_or_single(values: List[Optional[float]]) -> Optional[float]: """Mediana dei valori numerici; ignora None. Se nessun valore valido, ritorna None.""" nums = [float(v) for v in values if v is not None] if not nums: return None if len(nums) == 1: return nums[0] return _median(nums) def _merge_daily_three_models_median(daily_list: List[Dict]) -> Dict: """ Unisce i daily di più risposte meteo: per ogni data (unione di tutte) calcola la mediana di et0_fao_evapotranspiration_sum, precipitation_sum, snowfall_sum, sunshine_duration. """ time_idx: Dict[str, int] = {} all_times: List[str] = [] for d in daily_list: times = d.get("time", []) or [] for t in times: key = str(t)[:10] if t else "" if key and key not in time_idx: time_idx[key] = len(all_times) all_times.append(key) if not all_times: return {"time": [], "et0_fao_evapotranspiration_sum": [], "precipitation_sum": [], "snowfall_sum": [], "sunshine_duration": []} # Per ogni data, indice in ogni daily daily_keys = ["et0_fao_evapotranspiration_sum", "precipitation_sum", "snowfall_sum", "sunshine_duration"] out: Dict[str, List] = {k: [] for k in daily_keys} out["time"] = all_times for date_str in all_times: for key in daily_keys: vals = [] for d in daily_list: times = d.get("time", []) or [] arr = d.get(key, []) or [] for i, t in enumerate(times): if str(t)[:10] == date_str and i < len(arr) and arr[i] is not None: try: vals.append(float(arr[i])) except (TypeError, ValueError): pass break out[key].append(_median_or_single(vals) if vals else None) return out def _merge_hourly_three_models_median(hourly_list: List[Dict]) -> Dict: """ Unisce gli hourly di più risposte: per ogni timestamp (unione) calcola la mediana per ogni variabile numerica. Usa il primo dizionario per la lista dei nomi chiave. Complessità O(n): per ogni modello si indicizza UNA volta tempo→posizione (prima era O(n²): per ogni timestamp si ri-scorreva l'intera serie di ogni modello). """ time_idx: Dict[str, int] = {} all_times: List[str] = [] model_idx: List[Dict[str, int]] = [] # per modello: chiave-tempo normalizzata → indice for h in hourly_list: times = h.get("time", []) or [] idx: Dict[str, int] = {} for i, t in enumerate(times): k = _normalize_time_key(str(t)) if t else "" if not k: continue if k not in idx: idx[k] = i if k not in time_idx: time_idx[k] = len(all_times) all_times.append(t if isinstance(t, str) else k) model_idx.append(idx) keys = [k for k in (list(hourly_list[0].keys()) if hourly_list else []) if k != "time"] if not all_times: return {"time": [], **{k: [] for k in keys}} out: Dict[str, List] = {"time": all_times} for key in keys: col: List = [] for ref_t in all_times: ref_k = _normalize_time_key(str(ref_t)) vals = [] for h, idx in zip(hourly_list, model_idx): i = idx.get(ref_k) if i is None: continue arr = h.get(key, []) or [] if i < len(arr) and arr[i] is not None: try: vals.append(float(arr[i])) except (TypeError, ValueError): pass col.append(_median_or_single(vals) if vals else None) out[key] = col return out def fetch_weather_three_models(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]: """ Recupera meteo da tre modelli (ICON Italia, ECMWF IFS, ARPEGE/Météo-France) e restituisce un unico payload con daily e hourly ottenuti dalla mediana per ogni giorno/ora. Vedi OPEN_METEO_MODELS.md per la motivazione (ARPEGE preferito a GFS per Europa/Italia). """ daily_list: List[Dict] = [] hourly_list: List[Dict] = [] meta = None for model in WEATHER_MODELS_THREE: fd = WEATHER_MODELS_FORECAST_DAYS.get(model, 10) data = fetch_weather_single_model(lat, lon, timezone, model, forecast_days=fd) if not data: continue if meta is None: meta = { "latitude": data.get("latitude"), "longitude": data.get("longitude"), "timezone": data.get("timezone"), } d = data.get("daily", {}) or {} h = data.get("hourly", {}) or {} if d.get("time"): daily_list.append(d) if h.get("time"): hourly_list.append(h) if not daily_list or meta is None: LOGGER.warning("Three-model weather: no valid responses; fallback to single ICON Italia.") return fetch_weather_icon_italia(lat, lon, timezone) merged_daily = _merge_daily_three_models_median(daily_list) merged_hourly = _merge_hourly_three_models_median(hourly_list) if hourly_list else {} return { "latitude": meta["latitude"], "longitude": meta["longitude"], "timezone": meta["timezone"], "hourly": merged_hourly, "daily": merged_daily, } 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 analisi a tre modelli (ICON Italia + ECMWF IFS + ARPEGE) con mediana di ET₀ e precipitazioni. 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_three_models(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): analisi a tre modelli con mediana.""" return fetch_weather_three_models(lat, lon, timezone) # ============================================================================= # SEASONAL PHASE DETECTION # ============================================================================= def determine_seasonal_phase( month: int, soil_temp_6cm: Optional[float], soil_moisture_3_9cm: Optional[float], soil_moisture_9_27cm: Optional[float], state: Dict ) -> str: """ Determina la fase stagionale: "wakeup", "active", "shutdown", "dormant" """ # Primavera (Marzo-Maggio): fase risveglio o attiva if month in [3, 4, 5]: # Se temperatura suolo > soglia per X giorni consecutivi -> attiva # Altrimenti -> wakeup if soil_temp_6cm is not None and soil_temp_6cm >= SOIL_TEMP_WAKEUP_THRESHOLD: # Verifica persistenza (dai dati storici o corrente) temp_history = state.get("soil_temp_history", []) recent_warm_days = 0 now = now_local() for date_str, temp in temp_history: try: date_obj = datetime.datetime.fromisoformat(date_str).replace(tzinfo=TZINFO) days_ago = (now - date_obj).days if days_ago <= SOIL_TEMP_WAKEUP_DAYS_MAX and temp >= SOIL_TEMP_WAKEUP_THRESHOLD: recent_warm_days += 1 except Exception: continue # Serve PERSISTENZA del caldo per dichiarare "active": senza storico sufficiente # restiamo in "wakeup" (la vecchia condizione con 'or soil_temp_6cm>=soglia' # era tautologica perché già garantita dall'if esterno → saltava sempre wakeup). if recent_warm_days >= SOIL_TEMP_WAKEUP_DAYS_MIN - 1: return "active" else: return "wakeup" else: return "wakeup" # Estate (Giugno-Agosto): sempre attiva elif month in [6, 7, 8]: return "active" # Autunno (Settembre-Novembre): attiva o shutdown elif month in [9, 10, 11]: if soil_temp_6cm is not None and soil_temp_6cm < SOIL_TEMP_SHUTDOWN_THRESHOLD: return "shutdown" elif (soil_moisture_9_27cm is not None and soil_moisture_9_27cm >= SOIL_MOISTURE_AUTUMN_HIGH): # Verifica giorni consecutivi con umidità alta high_streak = state.get("high_moisture_streak", 0) if high_streak >= AUTUMN_HIGH_MOISTURE_DAYS: return "shutdown" return "active" # Inverno (Dicembre-Febbraio): dormiente else: return "dormant" # ============================================================================= # CLASSIFICATION HELPERS # ============================================================================= def classify_et0(et0: float) -> str: """Classifica ET₀ in basso, medio/basso, medio, medio/alto, alto""" if et0 < ET0_LOW: return "basso" elif et0 < ET0_MEDIUM_LOW: return "medio/basso" elif et0 < ET0_MEDIUM_HIGH: return "medio" elif et0 < 7.0: return "medio/alto" else: return "alto" def classify_soil_temp(temp: float) -> str: """Classifica temperatura suolo in basso, medio/basso, medio, medio/alto, alto""" if temp < SOIL_TEMP_LOW: return "basso" elif temp < SOIL_TEMP_MEDIUM_LOW: return "medio/basso" elif temp < SOIL_TEMP_MEDIUM_HIGH: return "medio" elif temp < 20.0: return "medio/alto" else: return "alto" def classify_soil_moisture(moisture: float) -> str: """ 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" if moisture < SOIL_MOISTURE_DEEP_STRESS: return "medio/basso" if moisture < SOIL_MOISTURE_FIELD_CAPACITY * 0.95: return "medio" if moisture < SOIL_MOISTURE_FIELD_CAPACITY: return "medio/alto" return "alto" def classify_vpd(vpd: float) -> str: """Classifica VPD in basso, medio/basso, medio, medio/alto, alto""" if vpd < VPD_LOW: return "basso" elif vpd < VPD_MEDIUM_LOW: return "medio/basso" elif vpd < VPD_MEDIUM_HIGH: return "medio" elif vpd < 1.8: return "medio/alto" else: return "alto" _SPARK_BLOCKS = "▁▂▃▄▅▆▇█" def _sparkline(values: List[Optional[float]], vmin: float = 0.0, vmax: float = 100.0) -> str: """Mini-grafico testuale da una serie di valori (es. necessità prossimi giorni).""" out = [] span = (vmax - vmin) or 1.0 for v in values: if v is None: out.append(" ") continue frac = max(0.0, min(1.0, (float(v) - vmin) / span)) out.append(_SPARK_BLOCKS[int(round(frac * (len(_SPARK_BLOCKS) - 1)))]) return "".join(out) def _need_label(n: Optional[float]) -> str: """Etichetta qualitativa per l'indice di necessità irrigazione (0-100).""" if n is None: return "—" if n < 30: return "basso" if n < 55: return "medio" if n < 75: return "medio/alto" return "alto" # ============================================================================= # IRRIGATION LOGIC # ============================================================================= def check_future_rainfall(daily_data: Dict, days_ahead: int = 5, include_snowfall: bool = True) -> Tuple[float, List[str]]: """ Controlla pioggia prevista nei prossimi giorni usando precipitation_sum. precipitation_sum include già pioggia, neve e temporali (è la somma totale). Returns: (total_mm, list_of_days_with_precip) """ daily_times = daily_data.get("time", []) or [] # Usa precipitation_sum che include già pioggia, neve e temporali daily_precip = daily_data.get("precipitation_sum", []) or [] daily_snowfall = daily_data.get("snowfall_sum", []) or [] # Solo per indicare se c'è neve total = 0.0 rainy_days = [] now = now_local() for i, time_str in enumerate(daily_times[:days_ahead]): try: day_time = parse_time_to_local(time_str) if day_time.date() <= now.date(): continue # Salta giorni passati # precipitation_sum include già tutto (pioggia + neve + temporali) precip = float(daily_precip[i]) if i < len(daily_precip) and daily_precip[i] is not None else 0.0 snow = float(daily_snowfall[i]) if (include_snowfall and i < len(daily_snowfall) and daily_snowfall[i] is not None) else 0.0 # Usa solo precipitation_sum (non sommare snowfall separatamente, è già incluso) total_precip = precip if total_precip > 0.1: # Almeno 0.1 mm total += total_precip if snow > 0.5: # Se c'è neve significativa rainy_days.append(f"{day_time.strftime('%d/%m')} ({total_precip:.1f}mm, di cui {snow/10:.1f}cm neve)") else: rainy_days.append(f"{day_time.strftime('%d/%m')} ({precip:.1f}mm)") except Exception: continue return total, rainy_days def vwc_to_balance_mm(m: float) -> float: """Converte l'umidità volumetrica (VWC) in mm di acqua disponibile del serbatoio (0 = punto di appassimento, WATER_BALANCE_MAX_MM = capacità di campo).""" awc = SOIL_MOISTURE_FIELD_CAPACITY - SOIL_MOISTURE_WILTING_POINT if awc <= 0: return WATER_BALANCE_MAX_MM frac = (m - SOIL_MOISTURE_WILTING_POINT) / awc return max(0.0, min(1.0, frac)) * WATER_BALANCE_MAX_MM def balance_mm_to_vwc(b: float) -> float: """Inversa di vwc_to_balance_mm: da mm serbatoio a VWC stimata.""" awc = SOIL_MOISTURE_FIELD_CAPACITY - SOIL_MOISTURE_WILTING_POINT frac = (max(0.0, min(WATER_BALANCE_MAX_MM, b)) / WATER_BALANCE_MAX_MM) if WATER_BALANCE_MAX_MM > 0 else 0.5 return SOIL_MOISTURE_WILTING_POINT + frac * awc def effective_precip_mm(precip: float) -> float: """Pioggia efficace: intercettazione ~10% e penalità su eventi intensi (>10mm) per runoff/percolazione profonda (più marcata su suoli argillosi).""" if precip <= 0: return 0.0 if precip <= 10.0: return precip * 0.9 return (10.0 * 0.9) + (precip - 10.0) * 0.5 def _irrigation_need_index( heart_m: Optional[float], et0_mm_day: Optional[float], vpd_avg: Optional[float] = None ) -> float: """ Indice 0-100 di necessità di irrigazione (alto = serve acqua). Usato per evidenziare variazioni significative nel report. - moisture_factor NORMALIZZATO sull'AWC reale del suolo: (FC - m)/(FC - PWP), 0 a capacità di campo, 1 al punto di appassimento (prima usava 1-m sull'intero range 0-1, sovrastimando lo stress per i suoli argillosi). - et0_mm_day è il fabbisogno GIORNALIERO in mm (non più la media oraria). """ if heart_m is None and et0_mm_day is None: return 50.0 if heart_m is not None: awc = SOIL_MOISTURE_FIELD_CAPACITY - SOIL_MOISTURE_WILTING_POINT moisture_factor = (SOIL_MOISTURE_FIELD_CAPACITY - heart_m) / awc if awc > 0 else 0.5 moisture_factor = max(0.0, min(1.0, moisture_factor)) else: moisture_factor = 0.5 # Fattore ET₀ (mm/giorno, ~0-6): alta domanda = alto bisogno e = et0_mm_day if et0_mm_day is not None else 3.0 et0_factor = min(1.0, max(0.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 T° suolo prevista supera la soglia di risveglio per almeno 2 GIORNI CONSECUTIVI (prima bastava 1 solo giorno: un picco isolato di gennaio poteva far scattare la notifica 'primo risveglio' fuori tempo). """ 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 # Richiede 2 giorni consecutivi (in ordine cronologico) sopra soglia consecutive = 0 for key in sorted(day_temps.keys()): vals = day_temps[key] if vals and (sum(vals) / len(vals)) >= SOIL_TEMP_WAKEUP_THRESHOLD: consecutive += 1 if consecutive >= 2: return True else: consecutive = 0 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 (SOIL_MOISTURE_FIELD_CAPACITY + SOIL_MOISTURE_WILTING_POINT) / 2.0 ) # Mini-bilancio idrico in mm di serbatoio (coerente con il bucket): parte dalla VWC reale balance = vwc_to_balance_mm(moisture) 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 # Bilancio: -ET₀·Kc + pioggia efficace, clampato tra appassimento e capacità di campo balance = balance - et0 * KC_LAWN + effective_precip_mm(precip) balance = max(0.0, min(WATER_BALANCE_MAX_MM, balance)) moisture = balance_mm_to_vwc(balance) need_list.append(_irrigation_need_index(moisture, et0, None)) return need_list # ============================================================================= # ADVICE GENERATION # ============================================================================= def generate_wakeup_advice( soil_temp_6cm: Optional[float], soil_moisture_3_9cm: Optional[float], soil_moisture_9_27cm: Optional[float], future_rain_mm: float, rainy_days: List[str], shortwave_avg: Optional[float] = None, sunshine_hours: Optional[float] = None, state: Optional[Dict] = None ) -> Dict: """ FASE RISVEGLIO: "Quando accendere?" Trigger: Termico + Energetico + Fotoperiodo Returns: Dict con season_phase, advice_level, human_message, soil_status_summary """ state = state or {} status = "**Fase: Risveglio Primaverile**" # TRIGGER 1: Soglia Termica - Soil Temperature (6cm) > 10°C per 3-5 giorni consecutivi temp_ok = False temp_avg_24h = None if soil_temp_6cm is not None: # Calcola media 24h (se disponibile storico, altrimenti usa valore corrente) temp_history = state.get("soil_temp_history", []) now = now_local() recent_temps = [] for date_str, temp in temp_history: try: date_obj = datetime.datetime.fromisoformat(date_str).replace(tzinfo=TZINFO) days_ago = (now - date_obj).days if days_ago <= 7: # Ultimi 7 giorni recent_temps.append(temp) except Exception: continue recent_temps.append(soil_temp_6cm) if recent_temps: temp_avg_24h = sum(recent_temps) / len(recent_temps) # Verifica giorni consecutivi sopra soglia warm_days = 0 for date_str, temp in temp_history: try: date_obj = datetime.datetime.fromisoformat(date_str).replace(tzinfo=TZINFO) days_ago = (now - date_obj).days if days_ago <= SOIL_TEMP_WAKEUP_DAYS_MAX and temp >= SOIL_TEMP_WAKEUP_THRESHOLD: warm_days += 1 except Exception: continue if soil_temp_6cm >= SOIL_TEMP_WAKEUP_THRESHOLD: warm_days += 1 temp_ok = (temp_avg_24h is not None and temp_avg_24h >= SOIL_TEMP_WAKEUP_THRESHOLD and warm_days >= SOIL_TEMP_WAKEUP_DAYS_MIN) # TRIGGER 2: Energetico - Shortwave Radiation GHI in crescita energy_ok = False if shortwave_avg is not None: # Verifica se GHI mostra trend positivo (semplificato: > 150 W/m² indica buon irraggiamento) energy_ok = shortwave_avg > 150.0 # TRIGGER 3: Fotoperiodo - Sunshine Duration in aumento photoperiod_ok = False if sunshine_hours is not None: # Fotoperiodo adeguato per risveglio (almeno 6-7 ore di sole) photoperiod_ok = sunshine_hours >= 6.0 # Trigger combinati: almeno 2 su 3 devono essere OK (termico è obbligatorio) triggers_active = temp_ok and (energy_ok or photoperiod_ok) # 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_DEEP_STRESS # Genera consiglio if triggers_active and moisture_deep_low: advice_level = "CRITICAL" advice_msg = "🌱 **SVEGLIA IL SISTEMA**\n\n" advice_msg += "Tutti i trigger di risveglio sono attivi:\n" if temp_ok: advice_msg += f"• Temperatura suolo stabile ≥{SOIL_TEMP_WAKEUP_THRESHOLD}°C\n" if energy_ok: 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) 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.\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" if soil_temp_6cm is not None: advice_msg += f"Trigger termico non soddisfatto: temperatura suolo {soil_temp_6cm:.1f}°C < {SOIL_TEMP_WAKEUP_THRESHOLD}°C (soglia risveglio). " else: advice_msg += "Temperatura suolo non disponibile. " advice_msg += "Le piante sono ancora in riposo vegetativo. Attendi che il terreno si scaldi stabilmente." elif not moisture_deep_low: advice_level = "NO_ACTION" advice_msg = "💤 **DORMI ANCORA**\n\n" if soil_moisture_9_27cm is not None: advice_msg += f"Terreno profondo (9-27cm) ancora sufficientemente umido ({soil_moisture_9_27cm*100:.0f}%). " advice_msg += "Nessuna necessità di irrigazione al momento." else: advice_level = "NO_ACTION" advice_msg = "💤 **DORMI ANCORA**\n\n" advice_msg += "Trigger energetici o fotoperiodo non ancora sufficienti. " if future_rain_mm >= PRECIP_THRESHOLD_SIGNIFICANT: advice_msg += f"Pioggia prevista: {future_rain_mm:.1f}mm nei prossimi giorni. " if rainy_days: advice_msg += f"Giorni: {', '.join(rainy_days)}.\n\n" advice_msg += "Attendi condizioni più favorevoli prima di attivare l'impianto." # Soil status summary soil_summary_parts = [] if soil_temp_6cm is not None: soil_summary_parts.append(f"T.Suolo 6cm: {soil_temp_6cm:.1f}°C") if soil_moisture_9_27cm is not None: soil_summary_parts.append(f"Umidità Radici (9-27cm): {soil_moisture_9_27cm*100:.0f}%") soil_status_summary = " | ".join(soil_summary_parts) if soil_summary_parts else "Dati suolo non disponibili" return { "season_phase": "AWAKENING", "advice_level": advice_level, "human_message": advice_msg, "soil_status_summary": soil_status_summary, "status_display": status } def generate_active_advice( soil_moisture_0_1cm: Optional[float], soil_moisture_3_9cm: Optional[float], soil_moisture_9_27cm: Optional[float], soil_moisture_27_81cm: Optional[float], # Riserva profonda (se disponibile) future_rain_mm: float, rainy_days: List[str], et0_avg: Optional[float], next_2_days_rain: float, vpd_avg: Optional[float] = None ) -> Dict: """ FASE ATTIVA: "Quanto irrigare?" 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)**" # Analisi stratificata - ignora fluttuazioni superficiali (0-1cm) # Calcola media ponderata del "Cuore" del sistema (3-9cm e 9-27cm) con formula UNICA heart_m = heart_moisture(soil_moisture_3_9cm, soil_moisture_9_27cm) # Monitora la "Riserva" profonda (27-81cm): è allarme solo se sotto il TRIGGER irrigazione, # non sotto FC (in estate la riserva sta fisiologicamente sotto FC → prima era sempre "in calo"). reserve_depleting = False if soil_moisture_27_81cm is not None: reserve_depleting = soil_moisture_27_81cm < SOIL_MOISTURE_DEEP_STRESS # Fabbisogno idrico (ET₀ ora in mm/GIORNO) e copertura da pioggia prevista daily_water_demand = et0_avg if et0_avg is not None else 0.0 estimated_deficit = daily_water_demand * 2.0 # Fabbisogno stimato ~2 giorni # La pioggia "copre" se supera il deficit stimato ed è comunque significativa rain_covers_demand = ( next_2_days_rain >= estimated_deficit and next_2_days_rain >= PRECIP_THRESHOLD_SIGNIFICANT ) significant_rain_soon = future_rain_mm >= PRECIP_THRESHOLD_SIGNIFICANT # Stato zona radicale: "sotto trigger" se 9-27cm ≤ trigger oppure riserva sotto trigger below_trigger = ( (soil_moisture_9_27cm is not None and soil_moisture_9_27cm <= SOIL_MOISTURE_DEEP_STRESS) or reserve_depleting ) high_demand = daily_water_demand > 4.0 # giornata estiva ad alta evapotraspirazione # LOGIC DECISIONALE (priorità: pioggia → sotto trigger → mantenimento → superficie → stop) if rain_covers_demand: # 🟢 La pioggia coprirà il fabbisogno: inutile irrigare ora advice_level = "NO_ACTION" advice_msg = "🟢 **LIVELLO STOP (Pioggia in arrivo)**\n\n" advice_msg += f"Pioggia prevista ({future_rain_mm:.1f}mm) ≥ fabbisogno stimato ({estimated_deficit:.1f}mm). " if rainy_days: advice_msg += f"Giorni: {', '.join(rainy_days)}. " if below_trigger: advice_msg += "Terreno sotto trigger, ma conviene attendere la pioggia prima di irrigare. " advice_msg += "\n\n**Stop**: Non irrigare, ci pensa la natura." elif below_trigger and high_demand: # 🔴 CRITICO: zona radicale sotto trigger, ET₀ elevato, pioggia insufficiente 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) sotto trigger ({soil_moisture_9_27cm*100:.0f}% ≤ {SOIL_MOISTURE_DEEP_STRESS*100:.0f}%). " if reserve_depleting: advice_msg += f"Riserva profonda (27-81cm) sotto trigger: {soil_moisture_27_81cm*100:.0f}%. " advice_msg += f"ET₀ elevato ({daily_water_demand:.1f} mm/d), pioggia insufficiente.\n\n" advice_msg += "**Emergenza**: Irrigazione profonda **subito**, portando l'umidità verso la 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) elif below_trigger: # 🟠 Sotto trigger ma urgenza ridotta (ET₀ contenuto / clima fresco): irrigare appena possibile advice_level = "STANDARD" advice_msg = "🟠 **LIVELLO STANDARD (Sotto trigger)**\n\n" if soil_moisture_9_27cm is not None: advice_msg += f"Umidità profonda (9-27cm) sotto trigger ({soil_moisture_9_27cm*100:.0f}% ≤ {SOIL_MOISTURE_DEEP_STRESS*100:.0f}%), " advice_msg += f"ma ET₀ contenuto ({daily_water_demand:.1f} mm/d)" if significant_rain_soon: advice_msg += f" e un po' di pioggia in arrivo ({future_rain_mm:.1f}mm)" advice_msg += ".\n\n**Consiglio**: Programma un ciclo lungo e lento appena possibile (urgenza ridotta). Con argilla: bagnare in profondità, meno frequente." # 🟠 STANDARD (Maintenance): sopra trigger ma in calo verso di esso elif (heart_m is not None and heart_m < SOIL_MOISTURE_FIELD_CAPACITY * 0.8): advice_level = "STANDARD" advice_msg = "🟠 **LIVELLO STANDARD (Maintenance)**\n\n" advice_msg += "Umidità in calo verso il trigger, profondo (9-27cm) ancora ok. " if et0_avg is not None: advice_msg += f"ET₀ ({et0_avg:.1f} mm/d). " if significant_rain_soon: advice_msg += f"Pioggia prevista ({future_rain_mm:.1f}mm) potrebbe bastare.\n\n" advice_msg += "**Consiglio**: Attendi le precipitazioni, poi valuta." else: advice_msg += "Nessuna pioggia sufficiente prevista a breve.\n\n" advice_msg += "**Routine**: Ciclo lungo e lento consigliato (argilla: bagnare in profondità, meno frequente)." # 🟡 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_m is not None and heart_m >= 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 significant_rain_soon: advice_msg += f"Pioggia prevista: {future_rain_mm:.1f}mm.\n\n" 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**: " + ("Attendi prossimo ciclo completo (argilla: niente brevi rinfrescate)." if SOIL_IS_CLAY else "Breve rinfrescata superficiale o attendi domani.") # 🟢 STOP: zona radicale adeguata (sopra trigger / a capacità di campo) else: advice_level = "NO_ACTION" advice_msg = "🟢 **LIVELLO STOP**\n\n" if heart_m is not None and heart_m >= SOIL_MOISTURE_FIELD_CAPACITY * 0.9: advice_msg += "Terreno a capacità di campo o oltre (evitare saturazione: perdita nutrienti, asfissia radicale). " else: advice_msg += "Umidità della zona radicale adeguata (sopra trigger). " advice_msg += "\n\n**Stop**: Non irrigare per ora." # Soil status summary soil_summary_parts = [] if soil_moisture_3_9cm is not None: soil_summary_parts.append(f"Umidità 3-9cm: {soil_moisture_3_9cm*100:.0f}%") if soil_moisture_9_27cm is not None: soil_summary_parts.append(f"Umidità 9-27cm: {soil_moisture_9_27cm*100:.0f}%") if soil_moisture_27_81cm is not None: soil_summary_parts.append(f"Riserva 27-81cm: {soil_moisture_27_81cm*100:.0f}%") if et0_avg is not None: soil_summary_parts.append(f"ET₀: {et0_avg:.1f}mm/d") soil_status_summary = " | ".join(soil_summary_parts) if soil_summary_parts else "Dati limitati" return { "season_phase": "ACTIVE", "advice_level": advice_level, "human_message": advice_msg, "soil_status_summary": soil_status_summary, "status_display": status } def generate_shutdown_advice( soil_temp_6cm: Optional[float], soil_moisture_9_27cm: Optional[float], high_moisture_streak: int, sunshine_hours: Optional[float] = None, shortwave_avg: Optional[float] = None, state: Optional[Dict] = None ) -> Dict: """ FASE CHIUSURA: "Quando spegnere?" Trigger: Crollo Termico + Segnale Luce + Saturazione Returns: Dict con season_phase, advice_level, human_message, soil_status_summary """ state = state or {} status = "**Fase: Chiusura Autunnale**" # TRIGGER 1: Crollo Termico - Soil Temperature (6cm) < 10°C stabilmente temp_below = False if soil_temp_6cm is not None: temp_below = soil_temp_6cm < SOIL_TEMP_SHUTDOWN_THRESHOLD # Verifica se è stabile (controlla storico) temp_history = state.get("soil_temp_history", []) now = now_local() recent_below_count = 0 for date_str, temp in temp_history: try: date_obj = datetime.datetime.fromisoformat(date_str).replace(tzinfo=TZINFO) days_ago = (now - date_obj).days if days_ago <= 3 and temp < SOIL_TEMP_SHUTDOWN_THRESHOLD: recent_below_count += 1 except Exception: continue if temp_below: recent_below_count += 1 temp_below = recent_below_count >= 2 # Almeno 2 giorni consecutivi # TRIGGER 2: Segnale Luce - Sunshine Duration in calo drastico light_declining = False if sunshine_hours is not None: # Fotoperiodo sotto 6 ore indica calo drastico (inizio dormienza) light_declining = sunshine_hours < 6.0 # TRIGGER 3: Saturazione - Soil Moisture (9-27cm) alta costantemente saturation_ok = False if (soil_moisture_9_27cm is not None and soil_moisture_9_27cm >= SOIL_MOISTURE_AUTUMN_HIGH and high_moisture_streak >= AUTUMN_HIGH_MOISTURE_DAYS): saturation_ok = True # Genera consiglio if temp_below or (light_declining and saturation_ok): advice_level = "NO_ACTION" advice_msg = "❄️ **CESSATA NECESSITÀ DI IRRIGAZIONE**\n\n" advice_msg += "Precipitazioni e temperature in calo rendono superfluo irrigare. " if temp_below: advice_msg += f"Temperatura suolo {soil_temp_6cm:.1f}°C < {SOIL_TEMP_SHUTDOWN_THRESHOLD}°C stabilmente. " if light_declining: advice_msg += f"Fotoperiodo in calo ({sunshine_hours:.1f}h di sole). " if saturation_ok: 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" advice_msg += "Stagione autunnale avanzata. Monitora attentamente:\n" if soil_temp_6cm is not None: advice_msg += f"• Temperatura suolo: {soil_temp_6cm:.1f}°C (soglia: {SOIL_TEMP_SHUTDOWN_THRESHOLD}°C)\n" 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}% (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 soil_summary_parts = [] if soil_temp_6cm is not None: soil_summary_parts.append(f"T.Suolo 6cm: {soil_temp_6cm:.1f}°C") if soil_moisture_9_27cm is not None: soil_summary_parts.append(f"Umidità 9-27cm: {soil_moisture_9_27cm*100:.0f}%") if sunshine_hours is not None: soil_summary_parts.append(f"Fotoperiodo: {sunshine_hours:.1f}h") soil_status_summary = " | ".join(soil_summary_parts) if soil_summary_parts else "Dati limitati" return { "season_phase": "CLOSING", "advice_level": advice_level, "human_message": advice_msg, "soil_status_summary": soil_status_summary, "status_display": status } def generate_dormant_advice() -> Dict: """ FASE DORMIENTE (Inverno) Returns: Dict con season_phase, advice_level, human_message, soil_status_summary """ status = "**Fase: Riposo Invernale**" advice_msg = "❄️ **IMPIANTO SPENTO**\n" advice_msg += "Stagione invernale. Le piante sono in riposo vegetativo completo.\n" advice_msg += "**Consiglio**: L'impianto di irrigazione dovrebbe essere già svuotato e spento. " advice_msg += "Nessuna irrigazione necessaria fino alla prossima primavera." return { "season_phase": "DORMANT", "advice_level": "NO_ACTION", "human_message": advice_msg, "soil_status_summary": "Dormienza invernale", "status_display": status } # ============================================================================= # 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}%") # Linea verticale "oggi" today_iso = now.date().isoformat() now_idx = None for i, d in enumerate(dates): if d and str(d).startswith(today_iso[:10]): now_idx = i break if now_idx is not None: ax1.axvline(x=now_idx, color="red", linewidth=1, linestyle="-", alpha=0.9) 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) if now_idx is not None: ax2.axvline(x=now_idx, color="red", linewidth=1, linestyle="-", alpha=0.9) 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 evidenziata nel report come "cambio significativo" def should_send_auto_report( phase: str, state: Dict, now: Optional[datetime.datetime] = None, force_debug: bool = False, context: Optional[Dict] = None, ) -> Tuple[bool, str]: """ Decide se inviare un report automatico. FUNZIONE PURA: non modifica `state`. La persistenza dei flag di notifica avviene in commit_auto_report SOLO dopo invio Telegram confermato (così un fallimento di rete non marca 'già notificato' impedendo il retry il giorno dopo). Regole: - dormant : silenzio, salvo prima uscita-dormiente prevista nel mese (una tantum). - wakeup : prima notifica del mese (una tantum). - active : DIGEST INFORMATIVO QUOTIDIANO per tutta la fase (scelta utente), con guardia anti-doppio-invio nello stesso giorno. - shutdown: una tantum (cessata necessità di irrigazione). """ if force_debug: return True, "DEBUG MODE" ctx = context or {} now = now or now_local() current_month_iso = now.strftime("%Y-%m") today_iso = now.date().isoformat() will_exit = ctx.get("will_exit_dormant_in_forecast", False) if phase == "dormant": if will_exit and state.get("wakeup_notified_for_month") != current_month_iso: return True, "PRIMO_RISVEGLIO_MESE" return False, "DORMANT_SILENT" if phase == "wakeup": if state.get("wakeup_notified_for_month") != current_month_iso: return True, "PRIMO_RISVEGLIO_MESE" return False, "WAKEUP_GIÀ_NOTIFICATO" if phase == "active": # Digest quotidiano per tutta la fase attiva; una sola volta al giorno # (idempotenza per ri-esecuzioni manuali o retry nello stesso giorno). if state.get("last_auto_report_date") == today_iso: return False, "ACTIVE_GIÀ_INVIATO_OGGI" return True, "AGGIORNAMENTO_QUOTIDIANO" if phase == "shutdown": if state.get("shutdown_confirmed", False): return False, "SHUTDOWN_GIÀ_NOTIFICATO" return True, "SHUTDOWN_CESSATA_NECESSITÀ" return False, "UNKNOWN_PHASE" def commit_auto_report(send_record: Optional[Dict]) -> None: """Persiste i flag di notifica DOPO un invio Telegram andato a buon fine. Separata dalla decisione (should_send_auto_report) così un invio fallito non lascia lo stato come 'già notificato'. Ricarica lo stato salvato da analyze_irrigation e vi aggiunge la sola contabilità di notifica.""" if not send_record: return try: state = load_state() now = now_local() phase = send_record.get("phase") state["last_auto_report_date"] = now.date().isoformat() if phase in ("dormant", "wakeup"): state["wakeup_notified_for_month"] = now.strftime("%Y-%m") elif phase == "active": nt = send_record.get("need_today") if nt is not None: state["last_irrigation_need"] = nt elif phase == "shutdown": state["shutdown_confirmed"] = True save_state(state) LOGGER.info("commit_auto_report: phase=%s reason=%s persistito", phase, send_record.get("reason")) except Exception as e: LOGGER.exception("commit_auto_report error: %s", e) def analyze_irrigation( lat: float = DEFAULT_LAT, lon: float = DEFAULT_LON, location_name: str = DEFAULT_LOCATION_NAME, timezone: str = TZ, debug_mode: bool = False, force_send: bool = False ) -> Tuple[str, bool, Optional[bytes], Dict]: """ Analisi principale e generazione report. Returns: (report, should_send_auto, chart_bytes o None, send_record) send_record = {phase, reason, need_today} per commit_auto_report dopo invio confermato. """ LOGGER.info("=== Analisi Irrigazione per %s ===", location_name) # Carica stato precedente state = load_state() # Recupera dati data = fetch_soil_and_weather(lat, lon, timezone) if not data: LOGGER.warning("Fetch dati meteo/suolo fallito: nessun dato disponibile") return ("❌ **ERRORE**\n\nImpossibile recuperare dati meteorologici. Riprova più tardi.", False, None, None) hourly = data.get("hourly", {}) or {} daily = data.get("daily", {}) or {} # Estrai dati attuali (primi valori) times = hourly.get("time", []) or [] if not times: LOGGER.warning("Nessun dato temporale nelle risposte API") return ("❌ **ERRORE**\n\nNessun dato temporale disponibile.", False, None, None) now = now_local() current_idx = 0 for i, t_str in enumerate(times): try: t = parse_time_to_local(t_str) if t >= now: current_idx = i break except Exception: continue # 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_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_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 [] vpd_list = hourly.get("vapour_pressure_deficit", []) or [] # Stress idrico sunshine_list = hourly.get("sunshine_duration", []) or [] 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) 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 6cm (fallback 0cm). first_non_none (non 'or') così uno 0.0°C # invernale legittimo non viene scartato. soil_temp_6cm = first_non_none(_at(current_idx, soil_temp_6cm_list), _at(current_idx, soil_temp_0cm_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 = first_non_none(_at(current_idx, soil_moisture_3_9_list), soil_moisture_0_1cm) soil_moisture_9_27cm = _at(current_idx, soil_moisture_9_27_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) vpd_values = [] for i in range(current_idx, min(current_idx + 24, len(vpd_list))): if i < len(vpd_list) and vpd_list[i] is not None: try: vpd_values.append(float(vpd_list[i])) except Exception: continue if vpd_values: vpd_avg = sum(vpd_values) / len(vpd_values) sunshine_hours = None # Ore di sole previste (24h) sunshine_total = 0.0 for i in range(current_idx, min(current_idx + 24, len(sunshine_list))): if i < len(sunshine_list) and sunshine_list[i] is not None: try: sunshine_total += float(sunshine_list[i]) except Exception: continue if sunshine_total > 0: sunshine_hours = sunshine_total / 3600.0 # Converti secondi in ore # Shortwave Radiation GHI media (24h) - energia per fotosintesi shortwave_avg = None shortwave_values = [] for i in range(current_idx, min(current_idx + 24, len(shortwave_rad_list))): if i < len(shortwave_rad_list) and shortwave_rad_list[i] is not None: try: shortwave_values.append(float(shortwave_rad_list[i])) except Exception: continue if shortwave_values: shortwave_avg = sum(shortwave_values) / len(shortwave_values) # W/m² # ET₀ fabbisogno prossime 24h = SOMMA delle 24 ore orarie (mm/GIORNO). # BUG storico corretto: prima si usava la MEDIA oraria (~0.2 mm/h) come se fosse # mm/giorno (~5), rendendo inerte il gate CRITICAL (>3 mm/d) e i deficit. et0_avg = None # interpretato come mm/giorno dai consumatori (daily_water_demand, et0/6) et0_values = [] for i in range(current_idx, min(current_idx + 24, len(et0_list))): if i < len(et0_list) and et0_list[i] is not None: try: et0_values.append(float(et0_list[i])) except Exception: continue if len(et0_values) >= 20: et0_avg = sum(et0_values) # ~ET₀ giornaliero (mm/d) else: # Fallback: ET₀ giornaliero da 'daily' per oggi (hourly insufficiente, es. modello ridotto) _dt = daily.get("time", []) or [] _es = daily.get("et0_fao_evapotranspiration_sum", []) or [] _today_iso = now.date().isoformat() for _i, _d in enumerate(_dt): if _d and str(_d).startswith(_today_iso[:10]) and _i < len(_es) and _es[_i] is not None: try: et0_avg = float(_es[_i]) except (TypeError, ValueError): pass break if et0_avg is None and et0_values: et0_avg = sum(et0_values) # Previsioni pioggia 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) ANCORATO ai sensori: la VWC misurata è la base di verità, # eliminando la deriva (prima il bucket scendeva a 0 mentre il suolo restava ~22%). 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 [] heart_now = heart_moisture(soil_moisture_3_9cm, soil_moisture_9_27cm) # Irrigazione manuale dichiarata dall'utente (mm), contabilizzata una sola volta try: manual_mm = float(state.get("manual_irrigation_mm", 0.0) or 0.0) except (TypeError, ValueError): manual_mm = 0.0 if heart_now is not None: # Bilancio derivato dal SENSORE (+ eventuale irrigazione manuale appena dichiarata) balance = vwc_to_balance_mm(heart_now) + manual_mm else: # Sensore assente: propaga dal bilancio memorizzato (fallback giornaliero ET₀/pioggia) balance = float(state.get("water_balance_mm", WATER_BALANCE_MAX_MM)) + manual_mm if state.get("last_balance_date") != today_iso and daily_times: day_idx = next((i for i, d in enumerate(daily_times) if d and str(d).startswith(today_iso[:10])), None) 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 + effective_precip_mm(precip_day) balance = max(0.0, min(WATER_BALANCE_MAX_MM, balance)) state["water_balance_mm"] = balance state["last_balance_date"] = today_iso if manual_mm > 0: state["manual_irrigation_mm"] = 0.0 # consumata 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_m = heart_moisture(soil_moisture_3_9cm, soil_moisture_9_27cm) report_ctx = { "will_exit_dormant_in_forecast": _will_exit_dormant_in_forecast(hourly, times, now), "irrigation_need_today": _irrigation_need_index(heart_m, et0_avg, vpd_avg), "irrigation_need_next_days": _irrigation_need_next_days(daily, heart_m, 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 (FUNZIONE PURA: non scrive su state) should_send, reason = should_send_auto_report( phase, state, now=now, force_debug=debug_mode, context=report_ctx ) # Contabilità di notifica (last_auto_report_date, ecc.) persistita da commit_auto_report # SOLO dopo invio Telegram confermato. send_record porta i dati necessari al caller. send_record = { "phase": phase, "reason": reason, "need_today": report_ctx.get("irrigation_need_today"), } # Valore di confronto PRIMA dell'aggiornamento, per evidenziare variazioni nel report prev_need = state.get("last_irrigation_need") # Aggiorna stato state["phase"] = phase state["last_check"] = now.isoformat() # Una nuova stagione attiva/risveglio azzera il flag di chiusura autunnale if phase in ("active", "wakeup"): state["shutdown_confirmed"] = False # Aggiungi a storico (mantieni ultimi 7 giorni); dedup per giorno (ri-esecuzioni) # Usa soil_temp_0cm per storico (mappato come 6cm nella logica) today_str = now.date().isoformat() state["soil_temp_history"] = [ (d, t) for d, t in state.get("soil_temp_history", []) if d != today_str and (now.date() - datetime.date.fromisoformat(d)).days <= 7 ] if soil_temp_6cm is not None: # Questa è già mappata da soil_temp_0cm state["soil_temp_history"].append((today_str, soil_temp_6cm)) # Aggiorna streak umidità alta if (soil_moisture_9_27cm is not None and soil_moisture_9_27cm >= SOIL_MOISTURE_AUTUMN_HIGH): state["high_moisture_streak"] = state.get("high_moisture_streak", 0) + 1 else: state["high_moisture_streak"] = 0 # Genera consiglio in base alla fase (restituisce Dict con JSON structure) advice_dict = None if phase == "wakeup": advice_dict = generate_wakeup_advice( soil_temp_6cm, soil_moisture_3_9cm, soil_moisture_9_27cm, future_rain_total, rainy_days, shortwave_avg=shortwave_avg, sunshine_hours=sunshine_hours, state=state ) elif phase == "active": advice_dict = generate_active_advice( soil_moisture_0_1cm=soil_moisture_3_9cm, # Mappa 0-1cm → 3-9cm per retrocompatibilità soil_moisture_3_9cm=soil_moisture_3_9cm, soil_moisture_9_27cm=soil_moisture_9_27cm, soil_moisture_27_81cm=soil_moisture_27_81cm, # None se non disponibile future_rain_mm=future_rain_total, rainy_days=rainy_days, et0_avg=et0_avg, next_2_days_rain=next_2_days_rain, vpd_avg=vpd_avg ) elif phase == "shutdown": advice_dict = generate_shutdown_advice( soil_temp_6cm, soil_moisture_9_27cm, state.get("high_moisture_streak", 0), sunshine_hours=sunshine_hours, shortwave_avg=shortwave_avg, state=state ) else: # dormant advice_dict = generate_dormant_advice() # Estrai status e advice dal dict per retrocompatibilità con report text status = advice_dict.get("status_display", "**Fase: Sconosciuta**") advice = advice_dict.get("human_message", "Analisi in corso...") # Il dict contiene anche: season_phase, advice_level, soil_status_summary (per JSON output) # Calcola trend per temperatura e umidità (ultimi 7 giorni dallo storico) temp_trend = None moisture_trend_3_9 = None moisture_trend_9_27 = None temp_history = state.get("soil_temp_history", []) if len(temp_history) >= 2 and soil_temp_6cm is not None: try: # Confronta con valore di 7 giorni fa (se disponibile) week_ago_date = (now.date() - datetime.timedelta(days=7)).isoformat() old_temp = None for date_str, temp_val in temp_history: if date_str == week_ago_date: old_temp = temp_val break if old_temp is not None: diff = soil_temp_6cm - old_temp if abs(diff) > 0.1: temp_trend = f"{diff:+.1f}°C" if diff > 0 else f"{diff:.1f}°C" except Exception: pass # Trend umidità 9-27cm dallo storico giornaliero (primo vs ultimo) moist_trend_9_27 = None _dh = state.get("daily_history", []) or [] _m_series = [h.get("moist_9_27") for h in _dh if h.get("moist_9_27") is not None] if len(_m_series) >= 2: _md = (_m_series[-1] - _m_series[0]) * 100.0 if abs(_md) >= 1.0: moist_trend_9_27 = f"{_md:+.0f}%/{len(_m_series)}gg" # Variazione indice di necessità rispetto all'ultimo invio (evidenziata se significativa) need_today = report_ctx.get("irrigation_need_today") need_next = report_ctx.get("irrigation_need_next_days") or [] need_delta_note = None if prev_need is not None and need_today is not None and abs(need_today - prev_need) >= IRRIGATION_NEED_DELTA_SIGNIFICANT: need_delta_note = f"{prev_need:.0f}→{need_today:.0f}" # 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.") # 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.") advice_level = advice_dict.get("advice_level") # Il suggerimento minuti appare SOLO se il consiglio invita a irrigare e non ci sono veti: # così sparisce la vecchia contraddizione "STOP ... ma irriga 120 min". show_suggest = ( suggested_minutes is not None and phase == "active" and advice_level in ("CRITICAL", "STANDARD") and not freeze_veto and not rain_veto ) # ---- Colpo d'occhio (digest informativo) ---- glance = [ status.strip(), f"📍 {location_name} · {now.strftime('%d/%m/%Y %H:%M')}", "", ] if veto_lines: glance.append("**Veti**") glance.extend(veto_lines) glance.append("") # Necessità irrigazione + andamento prossimi giorni (solo fase attiva) if phase == "active" and need_today is not None: need_line = f"🚰 Necessità: {need_today:.0f}/100 ({_need_label(need_today)})" if need_delta_note: need_line += f" · Δ {need_delta_note}" spark = _sparkline(need_next) if spark.strip(): need_line += f" · prossimi gg {spark}" glance.append(need_line) # Suolo: cuore radicale, riserva, temperatura (con trend) soil_bits = [] if soil_moisture_9_27cm is not None: s = f"radici 9-27cm {soil_moisture_9_27cm*100:.0f}% ({classify_soil_moisture(soil_moisture_9_27cm)}, trigger {SOIL_MOISTURE_DEEP_STRESS*100:.0f}%)" if moist_trend_9_27: s += f" {moist_trend_9_27}" soil_bits.append(s) if soil_moisture_27_81cm is not None: soil_bits.append(f"riserva 27-81cm {soil_moisture_27_81cm*100:.0f}%") if soil_temp_6cm is not None: t = f"T suolo {soil_temp_6cm:.1f}°C ({classify_soil_temp(soil_temp_6cm)})" if temp_trend: t += f" {temp_trend}/7gg" soil_bits.append(t) if soil_bits: glance.append("🌱 " + " · ".join(soil_bits)) # Meteo: ET₀, VPD, sole, pioggia 5 giorni meteo_bits = [] if et0_avg is not None: meteo_bits.append(f"ET₀ {et0_avg:.1f} mm/d ({classify_et0(et0_avg)})") if vpd_avg is not None: meteo_bits.append(f"VPD {vpd_avg:.2f} kPa ({classify_vpd(vpd_avg)})") if sunshine_hours is not None: meteo_bits.append(f"sole {sunshine_hours:.1f}h") meteo_bits.append(f"pioggia 5gg {future_rain_total:.1f}mm") glance.append("🌤️ " + " · ".join(meteo_bits)) # Bilancio idrico + eventuale suggerimento minuti (gated) if phase == "active": bal_line = f"🪣 Bilancio: {balance:.0f}/{WATER_BALANCE_MAX_MM:.0f} mm" if show_suggest: bal_line += f" → suggerito ~{suggested_minutes} min (ciclo lungo e lento)" glance.append(bal_line) glance.append("") # ---- Report completo (strutturato) ---- report_parts = [ "\n".join(glance), "─" * 24, "**Consiglio**", advice, "", ] if timing_advice: report_parts.append("**Orario** " + " · ".join(timing_advice)) if planning_8d_line: report_parts.append(planning_8d_line.strip()) report_parts.append(f"ℹ️ Motivo invio: `{reason}`") # 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), chart_bytes, send_record # ============================================================================= # TELEGRAM INTEGRATION (Optional) # ============================================================================= 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: photo not sent.") return False if chat_ids is None: chat_ids = TELEGRAM_CHAT_IDS 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: try: 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 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 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 # ============================================================================= # MAIN # ============================================================================= def main(): parser = argparse.ArgumentParser( description="Smart Irrigation Advisor - Consulente Agronomico" ) parser.add_argument("--lat", type=float, help="Latitudine (default: Casa)") parser.add_argument("--lon", type=float, help="Longitudine (default: Casa)") parser.add_argument("--location", help="Nome località (default: Casa)") parser.add_argument("--timezone", help="Timezone IANA (default: Europe/Berlin)") parser.add_argument("--telegram", action="store_true", help="Invia report via Telegram (solo se auto-reporting attivo o --force)") parser.add_argument("--force", action="store_true", help="Forza invio anche se auto-reporting disabilitato") parser.add_argument("--chat_id", help="Chat ID Telegram specifico (opzionale)") parser.add_argument("--debug", action="store_true", help="Modalità debug (invia sempre e bypassa controlli)") parser.add_argument("--auto", action="store_true", help="Modalità automatica (usa logica auto-reporting, invia via Telegram se attivo)") args = parser.parse_args() if args.debug: global DEBUG DEBUG = True LOGGER.setLevel(logging.DEBUG) lat = args.lat if args.lat is not None else DEFAULT_LAT lon = args.lon if args.lon is not None else DEFAULT_LON location = args.location if args.location else DEFAULT_LOCATION_NAME timezone = args.timezone if args.timezone else TZ run_mode = "auto" if args.auto else "manual" LOGGER.info("Heartbeat: start mode=%s location=%s", run_mode, location) if args.auto: now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") print(f"{now_str} INFO Heartbeat auto run for {location}") # Determina modalità operativa force_send = args.force or args.debug # Genera report (e eventuale grafico) report, should_send_auto, chart_bytes, send_record = analyze_irrigation( lat, lon, location, timezone, debug_mode=args.debug, force_send=force_send ) # Output send_to_telegram = False if args.auto: # Modalità automatica (cron): usa logica auto-reporting if should_send_auto: send_to_telegram = True LOGGER.info("Auto-reporting attivo: invio via Telegram") else: LOGGER.info("Auto-reporting disabilitato: report non inviato (fase: %s)", load_state().get("phase", "unknown")) # In modalità auto, se non inviamo, non stampiamo neanche if not args.debug: return elif args.telegram: # Modalità manuale (chiamata da Telegram): SEMPRE invia se --telegram è presente # La logica auto-reporting si applica solo a cron (--auto) send_to_telegram = True if force_send: LOGGER.info("Chiamata manuale da Telegram con --force: invio forzato") elif should_send_auto: LOGGER.info("Chiamata manuale da Telegram: invio (auto-reporting attivo)") else: LOGGER.info("Chiamata manuale da Telegram: invio (bypass auto-reporting)") if send_to_telegram: 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 success: # Persisti la contabilità di notifica SOLO dopo invio riuscito commit_auto_report(send_record) else: 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) if __name__ == "__main__": main()