diff --git a/docs/AIRPORT_REALTIME_SOURCES.md b/docs/AIRPORT_REALTIME_SOURCES.md index b23c7c72..d17f03b1 100644 --- a/docs/AIRPORT_REALTIME_SOURCES.md +++ b/docs/AIRPORT_REALTIME_SOURCES.md @@ -12,6 +12,7 @@ | 赫尔辛基 | Vantaa | EFHK | FMI (`opendata.fmi.fi`) | 10 分钟 | 机场站点实时温度 | 免费 | | 阿姆斯特丹 | Schiphol | EHAM | KNMI (`dataplatform.knmi.nl`) | 10 分钟 | 机场站点实时温度 | 免费(需注册) | | 巴黎 | Le Bourget | LFPB | AROME HD (`api.open-meteo.com`) | 15 分钟 | 模型预报(非实测) | 免费 | +| 新加坡 | Changi | WSSS | Singapore MSS (`api.data.gov.sg`) | 1 分钟 | 机场站点实时温度 (S24 站) | 免费 | | 纽约 | LaGuardia | KLGA | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 | | 洛杉矶 | LAX | KLAX | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 | | 芝加哥 | O'Hare | KORD | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 | @@ -24,6 +25,11 @@ | 奥斯汀 | Bergstrom | KAUS | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 | | 西雅图 | SeaTac | KSEA | NOAA MADIS HFMETAR | 5 分钟 | 机场站点实时温度 | 免费 | +> **Singapore MSS**: 新加坡气象局(MSS)通过 data.gov.sg 开放数据平台提供全国 15 个站点 +> 的干球温度(1 分钟均值),更新频率 ~1 分钟。选取 S24 Upper Changi Road North 站 +> 作为樟宜机场 (WSSS) 的实时温度锚点。数据公开免费,无需 API 密钥。 +> 后端通过 `singapore_mss_sources.py` 拉取并注入 `airport_primary`。 + > **NOAA MADIS HFMETAR**: 美国 11 个城市的机场高频实时数据通过 NOAA MADIS 公共档案获取。 > 数据源为 NetCDF 格式(`madis-data.ncep.noaa.gov/madisPublic1/data/LDAD/hfmetar/`), > 每 5 分钟全量更新一次,温度保留一位小数。匿名公开访问,无需 API 密钥。 diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.tsx b/frontend/components/dashboard/monitoring/MonitorPanel.tsx index 22996051..075f0385 100644 --- a/frontend/components/dashboard/monitoring/MonitorPanel.tsx +++ b/frontend/components/dashboard/monitoring/MonitorPanel.tsx @@ -167,13 +167,35 @@ function airportLabel(key: string, isEn: boolean) { */ const HKO_OBS_CITIES = new Set(["hong kong", "lau fau shan"]); -function obsSourceLabel( +const SOURCE_LABELS: Record = { + amos_runway_median: { en: "AMOS Runway", zh: "AMOS 跑道温度" }, + amos_runway: { en: "AMOS Runway", zh: "AMOS 跑道温度" }, + amos: { en: "AMOS", zh: "AMOS" }, + madis_hfmetar: { en: "NOAA MADIS", zh: "NOAA MADIS" }, + mgm: { en: "MGM", zh: "MGM" }, + jma_amedas: { en: "JMA AMeDAS", zh: "JMA AMeDAS" }, + fmi: { en: "FMI", zh: "FMI" }, + knmi: { en: "KNMI", zh: "KNMI" }, + sg_mss: { en: "Singapore MSS", zh: "新加坡 MSS" }, + airport_current: { en: "Airport METAR", zh: "机场报文" }, + current: { en: "Obs", zh: "观测" }, +}; + +function resolveSourceLabel( + detail: CityDetail | undefined, key: MonitorKey, isEn: boolean, - freshness?: ObservationFreshness | null, ): string { - const sourceLabel = String(freshness?.source_label || "").trim(); - if (sourceLabel) return sourceLabel; + // Use airport_primary source label from high-freq pipeline + const apSource = detail?.airport_primary?.source_code; + if (apSource && SOURCE_LABELS[apSource]) { + return isEn ? SOURCE_LABELS[apSource].en : SOURCE_LABELS[apSource].zh; + } + // Use temperature resolution source + const { source } = resolveMonitorTemperature(detail); + if (source && SOURCE_LABELS[source]) { + return isEn ? SOURCE_LABELS[source].en : SOURCE_LABELS[source].zh; + } if (HKO_OBS_CITIES.has(key)) return isEn ? "HKO Obs" : "天文台观测"; return isEn ? "Airport METAR" : "机场报文"; } @@ -570,7 +592,7 @@ export default function MonitorPanel({
- {obsSourceLabel(key, isEn, freshnessInfo)} + {resolveSourceLabel(detail, key, isEn)} {age != null ? ( diff --git a/src/data_collection/country_networks.py b/src/data_collection/country_networks.py index 9ffc4fc4..c0013686 100644 --- a/src/data_collection/country_networks.py +++ b/src/data_collection/country_networks.py @@ -273,6 +273,152 @@ def _airport_primary_from_raw(city: str, raw: Dict[str, Any]) -> Dict[str, Any]: }, ) + mgm = raw.get("mgm") or {} + mgm_current = mgm.get("current") or {} + if mgm_current.get("temp") is not None: + return _normalize_station_row( + station_code=meta.get("icao") or str(mgm.get("istNo") or ""), + station_label=meta.get("airport_name") or mgm.get("station_label") or meta.get("icao"), + temp=_safe_float(mgm_current["temp"]), + obs_time=mgm.get("obs_time") or metar.get("observation_time"), + source_code="mgm", + source_label="MGM", + is_official=True, + is_airport_station=True, + is_settlement_anchor=False, + extra={ + "max_so_far": _safe_float(current.get("max_temp_so_far")), + "max_temp_time": current.get("max_temp_time"), + "obs_age_min": None, + "report_time": metar.get("report_time"), + "receipt_time": metar.get("receipt_time"), + "obs_time_epoch": metar.get("obs_time_epoch"), + "obs_time_utc_offset_seconds": 0, + "wind_speed_kt": _safe_float(current.get("wind_speed_kt")), + "wind_dir": _safe_float(current.get("wind_dir")), + "humidity": _safe_float(current.get("humidity")), + "visibility_mi": _safe_float(current.get("visibility_mi")), + "wx_desc": current.get("wx_desc"), + "raw_metar": current.get("raw_metar"), + }, + ) + + jma = raw.get("jma_current") or {} + if jma.get("temp") is not None: + return _normalize_station_row( + station_code=meta.get("icao") or str(jma.get("icao") or "RJTT"), + station_label=meta.get("airport_name") or jma.get("station_label") or meta.get("icao"), + temp=_safe_float(jma["temp"]), + obs_time=str(jma.get("obs_time") or metar.get("observation_time") or ""), + source_code="jma_amedas", + source_label="JMA AMeDAS", + is_official=True, + is_airport_station=True, + is_settlement_anchor=False, + extra={ + "max_so_far": _safe_float(current.get("max_temp_so_far")), + "max_temp_time": current.get("max_temp_time"), + "obs_age_min": None, + "report_time": metar.get("report_time"), + "receipt_time": metar.get("receipt_time"), + "obs_time_epoch": metar.get("obs_time_epoch"), + "obs_time_utc_offset_seconds": 0, + "wind_speed_kt": _safe_float(current.get("wind_speed_kt")), + "wind_dir": _safe_float(current.get("wind_dir")), + "humidity": _safe_float(current.get("humidity")), + "visibility_mi": _safe_float(current.get("visibility_mi")), + "wx_desc": current.get("wx_desc"), + "raw_metar": current.get("raw_metar"), + }, + ) + + fmi = raw.get("fmi_current") or {} + if fmi.get("temp") is not None: + return _normalize_station_row( + station_code=meta.get("icao") or str(fmi.get("icao") or "EFHK"), + station_label=meta.get("airport_name") or fmi.get("station_label") or meta.get("icao"), + temp=_safe_float(fmi["temp"]), + obs_time=str(fmi.get("obs_time") or metar.get("observation_time") or ""), + source_code="fmi", + source_label="FMI", + is_official=True, + is_airport_station=True, + is_settlement_anchor=False, + extra={ + "max_so_far": _safe_float(current.get("max_temp_so_far")), + "max_temp_time": current.get("max_temp_time"), + "obs_age_min": None, + "report_time": metar.get("report_time"), + "receipt_time": metar.get("receipt_time"), + "obs_time_epoch": metar.get("obs_time_epoch"), + "obs_time_utc_offset_seconds": 0, + "wind_speed_kt": _safe_float(current.get("wind_speed_kt")), + "wind_dir": _safe_float(current.get("wind_dir")), + "humidity": _safe_float(current.get("humidity")), + "visibility_mi": _safe_float(current.get("visibility_mi")), + "wx_desc": current.get("wx_desc"), + "raw_metar": current.get("raw_metar"), + }, + ) + + knmi = raw.get("knmi_current") or {} + if knmi.get("temp") is not None: + return _normalize_station_row( + station_code=meta.get("icao") or str(knmi.get("icao") or "EHAM"), + station_label=meta.get("airport_name") or knmi.get("station_label") or meta.get("icao"), + temp=_safe_float(knmi["temp"]), + obs_time=str(knmi.get("obs_time") or metar.get("observation_time") or ""), + source_code="knmi", + source_label="KNMI", + is_official=True, + is_airport_station=True, + is_settlement_anchor=False, + extra={ + "max_so_far": _safe_float(current.get("max_temp_so_far")), + "max_temp_time": current.get("max_temp_time"), + "obs_age_min": None, + "report_time": metar.get("report_time"), + "receipt_time": metar.get("receipt_time"), + "obs_time_epoch": metar.get("obs_time_epoch"), + "obs_time_utc_offset_seconds": 0, + "wind_speed_kt": _safe_float(current.get("wind_speed_kt")), + "wind_dir": _safe_float(current.get("wind_dir")), + "humidity": _safe_float(current.get("humidity")), + "visibility_mi": _safe_float(current.get("visibility_mi")), + "wx_desc": current.get("wx_desc"), + "raw_metar": current.get("raw_metar"), + }, + ) + + sg_mss = raw.get("singapore_mss_current") or {} + if sg_mss.get("temp_c") is not None: + return _normalize_station_row( + station_code=meta.get("icao") or "WSSS", + station_label=meta.get("airport_name") or "Changi Airport", + temp=sg_mss["temp_c"], + obs_time=sg_mss.get("obs_time") or metar.get("observation_time"), + source_code="sg_mss", + source_label="Singapore MSS", + is_official=True, + is_airport_station=True, + is_settlement_anchor=False, + extra={ + "max_so_far": _safe_float(current.get("max_temp_so_far")), + "max_temp_time": current.get("max_temp_time"), + "obs_age_min": None, + "report_time": metar.get("report_time"), + "receipt_time": metar.get("receipt_time"), + "obs_time_epoch": metar.get("obs_time_epoch"), + "obs_time_utc_offset_seconds": 0, + "wind_speed_kt": _safe_float(current.get("wind_speed_kt")), + "wind_dir": _safe_float(current.get("wind_dir")), + "humidity": _safe_float(current.get("humidity")), + "visibility_mi": _safe_float(current.get("visibility_mi")), + "wx_desc": current.get("wx_desc"), + "raw_metar": current.get("raw_metar"), + }, + ) + return _normalize_station_row( station_code=meta.get("icao") or metar.get("icao"), station_label=meta.get("airport_name") or metar.get("station_name") or metar.get("icao"), diff --git a/src/data_collection/singapore_mss_sources.py b/src/data_collection/singapore_mss_sources.py new file mode 100644 index 00000000..760a106c --- /dev/null +++ b/src/data_collection/singapore_mss_sources.py @@ -0,0 +1,132 @@ +"""Singapore MSS 1-minute real-time temperature data source. + +Fetches air temperature from data.gov.sg public API. +The API provides dry-bulb temperature (1-min mean) at ~1 min interval +from 15 stations across Singapore. Station S24 (Upper Changi Road North) +is the closest to Changi Airport (WSSS). + +URL: https://api.data.gov.sg/v1/environment/air-temperature +""" + +from __future__ import annotations + +import time +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from loguru import logger + +from src.utils.metrics import record_source_call + +SINGAPORE_MSS_TEMP_URL = ( + "https://api.data.gov.sg/v1/environment/air-temperature" +) + +# Preferred station: closest to Changi Airport (WSSS) +PREFERRED_STATION_ID = "S24" +PREFERRED_STATION_NAME = "Upper Changi Road North" + + +def _parse_mss_reading(raw: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Extract the preferred station's temperature from an API response.""" + items = raw.get("items") or [] + if not items: + return None + + metadata = raw.get("metadata") or {} + stations = metadata.get("stations") or [] + + # Resolve station name + station_name = "" + for s in stations: + if isinstance(s, dict) and s.get("id") == PREFERRED_STATION_ID: + station_name = str(s.get("name") or PREFERRED_STATION_NAME) + break + if not station_name: + station_name = PREFERRED_STATION_NAME + + # Take the most recent reading + latest = items[-1] + readings = latest.get("readings") or [] + ts = str(latest.get("timestamp") or "") + + for r in readings: + if r.get("station_id") == PREFERRED_STATION_ID: + temp_c = r.get("value") + if temp_c is not None and -20 < temp_c < 55: + # Observed at: use the API timestamp, which is UTC+8 + obs_time = ts + if obs_time: + try: + dt = datetime.fromisoformat(obs_time) + obs_time = dt.astimezone(timezone.utc).isoformat() + except (ValueError, OverflowError): + pass + + return { + "station_id": PREFERRED_STATION_ID, + "station_name": station_name, + "temp_c": round(float(temp_c), 1), + "obs_time": obs_time, + "source": "sg_mss", + } + + return None + + +class SingaporeMssSourceMixin: + """Mixin that adds Singapore MSS 1-min data to WeatherDataCollector.""" + + def _sg_mss_http_get(self, url: str) -> Optional[Dict[str, Any]]: + getter = getattr(self, "_http_get", None) + if callable(getter): + resp = getter(url) + if hasattr(resp, "json"): + return resp.json() + return resp + try: + resp = self.session.get(url, timeout=self.timeout) + resp.raise_for_status() + return resp.json() + except Exception: + return None + + def fetch_singapore_mss_current(self) -> Optional[Dict[str, Any]]: + """Fetch latest Singapore MSS temperature for Changi station.""" + started = time.perf_counter() + + try: + raw = self._sg_mss_http_get(SINGAPORE_MSS_TEMP_URL) + if not raw: + record_source_call( + "sg_mss", "current", "empty", + (time.perf_counter() - started) * 1000.0, + ) + return None + + result = _parse_mss_reading(raw) + if result: + logger.info( + "Singapore MSS: station={} temp={}°C obs_time={}", + result["station_id"], + result["temp_c"], + result.get("obs_time", "?"), + ) + record_source_call( + "sg_mss", "current", "success", + (time.perf_counter() - started) * 1000.0, + ) + else: + record_source_call( + "sg_mss", "current", "no_station", + (time.perf_counter() - started) * 1000.0, + ) + return result + + except Exception as exc: + logger.warning("Singapore MSS fetch failed: {}", exc) + record_source_call( + "sg_mss", "current", "error", + (time.perf_counter() - started) * 1000.0, + ) + return None diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index 9757042d..13461212 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -19,10 +19,11 @@ from src.data_collection.fmi_sources import FmiSourceMixin from src.data_collection.knmi_sources import KnmiSourceMixin from src.data_collection.hko_obs_sources import HkoObsSourceMixin from src.data_collection.madis_sources import MadisSourceMixin +from src.data_collection.singapore_mss_sources import SingaporeMssSourceMixin from src.database.db_manager import DBManager -class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, RussiaStationSourceMixin, NmcSourceMixin, NwsOpenMeteoSourceMixin, AmosStationSourceMixin, FmiSourceMixin, KnmiSourceMixin, HkoObsSourceMixin, MadisSourceMixin): +class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, RussiaStationSourceMixin, NmcSourceMixin, NwsOpenMeteoSourceMixin, AmosStationSourceMixin, FmiSourceMixin, KnmiSourceMixin, HkoObsSourceMixin, MadisSourceMixin, SingaporeMssSourceMixin): """ Multi-source weather data collector @@ -923,6 +924,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour if not official_rows: return results["jma_official_nearby"] = official_rows + results["jma_current"] = official_rows[0] # primary station for airport_primary if "mgm_nearby" not in results: results["mgm_nearby"] = official_rows results["nearby_source"] = "jma" @@ -949,6 +951,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour if not rows: return results["knmi_official_nearby"] = rows + results["knmi_current"] = rows[0] # primary station for airport_primary if "mgm_nearby" not in results: results["mgm_nearby"] = rows results["nearby_source"] = "knmi" @@ -974,6 +977,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour if not rows: return results["fmi_official_nearby"] = rows + results["fmi_current"] = rows[0] # primary station for airport_primary if "mgm_nearby" not in results: results["mgm_nearby"] = rows results["nearby_source"] = "fmi" @@ -1101,6 +1105,30 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour ) break + def _attach_singapore_mss_data( + self, results: Dict, city_lower: str + ) -> None: + """Fetch Singapore MSS 1-min temperature and inject into results.""" + if city_lower != "singapore": + return + + obs = self.fetch_singapore_mss_current() + if not obs: + return + + results["singapore_mss_current"] = obs + try: + DBManager().append_airport_obs( + icao="WSSS", + city=city_lower, + temp_c=obs["temp_c"], + obs_time=obs["obs_time"] or datetime.now().isoformat(), + ) + except Exception: + logger.exception( + "airport_obs_log append failed for singapore_mss city={}", city_lower + ) + def _attach_russia_official_nearby( self, results: Dict, city_lower: str, use_fahrenheit: bool ) -> None: @@ -1227,6 +1255,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour ) self._attach_korean_amos_data(results, city_lower, use_fahrenheit) self._attach_madis_hfmetar_data(results, city_lower) + self._attach_singapore_mss_data(results, city_lower) if include_nearby: self._attach_china_official_nearby(results, city_lower, use_fahrenheit) self._attach_japan_official_nearby(results, city_lower, use_fahrenheit) @@ -1273,6 +1302,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour ) self._attach_korean_amos_data(results, city_lower, use_fahrenheit) self._attach_madis_hfmetar_data(results, city_lower) + self._attach_singapore_mss_data(results, city_lower) if include_nearby: self._attach_china_official_nearby(results, city_lower, use_fahrenheit) self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)