diff --git a/src/data_collection/forecast_source_bundle.py b/src/data_collection/forecast_source_bundle.py index 03589dd6..b25ed39c 100644 --- a/src/data_collection/forecast_source_bundle.py +++ b/src/data_collection/forecast_source_bundle.py @@ -46,10 +46,8 @@ def _read_open_meteo_bundle_from_cache( with collector._open_meteo_cache_lock: om_cached = collector._open_meteo_cache.get(om_key) - if not om_cached or not isinstance(om_cached.get("data"), dict): - return results - - results["open-meteo"] = dict(om_cached["data"]) + if om_cached and isinstance(om_cached.get("data"), dict): + results["open-meteo"] = dict(om_cached["data"]) if include_multi_model: mm_key = _multi_model_cache_key( collector, diff --git a/tests/test_multi_model_sources.py b/tests/test_multi_model_sources.py index 733cf379..9afeca17 100644 --- a/tests/test_multi_model_sources.py +++ b/tests/test_multi_model_sources.py @@ -265,6 +265,47 @@ def test_fetch_all_sources_delegates_non_hf_forecast_bundle(monkeypatch, tmp_pat assert result["multi_model"]["forecasts"]["ECMWF"] == 24.5 +def test_open_meteo_cache_only_reads_multi_model_without_forecast_cache(): + from src.data_collection.forecast_source_bundle import fetch_open_meteo_forecast_bundle + + class DummyLock: + def __enter__(self): + return None + + def __exit__(self, *_args): + return False + + class FakeCollector: + multi_model_cache_version = "v5" + _open_meteo_cache = {} + _open_meteo_cache_lock = DummyLock() + _multi_model_cache_lock = DummyLock() + _multi_model_cache = { + "48.9694:2.4414:paris:c:v5": { + "data": { + "hourly_times": ["2026-06-16T15:00"], + "hourly_forecasts": {"ECMWF": [24.5]}, + "forecasts": {"ECMWF": 27.0}, + } + } + } + + def _maybe_reload_open_meteo_disk_cache(self): + return None + + result = fetch_open_meteo_forecast_bundle( + FakeCollector(), + city="paris", + lat=48.9694, + lon=2.4414, + use_fahrenheit=False, + include_multi_model=True, + cache_only=True, + ) + + assert result["multi_model"]["hourly_forecasts"]["ECMWF"] == [24.5] + + def test_ensure_multi_model_hourly_payload_fetches_missing_hourly_outside_analysis_layer(): calls = [] diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 1dce203c..9ed0bbcd 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -1254,6 +1254,63 @@ def test_chart_data_cache_hit_starts_full_stale_refresh(monkeypatch): assert refresh_calls == ["paris"] +def test_chart_data_cache_hit_overlays_cached_multi_model_hourly(monkeypatch): + import asyncio + + class FakeCache: + def get_city_cache(self, kind, city): + assert kind == "full" + return { + "payload": { + "name": city, + "display_name": city.title(), + "local_date": "2026-06-16", + "local_time": "15:20", + "temp_symbol": "°C", + "current": {"temp": 20.0}, + "hourly": {"times": ["15:00"], "temps": [20.0]}, + "multi_model": {}, + }, + } + + def get_runway_obs_recent(self, icao, minutes=60): + return [] + + class DummyLock: + def __enter__(self): + return None + + def __exit__(self, *_args): + return False + + collector = city_api.legacy_routes._weather + monkeypatch.setattr(collector, "multi_model_cache_version", "v5") + monkeypatch.setattr(collector, "_open_meteo_cache", {}) + monkeypatch.setattr(collector, "_multi_model_cache", { + "48.9694:2.4414:paris:c:v5": { + "data": { + "hourly_times": ["2026-06-16T15:00"], + "hourly_forecasts": {"ECMWF": [24.5]}, + "forecasts": {"ECMWF": 27.0}, + } + } + }) + monkeypatch.setattr(collector, "_open_meteo_cache_lock", DummyLock()) + monkeypatch.setattr(collector, "_multi_model_cache_lock", DummyLock()) + monkeypatch.setattr(collector, "_maybe_reload_open_meteo_disk_cache", lambda: None) + monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeCache()) + monkeypatch.setattr(city_api.legacy_routes, "_city_cache_is_fresh", lambda entry, ttl: True) + monkeypatch.setattr( + city_api.legacy_routes, + "_overlay_latest_wunderground_current", + lambda city, payload: payload, + ) + + payload = asyncio.run(city_api._get_city_chart_data("paris", force_refresh=False)) + + assert payload["multi_model"]["hourly_forecasts"]["ECMWF"] == [24.5] + + def test_chart_data_cache_hit_overlays_latest_amsc_raw(monkeypatch): import asyncio diff --git a/web/services/city_api.py b/web/services/city_api.py index 24bd0c7d..685b46b2 100644 --- a/web/services/city_api.py +++ b/web/services/city_api.py @@ -14,6 +14,7 @@ from fastapi import HTTPException, Request from fastapi.concurrency import run_in_threadpool from loguru import logger +from src.data_collection.forecast_source_bundle import fetch_open_meteo_forecast_bundle import web.routes as legacy_routes from web.analysis_service import _runway_history_temp_for_city from web.services.canonical_temperature import build_city_weather_from_canonical @@ -722,6 +723,13 @@ async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, A fn=_overlay_cached_runway_history_from_db, args=(city, payload), ) + payload = await _run_optional_city_chart_overlay( + city=city, + overlay_name="multi_model_hourly", + payload=payload, + fn=_overlay_cached_multi_model_hourly, + args=(city, payload), + ) payload = await _overlay_latest_observation_sources(city, payload) return await _run_optional_city_chart_overlay( city=city, @@ -745,6 +753,13 @@ async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, A fn=_overlay_cached_runway_history_from_db, args=(city, payload), ) + payload = await _run_optional_city_chart_overlay( + city=city, + overlay_name="multi_model_hourly", + payload=payload, + fn=_overlay_cached_multi_model_hourly, + args=(city, payload), + ) payload = await _overlay_latest_observation_sources(city, payload) return await _run_optional_city_chart_overlay( city=city, @@ -760,6 +775,43 @@ async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, A } +def _overlay_cached_multi_model_hourly(city: str, payload: Dict[str, Any]) -> Dict[str, Any]: + current_multi_model = payload.get("multi_model") if isinstance(payload.get("multi_model"), dict) else {} + if current_multi_model.get("hourly_times") and current_multi_model.get("hourly_forecasts"): + return payload + + city_info = legacy_routes.CITIES.get(city) if isinstance(getattr(legacy_routes, "CITIES", None), dict) else None + if not isinstance(city_info, dict): + return payload + lat = city_info.get("lat") + lon = city_info.get("lon") + if lat is None or lon is None: + return payload + + cached_bundle = fetch_open_meteo_forecast_bundle( + legacy_routes._weather, + city=city, + lat=lat, + lon=lon, + use_fahrenheit=bool(city_info.get("f")), + include_multi_model=True, + cache_only=True, + ) + cached_multi_model = cached_bundle.get("multi_model") if isinstance(cached_bundle, dict) else None + if not isinstance(cached_multi_model, dict): + return payload + if not cached_multi_model.get("hourly_times") or not cached_multi_model.get("hourly_forecasts"): + return payload + + return { + **payload, + "multi_model": { + **current_multi_model, + **cached_multi_model, + }, + } + + def _city_detail_payload_cache_key( data: Dict[str, Any], market_slug: Optional[str],