diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index 7bc0b673..28f7a7d8 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -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(), diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index 085dceca..e782f7f3 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -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, + 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, diff --git a/src/data_collection/nws_open_meteo_sources.py b/src/data_collection/nws_open_meteo_sources.py index 8ac2f101..a4602fad 100644 --- a/src/data_collection/nws_open_meteo_sources.py +++ b/src/data_collection/nws_open_meteo_sources.py @@ -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) diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index f57ff3f4..3541a75c 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -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] = {} diff --git a/tests/test_multi_model_sources.py b/tests/test_multi_model_sources.py index 0029800d..733cf379 100644 --- a/tests/test_multi_model_sources.py +++ b/tests/test_multi_model_sources.py @@ -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"]