From d12058b2d16b13e3c47868de960b07c1ff7895b1 Mon Sep 17 00:00:00 2001 From: daniele Date: Sun, 14 Jun 2026 07:00:02 +0200 Subject: [PATCH] Backup automatico script del 2026-06-14 07:00 --- .../telegram-bot/smart_irrigation_advisor.py | 756 ++++++++++-------- 1 file changed, 422 insertions(+), 334 deletions(-) diff --git a/services/telegram-bot/smart_irrigation_advisor.py b/services/telegram-bot/smart_irrigation_advisor.py index 08e5471..3e80782 100755 --- a/services/telegram-bot/smart_irrigation_advisor.py +++ b/services/telegram-bot/smart_irrigation_advisor.py @@ -116,6 +116,10 @@ else: SOIL_MOISTURE_DEEP_STRESS = 0.35 SOIL_MOISTURE_AUTUMN_HIGH = 0.55 SOIL_INFILTRATION_MMH = 10.0 +# Soglia "saturazione" per chiusura autunnale: SEPARATA da FC (a FC pieno è di fatto +# irraggiungibile per 5 giorni). Usiamo FC*0.9 così il ramo saturazione dello shutdown +# è effettivamente raggiungibile. (Supera i valori per-tipo qui sopra.) +SOIL_MOISTURE_AUTUMN_HIGH = round(SOIL_MOISTURE_FIELD_CAPACITY * 0.9, 3) PRECIP_THRESHOLD_SIGNIFICANT = 5.0 # mm - Pioggia significativa PRECIP_VETO_MM_24H = 4.0 # Veto avvio: se pioggia ultime 24h >= questo valore, non irrigare (dato da sensore/API se disponibile) AUTUMN_HIGH_MOISTURE_DAYS = 5 # Giorni consecutivi con umidità alta per chiusura (path saturazione) @@ -158,18 +162,6 @@ 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 # ============================================================================= @@ -243,6 +235,27 @@ def ensure_parent_dir(path: str) -> None: os.makedirs(parent, exist_ok=True) +def first_non_none(*vals): + """Primo valore non None (a differenza di `a or b`, NON scarta lo 0.0 legittimo, + es. temperatura suolo invernale = 0°C).""" + for v in vals: + if v is not None: + return v + return None + + +def heart_moisture(m3_9: Optional[float], m9_27: Optional[float]) -> Optional[float]: + """Umidità del 'cuore' radicale (VWC): media ponderata 3-9cm (0.3) e 9-27cm (0.7). + Formula UNICA usata da advice, indice di stress e contesto notifiche (prima incoerente).""" + if m3_9 is not None and m9_27 is not None: + return 0.3 * m3_9 + 0.7 * m9_27 + if m9_27 is not None: + return m9_27 + if m3_9 is not None: + return m3_9 + return None + + def read_text_file(path: str) -> str: try: with open(path, "r", encoding="utf-8") as f: @@ -274,19 +287,17 @@ def load_state() -> Dict: "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) + "water_balance_mm": WATER_BALANCE_MAX_MM, # Bilancio idrico stimato (mm), ancorato ai sensori "last_balance_date": None, # Ultima data di aggiornamento bilancio (ISO) "daily_history": [], # Ultimi 7 giorni: [{date, temp_6, moist_9_27, moist_27_81, et0, precip}] "last_24h_precip_mm": None, # Pioggia ultime 24h (da sensore/API esterna; se None veto non applicato) + "manual_irrigation_mm": 0.0, # Irrigazione manuale dichiarata (mm): aggiunta al bilancio e azzerata } if os.path.exists(STATE_FILE): try: @@ -480,37 +491,46 @@ def _merge_hourly_three_models_median(hourly_list: List[Dict]) -> Dict: """ Unisce gli hourly di più risposte: per ogni timestamp (unione) calcola la mediana per ogni variabile numerica. Usa il primo dizionario per la lista dei nomi chiave. + Complessità O(n): per ogni modello si indicizza UNA volta tempo→posizione (prima + era O(n²): per ogni timestamp si ri-scorreva l'intera serie di ogni modello). """ time_idx: Dict[str, int] = {} all_times: List[str] = [] + model_idx: List[Dict[str, int]] = [] # per modello: chiave-tempo normalizzata → indice for h in hourly_list: times = h.get("time", []) or [] - for t in times: + idx: Dict[str, int] = {} + for i, t in enumerate(times): k = _normalize_time_key(str(t)) if t else "" - if k and k not in time_idx: + if not k: + continue + if k not in idx: + idx[k] = i + if k not in time_idx: time_idx[k] = len(all_times) all_times.append(t if isinstance(t, str) else k) - if not all_times: - keys = [k for k in (list(hourly_list[0].keys()) if hourly_list else []) if k != "time"] - return {"time": [], **{k: [] for k in keys}} + model_idx.append(idx) keys = [k for k in (list(hourly_list[0].keys()) if hourly_list else []) if k != "time"] + if not all_times: + return {"time": [], **{k: [] for k in keys}} out: Dict[str, List] = {"time": all_times} for key in keys: - out[key] = [] + col: List = [] for ref_t in all_times: ref_k = _normalize_time_key(str(ref_t)) vals = [] - for h in hourly_list: - times = h.get("time", []) or [] + for h, idx in zip(hourly_list, model_idx): + i = idx.get(ref_k) + if i is None: + continue arr = h.get(key, []) or [] - for i, t in enumerate(times): - if _normalize_time_key(str(t)) == ref_k and i < len(arr) and arr[i] is not None: - try: - vals.append(float(arr[i])) - except (TypeError, ValueError): - pass - break - out[key].append(_median_or_single(vals) if vals else None) + if i < len(arr) and arr[i] is not None: + try: + vals.append(float(arr[i])) + except (TypeError, ValueError): + pass + col.append(_median_or_single(vals) if vals else None) + out[key] = col return out @@ -725,7 +745,10 @@ def determine_seasonal_phase( except Exception: continue - if recent_warm_days >= SOIL_TEMP_WAKEUP_DAYS_MIN - 1 or soil_temp_6cm >= SOIL_TEMP_WAKEUP_THRESHOLD: + # Serve PERSISTENZA del caldo per dichiarare "active": senza storico sufficiente + # restiamo in "wakeup" (la vecchia condizione con 'or soil_temp_6cm>=soglia' + # era tautologica perché già garantita dall'if esterno → saltava sempre wakeup). + if recent_warm_days >= SOIL_TEMP_WAKEUP_DAYS_MIN - 1: return "active" else: return "wakeup" @@ -815,119 +838,39 @@ def classify_vpd(vpd: float) -> str: 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" +_SPARK_BLOCKS = "▁▂▃▄▅▆▇█" -def classify_precip_daily(precip: float) -> str: - """Classifica precipitazione giornaliera in basso, medio/basso, medio, medio/alto, alto""" - if precip < PRECIP_DAILY_LOW: +def _sparkline(values: List[Optional[float]], vmin: float = 0.0, vmax: float = 100.0) -> str: + """Mini-grafico testuale da una serie di valori (es. necessità prossimi giorni).""" + out = [] + span = (vmax - vmin) or 1.0 + for v in values: + if v is None: + out.append(" ") + continue + frac = max(0.0, min(1.0, (float(v) - vmin) / span)) + out.append(_SPARK_BLOCKS[int(round(frac * (len(_SPARK_BLOCKS) - 1)))]) + return "".join(out) + + +def _need_label(n: Optional[float]) -> str: + """Etichetta qualitativa per l'indice di necessità irrigazione (0-100).""" + if n is None: + return "—" + if n < 30: return "basso" - elif precip < PRECIP_DAILY_MEDIUM_LOW: - return "medio/basso" - elif precip < PRECIP_DAILY_MEDIUM_HIGH: + if n < 55: return "medio" - elif precip < 30.0: + if n < 75: return "medio/alto" - else: - return "alto" + 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]]: """ @@ -969,24 +912,58 @@ def check_future_rainfall(daily_data: Dict, days_ahead: int = 5, include_snowfal return total, rainy_days +def vwc_to_balance_mm(m: float) -> float: + """Converte l'umidità volumetrica (VWC) in mm di acqua disponibile del serbatoio + (0 = punto di appassimento, WATER_BALANCE_MAX_MM = capacità di campo).""" + awc = SOIL_MOISTURE_FIELD_CAPACITY - SOIL_MOISTURE_WILTING_POINT + if awc <= 0: + return WATER_BALANCE_MAX_MM + frac = (m - SOIL_MOISTURE_WILTING_POINT) / awc + return max(0.0, min(1.0, frac)) * WATER_BALANCE_MAX_MM + + +def balance_mm_to_vwc(b: float) -> float: + """Inversa di vwc_to_balance_mm: da mm serbatoio a VWC stimata.""" + awc = SOIL_MOISTURE_FIELD_CAPACITY - SOIL_MOISTURE_WILTING_POINT + frac = (max(0.0, min(WATER_BALANCE_MAX_MM, b)) / WATER_BALANCE_MAX_MM) if WATER_BALANCE_MAX_MM > 0 else 0.5 + return SOIL_MOISTURE_WILTING_POINT + frac * awc + + +def effective_precip_mm(precip: float) -> float: + """Pioggia efficace: intercettazione ~10% e penalità su eventi intensi (>10mm) + per runoff/percolazione profonda (più marcata su suoli argillosi).""" + if precip <= 0: + return 0.0 + if precip <= 10.0: + return precip * 0.9 + return (10.0 * 0.9) + (precip - 10.0) * 0.5 + + def _irrigation_need_index( - heart_moisture: Optional[float], - et0_avg: Optional[float], + heart_m: Optional[float], + et0_mm_day: 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. + Indice 0-100 di necessità di irrigazione (alto = serve acqua). + Usato per evidenziare variazioni significative nel report. + - moisture_factor NORMALIZZATO sull'AWC reale del suolo: (FC - m)/(FC - PWP), + 0 a capacità di campo, 1 al punto di appassimento (prima usava 1-m sull'intero + range 0-1, sovrastimando lo stress per i suoli argillosi). + - et0_mm_day è il fabbisogno GIORNALIERO in mm (non più la media oraria). """ - if heart_moisture is None and et0_avg is None: + if heart_m is None and et0_mm_day 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 heart_m is not None: + awc = SOIL_MOISTURE_FIELD_CAPACITY - SOIL_MOISTURE_WILTING_POINT + moisture_factor = (SOIL_MOISTURE_FIELD_CAPACITY - heart_m) / awc if awc > 0 else 0.5 + moisture_factor = max(0.0, min(1.0, moisture_factor)) + else: + moisture_factor = 0.5 + # Fattore ET₀ (mm/giorno, ~0-6): alta domanda = alto bisogno + e = et0_mm_day if et0_mm_day is not None else 3.0 + et0_factor = min(1.0, max(0.0, e / 6.0)) + need = moisture_factor * 50.0 + et0_factor * 50.0 if vpd_avg is not None and vpd_avg > 1.0: need = min(100.0, need * 1.1) return round(need, 1) @@ -994,8 +971,9 @@ def _irrigation_need_index( 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). + True se nei prossimi 10 giorni la T° suolo prevista supera la soglia di risveglio + per almeno 2 GIORNI CONSECUTIVI (prima bastava 1 solo giorno: un picco isolato + di gennaio poteva far scattare la notifica 'primo risveglio' fuori tempo). """ soil_temps = hourly.get("soil_temperature_6cm", []) or hourly.get("soil_temperature_0cm", []) or [] if not times or not soil_temps: @@ -1017,9 +995,16 @@ def _will_exit_dormant_in_forecast(hourly: Dict, times: List[str], now: datetime pass except Exception: continue - for _date_str, vals in day_temps.items(): + # Richiede 2 giorni consecutivi (in ordine cronologico) sopra soglia + consecutive = 0 + for key in sorted(day_temps.keys()): + vals = day_temps[key] if vals and (sum(vals) / len(vals)) >= SOIL_TEMP_WAKEUP_THRESHOLD: - return True + consecutive += 1 + if consecutive >= 2: + return True + else: + consecutive = 0 return False @@ -1037,7 +1022,12 @@ def _irrigation_need_next_days( 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 + moisture = ( + current_heart_moisture if current_heart_moisture is not None + else (SOIL_MOISTURE_FIELD_CAPACITY + SOIL_MOISTURE_WILTING_POINT) / 2.0 + ) + # Mini-bilancio idrico in mm di serbatoio (coerente con il bucket): parte dalla VWC reale + balance = vwc_to_balance_mm(moisture) for i in range(len(daily_times)): if len(need_list) >= days: break @@ -1049,8 +1039,10 @@ def _irrigation_need_next_days( 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)) + # Bilancio: -ET₀·Kc + pioggia efficace, clampato tra appassimento e capacità di campo + balance = balance - et0 * KC_LAWN + effective_precip_mm(precip) + balance = max(0.0, min(WATER_BALANCE_MAX_MM, balance)) + moisture = balance_mm_to_vwc(balance) need_list.append(_irrigation_need_index(moisture, et0, None)) return need_list @@ -1213,91 +1205,102 @@ def generate_active_advice( 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 + # Calcola media ponderata del "Cuore" del sistema (3-9cm e 9-27cm) con formula UNICA + heart_m = heart_moisture(soil_moisture_3_9cm, soil_moisture_9_27cm) + + # Monitora la "Riserva" profonda (27-81cm): è allarme solo se sotto il TRIGGER irrigazione, + # non sotto FC (in estate la riserva sta fisiologicamente sotto FC → prima era sempre "in calo"). reserve_depleting = False if soil_moisture_27_81cm is not None: - reserve_depleting = soil_moisture_27_81cm < SOIL_MOISTURE_FIELD_CAPACITY + reserve_depleting = soil_moisture_27_81cm < SOIL_MOISTURE_DEEP_STRESS - # Calcola fabbisogno idrico basato su ET₀ + # Fabbisogno idrico (ET₀ ora in mm/GIORNO) e copertura da pioggia prevista daily_water_demand = et0_avg if et0_avg is not None else 0.0 - estimated_deficit = daily_water_demand * 2.0 # Fabbisogno stimato 2 giorni (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: + estimated_deficit = daily_water_demand * 2.0 # Fabbisogno stimato ~2 giorni + # La pioggia "copre" se supera il deficit stimato ed è comunque significativa + rain_covers_demand = ( + next_2_days_rain >= estimated_deficit and next_2_days_rain >= PRECIP_THRESHOLD_SIGNIFICANT + ) + significant_rain_soon = future_rain_mm >= PRECIP_THRESHOLD_SIGNIFICANT + + # Stato zona radicale: "sotto trigger" se 9-27cm ≤ trigger oppure riserva sotto trigger + below_trigger = ( + (soil_moisture_9_27cm is not None and soil_moisture_9_27cm <= SOIL_MOISTURE_DEEP_STRESS) + or reserve_depleting + ) + high_demand = daily_water_demand > 4.0 # giornata estiva ad alta evapotraspirazione + + # LOGIC DECISIONALE (priorità: pioggia → sotto trigger → mantenimento → superficie → stop) + if rain_covers_demand: + # 🟢 La pioggia coprirà il fabbisogno: inutile irrigare ora + advice_level = "NO_ACTION" + advice_msg = "🟢 **LIVELLO STOP (Pioggia in arrivo)**\n\n" + advice_msg += f"Pioggia prevista ({future_rain_mm:.1f}mm) ≥ fabbisogno stimato ({estimated_deficit:.1f}mm). " + if rainy_days: + advice_msg += f"Giorni: {', '.join(rainy_days)}. " + if below_trigger: + advice_msg += "Terreno sotto trigger, ma conviene attendere la pioggia prima di irrigare. " + advice_msg += "\n\n**Stop**: Non irrigare, ci pensa la natura." + + elif below_trigger and high_demand: + # 🔴 CRITICO: zona radicale sotto trigger, ET₀ elevato, pioggia insufficiente advice_level = "CRITICAL" advice_msg = "🔴 **LIVELLO CRITICO (Deep Stress)**\n\n" if soil_moisture_9_27cm is not None and soil_moisture_9_27cm <= SOIL_MOISTURE_DEEP_STRESS: advice_msg += f"Umidità profonda (9-27cm) sotto trigger ({soil_moisture_9_27cm*100:.0f}% ≤ {SOIL_MOISTURE_DEEP_STRESS*100:.0f}%). " if reserve_depleting: - advice_msg += f"Riserva profonda (27-81cm) 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_msg += f"Riserva profonda (27-81cm) sotto trigger: {soil_moisture_27_81cm*100:.0f}%. " + advice_msg += f"ET₀ elevato ({daily_water_demand:.1f} mm/d), pioggia insufficiente.\n\n" + advice_msg += "**Emergenza**: Irrigazione profonda **subito**, portando l'umidità verso la capacità di campo (~{:.0f}%) senza superarla (evitare saturazione e percolazione). Con argilla: ciclo lungo e lento, niente brevi rinfrescate.".format(SOIL_MOISTURE_FIELD_CAPACITY * 100) + + elif below_trigger: + # 🟠 Sotto trigger ma urgenza ridotta (ET₀ contenuto / clima fresco): irrigare appena possibile + advice_level = "STANDARD" + advice_msg = "🟠 **LIVELLO STANDARD (Sotto trigger)**\n\n" + if soil_moisture_9_27cm is not None: + advice_msg += f"Umidità profonda (9-27cm) sotto trigger ({soil_moisture_9_27cm*100:.0f}% ≤ {SOIL_MOISTURE_DEEP_STRESS*100:.0f}%), " + advice_msg += f"ma ET₀ contenuto ({daily_water_demand:.1f} mm/d)" + if significant_rain_soon: + advice_msg += f" e un po' di pioggia in arrivo ({future_rain_mm:.1f}mm)" + advice_msg += ".\n\n**Consiglio**: Programma un ciclo lungo e lento appena possibile (urgenza ridotta). Con argilla: bagnare in profondità, meno frequente." + + # 🟠 STANDARD (Maintenance): sopra trigger ma in calo verso di esso + elif (heart_m is not None and heart_m < SOIL_MOISTURE_FIELD_CAPACITY * 0.8): advice_level = "STANDARD" advice_msg = "🟠 **LIVELLO STANDARD (Maintenance)**\n\n" advice_msg += "Umidità in calo verso il trigger, profondo (9-27cm) ancora ok. " if et0_avg is not None: - advice_msg += f"ET₀ 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 += f"ET₀ ({et0_avg:.1f} mm/d). " + if significant_rain_soon: + advice_msg += f"Pioggia prevista ({future_rain_mm:.1f}mm) potrebbe bastare.\n\n" advice_msg += "**Consiglio**: Attendi le precipitazioni, poi valuta." else: advice_msg += "Nessuna pioggia sufficiente prevista a breve.\n\n" advice_msg += "**Routine**: Ciclo lungo e lento consigliato (argilla: bagnare in profondità, meno frequente)." - + # 🟡 LIGHT (Surface Dry) - su argilla 0-3cm si secca e crepa; non indicativo, evitare brevi rinfrescate elif (soil_moisture_0_1cm is not None and soil_moisture_0_1cm < 0.5 and - heart_moisture is not None and heart_moisture >= SOIL_MOISTURE_FIELD_CAPACITY * 0.8): + heart_m is not None and heart_m >= SOIL_MOISTURE_FIELD_CAPACITY * 0.8): advice_level = "LIGHT" advice_msg = "🟡 **LIVELLO LIGHT (Surface Dry)**\n\n" advice_msg += "Solo strati superficiali (0-3cm) secchi, radici profonde (9-27cm) ok. " if SOIL_IS_CLAY: advice_msg += "Su argilla lo strato superficiale si secca e crepa in fretta: non è indicativo. " - if future_rain_mm >= PRECIP_THRESHOLD_SIGNIFICANT: + if significant_rain_soon: advice_msg += f"Pioggia prevista: {future_rain_mm:.1f}mm.\n\n" advice_msg += "**Opzionale**: Attendere precipitazioni." + (" Con argilla evitare brevi rinfrescate (preferire cicli lunghi quando serve)." if SOIL_IS_CLAY else " Breve rinfrescata o attendi precipitazioni.") else: advice_msg += "\n\n**Opzionale**: " + ("Attendi prossimo ciclo completo (argilla: niente brevi rinfrescate)." if SOIL_IS_CLAY else "Breve rinfrescata superficiale o attendi domani.") - - # 🟢 STOP (a capacità di campo o oltre, oppure pioggia copre fabbisogno) + + # 🟢 STOP: zona radicale adeguata (sopra trigger / a capacità di campo) 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 = "🟢 **LIVELLO STOP**\n\n" + if heart_m is not None and heart_m >= SOIL_MOISTURE_FIELD_CAPACITY * 0.9: advice_msg += "Terreno a capacità di campo o oltre (evitare saturazione: perdita nutrienti, asfissia radicale). " - 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." + else: + advice_msg += "Umidità della zona radicale adeguata (sopra trigger). " + advice_msg += "\n\n**Stop**: Non irrigare per ora." # Soil status summary soil_summary_parts = [] @@ -1573,86 +1576,88 @@ def build_irrigation_chart_bytes( # MAIN ANALYSIS # ============================================================================= -IRRIGATION_NEED_DELTA_SIGNIFICANT = 25 # Variazione indice 0-100 per "cambio significativo" -DAYS_WEEKLY_SUMMER = 7 # Cadenza settimanale in fase attiva +IRRIGATION_NEED_DELTA_SIGNIFICANT = 25 # Variazione indice 0-100 evidenziata nel report come "cambio significativo" + def should_send_auto_report( phase: str, - soil_temp_6cm: Optional[float], state: Dict, + now: Optional[datetime.datetime] = None, force_debug: bool = False, - context: Optional[Dict] = None + 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. + Decide se inviare un report automatico. FUNZIONE PURA: non modifica `state`. + La persistenza dei flag di notifica avviene in commit_auto_report SOLO dopo + invio Telegram confermato (così un fallimento di rete non marca 'già notificato' + impedendo il retry il giorno dopo). + + Regole: + - dormant : silenzio, salvo prima uscita-dormiente prevista nel mese (una tantum). + - wakeup : prima notifica del mese (una tantum). + - active : DIGEST INFORMATIVO QUOTIDIANO per tutta la fase (scelta utente), + con guardia anti-doppio-invio nello stesso giorno. + - shutdown: una tantum (cessata necessità di irrigazione). """ if force_debug: return True, "DEBUG MODE" ctx = context or {} - now = now_local() + now = now or now_local() current_month_iso = now.strftime("%Y-%m") + today_iso = now.date().isoformat() will_exit = ctx.get("will_exit_dormant_in_forecast", False) - 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" + # Digest quotidiano per tutta la fase attiva; una sola volta al giorno + # (idempotenza per ri-esecuzioni manuali o retry nello stesso giorno). + if state.get("last_auto_report_date") == today_iso: + return False, "ACTIVE_GIÀ_INVIATO_OGGI" + return True, "AGGIORNAMENTO_QUOTIDIANO" if phase == "shutdown": if state.get("shutdown_confirmed", False): return False, "SHUTDOWN_GIÀ_NOTIFICATO" - state["shutdown_confirmed"] = True - state["last_auto_report_date"] = now.date().isoformat() return True, "SHUTDOWN_CESSATA_NECESSITÀ" return False, "UNKNOWN_PHASE" +def commit_auto_report(send_record: Optional[Dict]) -> None: + """Persiste i flag di notifica DOPO un invio Telegram andato a buon fine. + Separata dalla decisione (should_send_auto_report) così un invio fallito non + lascia lo stato come 'già notificato'. Ricarica lo stato salvato da + analyze_irrigation e vi aggiunge la sola contabilità di notifica.""" + if not send_record: + return + try: + state = load_state() + now = now_local() + phase = send_record.get("phase") + state["last_auto_report_date"] = now.date().isoformat() + if phase in ("dormant", "wakeup"): + state["wakeup_notified_for_month"] = now.strftime("%Y-%m") + elif phase == "active": + nt = send_record.get("need_today") + if nt is not None: + state["last_irrigation_need"] = nt + elif phase == "shutdown": + state["shutdown_confirmed"] = True + save_state(state) + LOGGER.info("commit_auto_report: phase=%s reason=%s persistito", phase, send_record.get("reason")) + except Exception as e: + LOGGER.exception("commit_auto_report error: %s", e) + + def analyze_irrigation( lat: float = DEFAULT_LAT, lon: float = DEFAULT_LON, @@ -1660,10 +1665,11 @@ def analyze_irrigation( timezone: str = TZ, debug_mode: bool = False, force_send: bool = False -) -> Tuple[str, bool]: +) -> Tuple[str, bool, Optional[bytes], Dict]: """ Analisi principale e generazione report. - Returns: (report, should_send_auto, chart_bytes o None) + Returns: (report, should_send_auto, chart_bytes o None, send_record) + send_record = {phase, reason, need_today} per commit_auto_report dopo invio confermato. """ LOGGER.info("=== Analisi Irrigazione per %s ===", location_name) @@ -1674,7 +1680,7 @@ def analyze_irrigation( 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) + return ("❌ **ERRORE**\n\nImpossibile recuperare dati meteorologici. Riprova più tardi.", False, None, None) hourly = data.get("hourly", {}) or {} daily = data.get("daily", {}) or {} @@ -1683,7 +1689,7 @@ def analyze_irrigation( 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) + return ("❌ **ERRORE**\n\nNessun dato temporale disponibile.", False, None, None) now = now_local() current_idx = 0 @@ -1699,19 +1705,15 @@ def analyze_irrigation( # 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) @@ -1723,14 +1725,14 @@ def analyze_irrigation( 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) + # Temperatura suolo 6cm (fallback 0cm). first_non_none (non 'or') così uno 0.0°C + # invernale legittimo non viene scartato. + soil_temp_6cm = first_non_none(_at(current_idx, soil_temp_6cm_list), _at(current_idx, soil_temp_0cm_list)) # Umidità: layer 0-1, 3-9, 9-27, 27-81 (ICON Seamless o Italia) soil_moisture_0_1cm = _at(current_idx, soil_moisture_0_1_list) - soil_moisture_3_9cm = _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_3_9cm = first_non_none(_at(current_idx, soil_moisture_3_9_list), soil_moisture_0_1cm) + soil_moisture_9_27cm = _at(current_idx, soil_moisture_9_27_list) soil_moisture_27_81cm = _at(current_idx, soil_moisture_27_81_list) # Parametri aggiuntivi per calcolo stress idrico @@ -1756,18 +1758,6 @@ def analyze_irrigation( 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 = [] @@ -1780,8 +1770,10 @@ def analyze_irrigation( if shortwave_values: shortwave_avg = sum(shortwave_values) / len(shortwave_values) # W/m² - # ET₀ medio (calcola su prossime 24h) - et0_avg = None + # ET₀ fabbisogno prossime 24h = SOMMA delle 24 ore orarie (mm/GIORNO). + # BUG storico corretto: prima si usava la MEDIA oraria (~0.2 mm/h) come se fosse + # mm/giorno (~5), rendendo inerte il gate CRITICAL (>3 mm/d) e i deficit. + et0_avg = None # interpretato come mm/giorno dai consumatori (daily_water_demand, et0/6) et0_values = [] for i in range(current_idx, min(current_idx + 24, len(et0_list))): if i < len(et0_list) and et0_list[i] is not None: @@ -1789,8 +1781,22 @@ def analyze_irrigation( et0_values.append(float(et0_list[i])) except Exception: continue - if et0_values: - et0_avg = sum(et0_values) / len(et0_values) + if len(et0_values) >= 20: + et0_avg = sum(et0_values) # ~ET₀ giornaliero (mm/d) + else: + # Fallback: ET₀ giornaliero da 'daily' per oggi (hourly insufficiente, es. modello ridotto) + _dt = daily.get("time", []) or [] + _es = daily.get("et0_fao_evapotranspiration_sum", []) or [] + _today_iso = now.date().isoformat() + for _i, _d in enumerate(_dt): + if _d and str(_d).startswith(_today_iso[:10]) and _i < len(_es) and _es[_i] is not None: + try: + et0_avg = float(_es[_i]) + except (TypeError, ValueError): + pass + break + if et0_avg is None and et0_values: + et0_avg = sum(et0_values) # Previsioni pioggia future_rain_total, rainy_days = check_future_rainfall(daily, days_ahead=5) @@ -1811,26 +1817,35 @@ def analyze_irrigation( 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 + # Modello a serbatoio (bucket) ANCORATO ai sensori: la VWC misurata è la base di verità, + # eliminando la deriva (prima il bucket scendeva a 0 mentre il suolo restava ~22%). today_iso = now.date().isoformat() daily_times = daily.get("time", []) or [] et0_sum = daily.get("et0_fao_evapotranspiration_sum", []) or [] precip_sum = daily.get("precipitation_sum", []) or [] - 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 + heart_now = heart_moisture(soil_moisture_3_9cm, soil_moisture_9_27cm) + # Irrigazione manuale dichiarata dall'utente (mm), contabilizzata una sola volta + try: + manual_mm = float(state.get("manual_irrigation_mm", 0.0) or 0.0) + except (TypeError, ValueError): + manual_mm = 0.0 + if heart_now is not None: + # Bilancio derivato dal SENSORE (+ eventuale irrigazione manuale appena dichiarata) + balance = vwc_to_balance_mm(heart_now) + manual_mm + else: + # Sensore assente: propaga dal bilancio memorizzato (fallback giornaliero ET₀/pioggia) + balance = float(state.get("water_balance_mm", WATER_BALANCE_MAX_MM)) + manual_mm + if state.get("last_balance_date") != today_iso and daily_times: + day_idx = next((i for i, d in enumerate(daily_times) if d and str(d).startswith(today_iso[:10])), None) + if day_idx is not None: + et0_day = float(et0_sum[day_idx]) if day_idx < len(et0_sum) and et0_sum[day_idx] is not None else 0.0 + precip_day = float(precip_sum[day_idx]) if day_idx < len(precip_sum) and precip_sum[day_idx] is not None else 0.0 + balance = balance - et0_day * KC_LAWN + effective_precip_mm(precip_day) + balance = max(0.0, min(WATER_BALANCE_MAX_MM, balance)) + state["water_balance_mm"] = balance + state["last_balance_date"] = today_iso + if manual_mm > 0: + state["manual_irrigation_mm"] = 0.0 # consumata suggested_minutes = None if balance < WATER_BALANCE_CRITICAL_MM: deficit_mm = WATER_BALANCE_MAX_MM - balance @@ -1868,17 +1883,11 @@ def analyze_irrigation( ) # 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 + heart_m = heart_moisture(soil_moisture_3_9cm, soil_moisture_9_27cm) report_ctx = { "will_exit_dormant_in_forecast": _will_exit_dormant_in_forecast(hourly, times, now), - "irrigation_need_today": _irrigation_need_index(heart_moisture, et0_avg, vpd_avg), - "irrigation_need_next_days": _irrigation_need_next_days(daily, heart_moisture, 3), + "irrigation_need_today": _irrigation_need_index(heart_m, et0_avg, vpd_avg), + "irrigation_need_next_days": _irrigation_need_next_days(daily, heart_m, 3), } last_rep = state.get("last_auto_report_date") if last_rep: @@ -1889,21 +1898,33 @@ def analyze_irrigation( else: report_ctx["days_since_last_report"] = 999 - # Determina se inviare report automatico + # Determina se inviare report automatico (FUNZIONE PURA: non scrive su state) should_send, reason = should_send_auto_report( - phase, soil_temp_6cm, state, force_debug=debug_mode, context=report_ctx + phase, state, now=now, force_debug=debug_mode, context=report_ctx ) - + # Contabilità di notifica (last_auto_report_date, ecc.) persistita da commit_auto_report + # SOLO dopo invio Telegram confermato. send_record porta i dati necessari al caller. + send_record = { + "phase": phase, + "reason": reason, + "need_today": report_ctx.get("irrigation_need_today"), + } + # Valore di confronto PRIMA dell'aggiornamento, per evidenziare variazioni nel report + prev_need = state.get("last_irrigation_need") + # Aggiorna stato state["phase"] = phase state["last_check"] = now.isoformat() - - # Aggiungi a storico (mantieni ultimi 7 giorni) + # Una nuova stagione attiva/risveglio azzera il flag di chiusura autunnale + if phase in ("active", "wakeup"): + state["shutdown_confirmed"] = False + + # Aggiungi a storico (mantieni ultimi 7 giorni); dedup per giorno (ri-esecuzioni) # Usa soil_temp_0cm per storico (mappato come 6cm nella logica) today_str = now.date().isoformat() state["soil_temp_history"] = [ (d, t) for d, t in state.get("soil_temp_history", []) - if (now.date() - datetime.date.fromisoformat(d)).days <= 7 + if d != today_str and (now.date() - datetime.date.fromisoformat(d)).days <= 7 ] if soil_temp_6cm is not None: # Questa è già mappata da soil_temp_0cm state["soil_temp_history"].append((today_str, soil_temp_6cm)) @@ -1975,11 +1996,21 @@ def analyze_irrigation( 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" + # Trend umidità 9-27cm dallo storico giornaliero (primo vs ultimo) + moist_trend_9_27 = None + _dh = state.get("daily_history", []) or [] + _m_series = [h.get("moist_9_27") for h in _dh if h.get("moist_9_27") is not None] + if len(_m_series) >= 2: + _md = (_m_series[-1] - _m_series[0]) * 100.0 + if abs(_md) >= 1.0: + moist_trend_9_27 = f"{_md:+.0f}%/{len(_m_series)}gg" + + # Variazione indice di necessità rispetto all'ultimo invio (evidenziata se significativa) + need_today = report_ctx.get("irrigation_need_today") + need_next = report_ctx.get("irrigation_need_next_days") or [] + need_delta_note = None + if prev_need is not None and need_today is not None and abs(need_today - prev_need) >= IRRIGATION_NEED_DELTA_SIGNIFICANT: + need_delta_note = f"{prev_need:.0f}→{need_today:.0f}" # Pianificazione prossimi 8 giorni (da hourly: medie giornaliere 9-27cm, prima necessità sotto trigger) planning_8d_line = "" @@ -2021,8 +2052,17 @@ def analyze_irrigation( 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 (umidità e prossimi 8 gg sono nel grafico) + + advice_level = advice_dict.get("advice_level") + # Il suggerimento minuti appare SOLO se il consiglio invita a irrigare e non ci sono veti: + # così sparisce la vecchia contraddizione "STOP ... ma irriga 120 min". + show_suggest = ( + suggested_minutes is not None and phase == "active" + and advice_level in ("CRITICAL", "STANDARD") + and not freeze_veto and not rain_veto + ) + + # ---- Colpo d'occhio (digest informativo) ---- glance = [ status.strip(), f"📍 {location_name} · {now.strftime('%d/%m/%Y %H:%M')}", @@ -2032,21 +2072,67 @@ def analyze_irrigation( 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) + + # Necessità irrigazione + andamento prossimi giorni (solo fase attiva) + if phase == "active" and need_today is not None: + need_line = f"🚰 Necessità: {need_today:.0f}/100 ({_need_label(need_today)})" + if need_delta_note: + need_line += f" · Δ {need_delta_note}" + spark = _sparkline(need_next) + if spark.strip(): + need_line += f" · prossimi gg {spark}" + glance.append(need_line) + + # Suolo: cuore radicale, riserva, temperatura (con trend) + soil_bits = [] + if soil_moisture_9_27cm is not None: + s = f"radici 9-27cm {soil_moisture_9_27cm*100:.0f}% ({classify_soil_moisture(soil_moisture_9_27cm)}, trigger {SOIL_MOISTURE_DEEP_STRESS*100:.0f}%)" + if moist_trend_9_27: + s += f" {moist_trend_9_27}" + soil_bits.append(s) + if soil_moisture_27_81cm is not None: + soil_bits.append(f"riserva 27-81cm {soil_moisture_27_81cm*100:.0f}%") + if soil_temp_6cm is not None: + t = f"T suolo {soil_temp_6cm:.1f}°C ({classify_soil_temp(soil_temp_6cm)})" + if temp_trend: + t += f" {temp_trend}/7gg" + soil_bits.append(t) + if soil_bits: + glance.append("🌱 " + " · ".join(soil_bits)) + + # Meteo: ET₀, VPD, sole, pioggia 5 giorni + meteo_bits = [] + if et0_avg is not None: + meteo_bits.append(f"ET₀ {et0_avg:.1f} mm/d ({classify_et0(et0_avg)})") + if vpd_avg is not None: + meteo_bits.append(f"VPD {vpd_avg:.2f} kPa ({classify_vpd(vpd_avg)})") + if sunshine_hours is not None: + meteo_bits.append(f"sole {sunshine_hours:.1f}h") + meteo_bits.append(f"pioggia 5gg {future_rain_total:.1f}mm") + glance.append("🌤️ " + " · ".join(meteo_bits)) + + # Bilancio idrico + eventuale suggerimento minuti (gated) + if phase == "active": + bal_line = f"🪣 Bilancio: {balance:.0f}/{WATER_BALANCE_MAX_MM:.0f} mm" + if show_suggest: + bal_line += f" → suggerito ~{suggested_minutes} min (ciclo lungo e lento)" + glance.append(bal_line) + glance.append("") + + # ---- Report completo (strutturato) ---- report_parts = [ "\n".join(glance), - "─"*24, + "─" * 24, "**Consiglio**", advice, "", ] if timing_advice: report_parts.append("**Orario** " + " · ".join(timing_advice)) - + if planning_8d_line: + report_parts.append(planning_8d_line.strip()) + report_parts.append(f"ℹ️ Motivo invio: `{reason}`") + # Salva stato save_state(state) @@ -2062,8 +2148,8 @@ def analyze_irrigation( 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 + + return report, (should_send if not force_send else True), chart_bytes, send_record # ============================================================================= @@ -2211,10 +2297,9 @@ def main(): # 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( + report, should_send_auto, chart_bytes, send_record = analyze_irrigation( lat, lon, location, timezone, debug_mode=args.debug, force_send=force_send @@ -2254,7 +2339,10 @@ def main(): 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: + if success: + # Persisti la contabilità di notifica SOLO dopo invio riuscito + commit_auto_report(send_record) + else: print(report) # Fallback su stdout LOGGER.error("Errore invio Telegram, stampato su stdout") if chart_bytes: