diff --git a/docs/superpowers/plans/2026-05-15-amsc-awos-runway-observations.md b/docs/superpowers/plans/2026-05-15-amsc-awos-runway-observations.md new file mode 100644 index 00000000..3ef93d8c --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-amsc-awos-runway-observations.md @@ -0,0 +1,102 @@ +# AMSC AWOS Runway Observation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a China-only AMSC AWOS runway observation source and expose it as a runway observation tab next to Market Monitor. + +**Architecture:** Backend fetches and normalizes AMSC `getWindPlate?cccc=...` payloads into the existing `amos`/`runway_obs` shape so current dashboard consumers can reuse runway display logic. Frontend adds a dedicated `runway` scan terminal tab that fetches a domestic city whitelist and renders runway TDZ/MID/END air temperatures without changing settlement anchors. + +**Tech Stack:** Python data collection + pytest, Next.js/React TypeScript, existing business-state test runner. + +--- + +### Task 1: Backend parser and source + +**Files:** +- Create: `src/data_collection/amsc_awos_sources.py` +- Create: `tests/test_amsc_awos_sources.py` +- Modify: `src/data_collection/weather_sources.py` +- Modify: `src/data_collection/country_networks.py` + +- [ ] **Step 1: Write failing parser tests** + +Add tests that import `_amsc_parse_wind_plate_payload`, `_amsc_supported_city_codes`, and `AmscAwosSourceMixin`, parse a ZBAA-style sample, assert runway point temperatures, UTC observation conversion, `runway_temp_range`, and unauthorized/no-data fallback. + +- [ ] **Step 2: Run red test** + +Run: `python -m pytest tests/test_amsc_awos_sources.py -q` +Expected: FAIL because `src.data_collection.amsc_awos_sources` does not exist. + +- [ ] **Step 3: Implement minimal backend source** + +Create a source module with China whitelist: `shanghai=ZSPD`, `beijing=ZBAA`, `guangzhou=ZGGG`, `shenzhen=ZGSZ`, `chengdu=ZUUU`, `chongqing=ZUCK`, `wuhan=ZHHH`, `qingdao=ZSQD`. Fetch `https://www.amsc.net.cn/gateway/api/saas/rest/amc/AwosController/getWindPlate?cccc=`, optionally using `POLYWEATHER_AMSC_COOKIE` or `POLYWEATHER_AMSC_SESSION_ID`, and return existing-compatible `amos` payload with `source="amsc_awos"`. + +- [ ] **Step 4: Run green backend tests** + +Run: `python -m pytest tests/test_amsc_awos_sources.py tests/test_amos_station_sources.py -q` +Expected: PASS. + +### Task 2: Backend integration + +**Files:** +- Modify: `src/data_collection/weather_sources.py` +- Modify: `src/data_collection/country_networks.py` + +- [ ] **Step 1: Attach AMSC after AMOS** + +Add `AmscAwosSourceMixin` to `WeatherDataCollector`, call `_attach_china_amsc_awos_data` in both Open-Meteo and fallback paths, and persist aggregate plus first runway rows to `airport_obs_log` like AMOS. + +- [ ] **Step 2: Normalize airport primary source labels** + +Teach `_airport_primary_from_raw` that `raw["amos"].source == "amsc_awos"` should use `source_code="amsc_awos"`, `source_label="AMSC AWOS"`. + +- [ ] **Step 3: Compile check** + +Run: `python -m py_compile src/data_collection/amsc_awos_sources.py src/data_collection/weather_sources.py src/data_collection/country_networks.py` +Expected: exit 0. + +### Task 3: Frontend runway tab + +**Files:** +- Create: `frontend/components/dashboard/scan-terminal/RunwayObservationsPanel.tsx` +- Create: `frontend/components/dashboard/scan-terminal/__tests__/runwayObservationTab.test.ts` +- Modify: `frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx` +- Modify: `frontend/components/dashboard/ScanTerminalDashboard.tsx` +- Modify: `frontend/lib/dashboard-types.ts` +- Modify: `frontend/components/dashboard/monitoring/monitor-temperature.ts` +- Modify: `frontend/components/dashboard/monitoring/MonitorPanel.tsx` + +- [ ] **Step 1: Write failing frontend business-state test** + +Add a source-scan test asserting `ScanTerminalContentView` includes `runway`, dashboard has a `跑道观测` tab, and the panel includes `AMSC AWOS` plus TDZ/MID/END labels. + +- [ ] **Step 2: Run red frontend test** + +Run: `cd frontend; npm run test:business` +Expected: FAIL because the runway tab/panel strings do not exist yet. + +- [ ] **Step 3: Implement tab and panel** + +Add `runway` view next to Monitor. The panel fetches domestic whitelist details with `ensureCityDetail(key, false, "panel")`, displays city cards with runway rows and local-time labels, and uses a not-available message for cities without AMSC data. + +- [ ] **Step 4: Run green frontend checks** + +Run: `cd frontend; npm run test:business; npm run typecheck` +Expected: PASS. + +### Task 4: Final verification and publish + +**Files:** +- All changed files from Tasks 1-3. + +- [ ] **Step 1: Full verification** + +Run backend tests, Python compile, frontend business tests, typecheck, and build. + +- [ ] **Step 2: Completion audit** + +Map user requirement “国内几个城市的机场跑道温度,放在市场监控旁边 Tab” to changed backend source, frontend tab, tests, and build evidence. + +- [ ] **Step 3: Commit/push/deploy** + +If verification passes, commit, push `main`, and rely on configured deployment. diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 8ec1a8c8..e8f06e51 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -59,6 +59,14 @@ const MonitorPanel = dynamic( { ssr: false }, ); +const RunwayObservationsPanel = dynamic( + () => + import( + "@/components/dashboard/scan-terminal/RunwayObservationsPanel" + ).then((module) => module.RunwayObservationsPanel), + { ssr: false }, +); + const CityDetailPanel = dynamic( () => import("@/components/dashboard/DetailPanel").then( @@ -359,7 +367,7 @@ function ScanTerminalScreen() { /> ); } - if (resolvedView === "monitor") { + if (resolvedView === "monitor" || resolvedView === "runway") { return null; // MonitorPanel is rendered below the main view switch } if (!isPro) { @@ -472,6 +480,15 @@ function ScanTerminalScreen() { > 🔥 {isEn ? "Monitor" : "市场监控"} +
{terminalData?.generated_at ? ( @@ -539,6 +556,13 @@ function ScanTerminalScreen() { ) )} + {resolvedView === "runway" && ( + isPro ? ( + + ) : ( + + ) + )} diff --git a/frontend/components/dashboard/monitoring/MonitorPanel.tsx b/frontend/components/dashboard/monitoring/MonitorPanel.tsx index 7be22f7d..ed9472a1 100644 --- a/frontend/components/dashboard/monitoring/MonitorPanel.tsx +++ b/frontend/components/dashboard/monitoring/MonitorPanel.tsx @@ -95,7 +95,12 @@ function playNewHighBeep(): void { function trendClass(detail: CityDetail | undefined, key?: string): "rising" | "falling" | "flat" { const { source } = resolveMonitorTemperature(detail); // Runway surface temp vs air temp comparison is meaningless - if (source === "amos_runway_median" || source === "amos_runway") return "flat"; + if ( + source === "amos_runway_median" || + source === "amos_runway" || + source === "amsc_awos_runway_max" || + source === "amsc_awos_runway" + ) return "flat"; const cur = resolveMonitorTemperature(detail).value; const max = resolveMaxSoFar(detail, key); if (cur != null && max != null && cur >= max + 0.3) return "rising"; @@ -177,6 +182,9 @@ function airportLabel(key: string, isEn: boolean) { const HKO_OBS_CITIES = new Set(["hong kong", "lau fau shan"]); const SOURCE_LABELS: Record = { + amsc_awos_runway_max: { en: "AMSC AWOS Runway", zh: "AMSC AWOS 跑道观测" }, + amsc_awos_runway: { en: "AMSC AWOS Runway", zh: "AMSC AWOS 跑道观测" }, + amsc_awos: { en: "AMSC AWOS", zh: "AMSC AWOS" }, amos_runway_median: { en: "AMOS Runway", zh: "AMOS 跑道温度" }, amos_runway: { en: "AMOS Runway", zh: "AMOS 跑道温度" }, amos: { en: "AMOS", zh: "AMOS" }, @@ -512,7 +520,11 @@ export default function MonitorPanel({ const tempInfo = resolveMonitorTemperature(detail); const cur = tempInfo.value; const curSource = tempInfo.source; - const isRunwayTemp = curSource === "amos_runway_median" || curSource === "amos_runway"; + const isRunwayTemp = + curSource === "amos_runway_median" || + curSource === "amos_runway" || + curSource === "amsc_awos_runway_max" || + curSource === "amsc_awos_runway"; const max = resolveMaxSoFar(detail, key); // HKO cities fall back to current.max_so_far const mtt = ac?.max_temp_time ?? detail.current?.max_temp_time ?? null; const freshnessInfo = getObservationFreshness(detail); diff --git a/frontend/components/dashboard/monitoring/monitor-temperature.ts b/frontend/components/dashboard/monitoring/monitor-temperature.ts index 25fc2001..ab776edd 100644 --- a/frontend/components/dashboard/monitoring/monitor-temperature.ts +++ b/frontend/components/dashboard/monitoring/monitor-temperature.ts @@ -1,6 +1,8 @@ import type { CityDetail } from "@/lib/dashboard-types"; export type MonitorTemperatureSource = + | "amsc_awos_runway_max" + | "amsc_awos_runway" | "amos_runway_median" | "amos_runway" | "amos" @@ -36,6 +38,13 @@ export function getAmosRunwayTemperature(detail?: CityDetail | null) { const values = runwayTemps .map((pair) => finiteNumber(pair?.[0])) .filter((value): value is number => value != null); + if (detail?.amos?.source === "amsc_awos") { + if (!values.length) return null; + return { + source: values.length > 1 ? "amsc_awos_runway_max" : "amsc_awos_runway", + value: Math.max(...values), + } satisfies MonitorTemperature; + } const value = median(values); if (value == null) return null; return { diff --git a/frontend/components/dashboard/scan-terminal/RunwayObservationsPanel.tsx b/frontend/components/dashboard/scan-terminal/RunwayObservationsPanel.tsx new file mode 100644 index 00000000..dfc85fd6 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/RunwayObservationsPanel.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { RefreshCw } from "lucide-react"; +import { useCityDetails, useDashboardActions } from "@/hooks/useDashboardStore"; +import { useI18n } from "@/hooks/useI18n"; +import type { CityDetail } from "@/lib/dashboard-types"; + +const CHINA_RUNWAY_CITIES = [ + { key: "beijing", zh: "北京", en: "Beijing", icao: "ZBAA" }, + { key: "shanghai", zh: "上海", en: "Shanghai", icao: "ZSPD" }, + { key: "guangzhou", zh: "广州", en: "Guangzhou", icao: "ZGGG" }, + { key: "shenzhen", zh: "深圳", en: "Shenzhen", icao: "ZGSZ" }, + { key: "chengdu", zh: "成都", en: "Chengdu", icao: "ZUUU" }, + { key: "chongqing", zh: "重庆", en: "Chongqing", icao: "ZUCK" }, + { key: "wuhan", zh: "武汉", en: "Wuhan", icao: "ZHHH" }, +] as const; + +function formatTemp(value: number | null | undefined, symbol = "°C") { + return value == null || !Number.isFinite(Number(value)) + ? "-" + : `${Number(value).toFixed(1)}${symbol}`; +} + +function getRunwayRows(detail?: CityDetail | null) { + return detail?.amos?.runway_obs?.point_temperatures ?? []; +} + +function sourceIsAmsc(detail?: CityDetail | null) { + return detail?.amos?.source === "amsc_awos"; +} + +function RunwayCityCard({ + detail, + isEn, + label, +}: { + detail?: CityDetail | null; + isEn: boolean; + label: (typeof CHINA_RUNWAY_CITIES)[number]; +}) { + const rows = getRunwayRows(detail); + const tempSymbol = detail?.temp_symbol || "°C"; + const range = detail?.amos?.runway_temp_range; + const obsLocal = detail?.amos?.observation_time_local || detail?.amos?.observation_time; + const hasAmsc = sourceIsAmsc(detail) && rows.length > 0; + + return ( +
+
+ {isEn ? label.en : label.zh} + / {label.icao} + {obsLocal || "--"} +
+ +
+
+ AMSC AWOS + + {range ? `${range[0].toFixed(1)}–${range[1].toFixed(1)}${tempSymbol}` : "--"} + +
+
+ + {isEn ? "Runway-point air temperature" : "跑道观测点气温"} + + + {isEn ? "not pavement temp" : "非道面温度"} + +
+
+ + {hasAmsc ? ( + <> +
+
+ {isEn ? "Runway" : "跑道"} + TDZ / MID / END +
+ {rows.map((row) => ( +
+ {row.runway || "--"} + + {formatTemp(row.tdz_temp, tempSymbol)} / {formatTemp(row.mid_temp, tempSymbol)} / {formatTemp(row.end_temp, tempSymbol)} + +
+ ))} + {detail?.amos?.raw_metar ? ( +
+ METAR {detail.amos.raw_metar.replace(/^METAR\s+/i, "")} +
+ ) : null} + + ) : ( +
+ {isEn ? "No AMSC runway observation loaded yet." : "暂无 AMSC 跑道观测。"} +
+ )} +
+ ); +} + +export function RunwayObservationsPanel() { + const { locale } = useI18n(); + const isEn = locale === "en-US"; + const { cityDetailsByName } = useCityDetails(); + const { ensureCityDetail } = useDashboardActions(); + const [refreshing, setRefreshing] = useState(false); + + const loadAll = useCallback( + async (force: boolean) => { + setRefreshing(true); + try { + await Promise.allSettled( + CHINA_RUNWAY_CITIES.map((city) => + ensureCityDetail(city.key, force, "panel"), + ), + ); + } finally { + setRefreshing(false); + } + }, + [ensureCityDetail], + ); + + useEffect(() => { + void loadAll(false); + }, [loadAll]); + + const cards = useMemo( + () => + CHINA_RUNWAY_CITIES.map((city) => ({ + ...city, + detail: cityDetailsByName[city.key], + })), + [cityDetailsByName], + ); + + return ( +
+
+
+
+ {isEn ? "Runway Observations" : "跑道观测"} +
+
+ {isEn + ? "AMSC AWOS · China mainland airports · TDZ/MID/END air temperature" + : "AMSC AWOS · 国内机场 · TDZ/MID/END 跑道观测点气温"} +
+
+ +
+ +
+ {cards.map((city) => ( + + ))} +
+
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx b/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx index 3b66b973..aef73e21 100644 --- a/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx +++ b/frontend/components/dashboard/scan-terminal/ScanTerminalShellParts.tsx @@ -14,7 +14,7 @@ import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal"; import type { Locale } from "@/lib/i18n"; -export type ScanTerminalContentView = "analysis" | "map" | "monitor"; +export type ScanTerminalContentView = "analysis" | "map" | "monitor" | "runway"; type ThemeMode = "dark" | "light"; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/monitorTemperature.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/monitorTemperature.test.ts index 56c5095d..0863a63a 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/monitorTemperature.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/monitorTemperature.test.ts @@ -54,6 +54,23 @@ export function runTests() { assert.equal(seoulTemp.value, 25); assert.equal(seoulTemp.source, "amos_runway_median"); + const beijing = detail({ + display_name: "Beijing", + name: "beijing", + amos: { + runway_obs: { + runway_pairs: [["18R", "36L"], ["18L", "36R"]], + temperatures: [[20.8, null], [21.0, null]], + }, + source: "amsc_awos", + temp_c: 21, + temp_source: "runway_max", + }, + }); + const beijingTemp = resolveMonitorTemperature(beijing); + assert.equal(beijingTemp.value, 21); + assert.equal(beijingTemp.source, "amsc_awos_runway_max"); + const metarOnly = detail({ airport_current: { obs_time: "15:00", temp: 30 }, current: { temp: 29 } as CityDetail["current"], diff --git a/frontend/components/dashboard/scan-terminal/__tests__/runwayObservationTab.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/runwayObservationTab.test.ts new file mode 100644 index 00000000..569be241 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/runwayObservationTab.test.ts @@ -0,0 +1,53 @@ +import fs from "node:fs"; +import path from "node:path"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +export function runTests() { + const projectRoot = process.cwd(); + const shellPartsPath = path.join( + projectRoot, + "components", + "dashboard", + "scan-terminal", + "ScanTerminalShellParts.tsx", + ); + const dashboardPath = path.join( + projectRoot, + "components", + "dashboard", + "ScanTerminalDashboard.tsx", + ); + const panelPath = path.join( + projectRoot, + "components", + "dashboard", + "scan-terminal", + "RunwayObservationsPanel.tsx", + ); + + const shellPartsSource = fs.readFileSync(shellPartsPath, "utf8"); + const dashboardSource = fs.readFileSync(dashboardPath, "utf8"); + assert( + shellPartsSource.includes('"runway"'), + "scan terminal content view must include a runway tab state", + ); + assert( + dashboardSource.includes("跑道观测") && dashboardSource.includes('setActiveView("runway")'), + "dashboard tabs must expose 跑道观测 next to 市场监控", + ); + assert( + fs.existsSync(panelPath), + "RunwayObservationsPanel.tsx must render the dedicated runway tab body", + ); + const panelSource = fs.readFileSync(panelPath, "utf8"); + assert( + panelSource.includes("AMSC AWOS") && + panelSource.includes("TDZ") && + panelSource.includes("MID") && + panelSource.includes("END"), + "runway tab must identify AMSC AWOS and show TDZ/MID/END point temperatures", + ); +} diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index ab6fd73b..23f0e706 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -958,6 +958,12 @@ export interface AmosData { runway_obs?: { runway_pairs?: Array<[string, string]> | null; temperatures?: Array<[number | null, number | null]> | null; + point_temperatures?: Array<{ + runway?: string | null; + tdz_temp?: number | null; + mid_temp?: number | null; + end_temp?: number | null; + }> | null; pressures_hpa?: Array | null; wind_directions?: Array<[number, number, number] | null> | null; wind_speeds?: Array<[number, number, number] | null> | null; @@ -967,6 +973,7 @@ export interface AmosData { observation_source?: string | null; observation_source_zh?: string | null; observation_time?: string | null; + observation_time_local?: string | null; } export interface HistoryPoint { diff --git a/frontend/lib/source-freshness.ts b/frontend/lib/source-freshness.ts index 358a5977..e5df93bd 100644 --- a/frontend/lib/source-freshness.ts +++ b/frontend/lib/source-freshness.ts @@ -31,6 +31,15 @@ const DEFAULT_SOURCE_PROFILE: SourceProfile = { }; const SOURCE_PROFILES: Record = { + amsc_awos: { + code: "amsc_awos", + label: "AMSC AWOS", + nativeUpdateIntervalSec: 60, + freshWindowSec: 180, + expectedGraceSec: 180, + staleAfterSec: 900, + pollIntervalSec: 60, + }, amos: { code: "amos", label: "AMOS", @@ -111,6 +120,7 @@ const SOURCE_PROFILES: Record = { function canonicalSourceCode(value?: string | null) { const code = normalizeObservationSourceCode(value || "metar"); if (!code) return "metar"; + if (code.includes("amsc")) return "amsc_awos"; if (code.includes("amos")) return "amos"; if (code.includes("jma")) return "jma"; if (code.includes("fmi")) return "fmi"; @@ -207,12 +217,16 @@ export function getObservationFreshness(detail?: CityDetail | null) { const hasAmosRunway = (detail.amos?.runway_obs?.temperatures?.length || 0) > 0 || (detail.amos?.runway_temps?.length || 0) > 0; - if (hasAmosRunway || detail.amos?.source === "amos") { + if (hasAmosRunway || detail.amos?.source === "amos" || detail.amos?.source === "amsc_awos") { return buildObservationFreshness({ observedAt: detail.amos?.observation_time || null, - observedAtLocal: detail.airport_current?.obs_time || detail.current?.obs_time || null, - sourceCode: "amos", - sourceLabel: detail.amos?.source_label || "AMOS", + observedAtLocal: + detail.amos?.observation_time_local || + detail.airport_current?.obs_time || + detail.current?.obs_time || + null, + sourceCode: detail.amos?.source || "amos", + sourceLabel: detail.amos?.source_label || (detail.amos?.source === "amsc_awos" ? "AMSC AWOS" : "AMOS"), }); } const currentSource = canonicalSourceCode( diff --git a/src/data_collection/amsc_awos_sources.py b/src/data_collection/amsc_awos_sources.py new file mode 100644 index 00000000..7969f632 --- /dev/null +++ b/src/data_collection/amsc_awos_sources.py @@ -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 diff --git a/src/data_collection/country_networks.py b/src/data_collection/country_networks.py index ef4d39e6..386af9e6 100644 --- a/src/data_collection/country_networks.py +++ b/src/data_collection/country_networks.py @@ -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, diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index 91f77296..a18e8196 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -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: diff --git a/tests/test_amsc_awos_sources.py b/tests/test_amsc_awos_sources.py new file mode 100644 index 00000000..469137d6 --- /dev/null +++ b/tests/test_amsc_awos_sources.py @@ -0,0 +1,92 @@ +from src.data_collection.amsc_awos_sources import ( + AmscAwosSourceMixin, + _amsc_parse_wind_plate_payload, + _amsc_supported_city_codes, +) + + +ZBAA_SAMPLE = { + "code": 200, + "msg": "操作成功", + "data": { + "18R/36L": { + "RNO": "18R/36L", + "OTIME": "2026-05-14 17:19:00", + "TDZ_TEMP": "20.8", + "MID_TEMP": "-", + "END_TEMP": "20.8", + "TDZ_HUMID": "67", + "END_HUMID": "67", + "METAR": "METAR ZBAA 141700Z 12003MPS 050V170 7000 NSC 21/15 Q1014 NOSIG=", + }, + "18L/36R": { + "RNO": "18L/36R", + "OTIME": "2026-05-14 17:19:00", + "TDZ_TEMP": "21.0", + "MID_TEMP": "-", + "END_TEMP": "20.4", + "METAR": "METAR ZBAA 141700Z 12003MPS 050V170 7000 NSC 21/15 Q1014 NOSIG=", + }, + "19/01": { + "RNO": "19/01", + "OTIME": "2026-05-14 17:19:00", + "TDZ_TEMP": "20.2", + "MID_TEMP": "-", + "END_TEMP": "20.2", + "METAR": "METAR ZBAA 141700Z 12003MPS 050V170 7000 NSC 21/15 Q1014 NOSIG=", + }, + }, +} + + +def test_parse_wind_plate_payload_normalizes_runway_point_temperatures(): + parsed = _amsc_parse_wind_plate_payload(ZBAA_SAMPLE, city_key="beijing", icao="ZBAA") + + assert parsed["source"] == "amsc_awos" + assert parsed["source_label"] == "AMSC AWOS Beijing Capital (ZBAA)" + assert parsed["observation_source_zh"] == "AMSC AWOS 跑道观测气温" + assert parsed["icao"] == "ZBAA" + assert parsed["temp_c"] == 21.0 + assert parsed["temp_source"] == "runway_max" + assert parsed["runway_temp_range"] == (20.2, 21.0) + assert parsed["observation_time"] == "2026-05-14T17:19:00+00:00" + assert parsed["observation_time_local"] == "2026-05-15 01:19:00" + assert parsed["raw_metar"].startswith("METAR ZBAA 141700Z") + + runway_obs = parsed["runway_obs"] + assert runway_obs["runway_pairs"] == [("18R", "36L"), ("18L", "36R"), ("19", "01")] + assert runway_obs["temperatures"] == [(20.8, None), (21.0, None), (20.2, None)] + assert runway_obs["point_temperatures"][0] == { + "runway": "18R/36L", + "tdz_temp": 20.8, + "mid_temp": None, + "end_temp": 20.8, + } + + +def test_parse_wind_plate_payload_rejects_unauthorized_or_empty_payloads(): + assert _amsc_parse_wind_plate_payload( + {"errCode": -12010, "errMsg": "无权访问此接口"}, + city_key="beijing", + icao="ZBAA", + ) is None + assert _amsc_parse_wind_plate_payload({"code": 200, "data": {}}, city_key="beijing", icao="ZBAA") is None + + +def test_fetch_amsc_official_current_uses_domestic_city_whitelist(): + assert _amsc_supported_city_codes()["beijing"] == "ZBAA" + assert "new york" not in _amsc_supported_city_codes() + + class FakeCollector(AmscAwosSourceMixin): + timeout = 1.0 + + def _http_get_json(self, url, *, headers=None): + assert "getWindPlate?cccc=ZBAA" in url + return ZBAA_SAMPLE + + data = FakeCollector().fetch_amsc_awos_current("beijing") + + assert data is not None + assert data["icao"] == "ZBAA" + assert data["runway_temp_range"] == (20.2, 21.0) + assert FakeCollector().fetch_amsc_awos_current("new york") is None diff --git a/web/analysis_service.py b/web/analysis_service.py index a65a51bf..efd89298 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -159,6 +159,13 @@ _OBSERVATION_SOURCE_PROFILES: Dict[str, Dict[str, Any]] = { "expected_grace_sec": 180, "stale_after_sec": 900, }, + "amsc_awos": { + "label": "AMSC AWOS", + "native_update_interval_sec": 60, + "fresh_window_sec": 180, + "expected_grace_sec": 180, + "stale_after_sec": 900, + }, "jma": { "label": "JMA", "native_update_interval_sec": 600, @@ -1869,8 +1876,10 @@ def _analyze( now_utc=now_utc, ) - airport_source_code = "amos" if current_source == "amos" else "metar" - airport_source_label = "AMOS" if current_source == "amos" else "METAR" + airport_source_code = amos_data.get("source") if current_source == "amos" else "metar" + airport_source_code = airport_source_code or ("amos" if current_source == "amos" else "metar") + airport_source_label = amos_data.get("source_label") if current_source == "amos" else "METAR" + airport_source_label = airport_source_label or ("AMOS" if current_source == "amos" else "METAR") airport_obs_raw = amos_data.get("observation_time") if current_source == "amos" else (metar.get("observation_time") if metar else None) airport_age_min = _observation_age_min(airport_obs_raw, now_utc) if airport_obs_raw else metar_age_min if airport_age_min is None: