#!/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. """ 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 # ============================================================================= # 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 OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" MODEL_AROME = "meteofrance_seamless" MODEL_ICON = "italia_meteo_arpae_icon_2i" HTTP_HEADERS = {"User-Agent": "Smart-Irrigation-Advisor/1.0"} # 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) SOIL_MOISTURE_FIELD_CAPACITY = 0.6 # Capacità di campo (60% - valore tipico per terreno medio) SOIL_MOISTURE_WILTING_POINT = 0.3 # Punto di avvizzimento (30%) SOIL_MOISTURE_AUTUMN_HIGH = 0.8 # 80% - Umidità alta in autunno SOIL_MOISTURE_DEEP_STRESS = 0.35 # 35% - Umidità profonda critica (vicina a punto di avvizzimento) PRECIP_THRESHOLD_SIGNIFICANT = 5.0 # mm - Pioggia significativa AUTUMN_HIGH_MOISTURE_DAYS = 10 # Giorni consecutivi con umidità alta per chiusura # ============================================================================= # 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 - frazione (0-1) SOIL_MOISTURE_LOW = 0.3 # < 0.3 (30%) = basso (punto di avvizzimento) SOIL_MOISTURE_MEDIUM_LOW = 0.5 # 0.3-0.5 (30-50%) = medio/basso SOIL_MOISTURE_MEDIUM_HIGH = 0.7 # 0.5-0.7 (50-70%) = medio/alto # > 0.7 (70%) = alto (vicino a capacità di campo) # 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, # Giorni consecutivi con umidità alta (per fase shutdown) "auto_reporting_enabled": False, # Se True, i report automatici sono attivi "wakeup_threshold_reached": False, # Se True, abbiamo superato la soglia di risveglio "shutdown_confirmed": False, # Se True, la chiusura è stata confermata } 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 # ============================================================================= def fetch_soil_and_weather(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]: """ Recupera dati suolo e meteo da Open-Meteo. Nota: I parametri del suolo potrebbero non essere disponibili per tutte le località. In caso di errore, restituisce None. """ params = { "latitude": lat, "longitude": lon, "timezone": timezone, "forecast_days": 5, # 5 giorni per previsioni pioggia "hourly": ",".join([ # Parametri suolo ICON Italia "soil_temperature_0cm", "soil_temperature_54cm", "soil_moisture_0_to_1cm", "soil_moisture_81_to_243cm", # Meteo base "precipitation", "snowfall", "temperature_2m", # Evapotraspirazione e stress idrico "et0_fao_evapotranspiration", "vapour_pressure_deficit", # Parametri irraggiamento solare "direct_radiation", "diffuse_radiation", "shortwave_radiation", # GHI - Global Horizontal Irradiance (energia totale per fotosintesi) "sunshine_duration", ]), "daily": ",".join([ "precipitation_sum", "snowfall_sum", "et0_fao_evapotranspiration_sum", "sunshine_duration", ]), "models": MODEL_ICON, # Usa ICON Italia per migliore copertura Europa } try: r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=30) if r.status_code == 400: try: j = r.json() reason = j.get("reason", str(j)) LOGGER.warning("Open-Meteo 400: %s. Parametri suolo potrebbero non essere disponibili per questa località.", reason) # Prova senza parametri suolo (fallback) return fetch_weather_only(lat, lon, timezone) except Exception: LOGGER.error("Open-Meteo 400: %s", r.text[:500]) return fetch_weather_only(lat, lon, timezone) r.raise_for_status() data = r.json() # Verifica che i dati del suolo siano presenti (almeno alcuni valori non-None) hourly = data.get("hourly", {}) or {} # ICON Italia usa soil_temperature_0cm e soil_temperature_54cm soil_temp_0 = hourly.get("soil_temperature_0cm", []) or [] soil_temp_54 = hourly.get("soil_temperature_54cm", []) or [] # Controlla se ci sono almeno alcuni valori non-None has_soil_data = any(v is not None for v in soil_temp_0[:24]) or any(v is not None for v in soil_temp_54[:24]) if not has_soil_data: LOGGER.warning("Dati suolo non disponibili (tutti None). Uso fallback meteo-only.") return fetch_weather_only(lat, lon, timezone) return data except Exception as e: LOGGER.exception("Open-Meteo request error: %s", e) return fetch_weather_only(lat, lon, timezone) def fetch_weather_only(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]: """Fallback: recupera solo dati meteo (senza parametri suolo).""" params = { "latitude": lat, "longitude": lon, "timezone": timezone, "forecast_days": 5, "hourly": ",".join([ "precipitation", "snowfall", "et0_fao_evapotranspiration", "temperature_2m", ]), "daily": ",".join([ "precipitation_sum", "snowfall_sum", "et0_fao_evapotranspiration_sum", ]), "models": MODEL_ICON, } try: r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=30) r.raise_for_status() return r.json() except Exception as e: LOGGER.exception("Open-Meteo weather-only request error: %s", e) return None # ============================================================================= # 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 in basso, medio/basso, medio, medio/alto, alto""" if moisture < SOIL_MOISTURE_LOW: return "basso" elif moisture < SOIL_MOISTURE_MEDIUM_LOW: return "medio/basso" elif moisture < SOIL_MOISTURE_MEDIUM_HIGH: return "medio" elif moisture < 0.85: return "medio/alto" else: 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 # ============================================================================= # 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) # Controlla umidità profonda (9-27cm = radici attive) sotto capacità di campo moisture_deep_low = False if soil_moisture_9_27cm is not None: moisture_deep_low = soil_moisture_9_27cm < SOIL_MOISTURE_WILTING_POINT # < 0.30 m³/m³ # 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) si sta asciugando ({soil_moisture_9_27cm*100:.0f}% < capacità di campo). " 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." 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?" Analisi stratificata: ignora 0-1cm, monitora "Cuore" (3-9cm e 9-27cm) e "Riserva" (27-81cm) Returns: Dict con season_phase, advice_level, human_message, soil_status_summary """ 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) - se questa cala, è allarme rosso reserve_depleting = False if soil_moisture_27_81cm is not None: # Se la riserva scende sotto 40%, è critico reserve_depleting = soil_moisture_27_81cm < 0.40 # 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) critica: {soil_moisture_9_27cm*100:.0f}% (vicina al punto di avvizzimento). " 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**." # 🟠 STANDARD (Maintenance) elif (heart_moisture is not None and heart_moisture < SOIL_MOISTURE_FIELD_CAPACITY * 0.8 and (soil_moisture_9_27cm is None or soil_moisture_9_27cm > SOIL_MOISTURE_DEEP_STRESS)): advice_level = "STANDARD" advice_msg = "🟠 **LIVELLO STANDARD (Maintenance)**\n\n" advice_msg += "Umidità superficiale (3-9cm) bassa, ma profonda (9-27cm) ok. " 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 standard consigliato stasera o domattina." # 🟡 LIGHT (Surface Dry) 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 future_rain_mm >= PRECIP_THRESHOLD_SIGNIFICANT: advice_msg += f"Pioggia prevista: {future_rain_mm:.1f}mm.\n\n" advice_msg += "**Opzionale**: Breve rinfrescata o attendi precipitazioni." else: advice_msg += "\n\n**Opzionale**: Breve rinfrescata superficiale o attendi domani." # 🟢 STOP (Saturated/Rain) else: advice_level = "NO_ACTION" advice_msg = "🟢 **LIVELLO STOP (Saturated/Rain)**\n\n" if heart_moisture is not None and heart_moisture >= SOIL_MOISTURE_FIELD_CAPACITY * 0.9: advice_msg += "Terreno saturo o molto umido. " 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 = "❄️ **CHIUDI TUTTO**\n\n" if temp_below: advice_msg += f"Trigger termico attivo: temperatura suolo {soil_temp_6cm:.1f}°C < {SOIL_TEMP_SHUTDOWN_THRESHOLD}°C stabilmente. " if light_declining: advice_msg += f"Segnale luce: fotoperiodo in calo drastico ({sunshine_hours:.1f}h di sole). " if saturation_ok: advice_msg += f"Umidità alta costante ({soil_moisture_9_27cm*100:.0f}%) per {high_moisture_streak} giorni. " advice_msg += "\n\nLe piante sono entrate in riposo vegetativo. " advice_msg += "**Consiglio**: Puoi svuotare l'impianto di irrigazione per l'inverno. " advice_msg += "Il terreno non richiede più irrigazione artificiale." 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}% (alta se ≥{SOIL_MOISTURE_AUTUMN_HIGH*100}% 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 } # ============================================================================= # MAIN ANALYSIS # ============================================================================= def should_send_auto_report( phase: str, soil_temp_6cm: Optional[float], state: Dict, force_debug: bool = False ) -> Tuple[bool, str]: """ Determina se inviare un report automatico basato su indicatori di fase. Returns: (should_send, reason) """ # In modalità debug, invia sempre if force_debug: return True, "DEBUG MODE" # Se siamo in fase dormiente e non ci sono indicatori di risveglio, silente if phase == "dormant": # Controlla se ci sono indicatori di avvicinamento al risveglio if soil_temp_6cm is not None and soil_temp_6cm >= SOIL_TEMP_WAKEUP_INDICATOR: # Siamo in inverno ma il terreno si sta scaldando -> si avvicina il momento if not state.get("wakeup_threshold_reached", False): # Prima volta che superiamo l'indicatore -> sblocca report e notifica state["wakeup_threshold_reached"] = True state["auto_reporting_enabled"] = True # Abilita per monitorare il risveglio return True, "TERRENO_IN_RISVEGLIO" # Già notificato, ma continuiamo a monitorare se auto-reporting è attivo if state.get("auto_reporting_enabled", False): return True, "MONITORAGGIO_RISVEGLIO" # Anche se è dormiente, se abbiamo già raggiunto la soglia di risveglio, continua if state.get("wakeup_threshold_reached", False) and state.get("auto_reporting_enabled", False): return True, "POST_RISVEGLIO" # Silente return False, "DORMANT_SILENT" # Fase wakeup: sempre invia (stiamo monitorando l'attivazione) if phase == "wakeup": if not state.get("auto_reporting_enabled", False): # Prima volta che entriamo in wakeup -> abilita auto-reporting state["auto_reporting_enabled"] = True state["wakeup_threshold_reached"] = True return True, "WAKEUP_ENABLED" return True, "WAKEUP_MONITORING" # Fase active: sempre invia (stagione attiva) if phase == "active": state["auto_reporting_enabled"] = True state["wakeup_threshold_reached"] = True state["shutdown_confirmed"] = False return True, "ACTIVE_SEASON" # Fase shutdown: invia finché non confermiamo la chiusura if phase == "shutdown": if state.get("shutdown_confirmed", False): # Chiusura già confermata -> disabilita auto-reporting state["auto_reporting_enabled"] = False return False, "SHUTDOWN_CONFIRMED" # Prima chiusura -> invia notifica e poi disabilita state["shutdown_confirmed"] = True state["auto_reporting_enabled"] = False return True, "SHUTDOWN_NOTIFICATION" 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) """ """ Analisi principale e generazione report. """ 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: return ("❌ **ERRORE**\n\nImpossibile recuperare dati meteorologici. Riprova più tardi.", False) hourly = data.get("hourly", {}) or {} daily = data.get("daily", {}) or {} # Estrai dati attuali (primi valori) times = hourly.get("time", []) or [] if not times: return ("❌ **ERRORE**\n\nNessun dato temporale disponibile.", False) 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 Italia (potrebbero essere None) soil_temp_0cm_list = hourly.get("soil_temperature_0cm", []) 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_81_243_list = hourly.get("soil_moisture_81_to_243cm", []) 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 # Valori attuali (mappatura: 0cm ≈ 6cm per logica, 54cm ≈ 18cm) soil_temp_6cm = None # Usa soil_temp_0cm if current_idx < len(soil_temp_0cm_list) and soil_temp_0cm_list[current_idx] is not None: soil_temp_6cm = float(soil_temp_0cm_list[current_idx]) soil_temp_18cm = None # Usa soil_temp_54cm if current_idx < len(soil_temp_54cm_list) and soil_temp_54cm_list[current_idx] is not None: soil_temp_18cm = float(soil_temp_54cm_list[current_idx]) # Umidità superficiale (0-1cm da ICON, mappata come 3-9cm nella logica) soil_moisture_0_1cm = None if current_idx < len(soil_moisture_0_1_list) and soil_moisture_0_1_list[current_idx] is not None: soil_moisture_0_1cm = float(soil_moisture_0_1_list[current_idx]) # Per retrocompatibilità, usa anche come 3-9cm soil_moisture_3_9cm = soil_moisture_0_1cm soil_moisture_9_27cm = None # Usa soil_moisture_81_to_243cm (profondo) if current_idx < len(soil_moisture_81_243_list) and soil_moisture_81_243_list[current_idx] is not None: soil_moisture_9_27cm = float(soil_moisture_81_243_list[current_idx]) # Riserva profonda 27-81cm (non disponibile in ICON, potrebbe tornare in estate) # ICON fornisce solo 81-243cm, quindi lasciamo None soil_moisture_27_81cm = None # 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) # Determina fase stagionale month = now.month phase = determine_seasonal_phase( month, soil_temp_6cm, soil_moisture_3_9cm, soil_moisture_9_27cm, state ) # Determina se inviare report automatico should_send, reason = should_send_auto_report(phase, soil_temp_6cm, state, force_debug=debug_mode) # 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 # Costruisci report completo (senza righe vuote eccessive) report_parts = [ f"{status}\n", f"📍 {location_name}\n", f"📅 {now.strftime('%d/%m/%Y %H:%M')}\n", "="*25 + "\n", advice ] # Aggiungi dettagli tecnici (se disponibili) details = [] # Temperatura suolo con trend temp_found = False if soil_temp_6cm is not None: temp_class = classify_soil_temp(soil_temp_6cm) temp_str = f"🌡️ T° suolo (0cm): {soil_temp_6cm:.1f}°C ({temp_class})" if temp_trend: temp_str += f" | trend 7gg: {temp_trend}" details.append(temp_str) temp_found = True else: # Prova a vedere se c'è un valore futuro nella lista (ICON: 0cm) for i in range(current_idx, min(current_idx + 48, len(soil_temp_0cm_list))): if i < len(soil_temp_0cm_list) and soil_temp_0cm_list[i] is not None: temp_val = float(soil_temp_0cm_list[i]) details.append(f"🌡️ T° suolo (0cm): {temp_val:.1f}°C (prossime ore)") temp_found = True break if soil_temp_18cm is not None: temp_class_54 = classify_soil_temp(soil_temp_18cm) temp_str = f"🌡️ T° suolo (54cm): {soil_temp_18cm:.1f}°C ({temp_class_54})" details.append(temp_str) temp_found = True else: # Prova valore futuro (ICON: 54cm) for i in range(current_idx, min(current_idx + 48, len(soil_temp_54cm_list))): if i < len(soil_temp_54cm_list) and soil_temp_54cm_list[i] is not None: temp_val = float(soil_temp_54cm_list[i]) details.append(f"🌡️ T° suolo (54cm): {temp_val:.1f}°C (prossime ore)") temp_found = True break # Umidità suolo (ICON: 0-1cm e 81-243cm) moisture_found = False if soil_moisture_3_9cm is not None: moisture_class = classify_soil_moisture(soil_moisture_3_9cm) details.append(f"💧 Umidità (0-1cm): {soil_moisture_3_9cm*100:.0f}% ({moisture_class})") moisture_found = True else: # Prova valore futuro for i in range(current_idx, min(current_idx + 48, len(soil_moisture_0_1_list))): if i < len(soil_moisture_0_1_list) and soil_moisture_0_1_list[i] is not None: moisture_val = float(soil_moisture_0_1_list[i]) details.append(f"💧 Umidità (0-1cm): {moisture_val*100:.0f}% (prossime ore)") moisture_found = True break if soil_moisture_9_27cm is not None: moisture_class_deep = classify_soil_moisture(soil_moisture_9_27cm) details.append(f"💧 Umidità (81-243cm): {soil_moisture_9_27cm*100:.0f}% ({moisture_class_deep})") moisture_found = True else: # Prova valore futuro for i in range(current_idx, min(current_idx + 48, len(soil_moisture_81_243_list))): if i < len(soil_moisture_81_243_list) and soil_moisture_81_243_list[i] is not None: moisture_val = float(soil_moisture_81_243_list[i]) details.append(f"💧 Umidità (81-243cm): {moisture_val*100:.0f}% (prossime ore)") moisture_found = True break # Messaggio informativo se dati suolo non disponibili if not temp_found and not moisture_found: details.append("ℹ️ Dati suolo non disponibili per questa località") # ET₀ e parametri evapotraspirazione if et0_avg is not None: et0_class = classify_et0(et0_avg) details.append(f"☀️ ET₀ medio (24h): {et0_avg:.1f} mm/d ({et0_class})") # Vapour Pressure Deficit (stress idrico) if vpd_avg is not None: vpd_class = classify_vpd(vpd_avg) # VPD alto = stress idrico alto vpd_status = "" if vpd_avg > 1.5: vpd_status = " (stress idrico elevato)" elif vpd_avg > 1.0: vpd_status = " (stress moderato)" details.append(f"💨 VPD medio (24h): {vpd_avg:.2f} kPa ({vpd_class}){vpd_status}") # Ore di sole previste if sunshine_hours is not None: sunshine_class = classify_sunshine(sunshine_hours) details.append(f"☀️ Ore sole previste (24h): {sunshine_hours:.1f}h ({sunshine_class})") # Umidità relativa aria if humidity_avg is not None: # Classifica umidità relativa (bassa < 40%, media 40-70%, alta > 70%) if humidity_avg < 40: humidity_class = "basso (secco)" elif humidity_avg < 70: humidity_class = "medio" else: humidity_class = "alto (umido)" details.append(f"💨 Umidità relativa aria (24h): {humidity_avg:.0f}% ({humidity_class})") # Precipitazioni previste (include neve) if future_rain_total > 0: # Classifica come totale su 5 giorni (media giornaliera approssimativa) avg_daily = future_rain_total / 5.0 precip_class = classify_precip_daily(avg_daily) precip_str = f"🌧️ Precipitazioni previste (5gg): {future_rain_total:.1f}mm ({precip_class}, media ~{avg_daily:.1f}mm/giorno)" if rainy_days: precip_str += f"\n Giorni: {', '.join(rainy_days[:3])}" # Primi 3 giorni if len(rainy_days) > 3: precip_str += f" +{len(rainy_days)-3} altri" details.append(precip_str) elif len(rainy_days) == 0: details.append("🌧️ Precipitazioni previste (5gg): 0mm (basso)") if details: report_parts.append("─"*25 + "\n") report_parts.append("**Dettagli Tecnici:**\n") report_parts.append("\n".join(details)) # Salva stato save_state(state) 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 # ============================================================================= # TELEGRAM INTEGRATION (Optional) # ============================================================================= def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -> bool: """Invia messaggio Telegram in formato Markdown.""" 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 url = f"https://api.telegram.org/bot{token}/sendMessage" base_payload = { "text": message, "parse_mode": "Markdown", "disable_web_page_preview": True, } sent_ok = False import time with requests.Session() as s: for chat_id in chat_ids: payload = dict(base_payload) payload["chat_id"] = chat_id try: resp = s.post(url, json=payload, timeout=15) if resp.status_code == 200: sent_ok = True else: LOGGER.error("Telegram error chat_id=%s status=%s body=%s", chat_id, resp.status_code, resp.text[:500]) time.sleep(0.25) except Exception as e: LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e) 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 # 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 report, should_send_auto = 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()] 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") else: # Stampa sempre su stdout se non in modalità auto e non Telegram print(report) if __name__ == "__main__": main()