1551 lines
62 KiB
Python
Executable File
1551 lines
62 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
|
||
"""
|
||
Smart Irrigation Advisor - Consulente Agronomico "Smart Season"
|
||
Fornisce consigli pragmatici per la gestione stagionale dell'irrigazione del giardino
|
||
basati su dati meteo e stato del suolo.
|
||
"""
|
||
|
||
import argparse
|
||
import datetime
|
||
import json
|
||
import logging
|
||
import os
|
||
import sys
|
||
from logging.handlers import RotatingFileHandler
|
||
from typing import Dict, List, Optional, Tuple
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import requests
|
||
from dateutil import parser
|
||
|
||
# =============================================================================
|
||
# CONFIGURATION
|
||
# =============================================================================
|
||
|
||
DEBUG = os.environ.get("DEBUG", "0").strip() == "1"
|
||
|
||
# Location
|
||
DEFAULT_LAT = 43.9356
|
||
DEFAULT_LON = 12.4296
|
||
DEFAULT_LOCATION_NAME = "🏠 Casa"
|
||
|
||
# Timezone
|
||
TZ = "Europe/Berlin"
|
||
TZINFO = ZoneInfo(TZ)
|
||
|
||
# Open-Meteo
|
||
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
|
||
MODEL_AROME = "meteofrance_seamless"
|
||
MODEL_ICON = "italia_meteo_arpae_icon_2i"
|
||
HTTP_HEADERS = {"User-Agent": "Smart-Irrigation-Advisor/1.0"}
|
||
|
||
# Files
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
LOG_FILE = os.path.join(BASE_DIR, "irrigation_advisor.log")
|
||
STATE_FILE = os.path.join(BASE_DIR, "irrigation_state.json")
|
||
|
||
# Telegram (opzionale, per integrazione bot)
|
||
TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"]
|
||
TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token")
|
||
TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token"
|
||
|
||
# Soglie Agronomiche
|
||
SOIL_TEMP_WAKEUP_THRESHOLD = 10.0 # °C - Soglia di risveglio vegetativo
|
||
SOIL_TEMP_WAKEUP_DAYS_MIN = 3 # Giorni consecutivi minimi per risveglio
|
||
SOIL_TEMP_WAKEUP_DAYS_MAX = 5 # Giorni consecutivi massimi per risveglio
|
||
SOIL_TEMP_SHUTDOWN_THRESHOLD = 10.0 # °C - Soglia di chiusura autunnale
|
||
SOIL_TEMP_WAKEUP_INDICATOR = 8.0 # °C - Soglia indicatore di avvicinamento al risveglio (sblocca report)
|
||
SOIL_MOISTURE_FIELD_CAPACITY = 0.6 # Capacità di campo (60% - valore tipico per terreno medio)
|
||
SOIL_MOISTURE_WILTING_POINT = 0.3 # Punto di avvizzimento (30%)
|
||
SOIL_MOISTURE_AUTUMN_HIGH = 0.8 # 80% - Umidità alta in autunno
|
||
SOIL_MOISTURE_DEEP_STRESS = 0.35 # 35% - Umidità profonda critica (vicina a punto di avvizzimento)
|
||
PRECIP_THRESHOLD_SIGNIFICANT = 5.0 # mm - Pioggia significativa
|
||
AUTUMN_HIGH_MOISTURE_DAYS = 10 # Giorni consecutivi con umidità alta per chiusura
|
||
|
||
# =============================================================================
|
||
# CLASSIFICAZIONE VALORI PARAMETRI
|
||
# =============================================================================
|
||
# Soglie per classificare i parametri come bassi, medio/bassi, medi, alti, medio/alti
|
||
|
||
# Evapotraspirazione (ET₀) - mm/d
|
||
ET0_LOW = 2.0 # < 2.0 mm/d = basso
|
||
ET0_MEDIUM_LOW = 3.5 # 2.0-3.5 mm/d = medio/basso
|
||
ET0_MEDIUM_HIGH = 5.0 # 3.5-5.0 mm/d = medio/alto
|
||
# > 5.0 mm/d = alto
|
||
|
||
# Temperatura suolo - °C
|
||
SOIL_TEMP_LOW = 5.0 # < 5°C = basso
|
||
SOIL_TEMP_MEDIUM_LOW = 10.0 # 5-10°C = medio/basso
|
||
SOIL_TEMP_MEDIUM_HIGH = 15.0 # 10-15°C = medio/alto
|
||
# > 15°C = alto
|
||
|
||
# Umidità suolo - frazione (0-1)
|
||
SOIL_MOISTURE_LOW = 0.3 # < 0.3 (30%) = basso (punto di avvizzimento)
|
||
SOIL_MOISTURE_MEDIUM_LOW = 0.5 # 0.3-0.5 (30-50%) = medio/basso
|
||
SOIL_MOISTURE_MEDIUM_HIGH = 0.7 # 0.5-0.7 (50-70%) = medio/alto
|
||
# > 0.7 (70%) = alto (vicino a capacità di campo)
|
||
|
||
# VPD - kPa
|
||
VPD_LOW = 0.5 # < 0.5 kPa = basso (umido)
|
||
VPD_MEDIUM_LOW = 0.8 # 0.5-0.8 kPa = medio/basso
|
||
VPD_MEDIUM_HIGH = 1.2 # 0.8-1.2 kPa = medio/alto
|
||
# > 1.2 kPa = alto (secco, stress idrico)
|
||
|
||
# Sunshine duration - ore/giorno
|
||
SUNSHINE_LOW = 4.0 # < 4h = basso
|
||
SUNSHINE_MEDIUM_LOW = 6.0 # 4-6h = medio/basso
|
||
SUNSHINE_MEDIUM_HIGH = 8.0 # 6-8h = medio/alto
|
||
# > 8h = alto
|
||
|
||
# Precipitazioni - mm/giorno
|
||
PRECIP_DAILY_LOW = 2.0 # < 2mm/giorno = basso
|
||
PRECIP_DAILY_MEDIUM_LOW = 5.0 # 2-5mm/giorno = medio/basso
|
||
PRECIP_DAILY_MEDIUM_HIGH = 15.0 # 5-15mm/giorno = medio/alto
|
||
# > 15mm/giorno = alto
|
||
|
||
# =============================================================================
|
||
# LOGGING
|
||
# =============================================================================
|
||
|
||
def setup_logger() -> logging.Logger:
|
||
logger = logging.getLogger("irrigation_advisor")
|
||
logger.setLevel(logging.DEBUG if DEBUG else logging.INFO)
|
||
logger.handlers.clear()
|
||
|
||
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
||
parent_dir = os.path.dirname(LOG_FILE)
|
||
if parent_dir and not os.path.exists(parent_dir):
|
||
os.makedirs(parent_dir, exist_ok=True)
|
||
|
||
try:
|
||
fh = RotatingFileHandler(LOG_FILE, maxBytes=500_000, backupCount=3, encoding="utf-8")
|
||
fh.setLevel(logging.DEBUG)
|
||
fh.setFormatter(fmt)
|
||
logger.addHandler(fh)
|
||
except PermissionError:
|
||
fallback_log = "/tmp/irrigation_advisor.log"
|
||
try:
|
||
fh = RotatingFileHandler(fallback_log, maxBytes=500_000, backupCount=3, encoding="utf-8")
|
||
fh.setLevel(logging.DEBUG)
|
||
fh.setFormatter(fmt)
|
||
logger.addHandler(fh)
|
||
logger.warning("Permesso negato su %s, uso fallback %s", LOG_FILE, fallback_log)
|
||
except Exception:
|
||
sh = logging.StreamHandler()
|
||
sh.setLevel(logging.DEBUG)
|
||
sh.setFormatter(fmt)
|
||
logger.addHandler(sh)
|
||
logger.warning("Permesso negato su %s, fallback su stderr", LOG_FILE)
|
||
except Exception:
|
||
sh = logging.StreamHandler()
|
||
sh.setLevel(logging.DEBUG)
|
||
sh.setFormatter(fmt)
|
||
logger.addHandler(sh)
|
||
logger.warning("Errore logger file %s, fallback su stderr", LOG_FILE)
|
||
|
||
if DEBUG:
|
||
sh = logging.StreamHandler()
|
||
sh.setLevel(logging.DEBUG)
|
||
sh.setFormatter(fmt)
|
||
logger.addHandler(sh)
|
||
|
||
return logger
|
||
|
||
|
||
LOGGER = setup_logger()
|
||
|
||
|
||
# =============================================================================
|
||
# UTILITIES
|
||
# =============================================================================
|
||
|
||
def now_local() -> datetime.datetime:
|
||
return datetime.datetime.now(TZINFO)
|
||
|
||
|
||
def parse_time_to_local(t: str) -> datetime.datetime:
|
||
dt = parser.isoparse(t)
|
||
if dt.tzinfo is None:
|
||
return dt.replace(tzinfo=TZINFO)
|
||
return dt.astimezone(TZINFO)
|
||
|
||
|
||
def ensure_parent_dir(path: str) -> None:
|
||
parent = os.path.dirname(path)
|
||
if parent and not os.path.exists(parent):
|
||
os.makedirs(parent, exist_ok=True)
|
||
|
||
|
||
def read_text_file(path: str) -> str:
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
return f.read().strip()
|
||
except FileNotFoundError:
|
||
return ""
|
||
except Exception as e:
|
||
LOGGER.debug("Error reading %s: %s", path, e)
|
||
return ""
|
||
|
||
|
||
def load_bot_token() -> str:
|
||
tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
|
||
if tok:
|
||
return tok
|
||
tok = read_text_file(TOKEN_FILE_HOME)
|
||
if tok:
|
||
return tok
|
||
tok = read_text_file(TOKEN_FILE_ETC)
|
||
return tok.strip() if tok else ""
|
||
|
||
|
||
# =============================================================================
|
||
# STATE MANAGEMENT
|
||
# =============================================================================
|
||
|
||
def load_state() -> Dict:
|
||
default = {
|
||
"phase": "unknown", # "wakeup", "active", "shutdown", "dormant"
|
||
"last_check": None,
|
||
"soil_temp_history": [], # Lista di (date, temp_6cm)
|
||
"soil_moisture_history": [], # Lista di (date, moisture_3_9cm, moisture_9_27cm)
|
||
"high_moisture_streak": 0, # Giorni consecutivi con umidità alta (per fase shutdown)
|
||
"auto_reporting_enabled": False, # Se True, i report automatici sono attivi
|
||
"wakeup_threshold_reached": False, # Se True, abbiamo superato la soglia di risveglio
|
||
"shutdown_confirmed": False, # Se True, la chiusura è stata confermata
|
||
}
|
||
if os.path.exists(STATE_FILE):
|
||
try:
|
||
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
||
data = json.load(f) or {}
|
||
default.update(data)
|
||
except Exception as e:
|
||
LOGGER.exception("State read error: %s", e)
|
||
return default
|
||
|
||
|
||
def save_state(state: Dict) -> None:
|
||
try:
|
||
ensure_parent_dir(STATE_FILE)
|
||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(state, f, ensure_ascii=False, indent=2)
|
||
except Exception as e:
|
||
LOGGER.exception("State write error: %s", e)
|
||
|
||
|
||
# =============================================================================
|
||
# OPEN-METEO API
|
||
# =============================================================================
|
||
|
||
def fetch_soil_and_weather(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]:
|
||
"""
|
||
Recupera dati suolo e meteo da Open-Meteo.
|
||
Nota: I parametri del suolo potrebbero non essere disponibili per tutte le località.
|
||
In caso di errore, restituisce None.
|
||
"""
|
||
params = {
|
||
"latitude": lat,
|
||
"longitude": lon,
|
||
"timezone": timezone,
|
||
"forecast_days": 5, # 5 giorni per previsioni pioggia
|
||
"hourly": ",".join([
|
||
# Parametri suolo ICON Italia
|
||
"soil_temperature_0cm",
|
||
"soil_temperature_54cm",
|
||
"soil_moisture_0_to_1cm",
|
||
"soil_moisture_81_to_243cm",
|
||
# Meteo base
|
||
"precipitation",
|
||
"snowfall",
|
||
"temperature_2m",
|
||
# Evapotraspirazione e stress idrico
|
||
"et0_fao_evapotranspiration",
|
||
"vapour_pressure_deficit",
|
||
# Parametri irraggiamento solare
|
||
"direct_radiation",
|
||
"diffuse_radiation",
|
||
"shortwave_radiation", # GHI - Global Horizontal Irradiance (energia totale per fotosintesi)
|
||
"sunshine_duration",
|
||
]),
|
||
"daily": ",".join([
|
||
"precipitation_sum",
|
||
"snowfall_sum",
|
||
"et0_fao_evapotranspiration_sum",
|
||
"sunshine_duration",
|
||
]),
|
||
"models": MODEL_ICON, # Usa ICON Italia per migliore copertura Europa
|
||
}
|
||
|
||
try:
|
||
r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=30)
|
||
if r.status_code == 400:
|
||
try:
|
||
j = r.json()
|
||
reason = j.get("reason", str(j))
|
||
LOGGER.warning("Open-Meteo 400: %s. Parametri suolo potrebbero non essere disponibili per questa località.", reason)
|
||
# Prova senza parametri suolo (fallback)
|
||
return fetch_weather_only(lat, lon, timezone)
|
||
except Exception:
|
||
LOGGER.error("Open-Meteo 400: %s", r.text[:500])
|
||
return fetch_weather_only(lat, lon, timezone)
|
||
r.raise_for_status()
|
||
data = r.json()
|
||
|
||
# Verifica che i dati del suolo siano presenti (almeno alcuni valori non-None)
|
||
hourly = data.get("hourly", {}) or {}
|
||
# ICON Italia usa soil_temperature_0cm e soil_temperature_54cm
|
||
soil_temp_0 = hourly.get("soil_temperature_0cm", []) or []
|
||
soil_temp_54 = hourly.get("soil_temperature_54cm", []) or []
|
||
# Controlla se ci sono almeno alcuni valori non-None
|
||
has_soil_data = any(v is not None for v in soil_temp_0[:24]) or any(v is not None for v in soil_temp_54[:24])
|
||
if not has_soil_data:
|
||
LOGGER.warning("Dati suolo non disponibili (tutti None). Uso fallback meteo-only.")
|
||
return fetch_weather_only(lat, lon, timezone)
|
||
|
||
return data
|
||
except Exception as e:
|
||
LOGGER.exception("Open-Meteo request error: %s", e)
|
||
return fetch_weather_only(lat, lon, timezone)
|
||
|
||
|
||
def fetch_weather_only(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]:
|
||
"""Fallback: recupera solo dati meteo (senza parametri suolo)."""
|
||
params = {
|
||
"latitude": lat,
|
||
"longitude": lon,
|
||
"timezone": timezone,
|
||
"forecast_days": 5,
|
||
"hourly": ",".join([
|
||
"precipitation",
|
||
"snowfall",
|
||
"et0_fao_evapotranspiration",
|
||
"temperature_2m",
|
||
]),
|
||
"daily": ",".join([
|
||
"precipitation_sum",
|
||
"snowfall_sum",
|
||
"et0_fao_evapotranspiration_sum",
|
||
]),
|
||
"models": MODEL_ICON,
|
||
}
|
||
|
||
try:
|
||
r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=30)
|
||
r.raise_for_status()
|
||
return r.json()
|
||
except Exception as e:
|
||
LOGGER.exception("Open-Meteo weather-only request error: %s", e)
|
||
return None
|
||
|
||
|
||
# =============================================================================
|
||
# SEASONAL PHASE DETECTION
|
||
# =============================================================================
|
||
|
||
def determine_seasonal_phase(
|
||
month: int,
|
||
soil_temp_6cm: Optional[float],
|
||
soil_moisture_3_9cm: Optional[float],
|
||
soil_moisture_9_27cm: Optional[float],
|
||
state: Dict
|
||
) -> str:
|
||
"""
|
||
Determina la fase stagionale: "wakeup", "active", "shutdown", "dormant"
|
||
"""
|
||
# Primavera (Marzo-Maggio): fase risveglio o attiva
|
||
if month in [3, 4, 5]:
|
||
# Se temperatura suolo > soglia per X giorni consecutivi -> attiva
|
||
# Altrimenti -> wakeup
|
||
if soil_temp_6cm is not None and soil_temp_6cm >= SOIL_TEMP_WAKEUP_THRESHOLD:
|
||
# Verifica persistenza (dai dati storici o corrente)
|
||
temp_history = state.get("soil_temp_history", [])
|
||
recent_warm_days = 0
|
||
now = now_local()
|
||
for date_str, temp in temp_history:
|
||
try:
|
||
date_obj = datetime.datetime.fromisoformat(date_str).replace(tzinfo=TZINFO)
|
||
days_ago = (now - date_obj).days
|
||
if days_ago <= SOIL_TEMP_WAKEUP_DAYS_MAX and temp >= SOIL_TEMP_WAKEUP_THRESHOLD:
|
||
recent_warm_days += 1
|
||
except Exception:
|
||
continue
|
||
|
||
if recent_warm_days >= SOIL_TEMP_WAKEUP_DAYS_MIN - 1 or soil_temp_6cm >= SOIL_TEMP_WAKEUP_THRESHOLD:
|
||
return "active"
|
||
else:
|
||
return "wakeup"
|
||
else:
|
||
return "wakeup"
|
||
|
||
# Estate (Giugno-Agosto): sempre attiva
|
||
elif month in [6, 7, 8]:
|
||
return "active"
|
||
|
||
# Autunno (Settembre-Novembre): attiva o shutdown
|
||
elif month in [9, 10, 11]:
|
||
if soil_temp_6cm is not None and soil_temp_6cm < SOIL_TEMP_SHUTDOWN_THRESHOLD:
|
||
return "shutdown"
|
||
elif (soil_moisture_9_27cm is not None and
|
||
soil_moisture_9_27cm >= SOIL_MOISTURE_AUTUMN_HIGH):
|
||
# Verifica giorni consecutivi con umidità alta
|
||
high_streak = state.get("high_moisture_streak", 0)
|
||
if high_streak >= AUTUMN_HIGH_MOISTURE_DAYS:
|
||
return "shutdown"
|
||
return "active"
|
||
|
||
# Inverno (Dicembre-Febbraio): dormiente
|
||
else:
|
||
return "dormant"
|
||
|
||
|
||
# =============================================================================
|
||
# CLASSIFICATION HELPERS
|
||
# =============================================================================
|
||
|
||
def classify_et0(et0: float) -> str:
|
||
"""Classifica ET₀ in basso, medio/basso, medio, medio/alto, alto"""
|
||
if et0 < ET0_LOW:
|
||
return "basso"
|
||
elif et0 < ET0_MEDIUM_LOW:
|
||
return "medio/basso"
|
||
elif et0 < ET0_MEDIUM_HIGH:
|
||
return "medio"
|
||
elif et0 < 7.0:
|
||
return "medio/alto"
|
||
else:
|
||
return "alto"
|
||
|
||
|
||
def classify_soil_temp(temp: float) -> str:
|
||
"""Classifica temperatura suolo in basso, medio/basso, medio, medio/alto, alto"""
|
||
if temp < SOIL_TEMP_LOW:
|
||
return "basso"
|
||
elif temp < SOIL_TEMP_MEDIUM_LOW:
|
||
return "medio/basso"
|
||
elif temp < SOIL_TEMP_MEDIUM_HIGH:
|
||
return "medio"
|
||
elif temp < 20.0:
|
||
return "medio/alto"
|
||
else:
|
||
return "alto"
|
||
|
||
|
||
def classify_soil_moisture(moisture: float) -> str:
|
||
"""Classifica umidità suolo in basso, medio/basso, medio, medio/alto, alto"""
|
||
if moisture < SOIL_MOISTURE_LOW:
|
||
return "basso"
|
||
elif moisture < SOIL_MOISTURE_MEDIUM_LOW:
|
||
return "medio/basso"
|
||
elif moisture < SOIL_MOISTURE_MEDIUM_HIGH:
|
||
return "medio"
|
||
elif moisture < 0.85:
|
||
return "medio/alto"
|
||
else:
|
||
return "alto"
|
||
|
||
|
||
def classify_vpd(vpd: float) -> str:
|
||
"""Classifica VPD in basso, medio/basso, medio, medio/alto, alto"""
|
||
if vpd < VPD_LOW:
|
||
return "basso"
|
||
elif vpd < VPD_MEDIUM_LOW:
|
||
return "medio/basso"
|
||
elif vpd < VPD_MEDIUM_HIGH:
|
||
return "medio"
|
||
elif vpd < 1.8:
|
||
return "medio/alto"
|
||
else:
|
||
return "alto"
|
||
|
||
|
||
def classify_sunshine(hours: float) -> str:
|
||
"""Classifica ore di sole in basso, medio/basso, medio, medio/alto, alto"""
|
||
if hours < SUNSHINE_LOW:
|
||
return "basso"
|
||
elif hours < SUNSHINE_MEDIUM_LOW:
|
||
return "medio/basso"
|
||
elif hours < SUNSHINE_MEDIUM_HIGH:
|
||
return "medio"
|
||
elif hours < 10.0:
|
||
return "medio/alto"
|
||
else:
|
||
return "alto"
|
||
|
||
|
||
def classify_precip_daily(precip: float) -> str:
|
||
"""Classifica precipitazione giornaliera in basso, medio/basso, medio, medio/alto, alto"""
|
||
if precip < PRECIP_DAILY_LOW:
|
||
return "basso"
|
||
elif precip < PRECIP_DAILY_MEDIUM_LOW:
|
||
return "medio/basso"
|
||
elif precip < PRECIP_DAILY_MEDIUM_HIGH:
|
||
return "medio"
|
||
elif precip < 30.0:
|
||
return "medio/alto"
|
||
else:
|
||
return "alto"
|
||
|
||
|
||
# =============================================================================
|
||
# IRRIGATION LOGIC
|
||
# =============================================================================
|
||
|
||
def calculate_water_stress_index(
|
||
moisture_3_9cm: Optional[float],
|
||
moisture_9_27cm: Optional[float],
|
||
vpd_avg: Optional[float] = None
|
||
) -> Tuple[float, str]:
|
||
"""
|
||
Calcola Indice di Stress Idrico (0-100%) usando umidità suolo e VPD.
|
||
VPD (Vapour Pressure Deficit) è un ottimo indicatore di stress idrico:
|
||
- VPD alto (>1.5 kPa) = stress idrico elevato
|
||
- VPD medio (0.8-1.5 kPa) = stress moderato
|
||
- VPD basso (<0.8 kPa) = condizioni ottimali
|
||
|
||
Returns: (index, level_description)
|
||
"""
|
||
if moisture_3_9cm is None and moisture_9_27cm is None:
|
||
# Se non abbiamo dati umidità, usa solo VPD se disponibile
|
||
if vpd_avg is not None:
|
||
if vpd_avg > 1.5:
|
||
return 85.0, "ROSSO_VPD"
|
||
elif vpd_avg > 1.0:
|
||
return 60.0, "ARANCIONE_VPD"
|
||
elif vpd_avg > 0.8:
|
||
return 30.0, "GIALLO_VPD"
|
||
else:
|
||
return 10.0, "VERDE_VPD"
|
||
return 50.0, "UNKNOWN" # Dati non disponibili
|
||
|
||
# Usa media pesata (superficie più importante)
|
||
if moisture_3_9cm is not None and moisture_9_27cm is not None:
|
||
effective_moisture = 0.6 * moisture_3_9cm + 0.4 * moisture_9_27cm
|
||
elif moisture_3_9cm is not None:
|
||
effective_moisture = moisture_3_9cm
|
||
else:
|
||
effective_moisture = moisture_9_27cm
|
||
|
||
# Calcola indice base rispetto a capacità di campo
|
||
if effective_moisture >= SOIL_MOISTURE_FIELD_CAPACITY:
|
||
index_base = 0.0
|
||
level = "VERDE"
|
||
elif effective_moisture <= SOIL_MOISTURE_WILTING_POINT:
|
||
index_base = 100.0
|
||
level = "ROSSO"
|
||
else:
|
||
# Interpolazione lineare
|
||
range_moisture = SOIL_MOISTURE_FIELD_CAPACITY - SOIL_MOISTURE_WILTING_POINT
|
||
deficit = SOIL_MOISTURE_FIELD_CAPACITY - effective_moisture
|
||
index_base = (deficit / range_moisture) * 100.0
|
||
|
||
if index_base >= 70:
|
||
level = "ARANCIONE"
|
||
elif index_base >= 40:
|
||
level = "GIALLO"
|
||
else:
|
||
level = "VERDE"
|
||
|
||
# Aggiusta indice usando VPD se disponibile
|
||
# VPD alto aumenta lo stress percepito, VPD basso lo riduce
|
||
final_index = index_base
|
||
if vpd_avg is not None:
|
||
vpd_factor = 1.0
|
||
if vpd_avg > 1.5:
|
||
vpd_factor = 1.3 # Aumenta stress del 30% se VPD molto alto
|
||
elif vpd_avg > 1.0:
|
||
vpd_factor = 1.15 # Aumenta stress del 15%
|
||
elif vpd_avg < 0.8:
|
||
vpd_factor = 0.9 # Riduce stress del 10% se VPD basso
|
||
|
||
final_index = min(100.0, index_base * vpd_factor)
|
||
|
||
# Aggiorna livello se VPD modifica significativamente l'indice
|
||
if vpd_avg > 1.5 and level != "ROSSO":
|
||
if final_index >= 70:
|
||
level = "ARANCIONE_VPD"
|
||
if final_index >= 85:
|
||
level = "ROSSO_VPD"
|
||
elif vpd_avg < 0.8 and index_base > 40:
|
||
if final_index < 40:
|
||
level = "GIALLO_VPD"
|
||
|
||
return final_index, level
|
||
|
||
|
||
def check_future_rainfall(daily_data: Dict, days_ahead: int = 5, include_snowfall: bool = True) -> Tuple[float, List[str]]:
|
||
"""
|
||
Controlla pioggia prevista nei prossimi giorni usando precipitation_sum.
|
||
precipitation_sum include già pioggia, neve e temporali (è la somma totale).
|
||
Returns: (total_mm, list_of_days_with_precip)
|
||
"""
|
||
daily_times = daily_data.get("time", []) or []
|
||
# Usa precipitation_sum che include già pioggia, neve e temporali
|
||
daily_precip = daily_data.get("precipitation_sum", []) or []
|
||
daily_snowfall = daily_data.get("snowfall_sum", []) or [] # Solo per indicare se c'è neve
|
||
|
||
total = 0.0
|
||
rainy_days = []
|
||
|
||
now = now_local()
|
||
for i, time_str in enumerate(daily_times[:days_ahead]):
|
||
try:
|
||
day_time = parse_time_to_local(time_str)
|
||
if day_time.date() <= now.date():
|
||
continue # Salta giorni passati
|
||
|
||
# precipitation_sum include già tutto (pioggia + neve + temporali)
|
||
precip = float(daily_precip[i]) if i < len(daily_precip) and daily_precip[i] is not None else 0.0
|
||
snow = float(daily_snowfall[i]) if (include_snowfall and i < len(daily_snowfall) and daily_snowfall[i] is not None) else 0.0
|
||
|
||
# Usa solo precipitation_sum (non sommare snowfall separatamente, è già incluso)
|
||
total_precip = precip
|
||
|
||
if total_precip > 0.1: # Almeno 0.1 mm
|
||
total += total_precip
|
||
if snow > 0.5: # Se c'è neve significativa
|
||
rainy_days.append(f"{day_time.strftime('%d/%m')} ({total_precip:.1f}mm, di cui {snow/10:.1f}cm neve)")
|
||
else:
|
||
rainy_days.append(f"{day_time.strftime('%d/%m')} ({precip:.1f}mm)")
|
||
except Exception:
|
||
continue
|
||
|
||
return total, rainy_days
|
||
|
||
|
||
# =============================================================================
|
||
# ADVICE GENERATION
|
||
# =============================================================================
|
||
|
||
def generate_wakeup_advice(
|
||
soil_temp_6cm: Optional[float],
|
||
soil_moisture_3_9cm: Optional[float],
|
||
soil_moisture_9_27cm: Optional[float],
|
||
future_rain_mm: float,
|
||
rainy_days: List[str],
|
||
shortwave_avg: Optional[float] = None,
|
||
sunshine_hours: Optional[float] = None,
|
||
state: Optional[Dict] = None
|
||
) -> Dict:
|
||
"""
|
||
FASE RISVEGLIO: "Quando accendere?"
|
||
Trigger: Termico + Energetico + Fotoperiodo
|
||
Returns: Dict con season_phase, advice_level, human_message, soil_status_summary
|
||
"""
|
||
state = state or {}
|
||
status = "**Fase: Risveglio Primaverile**"
|
||
|
||
# TRIGGER 1: Soglia Termica - Soil Temperature (6cm) > 10°C per 3-5 giorni consecutivi
|
||
temp_ok = False
|
||
temp_avg_24h = None
|
||
if soil_temp_6cm is not None:
|
||
# Calcola media 24h (se disponibile storico, altrimenti usa valore corrente)
|
||
temp_history = state.get("soil_temp_history", [])
|
||
now = now_local()
|
||
recent_temps = []
|
||
for date_str, temp in temp_history:
|
||
try:
|
||
date_obj = datetime.datetime.fromisoformat(date_str).replace(tzinfo=TZINFO)
|
||
days_ago = (now - date_obj).days
|
||
if days_ago <= 7: # Ultimi 7 giorni
|
||
recent_temps.append(temp)
|
||
except Exception:
|
||
continue
|
||
recent_temps.append(soil_temp_6cm)
|
||
if recent_temps:
|
||
temp_avg_24h = sum(recent_temps) / len(recent_temps)
|
||
|
||
# Verifica giorni consecutivi sopra soglia
|
||
warm_days = 0
|
||
for date_str, temp in temp_history:
|
||
try:
|
||
date_obj = datetime.datetime.fromisoformat(date_str).replace(tzinfo=TZINFO)
|
||
days_ago = (now - date_obj).days
|
||
if days_ago <= SOIL_TEMP_WAKEUP_DAYS_MAX and temp >= SOIL_TEMP_WAKEUP_THRESHOLD:
|
||
warm_days += 1
|
||
except Exception:
|
||
continue
|
||
if soil_temp_6cm >= SOIL_TEMP_WAKEUP_THRESHOLD:
|
||
warm_days += 1
|
||
|
||
temp_ok = (temp_avg_24h is not None and temp_avg_24h >= SOIL_TEMP_WAKEUP_THRESHOLD and
|
||
warm_days >= SOIL_TEMP_WAKEUP_DAYS_MIN)
|
||
|
||
# TRIGGER 2: Energetico - Shortwave Radiation GHI in crescita
|
||
energy_ok = False
|
||
if shortwave_avg is not None:
|
||
# Verifica se GHI mostra trend positivo (semplificato: > 150 W/m² indica buon irraggiamento)
|
||
energy_ok = shortwave_avg > 150.0
|
||
|
||
# TRIGGER 3: Fotoperiodo - Sunshine Duration in aumento
|
||
photoperiod_ok = False
|
||
if sunshine_hours is not None:
|
||
# Fotoperiodo adeguato per risveglio (almeno 6-7 ore di sole)
|
||
photoperiod_ok = sunshine_hours >= 6.0
|
||
|
||
# Trigger combinati: almeno 2 su 3 devono essere OK (termico è obbligatorio)
|
||
triggers_active = temp_ok and (energy_ok or photoperiod_ok)
|
||
|
||
# Controlla umidità profonda (9-27cm = radici attive) sotto capacità di campo
|
||
moisture_deep_low = False
|
||
if soil_moisture_9_27cm is not None:
|
||
moisture_deep_low = soil_moisture_9_27cm < SOIL_MOISTURE_WILTING_POINT # < 0.30 m³/m³
|
||
|
||
# Genera consiglio
|
||
if triggers_active and moisture_deep_low:
|
||
advice_level = "CRITICAL"
|
||
advice_msg = "🌱 **SVEGLIA IL SISTEMA**\n\n"
|
||
advice_msg += "Tutti i trigger di risveglio sono attivi:\n"
|
||
if temp_ok:
|
||
advice_msg += f"• Temperatura suolo stabile ≥{SOIL_TEMP_WAKEUP_THRESHOLD}°C\n"
|
||
if energy_ok:
|
||
advice_msg += f"• Irraggiamento solare adeguato ({shortwave_avg:.0f} W/m²)\n"
|
||
if photoperiod_ok:
|
||
advice_msg += f"• Fotoperiodo sufficiente ({sunshine_hours:.1f}h di sole)\n"
|
||
advice_msg += f"\nIl terreno profondo (9-27cm) si sta asciugando ({soil_moisture_9_27cm*100:.0f}% < capacità di campo). "
|
||
if future_rain_mm < PRECIP_THRESHOLD_SIGNIFICANT:
|
||
advice_msg += "Nessuna pioggia significativa prevista.\n\n"
|
||
else:
|
||
advice_msg += f"Pioggia prevista: {future_rain_mm:.1f}mm nei prossimi giorni.\n\n"
|
||
advice_msg += "**Consigliato**: Primo ciclo di test/attivazione dell'impianto di irrigazione."
|
||
elif not temp_ok:
|
||
advice_level = "NO_ACTION"
|
||
advice_msg = "💤 **DORMI ANCORA**\n\n"
|
||
if soil_temp_6cm is not None:
|
||
advice_msg += f"Trigger termico non soddisfatto: temperatura suolo {soil_temp_6cm:.1f}°C < {SOIL_TEMP_WAKEUP_THRESHOLD}°C (soglia risveglio). "
|
||
else:
|
||
advice_msg += "Temperatura suolo non disponibile. "
|
||
advice_msg += "Le piante sono ancora in riposo vegetativo. Attendi che il terreno si scaldi stabilmente."
|
||
elif not moisture_deep_low:
|
||
advice_level = "NO_ACTION"
|
||
advice_msg = "💤 **DORMI ANCORA**\n\n"
|
||
if soil_moisture_9_27cm is not None:
|
||
advice_msg += f"Terreno profondo (9-27cm) ancora sufficientemente umido ({soil_moisture_9_27cm*100:.0f}%). "
|
||
advice_msg += "Nessuna necessità di irrigazione al momento."
|
||
else:
|
||
advice_level = "NO_ACTION"
|
||
advice_msg = "💤 **DORMI ANCORA**\n\n"
|
||
advice_msg += "Trigger energetici o fotoperiodo non ancora sufficienti. "
|
||
if future_rain_mm >= PRECIP_THRESHOLD_SIGNIFICANT:
|
||
advice_msg += f"Pioggia prevista: {future_rain_mm:.1f}mm nei prossimi giorni. "
|
||
if rainy_days:
|
||
advice_msg += f"Giorni: {', '.join(rainy_days)}.\n\n"
|
||
advice_msg += "Attendi condizioni più favorevoli prima di attivare l'impianto."
|
||
|
||
# Soil status summary
|
||
soil_summary_parts = []
|
||
if soil_temp_6cm is not None:
|
||
soil_summary_parts.append(f"T.Suolo 6cm: {soil_temp_6cm:.1f}°C")
|
||
if soil_moisture_9_27cm is not None:
|
||
soil_summary_parts.append(f"Umidità Radici (9-27cm): {soil_moisture_9_27cm*100:.0f}%")
|
||
soil_status_summary = " | ".join(soil_summary_parts) if soil_summary_parts else "Dati suolo non disponibili"
|
||
|
||
return {
|
||
"season_phase": "AWAKENING",
|
||
"advice_level": advice_level,
|
||
"human_message": advice_msg,
|
||
"soil_status_summary": soil_status_summary,
|
||
"status_display": status
|
||
}
|
||
|
||
|
||
def generate_active_advice(
|
||
soil_moisture_0_1cm: Optional[float],
|
||
soil_moisture_3_9cm: Optional[float],
|
||
soil_moisture_9_27cm: Optional[float],
|
||
soil_moisture_27_81cm: Optional[float], # Riserva profonda (se disponibile)
|
||
future_rain_mm: float,
|
||
rainy_days: List[str],
|
||
et0_avg: Optional[float],
|
||
next_2_days_rain: float,
|
||
vpd_avg: Optional[float] = None
|
||
) -> Dict:
|
||
"""
|
||
FASE ATTIVA: "Quanto irrigare?"
|
||
Analisi stratificata: ignora 0-1cm, monitora "Cuore" (3-9cm e 9-27cm) e "Riserva" (27-81cm)
|
||
Returns: Dict con season_phase, advice_level, human_message, soil_status_summary
|
||
"""
|
||
status = "**Fase: Piena Stagione (Primavera/Estate)**"
|
||
|
||
# Analisi stratificata - ignora fluttuazioni superficiali (0-1cm)
|
||
# Calcola media ponderata del "Cuore" del sistema (3-9cm e 9-27cm)
|
||
heart_moisture = None
|
||
if soil_moisture_3_9cm is not None and soil_moisture_9_27cm is not None:
|
||
# Media ponderata: 9-27cm più importante (60%)
|
||
heart_moisture = 0.4 * soil_moisture_3_9cm + 0.6 * soil_moisture_9_27cm
|
||
elif soil_moisture_9_27cm is not None:
|
||
heart_moisture = soil_moisture_9_27cm
|
||
elif soil_moisture_3_9cm is not None:
|
||
heart_moisture = soil_moisture_3_9cm
|
||
|
||
# Monitora la "Riserva" profonda (27-81cm) - se questa cala, è allarme rosso
|
||
reserve_depleting = False
|
||
if soil_moisture_27_81cm is not None:
|
||
# Se la riserva scende sotto 40%, è critico
|
||
reserve_depleting = soil_moisture_27_81cm < 0.40
|
||
|
||
# Calcola fabbisogno idrico basato su ET₀
|
||
daily_water_demand = et0_avg if et0_avg is not None else 0.0
|
||
estimated_deficit = daily_water_demand * 2.0 # Fabbisogno stimato 2 giorni (approssimativo)
|
||
|
||
# Confronta con precipitazioni previste
|
||
rain_covers_demand = next_2_days_rain > estimated_deficit
|
||
|
||
# LOGIC DECISIONALE - 4 livelli
|
||
|
||
# 🔴 CRITICO (Deep Stress)
|
||
is_critical = False
|
||
if heart_moisture is not None:
|
||
# Umidità 9-27cm vicina al punto di avvizzimento O Riserva in calo
|
||
if (soil_moisture_9_27cm is not None and
|
||
soil_moisture_9_27cm <= SOIL_MOISTURE_DEEP_STRESS):
|
||
is_critical = True
|
||
elif reserve_depleting:
|
||
is_critical = True
|
||
|
||
if is_critical and daily_water_demand > 3.0 and future_rain_mm < PRECIP_THRESHOLD_SIGNIFICANT:
|
||
advice_level = "CRITICAL"
|
||
advice_msg = "🔴 **LIVELLO CRITICO (Deep Stress)**\n\n"
|
||
if soil_moisture_9_27cm is not None and soil_moisture_9_27cm <= SOIL_MOISTURE_DEEP_STRESS:
|
||
advice_msg += f"Umidità profonda (9-27cm) critica: {soil_moisture_9_27cm*100:.0f}% (vicina al punto di avvizzimento). "
|
||
if reserve_depleting:
|
||
advice_msg += f"Riserva profonda (27-81cm) in calo: {soil_moisture_27_81cm*100:.0f}%. "
|
||
advice_msg += f"ET₀ elevato ({daily_water_demand:.1f} mm/d). Nessuna pioggia prevista.\n\n"
|
||
advice_msg += "**Emergenza**: Irrigazione profonda necessaria **subito**."
|
||
|
||
# 🟠 STANDARD (Maintenance)
|
||
elif (heart_moisture is not None and
|
||
heart_moisture < SOIL_MOISTURE_FIELD_CAPACITY * 0.8 and
|
||
(soil_moisture_9_27cm is None or soil_moisture_9_27cm > SOIL_MOISTURE_DEEP_STRESS)):
|
||
advice_level = "STANDARD"
|
||
advice_msg = "🟠 **LIVELLO STANDARD (Maintenance)**\n\n"
|
||
advice_msg += "Umidità superficiale (3-9cm) bassa, ma profonda (9-27cm) ok. "
|
||
if et0_avg is not None:
|
||
advice_msg += f"ET₀ moderato ({et0_avg:.1f} mm/d). "
|
||
if rain_covers_demand:
|
||
advice_msg += f"Pioggia prevista domani/dopodomani ({next_2_days_rain:.1f}mm) dovrebbe coprire il fabbisogno.\n\n"
|
||
advice_msg += "**Consiglio**: Attendi le precipitazioni, poi valuta."
|
||
else:
|
||
advice_msg += "Nessuna pioggia sufficiente prevista a breve.\n\n"
|
||
advice_msg += "**Routine**: Ciclo standard consigliato stasera o domattina."
|
||
|
||
# 🟡 LIGHT (Surface Dry)
|
||
elif (soil_moisture_0_1cm is not None and soil_moisture_0_1cm < 0.5 and
|
||
heart_moisture is not None and heart_moisture >= SOIL_MOISTURE_FIELD_CAPACITY * 0.8):
|
||
advice_level = "LIGHT"
|
||
advice_msg = "🟡 **LIVELLO LIGHT (Surface Dry)**\n\n"
|
||
advice_msg += "Solo strati superficiali (0-3cm) secchi, radici profonde (9-27cm) ok. "
|
||
if future_rain_mm >= PRECIP_THRESHOLD_SIGNIFICANT:
|
||
advice_msg += f"Pioggia prevista: {future_rain_mm:.1f}mm.\n\n"
|
||
advice_msg += "**Opzionale**: Breve rinfrescata o attendi precipitazioni."
|
||
else:
|
||
advice_msg += "\n\n**Opzionale**: Breve rinfrescata superficiale o attendi domani."
|
||
|
||
# 🟢 STOP (Saturated/Rain)
|
||
else:
|
||
advice_level = "NO_ACTION"
|
||
advice_msg = "🟢 **LIVELLO STOP (Saturated/Rain)**\n\n"
|
||
if heart_moisture is not None and heart_moisture >= SOIL_MOISTURE_FIELD_CAPACITY * 0.9:
|
||
advice_msg += "Terreno saturo o molto umido. "
|
||
if rain_covers_demand or future_rain_mm >= PRECIP_THRESHOLD_SIGNIFICANT:
|
||
advice_msg += f"Pioggia prevista ({future_rain_mm:.1f}mm) > fabbisogno calcolato ({estimated_deficit:.1f}mm). "
|
||
if rainy_days:
|
||
advice_msg += f"Giorni: {', '.join(rainy_days)}. "
|
||
advice_msg += "\n\n**Stop**: Non irrigare, ci pensa la natura."
|
||
|
||
# Soil status summary
|
||
soil_summary_parts = []
|
||
if soil_moisture_3_9cm is not None:
|
||
soil_summary_parts.append(f"Umidità 3-9cm: {soil_moisture_3_9cm*100:.0f}%")
|
||
if soil_moisture_9_27cm is not None:
|
||
soil_summary_parts.append(f"Umidità 9-27cm: {soil_moisture_9_27cm*100:.0f}%")
|
||
if soil_moisture_27_81cm is not None:
|
||
soil_summary_parts.append(f"Riserva 27-81cm: {soil_moisture_27_81cm*100:.0f}%")
|
||
if et0_avg is not None:
|
||
soil_summary_parts.append(f"ET₀: {et0_avg:.1f}mm/d")
|
||
soil_status_summary = " | ".join(soil_summary_parts) if soil_summary_parts else "Dati limitati"
|
||
|
||
return {
|
||
"season_phase": "ACTIVE",
|
||
"advice_level": advice_level,
|
||
"human_message": advice_msg,
|
||
"soil_status_summary": soil_status_summary,
|
||
"status_display": status
|
||
}
|
||
|
||
|
||
def generate_shutdown_advice(
|
||
soil_temp_6cm: Optional[float],
|
||
soil_moisture_9_27cm: Optional[float],
|
||
high_moisture_streak: int,
|
||
sunshine_hours: Optional[float] = None,
|
||
shortwave_avg: Optional[float] = None,
|
||
state: Optional[Dict] = None
|
||
) -> Dict:
|
||
"""
|
||
FASE CHIUSURA: "Quando spegnere?"
|
||
Trigger: Crollo Termico + Segnale Luce + Saturazione
|
||
Returns: Dict con season_phase, advice_level, human_message, soil_status_summary
|
||
"""
|
||
state = state or {}
|
||
status = "**Fase: Chiusura Autunnale**"
|
||
|
||
# TRIGGER 1: Crollo Termico - Soil Temperature (6cm) < 10°C stabilmente
|
||
temp_below = False
|
||
if soil_temp_6cm is not None:
|
||
temp_below = soil_temp_6cm < SOIL_TEMP_SHUTDOWN_THRESHOLD
|
||
# Verifica se è stabile (controlla storico)
|
||
temp_history = state.get("soil_temp_history", [])
|
||
now = now_local()
|
||
recent_below_count = 0
|
||
for date_str, temp in temp_history:
|
||
try:
|
||
date_obj = datetime.datetime.fromisoformat(date_str).replace(tzinfo=TZINFO)
|
||
days_ago = (now - date_obj).days
|
||
if days_ago <= 3 and temp < SOIL_TEMP_SHUTDOWN_THRESHOLD:
|
||
recent_below_count += 1
|
||
except Exception:
|
||
continue
|
||
if temp_below:
|
||
recent_below_count += 1
|
||
temp_below = recent_below_count >= 2 # Almeno 2 giorni consecutivi
|
||
|
||
# TRIGGER 2: Segnale Luce - Sunshine Duration in calo drastico
|
||
light_declining = False
|
||
if sunshine_hours is not None:
|
||
# Fotoperiodo sotto 6 ore indica calo drastico (inizio dormienza)
|
||
light_declining = sunshine_hours < 6.0
|
||
|
||
# TRIGGER 3: Saturazione - Soil Moisture (9-27cm) alta costantemente
|
||
saturation_ok = False
|
||
if (soil_moisture_9_27cm is not None and
|
||
soil_moisture_9_27cm >= SOIL_MOISTURE_AUTUMN_HIGH and
|
||
high_moisture_streak >= AUTUMN_HIGH_MOISTURE_DAYS):
|
||
saturation_ok = True
|
||
|
||
# Genera consiglio
|
||
if temp_below or (light_declining and saturation_ok):
|
||
advice_level = "NO_ACTION"
|
||
advice_msg = "❄️ **CHIUDI TUTTO**\n\n"
|
||
if temp_below:
|
||
advice_msg += f"Trigger termico attivo: temperatura suolo {soil_temp_6cm:.1f}°C < {SOIL_TEMP_SHUTDOWN_THRESHOLD}°C stabilmente. "
|
||
if light_declining:
|
||
advice_msg += f"Segnale luce: fotoperiodo in calo drastico ({sunshine_hours:.1f}h di sole). "
|
||
if saturation_ok:
|
||
advice_msg += f"Umidità alta costante ({soil_moisture_9_27cm*100:.0f}%) per {high_moisture_streak} giorni. "
|
||
advice_msg += "\n\nLe piante sono entrate in riposo vegetativo. "
|
||
advice_msg += "**Consiglio**: Puoi svuotare l'impianto di irrigazione per l'inverno. "
|
||
advice_msg += "Il terreno non richiede più irrigazione artificiale."
|
||
else:
|
||
advice_level = "STANDARD"
|
||
advice_msg = "🟡 **MONITORAGGIO CHIUSURA**\n\n"
|
||
advice_msg += "Stagione autunnale avanzata. Monitora attentamente:\n"
|
||
if soil_temp_6cm is not None:
|
||
advice_msg += f"• Temperatura suolo: {soil_temp_6cm:.1f}°C (soglia: {SOIL_TEMP_SHUTDOWN_THRESHOLD}°C)\n"
|
||
if sunshine_hours is not None:
|
||
advice_msg += f"• Fotoperiodo: {sunshine_hours:.1f}h (calo drastico se < 6h)\n"
|
||
if soil_moisture_9_27cm is not None:
|
||
advice_msg += f"• Umidità: {soil_moisture_9_27cm*100:.0f}% (alta se ≥{SOIL_MOISTURE_AUTUMN_HIGH*100}% per {AUTUMN_HIGH_MOISTURE_DAYS} giorni)\n"
|
||
advice_msg += "\n**Consiglio**: Continua il monitoraggio. Lo spegnimento è imminente."
|
||
|
||
# Soil status summary
|
||
soil_summary_parts = []
|
||
if soil_temp_6cm is not None:
|
||
soil_summary_parts.append(f"T.Suolo 6cm: {soil_temp_6cm:.1f}°C")
|
||
if soil_moisture_9_27cm is not None:
|
||
soil_summary_parts.append(f"Umidità 9-27cm: {soil_moisture_9_27cm*100:.0f}%")
|
||
if sunshine_hours is not None:
|
||
soil_summary_parts.append(f"Fotoperiodo: {sunshine_hours:.1f}h")
|
||
soil_status_summary = " | ".join(soil_summary_parts) if soil_summary_parts else "Dati limitati"
|
||
|
||
return {
|
||
"season_phase": "CLOSING",
|
||
"advice_level": advice_level,
|
||
"human_message": advice_msg,
|
||
"soil_status_summary": soil_status_summary,
|
||
"status_display": status
|
||
}
|
||
|
||
|
||
def generate_dormant_advice() -> Dict:
|
||
"""
|
||
FASE DORMIENTE (Inverno)
|
||
Returns: Dict con season_phase, advice_level, human_message, soil_status_summary
|
||
"""
|
||
status = "**Fase: Riposo Invernale**"
|
||
advice_msg = "❄️ **IMPIANTO SPENTO**\n"
|
||
advice_msg += "Stagione invernale. Le piante sono in riposo vegetativo completo.\n"
|
||
advice_msg += "**Consiglio**: L'impianto di irrigazione dovrebbe essere già svuotato e spento. "
|
||
advice_msg += "Nessuna irrigazione necessaria fino alla prossima primavera."
|
||
|
||
return {
|
||
"season_phase": "DORMANT",
|
||
"advice_level": "NO_ACTION",
|
||
"human_message": advice_msg,
|
||
"soil_status_summary": "Dormienza invernale",
|
||
"status_display": status
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# MAIN ANALYSIS
|
||
# =============================================================================
|
||
|
||
def should_send_auto_report(
|
||
phase: str,
|
||
soil_temp_6cm: Optional[float],
|
||
state: Dict,
|
||
force_debug: bool = False
|
||
) -> Tuple[bool, str]:
|
||
"""
|
||
Determina se inviare un report automatico basato su indicatori di fase.
|
||
Returns: (should_send, reason)
|
||
"""
|
||
# In modalità debug, invia sempre
|
||
if force_debug:
|
||
return True, "DEBUG MODE"
|
||
|
||
# Se siamo in fase dormiente e non ci sono indicatori di risveglio, silente
|
||
if phase == "dormant":
|
||
# Controlla se ci sono indicatori di avvicinamento al risveglio
|
||
if soil_temp_6cm is not None and soil_temp_6cm >= SOIL_TEMP_WAKEUP_INDICATOR:
|
||
# Siamo in inverno ma il terreno si sta scaldando -> si avvicina il momento
|
||
if not state.get("wakeup_threshold_reached", False):
|
||
# Prima volta che superiamo l'indicatore -> sblocca report e notifica
|
||
state["wakeup_threshold_reached"] = True
|
||
state["auto_reporting_enabled"] = True # Abilita per monitorare il risveglio
|
||
return True, "TERRENO_IN_RISVEGLIO"
|
||
# Già notificato, ma continuiamo a monitorare se auto-reporting è attivo
|
||
if state.get("auto_reporting_enabled", False):
|
||
return True, "MONITORAGGIO_RISVEGLIO"
|
||
# Anche se è dormiente, se abbiamo già raggiunto la soglia di risveglio, continua
|
||
if state.get("wakeup_threshold_reached", False) and state.get("auto_reporting_enabled", False):
|
||
return True, "POST_RISVEGLIO"
|
||
# Silente
|
||
return False, "DORMANT_SILENT"
|
||
|
||
# Fase wakeup: sempre invia (stiamo monitorando l'attivazione)
|
||
if phase == "wakeup":
|
||
if not state.get("auto_reporting_enabled", False):
|
||
# Prima volta che entriamo in wakeup -> abilita auto-reporting
|
||
state["auto_reporting_enabled"] = True
|
||
state["wakeup_threshold_reached"] = True
|
||
return True, "WAKEUP_ENABLED"
|
||
return True, "WAKEUP_MONITORING"
|
||
|
||
# Fase active: sempre invia (stagione attiva)
|
||
if phase == "active":
|
||
state["auto_reporting_enabled"] = True
|
||
state["wakeup_threshold_reached"] = True
|
||
state["shutdown_confirmed"] = False
|
||
return True, "ACTIVE_SEASON"
|
||
|
||
# Fase shutdown: invia finché non confermiamo la chiusura
|
||
if phase == "shutdown":
|
||
if state.get("shutdown_confirmed", False):
|
||
# Chiusura già confermata -> disabilita auto-reporting
|
||
state["auto_reporting_enabled"] = False
|
||
return False, "SHUTDOWN_CONFIRMED"
|
||
# Prima chiusura -> invia notifica e poi disabilita
|
||
state["shutdown_confirmed"] = True
|
||
state["auto_reporting_enabled"] = False
|
||
return True, "SHUTDOWN_NOTIFICATION"
|
||
|
||
return False, "UNKNOWN_PHASE"
|
||
|
||
|
||
def analyze_irrigation(
|
||
lat: float = DEFAULT_LAT,
|
||
lon: float = DEFAULT_LON,
|
||
location_name: str = DEFAULT_LOCATION_NAME,
|
||
timezone: str = TZ,
|
||
debug_mode: bool = False,
|
||
force_send: bool = False
|
||
) -> Tuple[str, bool]:
|
||
"""
|
||
Analisi principale e generazione report.
|
||
Returns: (report, should_send_auto)
|
||
"""
|
||
"""
|
||
Analisi principale e generazione report.
|
||
"""
|
||
LOGGER.info("=== Analisi Irrigazione per %s ===", location_name)
|
||
|
||
# Carica stato precedente
|
||
state = load_state()
|
||
|
||
# Recupera dati
|
||
data = fetch_soil_and_weather(lat, lon, timezone)
|
||
if not data:
|
||
return ("❌ **ERRORE**\n\nImpossibile recuperare dati meteorologici. Riprova più tardi.", False)
|
||
|
||
hourly = data.get("hourly", {}) or {}
|
||
daily = data.get("daily", {}) or {}
|
||
|
||
# Estrai dati attuali (primi valori)
|
||
times = hourly.get("time", []) or []
|
||
if not times:
|
||
return ("❌ **ERRORE**\n\nNessun dato temporale disponibile.", False)
|
||
|
||
now = now_local()
|
||
current_idx = 0
|
||
for i, t_str in enumerate(times):
|
||
try:
|
||
t = parse_time_to_local(t_str)
|
||
if t >= now:
|
||
current_idx = i
|
||
break
|
||
except Exception:
|
||
continue
|
||
|
||
# Dati suolo ICON Italia (potrebbero essere None)
|
||
soil_temp_0cm_list = hourly.get("soil_temperature_0cm", []) or []
|
||
soil_temp_54cm_list = hourly.get("soil_temperature_54cm", []) or []
|
||
soil_moisture_0_1_list = hourly.get("soil_moisture_0_to_1cm", []) or []
|
||
soil_moisture_81_243_list = hourly.get("soil_moisture_81_to_243cm", []) or []
|
||
precip_list = hourly.get("precipitation", []) or []
|
||
snowfall_list = hourly.get("snowfall", []) or []
|
||
et0_list = hourly.get("et0_fao_evapotranspiration", []) or []
|
||
vpd_list = hourly.get("vapour_pressure_deficit", []) or [] # Stress idrico
|
||
sunshine_list = hourly.get("sunshine_duration", []) or []
|
||
humidity_list = hourly.get("relative_humidity_2m", []) or [] # Umidità relativa aria
|
||
shortwave_rad_list = hourly.get("shortwave_radiation", []) or [] # GHI - Global Horizontal Irradiance
|
||
|
||
# Valori attuali (mappatura: 0cm ≈ 6cm per logica, 54cm ≈ 18cm)
|
||
soil_temp_6cm = None # Usa soil_temp_0cm
|
||
if current_idx < len(soil_temp_0cm_list) and soil_temp_0cm_list[current_idx] is not None:
|
||
soil_temp_6cm = float(soil_temp_0cm_list[current_idx])
|
||
|
||
soil_temp_18cm = None # Usa soil_temp_54cm
|
||
if current_idx < len(soil_temp_54cm_list) and soil_temp_54cm_list[current_idx] is not None:
|
||
soil_temp_18cm = float(soil_temp_54cm_list[current_idx])
|
||
|
||
# Umidità superficiale (0-1cm da ICON, mappata come 3-9cm nella logica)
|
||
soil_moisture_0_1cm = None
|
||
if current_idx < len(soil_moisture_0_1_list) and soil_moisture_0_1_list[current_idx] is not None:
|
||
soil_moisture_0_1cm = float(soil_moisture_0_1_list[current_idx])
|
||
|
||
# Per retrocompatibilità, usa anche come 3-9cm
|
||
soil_moisture_3_9cm = soil_moisture_0_1cm
|
||
|
||
soil_moisture_9_27cm = None # Usa soil_moisture_81_to_243cm (profondo)
|
||
if current_idx < len(soil_moisture_81_243_list) and soil_moisture_81_243_list[current_idx] is not None:
|
||
soil_moisture_9_27cm = float(soil_moisture_81_243_list[current_idx])
|
||
|
||
# Riserva profonda 27-81cm (non disponibile in ICON, potrebbe tornare in estate)
|
||
# ICON fornisce solo 81-243cm, quindi lasciamo None
|
||
soil_moisture_27_81cm = None
|
||
|
||
# Parametri aggiuntivi per calcolo stress idrico
|
||
vpd_avg = None # Vapour Pressure Deficit medio (24h)
|
||
vpd_values = []
|
||
for i in range(current_idx, min(current_idx + 24, len(vpd_list))):
|
||
if i < len(vpd_list) and vpd_list[i] is not None:
|
||
try:
|
||
vpd_values.append(float(vpd_list[i]))
|
||
except Exception:
|
||
continue
|
||
if vpd_values:
|
||
vpd_avg = sum(vpd_values) / len(vpd_values)
|
||
|
||
sunshine_hours = None # Ore di sole previste (24h)
|
||
sunshine_total = 0.0
|
||
for i in range(current_idx, min(current_idx + 24, len(sunshine_list))):
|
||
if i < len(sunshine_list) and sunshine_list[i] is not None:
|
||
try:
|
||
sunshine_total += float(sunshine_list[i])
|
||
except Exception:
|
||
continue
|
||
if sunshine_total > 0:
|
||
sunshine_hours = sunshine_total / 3600.0 # Converti secondi in ore
|
||
|
||
# Umidità relativa aria media (24h)
|
||
humidity_avg = None
|
||
humidity_values = []
|
||
for i in range(current_idx, min(current_idx + 24, len(humidity_list))):
|
||
if i < len(humidity_list) and humidity_list[i] is not None:
|
||
try:
|
||
humidity_values.append(float(humidity_list[i]))
|
||
except Exception:
|
||
continue
|
||
if humidity_values:
|
||
humidity_avg = sum(humidity_values) / len(humidity_values)
|
||
|
||
# Shortwave Radiation GHI media (24h) - energia per fotosintesi
|
||
shortwave_avg = None
|
||
shortwave_values = []
|
||
for i in range(current_idx, min(current_idx + 24, len(shortwave_rad_list))):
|
||
if i < len(shortwave_rad_list) and shortwave_rad_list[i] is not None:
|
||
try:
|
||
shortwave_values.append(float(shortwave_rad_list[i]))
|
||
except Exception:
|
||
continue
|
||
if shortwave_values:
|
||
shortwave_avg = sum(shortwave_values) / len(shortwave_values) # W/m²
|
||
|
||
# ET₀ medio (calcola su prossime 24h)
|
||
et0_avg = None
|
||
et0_values = []
|
||
for i in range(current_idx, min(current_idx + 24, len(et0_list))):
|
||
if i < len(et0_list) and et0_list[i] is not None:
|
||
try:
|
||
et0_values.append(float(et0_list[i]))
|
||
except Exception:
|
||
continue
|
||
if et0_values:
|
||
et0_avg = sum(et0_values) / len(et0_values)
|
||
|
||
# Previsioni pioggia
|
||
future_rain_total, rainy_days = check_future_rainfall(daily, days_ahead=5)
|
||
next_2_days_rain, _ = check_future_rainfall(daily, days_ahead=2)
|
||
|
||
# Determina fase stagionale
|
||
month = now.month
|
||
phase = determine_seasonal_phase(
|
||
month, soil_temp_6cm, soil_moisture_3_9cm, soil_moisture_9_27cm, state
|
||
)
|
||
|
||
# Determina se inviare report automatico
|
||
should_send, reason = should_send_auto_report(phase, soil_temp_6cm, state, force_debug=debug_mode)
|
||
|
||
# Aggiorna stato
|
||
state["phase"] = phase
|
||
state["last_check"] = now.isoformat()
|
||
|
||
# Aggiungi a storico (mantieni ultimi 7 giorni)
|
||
# Usa soil_temp_0cm per storico (mappato come 6cm nella logica)
|
||
today_str = now.date().isoformat()
|
||
state["soil_temp_history"] = [
|
||
(d, t) for d, t in state.get("soil_temp_history", [])
|
||
if (now.date() - datetime.date.fromisoformat(d)).days <= 7
|
||
]
|
||
if soil_temp_6cm is not None: # Questa è già mappata da soil_temp_0cm
|
||
state["soil_temp_history"].append((today_str, soil_temp_6cm))
|
||
|
||
# Aggiorna streak umidità alta
|
||
if (soil_moisture_9_27cm is not None and
|
||
soil_moisture_9_27cm >= SOIL_MOISTURE_AUTUMN_HIGH):
|
||
state["high_moisture_streak"] = state.get("high_moisture_streak", 0) + 1
|
||
else:
|
||
state["high_moisture_streak"] = 0
|
||
|
||
# Genera consiglio in base alla fase (restituisce Dict con JSON structure)
|
||
advice_dict = None
|
||
if phase == "wakeup":
|
||
advice_dict = generate_wakeup_advice(
|
||
soil_temp_6cm, soil_moisture_3_9cm, soil_moisture_9_27cm,
|
||
future_rain_total, rainy_days,
|
||
shortwave_avg=shortwave_avg,
|
||
sunshine_hours=sunshine_hours,
|
||
state=state
|
||
)
|
||
elif phase == "active":
|
||
advice_dict = generate_active_advice(
|
||
soil_moisture_0_1cm=soil_moisture_3_9cm, # Mappa 0-1cm → 3-9cm per retrocompatibilità
|
||
soil_moisture_3_9cm=soil_moisture_3_9cm,
|
||
soil_moisture_9_27cm=soil_moisture_9_27cm,
|
||
soil_moisture_27_81cm=soil_moisture_27_81cm, # None se non disponibile
|
||
future_rain_mm=future_rain_total,
|
||
rainy_days=rainy_days,
|
||
et0_avg=et0_avg,
|
||
next_2_days_rain=next_2_days_rain,
|
||
vpd_avg=vpd_avg
|
||
)
|
||
elif phase == "shutdown":
|
||
advice_dict = generate_shutdown_advice(
|
||
soil_temp_6cm, soil_moisture_9_27cm, state.get("high_moisture_streak", 0),
|
||
sunshine_hours=sunshine_hours,
|
||
shortwave_avg=shortwave_avg,
|
||
state=state
|
||
)
|
||
else: # dormant
|
||
advice_dict = generate_dormant_advice()
|
||
|
||
# Estrai status e advice dal dict per retrocompatibilità con report text
|
||
status = advice_dict.get("status_display", "**Fase: Sconosciuta**")
|
||
advice = advice_dict.get("human_message", "Analisi in corso...")
|
||
|
||
# Il dict contiene anche: season_phase, advice_level, soil_status_summary (per JSON output)
|
||
|
||
# Calcola trend per temperatura e umidità (ultimi 7 giorni dallo storico)
|
||
temp_trend = None
|
||
moisture_trend_3_9 = None
|
||
moisture_trend_9_27 = None
|
||
temp_history = state.get("soil_temp_history", [])
|
||
if len(temp_history) >= 2 and soil_temp_6cm is not None:
|
||
try:
|
||
# Confronta con valore di 7 giorni fa (se disponibile)
|
||
week_ago_date = (now.date() - datetime.timedelta(days=7)).isoformat()
|
||
old_temp = None
|
||
for date_str, temp_val in temp_history:
|
||
if date_str == week_ago_date:
|
||
old_temp = temp_val
|
||
break
|
||
|
||
if old_temp is not None:
|
||
diff = soil_temp_6cm - old_temp
|
||
if abs(diff) > 0.1:
|
||
temp_trend = f"{diff:+.1f}°C" if diff > 0 else f"{diff:.1f}°C"
|
||
except Exception:
|
||
pass
|
||
|
||
# Costruisci report completo (senza righe vuote eccessive)
|
||
report_parts = [
|
||
f"{status}\n",
|
||
f"📍 {location_name}\n",
|
||
f"📅 {now.strftime('%d/%m/%Y %H:%M')}\n",
|
||
"="*25 + "\n",
|
||
advice
|
||
]
|
||
|
||
# Aggiungi dettagli tecnici (se disponibili)
|
||
details = []
|
||
|
||
# Temperatura suolo con trend
|
||
temp_found = False
|
||
if soil_temp_6cm is not None:
|
||
temp_class = classify_soil_temp(soil_temp_6cm)
|
||
temp_str = f"🌡️ T° suolo (0cm): {soil_temp_6cm:.1f}°C ({temp_class})"
|
||
if temp_trend:
|
||
temp_str += f" | trend 7gg: {temp_trend}"
|
||
details.append(temp_str)
|
||
temp_found = True
|
||
else:
|
||
# Prova a vedere se c'è un valore futuro nella lista (ICON: 0cm)
|
||
for i in range(current_idx, min(current_idx + 48, len(soil_temp_0cm_list))):
|
||
if i < len(soil_temp_0cm_list) and soil_temp_0cm_list[i] is not None:
|
||
temp_val = float(soil_temp_0cm_list[i])
|
||
details.append(f"🌡️ T° suolo (0cm): {temp_val:.1f}°C (prossime ore)")
|
||
temp_found = True
|
||
break
|
||
|
||
if soil_temp_18cm is not None:
|
||
temp_class_54 = classify_soil_temp(soil_temp_18cm)
|
||
temp_str = f"🌡️ T° suolo (54cm): {soil_temp_18cm:.1f}°C ({temp_class_54})"
|
||
details.append(temp_str)
|
||
temp_found = True
|
||
else:
|
||
# Prova valore futuro (ICON: 54cm)
|
||
for i in range(current_idx, min(current_idx + 48, len(soil_temp_54cm_list))):
|
||
if i < len(soil_temp_54cm_list) and soil_temp_54cm_list[i] is not None:
|
||
temp_val = float(soil_temp_54cm_list[i])
|
||
details.append(f"🌡️ T° suolo (54cm): {temp_val:.1f}°C (prossime ore)")
|
||
temp_found = True
|
||
break
|
||
|
||
# Umidità suolo (ICON: 0-1cm e 81-243cm)
|
||
moisture_found = False
|
||
if soil_moisture_3_9cm is not None:
|
||
moisture_class = classify_soil_moisture(soil_moisture_3_9cm)
|
||
details.append(f"💧 Umidità (0-1cm): {soil_moisture_3_9cm*100:.0f}% ({moisture_class})")
|
||
moisture_found = True
|
||
else:
|
||
# Prova valore futuro
|
||
for i in range(current_idx, min(current_idx + 48, len(soil_moisture_0_1_list))):
|
||
if i < len(soil_moisture_0_1_list) and soil_moisture_0_1_list[i] is not None:
|
||
moisture_val = float(soil_moisture_0_1_list[i])
|
||
details.append(f"💧 Umidità (0-1cm): {moisture_val*100:.0f}% (prossime ore)")
|
||
moisture_found = True
|
||
break
|
||
|
||
if soil_moisture_9_27cm is not None:
|
||
moisture_class_deep = classify_soil_moisture(soil_moisture_9_27cm)
|
||
details.append(f"💧 Umidità (81-243cm): {soil_moisture_9_27cm*100:.0f}% ({moisture_class_deep})")
|
||
moisture_found = True
|
||
else:
|
||
# Prova valore futuro
|
||
for i in range(current_idx, min(current_idx + 48, len(soil_moisture_81_243_list))):
|
||
if i < len(soil_moisture_81_243_list) and soil_moisture_81_243_list[i] is not None:
|
||
moisture_val = float(soil_moisture_81_243_list[i])
|
||
details.append(f"💧 Umidità (81-243cm): {moisture_val*100:.0f}% (prossime ore)")
|
||
moisture_found = True
|
||
break
|
||
|
||
# Messaggio informativo se dati suolo non disponibili
|
||
if not temp_found and not moisture_found:
|
||
details.append("ℹ️ Dati suolo non disponibili per questa località")
|
||
|
||
# ET₀ e parametri evapotraspirazione
|
||
if et0_avg is not None:
|
||
et0_class = classify_et0(et0_avg)
|
||
details.append(f"☀️ ET₀ medio (24h): {et0_avg:.1f} mm/d ({et0_class})")
|
||
|
||
# Vapour Pressure Deficit (stress idrico)
|
||
if vpd_avg is not None:
|
||
vpd_class = classify_vpd(vpd_avg)
|
||
# VPD alto = stress idrico alto
|
||
vpd_status = ""
|
||
if vpd_avg > 1.5:
|
||
vpd_status = " (stress idrico elevato)"
|
||
elif vpd_avg > 1.0:
|
||
vpd_status = " (stress moderato)"
|
||
details.append(f"💨 VPD medio (24h): {vpd_avg:.2f} kPa ({vpd_class}){vpd_status}")
|
||
|
||
# Ore di sole previste
|
||
if sunshine_hours is not None:
|
||
sunshine_class = classify_sunshine(sunshine_hours)
|
||
details.append(f"☀️ Ore sole previste (24h): {sunshine_hours:.1f}h ({sunshine_class})")
|
||
|
||
# Umidità relativa aria
|
||
if humidity_avg is not None:
|
||
# Classifica umidità relativa (bassa < 40%, media 40-70%, alta > 70%)
|
||
if humidity_avg < 40:
|
||
humidity_class = "basso (secco)"
|
||
elif humidity_avg < 70:
|
||
humidity_class = "medio"
|
||
else:
|
||
humidity_class = "alto (umido)"
|
||
details.append(f"💨 Umidità relativa aria (24h): {humidity_avg:.0f}% ({humidity_class})")
|
||
|
||
# Precipitazioni previste (include neve)
|
||
if future_rain_total > 0:
|
||
# Classifica come totale su 5 giorni (media giornaliera approssimativa)
|
||
avg_daily = future_rain_total / 5.0
|
||
precip_class = classify_precip_daily(avg_daily)
|
||
precip_str = f"🌧️ Precipitazioni previste (5gg): {future_rain_total:.1f}mm ({precip_class}, media ~{avg_daily:.1f}mm/giorno)"
|
||
if rainy_days:
|
||
precip_str += f"\n Giorni: {', '.join(rainy_days[:3])}" # Primi 3 giorni
|
||
if len(rainy_days) > 3:
|
||
precip_str += f" +{len(rainy_days)-3} altri"
|
||
details.append(precip_str)
|
||
elif len(rainy_days) == 0:
|
||
details.append("🌧️ Precipitazioni previste (5gg): 0mm (basso)")
|
||
|
||
if details:
|
||
report_parts.append("─"*25 + "\n")
|
||
report_parts.append("**Dettagli Tecnici:**\n")
|
||
report_parts.append("\n".join(details))
|
||
|
||
# Salva stato
|
||
save_state(state)
|
||
|
||
report = "\n".join(report_parts)
|
||
LOGGER.info("Analisi completata. Fase: %s, Auto-send: %s (%s)", phase, should_send, reason)
|
||
|
||
return report, should_send if not force_send else True
|
||
|
||
|
||
# =============================================================================
|
||
# TELEGRAM INTEGRATION (Optional)
|
||
# =============================================================================
|
||
|
||
def telegram_send_markdown(message: str, chat_ids: Optional[List[str]] = None) -> bool:
|
||
"""Invia messaggio Telegram in formato Markdown."""
|
||
token = load_bot_token()
|
||
if not token:
|
||
LOGGER.warning("Telegram token missing: message not sent.")
|
||
return False
|
||
|
||
if chat_ids is None:
|
||
chat_ids = TELEGRAM_CHAT_IDS
|
||
|
||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||
base_payload = {
|
||
"text": message,
|
||
"parse_mode": "Markdown",
|
||
"disable_web_page_preview": True,
|
||
}
|
||
|
||
sent_ok = False
|
||
import time
|
||
with requests.Session() as s:
|
||
for chat_id in chat_ids:
|
||
payload = dict(base_payload)
|
||
payload["chat_id"] = chat_id
|
||
try:
|
||
resp = s.post(url, json=payload, timeout=15)
|
||
if resp.status_code == 200:
|
||
sent_ok = True
|
||
else:
|
||
LOGGER.error("Telegram error chat_id=%s status=%s body=%s",
|
||
chat_id, resp.status_code, resp.text[:500])
|
||
time.sleep(0.25)
|
||
except Exception as e:
|
||
LOGGER.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
|
||
|
||
return sent_ok
|
||
|
||
|
||
# =============================================================================
|
||
# MAIN
|
||
# =============================================================================
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="Smart Irrigation Advisor - Consulente Agronomico"
|
||
)
|
||
parser.add_argument("--lat", type=float, help="Latitudine (default: Casa)")
|
||
parser.add_argument("--lon", type=float, help="Longitudine (default: Casa)")
|
||
parser.add_argument("--location", help="Nome località (default: Casa)")
|
||
parser.add_argument("--timezone", help="Timezone IANA (default: Europe/Berlin)")
|
||
parser.add_argument("--telegram", action="store_true", help="Invia report via Telegram (solo se auto-reporting attivo o --force)")
|
||
parser.add_argument("--force", action="store_true", help="Forza invio anche se auto-reporting disabilitato")
|
||
parser.add_argument("--chat_id", help="Chat ID Telegram specifico (opzionale)")
|
||
parser.add_argument("--debug", action="store_true", help="Modalità debug (invia sempre e bypassa controlli)")
|
||
parser.add_argument("--auto", action="store_true", help="Modalità automatica (usa logica auto-reporting, invia via Telegram se attivo)")
|
||
|
||
args = parser.parse_args()
|
||
|
||
if args.debug:
|
||
global DEBUG
|
||
DEBUG = True
|
||
LOGGER.setLevel(logging.DEBUG)
|
||
|
||
lat = args.lat if args.lat is not None else DEFAULT_LAT
|
||
lon = args.lon if args.lon is not None else DEFAULT_LON
|
||
location = args.location if args.location else DEFAULT_LOCATION_NAME
|
||
timezone = args.timezone if args.timezone else TZ
|
||
|
||
# Determina modalità operativa
|
||
force_send = args.force or args.debug
|
||
use_auto_logic = args.auto or (not args.telegram and not args.force)
|
||
|
||
# Genera report
|
||
report, should_send_auto = analyze_irrigation(
|
||
lat, lon, location, timezone,
|
||
debug_mode=args.debug,
|
||
force_send=force_send
|
||
)
|
||
|
||
# Output
|
||
send_to_telegram = False
|
||
|
||
if args.auto:
|
||
# Modalità automatica (cron): usa logica auto-reporting
|
||
if should_send_auto:
|
||
send_to_telegram = True
|
||
LOGGER.info("Auto-reporting attivo: invio via Telegram")
|
||
else:
|
||
LOGGER.info("Auto-reporting disabilitato: report non inviato (fase: %s)",
|
||
load_state().get("phase", "unknown"))
|
||
# In modalità auto, se non inviamo, non stampiamo neanche
|
||
if not args.debug:
|
||
return
|
||
|
||
elif args.telegram:
|
||
# Modalità manuale (chiamata da Telegram): SEMPRE invia se --telegram è presente
|
||
# La logica auto-reporting si applica solo a cron (--auto)
|
||
send_to_telegram = True
|
||
if force_send:
|
||
LOGGER.info("Chiamata manuale da Telegram con --force: invio forzato")
|
||
elif should_send_auto:
|
||
LOGGER.info("Chiamata manuale da Telegram: invio (auto-reporting attivo)")
|
||
else:
|
||
LOGGER.info("Chiamata manuale da Telegram: invio (bypass auto-reporting)")
|
||
|
||
if send_to_telegram:
|
||
chat_ids = None
|
||
if args.chat_id:
|
||
chat_ids = [args.chat_id.strip()]
|
||
success = telegram_send_markdown(report, chat_ids=chat_ids)
|
||
if not success:
|
||
print(report) # Fallback su stdout
|
||
LOGGER.error("Errore invio Telegram, stampato su stdout")
|
||
else:
|
||
# Stampa sempre su stdout se non in modalità auto e non Telegram
|
||
print(report)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|