修复 AROME HD 旧预测片段渲染
This commit is contained in:
@@ -226,6 +226,76 @@ export function runTests() {
|
||||
"chart should use the current scan row local date when cached detail localDate is older so today's model curve is drawn through 23:00",
|
||||
);
|
||||
|
||||
const staleShortRangeModelChart = buildFullDayChartData(
|
||||
{
|
||||
city: "paris",
|
||||
local_date: "2026-06-16",
|
||||
local_time: "19:33",
|
||||
temp_symbol: "°C",
|
||||
tz_offset_seconds: 2 * 3600,
|
||||
} as any,
|
||||
{
|
||||
forecastTodayHigh: 27,
|
||||
debPrediction: 27,
|
||||
localDate: "2026-06-16",
|
||||
localTime: "19:33",
|
||||
times: fullDayHourlyTimes,
|
||||
temps: fullDayHourlyTemps,
|
||||
debHourlyPath: {
|
||||
times: fullDayHourlyTimes,
|
||||
temps: fullDayHourlyTimes.map((_, hour) => 16 + Math.sin((hour / 23) * Math.PI) * 8),
|
||||
},
|
||||
modelTimes: fullDayHourlyTimes,
|
||||
modelCurves: {
|
||||
"AROME HD": [
|
||||
25.1,
|
||||
25.0,
|
||||
24.1,
|
||||
24.0,
|
||||
23.9,
|
||||
22.8,
|
||||
...Array.from({ length: 18 }, () => null),
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
true,
|
||||
);
|
||||
assert(
|
||||
!staleShortRangeModelChart.series.some((item) => item.key === "model_curve_AROME HD"),
|
||||
"Paris AROME HD should not render a stale early-morning fragment as a live prediction curve at 19:33",
|
||||
);
|
||||
|
||||
const freshShortRangeModelChart = buildFullDayChartData(
|
||||
{
|
||||
city: "paris",
|
||||
local_date: "2026-06-16",
|
||||
local_time: "19:33",
|
||||
temp_symbol: "°C",
|
||||
tz_offset_seconds: 2 * 3600,
|
||||
} as any,
|
||||
{
|
||||
forecastTodayHigh: 27,
|
||||
debPrediction: 27,
|
||||
localDate: "2026-06-16",
|
||||
localTime: "19:33",
|
||||
times: fullDayHourlyTimes,
|
||||
temps: fullDayHourlyTemps,
|
||||
debHourlyPath: {
|
||||
times: fullDayHourlyTimes,
|
||||
temps: fullDayHourlyTimes.map((_, hour) => 16 + Math.sin((hour / 23) * Math.PI) * 8),
|
||||
},
|
||||
modelTimes: fullDayHourlyTimes,
|
||||
modelCurves: {
|
||||
"AROME HD": fullDayHourlyTimes.map((_, hour) => 21 + Math.sin((hour / 23) * Math.PI) * 7),
|
||||
},
|
||||
} as any,
|
||||
true,
|
||||
);
|
||||
assert(
|
||||
freshShortRangeModelChart.series.some((item) => item.key === "model_curve_AROME HD"),
|
||||
"Paris AROME HD should still render when the curve has current or future points",
|
||||
);
|
||||
|
||||
_hourlyCache.clear();
|
||||
_hourlyCache.set("madrid:10m", {
|
||||
ts: Date.now(),
|
||||
|
||||
@@ -371,6 +371,8 @@ const HOURLY_DETAIL_REQUEST_TIMEOUT_MS = 16_000;
|
||||
let _hourlyActiveDetailRequests = 0;
|
||||
const _hourlyDetailRequestQueue: Array<() => void> = [];
|
||||
const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
|
||||
const SHORT_RANGE_MODEL_CURVES = new Set(["AROME HD", "HRRR", "NAM", "ICON-D2", "HRDPS"]);
|
||||
const SHORT_RANGE_MODEL_STALE_GRACE_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:";
|
||||
|
||||
@@ -2799,6 +2801,38 @@ function resolveModelCurveTimes(
|
||||
return hourly?.times?.length === modelTemps.length ? hourly.times : [];
|
||||
}
|
||||
|
||||
function isShortRangeModelCurve(model: string) {
|
||||
return SHORT_RANGE_MODEL_CURVES.has(String(model || "").trim().toUpperCase());
|
||||
}
|
||||
|
||||
function shouldRenderModelCurve(
|
||||
model: string,
|
||||
values: Array<number | null>,
|
||||
timeline: number[],
|
||||
row: ScanOpportunityRow | null,
|
||||
hourly: ChartRenderState,
|
||||
tzOffsetSeconds: number,
|
||||
localDateStr: string,
|
||||
) {
|
||||
if (!isShortRangeModelCurve(model)) return true;
|
||||
const currentTs = getCityLocalUtcTimestamp(
|
||||
hourly?.localTime || row?.local_time,
|
||||
tzOffsetSeconds,
|
||||
localDateStr,
|
||||
);
|
||||
if (currentTs === null) return true;
|
||||
|
||||
let latestValidTs: number | null = null;
|
||||
values.forEach((value, index) => {
|
||||
if (validNumber(value) === null) return;
|
||||
const ts = timeline[index];
|
||||
if (!Number.isFinite(ts)) return;
|
||||
latestValidTs = latestValidTs === null ? ts : Math.max(latestValidTs, ts);
|
||||
});
|
||||
|
||||
return latestValidTs !== null && latestValidTs >= currentTs - SHORT_RANGE_MODEL_STALE_GRACE_MS;
|
||||
}
|
||||
|
||||
function probabilityBucketValue(bucket: ProbabilityBucket) {
|
||||
return validNumber(bucket.value ?? (bucket as any).temp ?? (bucket as any).temperature);
|
||||
}
|
||||
@@ -3132,6 +3166,7 @@ function buildFullDayChartData(
|
||||
localDayBounds,
|
||||
);
|
||||
if (vals.some((v) => v !== null)) {
|
||||
if (!shouldRenderModelCurve(model, vals, timeline, row, hourly, tzOffset, localDateStr)) return;
|
||||
series.push({
|
||||
key: `model_curve_${model}`,
|
||||
label: model,
|
||||
|
||||
@@ -157,6 +157,11 @@ OPEN_METEO_MULTI_MODEL_SPECS: Dict[str, Dict[str, Any]] = {
|
||||
}
|
||||
|
||||
OPEN_METEO_MULTI_MODEL_ORDER = tuple(OPEN_METEO_MULTI_MODEL_SPECS.keys())
|
||||
SHORT_RANGE_HOURLY_MODEL_LABELS = frozenset(
|
||||
str(spec["label"])
|
||||
for spec in OPEN_METEO_MULTI_MODEL_SPECS.values()
|
||||
if str(spec.get("tier") or "").startswith("short_range")
|
||||
)
|
||||
|
||||
|
||||
def _parse_open_meteo_multi_model_daily(
|
||||
@@ -320,14 +325,22 @@ def _merge_multi_model_result_with_cache(
|
||||
cutoff = fresh_hourly_times[0]
|
||||
all_hourly_times = [t for t in all_hourly_times if t >= cutoff]
|
||||
|
||||
fresh_hourly_labels = {
|
||||
str(label)
|
||||
for label, values in fresh_hourly_forecasts.items()
|
||||
if isinstance(values, list)
|
||||
}
|
||||
|
||||
for t_str in all_hourly_times:
|
||||
c_hour = cached_hourly_by_time.get(t_str) or {}
|
||||
f_hour = fresh_hourly_by_time.get(t_str) or {}
|
||||
merged_hourly_by_time[t_str] = (
|
||||
dict(f_hour)
|
||||
if _count_models(f_hour) >= _count_models(c_hour)
|
||||
else {**dict(c_hour), **dict(f_hour)}
|
||||
)
|
||||
reusable_cached_hour = {
|
||||
label: value
|
||||
for label, value in c_hour.items()
|
||||
if label not in fresh_hourly_labels
|
||||
and label not in SHORT_RANGE_HOURLY_MODEL_LABELS
|
||||
}
|
||||
merged_hourly_by_time[t_str] = {**reusable_cached_hour, **dict(f_hour)}
|
||||
|
||||
merged_hourly_forecasts = _reconstruct_hourly_forecasts(all_hourly_times, merged_hourly_by_time)
|
||||
|
||||
|
||||
@@ -184,8 +184,8 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
||||
)
|
||||
self.open_meteo_multi_model_cache_ttl_sec = min(self.open_meteo_multi_model_cache_ttl_sec, MODEL_CACHE_TTL_SEC)
|
||||
self.multi_model_cache_version = str(
|
||||
os.getenv("OPEN_METEO_MULTI_MODEL_CACHE_VERSION", "v4")
|
||||
).strip() or "v4"
|
||||
os.getenv("OPEN_METEO_MULTI_MODEL_CACHE_VERSION", "v5")
|
||||
).strip() or "v5"
|
||||
self._open_meteo_cache: Dict[str, Dict] = {}
|
||||
self._ensemble_cache: Dict[str, Dict] = {}
|
||||
self._multi_model_cache: Dict[str, Dict] = {}
|
||||
|
||||
@@ -92,7 +92,7 @@ def test_multi_model_default_cache_version_refreshes_noaa_model_set(monkeypatch)
|
||||
|
||||
collector = WeatherDataCollector({})
|
||||
|
||||
assert collector.multi_model_cache_version == "v4"
|
||||
assert collector.multi_model_cache_version == "v5"
|
||||
|
||||
|
||||
def test_madis_patch_uses_city_display_unit_for_us(monkeypatch):
|
||||
@@ -467,3 +467,47 @@ def test_merge_multi_model_result_with_cache_hourly():
|
||||
assert merged["hourly_forecasts"]["ECMWF"] == [15.1, 15.3]
|
||||
assert merged["hourly_forecasts"]["GFS"] == [14.5, None]
|
||||
assert merged["hourly_forecasts"]["ICON-EU"] == [14.8, None]
|
||||
|
||||
|
||||
def test_merge_multi_model_result_drops_missing_short_range_hourly_from_cache():
|
||||
from src.data_collection.nws_open_meteo_sources import _merge_multi_model_result_with_cache
|
||||
|
||||
cached = {
|
||||
"forecasts": {"ECMWF": 28.0, "AROME HD": 27.0},
|
||||
"daily_forecasts": {
|
||||
"2026-06-16": {"ECMWF": 28.0, "AROME HD": 27.0}
|
||||
},
|
||||
"hourly_times": [
|
||||
"2026-06-16T00:00",
|
||||
"2026-06-16T01:00",
|
||||
"2026-06-16T02:00",
|
||||
"2026-06-16T03:00",
|
||||
"2026-06-16T04:00",
|
||||
"2026-06-16T05:00",
|
||||
],
|
||||
"hourly_forecasts": {
|
||||
"ECMWF": [21.0, 21.5, 22.0, 22.5, 23.0, 23.5],
|
||||
"AROME HD": [25.0, 24.5, 24.0, 23.5, 23.0, 22.5],
|
||||
},
|
||||
}
|
||||
fresh = {
|
||||
"forecasts": {"ECMWF": 28.2},
|
||||
"daily_forecasts": {"2026-06-16": {"ECMWF": 28.2}},
|
||||
"hourly_times": [
|
||||
"2026-06-16T00:00",
|
||||
"2026-06-16T01:00",
|
||||
"2026-06-16T02:00",
|
||||
"2026-06-16T03:00",
|
||||
"2026-06-16T04:00",
|
||||
"2026-06-16T05:00",
|
||||
"2026-06-16T06:00",
|
||||
],
|
||||
"hourly_forecasts": {
|
||||
"ECMWF": [21.2, 21.7, 22.2, 22.7, 23.2, 23.7, 24.0],
|
||||
},
|
||||
}
|
||||
|
||||
merged = _merge_multi_model_result_with_cache(cached, fresh)
|
||||
|
||||
assert merged["hourly_forecasts"]["ECMWF"] == [21.2, 21.7, 22.2, 22.7, 23.2, 23.7, 24.0]
|
||||
assert "AROME HD" not in merged["hourly_forecasts"]
|
||||
|
||||
Reference in New Issue
Block a user