diff --git a/src/data_collection/nws_open_meteo_sources.py b/src/data_collection/nws_open_meteo_sources.py index a68c91d0..56d3a149 100644 --- a/src/data_collection/nws_open_meteo_sources.py +++ b/src/data_collection/nws_open_meteo_sources.py @@ -2,13 +2,143 @@ from __future__ import annotations import time from datetime import datetime, timedelta -from typing import Dict, Optional +from typing import Any, Dict, Optional from loguru import logger from src.utils.metrics import record_source_call +OPEN_METEO_MULTI_MODEL_SPECS: Dict[str, Dict[str, Any]] = { + "ecmwf_ifs025": { + "label": "ECMWF", + "provider": "ECMWF", + "model": "IFS", + "tier": "global", + "resolution_km": 25, + "horizon": "15d", + }, + "ecmwf_aifs025_single": { + "label": "ECMWF AIFS", + "provider": "ECMWF", + "model": "AIFS single", + "tier": "global_ai", + "resolution_km": 25, + "horizon": "15d", + }, + "gfs_seamless": { + "label": "GFS", + "provider": "NOAA", + "model": "GFS Seamless", + "tier": "global", + "resolution_km": None, + "horizon": "16d", + }, + "icon_seamless": { + "label": "ICON", + "provider": "DWD", + "model": "ICON Seamless", + "tier": "global", + "resolution_km": None, + "horizon": "7d", + }, + "icon_eu": { + "label": "ICON-EU", + "provider": "DWD", + "model": "ICON-EU", + "tier": "regional_europe", + "resolution_km": 7, + "horizon": "5d", + }, + "icon_d2": { + "label": "ICON-D2", + "provider": "DWD", + "model": "ICON-D2", + "tier": "short_range_europe", + "resolution_km": 2.2, + "horizon": "2d", + }, + "gem_seamless": { + "label": "GEM", + "provider": "ECCC", + "model": "GEM Seamless", + "tier": "global", + "resolution_km": None, + "horizon": "10d", + }, + "gem_global": { + "label": "GDPS", + "provider": "ECCC", + "model": "GEM Global / GDPS", + "tier": "global", + "resolution_km": 15, + "horizon": "10d", + }, + "gem_regional": { + "label": "RDPS", + "provider": "ECCC", + "model": "GEM Regional / RDPS", + "tier": "regional_north_america", + "resolution_km": 10, + "horizon": "3d", + }, + "gem_hrdps_continental": { + "label": "HRDPS", + "provider": "ECCC", + "model": "HRDPS Continental", + "tier": "short_range_north_america", + "resolution_km": 2.5, + "horizon": "2d", + }, + "jma_seamless": { + "label": "JMA", + "provider": "JMA", + "model": "JMA Seamless", + "tier": "global", + "resolution_km": None, + "horizon": "11d", + }, +} + +OPEN_METEO_MULTI_MODEL_ORDER = tuple(OPEN_METEO_MULTI_MODEL_SPECS.keys()) + + +def _parse_open_meteo_multi_model_daily( + daily: Dict[str, Any], +) -> tuple[list[str], Dict[str, Dict[str, float]], Dict[str, Dict[str, Any]], Dict[str, str]]: + dates = daily.get("time", []) + if not isinstance(dates, list): + dates = [] + + model_metadata: Dict[str, Dict[str, Any]] = {} + model_keys: Dict[str, str] = {} + daily_forecasts: Dict[str, Dict[str, float]] = {} + + for day_idx, date_str in enumerate(dates): + day_data: Dict[str, float] = {} + for model_key, spec in OPEN_METEO_MULTI_MODEL_SPECS.items(): + label = str(spec["label"]) + key = f"temperature_2m_max_{model_key}" + values = daily.get(key, []) + if not isinstance(values, list): + continue + if day_idx >= len(values) or values[day_idx] is None: + continue + try: + day_data[label] = round(float(values[day_idx]), 1) + except (TypeError, ValueError): + continue + model_metadata[label] = { + **spec, + "open_meteo_model": model_key, + } + model_keys[label] = model_key + if day_data: + daily_forecasts[str(date_str)] = day_data + + return [str(d) for d in dates], daily_forecasts, model_metadata, model_keys + + class NwsOpenMeteoSourceMixin: def fetch_nws(self, lat: float, lon: float) -> Optional[Dict]: """ @@ -468,10 +598,10 @@ class NwsOpenMeteoSourceMixin: 用于真正的多模型共识评分 模型列表: - - ECMWF IFS (欧洲中期天气预报中心) + - ECMWF IFS / AIFS - GFS (美国 NOAA) - - ICON (德国气象局 DWD) - - GEM (加拿大气象局) + - ICON Seamless / ICON-EU / ICON-D2 (德国气象局 DWD) + - GEM Seamless / GDPS / RDPS / HRDPS (加拿大 ECCC) - JMA (日本气象厅) 返回 3 天的预报数据,支持今日+明日共识分析 @@ -510,7 +640,7 @@ class NwsOpenMeteoSourceMixin: return dict(cached_data) try: url = "https://api.open-meteo.com/v1/forecast" - models = "ecmwf_ifs025,gfs_seamless,icon_seamless,gem_seamless,jma_seamless" + models = ",".join(OPEN_METEO_MULTI_MODEL_ORDER) params = { "latitude": lat, "longitude": lon, @@ -532,27 +662,11 @@ class NwsOpenMeteoSourceMixin: data = response.json() daily = data.get("daily", {}) - dates = daily.get("time", []) - - model_labels = { - "ecmwf_ifs025": "ECMWF", - "gfs_seamless": "GFS", - "icon_seamless": "ICON", - "gem_seamless": "GEM", - "jma_seamless": "JMA", - } - - # 按天提取每个模型的预报 - daily_forecasts = {} # {"2026-02-23": {"ECMWF": 7.9, "GFS": 6.5, ...}, ...} - for day_idx, date_str in enumerate(dates): - day_data = {} - for model_key, label in model_labels.items(): - key = f"temperature_2m_max_{model_key}" - values = daily.get(key, []) - if day_idx < len(values) and values[day_idx] is not None: - day_data[label] = round(values[day_idx], 1) - if day_data: - daily_forecasts[date_str] = day_data + if not isinstance(daily, dict): + daily = {} + dates, daily_forecasts, model_metadata, model_keys = ( + _parse_open_meteo_multi_model_daily(daily) + ) if not daily_forecasts: logger.warning("Multi-model: 无有效模型数据") @@ -570,10 +684,14 @@ class NwsOpenMeteoSourceMixin: result = { "source": "multi_model", + "provider": "open-meteo", "forecasts": forecasts, # 今天 {"ECMWF": 12.3, "GFS": 11.8, ...} (向后兼容) "daily_forecasts": daily_forecasts, # 按天 {"2026-02-23": {...}, "2026-02-24": {...}} + "model_metadata": model_metadata, + "model_keys": model_keys, "dates": dates, "unit": "fahrenheit" if use_fahrenheit else "celsius", + "attribution": "Open-Meteo forecast model API; underlying models from ECMWF, DWD, ECCC, NOAA and JMA.", } with self._multi_model_cache_lock: self._multi_model_cache[cache_key] = { diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index e9fdd5f4..77e9c7da 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -161,8 +161,8 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour os.getenv("OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC", "900") ) self.multi_model_cache_version = str( - os.getenv("OPEN_METEO_MULTI_MODEL_CACHE_VERSION", "v2") - ).strip() or "v2" + os.getenv("OPEN_METEO_MULTI_MODEL_CACHE_VERSION", "v3") + ).strip() or "v3" self._open_meteo_cache: Dict[str, Dict] = {} self._ensemble_cache: Dict[str, Dict] = {} self._multi_model_cache: Dict[str, Dict] = {} diff --git a/tests/test_multi_model_sources.py b/tests/test_multi_model_sources.py new file mode 100644 index 00000000..374e947e --- /dev/null +++ b/tests/test_multi_model_sources.py @@ -0,0 +1,46 @@ +from src.data_collection.nws_open_meteo_sources import ( + OPEN_METEO_MULTI_MODEL_ORDER, + _parse_open_meteo_multi_model_daily, +) + + +def test_multi_model_parser_exposes_open_recommended_models(): + daily = { + "time": ["2026-04-17", "2026-04-18"], + "temperature_2m_max_ecmwf_ifs025": [20.1, 21.1], + "temperature_2m_max_ecmwf_aifs025_single": [20.2, 21.2], + "temperature_2m_max_icon_eu": [20.3, 21.3], + "temperature_2m_max_icon_d2": [20.4, None], + "temperature_2m_max_gem_global": [19.8, 20.8], + "temperature_2m_max_gem_regional": [21.0, 22.0], + "temperature_2m_max_gem_hrdps_continental": [21.5, None], + } + + dates, forecasts, metadata, model_keys = _parse_open_meteo_multi_model_daily(daily) + + assert dates == ["2026-04-17", "2026-04-18"] + assert forecasts["2026-04-17"]["ECMWF"] == 20.1 + assert forecasts["2026-04-17"]["ECMWF AIFS"] == 20.2 + assert forecasts["2026-04-17"]["ICON-EU"] == 20.3 + assert forecasts["2026-04-17"]["ICON-D2"] == 20.4 + assert forecasts["2026-04-17"]["GDPS"] == 19.8 + assert forecasts["2026-04-17"]["RDPS"] == 21.0 + assert forecasts["2026-04-17"]["HRDPS"] == 21.5 + assert "ICON-D2" not in forecasts["2026-04-18"] + assert metadata["ECMWF AIFS"]["provider"] == "ECMWF" + assert metadata["HRDPS"]["resolution_km"] == 2.5 + assert model_keys["RDPS"] == "gem_regional" + + +def test_multi_model_order_includes_legacy_and_new_sources(): + assert "ecmwf_ifs025" in OPEN_METEO_MULTI_MODEL_ORDER + assert "ecmwf_aifs025_single" in OPEN_METEO_MULTI_MODEL_ORDER + assert "gfs_seamless" in OPEN_METEO_MULTI_MODEL_ORDER + assert "icon_seamless" in OPEN_METEO_MULTI_MODEL_ORDER + assert "icon_eu" in OPEN_METEO_MULTI_MODEL_ORDER + assert "icon_d2" in OPEN_METEO_MULTI_MODEL_ORDER + assert "gem_seamless" in OPEN_METEO_MULTI_MODEL_ORDER + assert "gem_global" in OPEN_METEO_MULTI_MODEL_ORDER + assert "gem_regional" in OPEN_METEO_MULTI_MODEL_ORDER + assert "gem_hrdps_continental" in OPEN_METEO_MULTI_MODEL_ORDER + assert "jma_seamless" in OPEN_METEO_MULTI_MODEL_ORDER diff --git a/web/analysis_service.py b/web/analysis_service.py index 84008bce..0408365f 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -2313,6 +2313,14 @@ def _analyze( }, "source_forecasts": { "weather_gov": raw.get("nws") or {}, + "open_meteo_multi_model": { + "source": mm.get("source"), + "provider": mm.get("provider"), + "dates": mm.get("dates") or [], + "model_metadata": mm.get("model_metadata") or {}, + "model_keys": mm.get("model_keys") or {}, + "attribution": mm.get("attribution"), + } if isinstance(mm, dict) and mm else {}, }, "multi_model": {k: v for k, v in current_forecasts.items() if v is not None}, "multi_model_daily": multi_model_daily,