feat: add AMSC runway observations

This commit is contained in:
2569718930@qq.com
2026-05-15 01:41:49 +08:00
parent c4b1844a67
commit 4cc579ccb3
15 changed files with 823 additions and 14 deletions
@@ -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=<ICAO>`, 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.
@@ -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" : "市场监控"}
</button>
<button
type="button"
role="tab"
aria-selected={resolvedView === "runway"}
className={resolvedView === "runway" ? "active" : ""}
onClick={() => setActiveView("runway")}
>
🛬 {isEn ? "Runways" : "跑道观测"}
</button>
</div>
<div className="scan-list-status">
{terminalData?.generated_at ? (
@@ -539,6 +556,13 @@ function ScanTerminalScreen() {
<ProFeaturePaywall feature="monitor" />
)
)}
{resolvedView === "runway" && (
isPro ? (
<RunwayObservationsPanel />
) : (
<ProFeaturePaywall feature="monitor" />
)
)}
</section>
</main>
@@ -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<MonitorKey>(["hong kong", "lau fau shan"]);
const SOURCE_LABELS: Record<string, { en: string; zh: string }> = {
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);
@@ -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 {
@@ -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 (
<article className="monitor-card">
<div className="monitor-card-head">
<span className="monitor-city-name">{isEn ? label.en : label.zh}</span>
<span className="monitor-airport-name">/ {label.icao}</span>
<span className="monitor-obs-time">{obsLocal || "--"}</span>
</div>
<div className="monitor-stats">
<div className="monitor-high-row">
<span className="monitor-stat-label">AMSC AWOS</span>
<span className="monitor-high-value">
{range ? `${range[0].toFixed(1)}${range[1].toFixed(1)}${tempSymbol}` : "--"}
</span>
</div>
<div className="monitor-obs-row">
<span className="monitor-stat-label">
{isEn ? "Runway-point air temperature" : "跑道观测点气温"}
</span>
<span className="monitor-obs-age fresh">
{isEn ? "not pavement temp" : "非道面温度"}
</span>
</div>
</div>
{hasAmsc ? (
<>
<div className="monitor-divider" />
<div className="monitor-rw-row">
<span className="monitor-rw-label">{isEn ? "Runway" : "跑道"}</span>
<span className="monitor-rw-temp">TDZ / MID / END</span>
</div>
{rows.map((row) => (
<div key={row.runway || `${row.tdz_temp}-${row.end_temp}`} className="monitor-rw-row">
<span className="monitor-rw-label">{row.runway || "--"}</span>
<span className="monitor-rw-temp">
{formatTemp(row.tdz_temp, tempSymbol)} / {formatTemp(row.mid_temp, tempSymbol)} / {formatTemp(row.end_temp, tempSymbol)}
</span>
</div>
))}
{detail?.amos?.raw_metar ? (
<div className="scan-amos-runway-detail">
METAR {detail.amos.raw_metar.replace(/^METAR\s+/i, "")}
</div>
) : null}
</>
) : (
<div className="scan-empty-state compact">
{isEn ? "No AMSC runway observation loaded yet." : "暂无 AMSC 跑道观测。"}
</div>
)}
</article>
);
}
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 (
<div className="monitor-panel runway-observations-panel">
<div className="monitor-toolbar">
<div>
<div className="monitor-title">
{isEn ? "Runway Observations" : "跑道观测"}
</div>
<div className="monitor-subtitle">
{isEn
? "AMSC AWOS · China mainland airports · TDZ/MID/END air temperature"
: "AMSC AWOS · 国内机场 · TDZ/MID/END 跑道观测点气温"}
</div>
</div>
<button
type="button"
className="monitor-refresh-button"
disabled={refreshing}
onClick={() => void loadAll(true)}
>
<RefreshCw size={14} className={refreshing ? "spin" : undefined} />
{isEn ? "Refresh" : "刷新"}
</button>
</div>
<div className="monitor-grid">
{cards.map((city) => (
<RunwayCityCard
key={city.key}
detail={city.detail}
isEn={isEn}
label={city}
/>
))}
</div>
</div>
);
}
@@ -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";
@@ -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"],
@@ -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",
);
}
+7
View File
@@ -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<number | null> | 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 {
+18 -4
View File
@@ -31,6 +31,15 @@ const DEFAULT_SOURCE_PROFILE: SourceProfile = {
};
const SOURCE_PROFILES: Record<string, SourceProfile> = {
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<string, SourceProfile> = {
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(
+244
View File
@@ -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
+4 -3
View File
@@ -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,
+51 -1
View File
@@ -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:
+92
View File
@@ -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
+11 -2
View File
@@ -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: