diff --git a/services/telegram-bot/log_monitor.py b/services/telegram-bot/log_monitor.py index 2fcc5b3..164a360 100644 --- a/services/telegram-bot/log_monitor.py +++ b/services/telegram-bot/log_monitor.py @@ -16,6 +16,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DEFAULT_PATTERNS = ["*.log", "*_log.txt"] EXCLUDED_FILES = { "circondario_log.txt", + "irrigation_cron.log", "road_weather.log", "snow_radar.log", } diff --git a/services/telegram-bot/previsione7.py b/services/telegram-bot/previsione7.py index 706bafc..f0c6dfd 100755 --- a/services/telegram-bot/previsione7.py +++ b/services/telegram-bot/previsione7.py @@ -55,11 +55,18 @@ MODEL_NAMES = { "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): """ Seleziona modelli meteo ottimali. - - Per Casa e Italia: solo ICON Italia (ARPAE 2i); AROME HD non copre San Marino. - - Per altre località: usa best match di Open-Meteo (senza specificare models). + - Per Casa e Italia: 0-2d mediana ICON Italia + AROME HD; 3-10d mediana ICON Italia + ECMWF IFS + ARPEGE. + - Per altre località: best match Open-Meteo. Ritorna (short_term_models, long_term_models) """ 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"] if is_home or cc == "IT": - # ICON Italia (0–72h) + ECMWF IFS per i giorni successivi (dove Icon Italia non arriva) - return ["italia_meteo_arpae_icon_2i"], ["ecmwf_ifs"] + # 0-2d: due modelli ad alta risoluzione (mediana). 3-10d: tre modelli (mediana, come Irrigazione). + return ( + ["italia_meteo_arpae_icon_2i", "meteofrance_arome_france_hd"], + ["italia_meteo_arpae_icon_2i", "ecmwf_ifs", "meteofrance_seamless"], + ) else: return None, long_term_default @@ -153,22 +163,28 @@ def get_weather_multi_model(lat, lon, short_term_models, long_term_models, forec except: results["best_match"] = None 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: 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": 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" + 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: 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" + fd_short = min(forecast_days, 3) params = { "latitude": lat, "longitude": lon, "hourly": hourly_params, "daily": daily_params, "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: 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: 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 []): 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": 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" + 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: - 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" + # ICON Italia e altri + 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 = { "latitude": lat, "longitude": lon, "hourly": hourly_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: 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 + +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): """Combina dati da modelli a breve e lungo termine in un forecast unificato""" merged = { @@ -299,183 +457,141 @@ def merge_multi_model_forecast(models_data, forecast_days=10): "models_used": [] } - # Trova modello a breve termine disponibile (cerca tutti i modelli con type "short_term") - # Priorità: ICON Italia per snow_depth, altrimenti primo disponibile - short_term_data = None - short_term_model = None - icon_italia_data = None - icon_italia_model = None + cutoff_day = 2 # 0-2d alta risoluzione, 3-10d mediana tre modelli + 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"] + 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"] - # Prima cerca ICON Italia (ha snow_depth quando disponibile) - # 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: + if not short_term_list and not long_term_list: return None - # Usa dati a breve termine per primi 2-3 giorni, poi passa a lungo termine - cutoff_day = 2 # Usa modelli ad alta risoluzione per primi 2 giorni + daily_keys = list(merged["daily"].keys()) + hourly_keys = list(merged["hourly"].keys()) - if short_term_data: - # Gestisci best_match o modelli specifici - if short_term_model == "best_match": - model_display = "Best Match" - else: - model_display = MODEL_NAMES.get(short_term_model, short_term_model) - short_daily = short_term_data.get("daily", {}) - short_hourly = short_term_data.get("hourly", {}) - # Prendi dati daily: tutti i giorni se è l'unico modello, altrimenti primi cutoff_day+1 - short_daily_times_all = short_daily.get("time", []) - short_daily_times = short_daily_times_all[:cutoff_day+1] if long_term_data else short_daily_times_all - - # Verifica se ICON Italia ha dati di snow_depth (controllo diretto, non solo il flag) - has_icon_snow_depth = False - if icon_italia_data: - icon_hourly = icon_italia_data.get("hourly", {}) - icon_snow_depth = icon_hourly.get("snow_depth", []) if icon_hourly else [] - if icon_snow_depth: - for sd in icon_snow_depth[:72]: # Controlla prime 72h - if sd is not None: - try: - if float(sd) > 0: - has_icon_snow_depth = True - break - except (ValueError, TypeError): - continue - - num_days = len(short_daily_times) - if has_icon_snow_depth or (icon_italia_data and icon_italia_data.get("has_snow_depth_data")): - icon_display = MODEL_NAMES.get("italia_meteo_arpae_icon_2i", "ICON Italia") - 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): + def ensure_merged_keys(merged, daily_times, hourly_times): + for k in daily_keys: + if k == "time": + continue + while len(merged["daily"][k]) < len(merged["daily"]["time"]): + merged["daily"][k].append(None) + for k in hourly_keys: + if k == "time": + continue + while len(merged["hourly"][k]) < len(merged["hourly"]["time"]): + merged["hourly"][k].append(None) + + # ---- 0-2 giorni: uno o due modelli short-term ---- + if short_term_list: + if len(short_term_list) >= 2: + # Mediana ICON Italia + AROME HD; snow_depth e showers solo da ICON Italia + short_daily_by_model = [(m, d.get("daily", {}) or {}) for m, d in short_term_list] + short_hourly_by_model = [(m, d.get("hourly", {}) or {}) for m, d in short_term_list] + 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") + 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") + short_daily_times = (merged_short_daily.get("time") or [])[:cutoff_day + 1] if long_term_list else (merged_short_daily.get("time") or []) + short_hourly_times = merged_short_hourly.get("time") or [] + cutoff_h = (cutoff_day + 1) * 24 if long_term_list else len(short_hourly_times) + short_hourly_times = short_hourly_times[:cutoff_h] + names_short = " + ".join(MODEL_NAMES.get(m, m) for m, _ in short_term_list[:2]) + merged["models_used"].append(f"{names_short} (mediana) (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 - - cutoff_hour = (cutoff_day + 1) * 24 if long_term_data else len(short_hourly_times) - for i, hour_time in enumerate(short_hourly_times[:cutoff_hour]): - merged["hourly"]["time"].append(hour_time) - for key in ["temperature_2m", "precipitation", "snowfall", "rain", "weathercode", "windspeed_10m", "winddirection_10m", "dewpoint_2m", "cloud_cover", "soil_temperature_0cm"]: - val = short_hourly.get(key, [])[i] if i < len(short_hourly.get(key, [])) else None - merged["hourly"][key].append(val) - # Per snow_depth: usa ICON Italia se disponibile (corrispondenza per timestamp), altrimenti modello principale - # NOTA: I valori sono già convertiti in cm durante il recupero dall'API - val_snow_depth = None - # Cerca corrispondenza esatta per timestamp - if hour_time in icon_snow_depth_map: - # Usa snow_depth da ICON Italia per questo timestamp (già in cm) - val_snow_depth = icon_snow_depth_map[hour_time] - else: - # Fallback 1: cerca corrispondenza per ora approssimata (se i timestamp non corrispondono esattamente) - # Estrai solo la parte ora (YYYY-MM-DDTHH) per corrispondenza approssimata - hour_time_base = hour_time[:13] if len(hour_time) >= 13 else hour_time # "2025-01-09T12" - for icon_ts, icon_val in icon_snow_depth_map.items(): - if icon_ts.startswith(hour_time_base): - val_snow_depth = icon_val - break - # Fallback 2: se non trovato, cerca il valore più vicino nello stesso giorno - if val_snow_depth is None and hour_time_base: - day_date_str = hour_time[:10] if len(hour_time) >= 10 else None # "2025-01-09" - if day_date_str: - # Cerca tutti i valori di ICON Italia per lo stesso giorno - 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) + 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"]) - 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 + # ---- 3-10 giorni: uno o più modelli long-term ---- + if long_term_list: + if len(long_term_list) >= 2: + long_daily_by_model = [(m, d.get("daily", {}) or {}) for m, d in long_term_list] + long_hourly_by_model = [(m, d.get("hourly", {}) or {}) for m, d in long_term_list] + 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") + 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") + long_daily_times = merged_long_daily.get("time") or [] + long_hourly_times = merged_long_hourly.get("time") or [] + names_long = " + ".join(MODEL_NAMES.get(m, m) for m, _ in long_term_list[:3]) + merged["models_used"].append(f"{names_long} (mediana) ({cutoff_day+1}-{forecast_days}d)") + start_idx = cutoff_day + 1 + for i, day_time in enumerate(long_daily_times): + day_num = i + if day_num < start_idx: + continue + if day_num >= forecast_days: + break + merged["daily"]["time"].append(day_time) + for key in daily_keys: + if key == "time": + continue + arr = merged_long_daily.get(key, []) + merged["daily"][key].append(arr[i] if i < len(arr) else None) + start_hour_idx = (cutoff_day + 1) * 24 + needed_hours = forecast_days * 24 for i in range(start_hour_idx, min(len(long_hourly_times), needed_hours)): 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"]: - val = long_hourly.get(key, [])[i] if i < len(long_hourly.get(key, [])) else None - merged["hourly"][key].append(val) + for key in hourly_keys: + if key == "time": + 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 diff --git a/services/telegram-bot/smart_irrigation_advisor.py b/services/telegram-bot/smart_irrigation_advisor.py index 46d64e3..08e5471 100755 --- a/services/telegram-bot/smart_irrigation_advisor.py +++ b/services/telegram-bot/smart_irrigation_advisor.py @@ -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)