Build runway history from raw observations
This commit is contained in:
@@ -1293,6 +1293,64 @@ class DBManager:
|
|||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def list_raw_observation_history(
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
city: str,
|
||||||
|
*,
|
||||||
|
minutes: int = 60,
|
||||||
|
limit: int = 1000,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
normalized_source = str(source or "").strip().lower()
|
||||||
|
normalized_city = str(city or "").strip().lower()
|
||||||
|
if not normalized_source or not normalized_city:
|
||||||
|
return []
|
||||||
|
safe_limit = max(1, min(int(limit or 1000), 5000))
|
||||||
|
safe_minutes = max(1, min(int(minutes or 60), 7 * 24 * 60))
|
||||||
|
cutoff_ts = datetime.now().timestamp() - safe_minutes * 60
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM raw_observation_store
|
||||||
|
WHERE source = ?
|
||||||
|
AND city = ?
|
||||||
|
AND created_at_ts >= ?
|
||||||
|
ORDER BY observed_at ASC, fetched_at ASC, created_at_ts ASC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(normalized_source, normalized_city, cutoff_ts, safe_limit),
|
||||||
|
).fetchall()
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
payload = json.loads(str(row["payload_json"] or "{}"))
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"source": str(row["source"] or ""),
|
||||||
|
"city": str(row["city"] or ""),
|
||||||
|
"station_code": str(row["station_code"] or ""),
|
||||||
|
"station_name": str(row["station_name"] or ""),
|
||||||
|
"runway": str(row["runway"] or ""),
|
||||||
|
"value": self._float_or_none(row["value"]),
|
||||||
|
"value_unit": str(row["value_unit"] or ""),
|
||||||
|
"observed_at": str(row["observed_at"] or ""),
|
||||||
|
"fetched_at": str(row["fetched_at"] or ""),
|
||||||
|
"source_latency_sec": self._float_or_none(row["source_latency_sec"]),
|
||||||
|
"status": str(row["status"] or ""),
|
||||||
|
"error_count": int(row["error_count"] or 0),
|
||||||
|
"last_success_at": str(row["last_success_at"] or ""),
|
||||||
|
"payload": payload,
|
||||||
|
"created_at_ts": float(row["created_at_ts"] or 0.0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
def enqueue_observation_refresh_request(
|
def enqueue_observation_refresh_request(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -117,6 +117,50 @@ def test_overlay_appends_latest_amsc_points_to_runway_history():
|
|||||||
assert runway_writes[0]["target_runway_max"] == 28.4
|
assert runway_writes[0]["target_runway_max"] == 28.4
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_builds_amsc_runway_history_from_raw_store():
|
||||||
|
def raw_row(observed_at, temp):
|
||||||
|
return {
|
||||||
|
"observed_at": observed_at,
|
||||||
|
"payload": {
|
||||||
|
"source": "amsc_awos",
|
||||||
|
"icao": "ZUUU",
|
||||||
|
"temp_c": temp,
|
||||||
|
"observation_time": observed_at,
|
||||||
|
"runway_obs": {
|
||||||
|
"point_temperatures": [
|
||||||
|
{
|
||||||
|
"runway": "02L/20R",
|
||||||
|
"target_runway_max": temp,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDB:
|
||||||
|
def get_latest_raw_observation(self, source, city):
|
||||||
|
assert (source, city) == ("amsc_awos", "chengdu")
|
||||||
|
return raw_row("2026-06-15T11:08:00+00:00", 25.4)
|
||||||
|
|
||||||
|
def list_raw_observation_history(self, source, city, *, minutes=60, limit=1000):
|
||||||
|
assert (source, city) == ("amsc_awos", "chengdu")
|
||||||
|
return [
|
||||||
|
raw_row("2026-06-15T11:04:00+00:00", 25.6),
|
||||||
|
raw_row("2026-06-15T11:08:00+00:00", 25.4),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = overlay_latest_amsc_observation(
|
||||||
|
FakeDB(),
|
||||||
|
"chengdu",
|
||||||
|
{"name": "chengdu", "temp_symbol": "°C", "runway_plate_history": {}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["runway_plate_history"]["02L/20R"][-2:] == [
|
||||||
|
{"time": "2026-06-15T11:04:00+00:00", "temp": 25.6},
|
||||||
|
{"time": "2026-06-15T11:08:00+00:00", "temp": 25.4},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_overlay_uses_latest_success_when_newer_status_row_has_no_observation():
|
def test_overlay_uses_latest_success_when_newer_status_row_has_no_observation():
|
||||||
class FakeDB:
|
class FakeDB:
|
||||||
def get_latest_raw_observation(self, source, city):
|
def get_latest_raw_observation(self, source, city):
|
||||||
|
|||||||
@@ -181,6 +181,38 @@ def test_raw_observation_store_records_latest_observation(tmp_path):
|
|||||||
assert latest["payload"]["temp_c"] == 24.0
|
assert latest["payload"]["temp_c"] == 24.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_observation_store_lists_source_city_history(tmp_path):
|
||||||
|
from src.database.db_manager import DBManager
|
||||||
|
|
||||||
|
db = DBManager(str(tmp_path / "polyweather.db"))
|
||||||
|
db.append_raw_observation(
|
||||||
|
source="amsc_awos",
|
||||||
|
city="Chengdu",
|
||||||
|
value=25.6,
|
||||||
|
observed_at="2026-06-15T11:04:00+00:00",
|
||||||
|
fetched_at="2026-06-15T11:04:30+00:00",
|
||||||
|
station_code="ZUUU",
|
||||||
|
payload={"temp_c": 25.6},
|
||||||
|
)
|
||||||
|
db.append_raw_observation(
|
||||||
|
source="amsc_awos",
|
||||||
|
city="chengdu",
|
||||||
|
value=25.4,
|
||||||
|
observed_at="2026-06-15T11:08:00+00:00",
|
||||||
|
fetched_at="2026-06-15T11:08:30+00:00",
|
||||||
|
station_code="ZUUU",
|
||||||
|
payload={"temp_c": 25.4},
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = db.list_raw_observation_history("amsc_awos", "chengdu", minutes=60, limit=10)
|
||||||
|
|
||||||
|
assert [row["observed_at"] for row in rows] == [
|
||||||
|
"2026-06-15T11:04:00+00:00",
|
||||||
|
"2026-06-15T11:08:00+00:00",
|
||||||
|
]
|
||||||
|
assert rows[-1]["payload"]["temp_c"] == 25.4
|
||||||
|
|
||||||
|
|
||||||
def test_observation_collector_aligns_amsc_payload_time_with_record_observed_at(tmp_path):
|
def test_observation_collector_aligns_amsc_payload_time_with_record_observed_at(tmp_path):
|
||||||
from src.database.db_manager import DBManager
|
from src.database.db_manager import DBManager
|
||||||
from web.observation_collector_service import ObservationCollector
|
from web.observation_collector_service import ObservationCollector
|
||||||
|
|||||||
@@ -214,6 +214,8 @@ def _append_latest_amsc_runway_history(
|
|||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
row: dict[str, Any],
|
row: dict[str, Any],
|
||||||
raw_payload: dict[str, Any],
|
raw_payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
persist: bool = True,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
runway_obs = raw_payload.get("runway_obs")
|
runway_obs = raw_payload.get("runway_obs")
|
||||||
if not isinstance(runway_obs, dict):
|
if not isinstance(runway_obs, dict):
|
||||||
@@ -237,7 +239,7 @@ def _append_latest_amsc_runway_history(
|
|||||||
history = deepcopy(history)
|
history = deepcopy(history)
|
||||||
|
|
||||||
use_fahrenheit = "F" in str(payload.get("temp_symbol") or "").upper()
|
use_fahrenheit = "F" in str(payload.get("temp_symbol") or "").upper()
|
||||||
appender = getattr(db, "append_runway_obs", None)
|
appender = getattr(db, "append_runway_obs", None) if persist else None
|
||||||
icao = str(raw_payload.get("icao") or row.get("station_code") or "").strip().upper()
|
icao = str(raw_payload.get("icao") or row.get("station_code") or "").strip().upper()
|
||||||
changed = False
|
changed = False
|
||||||
for point in point_temperatures:
|
for point in point_temperatures:
|
||||||
@@ -305,6 +307,37 @@ def _append_latest_amsc_runway_history(
|
|||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _append_amsc_runway_history_from_raw_store(
|
||||||
|
db: Any,
|
||||||
|
city: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
lister = getattr(db, "list_raw_observation_history", None)
|
||||||
|
if not callable(lister):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
rows = lister("amsc_awos", city, minutes=36 * 60, limit=3000)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("latest AMSC raw history overlay skipped city={}: {}", city, exc)
|
||||||
|
return False
|
||||||
|
changed = False
|
||||||
|
for row in rows if isinstance(rows, list) else []:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
raw_payload = row.get("payload")
|
||||||
|
if not isinstance(raw_payload, dict) or not _amsc_payload_has_observation(raw_payload):
|
||||||
|
continue
|
||||||
|
changed = _append_latest_amsc_runway_history(
|
||||||
|
db,
|
||||||
|
city,
|
||||||
|
payload,
|
||||||
|
row,
|
||||||
|
raw_payload,
|
||||||
|
persist=False,
|
||||||
|
) or changed
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
def overlay_latest_amsc_observation(
|
def overlay_latest_amsc_observation(
|
||||||
db: Any,
|
db: Any,
|
||||||
city: str,
|
city: str,
|
||||||
@@ -334,6 +367,7 @@ 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_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