接入新加坡 MSS 1分钟实时温度及 MGM/JMA/FMI/KNMI 高频源到 airport_primary

- 新建 singapore_mss_sources.py:拉取 data.gov.sg 1分钟干球温度(S24 樟宜站)
- country_networks.py:_airport_primary_from_raw 新增 MGM/JMA/FMI/KNMI/SG_MSS 分支
- weather_sources.py:注入 jma_current/fmi_current/knmi_current/singapore_mss_current
- 前端 MonitorPanel:resolveSourceLabel 根据 airport_primary.source_code 显示数据源标签
- 文档:更新 AIRPORT_REALTIME_SOURCES.md 新增新加坡

Tested: ruff check . ✓  npx tsc --noEmit ✓
This commit is contained in:
2569718930@qq.com
2026-05-14 17:12:11 +08:00
parent 96676e7097
commit 8dd9aa59b3
5 changed files with 342 additions and 6 deletions
+146
View File
@@ -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"),
@@ -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
+31 -1
View File
@@ -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)