#!/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 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°): ICON Italia - risoluzione spaziale migliore per Italia/San Marino 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" # 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__)) 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 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) # Sunshine duration - ore/giorno SUNSHINE_LOW = 4.0 # < 4h = basso SUNSHINE_MEDIUM_LOW = 6.0 # 4-6h = medio/basso SUNSHINE_MEDIUM_HIGH = 8.0 # 6-8h = medio/alto # > 8h = alto # Precipitazioni - mm/giorno PRECIP_DAILY_LOW = 2.0 # < 2mm/giorno = basso PRECIP_DAILY_MEDIUM_LOW = 5.0 # 2-5mm/giorno = medio/basso PRECIP_DAILY_MEDIUM_HIGH = 15.0 # 5-15mm/giorno = medio/alto # > 15mm/giorno = alto # ============================================================================= # 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 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) "soil_moisture_history": [], # Lista di (date, moisture_3_9cm, moisture_9_27cm) "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: 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 _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 # ============================================================================= 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 if recent_warm_days >= SOIL_TEMP_WAKEUP_DAYS_MIN - 1 or soil_temp_6cm >= SOIL_TEMP_WAKEUP_THRESHOLD: 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" def classify_sunshine(hours: float) -> str: """Classifica ore di sole in basso, medio/basso, medio, medio/alto, alto""" if hours < SUNSHINE_LOW: return "basso" elif hours < SUNSHINE_MEDIUM_LOW: return "medio/basso" elif hours < SUNSHINE_MEDIUM_HIGH: return "medio" elif hours < 10.0: return "medio/alto" else: return "alto" def classify_precip_daily(precip: float) -> str: """Classifica precipitazione giornaliera in basso, medio/basso, medio, medio/alto, alto""" if precip < PRECIP_DAILY_LOW: return "basso" elif precip < PRECIP_DAILY_MEDIUM_LOW: return "medio/basso" elif precip < PRECIP_DAILY_MEDIUM_HIGH: return "medio" elif precip < 30.0: return "medio/alto" else: return "alto" # ============================================================================= # IRRIGATION LOGIC # ============================================================================= def calculate_water_stress_index( moisture_3_9cm: Optional[float], moisture_9_27cm: Optional[float], vpd_avg: Optional[float] = None ) -> Tuple[float, str]: """ Calcola Indice di Stress Idrico (0-100%) usando umidità suolo e VPD. VPD (Vapour Pressure Deficit) è un ottimo indicatore di stress idrico: - VPD alto (>1.5 kPa) = stress idrico elevato - VPD medio (0.8-1.5 kPa) = stress moderato - VPD basso (<0.8 kPa) = condizioni ottimali Returns: (index, level_description) """ if moisture_3_9cm is None and moisture_9_27cm is None: # Se non abbiamo dati umidità, usa solo VPD se disponibile if vpd_avg is not None: if vpd_avg > 1.5: return 85.0, "ROSSO_VPD" elif vpd_avg > 1.0: return 60.0, "ARANCIONE_VPD" elif vpd_avg > 0.8: return 30.0, "GIALLO_VPD" else: return 10.0, "VERDE_VPD" return 50.0, "UNKNOWN" # Dati non disponibili # Usa media pesata (superficie più importante) if moisture_3_9cm is not None and moisture_9_27cm is not None: effective_moisture = 0.6 * moisture_3_9cm + 0.4 * moisture_9_27cm elif moisture_3_9cm is not None: effective_moisture = moisture_3_9cm else: effective_moisture = moisture_9_27cm # Calcola indice base rispetto a capacità di campo if effective_moisture >= SOIL_MOISTURE_FIELD_CAPACITY: index_base = 0.0 level = "VERDE" elif effective_moisture <= SOIL_MOISTURE_WILTING_POINT: index_base = 100.0 level = "ROSSO" else: # Interpolazione lineare range_moisture = SOIL_MOISTURE_FIELD_CAPACITY - SOIL_MOISTURE_WILTING_POINT deficit = SOIL_MOISTURE_FIELD_CAPACITY - effective_moisture index_base = (deficit / range_moisture) * 100.0 if index_base >= 70: level = "ARANCIONE" elif index_base >= 40: level = "GIALLO" else: level = "VERDE" # Aggiusta indice usando VPD se disponibile # VPD alto aumenta lo stress percepito, VPD basso lo riduce final_index = index_base if vpd_avg is not None: vpd_factor = 1.0 if vpd_avg > 1.5: vpd_factor = 1.3 # Aumenta stress del 30% se VPD molto alto elif vpd_avg > 1.0: vpd_factor = 1.15 # Aumenta stress del 15% elif vpd_avg < 0.8: vpd_factor = 0.9 # Riduce stress del 10% se VPD basso final_index = min(100.0, index_base * vpd_factor) # Aggiorna livello se VPD modifica significativamente l'indice if vpd_avg > 1.5 and level != "ROSSO": if final_index >= 70: level = "ARANCIONE_VPD" if final_index >= 85: level = "ROSSO_VPD" elif vpd_avg < 0.8 and index_base > 40: if final_index < 40: level = "GIALLO_VPD" return final_index, level 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 _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 # ============================================================================= 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) heart_moisture = None if soil_moisture_3_9cm is not None and soil_moisture_9_27cm is not None: # Media ponderata: 9-27cm più importante (60%) 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 # Monitora la "Riserva" profonda (27-81cm) - sotto capacità di campo = allarme reserve_depleting = False if soil_moisture_27_81cm is not None: 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 estimated_deficit = daily_water_demand * 2.0 # Fabbisogno stimato 2 giorni (approssimativo) # Confronta con precipitazioni previste rain_covers_demand = next_2_days_rain > estimated_deficit # LOGIC DECISIONALE - 4 livelli # 🔴 CRITICO (Deep Stress) is_critical = False if heart_moisture is not None: # Umidità 9-27cm vicina al punto di avvizzimento O Riserva in calo if (soil_moisture_9_27cm is not None and soil_moisture_9_27cm <= SOIL_MOISTURE_DEEP_STRESS): is_critical = True elif reserve_depleting: is_critical = True if is_critical and daily_water_demand > 3.0 and future_rain_mm < PRECIP_THRESHOLD_SIGNIFICANT: 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) 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**. 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) - 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à 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: advice_msg += f"Pioggia prevista domani/dopodomani ({next_2_days_rain:.1f}mm) dovrebbe coprire il fabbisogno.\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_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**: 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 (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 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: advice_msg += f"Giorni: {', '.join(rainy_days)}. " advice_msg += "\n\n**Stop**: Non irrigare, ci pensa la natura." # 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}%") 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, context: Optional[Dict] = None ) -> Tuple[bool, str]: """ 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. """ if force_debug: return True, "DEBUG MODE" 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": 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" if phase == "wakeup": 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["shutdown_confirmed"] = False 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): return False, "SHUTDOWN_GIÀ_NOTIFICATO" state["shutdown_confirmed"] = True state["last_auto_report_date"] = now.date().isoformat() return True, "SHUTDOWN_CESSATA_NECESSITÀ" return False, "UNKNOWN_PHASE" 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]: """ Analisi principale e generazione report. Returns: (report, should_send_auto, chart_bytes o None) """ 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) 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) 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_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 [] vpd_list = hourly.get("vapour_pressure_deficit", []) or [] # Stress idrico 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) 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) 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 # Umidità relativa aria media (24h) humidity_avg = None humidity_values = [] for i in range(current_idx, min(current_idx + 24, len(humidity_list))): if i < len(humidity_list) and humidity_list[i] is not None: try: humidity_values.append(float(humidity_list[i])) except Exception: continue if humidity_values: humidity_avg = sum(humidity_values) / len(humidity_values) # 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₀ medio (calcola su prossime 24h) et0_avg = None 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 et0_values: et0_avg = sum(et0_values) / len(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): 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, context=report_ctx ) # Aggiorna stato state["phase"] = phase state["last_check"] = now.isoformat() # Aggiungi a storico (mantieni ultimi 7 giorni) # 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 (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 # 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.") # 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 glance = [ status.strip(), f"📍 {location_name} · {now.strftime('%d/%m/%Y %H:%M')}", "", 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), "─"*24, "**Consiglio**", advice, "", ] if timing_advice: report_parts.append("**Orario** " + " · ".join(timing_advice)) report_parts.append("") if planning_8d_line: report_parts.append(planning_8d_line.strip()) report_parts.append("") report_parts.append("─"*24) # Dettagli tecnici (compatti, at a glance) details = [] soil_temp_0cm = _at(current_idx, soil_temp_0cm_list) soil_temp_54cm = _at(current_idx, soil_temp_54cm_list) temp_parts = [] 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_parts.append(f"{label} {val:.1f}°C") if temp_parts: trend_str = f" · trend 7gg: {temp_trend}" if temp_trend else "" details.append("🌡️ T° suolo: " + " · ".join(temp_parts) + trend_str) elif soil_temp_0cm_list and current_idx < len(soil_temp_0cm_list) and soil_temp_0cm_list[current_idx] is not None: details.append(f"🌡️ T° suolo 0cm: {float(soil_temp_0cm_list[current_idx]):.1f}°C") moist_parts = [] any_at_fc = False for label, val in [("0-1", soil_moisture_0_1cm), ("3-9", soil_moisture_3_9cm), ("9-27", soil_moisture_9_27cm), ("27-81", soil_moisture_27_81cm)]: if val is not None: moist_parts.append(f"{label} {val*100:.0f}%") if val >= SOIL_MOISTURE_FIELD_CAPACITY: any_at_fc = True else: moist_parts.append(f"{label} —") if moist_parts: line = "💧 Umidità: " + " · ".join(moist_parts) if any_at_fc: line += " — terreno pieno" details.append(line) if not details: details.append("ℹ️ Dati suolo non disponibili") # Una riga: ET₀, VPD, sole, umidità aria meteo_parts = [] if et0_avg is not None: meteo_parts.append(f"ET₀ {et0_avg:.1f} mm/d") if vpd_avg is not None: meteo_parts.append(f"VPD {vpd_avg:.2f} kPa") if sunshine_hours is not None: meteo_parts.append(f"Sole {sunshine_hours:.1f}h") if humidity_avg is not None: meteo_parts.append(f"UR {humidity_avg:.0f}%") if meteo_parts: details.append("☀️ " + " · ".join(meteo_parts)) # Precipitazioni: una riga if future_rain_total > 0: days_short = ", ".join(rainy_days[:3]) if rainy_days else "" details.append(f"🌧️ Precip 5gg: {future_rain_total:.1f} mm — {days_short}") else: details.append("🌧️ Precip 5gg: 0 mm") if details: report_parts.append("**Dettagli**") 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, chart_bytes # ============================================================================= # 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 use_auto_logic = args.auto or (not args.telegram and not args.force) # 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 ) # 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 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) if __name__ == "__main__": main()