Require recent runway history coverage
This commit is contained in:
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user