Backup automatico script del 2026-02-22 07:00

This commit is contained in:
2026-02-22 07:00:03 +01:00
parent 11b6768fa3
commit c25c309a15
3 changed files with 499 additions and 250 deletions
+1
View File
@@ -16,6 +16,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_PATTERNS = ["*.log", "*_log.txt"] DEFAULT_PATTERNS = ["*.log", "*_log.txt"]
EXCLUDED_FILES = { EXCLUDED_FILES = {
"circondario_log.txt", "circondario_log.txt",
"irrigation_cron.log",
"road_weather.log", "road_weather.log",
"snow_radar.log", "snow_radar.log",
} }
+296 -180
View File
@@ -55,11 +55,18 @@ MODEL_NAMES = {
"italia_meteo_arpae_icon_2i": "ICON Italia (ARPAE 2i)" "italia_meteo_arpae_icon_2i": "ICON Italia (ARPAE 2i)"
} }
# Per Casa/Italia: forecast_days per modello lungo termine (come Agent Irrigazione / OPEN_METEO_MODELS.md)
LONG_TERM_FORECAST_DAYS = {
"italia_meteo_arpae_icon_2i": 10,
"ecmwf_ifs": 10,
"meteofrance_seamless": 4,
}
def choose_models_by_country(cc, is_home=False): def choose_models_by_country(cc, is_home=False):
""" """
Seleziona modelli meteo ottimali. Seleziona modelli meteo ottimali.
- Per Casa e Italia: solo ICON Italia (ARPAE 2i); AROME HD non copre San Marino. - Per Casa e Italia: 0-2d mediana ICON Italia + AROME HD; 3-10d mediana ICON Italia + ECMWF IFS + ARPEGE.
- Per altre località: usa best match di Open-Meteo (senza specificare models). - Per altre località: best match Open-Meteo.
Ritorna (short_term_models, long_term_models) Ritorna (short_term_models, long_term_models)
""" """
cc = cc.upper() if cc else "UNKNOWN" cc = cc.upper() if cc else "UNKNOWN"
@@ -67,8 +74,11 @@ def choose_models_by_country(cc, is_home=False):
long_term_default = ["gfs_global", "ecmwf_ifs04"] long_term_default = ["gfs_global", "ecmwf_ifs04"]
if is_home or cc == "IT": if is_home or cc == "IT":
# ICON Italia (072h) + ECMWF IFS per i giorni successivi (dove Icon Italia non arriva) # 0-2d: due modelli ad alta risoluzione (mediana). 3-10d: tre modelli (mediana, come Irrigazione).
return ["italia_meteo_arpae_icon_2i"], ["ecmwf_ifs"] return (
["italia_meteo_arpae_icon_2i", "meteofrance_arome_france_hd"],
["italia_meteo_arpae_icon_2i", "ecmwf_ifs", "meteofrance_seamless"],
)
else: else:
return None, long_term_default return None, long_term_default
@@ -153,22 +163,28 @@ def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forec
except: except:
results["best_match"] = None results["best_match"] = None
else: else:
# Modelli specifici (per Casa: AROME + ICON, per Italia: ICON ARPAE) # Modelli specifici: 0-2d ICON Italia + AROME HD (mediana); AROME HD solo 2 giorni
for model in short_term_models: for model in short_term_models:
url = "https://api.open-meteo.com/v1/forecast" url = "https://api.open-meteo.com/v1/forecast"
# ICON Italia (ARPAE 2i): parametri come da API, senza precipitation_probability
if model == "italia_meteo_arpae_icon_2i": if model == "italia_meteo_arpae_icon_2i":
hourly_params = "rain,showers,snowfall,snow_depth,precipitation,temperature_2m,weathercode,windspeed_10m,winddirection_10m" hourly_params = "rain,showers,snowfall,snow_depth,precipitation,temperature_2m,weathercode,windspeed_10m,winddirection_10m"
daily_params = "temperature_2m_max,temperature_2m_min,showers_sum,rain_sum,snowfall_sum,precipitation_sum,precipitation_hours,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max" daily_params = "temperature_2m_max,temperature_2m_min,showers_sum,rain_sum,snowfall_sum,precipitation_sum,precipitation_hours,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max"
fd_short = min(forecast_days, 7)
elif model == "meteofrance_arome_france_hd":
# AROME HD: 2 giorni, set variabili ridotto (no snow_depth/showers in output)
hourly_params = "temperature_2m,precipitation,snowfall,rain,weathercode,windspeed_10m,winddirection_10m"
daily_params = "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_hours,snowfall_sum,rain_sum,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max"
fd_short = 2
else: else:
hourly_params = "temperature_2m,precipitation,snowfall,rain,snow_depth,weathercode,windspeed_10m,windgusts_10m,winddirection_10m,dewpoint_2m,relative_humidity_2m,cloud_cover,soil_temperature_0cm" hourly_params = "temperature_2m,precipitation,snowfall,rain,snow_depth,weathercode,windspeed_10m,windgusts_10m,winddirection_10m,dewpoint_2m,relative_humidity_2m,cloud_cover,soil_temperature_0cm"
daily_params = "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_hours,snowfall_sum,showers_sum,rain_sum,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max" daily_params = "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_hours,snowfall_sum,showers_sum,rain_sum,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max"
fd_short = min(forecast_days, 3)
params = { params = {
"latitude": lat, "longitude": lon, "latitude": lat, "longitude": lon,
"hourly": hourly_params, "hourly": hourly_params,
"daily": daily_params, "daily": daily_params,
"timezone": timezone if timezone else TZ_STR, "models": model, "timezone": timezone if timezone else TZ_STR, "models": model,
"forecast_days": min(forecast_days, 7) if model == "italia_meteo_arpae_icon_2i" else min(forecast_days, 3) "forecast_days": fd_short
} }
try: try:
resp = open_meteo_get(url, params=params, timeout=(5, 20)) resp = open_meteo_get(url, params=params, timeout=(5, 20))
@@ -218,21 +234,25 @@ def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forec
except: except:
results[model] = None results[model] = None
# Recupera modelli a lungo termine (dopo 72h, dove Icon Italia non arriva) # Recupera modelli a lungo termine (3-10d): tre modelli per mediana (come Agent Irrigazione)
for model in (long_term_models or []): for model in (long_term_models or []):
url = "https://api.open-meteo.com/v1/forecast" url = "https://api.open-meteo.com/v1/forecast"
# ECMWF IFS: parametri come da API (rain, showers, snowfall) + campi necessari per il report fd_long = LONG_TERM_FORECAST_DAYS.get(model, forecast_days)
if model == "ecmwf_ifs": if model == "ecmwf_ifs":
hourly_params = "rain,showers,snowfall,precipitation,temperature_2m,weathercode,windspeed_10m,winddirection_10m" hourly_params = "rain,showers,snowfall,precipitation,temperature_2m,weathercode,windspeed_10m,winddirection_10m"
daily_params = "temperature_2m_max,temperature_2m_min,showers_sum,rain_sum,snowfall_sum,precipitation_sum,precipitation_hours,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max" daily_params = "temperature_2m_max,temperature_2m_min,showers_sum,rain_sum,snowfall_sum,precipitation_sum,precipitation_hours,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max"
elif model == "meteofrance_seamless":
hourly_params = "temperature_2m,precipitation,snowfall,rain,weathercode,windspeed_10m,windgusts_10m,winddirection_10m"
daily_params = "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_hours,snowfall_sum,rain_sum,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max"
else: else:
hourly_params = "temperature_2m,precipitation,snowfall,rain,snow_depth,weathercode,windspeed_10m,windgusts_10m,winddirection_10m,dewpoint_2m,relative_humidity_2m,cloud_cover,soil_temperature_0cm" # ICON Italia e altri
daily_params = "temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_hours,snowfall_sum,showers_sum,rain_sum,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max" hourly_params = "rain,showers,snowfall,snow_depth,precipitation,temperature_2m,weathercode,windspeed_10m,winddirection_10m"
daily_params = "temperature_2m_max,temperature_2m_min,showers_sum,rain_sum,snowfall_sum,precipitation_sum,precipitation_hours,weathercode,winddirection_10m_dominant,windspeed_10m_max,windgusts_10m_max"
params = { params = {
"latitude": lat, "longitude": lon, "latitude": lat, "longitude": lon,
"hourly": hourly_params, "hourly": hourly_params,
"daily": daily_params, "daily": daily_params,
"timezone": timezone if timezone else TZ_STR, "models": model, "forecast_days": forecast_days "timezone": timezone if timezone else TZ_STR, "models": model, "forecast_days": fd_long
} }
try: try:
resp = open_meteo_get(url, params=params, timeout=(5, 25)) resp = open_meteo_get(url, params=params, timeout=(5, 25))
@@ -265,6 +285,144 @@ def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forec
return results return results
def _normalize_time_key(t):
"""Normalizza timestamp per confronto (YYYY-MM-DDTHH:MM)."""
if not t or not isinstance(t, str):
return str(t) if t else ""
return t.strip()[:16]
def _median_or_single(values):
"""Mediana dei valori numerici; ignora None. Con 2 valori restituisce la media dei due."""
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)
# Chiavi che esistono solo su ICON Italia (no merge, si tiene il valore da quel modello)
HOURLY_KEYS_ICON_ONLY = ["snow_depth", "showers"]
DAILY_KEYS_ICON_ONLY = ["showers_sum"]
def _merge_hourly_median(hourly_by_model, single_source_keys=None, single_source_model=None):
"""
Unisce hourly da più modelli: mediana per ogni timestamp.
single_source_keys: per queste chiavi si prende il valore solo da single_source_model (es. ICON Italia per snow_depth, showers).
"""
single_source_keys = single_source_keys or []
time_idx = {}
all_times = []
for _model, h in hourly_by_model:
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:
return {"time": [], "temperature_2m": [], "precipitation": [], "snowfall": [], "rain": [], "weathercode": [], "windspeed_10m": [], "winddirection_10m": [], "snow_depth": [], "dewpoint_2m": [], "cloud_cover": [], "soil_temperature_0cm": []}
# Raccogli tutte le chiavi numeriche dal primo modello che le ha
all_keys = []
for _model, h in hourly_by_model:
for key in (h.keys() - {"time"}):
if key not in all_keys:
all_keys.append(key)
out = {"time": all_times}
for key in all_keys:
out[key] = []
for ref_t in all_times:
ref_k = _normalize_time_key(str(ref_t))
if key in single_source_keys and single_source_model:
val = None
for m, h in hourly_by_model:
if m != single_source_model:
continue
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:
val = float(arr[i]) if key != "weathercode" else (int(arr[i]) if arr[i] is not None else None)
except (TypeError, ValueError):
pass
break
break
out[key].append(val)
else:
vals = []
for _m, h in hourly_by_model:
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 _merge_daily_median(daily_by_model, single_source_keys=None, single_source_model=None):
"""Unisce daily da più modelli: mediana per data. single_source_keys: valore solo da single_source_model."""
single_source_keys = single_source_keys or []
time_idx = {}
all_times = []
for _model, d in daily_by_model:
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": [], "temperature_2m_max": [], "temperature_2m_min": [], "precipitation_sum": [], "precipitation_hours": [], "snowfall_sum": [], "showers_sum": [], "rain_sum": [], "weathercode": [], "winddirection_10m_dominant": [], "windspeed_10m_max": [], "windgusts_10m_max": []}
all_keys = []
for _model, d in daily_by_model:
for key in (d.keys() - {"time"}):
if key not in all_keys:
all_keys.append(key)
out = {"time": all_times}
for key in all_keys:
out[key] = []
for date_str in all_times:
if key in single_source_keys and single_source_model:
val = None
for m, d in daily_by_model:
if m != single_source_model:
continue
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:
val = float(arr[i]) if key != "weathercode" else (int(arr[i]) if arr[i] is not None else None)
except (TypeError, ValueError):
pass
break
break
out[key].append(val)
else:
vals = []
for _m, d in daily_by_model:
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_multi_model_forecast(models_data, forecast_days=10): def merge_multi_model_forecast(models_data, forecast_days=10):
"""Combina dati da modelli a breve e lungo termine in un forecast unificato""" """Combina dati da modelli a breve e lungo termine in un forecast unificato"""
merged = { merged = {
@@ -299,183 +457,141 @@ def merge_multi_model_forecast(models_data, forecast_days=10):
"models_used": [] "models_used": []
} }
# Trova modello a breve termine disponibile (cerca tutti i modelli con type "short_term") cutoff_day = 2 # 0-2d alta risoluzione, 3-10d mediana tre modelli
# Priorità: ICON Italia per snow_depth, altrimenti primo disponibile short_term_list = [(m, models_data[m]) for m in models_data if models_data.get(m) and models_data[m].get("model_type") == "short_term"]
short_term_data = None long_term_list = [(m, models_data[m]) for m in models_data if models_data.get(m) and models_data[m].get("model_type") == "long_term"]
short_term_model = None
icon_italia_data = None
icon_italia_model = None
# Prima cerca ICON Italia (ha snow_depth quando disponibile) if not short_term_list and not long_term_list:
# Cerca anche altri modelli che potrebbero avere snow_depth (icon_d2, etc.)
for model in models_data.keys():
if models_data[model] and models_data[model].get("model_type") == "short_term":
# Priorità a ICON Italia, ma cerca anche altri modelli con snow_depth
if model == "italia_meteo_arpae_icon_2i":
icon_italia_data = models_data[model]
icon_italia_model = model
# ICON-D2 può avere anche snow_depth
elif model == "icon_d2" and icon_italia_data is None:
# Usa ICON-D2 come fallback se ICON Italia non disponibile
hourly_data = models_data[model].get("hourly", {})
snow_depth_values = hourly_data.get("snow_depth", []) if hourly_data else []
# Verifica se ha dati di snow_depth validi
has_valid_snow_depth = False
if snow_depth_values:
for sd in snow_depth_values[:24]:
if sd is not None:
try:
if float(sd) > 0:
has_valid_snow_depth = True
break
except (ValueError, TypeError):
continue
if has_valid_snow_depth:
icon_italia_data = models_data[model]
icon_italia_model = model
# Poi cerca primo modello disponibile (per altri parametri)
for model in models_data.keys():
if models_data[model] and models_data[model].get("model_type") == "short_term":
short_term_data = models_data[model]
short_term_model = model
break
# Trova modello a lungo termine disponibile (cerca tutti i modelli con type "long_term")
long_term_data = None
long_term_model = None
for model in models_data.keys():
if models_data[model] and models_data[model].get("model_type") == "long_term":
long_term_data = models_data[model]
long_term_model = model
break
if not short_term_data and not long_term_data:
return None return None
# Usa dati a breve termine per primi 2-3 giorni, poi passa a lungo termine daily_keys = list(merged["daily"].keys())
cutoff_day = 2 # Usa modelli ad alta risoluzione per primi 2 giorni hourly_keys = list(merged["hourly"].keys())
if short_term_data: def ensure_merged_keys(merged, daily_times, hourly_times):
# Gestisci best_match o modelli specifici for k in daily_keys:
if short_term_model == "best_match": if k == "time":
model_display = "Best Match" continue
else: while len(merged["daily"][k]) < len(merged["daily"]["time"]):
model_display = MODEL_NAMES.get(short_term_model, short_term_model) merged["daily"][k].append(None)
short_daily = short_term_data.get("daily", {}) for k in hourly_keys:
short_hourly = short_term_data.get("hourly", {}) if k == "time":
# Prendi dati daily: tutti i giorni se è l'unico modello, altrimenti primi cutoff_day+1 continue
short_daily_times_all = short_daily.get("time", []) while len(merged["hourly"][k]) < len(merged["hourly"]["time"]):
short_daily_times = short_daily_times_all[:cutoff_day+1] if long_term_data else short_daily_times_all merged["hourly"][k].append(None)
# Verifica se ICON Italia ha dati di snow_depth (controllo diretto, non solo il flag) # ---- 0-2 giorni: uno o due modelli short-term ----
has_icon_snow_depth = False if short_term_list:
if icon_italia_data: if len(short_term_list) >= 2:
icon_hourly = icon_italia_data.get("hourly", {}) # Mediana ICON Italia + AROME HD; snow_depth e showers solo da ICON Italia
icon_snow_depth = icon_hourly.get("snow_depth", []) if icon_hourly else [] short_daily_by_model = [(m, d.get("daily", {}) or {}) for m, d in short_term_list]
if icon_snow_depth: short_hourly_by_model = [(m, d.get("hourly", {}) or {}) for m, d in short_term_list]
for sd in icon_snow_depth[:72]: # Controlla prime 72h merged_short_daily = _merge_daily_median(short_daily_by_model, single_source_keys=DAILY_KEYS_ICON_ONLY, single_source_model="italia_meteo_arpae_icon_2i")
if sd is not None: merged_short_hourly = _merge_hourly_median(short_hourly_by_model, single_source_keys=HOURLY_KEYS_ICON_ONLY, single_source_model="italia_meteo_arpae_icon_2i")
try: short_daily_times = (merged_short_daily.get("time") or [])[:cutoff_day + 1] if long_term_list else (merged_short_daily.get("time") or [])
if float(sd) > 0: short_hourly_times = merged_short_hourly.get("time") or []
has_icon_snow_depth = True cutoff_h = (cutoff_day + 1) * 24 if long_term_list else len(short_hourly_times)
break short_hourly_times = short_hourly_times[:cutoff_h]
except (ValueError, TypeError): names_short = " + ".join(MODEL_NAMES.get(m, m) for m, _ in short_term_list[:2])
continue merged["models_used"].append(f"{names_short} (mediana) (0-{len(short_daily_times)}d)")
for i, day_time in enumerate(short_daily_times):
num_days = len(short_daily_times) merged["daily"]["time"].append(day_time)
if has_icon_snow_depth or (icon_italia_data and icon_italia_data.get("has_snow_depth_data")): for key in daily_keys:
icon_display = MODEL_NAMES.get("italia_meteo_arpae_icon_2i", "ICON Italia") if key == "time":
merged["models_used"].append(f"{model_display} + {icon_display} (snow_depth) (0-{num_days}d)")
else:
merged["models_used"].append(f"{model_display} (0-{num_days}d)")
for i, day_time in enumerate(short_daily_times):
merged["daily"]["time"].append(day_time)
for key in ["temperature_2m_max", "temperature_2m_min", "precipitation_sum", "precipitation_hours", "snowfall_sum", "showers_sum", "rain_sum", "weathercode", "winddirection_10m_dominant", "windspeed_10m_max", "windgusts_10m_max"]:
val = short_daily.get(key, [])[i] if i < len(short_daily.get(key, [])) else None
merged["daily"][key].append(val)
# Prendi dati hourly dal modello a breve termine
# Priorità: usa snow_depth da ICON Italia se disponibile, altrimenti dal modello principale
short_hourly_times = short_hourly.get("time", [])
icon_italia_hourly = icon_italia_data.get("hourly", {}) if icon_italia_data else {}
icon_italia_hourly_times = icon_italia_hourly.get("time", []) if icon_italia_hourly else []
icon_italia_snow_depth = icon_italia_hourly.get("snow_depth", []) if icon_italia_hourly else []
# Crea mappa timestamp -> snow_depth per ICON Italia (per corrispondenza esatta o approssimata)
icon_snow_depth_map = {}
if icon_italia_hourly_times and icon_italia_snow_depth:
for idx, ts in enumerate(icon_italia_hourly_times):
if idx < len(icon_italia_snow_depth) and icon_italia_snow_depth[idx] is not None:
try:
val_cm = float(icon_italia_snow_depth[idx])
if val_cm >= 0: # Solo valori validi (già in cm)
icon_snow_depth_map[ts] = val_cm
except (ValueError, TypeError):
continue continue
arr = merged_short_daily.get(key, [])
merged["daily"][key].append(arr[i] if i < len(arr) else None)
for i, hour_time in enumerate(short_hourly_times):
merged["hourly"]["time"].append(hour_time)
for key in hourly_keys:
if key == "time":
continue
arr = merged_short_hourly.get(key, [])
merged["hourly"][key].append(arr[i] if i < len(arr) else None)
else:
# Un solo modello short-term (es. best_match o fallback)
short_term_model, short_term_data = short_term_list[0]
short_daily = short_term_data.get("daily", {}) or {}
short_hourly = short_term_data.get("hourly", {}) or {}
short_daily_times_all = short_daily.get("time", []) or []
short_daily_times = short_daily_times_all[:cutoff_day + 1] if long_term_list else short_daily_times_all
model_display = "Best Match" if short_term_model == "best_match" else MODEL_NAMES.get(short_term_model, short_term_model)
merged["models_used"].append(f"{model_display} (0-{len(short_daily_times)}d)")
for i, day_time in enumerate(short_daily_times):
merged["daily"]["time"].append(day_time)
for key in daily_keys:
if key == "time":
continue
arr = short_daily.get(key, [])
merged["daily"][key].append(arr[i] if i < len(arr) else None)
short_hourly_times = short_hourly.get("time", []) or []
cutoff_h = (cutoff_day + 1) * 24 if long_term_list else len(short_hourly_times)
for i, hour_time in enumerate(short_hourly_times[:cutoff_h]):
merged["hourly"]["time"].append(hour_time)
for key in hourly_keys:
if key == "time":
continue
arr = short_hourly.get(key, [])
merged["hourly"][key].append(arr[i] if i < len(arr) else None)
ensure_merged_keys(merged, merged["daily"]["time"], merged["hourly"]["time"])
cutoff_hour = (cutoff_day + 1) * 24 if long_term_data else len(short_hourly_times) # ---- 3-10 giorni: uno o più modelli long-term ----
for i, hour_time in enumerate(short_hourly_times[:cutoff_hour]): if long_term_list:
merged["hourly"]["time"].append(hour_time) if len(long_term_list) >= 2:
for key in ["temperature_2m", "precipitation", "snowfall", "rain", "weathercode", "windspeed_10m", "winddirection_10m", "dewpoint_2m", "cloud_cover", "soil_temperature_0cm"]: long_daily_by_model = [(m, d.get("daily", {}) or {}) for m, d in long_term_list]
val = short_hourly.get(key, [])[i] if i < len(short_hourly.get(key, [])) else None long_hourly_by_model = [(m, d.get("hourly", {}) or {}) for m, d in long_term_list]
merged["hourly"][key].append(val) merged_long_daily = _merge_daily_median(long_daily_by_model, single_source_keys=DAILY_KEYS_ICON_ONLY, single_source_model="italia_meteo_arpae_icon_2i")
# Per snow_depth: usa ICON Italia se disponibile (corrispondenza per timestamp), altrimenti modello principale merged_long_hourly = _merge_hourly_median(long_hourly_by_model, single_source_keys=HOURLY_KEYS_ICON_ONLY, single_source_model="italia_meteo_arpae_icon_2i")
# NOTA: I valori sono già convertiti in cm durante il recupero dall'API long_daily_times = merged_long_daily.get("time") or []
val_snow_depth = None long_hourly_times = merged_long_hourly.get("time") or []
# Cerca corrispondenza esatta per timestamp names_long = " + ".join(MODEL_NAMES.get(m, m) for m, _ in long_term_list[:3])
if hour_time in icon_snow_depth_map: merged["models_used"].append(f"{names_long} (mediana) ({cutoff_day+1}-{forecast_days}d)")
# Usa snow_depth da ICON Italia per questo timestamp (già in cm) start_idx = cutoff_day + 1
val_snow_depth = icon_snow_depth_map[hour_time] for i, day_time in enumerate(long_daily_times):
else: day_num = i
# Fallback 1: cerca corrispondenza per ora approssimata (se i timestamp non corrispondono esattamente) if day_num < start_idx:
# Estrai solo la parte ora (YYYY-MM-DDTHH) per corrispondenza approssimata continue
hour_time_base = hour_time[:13] if len(hour_time) >= 13 else hour_time # "2025-01-09T12" if day_num >= forecast_days:
for icon_ts, icon_val in icon_snow_depth_map.items(): break
if icon_ts.startswith(hour_time_base): merged["daily"]["time"].append(day_time)
val_snow_depth = icon_val for key in daily_keys:
break if key == "time":
# Fallback 2: se non trovato, cerca il valore più vicino nello stesso giorno continue
if val_snow_depth is None and hour_time_base: arr = merged_long_daily.get(key, [])
day_date_str = hour_time[:10] if len(hour_time) >= 10 else None # "2025-01-09" merged["daily"][key].append(arr[i] if i < len(arr) else None)
if day_date_str: start_hour_idx = (cutoff_day + 1) * 24
# Cerca tutti i valori di ICON Italia per lo stesso giorno needed_hours = forecast_days * 24
same_day_values = [v for ts, v in icon_snow_depth_map.items() if ts.startswith(day_date_str)]
if same_day_values:
# Usa il primo valore disponibile per quel giorno (approssimazione)
val_snow_depth = same_day_values[0]
# Fallback 3: usa snow_depth dal modello principale se ICON Italia non disponibile
if val_snow_depth is None and i < len(short_hourly.get("snow_depth", [])):
val_snow_depth = short_hourly.get("snow_depth", [])[i]
merged["hourly"]["snow_depth"].append(val_snow_depth)
if long_term_data:
merged["models_used"].append(f"{MODEL_NAMES.get(long_term_model, long_term_model)} ({cutoff_day+1}-{forecast_days}d)")
long_daily = long_term_data.get("daily", {})
long_hourly = long_term_data.get("hourly", {})
# Prendi dati daily dal modello a lungo termine per i giorni successivi
long_daily_times = long_daily.get("time", [])
start_idx = cutoff_day + 1
for i in range(start_idx, min(len(long_daily_times), forecast_days)):
merged["daily"]["time"].append(long_daily_times[i])
for key in ["temperature_2m_max", "temperature_2m_min", "precipitation_sum", "precipitation_hours", "snowfall_sum", "showers_sum", "rain_sum", "weathercode", "winddirection_10m_dominant", "windspeed_10m_max", "windgusts_10m_max"]:
val = long_daily.get(key, [])[i] if i < len(long_daily.get(key, [])) else None
merged["daily"][key].append(val)
# Per i dati hourly, completa con dati a lungo termine se necessario
long_hourly_times = long_hourly.get("time", [])
current_hourly_count = len(merged["hourly"]["time"])
needed_hours = forecast_days * 24
if current_hourly_count < needed_hours:
start_hour_idx = current_hourly_count
for i in range(start_hour_idx, min(len(long_hourly_times), needed_hours)): for i in range(start_hour_idx, min(len(long_hourly_times), needed_hours)):
merged["hourly"]["time"].append(long_hourly_times[i]) merged["hourly"]["time"].append(long_hourly_times[i])
for key in ["temperature_2m", "precipitation", "snowfall", "snow_depth", "rain", "weathercode", "windspeed_10m", "winddirection_10m", "dewpoint_2m", "cloud_cover", "soil_temperature_0cm"]: for key in hourly_keys:
val = long_hourly.get(key, [])[i] if i < len(long_hourly.get(key, [])) else None if key == "time":
merged["hourly"][key].append(val) continue
arr = merged_long_hourly.get(key, [])
merged["hourly"][key].append(arr[i] if i < len(arr) else None)
else:
long_term_model, long_term_data = long_term_list[0]
long_daily = long_term_data.get("daily", {}) or {}
long_hourly = long_term_data.get("hourly", {}) or {}
merged["models_used"].append(f"{MODEL_NAMES.get(long_term_model, long_term_model)} ({cutoff_day+1}-{forecast_days}d)")
long_daily_times = long_daily.get("time", []) or []
start_idx = cutoff_day + 1
for i in range(start_idx, min(len(long_daily_times), forecast_days)):
merged["daily"]["time"].append(long_daily_times[i])
for key in daily_keys:
if key == "time":
continue
arr = long_daily.get(key, [])
merged["daily"][key].append(arr[i] if i < len(arr) else None)
long_hourly_times = long_hourly.get("time", []) or []
current_hourly_count = len(merged["hourly"]["time"])
needed_hours = forecast_days * 24
for i in range(current_hourly_count, min(len(long_hourly_times), needed_hours)):
merged["hourly"]["time"].append(long_hourly_times[i])
for key in hourly_keys:
if key == "time":
continue
arr = long_hourly.get(key, [])
merged["hourly"][key].append(arr[i] if i < len(arr) else None)
ensure_merged_keys(merged, merged["daily"]["time"], merged["hourly"]["time"])
return merged return merged
+201 -69
View File
@@ -18,6 +18,7 @@ import logging
import os import os
import sys import sys
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from statistics import median as _median
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -42,11 +43,22 @@ TZINFO = ZoneInfo(TZ)
# Open-Meteo: due fonti # Open-Meteo: due fonti
# - Suolo (tutti i layer): ICON Seamless (DWD) - copertura Europa centrale # - 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" 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_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à 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"} HTTP_HEADERS = {"User-Agent": "Smart-Irrigation-Advisor/2.0"}
# Files # Files
@@ -372,6 +384,176 @@ def fetch_weather_icon_italia(lat: float, lon: float, timezone: str = TZ) -> Opt
return None 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: def _normalize_time_key(t: str) -> str:
"""Normalizza timestamp per confronto (es. '2026-02-05T16:00' e '2026-02-05T16:00:00' → stesso key).""" """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): 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]: 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). In caso di fallimento suolo, prova fallback con singola fonte (solo ICON Italia).
""" """
soil_data = fetch_soil_icon_seamless(lat, lon, timezone) 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: if not weather_data:
return None return None
hourly_w = weather_data.get("hourly", {}) or {} 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]: def fetch_weather_only(lat: float, lon: float, timezone: str = TZ) -> Optional[Dict]:
"""Recupera solo dati meteo (senza suolo).""" """Recupera solo dati meteo (senza suolo): analisi a tre modelli con mediana."""
return fetch_weather_icon_italia(lat, lon, timezone) 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.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_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}%") 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.legend(loc="upper right", fontsize=7)
ax1.grid(True, alpha=0.3) ax1.grid(True, alpha=0.3)
ax1.set_ylim(bottom=0) 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] 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], 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) 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.legend(loc="upper right", fontsize=7)
ax2.grid(True, alpha=0.3) ax2.grid(True, alpha=0.3)
ax2.set_ylim(bottom=0) ax2.set_ylim(bottom=0)
@@ -1828,13 +2022,11 @@ def analyze_irrigation(
if rain_veto: if rain_veto:
veto_lines.append(f"🌧️ **VETO PIOGGIA**: Ultime 24h ≥ {PRECIP_VETO_MM_24H:.0f} mm — non avviare irrigazione.") 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 = [ glance = [
status.strip(), status.strip(),
f"📍 {location_name} · {now.strftime('%d/%m/%Y %H:%M')}", f"📍 {location_name} · {now.strftime('%d/%m/%Y %H:%M')}",
"", "",
moisture_summary_line.strip(),
"",
] ]
if veto_lines: if veto_lines:
glance.append("**Veti**") glance.append("**Veti**")
@@ -1854,66 +2046,6 @@ def analyze_irrigation(
] ]
if timing_advice: if timing_advice:
report_parts.append("**Orario** " + " · ".join(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 # Salva stato
save_state(state) save_state(state)