Slim terminal chart detail batches

This commit is contained in:
2569718930@qq.com
2026-06-01 11:37:16 +08:00
parent d1633331bc
commit fee402145e
7 changed files with 168 additions and 3 deletions
@@ -31,7 +31,7 @@ export async function GET(req: NextRequest) {
force_refresh: forceRefresh, force_refresh: forceRefresh,
limit: req.nextUrl.searchParams.get("limit") || "12", limit: req.nextUrl.searchParams.get("limit") || "12",
}); });
for (const key of ["market_slug", "target_date", "resolution"]) { for (const key of ["market_slug", "target_date", "resolution", "scope"]) {
const value = req.nextUrl.searchParams.get(key); const value = req.nextUrl.searchParams.get(key);
if (value) searchParams.set(key, value); if (value) searchParams.set(key, value);
} }
@@ -119,6 +119,11 @@ export async function runTests() {
chartLogicSource.includes("primeCityDetailCache"), chartLogicSource.includes("primeCityDetailCache"),
"visible terminal chart detail fetches should be coalesced into one batch request and prime the shared chart cache", "visible terminal chart detail fetches should be coalesced into one batch request and prime the shared chart cache",
); );
assert(
chartLogicSource.includes('scope: "chart"') &&
chartLogicSource.includes("params.toString()"),
"terminal chart detail batches should request the slim chart scope instead of the full city detail payload",
);
assert( assert(
chartLogicSource.includes("CITY_DETAIL_BATCH_WINDOW_MS = 100"), chartLogicSource.includes("CITY_DETAIL_BATCH_WINDOW_MS = 100"),
"visible terminal chart detail fetches should use a wide enough batch window to coalesce cards mounted across adjacent frames", "visible terminal chart detail fetches should use a wide enough batch window to coalesce cards mounted across adjacent frames",
@@ -1179,6 +1179,7 @@ function fetchCityDetailBatchWithTimeout(cities: string[], resolution: string) {
force_refresh: "false", force_refresh: "false",
limit: String(Math.max(cities.length, CITY_DETAIL_BATCH_MAX_CITIES)), limit: String(Math.max(cities.length, CITY_DETAIL_BATCH_MAX_CITIES)),
resolution, resolution,
scope: "chart",
}); });
return fetch(`/api/cities/detail-batch?${params.toString()}`, { return fetch(`/api/cities/detail-batch?${params.toString()}`, {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
+4
View File
@@ -23,6 +23,10 @@ def test_backend_shared_timing_helper_avoids_sensitive_identity_fields():
def test_city_detail_batch_response_includes_backend_server_timing(monkeypatch): def test_city_detail_batch_response_includes_backend_server_timing(monkeypatch):
city_api._CITY_DETAIL_BATCH_RESPONSE_CACHE.clear()
city_api._CITY_DETAIL_BATCH_RESPONSE_CACHE_TS.clear()
city_api._CITY_DETAIL_BATCH_RESPONSE_INFLIGHT.clear()
class FakeCache: class FakeCache:
def get_city_cache(self, kind, city): def get_city_cache(self, kind, city):
assert kind == "full" assert kind == "full"
+72
View File
@@ -678,6 +678,78 @@ def test_city_detail_batch_endpoint_builds_multiple_cached_details(monkeypatch):
assert sorted(calls) == [("paris", "10m"), ("shanghai", "10m")] assert sorted(calls) == [("paris", "10m"), ("shanghai", "10m")]
def test_city_detail_batch_chart_scope_returns_only_chart_fields(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": {
"city": city,
"hourly": {"times": ["2026-05-30T00:00:00Z"], "temps": [20.0]},
}
}
def build_detail(data, market_slug, target_date, resolution):
return {
"city": data["city"],
"overview": {
"local_date": "2026-05-30",
"local_time": "15:20",
"deb_prediction": 21.5,
"airport_primary_today_obs": [["15:20", 20.0]],
},
"timeseries": {
"hourly": data["hourly"],
"metar_today_obs": [{"time": "15:20", "temp": 20.0}],
"settlement_today_obs": [],
"forecast_daily": [{"date": "2026-05-30", "max_temp": 22.0}],
},
"models_hourly": {"times": ["15:00"], "curves": {"ECMWF": [21.0]}},
"deb": {"prediction": 21.5, "hourly_path": {"times": ["15:00"], "temps": [21.5]}},
"probabilities": {"mu": 21.4, "distribution": [{"value": 21, "probability": 0.4}]},
"runway_plate_history": {"01/19": [{"timestamp": "15:20", "temp_c": 20.1}]},
"runway_band_history": [{"time": "2026-05-30T15:20:00Z", "high_temp": 20.1}],
"airport_current": {"temp": 20.0},
"airport_primary": {"temp": 20.0},
"wunderground_current": {"max_so_far": 20.5},
"settlement_station": {"settlement_station_label": "Station"},
"amos": {"runway_obs": {"point_temperatures": []}},
"dynamic_commentary": {"summary": "large text"},
"official_nearby": [{"name": "unused"}],
"taf": {"raw": "unused"},
"ai_analysis": "unused",
}
monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeCache())
monkeypatch.setattr(city_api.legacy_routes, "_build_city_detail_payload", build_detail)
response = client.get("/api/cities/detail-batch?cities=Paris&resolution=10m&scope=chart")
assert response.status_code == 200
detail = response.json()["details"]["paris"]
assert detail["timeseries"]["hourly"]["temps"] == [20.0]
assert detail["models_hourly"]["curves"]["ECMWF"] == [21.0]
assert detail["deb"]["hourly_path"]["temps"] == [21.5]
assert detail["airport_primary_today_obs"] == [["15:20", 20.0]]
assert "dynamic_commentary" not in detail
assert "official_nearby" not in detail
assert "taf" not in detail
assert "ai_analysis" not in detail
def test_city_detail_batch_endpoint_limits_backend_concurrency(monkeypatch): def test_city_detail_batch_endpoint_limits_backend_concurrency(monkeypatch):
import asyncio import asyncio
+2
View File
@@ -115,6 +115,7 @@ async def city_detail_batch(
market_slug: Optional[str] = None, market_slug: Optional[str] = None,
target_date: Optional[str] = None, target_date: Optional[str] = None,
resolution: Optional[str] = "10m", resolution: Optional[str] = "10m",
scope: Optional[str] = "full",
limit: int = 12, limit: int = 12,
): ):
payload = await get_city_detail_batch_payload( payload = await get_city_detail_batch_payload(
@@ -124,6 +125,7 @@ async def city_detail_batch(
market_slug=market_slug, market_slug=market_slug,
target_date=target_date, target_date=target_date,
resolution=resolution, resolution=resolution,
scope=scope,
limit=limit, limit=limit,
) )
attach_server_timing_header(response, request, "city_detail_batch_server_timing") attach_server_timing_header(response, request, "city_detail_batch_server_timing")
+83 -2
View File
@@ -27,7 +27,7 @@ _CITY_FULL_REFRESH_INFLIGHT: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {}
_CITY_FULL_STALE_REFRESH_TASKS: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {} _CITY_FULL_STALE_REFRESH_TASKS: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {}
_CITY_FULL_REFRESH_LOCK = asyncio.Lock() _CITY_FULL_REFRESH_LOCK = asyncio.Lock()
CityDetailPayloadCacheKey = Tuple[str, str, str, str, str, int] CityDetailPayloadCacheKey = Tuple[str, str, str, str, str, int]
CityDetailBatchResponseCacheKey = Tuple[Tuple[str, ...], bool, str, str, str] CityDetailBatchResponseCacheKey = Tuple[Tuple[str, ...], bool, str, str, str, str]
_CITY_DETAIL_PAYLOAD_CACHE: Dict[CityDetailPayloadCacheKey, Dict[str, Any]] = {} _CITY_DETAIL_PAYLOAD_CACHE: Dict[CityDetailPayloadCacheKey, Dict[str, Any]] = {}
_CITY_DETAIL_PAYLOAD_CACHE_TS: Dict[CityDetailPayloadCacheKey, float] = {} _CITY_DETAIL_PAYLOAD_CACHE_TS: Dict[CityDetailPayloadCacheKey, float] = {}
_CITY_DETAIL_PAYLOAD_INFLIGHT: Dict[CityDetailPayloadCacheKey, "asyncio.Task[Dict[str, Any]]"] = {} _CITY_DETAIL_PAYLOAD_INFLIGHT: Dict[CityDetailPayloadCacheKey, "asyncio.Task[Dict[str, Any]]"] = {}
@@ -481,6 +481,7 @@ def _city_detail_batch_response_cache_key(
market_slug: Optional[str], market_slug: Optional[str],
target_date: Optional[str], target_date: Optional[str],
resolution: Optional[str], resolution: Optional[str],
scope: str,
) -> CityDetailBatchResponseCacheKey: ) -> CityDetailBatchResponseCacheKey:
return ( return (
tuple(city_names), tuple(city_names),
@@ -488,9 +489,84 @@ def _city_detail_batch_response_cache_key(
str(market_slug or ""), str(market_slug or ""),
str(target_date or ""), str(target_date or ""),
str(resolution or "10m"), str(resolution or "10m"),
str(scope or "full"),
) )
def _normalize_city_detail_scope(scope: Optional[str]) -> str:
raw = str(scope or "full").strip().lower()
if raw in {"chart", "charts", "terminal", "terminal_chart"}:
return "chart"
return "full"
def _chart_scoped_city_detail(detail: Dict[str, Any]) -> Dict[str, Any]:
overview = detail.get("overview") if isinstance(detail.get("overview"), dict) else {}
timeseries = detail.get("timeseries") if isinstance(detail.get("timeseries"), dict) else {}
forecast = detail.get("forecast") if isinstance(detail.get("forecast"), dict) else {}
airport_primary_today_obs = (
detail.get("airport_primary_today_obs")
or overview.get("airport_primary_today_obs")
or []
)
forecast_daily = (
(forecast.get("daily") if isinstance(forecast, dict) else None)
or timeseries.get("forecast_daily")
or []
)
local_date = detail.get("local_date") or overview.get("local_date")
local_time = detail.get("local_time") or overview.get("local_time")
scoped = {
"city": detail.get("city") or overview.get("name"),
"fetched_at": detail.get("fetched_at"),
"local_date": local_date,
"local_time": local_time,
"overview": {
"name": overview.get("name"),
"display_name": overview.get("display_name"),
"local_date": local_date,
"local_time": local_time,
"temp_symbol": overview.get("temp_symbol"),
"current_temp": overview.get("current_temp"),
"deb_prediction": overview.get("deb_prediction"),
"settlement_source": overview.get("settlement_source"),
"settlement_source_label": overview.get("settlement_source_label"),
},
"timeseries": {
"hourly": timeseries.get("hourly") or detail.get("hourly") or {},
"metar_today_obs": timeseries.get("metar_today_obs") or [],
"settlement_today_obs": timeseries.get("settlement_today_obs") or [],
"forecast_daily": forecast_daily,
},
"hourly": timeseries.get("hourly") or detail.get("hourly") or {},
"models_hourly": detail.get("models_hourly") or {},
"deb": detail.get("deb") or {},
"forecast": {
"today_high": forecast.get("today_high") if isinstance(forecast, dict) else None,
"daily": forecast_daily,
},
"multi_model_daily": detail.get("multi_model_daily") or {},
"probabilities": detail.get("probabilities") or {"mu": None, "distribution": []},
"runway_plate_history": detail.get("runway_plate_history") or {},
"runway_band_history": detail.get("runway_band_history") or [],
"amos": detail.get("amos") or {},
"airport_current": detail.get("airport_current") or {},
"airport_primary": detail.get("airport_primary") or overview.get("airport_primary") or {},
"airport_primary_today_obs": airport_primary_today_obs,
"official": {"airport_primary_today_obs": airport_primary_today_obs},
"wunderground_current": detail.get("wunderground_current") or {},
"settlement_station": detail.get("settlement_station") or overview.get("settlement_station") or {},
}
return scoped
def _apply_city_detail_scope(detail: Dict[str, Any], scope: str) -> Dict[str, Any]:
if scope == "chart":
return _chart_scoped_city_detail(detail)
return detail
async def _build_city_detail_batch_item_async( async def _build_city_detail_batch_item_async(
city: str, city: str,
*, *,
@@ -498,6 +574,7 @@ async def _build_city_detail_batch_item_async(
market_slug: Optional[str], market_slug: Optional[str],
target_date: Optional[str], target_date: Optional[str],
resolution: Optional[str], resolution: Optional[str],
detail_scope: str = "full",
timing_recorder: Optional[ServerTimingRecorder] = None, timing_recorder: Optional[ServerTimingRecorder] = None,
) -> Tuple[str, Dict[str, Any]]: ) -> Tuple[str, Dict[str, Any]]:
if timing_recorder is not None: if timing_recorder is not None:
@@ -522,7 +599,7 @@ async def _build_city_detail_batch_item_async(
target_date, target_date,
resolution, resolution,
) )
return city, detail return city, _apply_city_detail_scope(detail, detail_scope)
def _city_detail_batch_concurrency() -> int: def _city_detail_batch_concurrency() -> int:
@@ -554,6 +631,7 @@ async def get_city_detail_batch_payload(
market_slug: Optional[str] = None, market_slug: Optional[str] = None,
target_date: Optional[str] = None, target_date: Optional[str] = None,
resolution: Optional[str] = "10m", resolution: Optional[str] = "10m",
scope: Optional[str] = "full",
limit: int = 12, limit: int = 12,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
timer = ServerTimingRecorder( timer = ServerTimingRecorder(
@@ -581,6 +659,7 @@ async def get_city_detail_batch_payload(
"missing": [], "missing": [],
"partial": False, "partial": False,
} }
detail_scope = _normalize_city_detail_scope(scope)
async def _build_uncached_payload() -> Dict[str, Any]: async def _build_uncached_payload() -> Dict[str, Any]:
semaphore = asyncio.Semaphore(_city_detail_batch_concurrency()) semaphore = asyncio.Semaphore(_city_detail_batch_concurrency())
@@ -593,6 +672,7 @@ async def get_city_detail_batch_payload(
market_slug=market_slug, market_slug=market_slug,
target_date=target_date, target_date=target_date,
resolution=resolution, resolution=resolution,
detail_scope=detail_scope,
timing_recorder=timer, timing_recorder=timer,
) )
@@ -642,6 +722,7 @@ async def get_city_detail_batch_payload(
market_slug=market_slug, market_slug=market_slug,
target_date=target_date, target_date=target_date,
resolution=resolution, resolution=resolution,
scope=detail_scope,
) )
if cache_ttl > 0 and not force_refresh: if cache_ttl > 0 and not force_refresh:
now_ts = time.time() now_ts = time.time()