feat: add AMSC runway observations

This commit is contained in:
2569718930@qq.com
2026-05-15 01:41:49 +08:00
parent c4b1844a67
commit 4cc579ccb3
15 changed files with 823 additions and 14 deletions
+244
View File
@@ -0,0 +1,244 @@
"""AMSC AWOS runway observation source for China mainland airports.
The AMSC `getWindPlate` endpoint exposes runway-point air temperature fields:
TDZ_TEMP (touchdown zone), MID_TEMP (mid runway), and END_TEMP (runway end).
These values are air temperatures reported by runway observation positions,
not pavement/surface temperatures.
"""
from __future__ import annotations
import json
import os
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from urllib.parse import quote
import httpx
from loguru import logger
from src.utils.metrics import record_source_call
AMSC_AWOS_BASE_URL = (
"https://www.amsc.net.cn/gateway/api/saas/rest/amc/"
"AwosController/getWindPlate"
)
AMSC_AWOS_AIRPORTS: Dict[str, Dict[str, str]] = {
"shanghai": {"icao": "ZSPD", "label": "Shanghai Pudong"},
"beijing": {"icao": "ZBAA", "label": "Beijing Capital"},
"guangzhou": {"icao": "ZGGG", "label": "Guangzhou Baiyun"},
"shenzhen": {"icao": "ZGSZ", "label": "Shenzhen Bao'an"},
"chengdu": {"icao": "ZUUU", "label": "Chengdu Shuangliu"},
"chongqing": {"icao": "ZUCK", "label": "Chongqing Jiangbei"},
"wuhan": {"icao": "ZHHH", "label": "Wuhan Tianhe"},
"qingdao": {"icao": "ZSQD", "label": "Qingdao Jiaodong"},
}
def _amsc_supported_city_codes() -> Dict[str, str]:
return {city: meta["icao"] for city, meta in AMSC_AWOS_AIRPORTS.items()}
def _amsc_safe_float(value: Any) -> Optional[float]:
if value is None:
return None
text = str(value).strip()
if not text or text in {"-", "--", "null", "None"}:
return None
try:
parsed = float(text)
except (TypeError, ValueError):
return None
if not -80.0 < parsed < 80.0:
return None
return parsed
def _amsc_split_runway_pair(label: str) -> tuple[str, str]:
parts = [part.strip() for part in str(label or "").split("/") if part.strip()]
if len(parts) >= 2:
return parts[0], parts[1]
runway = str(label or "").strip() or "--"
return runway, runway
def _amsc_parse_utc_time(value: Any) -> tuple[Optional[str], Optional[str]]:
text = str(value or "").strip()
if not text:
return None, None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
utc_dt = datetime.strptime(text, fmt).replace(tzinfo=timezone.utc)
local_dt = utc_dt + timedelta(hours=8)
return utc_dt.isoformat(), local_dt.strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
continue
return text, None
def _amsc_parse_wind_plate_payload(
payload: Dict[str, Any],
*,
city_key: str,
icao: str,
) -> Optional[Dict[str, Any]]:
if not isinstance(payload, dict):
return None
if payload.get("errCode") is not None:
return None
if payload.get("code") not in (None, 200, "200"):
return None
data = payload.get("data")
if not isinstance(data, dict) or not data:
return None
airport_meta = AMSC_AWOS_AIRPORTS.get(city_key, {"icao": icao, "label": icao})
runway_pairs = []
runway_temps = []
point_temperatures = []
valid_values = []
observation_time = None
observation_time_local = None
raw_metar = None
for key, raw_row in data.items():
if not isinstance(raw_row, dict):
continue
runway_label = str(raw_row.get("RNO") or key or "").strip()
if not runway_label:
continue
tdz = _amsc_safe_float(raw_row.get("TDZ_TEMP"))
mid = _amsc_safe_float(raw_row.get("MID_TEMP"))
end = _amsc_safe_float(raw_row.get("END_TEMP"))
points = [value for value in (tdz, mid, end) if value is not None]
if not points:
continue
if observation_time is None:
observation_time, observation_time_local = _amsc_parse_utc_time(raw_row.get("OTIME"))
if raw_metar is None and raw_row.get("METAR"):
raw_metar = str(raw_row.get("METAR"))
runway_pairs.append(_amsc_split_runway_pair(runway_label))
best_temp = tdz if tdz is not None else max(points)
runway_temps.append((best_temp, None))
valid_values.extend(points)
point_temperatures.append(
{
"runway": runway_label,
"tdz_temp": tdz,
"mid_temp": mid,
"end_temp": end,
}
)
if not valid_values or not runway_pairs:
return None
max_temp = round(max(valid_values), 1)
min_temp = round(min(valid_values), 1)
avg_temp = round(sum(valid_values) / len(valid_values), 1)
return {
"temp": max_temp,
"temp_c": max_temp,
"temp_source": "runway_max",
"runway_temps": runway_temps,
"runway_temp_range": (min_temp, max_temp),
"runway_temp_avg": avg_temp,
"source": "amsc_awos",
"source_label": f"AMSC AWOS {airport_meta.get('label', icao)} ({icao})",
"source_code": "amsc_awos",
"network_type": "amsc_awos",
"icao": icao,
"station_label": airport_meta.get("label") or icao,
"raw_metar": raw_metar,
"raw_taf": None,
"runway_obs": {
"runway_pairs": runway_pairs,
"temperatures": runway_temps,
"point_temperatures": point_temperatures,
},
"observation_source": "AMSC AWOS runway-point air temperature",
"observation_source_zh": "AMSC AWOS 跑道观测气温",
"observation_time": observation_time,
"observation_time_local": observation_time_local,
}
class AmscAwosSourceMixin:
"""Mixin that adds AMSC AWOS runway-point air temperatures."""
def _amsc_headers(self) -> Dict[str, str]:
headers = {
"Accept": "application/json, text/plain, */*",
"Referer": "https://www.amsc.net.cn/",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36"
),
}
cookie = os.getenv("POLYWEATHER_AMSC_COOKIE", "").strip()
session_id = os.getenv("POLYWEATHER_AMSC_SESSION_ID", "").strip()
if cookie:
headers["Cookie"] = cookie
elif session_id:
headers["Cookie"] = f"sessionId={session_id}"
return headers
def _http_get_json(self, url: str, *, headers: Optional[Dict[str, str]] = None) -> Optional[Dict[str, Any]]:
response = httpx.get(url, headers=headers, timeout=getattr(self, "timeout", 10.0))
response.raise_for_status()
try:
return response.json()
except json.JSONDecodeError:
return None
def fetch_amsc_awos_current(
self,
city_key: str,
*,
use_fahrenheit: bool = False,
) -> Optional[Dict[str, Any]]:
del use_fahrenheit # AMSC reports Celsius; project UI converts elsewhere if needed.
normalized_city = str(city_key or "").strip().lower()
airport_meta = AMSC_AWOS_AIRPORTS.get(normalized_city)
if not airport_meta:
return None
icao = airport_meta["icao"]
url = f"{AMSC_AWOS_BASE_URL}?cccc={quote(icao)}"
started = time.perf_counter()
try:
payload = self._http_get_json(url, headers=self._amsc_headers())
result = _amsc_parse_wind_plate_payload(
payload or {},
city_key=normalized_city,
icao=icao,
)
if result:
record_source_call(
"amsc_awos",
"current",
"success",
(time.perf_counter() - started) * 1000.0,
)
else:
record_source_call(
"amsc_awos",
"current",
"empty",
(time.perf_counter() - started) * 1000.0,
)
return result
except Exception as exc:
logger.warning("AMSC AWOS fetch failed city={} icao={}: {}", normalized_city, icao, exc)
record_source_call(
"amsc_awos",
"current",
"error",
(time.perf_counter() - started) * 1000.0,
)
return None
+4 -3
View File
@@ -275,13 +275,14 @@ def _airport_primary_from_raw(city: str, raw: Dict[str, Any]) -> Dict[str, Any]:
amos = raw.get("amos") or {}
if amos.get("temp_c") is not None:
is_amsc = amos.get("source") == "amsc_awos"
return _normalize_station_row(
station_code=amos.get("icao") or meta.get("icao"),
station_label=amos.get("station_label") or meta.get("airport_name") or meta.get("icao"),
temp=amos["temp_c"],
obs_time=amos.get("obs_time") or metar.get("observation_time"),
source_code="amos",
source_label="AMOS",
obs_time=amos.get("obs_time") or amos.get("observation_time") or metar.get("observation_time"),
source_code="amsc_awos" if is_amsc else "amos",
source_label="AMSC AWOS" if is_amsc else "AMOS",
is_official=True,
is_airport_station=True,
is_settlement_anchor=False,
+51 -1
View File
@@ -15,6 +15,7 @@ from src.data_collection.russia_station_sources import RussiaStationSourceMixin
from src.data_collection.nmc_sources import NmcSourceMixin
from src.data_collection.nws_open_meteo_sources import NwsOpenMeteoSourceMixin
from src.data_collection.amos_station_sources import AmosStationSourceMixin
from src.data_collection.amsc_awos_sources import AmscAwosSourceMixin
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
@@ -23,7 +24,7 @@ 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, SingaporeMssSourceMixin):
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, RussiaStationSourceMixin, NmcSourceMixin, NwsOpenMeteoSourceMixin, AmosStationSourceMixin, AmscAwosSourceMixin, FmiSourceMixin, KnmiSourceMixin, HkoObsSourceMixin, MadisSourceMixin, SingaporeMssSourceMixin):
"""
Multi-source weather data collector
@@ -31,6 +32,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
- Open-Meteo (global forecast + multi-model ensemble)
- METAR/TAF (aviation weather observations)
- AMOS (Korean runway-level airport sensors — RKSI, RKPK)
- AMSC AWOS (China mainland runway-point airport sensors)
- NWS (US National Weather Service)
- MGM (Turkish Meteorological Service)
- JMA / NMC / HKO / CWA (country official networks)
@@ -1094,6 +1096,52 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
except Exception as exc:
logger.warning("AMOS attach failed city={}: {}", city_lower, exc)
def _attach_china_amsc_awos_data(
self, results: Dict, city_lower: str, use_fahrenheit: bool
) -> None:
"""Fetch AMSC AWOS runway-point air temperature for selected China cities."""
try:
amsc_data = self.fetch_amsc_awos_current(
city_lower, use_fahrenheit=use_fahrenheit
)
if not amsc_data:
return
logger.info(
"AMSC AWOS: got data for city={} temp_c={} runway_pairs={}",
city_lower,
amsc_data.get("temp_c"),
len(amsc_data.get("runway_obs", {}).get("runway_pairs", []) or []),
)
# Reuse the existing `amos` detail shape consumed by dashboard runway panels.
results["amos"] = amsc_data
try:
DBManager().append_airport_obs(
icao=amsc_data.get("icao") or "",
city=city_lower,
temp_c=amsc_data.get("temp_c"),
wind_kt=amsc_data.get("wind_kt"),
pressure_hpa=amsc_data.get("pressure_hpa"),
obs_time=amsc_data.get("observation_time") or datetime.now().isoformat(),
)
runway_obs = amsc_data.get("runway_obs") or {}
rw_pairs = runway_obs.get("runway_pairs") or []
rw_temps = runway_obs.get("temperatures") or []
for i, (pair, temp_pair) in enumerate(zip(rw_pairs, rw_temps)):
t = temp_pair[0] if temp_pair else None
if t is not None and i < 6:
pair_label = "/".join(pair) if isinstance(pair, (list, tuple)) else str(pair)
DBManager().append_airport_obs(
icao=f"{amsc_data.get('icao', '')}_RWY_{i}",
city=city_lower,
temp_c=t,
obs_time=amsc_data.get("observation_time") or datetime.now().isoformat(),
)
logger.debug("AMSC AWOS stored runway row city={} runway={} temp={}", city_lower, pair_label, t)
except Exception:
logger.exception("airport_obs_log append failed for amsc_awos city={}", city_lower)
except Exception as exc:
logger.warning("AMSC AWOS attach failed city={}: {}", city_lower, exc)
def _attach_madis_hfmetar_data(
self, results: Dict, city_lower: str
) -> None:
@@ -1286,6 +1334,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
include_nearby=include_nearby,
)
self._attach_korean_amos_data(results, city_lower, use_fahrenheit)
self._attach_china_amsc_awos_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:
@@ -1334,6 +1383,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
include_nearby=include_nearby,
)
self._attach_korean_amos_data(results, city_lower, use_fahrenheit)
self._attach_china_amsc_awos_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: