Fix Ankara decision chart model coverage
This commit is contained in:
@@ -37,4 +37,40 @@ export function runTests() {
|
|||||||
chartData?.datasets.debSeries.some((point) => point.labelTime === "10:00" && point.y === 24),
|
chartData?.datasets.debSeries.some((point) => point.labelTime === "10:00" && point.y === 24),
|
||||||
"temperature chart should keep normalized hourly temperatures on the curve",
|
"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",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,8 +370,24 @@ export function getTemperatureChartData(
|
|||||||
locale: Locale = "zh-CN",
|
locale: Locale = "zh-CN",
|
||||||
) {
|
) {
|
||||||
const hourly = detail.hourly || {};
|
const hourly = detail.hourly || {};
|
||||||
const rawTimes = Array.isArray(hourly.times) ? hourly.times : [];
|
const mgmHourlyRows = Array.isArray(detail.mgm?.hourly)
|
||||||
const rawTemps = Array.isArray(hourly.temps) ? hourly.temps : [];
|
? 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
|
const validEntries = rawTimes
|
||||||
.map((time, index) => ({
|
.map((time, index) => ({
|
||||||
tail: normalizeHm(String(time || "").trim()) || "",
|
tail: normalizeHm(String(time || "").trim()) || "",
|
||||||
@@ -404,7 +420,14 @@ export function getTemperatureChartData(
|
|||||||
if (!times.length) return null;
|
if (!times.length) return null;
|
||||||
|
|
||||||
const currentIndex = findNearestTimeIndex(times, detail.local_time);
|
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<number | null>(
|
||||||
|
(maxValue, value) => (maxValue == null ? value : Math.max(maxValue, value)),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const omMax = detail.forecast?.today_high ?? mgmHourlyMax;
|
||||||
const debMax = detail.deb?.prediction;
|
const debMax = detail.deb?.prediction;
|
||||||
const offset =
|
const offset =
|
||||||
debMax != null && omMax != null ? Number(debMax) - Number(omMax) : 0;
|
debMax != null && omMax != null ? Number(debMax) - Number(omMax) : 0;
|
||||||
@@ -570,9 +593,6 @@ export function getTemperatureChartData(
|
|||||||
|
|
||||||
const mgmHourlyPoints = new Array(times.length).fill(null);
|
const mgmHourlyPoints = new Array(times.length).fill(null);
|
||||||
let hasMgmHourly = false;
|
let hasMgmHourly = false;
|
||||||
const mgmHourlyRows = Array.isArray(detail.mgm?.hourly)
|
|
||||||
? detail.mgm?.hourly || []
|
|
||||||
: [];
|
|
||||||
mgmHourlyRows.forEach((item) => {
|
mgmHourlyRows.forEach((item) => {
|
||||||
const index = findNearestTimeIndex(times, String(item.time || ""));
|
const index = findNearestTimeIndex(times, String(item.time || ""));
|
||||||
const temp = Number(item.temp);
|
const temp = Number(item.temp);
|
||||||
@@ -771,8 +791,12 @@ export function getTemperatureChartData(
|
|||||||
if (hasMgmHourly) {
|
if (hasMgmHourly) {
|
||||||
legendParts.push(
|
legendParts.push(
|
||||||
isEnglish(locale)
|
isEnglish(locale)
|
||||||
? "Using MGM hourly forecast to replace DEB curve"
|
? useMgmHourlyAsForecastBase
|
||||||
: "已使用 MGM 小时预报替代 DEB 曲线",
|
? "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) {
|
if ((detail.trend?.recent?.length || 0) > 0 || observationSource.length > 0) {
|
||||||
|
|||||||
@@ -1322,6 +1322,21 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
self._attach_settlement_sources(results, city_lower)
|
self._attach_settlement_sources(results, city_lower)
|
||||||
|
|
||||||
if lat and lon:
|
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(
|
open_meteo = self.fetch_from_open_meteo(
|
||||||
lat, lon, use_fahrenheit=use_fahrenheit
|
lat, lon, use_fahrenheit=use_fahrenheit
|
||||||
)
|
)
|
||||||
@@ -1370,7 +1385,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
lon,
|
lon,
|
||||||
use_fahrenheit,
|
use_fahrenheit,
|
||||||
include_ensemble=include_ensemble,
|
include_ensemble=include_ensemble,
|
||||||
include_multi_model=include_multi_model,
|
include_multi_model=False,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
fallback_utc_offset = int(
|
fallback_utc_offset = int(
|
||||||
@@ -1419,7 +1434,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
lon,
|
lon,
|
||||||
use_fahrenheit,
|
use_fahrenheit,
|
||||||
include_ensemble=include_ensemble,
|
include_ensemble=include_ensemble,
|
||||||
include_multi_model=include_multi_model,
|
include_multi_model=False,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if supports_aviationweather:
|
if supports_aviationweather:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from src.data_collection.nws_open_meteo_sources import (
|
|||||||
OPEN_METEO_MULTI_MODEL_ORDER,
|
OPEN_METEO_MULTI_MODEL_ORDER,
|
||||||
_parse_open_meteo_multi_model_daily,
|
_parse_open_meteo_multi_model_daily,
|
||||||
)
|
)
|
||||||
|
from src.data_collection.weather_sources import WeatherDataCollector
|
||||||
|
|
||||||
|
|
||||||
def test_multi_model_parser_exposes_open_recommended_models():
|
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_regional" in OPEN_METEO_MULTI_MODEL_ORDER
|
||||||
assert "gem_hrdps_continental" 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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user