feat: add AMSC runway observations
This commit is contained in:
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user