"""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 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 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["sessionId"] = session_id headers["app"] = "AMS" 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") try: return json.loads(raw) 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()) 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