修复图表多模型旧日期缓存

This commit is contained in:
2569718930@qq.com
2026-06-17 21:52:14 +08:00
parent 4b4fb92070
commit 566c47e683
2 changed files with 249 additions and 6 deletions
+135
View File
@@ -1311,6 +1311,141 @@ def test_chart_data_cache_hit_overlays_cached_multi_model_hourly(monkeypatch):
assert payload["multi_model"]["hourly_forecasts"]["ECMWF"] == [24.5]
def test_chart_data_cache_hit_replaces_stale_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-17",
"local_time": "15:20",
"temp_symbol": "°C",
"current": {"temp": 20.0},
"hourly": {"times": ["15:00"], "temps": [20.0]},
"multi_model": {
"hourly_times": ["2026-06-14T15:00", "2026-06-16T23:00"],
"hourly_forecasts": {"ECMWF": [21.0, 22.0]},
},
},
}
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-17T15:00", "2026-06-17T16:00"],
"hourly_forecasts": {"ECMWF": [24.5, 25.0]},
"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_times"] == ["2026-06-17T15:00", "2026-06-17T16:00"]
assert payload["multi_model"]["hourly_forecasts"]["ECMWF"] == [24.5, 25.0]
def test_chart_data_cache_hit_refreshes_when_multi_model_cache_is_stale(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-17",
"local_time": "15:20",
"temp_symbol": "°C",
"current": {"temp": 20.0},
"hourly": {"times": ["15:00"], "temps": [20.0]},
"multi_model": {
"hourly_times": ["2026-06-14T15:00", "2026-06-16T23:00"],
"hourly_forecasts": {"ECMWF": [21.0, 22.0]},
},
},
}
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
fetch_calls = []
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": {
"t": 1781695200,
"data": {
"hourly_times": ["2026-06-14T15:00", "2026-06-16T23:00"],
"hourly_forecasts": {"ECMWF": [21.0, 22.0]},
"forecasts": {"ECMWF": 22.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(
collector,
"fetch_multi_model",
lambda lat, lon, city, use_fahrenheit: fetch_calls.append((lat, lon, city, use_fahrenheit))
or {
"hourly_times": ["2026-06-17T15:00", "2026-06-17T16:00"],
"hourly_forecasts": {"ECMWF": [24.5, 25.0]},
"forecasts": {"ECMWF": 27.0},
},
)
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 fetch_calls == [(48.9694, 2.4414, "paris", False)]
assert payload["multi_model"]["hourly_times"] == ["2026-06-17T15:00", "2026-06-17T16:00"]
assert payload["multi_model"]["hourly_forecasts"]["ECMWF"] == [24.5, 25.0]
def test_chart_data_cache_hit_overlays_latest_amsc_raw(monkeypatch):
import asyncio
+114 -6
View File
@@ -14,7 +14,10 @@ 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
from src.data_collection.forecast_source_bundle import (
_multi_model_cache_key,
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
@@ -777,7 +780,8 @@ 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"):
local_date = _payload_local_date(payload)
if _multi_model_hourly_covers_local_date(current_multi_model, local_date):
return payload
city_info = legacy_routes.CITIES.get(city) if isinstance(getattr(legacy_routes, "CITIES", None), dict) else None
@@ -798,20 +802,124 @@ def _overlay_cached_multi_model_hourly(city: str, payload: Dict[str, Any]) -> Di
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"):
if isinstance(cached_multi_model, dict) and _multi_model_hourly_covers_local_date(cached_multi_model, local_date):
return {
**payload,
"multi_model": {
**current_multi_model,
**cached_multi_model,
},
}
fresh_multi_model = _refresh_multi_model_hourly_if_stale(
city=city,
lat=float(lat),
lon=float(lon),
use_fahrenheit=bool(city_info.get("f")),
local_date=local_date,
cached_multi_model=cached_multi_model,
)
if not _multi_model_hourly_covers_local_date(fresh_multi_model, local_date):
return payload
return {
**payload,
"multi_model": {
**current_multi_model,
**cached_multi_model,
**fresh_multi_model,
},
}
def _payload_local_date(payload: Dict[str, Any]) -> str:
overview = payload.get("overview") if isinstance(payload.get("overview"), dict) else {}
return str(payload.get("local_date") or overview.get("local_date") or "").strip()
def _multi_model_has_hourly_payload(multi_model: Any) -> bool:
if not isinstance(multi_model, dict):
return False
times = multi_model.get("hourly_times")
forecasts = multi_model.get("hourly_forecasts")
return bool(times) and isinstance(forecasts, dict) and bool(forecasts)
def _multi_model_hourly_covers_local_date(multi_model: Any, local_date: str) -> bool:
if not _multi_model_has_hourly_payload(multi_model):
return False
wanted_date = str(local_date or "").strip()
if not wanted_date:
return True
times = multi_model.get("hourly_times") or []
forecasts = multi_model.get("hourly_forecasts") or {}
for idx, raw_time in enumerate(times):
if not str(raw_time or "").startswith(wanted_date):
continue
for values in forecasts.values():
if isinstance(values, list) and idx < len(values) and values[idx] is not None:
return True
return False
def _evict_stale_multi_model_cache_entry(
*,
city: str,
lat: float,
lon: float,
use_fahrenheit: bool,
local_date: str,
cached_multi_model: Any,
) -> None:
if _multi_model_hourly_covers_local_date(cached_multi_model, local_date):
return
try:
key = _multi_model_cache_key(
legacy_routes._weather,
city,
lat,
lon,
use_fahrenheit=use_fahrenheit,
)
with legacy_routes._weather._multi_model_cache_lock:
entry = legacy_routes._weather._multi_model_cache.get(key)
data = entry.get("data") if isinstance(entry, dict) else None
if isinstance(data, dict) and not _multi_model_hourly_covers_local_date(data, local_date):
legacy_routes._weather._multi_model_cache.pop(key, None)
except Exception as exc:
logger.debug("stale multi-model cache eviction skipped city={}: {}", city, exc)
def _refresh_multi_model_hourly_if_stale(
*,
city: str,
lat: float,
lon: float,
use_fahrenheit: bool,
local_date: str,
cached_multi_model: Any,
) -> Dict[str, Any]:
_evict_stale_multi_model_cache_entry(
city=city,
lat=lat,
lon=lon,
use_fahrenheit=use_fahrenheit,
local_date=local_date,
cached_multi_model=cached_multi_model,
)
try:
fresh = legacy_routes._weather.fetch_multi_model(
lat,
lon,
city=city,
use_fahrenheit=use_fahrenheit,
)
except Exception as exc:
logger.debug("multi-model chart refresh skipped city={}: {}", city, exc)
return {}
return fresh if isinstance(fresh, dict) else {}
def _city_detail_payload_cache_key(
data: Dict[str, Any],
market_slug: Optional[str],