Backup automatico script del 2026-02-22 07:00
This commit is contained in:
@@ -18,6 +18,7 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from statistics import median as _median
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -42,11 +43,22 @@ TZINFO = ZoneInfo(TZ)
|
||||
|
||||
# Open-Meteo: due fonti
|
||||
# - Suolo (tutti i layer): ICON Seamless (DWD) - copertura Europa centrale
|
||||
# - Meteo (ET₀, precipitazioni, T°): ICON Italia - risoluzione spaziale migliore per Italia/San Marino
|
||||
# - Meteo (ET₀, precipitazioni, T°): analisi a tre modelli con mediana (vedi OPEN_METEO_MODELS.md)
|
||||
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
MODEL_SOIL = "icon_seamless" # Dati suolo (0-1, 1-3, 3-9, 9-27, 27-81 cm) e T° suolo; forecast_days=8
|
||||
MODEL_WEATHER = "italia_meteo_arpae_icon_2i" # ET₀, precipitazioni, temperatura, radiazione
|
||||
MODEL_WEATHER = "italia_meteo_arpae_icon_2i" # Retrocompatibilità / primo modello
|
||||
MODEL_ICON = MODEL_WEATHER # Retrocompatibilità
|
||||
# Tre modelli per mediana (Europa/Italia: ICON Italia + ECMWF IFS + ARPEGE/Météo-France; ARPEGE preferito a GFS)
|
||||
WEATHER_MODELS_THREE = [
|
||||
"italia_meteo_arpae_icon_2i", # ~3 d utili, 2 km Italia/SM
|
||||
"ecmwf_ifs", # 15 d, ~9 km
|
||||
"meteofrance_seamless", # ARPEGE+AROME, 4 d, 0.1° Europa
|
||||
]
|
||||
WEATHER_MODELS_FORECAST_DAYS = {
|
||||
"italia_meteo_arpae_icon_2i": 10,
|
||||
"ecmwf_ifs": 10,
|
||||
"meteofrance_seamless": 4,
|
||||
}
|
||||
HTTP_HEADERS = {"User-Agent": "Smart-Irrigation-Advisor/2.0"}
|
||||
|
||||
# Files
|
||||
@@ -372,6 +384,176 @@ def fetch_weather_icon_italia(lat: float, lon: float, timezone: str = TZ) -> Opt
|
||||
return None
|
||||
|
||||
|
||||
def _weather_params_common(lat: float, lon: float, timezone: str) -> Dict:
|
||||
"""Parametri comuni hourly/daily per fetch meteo (ET₀, precipitazioni, ecc.)."""
|
||||
return {
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"timezone": timezone,
|
||||
"hourly": ",".join([
|
||||
"precipitation",
|
||||
"snowfall",
|
||||
"temperature_2m",
|
||||
"relative_humidity_2m",
|
||||
"et0_fao_evapotranspiration",
|
||||
"vapour_pressure_deficit",
|
||||
"direct_radiation",
|
||||
"diffuse_radiation",
|
||||
"shortwave_radiation",
|
||||
"sunshine_duration",
|
||||
]),
|
||||
"daily": ",".join([
|
||||
"precipitation_sum",
|
||||
"snowfall_sum",
|
||||
"et0_fao_evapotranspiration_sum",
|
||||
"sunshine_duration",
|
||||
]),
|
||||
}
|
||||
|
||||
|
||||
def fetch_weather_single_model(
|
||||
lat: float, lon: float, timezone: str, model: str, forecast_days: int = 10
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Recupera dati meteo per un singolo modello Open-Meteo (stessa struttura di fetch_weather_icon_italia).
|
||||
"""
|
||||
params = _weather_params_common(lat, lon, timezone)
|
||||
params["forecast_days"] = forecast_days
|
||||
params["models"] = model
|
||||
try:
|
||||
r = open_meteo_get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
LOGGER.debug("Open-Meteo single model %s error: %s", model, e)
|
||||
return None
|
||||
|
||||
|
||||
def _median_or_single(values: List[Optional[float]]) -> Optional[float]:
|
||||
"""Mediana dei valori numerici; ignora None. Se nessun valore valido, ritorna None."""
|
||||
nums = [float(v) for v in values if v is not None]
|
||||
if not nums:
|
||||
return None
|
||||
if len(nums) == 1:
|
||||
return nums[0]
|
||||
return _median(nums)
|
||||
|
||||
|
||||
def _merge_daily_three_models_median(daily_list: List[Dict]) -> Dict:
|
||||
"""
|
||||
Unisce i daily di più risposte meteo: per ogni data (unione di tutte) calcola la mediana
|
||||
di et0_fao_evapotranspiration_sum, precipitation_sum, snowfall_sum, sunshine_duration.
|
||||
"""
|
||||
time_idx: Dict[str, int] = {}
|
||||
all_times: List[str] = []
|
||||
for d in daily_list:
|
||||
times = d.get("time", []) or []
|
||||
for t in times:
|
||||
key = str(t)[:10] if t else ""
|
||||
if key and key not in time_idx:
|
||||
time_idx[key] = len(all_times)
|
||||
all_times.append(key)
|
||||
if not all_times:
|
||||
return {"time": [], "et0_fao_evapotranspiration_sum": [], "precipitation_sum": [], "snowfall_sum": [], "sunshine_duration": []}
|
||||
# Per ogni data, indice in ogni daily
|
||||
daily_keys = ["et0_fao_evapotranspiration_sum", "precipitation_sum", "snowfall_sum", "sunshine_duration"]
|
||||
out: Dict[str, List] = {k: [] for k in daily_keys}
|
||||
out["time"] = all_times
|
||||
for date_str in all_times:
|
||||
for key in daily_keys:
|
||||
vals = []
|
||||
for d in daily_list:
|
||||
times = d.get("time", []) or []
|
||||
arr = d.get(key, []) or []
|
||||
for i, t in enumerate(times):
|
||||
if str(t)[:10] == date_str and i < len(arr) and arr[i] is not None:
|
||||
try:
|
||||
vals.append(float(arr[i]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
break
|
||||
out[key].append(_median_or_single(vals) if vals else None)
|
||||
return out
|
||||
|
||||
|
||||
def _merge_hourly_three_models_median(hourly_list: List[Dict]) -> Dict:
|
||||
"""
|
||||
Unisce gli hourly di più risposte: per ogni timestamp (unione) calcola la mediana
|
||||
per ogni variabile numerica. Usa il primo dizionario per la lista dei nomi chiave.
|
||||
"""
|
||||
time_idx: Dict[str, int] = {}
|
||||
all_times: List[str] = []
|
||||
for h in hourly_list:
|
||||
times = h.get("time", []) or []
|
||||
for t in times:
|
||||
k = _normalize_time_key(str(t)) if t else ""
|
||||
if k and k not in time_idx:
|
||||
time_idx[k] = len(all_times)
|
||||
all_times.append(t if isinstance(t, str) else k)
|
||||
if not all_times:
|
||||
keys = [k for k in (list(hourly_list[0].keys()) if hourly_list else []) if k != "time"]
|
||||
return {"time": [], **{k: [] for k in keys}}
|
||||
keys = [k for k in (list(hourly_list[0].keys()) if hourly_list else []) if k != "time"]
|
||||
out: Dict[str, List] = {"time": all_times}
|
||||
for key in keys:
|
||||
out[key] = []
|
||||
for ref_t in all_times:
|
||||
ref_k = _normalize_time_key(str(ref_t))
|
||||
vals = []
|
||||
for h in hourly_list:
|
||||
times = h.get("time", []) or []
|
||||
arr = h.get(key, []) or []
|
||||
for i, t in enumerate(times):
|
||||
if _normalize_time_key(str(t)) == ref_k and i < len(arr) and arr[i] is not None:
|
||||
try:
|
||||
vals.append(float(arr[i]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
break
|
||||
out[key].append(_median_or_single(vals) if vals else None)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_weather_three_models(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]:
|
||||
"""
|
||||
Recupera meteo da tre modelli (ICON Italia, ECMWF IFS, ARPEGE/Météo-France) e restituisce
|
||||
un unico payload con daily e hourly ottenuti dalla mediana per ogni giorno/ora.
|
||||
Vedi OPEN_METEO_MODELS.md per la motivazione (ARPEGE preferito a GFS per Europa/Italia).
|
||||
"""
|
||||
daily_list: List[Dict] = []
|
||||
hourly_list: List[Dict] = []
|
||||
meta = None
|
||||
for model in WEATHER_MODELS_THREE:
|
||||
fd = WEATHER_MODELS_FORECAST_DAYS.get(model, 10)
|
||||
data = fetch_weather_single_model(lat, lon, timezone, model, forecast_days=fd)
|
||||
if not data:
|
||||
continue
|
||||
if meta is None:
|
||||
meta = {
|
||||
"latitude": data.get("latitude"),
|
||||
"longitude": data.get("longitude"),
|
||||
"timezone": data.get("timezone"),
|
||||
}
|
||||
d = data.get("daily", {}) or {}
|
||||
h = data.get("hourly", {}) or {}
|
||||
if d.get("time"):
|
||||
daily_list.append(d)
|
||||
if h.get("time"):
|
||||
hourly_list.append(h)
|
||||
if not daily_list or meta is None:
|
||||
LOGGER.warning("Three-model weather: no valid responses; fallback to single ICON Italia.")
|
||||
return fetch_weather_icon_italia(lat, lon, timezone)
|
||||
merged_daily = _merge_daily_three_models_median(daily_list)
|
||||
merged_hourly = _merge_hourly_three_models_median(hourly_list) if hourly_list else {}
|
||||
return {
|
||||
"latitude": meta["latitude"],
|
||||
"longitude": meta["longitude"],
|
||||
"timezone": meta["timezone"],
|
||||
"hourly": merged_hourly,
|
||||
"daily": merged_daily,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_time_key(t: str) -> str:
|
||||
"""Normalizza timestamp per confronto (es. '2026-02-05T16:00' e '2026-02-05T16:00:00' → stesso key)."""
|
||||
if not t or not isinstance(t, str):
|
||||
@@ -437,11 +619,12 @@ def _merge_hourly_by_time(soil_hourly: Dict, weather_hourly: Dict, weather_daily
|
||||
|
||||
def fetch_soil_and_weather(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]:
|
||||
"""
|
||||
Recupera dati combinati: suolo da ICON Seamless (tutti i layer), meteo da ICON Italia.
|
||||
Recupera dati combinati: suolo da ICON Seamless (tutti i layer), meteo da analisi
|
||||
a tre modelli (ICON Italia + ECMWF IFS + ARPEGE) con mediana di ET₀ e precipitazioni.
|
||||
In caso di fallimento suolo, prova fallback con singola fonte (solo ICON Italia).
|
||||
"""
|
||||
soil_data = fetch_soil_icon_seamless(lat, lon, timezone)
|
||||
weather_data = fetch_weather_icon_italia(lat, lon, timezone)
|
||||
weather_data = fetch_weather_three_models(lat, lon, timezone)
|
||||
if not weather_data:
|
||||
return None
|
||||
hourly_w = weather_data.get("hourly", {}) or {}
|
||||
@@ -506,8 +689,8 @@ def fetch_soil_and_weather_fallback(lat: float, lon: float, timezone: str = TZ)
|
||||
|
||||
|
||||
def fetch_weather_only(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]:
|
||||
"""Recupera solo dati meteo (senza suolo)."""
|
||||
return fetch_weather_icon_italia(lat, lon, timezone)
|
||||
"""Recupera solo dati meteo (senza suolo): analisi a tre modelli con mediana."""
|
||||
return fetch_weather_three_models(lat, lon, timezone)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1353,6 +1536,15 @@ def build_irrigation_chart_bytes(
|
||||
ax1.plot(x, m27, "^-", color="C3", label="Umidità 27-81cm", markersize=4)
|
||||
ax1.axhline(y=SOIL_MOISTURE_DEEP_STRESS * 100, color="gray", linestyle="--", alpha=0.7, label=f"Trigger {SOIL_MOISTURE_DEEP_STRESS*100:.0f}%")
|
||||
ax1.axhline(y=SOIL_MOISTURE_WILTING_POINT * 100, color="brown", linestyle="--", alpha=0.7, label=f"Appassimento {SOIL_MOISTURE_WILTING_POINT*100:.0f}%")
|
||||
# Linea verticale "oggi"
|
||||
today_iso = now.date().isoformat()
|
||||
now_idx = None
|
||||
for i, d in enumerate(dates):
|
||||
if d and str(d).startswith(today_iso[:10]):
|
||||
now_idx = i
|
||||
break
|
||||
if now_idx is not None:
|
||||
ax1.axvline(x=now_idx, color="red", linewidth=1, linestyle="-", alpha=0.9)
|
||||
ax1.legend(loc="upper right", fontsize=7)
|
||||
ax1.grid(True, alpha=0.3)
|
||||
ax1.set_ylim(bottom=0)
|
||||
@@ -1362,6 +1554,8 @@ def build_irrigation_chart_bytes(
|
||||
precip_vals = [float(p) if p is not None else 0.0 for p in precip_list]
|
||||
ax2.bar([i - 0.2 for i in x], et0_vals, 0.35, label="ET₀", color="C0", alpha=0.8)
|
||||
ax2.bar([i + 0.2 for i in x], precip_vals, 0.35, label="Precip", color="C1", alpha=0.8)
|
||||
if now_idx is not None:
|
||||
ax2.axvline(x=now_idx, color="red", linewidth=1, linestyle="-", alpha=0.9)
|
||||
ax2.legend(loc="upper right", fontsize=7)
|
||||
ax2.grid(True, alpha=0.3)
|
||||
ax2.set_ylim(bottom=0)
|
||||
@@ -1828,13 +2022,11 @@ def analyze_irrigation(
|
||||
if rain_veto:
|
||||
veto_lines.append(f"🌧️ **VETO PIOGGIA**: Ultime 24h ≥ {PRECIP_VETO_MM_24H:.0f} mm — non avviare irrigazione.")
|
||||
|
||||
# Colpo d'occhio
|
||||
# Colpo d'occhio (umidità e prossimi 8 gg sono nel grafico)
|
||||
glance = [
|
||||
status.strip(),
|
||||
f"📍 {location_name} · {now.strftime('%d/%m/%Y %H:%M')}",
|
||||
"",
|
||||
moisture_summary_line.strip(),
|
||||
"",
|
||||
]
|
||||
if veto_lines:
|
||||
glance.append("**Veti**")
|
||||
@@ -1854,66 +2046,6 @@ def analyze_irrigation(
|
||||
]
|
||||
if timing_advice:
|
||||
report_parts.append("**Orario** " + " · ".join(timing_advice))
|
||||
report_parts.append("")
|
||||
if planning_8d_line:
|
||||
report_parts.append(planning_8d_line.strip())
|
||||
report_parts.append("")
|
||||
report_parts.append("─"*24)
|
||||
|
||||
# Dettagli tecnici (compatti, at a glance)
|
||||
details = []
|
||||
soil_temp_0cm = _at(current_idx, soil_temp_0cm_list)
|
||||
soil_temp_54cm = _at(current_idx, soil_temp_54cm_list)
|
||||
temp_parts = []
|
||||
for label, val in [("0cm", soil_temp_0cm), ("6cm", soil_temp_6cm), ("18cm", soil_temp_18cm), ("54cm", soil_temp_54cm)]:
|
||||
if val is not None:
|
||||
temp_parts.append(f"{label} {val:.1f}°C")
|
||||
if temp_parts:
|
||||
trend_str = f" · trend 7gg: {temp_trend}" if temp_trend else ""
|
||||
details.append("🌡️ T° suolo: " + " · ".join(temp_parts) + trend_str)
|
||||
elif soil_temp_0cm_list and current_idx < len(soil_temp_0cm_list) and soil_temp_0cm_list[current_idx] is not None:
|
||||
details.append(f"🌡️ T° suolo 0cm: {float(soil_temp_0cm_list[current_idx]):.1f}°C")
|
||||
|
||||
moist_parts = []
|
||||
any_at_fc = False
|
||||
for label, val in [("0-1", soil_moisture_0_1cm), ("3-9", soil_moisture_3_9cm), ("9-27", soil_moisture_9_27cm), ("27-81", soil_moisture_27_81cm)]:
|
||||
if val is not None:
|
||||
moist_parts.append(f"{label} {val*100:.0f}%")
|
||||
if val >= SOIL_MOISTURE_FIELD_CAPACITY:
|
||||
any_at_fc = True
|
||||
else:
|
||||
moist_parts.append(f"{label} —")
|
||||
if moist_parts:
|
||||
line = "💧 Umidità: " + " · ".join(moist_parts)
|
||||
if any_at_fc:
|
||||
line += " — terreno pieno"
|
||||
details.append(line)
|
||||
if not details:
|
||||
details.append("ℹ️ Dati suolo non disponibili")
|
||||
|
||||
# Una riga: ET₀, VPD, sole, umidità aria
|
||||
meteo_parts = []
|
||||
if et0_avg is not None:
|
||||
meteo_parts.append(f"ET₀ {et0_avg:.1f} mm/d")
|
||||
if vpd_avg is not None:
|
||||
meteo_parts.append(f"VPD {vpd_avg:.2f} kPa")
|
||||
if sunshine_hours is not None:
|
||||
meteo_parts.append(f"Sole {sunshine_hours:.1f}h")
|
||||
if humidity_avg is not None:
|
||||
meteo_parts.append(f"UR {humidity_avg:.0f}%")
|
||||
if meteo_parts:
|
||||
details.append("☀️ " + " · ".join(meteo_parts))
|
||||
|
||||
# Precipitazioni: una riga
|
||||
if future_rain_total > 0:
|
||||
days_short = ", ".join(rainy_days[:3]) if rainy_days else ""
|
||||
details.append(f"🌧️ Precip 5gg: {future_rain_total:.1f} mm — {days_short}")
|
||||
else:
|
||||
details.append("🌧️ Precip 5gg: 0 mm")
|
||||
|
||||
if details:
|
||||
report_parts.append("**Dettagli**")
|
||||
report_parts.append("\n".join(details))
|
||||
|
||||
# Salva stato
|
||||
save_state(state)
|
||||
|
||||
Reference in New Issue
Block a user