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.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"); 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( const busanMergedHourly = __mergePatchIntoHourlyForTest(
{ {
localTime: "08:19", localTime: "08:19",
+47 -2
View File
@@ -10,7 +10,7 @@ import re
import os import os
import time import time
from html import unescape from html import unescape
from datetime import datetime from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from loguru import logger from loguru import logger
@@ -75,6 +75,42 @@ def _amos_extract_metar_qnh(metar_line: str) -> Optional[float]:
return None 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]: def _amos_extract_metar_wind(metar_line: str) -> Optional[float]:
"""Extract wind speed in knots from METAR like '22014KT'.""" """Extract wind speed in knots from METAR like '22014KT'."""
match = re.search(r"\b(\d{3})(\d{2,3})KT\b", metar_line) 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 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) 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 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 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, "runway_obs": runway_data if runway_data.get("temperatures") else None,
"observation_source": "AMOS runway sensors", "observation_source": "AMOS runway sensors",
"observation_source_zh": "AMOS 跑道传感器", "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") # 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(): 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 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(): def test_amos_get_page_rejects_ignored_query_returning_default_rksi():
class FakeCollector(AmosStationSourceMixin): class FakeCollector(AmosStationSourceMixin):
timeout = 1.0 timeout = 1.0