Require recent runway history coverage

This commit is contained in:
2569718930@qq.com
2026-06-15 20:40:57 +08:00
parent 383011f9fe
commit 07e28481e8
2 changed files with 66 additions and 16 deletions
+10 -7
View File
@@ -162,17 +162,23 @@ def test_overlay_builds_amsc_runway_history_from_raw_store():
def test_overlay_skips_raw_store_when_runway_history_already_has_points(): def test_overlay_skips_raw_store_when_runway_history_already_has_points():
existing_points = [
{"time": f"2026-06-15T{hour:02d}:00:00+00:00", "temp": 25.0 + hour / 10}
for hour in range(0, 15)
for _ in range(2)
]
class FakeDB: class FakeDB:
def get_latest_raw_observation(self, source, city): def get_latest_raw_observation(self, source, city):
assert (source, city) == ("amsc_awos", "chengdu") assert (source, city) == ("amsc_awos", "chengdu")
return { return {
"observed_at": "2026-06-15T11:08:00+00:00", "observed_at": "2026-06-15T14:30:00+00:00",
"station_code": "ZUUU", "station_code": "ZUUU",
"payload": { "payload": {
"source": "amsc_awos", "source": "amsc_awos",
"icao": "ZUUU", "icao": "ZUUU",
"temp_c": 25.4, "temp_c": 25.4,
"observation_time": "2026-06-15T11:08:00+00:00", "observation_time": "2026-06-15T14:30:00+00:00",
"runway_obs": { "runway_obs": {
"point_temperatures": [ "point_temperatures": [
{ {
@@ -194,16 +200,13 @@ def test_overlay_skips_raw_store_when_runway_history_already_has_points():
"name": "chengdu", "name": "chengdu",
"temp_symbol": "°C", "temp_symbol": "°C",
"runway_plate_history": { "runway_plate_history": {
"02L/20R": [ "02L/20R": existing_points,
{"time": "2026-06-15T11:04:00+00:00", "temp": 25.6},
{"time": "2026-06-15T11:06:00+00:00", "temp": 25.5},
],
}, },
}, },
) )
assert result["runway_plate_history"]["02L/20R"][-1] == { assert result["runway_plate_history"]["02L/20R"][-1] == {
"time": "2026-06-15T11:08:00+00:00", "time": "2026-06-15T14:30:00+00:00",
"temp": 25.4, "temp": 25.4,
} }
+56 -9
View File
@@ -2,12 +2,19 @@ from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from datetime import datetime, timezone from datetime import datetime, timezone
import time
from typing import Any, Optional from typing import Any, Optional
from loguru import logger from loguru import logger
from web.services.canonical_temperature import build_canonical_temperature from web.services.canonical_temperature import build_canonical_temperature
_RAW_AMSC_RUNWAY_HISTORY_CACHE: dict[tuple[str, bool], tuple[float, dict[str, list[dict[str, Any]]]]] = {}
_RAW_AMSC_RUNWAY_HISTORY_CACHE_TTL_SEC = 60.0
_RAW_AMSC_RUNWAY_HISTORY_RECENT_WINDOW_SEC = 24 * 60 * 60
_RAW_AMSC_RUNWAY_HISTORY_MIN_POINTS = 30
_RAW_AMSC_RUNWAY_HISTORY_MIN_SPAN_SEC = 12 * 60 * 60
def parse_observation_epoch(value: Any) -> Optional[int]: def parse_observation_epoch(value: Any) -> Optional[int]:
if value is None or value == "": if value is None or value == "":
@@ -208,6 +215,33 @@ def _runway_history_temp(point: dict[str, Any]) -> Optional[float]:
return None return None
def _runway_history_has_recent_coverage(
history: Any,
latest_epoch: int,
) -> bool:
if not isinstance(history, dict):
return False
window_start = latest_epoch - _RAW_AMSC_RUNWAY_HISTORY_RECENT_WINDOW_SEC
span_start = latest_epoch - _RAW_AMSC_RUNWAY_HISTORY_MIN_SPAN_SEC
for points in history.values():
if not isinstance(points, list):
continue
epochs = [
epoch
for epoch in (
parse_observation_epoch(
point.get("time") or point.get("timestamp") or point.get("observed_at")
)
for point in points
if isinstance(point, dict)
)
if epoch is not None and window_start <= epoch <= latest_epoch + 300
]
if len(epochs) >= _RAW_AMSC_RUNWAY_HISTORY_MIN_POINTS and min(epochs) <= span_start:
return True
return False
def _append_latest_amsc_runway_history( def _append_latest_amsc_runway_history(
db: Any, db: Any,
city: str, city: str,
@@ -312,16 +346,19 @@ def _append_amsc_runway_history_from_raw_store(
db: Any, db: Any,
city: str, city: str,
payload: dict[str, Any], payload: dict[str, Any],
latest_epoch: int,
) -> bool: ) -> bool:
existing_history = payload.get("runway_plate_history") existing_history = payload.get("runway_plate_history")
if isinstance(existing_history, dict): if _runway_history_has_recent_coverage(existing_history, latest_epoch):
existing_points = [ return False
len(points)
for points in existing_history.values() use_fahrenheit = "F" in str(payload.get("temp_symbol") or "").upper()
if isinstance(points, list) cache_key = (city, use_fahrenheit)
] cached = _RAW_AMSC_RUNWAY_HISTORY_CACHE.get(cache_key)
if max(existing_points or [0]) > 1: now = time.monotonic()
return False if cached and now - cached[0] <= _RAW_AMSC_RUNWAY_HISTORY_CACHE_TTL_SEC:
payload["runway_plate_history"] = deepcopy(cached[1])
return True
lister = getattr(db, "list_raw_observation_history", None) lister = getattr(db, "list_raw_observation_history", None)
if not callable(lister): if not callable(lister):
@@ -348,6 +385,11 @@ def _append_amsc_runway_history_from_raw_store(
persist=False, persist=False,
copy_history=False, copy_history=False,
) or changed ) or changed
if changed and isinstance(payload.get("runway_plate_history"), dict):
_RAW_AMSC_RUNWAY_HISTORY_CACHE[cache_key] = (
now,
deepcopy(payload["runway_plate_history"]),
)
return changed return changed
@@ -380,7 +422,12 @@ def overlay_latest_amsc_observation(
for key in ("current", "airport_primary", "airport_current"): for key in ("current", "airport_primary", "airport_current"):
changed = _merge_observation_block(next_payload, key, update, raw_epoch) or changed changed = _merge_observation_block(next_payload, key, update, raw_epoch) or changed
changed = _append_amsc_runway_history_from_raw_store(db, normalized_city, next_payload) or changed changed = _append_amsc_runway_history_from_raw_store(
db,
normalized_city,
next_payload,
raw_epoch,
) or changed
changed = _append_latest_amsc_runway_history(db, normalized_city, next_payload, row, raw_payload) or changed changed = _append_latest_amsc_runway_history(db, normalized_city, next_payload, row, raw_payload) or changed
canonical = next_payload.get("canonical_temperature") canonical = next_payload.get("canonical_temperature")