Files
loogle-scripts/services/telegram-bot/smart_irrigation_advisor.py

1526 lines
61 KiB
Python
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()
fh = RotatingFileHandler(LOG_FILE, maxBytes=500_000, backupCount=3, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
fh.setFormatter(fmt)
logger.addHandler(fh)
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()