From 05010d20e2fd6ecfeaf4cae33610028b60e0958a Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 27 May 2026 09:07:48 +0800 Subject: [PATCH] feat: implement AMOS real-time weather data collection and add corresponding unit and frontend tests --- ...temperatureDefaultVisibilityPolicy.test.ts | 37 ++++++++++++++ src/data_collection/amos_station_sources.py | 49 ++++++++++++++++++- tests/test_amos_station_sources.py | 15 +++++- 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts index 5c48bcbe..eb5b8e56 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts @@ -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", diff --git a/src/data_collection/amos_station_sources.py b/src/data_collection/amos_station_sources.py index b4f25d6c..c06eb919 100644 --- a/src/data_collection/amos_station_sources.py +++ b/src/data_collection/amos_station_sources.py @@ -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") diff --git a/tests/test_amos_station_sources.py b/tests/test_amos_station_sources.py index b71a1632..b43168e1 100644 --- a/tests/test_amos_station_sources.py +++ b/tests/test_amos_station_sources.py @@ -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