fix: keep chart runway history current
This commit is contained in:
@@ -853,6 +853,78 @@ def test_city_detail_batch_chart_scope_returns_only_chart_fields(monkeypatch):
|
|||||||
assert "ai_analysis" not in detail
|
assert "ai_analysis" not in detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_chart_scope_overlays_collector_runway_history_from_db(monkeypatch):
|
||||||
|
monkeypatch.setattr(city_api.legacy_routes, "_assert_entitlement", lambda request: None)
|
||||||
|
monkeypatch.setattr(city_api.legacy_routes, "_normalize_city_or_404", lambda name: name.strip().lower())
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeCache:
|
||||||
|
def get_city_cache(self, kind, city):
|
||||||
|
assert kind == "full"
|
||||||
|
return {
|
||||||
|
"payload": {
|
||||||
|
"name": city,
|
||||||
|
"display_name": city.title(),
|
||||||
|
"local_date": "2026-06-06",
|
||||||
|
"local_time": "13:28",
|
||||||
|
"temp_symbol": "°C",
|
||||||
|
"risk": {"icao": "ZSPD"},
|
||||||
|
"current": {
|
||||||
|
"temp": 25.0,
|
||||||
|
"settlement_source": "metar",
|
||||||
|
"settlement_source_label": "METAR",
|
||||||
|
},
|
||||||
|
"hourly": {"times": ["13:00"], "temps": [25.0]},
|
||||||
|
"forecast": {"today_high": 26.0, "daily": []},
|
||||||
|
"multi_model": {},
|
||||||
|
"deb": {"prediction": 26.0},
|
||||||
|
"probabilities": {"mu": 26.0, "distribution": []},
|
||||||
|
"runway_plate_history": {
|
||||||
|
"35R/17L": [{"time": "2026-06-06T05:21:00+00:00", "temp": 24.2}]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_runway_obs_recent(self, icao, minutes=60):
|
||||||
|
assert icao == "ZSPD"
|
||||||
|
assert minutes == 36 * 60
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"runway": "35R/17L",
|
||||||
|
"tdz_temp": 24.2,
|
||||||
|
"mid_temp": None,
|
||||||
|
"end_temp": 24.0,
|
||||||
|
"target_runway_max": 24.2,
|
||||||
|
"otime_utc": "2026-06-06T05:21:00+00:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"runway": "35R/17L",
|
||||||
|
"tdz_temp": 24.8,
|
||||||
|
"mid_temp": None,
|
||||||
|
"end_temp": 24.6,
|
||||||
|
"target_runway_max": 24.8,
|
||||||
|
"otime_utc": "2026-06-06T05:28:00+00:00",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeCache())
|
||||||
|
|
||||||
|
response = client.get("/api/cities/detail-batch?cities=Shanghai&resolution=1m&scope=chart")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
history = response.json()["details"]["shanghai"]["runway_plate_history"]["35R/17L"]
|
||||||
|
assert history[-1] == {"time": "2026-06-06T05:28:00+00:00", "temp": 24.8}
|
||||||
|
|
||||||
|
|
||||||
def test_chart_detail_payload_uses_threadpool_and_reuses_short_cache(monkeypatch):
|
def test_chart_detail_payload_uses_threadpool_and_reuses_short_cache(monkeypatch):
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import os
|
|||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from copy import deepcopy
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
@@ -13,6 +14,7 @@ from fastapi.concurrency import run_in_threadpool
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
import web.routes as legacy_routes
|
import web.routes as legacy_routes
|
||||||
|
from web.analysis_service import _runway_history_temp_for_city
|
||||||
from web.services.request_timing import ServerTimingRecorder
|
from web.services.request_timing import ServerTimingRecorder
|
||||||
|
|
||||||
_RECENT_DEB_CACHE: Optional[Dict[str, Dict[str, object]]] = None
|
_RECENT_DEB_CACHE: Optional[Dict[str, Dict[str, object]]] = None
|
||||||
@@ -70,6 +72,65 @@ async def _overlay_cached_wunderground(city: str, payload: Dict[str, Any]) -> Di
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _overlay_cached_runway_history_from_db(city: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if not isinstance(payload, dict) or not payload:
|
||||||
|
return payload
|
||||||
|
normalized_city = str(city or payload.get("name") or payload.get("city") or "").strip().lower()
|
||||||
|
if not normalized_city:
|
||||||
|
return payload
|
||||||
|
|
||||||
|
risk = payload.get("risk") if isinstance(payload.get("risk"), dict) else {}
|
||||||
|
city_risk = legacy_routes.CITY_RISK_PROFILES.get(normalized_city, {}) or {}
|
||||||
|
city_meta = legacy_routes.CITY_REGISTRY.get(normalized_city, {}) or {}
|
||||||
|
icao = str(
|
||||||
|
risk.get("icao")
|
||||||
|
or city_risk.get("icao")
|
||||||
|
or city_meta.get("icao")
|
||||||
|
or ""
|
||||||
|
).strip().upper()
|
||||||
|
if not icao:
|
||||||
|
return payload
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = legacy_routes._CACHE_DB.get_runway_obs_recent(icao, minutes=36 * 60)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("chart runway DB overlay skipped city={} icao={}: {}", normalized_city, icao, exc)
|
||||||
|
return payload
|
||||||
|
if not rows:
|
||||||
|
return payload
|
||||||
|
|
||||||
|
use_fahrenheit = (
|
||||||
|
"F" in str(payload.get("temp_symbol") or "").upper()
|
||||||
|
or bool((legacy_routes.CITIES.get(normalized_city, {}) or {}).get("f"))
|
||||||
|
)
|
||||||
|
runway_history: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
runway = str(row.get("runway") or "").strip().upper()
|
||||||
|
time_val = row.get("otime_utc") or row.get("created_at")
|
||||||
|
if not runway or not time_val:
|
||||||
|
continue
|
||||||
|
temp_val = _runway_history_temp_for_city(normalized_city, row)
|
||||||
|
if temp_val is None:
|
||||||
|
continue
|
||||||
|
if use_fahrenheit:
|
||||||
|
temp_val = temp_val * 9.0 / 5.0 + 32.0
|
||||||
|
runway_history.setdefault(runway, []).append(
|
||||||
|
{
|
||||||
|
"time": str(time_val),
|
||||||
|
"temp": round(float(temp_val), 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not runway_history:
|
||||||
|
return payload
|
||||||
|
|
||||||
|
next_payload = deepcopy(payload)
|
||||||
|
next_payload["runway_plate_history"] = runway_history
|
||||||
|
return next_payload
|
||||||
|
|
||||||
|
|
||||||
async def _refresh_city_full_cache_singleflight(city: str, force_refresh: bool) -> Dict[str, Any]:
|
async def _refresh_city_full_cache_singleflight(city: str, force_refresh: bool) -> Dict[str, Any]:
|
||||||
key = f"{city}:{bool(force_refresh)}"
|
key = f"{city}:{bool(force_refresh)}"
|
||||||
async with _CITY_FULL_REFRESH_LOCK:
|
async with _CITY_FULL_REFRESH_LOCK:
|
||||||
@@ -165,6 +226,11 @@ async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, A
|
|||||||
if payload:
|
if payload:
|
||||||
if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_FULL_CACHE_TTL_SEC):
|
if not legacy_routes._city_cache_is_fresh(cached_entry, legacy_routes.CITY_FULL_CACHE_TTL_SEC):
|
||||||
_start_city_full_stale_refresh(city)
|
_start_city_full_stale_refresh(city)
|
||||||
|
payload = await run_in_threadpool(
|
||||||
|
_overlay_cached_runway_history_from_db,
|
||||||
|
city,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
return await _overlay_cached_wunderground(city, payload)
|
return await _overlay_cached_wunderground(city, payload)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user