修复图表多模型缓存兜底

This commit is contained in:
2569718930@qq.com
2026-06-17 21:26:10 +08:00
parent 6279b8ac32
commit 4b4fb92070
4 changed files with 152 additions and 4 deletions
@@ -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,
+41
View File
@@ -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 = []
+57
View File
@@ -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
+52
View File
@@ -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],