Files
PolyWeather/src/data_collection/amsc_awos_sources.py
T

386 lines
13 KiB
Python
Raw Normal View History

2026-05-15 01:41:49 +08:00
"""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 ssl
2026-05-15 01:41:49 +08:00
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from urllib.parse import quote
from urllib.request import Request, urlopen
2026-05-15 01:41:49 +08:00
from loguru import logger
from src.utils.metrics import record_source_call
2026-05-30 20:33:30 +08:00
from src.utils.runtime_secrets import get_runtime_secret
2026-05-15 01:41:49 +08:00
AMSC_AWOS_BASE_URL = os.getenv("AMSC_AWOS_BASE_URL", "").strip()
2026-05-15 01:41:49 +08:00
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"},
"chengdu": {"icao": "ZUUU", "label": "Chengdu Shuangliu"},
"chongqing": {"icao": "ZUCK", "label": "Chongqing Jiangbei"},
"wuhan": {"icao": "ZHHH", "label": "Wuhan Tianhe"},
"qingdao": {"icao": "ZSQD", "label": "Qingdao Jiaodong"},
}
AMSC_SETTLEMENT_RUNWAY_TARGETS: Dict[str, str] = {
"shanghai": "35R",
"chengdu": "02L",
"chongqing": "02L",
"guangzhou": "02L",
"wuhan": "04",
"beijing": "01",
"qingdao": "34",
}
2026-05-15 01:41:49 +08:00
def _amsc_supported_city_codes() -> Dict[str, str]:
return {city: meta["icao"] for city, meta in AMSC_AWOS_AIRPORTS.items()}
def _amsc_normalize_runway(value: Any) -> str:
return str(value or "").strip().upper().replace(" ", "")
2026-05-15 01:41:49 +08:00
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 = [_amsc_normalize_runway(part) for part in str(label or "").split("/") if part.strip()]
2026-05-15 01:41:49 +08:00
if len(parts) >= 2:
return parts[0], parts[1]
runway = _amsc_normalize_runway(label) or "--"
2026-05-15 01:41:49 +08:00
return runway, runway
def _amsc_settlement_endpoint(
city_key: str,
runway_pair: tuple[str, str],
*,
tdz_temp: Optional[float],
end_temp: Optional[float],
) -> Optional[Dict[str, Any]]:
target = _amsc_normalize_runway(AMSC_SETTLEMENT_RUNWAY_TARGETS.get(city_key))
if not target:
return None
first = _amsc_normalize_runway(runway_pair[0])
second = _amsc_normalize_runway(runway_pair[1])
if target == first:
temp = tdz_temp if tdz_temp is not None else end_temp
position = "tdz" if tdz_temp is not None else "end_fallback"
elif target == second:
temp = end_temp if end_temp is not None else tdz_temp
position = "end" if end_temp is not None else "tdz_fallback"
else:
return None
if temp is None:
return None
return {
"runway": target,
"pair": f"{first}/{second}",
"position": position,
"temp": temp,
}
def _amsc_wind_dir(*candidates: Any) -> Optional[int]:
"""Parse wind direction from AMSC fields (degrees)."""
for value in candidates:
parsed = _amsc_safe_float(value)
if parsed is not None and 0 <= parsed <= 360:
return int(round(parsed))
return None
def _amsc_parse_rvr(value: Any) -> Optional[int]:
"""Parse RVR field like 'P2000' → 2000 (meters)."""
text = str(value or "").strip().upper()
if not text or text in {"--", "-", "NULL", "NONE"}:
return None
text = text.lstrip("PM")
parsed = _amsc_safe_float(text)
return int(round(parsed)) if parsed is not None else None
2026-05-15 01:41:49 +08:00
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_pair = _amsc_split_runway_pair(runway_label)
settlement_endpoint = _amsc_settlement_endpoint(
city_key,
runway_pair,
tdz_temp=tdz,
end_temp=end,
)
runway_pairs.append(runway_pair)
target_temp = (
settlement_endpoint["temp"]
if settlement_endpoint is not None
else max(points)
)
best_temp = (
settlement_endpoint["temp"]
if settlement_endpoint is not None
else tdz if tdz is not None else max(points)
)
2026-05-15 01:41:49 +08:00
runway_temps.append((best_temp, None))
valid_values.extend(points)
# Wind: prefer TDZ point, fallback to END
wind_dir = _amsc_wind_dir(
raw_row.get("TDZ_WIND_D10"),
raw_row.get("END_WIND_D10"),
)
wind_speed = _amsc_safe_float(
raw_row.get("TDZ_WIND_F10") or raw_row.get("END_WIND_F10")
)
# RVR / MOR: prefer 1-min average
raw_rvr = (
raw_row.get("TDZ_RVR_1A") or raw_row.get("END_RVR_1A")
or raw_row.get("TDZ_RVR_10A") or raw_row.get("END_RVR_10A")
)
rvr = _amsc_parse_rvr(raw_rvr)
raw_mor = (
raw_row.get("TDZ_MOR_1A") or raw_row.get("END_MOR_1A")
or raw_row.get("TDZ_MOR_10A") or raw_row.get("END_MOR_10A")
)
mor = _amsc_safe_float(raw_mor)
# Humidity: prefer TDZ, fallback to END, then MID
humidity = _amsc_safe_float(
raw_row.get("TDZ_HUMID") or raw_row.get("END_HUMID") or raw_row.get("MID_HUMID")
)
point = {
"runway": f"{runway_pair[0]}/{runway_pair[1]}",
"temp": target_temp,
"tdz_temp": tdz,
"mid_temp": mid,
"end_temp": end,
"target_runway_max": target_temp,
"wind_dir": wind_dir,
"wind_speed": wind_speed,
"rvr": rvr,
"mor": mor,
"humidity": humidity,
}
if settlement_endpoint is not None:
point.update(
{
"is_settlement": True,
"settlement_runway": settlement_endpoint["runway"],
"settlement_runway_position": settlement_endpoint["position"],
"settlement_runway_temp": settlement_endpoint["temp"],
}
)
point_temperatures.append(point)
2026-05-15 01:41:49 +08:00
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)
settlement_point = next(
(
point
for point in point_temperatures
if point.get("is_settlement") and point.get("settlement_runway_temp") is not None
),
None,
)
display_temp = (
round(float(settlement_point["settlement_runway_temp"]), 1)
if settlement_point is not None
else max_temp
)
2026-05-15 01:41:49 +08:00
return {
"temp": display_temp,
"temp_c": display_temp,
"temp_source": "settlement_runway_endpoint" if settlement_point else "runway_max",
"settlement_runway": settlement_point.get("settlement_runway") if settlement_point else None,
"settlement_runway_pair": settlement_point.get("runway") if settlement_point else None,
"settlement_runway_position": settlement_point.get("settlement_runway_position") if settlement_point else None,
"settlement_runway_temp": display_temp if settlement_point else None,
2026-05-15 01:41:49 +08:00
"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,
"wind_dir": next((pt.get("wind_dir") for pt in point_temperatures if pt.get("wind_dir") is not None), None) if point_temperatures else None,
"wind_speed": point_temperatures[0].get("wind_speed") if point_temperatures else None,
2026-05-15 01:41:49 +08:00
"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": os.getenv("AMSC_AWOS_REFERER", "https://www.amsc.net.cn/"),
2026-05-15 01:41:49 +08:00
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36"
),
}
2026-05-30 20:33:30 +08:00
cookie = get_runtime_secret("POLYWEATHER_AMSC_COOKIE")
session_id = get_runtime_secret("POLYWEATHER_AMSC_SESSION_ID")
2026-05-15 01:41:49 +08:00
if cookie:
headers["Cookie"] = cookie
elif session_id:
headers["sessionId"] = session_id
headers["app"] = "AMS"
2026-05-15 01:41:49 +08:00
return headers
def _amsc_http_get_json(self, url: str, *, headers: Optional[Dict[str, str]] = None) -> Optional[Dict[str, Any]]:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = Request(url, headers=headers or {})
with urlopen(req, timeout=getattr(self, "timeout", 10.0), context=ctx) as resp:
raw = resp.read().decode("utf-8", errors="replace")
2026-05-15 01:41:49 +08:00
try:
return json.loads(raw)
2026-05-15 01:41:49 +08:00
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._amsc_http_get_json(url, headers=self._amsc_headers())
2026-05-15 01:41:49 +08:00
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