feat: implement AMOS real-time weather data collection and add corresponding unit and frontend tests
This commit is contained in:
+37
@@ -518,6 +518,43 @@ export function runTests() {
|
||||
assert(busanRunway.featured === true, "Busan SR/SL should be treated as the settlement runway");
|
||||
assert(busanRunway.label.includes("结算跑道"), "Busan SR/SL should be labeled as the settlement runway");
|
||||
|
||||
const originalGetTimezoneOffset = Date.prototype.getTimezoneOffset;
|
||||
let busanUtcPointLabel: string | null = null;
|
||||
try {
|
||||
Date.prototype.getTimezoneOffset = function () {
|
||||
return -8 * 60;
|
||||
};
|
||||
const busanUtcTimestampChart = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "busan",
|
||||
local_date: "2026-05-27",
|
||||
local_time: "09:58",
|
||||
tz_offset_seconds: 9 * 60 * 60,
|
||||
temp_symbol: "°C",
|
||||
} as any,
|
||||
{
|
||||
localTime: "09:58",
|
||||
times: ["00:00", "12:00", "18:00", "23:00"],
|
||||
temps: [19.6, 21.1, 20.0, 19.0],
|
||||
runwayPlateHistory: {
|
||||
"SR/SL": [
|
||||
{ time: "2026-05-27T00:57:00Z", temp: 21.4 },
|
||||
{ time: "2026-05-27T00:58:00Z", temp: 21.5 },
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
"1D",
|
||||
);
|
||||
busanUtcPointLabel =
|
||||
(busanUtcTimestampChart.data.find((point: any) => point[runwayKey("SR/SL")] === 21.5) as any)?.label || null;
|
||||
} finally {
|
||||
Date.prototype.getTimezoneOffset = originalGetTimezoneOffset;
|
||||
}
|
||||
assert(
|
||||
busanUtcPointLabel === "09:58:00",
|
||||
"UTC runway observation timestamps should render at the city-local time regardless of the browser timezone",
|
||||
);
|
||||
|
||||
const busanMergedHourly = __mergePatchIntoHourlyForTest(
|
||||
{
|
||||
localTime: "08:19",
|
||||
|
||||
@@ -10,7 +10,7 @@ import re
|
||||
import os
|
||||
import time
|
||||
from html import unescape
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from loguru import logger
|
||||
@@ -75,6 +75,42 @@ def _amos_extract_metar_qnh(metar_line: str) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def _amos_extract_observation_time(text: str, icao: str) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Return AMOS page timestamp as (UTC ISO, KST local display)."""
|
||||
icao_pattern = re.escape(str(icao or "").strip().upper())
|
||||
patterns = []
|
||||
if icao_pattern:
|
||||
patterns.append(
|
||||
rf"\({icao_pattern}\)\s*(\d{{4}})년\s*(\d{{1,2}})월\s*(\d{{1,2}})일\s*(\d{{1,2}}):(\d{{2}})\s*KST"
|
||||
)
|
||||
patterns.append(
|
||||
r"(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일\s*(\d{1,2}):(\d{2})\s*KST"
|
||||
)
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text or "", re.I)
|
||||
if not match:
|
||||
continue
|
||||
try:
|
||||
year, month, day, hour, minute = [int(part) for part in match.groups()[:5]]
|
||||
local_dt = datetime(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
tzinfo=timezone(timedelta(hours=9)),
|
||||
)
|
||||
utc_dt = local_dt.astimezone(timezone.utc).replace(microsecond=0)
|
||||
return (
|
||||
utc_dt.isoformat().replace("+00:00", "Z"),
|
||||
local_dt.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None, None
|
||||
|
||||
|
||||
def _amos_extract_metar_wind(metar_line: str) -> Optional[float]:
|
||||
"""Extract wind speed in knots from METAR like '22014KT'."""
|
||||
match = re.search(r"\b(\d{3})(\d{2,3})KT\b", metar_line)
|
||||
@@ -513,6 +549,14 @@ class AmosStationSourceMixin:
|
||||
mid = len(sorted_p) // 2
|
||||
pressure_hpa = float(sorted_p[mid]) if len(sorted_p) % 2 else float((sorted_p[mid-1] + sorted_p[mid]) / 2)
|
||||
|
||||
observation_time, observation_time_local = _amos_extract_observation_time(html, icao)
|
||||
if not observation_time:
|
||||
now_utc = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
observation_time = now_utc.isoformat().replace("+00:00", "Z")
|
||||
observation_time_local = now_utc.astimezone(
|
||||
timezone(timedelta(hours=9))
|
||||
).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
temp = round(temp_c * 9 / 5 + 32, 1) if use_fahrenheit and temp_c is not None else temp_c
|
||||
dew = round(dew_c * 9 / 5 + 32, 1) if use_fahrenheit and dew_c is not None else dew_c
|
||||
|
||||
@@ -541,7 +585,8 @@ class AmosStationSourceMixin:
|
||||
"runway_obs": runway_data if runway_data.get("temperatures") else None,
|
||||
"observation_source": "AMOS runway sensors",
|
||||
"observation_source_zh": "AMOS 跑道传感器",
|
||||
"observation_time": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"observation_time": observation_time,
|
||||
"observation_time_local": observation_time_local,
|
||||
}
|
||||
|
||||
# Compute runway temp range for compact display ("14.6~15.2")
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
from src.data_collection.amos_station_sources import AmosStationSourceMixin, _amos_parse_runway_table
|
||||
from src.data_collection.amos_station_sources import (
|
||||
AmosStationSourceMixin,
|
||||
_amos_extract_observation_time,
|
||||
_amos_parse_runway_table,
|
||||
)
|
||||
|
||||
|
||||
def test_amos_parser_handles_current_public_page_format():
|
||||
@@ -46,6 +50,15 @@ def test_amos_parser_handles_current_public_page_format():
|
||||
assert parsed["rvr"][0] == 2000
|
||||
|
||||
|
||||
def test_amos_observation_time_uses_page_kst_timestamp_as_utc_iso():
|
||||
html = "[김해공항 (RKPK) 2026년 05월 27일 09:58 KST]"
|
||||
|
||||
obs_utc, obs_local = _amos_extract_observation_time(html, "RKPK")
|
||||
|
||||
assert obs_utc == "2026-05-27T00:58:00Z"
|
||||
assert obs_local == "2026-05-27 09:58:00"
|
||||
|
||||
|
||||
def test_amos_get_page_rejects_ignored_query_returning_default_rksi():
|
||||
class FakeCollector(AmosStationSourceMixin):
|
||||
timeout = 1.0
|
||||
|
||||
Reference in New Issue
Block a user