feat: implement AMOS real-time weather data collection and add corresponding unit and frontend tests

This commit is contained in:
2569718930@qq.com
2026-05-27 09:07:48 +08:00
parent bef3c610b7
commit 05010d20e2
3 changed files with 98 additions and 3 deletions
@@ -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",
+47 -2
View File
@@ -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")
+14 -1
View File
@@ -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