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.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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user