From a83200d5055677dd98999d9185b2f96a2520d393 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 17 May 2026 19:12:28 +0800 Subject: [PATCH] Fix Ankara decision chart model coverage --- .../__tests__/temperatureChartData.test.ts | 36 +++++++++++++ frontend/lib/chart-utils.ts | 40 ++++++++++++--- src/data_collection/weather_sources.py | 19 ++++++- tests/test_multi_model_sources.py | 50 +++++++++++++++++++ 4 files changed, 135 insertions(+), 10 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index 80778b58..3f7a27a7 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -37,4 +37,40 @@ export function runTests() { chartData?.datasets.debSeries.some((point) => point.labelTime === "10:00" && point.y === 24), "temperature chart should keep normalized hourly temperatures on the curve", ); + + const ankaraChartData = getTemperatureChartData( + { + name: "ankara", + display_name: "Ankara", + local_date: "2026-05-17", + local_time: "13:00", + temp_symbol: "°C", + current: { + temp: 18, + obs_time: "2026-05-17T10:50:00Z", + settlement_source: "mgm", + }, + forecast: { today_high: null }, + deb: { prediction: 24 }, + mgm: { + hourly: [ + { time: "11:00", temp: 19 }, + { time: "12:00", temp: 21 }, + { time: "13:00", temp: 22 }, + { time: "14:00", temp: 23 }, + ], + }, + metar_today_obs: [{ time: "13:00", temp: 22 }], + } as unknown as CityDetail, + "zh-CN", + ); + + assert( + ankaraChartData?.datasets.debSeries.some((point) => point.labelTime === "13:00"), + "Ankara chart should build the DEB original path from MGM hourly data when Open-Meteo hourly is unavailable", + ); + assert( + ankaraChartData?.datasets.calibratedFutureSeries.length, + "Ankara chart should still expose a calibrated path when observation points exist", + ); } diff --git a/frontend/lib/chart-utils.ts b/frontend/lib/chart-utils.ts index 9035fc18..53cc6784 100644 --- a/frontend/lib/chart-utils.ts +++ b/frontend/lib/chart-utils.ts @@ -370,8 +370,24 @@ export function getTemperatureChartData( locale: Locale = "zh-CN", ) { const hourly = detail.hourly || {}; - const rawTimes = Array.isArray(hourly.times) ? hourly.times : []; - const rawTemps = Array.isArray(hourly.temps) ? hourly.temps : []; + const mgmHourlyRows = Array.isArray(detail.mgm?.hourly) + ? detail.mgm?.hourly || [] + : []; + const hasPrimaryHourly = + Array.isArray(hourly.times) && + Array.isArray(hourly.temps) && + Math.min(hourly.times.length, hourly.temps.length) > 0; + const useMgmHourlyAsForecastBase = !hasPrimaryHourly && isTurkishMgmCity(detail); + const rawTimes = useMgmHourlyAsForecastBase + ? mgmHourlyRows.map((row) => String(row?.time || "")) + : Array.isArray(hourly.times) + ? hourly.times + : []; + const rawTemps = useMgmHourlyAsForecastBase + ? mgmHourlyRows.map((row) => row?.temp ?? null) + : Array.isArray(hourly.temps) + ? hourly.temps + : []; const validEntries = rawTimes .map((time, index) => ({ tail: normalizeHm(String(time || "").trim()) || "", @@ -404,7 +420,14 @@ export function getTemperatureChartData( if (!times.length) return null; const currentIndex = findNearestTimeIndex(times, detail.local_time); - const omMax = detail.forecast?.today_high; + const mgmHourlyMax = mgmHourlyRows + .map((row) => Number(row?.temp)) + .filter((value) => Number.isFinite(value)) + .reduce( + (maxValue, value) => (maxValue == null ? value : Math.max(maxValue, value)), + null, + ); + const omMax = detail.forecast?.today_high ?? mgmHourlyMax; const debMax = detail.deb?.prediction; const offset = debMax != null && omMax != null ? Number(debMax) - Number(omMax) : 0; @@ -570,9 +593,6 @@ export function getTemperatureChartData( const mgmHourlyPoints = new Array(times.length).fill(null); let hasMgmHourly = false; - const mgmHourlyRows = Array.isArray(detail.mgm?.hourly) - ? detail.mgm?.hourly || [] - : []; mgmHourlyRows.forEach((item) => { const index = findNearestTimeIndex(times, String(item.time || "")); const temp = Number(item.temp); @@ -771,8 +791,12 @@ export function getTemperatureChartData( if (hasMgmHourly) { legendParts.push( isEnglish(locale) - ? "Using MGM hourly forecast to replace DEB curve" - : "已使用 MGM 小时预报替代 DEB 曲线", + ? useMgmHourlyAsForecastBase + ? "Using MGM hourly forecast as the DEB curve base" + : "MGM hourly forecast is shown as official hourly guidance" + : useMgmHourlyAsForecastBase + ? "已使用 MGM 小时预报作为 DEB 曲线基底" + : "MGM 小时预报作为官方小时指引显示", ); } if ((detail.trend?.recent?.length || 0) > 0 || observationSource.length > 0) { diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index b3d05152..d0645e07 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -1322,6 +1322,21 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour self._attach_settlement_sources(results, city_lower) if lat and lon: + # Prioritize the model cluster before the regular Open-Meteo + # forecast. The regular forecast endpoint can set the shared + # Open-Meteo 429 cooldown; if that happens first, cities with no + # existing multi-model cache (notably Ankara after a deploy) fall + # back to a single Open-Meteo/DEB line and the decision card loses + # most of its model support. Fetching the multi-model payload + # first gives the richer, longer-lived model cache the first chance + # to populate; the regular forecast can still use its stale cache + # if Open-Meteo rate-limits the cycle. + if include_multi_model: + multi_model_data = self.fetch_multi_model( + lat, lon, city=city, use_fahrenheit=use_fahrenheit + ) + if multi_model_data: + results["multi_model"] = multi_model_data open_meteo = self.fetch_from_open_meteo( lat, lon, use_fahrenheit=use_fahrenheit ) @@ -1370,7 +1385,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour lon, use_fahrenheit, include_ensemble=include_ensemble, - include_multi_model=include_multi_model, + include_multi_model=False, ) else: fallback_utc_offset = int( @@ -1419,7 +1434,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour lon, use_fahrenheit, include_ensemble=include_ensemble, - include_multi_model=include_multi_model, + include_multi_model=False, ) else: if supports_aviationweather: diff --git a/tests/test_multi_model_sources.py b/tests/test_multi_model_sources.py index 374e947e..5445b16f 100644 --- a/tests/test_multi_model_sources.py +++ b/tests/test_multi_model_sources.py @@ -2,6 +2,7 @@ from src.data_collection.nws_open_meteo_sources import ( OPEN_METEO_MULTI_MODEL_ORDER, _parse_open_meteo_multi_model_daily, ) +from src.data_collection.weather_sources import WeatherDataCollector def test_multi_model_parser_exposes_open_recommended_models(): @@ -44,3 +45,52 @@ def test_multi_model_order_includes_legacy_and_new_sources(): 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 + + +def test_fetch_all_sources_prioritizes_multi_model_before_forecast(monkeypatch, tmp_path): + monkeypatch.setenv("OPEN_METEO_DISK_CACHE_PATH", str(tmp_path / "om-cache.json")) + collector = WeatherDataCollector({}) + calls = [] + + monkeypatch.setattr(collector, "_log_temperature_unit", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_attach_settlement_sources", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_supports_aviationweather", lambda city: False) + monkeypatch.setattr(collector, "_attach_turkish_mgm_data", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_attach_korean_amos_data", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_attach_china_amsc_awos_data", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_attach_madis_hfmetar_data", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_attach_singapore_mss_data", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_attach_china_official_nearby", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_attach_japan_official_nearby", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_attach_fmi_official_nearby", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_attach_knmi_official_nearby", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_attach_hko_obs_official_nearby", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_attach_cwa_settlement_nearby", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_attach_russia_official_nearby", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_attach_global_nearby_cluster", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "fetch_ensemble", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "fetch_nws", lambda *args, **kwargs: None) + + def fake_multi_model(*args, **kwargs): + calls.append("multi_model") + return {"forecasts": {"ECMWF": 24.0, "GFS": 25.0}} + + def fake_open_meteo(*args, **kwargs): + calls.append("open_meteo") + return {"utc_offset": 10800, "daily": {"temperature_2m_max": [24]}} + + monkeypatch.setattr(collector, "fetch_multi_model", fake_multi_model) + monkeypatch.setattr(collector, "fetch_from_open_meteo", fake_open_meteo) + + result = collector.fetch_all_sources( + "ankara", + lat=40.1281, + lon=32.9951, + include_ensemble=False, + include_nearby=False, + include_taf=False, + include_mgm=False, + ) + + assert calls[:2] == ["multi_model", "open_meteo"] + assert result["multi_model"]["forecasts"]["ECMWF"] == 24.0