修复图表多模型旧日期缓存
This commit is contained in:
@@ -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
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user