feat: add AMSC runway observations
This commit is contained in:
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user