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
|
||||
Reference in New Issue
Block a user