diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 25c236ca..ea35b306 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -1014,34 +1014,56 @@ export function FutureForecastModal() { ? "Settlement station" : "结算站点" : t("section.airport"); + const settlementStationLabel = + detail.settlement_station?.settlement_station_label || + detail.current?.station_name || + risk.airport || + noaaStationName; + const settlementStationCode = + detail.settlement_station?.settlement_station_code || + detail.current?.station_code || + risk.icao || + noaaStationCode; const settlementProfileValue = - settlementSourceCode === "noaa" - ? locale === "en-US" - ? `${noaaStationName}${noaaStationCode ? ` (${noaaStationCode})` : ""}` - : `${noaaStationName}${noaaStationCode ? `(${noaaStationCode})` : ""}` - : detail.current?.station_name - ? `${detail.current.station_name}${ - detail.current?.station_code ? ` (${detail.current.station_code})` : "" - }` - : risk.airport - ? `${risk.airport}${risk.icao ? ` (${risk.icao})` : ""}` - : "--"; + settlementStationLabel + ? `${settlementStationLabel}${settlementStationCode ? ` (${settlementStationCode})` : ""}` + : "--"; + const airportPrimary = detail.airport_primary || detail.airport_current; const airportCurrentText = - detail.airport_current?.temp != null - ? `${detail.airport_current.temp}${detail.temp_symbol}${ - detail.airport_current?.obs_time - ? ` @${detail.airport_current.obs_time}` + airportPrimary?.temp != null + ? `${airportPrimary.temp}${detail.temp_symbol}${ + airportPrimary?.obs_time + ? ` @${airportPrimary.obs_time}` : "" }` : "--"; const airportMaxText = - detail.airport_current?.max_so_far != null - ? `${detail.airport_current.max_so_far}${detail.temp_symbol}${ - detail.airport_current?.max_temp_time - ? ` @${detail.airport_current.max_temp_time}` + airportPrimary?.max_so_far != null + ? `${airportPrimary.max_so_far}${detail.temp_symbol}${ + airportPrimary?.max_temp_time + ? ` @${airportPrimary.max_temp_time}` : "" }` : "--"; + const officialNearbyCount = Array.isArray(detail.official_nearby) + ? detail.official_nearby.length + : Array.isArray(detail.mgm_nearby) + ? detail.mgm_nearby.length + : 0; + const officialNetworkSourceText = + detail.official_network_status?.provider_label || + detail.official_network_source || + "--"; + const officialNetworkStatusText = + detail.official_network_status?.mode || + "--"; + const airportVsNetworkDeltaText = + detail.airport_vs_network_delta != null && + Number.isFinite(Number(detail.airport_vs_network_delta)) + ? `${Number(detail.airport_vs_network_delta) > 0 ? "+" : ""}${Number( + detail.airport_vs_network_delta, + ).toFixed(1)}${detail.temp_symbol}` + : "--"; const displayedUpperAirSummary = marketAwareUpperAirCue?.summary || view.front.upperAirSummary; const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map( @@ -1390,6 +1412,40 @@ export function FutureForecastModal() { {settlementProfileValue} +
+ + {locale === "en-US" ? "Airport primary" : "机场主站"} + + {airportCurrentText} +
+
+ + {locale === "en-US" ? "Airport max today" : "机场今日最高"} + + {airportMaxText} +
+
+ + {locale === "en-US" ? "Official nearby" : "官方周边站"} + + + {officialNearbyCount} · {officialNetworkSourceText} + +
+
+ + {locale === "en-US" ? "Network mode" : "站网模式"} + + {officialNetworkStatusText} +
+
+ + {locale === "en-US" + ? "Airport vs network" + : "机场相对站网偏差"} + + {airportVsNetworkDeltaText} +
{t("section.distance")} diff --git a/frontend/components/ops/OpsDashboard.tsx b/frontend/components/ops/OpsDashboard.tsx index 4d3f678f..8b35fb20 100644 --- a/frontend/components/ops/OpsDashboard.tsx +++ b/frontend/components/ops/OpsDashboard.tsx @@ -122,6 +122,17 @@ type SystemStatusPayload = { lgbm_validation_deb_mae?: number | null; }; }; + station_networks?: { + airport_anchor_coverage?: number; + official_station_anchor_coverage?: number; + providers?: Record< + string, + { + cities?: string[]; + cities_count?: number; + } + >; + }; }; type PaymentRuntimePayload = { @@ -421,7 +432,19 @@ export function OpsDashboard() { const cityCoverage = trainingData?.city_coverage; const modelCityCoverage = trainingData?.model_city_coverage; const trainingArtifacts = trainingData?.artifacts; + const stationNetworks = status?.station_networks; const truthSources = Object.entries(truthRecords?.source_counts || {}); + const providerRows = useMemo( + () => + Object.entries(stationNetworks?.providers || {}) + .map(([code, value]) => ({ + code, + cities: value?.cities || [], + citiesCount: value?.cities_count ?? 0, + })) + .sort((a, b) => b.citiesCount - a.citiesCount || a.code.localeCompare(b.code)), + [stationNetworks?.providers], + ); const cityCoverageRows = useMemo(() => { const modelIndex = new Map( [...(modelCityCoverage?.strongest || []), ...(modelCityCoverage?.weakest || [])].map((entry) => [entry.city, entry]), @@ -561,6 +584,7 @@ export function OpsDashboard() { const opsNav = [ { id: "overview", label: "总览" }, + { id: "station-networks", label: "站网覆盖" }, { id: "funnel", label: "转化漏斗" }, { id: "probability", label: "EMOS 门禁" }, { id: "training-data", label: "训练数据" }, @@ -660,6 +684,68 @@ export function OpsDashboard() {
+
+ + + 站网协议覆盖 + + 统一查看机场锚点覆盖、官方站锚点覆盖,以及新的国家 provider 分布。 + + + +
+ 机场锚点覆盖 + {stationNetworks?.airport_anchor_coverage ?? 0} +
+
+ 官方站锚点覆盖 + {stationNetworks?.official_station_anchor_coverage ?? 0} +
+
+ 国家 provider 数 + {providerRows.length} +
+
+ 规则固定为“机场主站 + 官方增强 + 模型层”。官方站网只做增强,除明确官方结算站外不会替代机场锚点。 +
+
+
+ + + + 国家 Provider 分布 + + 这些 provider 已进入统一协议层,业务侧不再直接按国家写分支。 + + + + {providerRows.length === 0 ? ( +
+ 暂无站网 provider 数据。 +
+ ) : ( + providerRows.map((provider) => ( +
+
+
{provider.code}
+ {provider.citiesCount} 城 +
+
+ {provider.cities.length > 0 ? provider.cities.join(", ") : "无城市"} +
+
+ )) + )} +
+
+
+ {error ? ( diff --git a/frontend/lib/dashboard-official-sources.ts b/frontend/lib/dashboard-official-sources.ts index 2996850c..dc666339 100644 --- a/frontend/lib/dashboard-official-sources.ts +++ b/frontend/lib/dashboard-official-sources.ts @@ -312,10 +312,20 @@ const CITY_SPECIFIC_SOURCES: Record = { ], shanghai: [ { - label: "中国天气网", - href: "https://www.weather.com.cn/", + label: "NMC 全国主要机场天气预报", + href: "https://www.nmc.cn/publish/weather_forecast/swssr.htm", kind: "agency", }, + { + label: "NMC 浦东天气", + href: "https://m.nmc.cn/publish/forecast/ASH/pudong.html", + kind: "agency", + }, + { + label: "上海浦东国际机场", + href: "https://www.shanghai-airport.com/", + kind: "airport", + }, { label: "ZSPD METAR", href: "https://aviationweather.gov/data/metar/?id=ZSPD&decoded=1&taf=1", @@ -504,10 +514,20 @@ const CITY_SPECIFIC_SOURCES: Record = { ], chengdu: [ { - label: "中国天气网", - href: "https://www.weather.com.cn/", + label: "NMC 全国主要机场天气预报", + href: "https://www.nmc.cn/publish/weather_forecast/swssr.htm", kind: "agency", }, + { + label: "NMC 双流天气", + href: "https://m.nmc.cn/publish/forecast/ASC/shuangliu.html", + kind: "agency", + }, + { + label: "成都双流国际机场", + href: "https://www.cdairport.com/", + kind: "airport", + }, { label: "ZUUU METAR", href: "https://aviationweather.gov/data/metar/?id=ZUUU&decoded=1&taf=1", @@ -516,10 +536,20 @@ const CITY_SPECIFIC_SOURCES: Record = { ], chongqing: [ { - label: "中国天气网", - href: "https://www.weather.com.cn/", + label: "NMC 全国主要机场天气预报", + href: "https://www.nmc.cn/publish/weather_forecast/swssr.htm", kind: "agency", }, + { + label: "NMC 渝北天气", + href: "https://m.nmc.cn/publish/forecast/ACQ/yubei.html", + kind: "agency", + }, + { + label: "重庆江北国际机场", + href: "https://www.cqa.cn/", + kind: "airport", + }, { label: "ZUCK METAR", href: "https://aviationweather.gov/data/metar/?id=ZUCK&decoded=1&taf=1", @@ -528,8 +558,13 @@ const CITY_SPECIFIC_SOURCES: Record = { ], shenzhen: [ { - label: "Wunderground ZGSZ", - href: "https://www.wunderground.com/history/daily/cn/shenzhen/ZGSZ", + label: "NMC 全国主要机场天气预报", + href: "https://www.nmc.cn/publish/weather_forecast/swssr.htm", + kind: "agency", + }, + { + label: "NMC 深圳天气", + href: "https://m.nmc.cn/publish/forecast/AGD/shenzhen.html", kind: "agency", }, { @@ -630,10 +665,20 @@ const CITY_SPECIFIC_SOURCES: Record = { ], beijing: [ { - label: "中国天气网", - href: "https://www.weather.com.cn/", + label: "NMC 全国主要机场天气预报", + href: "https://www.nmc.cn/publish/weather_forecast/swssr.htm", kind: "agency", }, + { + label: "NMC 顺义天气", + href: "https://m.nmc.cn/publish/forecast/ABJ/shunyi.html", + kind: "agency", + }, + { + label: "北京首都国际机场", + href: "https://www.bcia.com.cn/", + kind: "airport", + }, { label: "ZBAA METAR", href: "https://aviationweather.gov/data/metar/?id=ZBAA&decoded=1&taf=1", @@ -642,10 +687,20 @@ const CITY_SPECIFIC_SOURCES: Record = { ], wuhan: [ { - label: "中国天气网", - href: "https://www.weather.com.cn/", + label: "NMC 全国主要机场天气预报", + href: "https://www.nmc.cn/publish/weather_forecast/swssr.htm", kind: "agency", }, + { + label: "NMC 武汉天气", + href: "https://m.nmc.cn/publish/forecast/AHB/wuhan.html", + kind: "agency", + }, + { + label: "武汉天河国际机场", + href: "https://www.whairport.com/", + kind: "airport", + }, { label: "ZHHH METAR", href: "https://aviationweather.gov/data/metar/?id=ZHHH&decoded=1&taf=1", diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index de9b8511..e0b9dee2 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -18,6 +18,10 @@ export interface CityListItem { is_major?: boolean; settlement_source?: string; settlement_source_label?: string; + settlement_station_code?: string; + settlement_station_label?: string; + network_provider?: string; + network_provider_label?: string; } export interface ProbabilityBucket { @@ -90,17 +94,29 @@ export interface AirportCurrentConditions { wx_desc?: string | null; raw_metar?: string | null; source_label?: string | null; + station_code?: string | null; + station_label?: string | null; + is_airport_station?: boolean; + is_official?: boolean; + is_settlement_anchor?: boolean; } export interface NearbyStation { name?: string; icao?: string; + station_code?: string | null; + station_label?: string | null; lat: number; lon: number; temp: number | null; wind_dir?: number | null; wind_speed?: number | null; wind_speed_kt?: number | null; + source_code?: string | null; + source_label?: string | null; + is_official?: boolean; + is_airport_station?: boolean; + is_settlement_anchor?: boolean; } export interface HourlyTrendPoint { @@ -316,10 +332,49 @@ export interface CityDetail { local_date: string; risk: DashboardRisk; current: CurrentConditions; + settlement_station?: { + provider_code?: string | null; + settlement_source?: string | null; + settlement_station_code?: string | null; + settlement_station_label?: string | null; + airport_code?: string | null; + airport_name?: string | null; + is_airport_anchor?: boolean; + is_official_station_anchor?: boolean; + }; airport_current?: AirportCurrentConditions; + airport_primary?: AirportCurrentConditions; + airport_primary_today_obs?: Array<{ + time?: string; + temp?: number | null; + }>; mgm?: MgmData; mgm_nearby?: NearbyStation[]; + official_nearby?: NearbyStation[]; nearby_source?: string; + official_network_source?: string; + official_network_status?: { + provider_code?: string | null; + provider_label?: string | null; + available?: boolean; + mode?: string | null; + row_count?: number | null; + }; + network_lead_signal?: { + available?: boolean; + delta?: number | null; + leader_station_code?: string | null; + leader_station_label?: string | null; + leader_temp?: number | null; + }; + network_spread_signal?: { + available?: boolean; + spread?: number | null; + hottest_station_code?: string | null; + coolest_station_code?: string | null; + }; + center_station_candidate?: NearbyStation | null; + airport_vs_network_delta?: number | null; forecast?: ForecastData; multi_model?: Record; deb?: DebForecast; @@ -419,6 +474,12 @@ export interface HistoryPoint { mu?: number | null; mgm?: number | null; forecasts?: Record; + settlement_source?: string | null; + settlement_station_code?: string | null; + settlement_station_label?: string | null; + truth_version?: string | null; + updated_by?: string | null; + truth_updated_at?: number | null; actual_peak_time?: string | null; deb_at_peak_minus_12h?: number | null; deb_at_peak_minus_12h_time?: string | null; diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index c4aae4aa..70087e8d 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -828,7 +828,11 @@ function distanceKm( } export function pickMapNearbyStations(detail: CityDetail) { - const stations = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : []; + const stations = Array.isArray(detail.official_nearby) + ? detail.official_nearby + : Array.isArray(detail.mgm_nearby) + ? detail.mgm_nearby + : []; const city = String(detail.name || detail.display_name || "") .trim() .toLowerCase(); diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 95d02b55..bc682641 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -328,6 +328,16 @@ export interface CityDetail { local_date: string; temp_symbol: string; current_temp: number | null; + settlement_station?: { + provider_code?: string | null; + settlement_source?: string | null; + settlement_station_code?: string | null; + settlement_station_label?: string | null; + airport_code?: string | null; + airport_name?: string | null; + is_airport_anchor?: boolean; + is_official_station_anchor?: boolean; + }; deb_prediction: number | null; risk_level: string; risk_warning: string; @@ -340,6 +350,15 @@ export interface CityDetail { mgm: any; mgm_nearby: any[]; nearby_source?: string; + airport_primary?: any; + airport_primary_today_obs?: any[]; + official_nearby?: any[]; + official_network_source?: string; + official_network_status?: any; + network_lead_signal?: any; + network_spread_signal?: any; + center_station_candidate?: any; + airport_vs_network_delta?: number | null; }; timeseries: { metar_recent_obs: any[]; @@ -356,6 +375,15 @@ export interface CityDetail { }; market_scan: MarketScan; risk: any; + settlement_station?: any; + airport_primary?: any; + official_nearby?: any[]; + official_network_source?: string; + official_network_status?: any; + network_lead_signal?: any; + network_spread_signal?: any; + center_station_candidate?: any; + airport_vs_network_delta?: number | null; ai_analysis: string; errors: Record; } diff --git a/src/data_collection/country_networks.py b/src/data_collection/country_networks.py new file mode 100644 index 00000000..c186625e --- /dev/null +++ b/src/data_collection/country_networks.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from src.data_collection.city_registry import CITY_REGISTRY + + +CHINA_CMA_CITIES = { + "beijing", + "chengdu", + "chongqing", + "shanghai", + "shenzhen", + "wuhan", +} + + +def _safe_float(value: Any) -> Optional[float]: + try: + if value is None or value == "": + return None + return float(value) + except Exception: + return None + + +def _city_meta(city: str) -> Dict[str, Any]: + return CITY_REGISTRY.get(str(city or "").strip().lower(), {}) or {} + + +def _provider_code_for_city(city: str) -> str: + normalized = str(city or "").strip().lower() + meta = _city_meta(normalized) + settlement_source = str(meta.get("settlement_source") or "").strip().lower() + if normalized in {"ankara", "istanbul"}: + return "turkey_mgm" + if settlement_source == "hko": + return "hongkong_hko" + if settlement_source == "cwa": + return "taiwan_cwa" + if normalized in CHINA_CMA_CITIES: + return "china_cma" + return "global_metar" + + +def _bool(value: Any) -> bool: + return bool(value) + + +def _normalize_station_row( + *, + station_code: Optional[str], + station_label: Optional[str], + temp: Any, + lat: Any = None, + lon: Any = None, + obs_time: Optional[str] = None, + source_code: str, + source_label: str, + is_official: bool, + is_airport_station: bool, + is_settlement_anchor: bool, + extra: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + payload = { + "station_code": str(station_code or "").strip() or None, + "station_label": str(station_label or "").strip() or None, + "is_airport_station": bool(is_airport_station), + "lat": _safe_float(lat), + "lon": _safe_float(lon), + "obs_time": str(obs_time or "").strip() or None, + "temp": _safe_float(temp), + "source_code": str(source_code or "").strip().lower() or None, + "source_label": str(source_label or "").strip() or None, + "is_official": bool(is_official), + "is_settlement_anchor": bool(is_settlement_anchor), + } + if isinstance(extra, dict): + for key, value in extra.items(): + if key not in payload: + payload[key] = value + return payload + + +def _airport_primary_from_raw(city: str, raw: Dict[str, Any]) -> Dict[str, Any]: + meta = _city_meta(city) + metar = raw.get("metar") or {} + current = metar.get("current") or {} + return _normalize_station_row( + station_code=meta.get("icao") or metar.get("icao"), + station_label=meta.get("airport_name") or metar.get("station_name") or metar.get("icao"), + temp=current.get("temp"), + obs_time=metar.get("observation_time"), + source_code="metar", + source_label="METAR", + is_official=True, + is_airport_station=True, + is_settlement_anchor=False, + extra={ + "max_so_far": _safe_float(current.get("max_temp_so_far")), + "max_temp_time": current.get("max_temp_time"), + "obs_age_min": None, + "report_time": metar.get("report_time"), + "receipt_time": metar.get("receipt_time"), + "obs_time_epoch": metar.get("obs_time_epoch"), + "wind_speed_kt": _safe_float(current.get("wind_speed_kt")), + "wind_dir": _safe_float(current.get("wind_dir")), + "humidity": _safe_float(current.get("humidity")), + "visibility_mi": _safe_float(current.get("visibility_mi")), + "wx_desc": current.get("wx_desc"), + "raw_metar": current.get("raw_metar"), + }, + ) + + +def _metar_cluster_rows(raw: Dict[str, Any]) -> List[Dict[str, Any]]: + rows = raw.get("mgm_nearby") or [] + out: List[Dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + out.append( + _normalize_station_row( + station_code=row.get("icao") or row.get("istNo"), + station_label=row.get("name"), + temp=row.get("temp"), + lat=row.get("lat"), + lon=row.get("lon"), + source_code="metar_cluster", + source_label="METAR cluster", + is_official=False, + is_airport_station=True, + is_settlement_anchor=False, + extra={ + "wind_dir": _safe_float(row.get("wind_dir")), + "wind_speed_kt": _safe_float(row.get("wind_speed_kt") or row.get("wind_speed")), + "raw_metar": row.get("raw_metar"), + }, + ) + ) + return out + + +def _nmc_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]: + rows = raw.get("nmc_official_nearby") or [] + out: List[Dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + out.append( + _normalize_station_row( + station_code=row.get("icao") or row.get("istNo"), + station_label=row.get("name"), + temp=row.get("temp"), + lat=row.get("lat"), + lon=row.get("lon"), + obs_time=row.get("obs_time"), + source_code="nmc", + source_label="NMC", + is_official=True, + is_airport_station=False, + is_settlement_anchor=False, + extra={ + "page_url": row.get("page_url"), + "humidity": _safe_float(row.get("humidity")), + "rain": _safe_float(row.get("rain")), + "airpressure": _safe_float(row.get("airpressure")), + "wx_desc": row.get("wx_desc"), + "wind_direction_text": row.get("wind_direction_text"), + "wind_power_text": row.get("wind_power_text"), + }, + ) + ) + return out + + +def _mgm_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]: + meta = _city_meta(city) + rows = raw.get("mgm_nearby") or [] + out: List[Dict[str, Any]] = [] + airport_code = str(meta.get("icao") or "").strip().upper() + for row in rows: + if not isinstance(row, dict): + continue + station_code = str(row.get("icao") or row.get("istNo") or "").strip() or None + station_label = row.get("name") + out.append( + _normalize_station_row( + station_code=station_code, + station_label=station_label, + temp=row.get("temp"), + lat=row.get("lat"), + lon=row.get("lon"), + source_code="mgm", + source_label="MGM", + is_official=True, + is_airport_station=_bool(station_code and station_code.upper() == airport_code) + or ("airport" in str(station_label or "").lower()), + is_settlement_anchor=False, + extra={ + "wind_dir": _safe_float(row.get("wind_dir")), + "wind_speed_kt": _safe_float(row.get("wind_speed_kt") or row.get("wind_speed")), + }, + ) + ) + return out + + +def _settlement_anchor_row(city: str, raw: Dict[str, Any]) -> Optional[Dict[str, Any]]: + meta = _city_meta(city) + settlement_current = raw.get("settlement_current") or {} + current = settlement_current.get("current") or {} + if not current and not settlement_current: + return None + station_code = ( + settlement_current.get("station_code") + or meta.get("settlement_station_code") + or meta.get("icao") + ) + station_label = ( + settlement_current.get("station_name") + or meta.get("settlement_station_label") + or meta.get("airport_name") + ) + settlement_source = str(meta.get("settlement_source") or "official").strip().lower() or "official" + return _normalize_station_row( + station_code=station_code, + station_label=station_label, + temp=current.get("temp"), + obs_time=settlement_current.get("observation_time"), + source_code=settlement_source, + source_label=settlement_source.upper(), + is_official=True, + is_airport_station=False, + is_settlement_anchor=True, + extra={ + "max_so_far": _safe_float(current.get("max_temp_so_far")), + "max_temp_time": current.get("max_temp_time"), + "humidity": _safe_float(current.get("humidity")), + "wind_speed_kt": _safe_float(current.get("wind_speed_kt")), + "wind_dir": _safe_float(current.get("wind_dir")), + }, + ) + + +def _settlement_station_metadata(city: str) -> Dict[str, Any]: + meta = _city_meta(city) + settlement_source = str(meta.get("settlement_source") or "metar").strip().lower() or "metar" + station_code = ( + str(meta.get("settlement_station_code") or "").strip() + or str(meta.get("icao") or "").strip() + or None + ) + station_label = ( + str(meta.get("settlement_station_label") or "").strip() + or str(meta.get("airport_name") or "").strip() + or None + ) + airport_code = str(meta.get("icao") or "").strip() + is_explicit_official_anchor = settlement_source in {"hko", "cwa"} + return { + "provider_code": _provider_code_for_city(city), + "settlement_source": settlement_source, + "settlement_station_code": station_code, + "settlement_station_label": station_label, + "airport_code": airport_code or None, + "airport_name": str(meta.get("airport_name") or "").strip() or None, + "is_airport_anchor": not is_explicit_official_anchor, + "is_official_station_anchor": is_explicit_official_anchor, + } + + +def _network_signals( + airport_primary: Optional[Dict[str, Any]], + official_rows: List[Dict[str, Any]], +) -> Dict[str, Any]: + airport_temp = _safe_float((airport_primary or {}).get("temp")) + valid_rows = [row for row in official_rows if _safe_float(row.get("temp")) is not None] + if not valid_rows: + return { + "network_lead_signal": {"available": False}, + "network_spread_signal": {"available": False}, + "center_station_candidate": None, + "airport_vs_network_delta": None, + } + + hottest = max(valid_rows, key=lambda row: float(row.get("temp") or -999)) + coolest = min(valid_rows, key=lambda row: float(row.get("temp") or 999)) + hottest_temp = _safe_float(hottest.get("temp")) + coolest_temp = _safe_float(coolest.get("temp")) + spread = None + airport_delta = None + if hottest_temp is not None and coolest_temp is not None: + spread = round(hottest_temp - coolest_temp, 1) + if airport_temp is not None and hottest_temp is not None: + airport_delta = round(hottest_temp - airport_temp, 1) + return { + "network_lead_signal": { + "available": airport_delta is not None, + "delta": airport_delta, + "leader_station_code": hottest.get("station_code"), + "leader_station_label": hottest.get("station_label"), + "leader_temp": hottest_temp, + }, + "network_spread_signal": { + "available": spread is not None, + "spread": spread, + "hottest_station_code": hottest.get("station_code"), + "coolest_station_code": coolest.get("station_code"), + }, + "center_station_candidate": hottest, + "airport_vs_network_delta": airport_delta, + } + + +@dataclass +class CountryNetworkProvider: + provider_code: str + provider_label: str + + def airport_primary_current(self, city: str, raw: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return _airport_primary_from_raw(city, raw) + + def airport_primary_history(self, city: str, target_date: str) -> Optional[Dict[str, Any]]: + return None + + def official_nearby_current(self, city: str, raw: Dict[str, Any]) -> List[Dict[str, Any]]: + return [] + + def official_nearby_history(self, city: str, target_date: str) -> List[Dict[str, Any]]: + return [] + + def settlement_station_metadata(self, city: str) -> Dict[str, Any]: + return _settlement_station_metadata(city) + + def official_network_status(self, city: str, raw: Dict[str, Any]) -> Dict[str, Any]: + rows = self.official_nearby_current(city, raw) + return { + "provider_code": self.provider_code, + "provider_label": self.provider_label, + "available": bool(rows), + "mode": "active" if rows else "unavailable", + "row_count": len(rows), + } + + +class GlobalMetarNetworkProvider(CountryNetworkProvider): + def __init__(self) -> None: + super().__init__("global_metar", "METAR") + + def official_nearby_current(self, city: str, raw: Dict[str, Any]) -> List[Dict[str, Any]]: + return _metar_cluster_rows(raw) + + def official_network_status(self, city: str, raw: Dict[str, Any]) -> Dict[str, Any]: + rows = self.official_nearby_current(city, raw) + return { + "provider_code": self.provider_code, + "provider_label": self.provider_label, + "available": bool(rows), + "mode": "fallback_metar_cluster" if rows else "no_official_network", + "row_count": len(rows), + } + + +class TurkeyMgmNetworkProvider(CountryNetworkProvider): + def __init__(self) -> None: + super().__init__("turkey_mgm", "MGM") + + def official_nearby_current(self, city: str, raw: Dict[str, Any]) -> List[Dict[str, Any]]: + return _mgm_rows(raw, city) + + +class ChinaCmaNetworkProvider(CountryNetworkProvider): + def __init__(self) -> None: + super().__init__("china_cma", "CMA/NMC") + + def official_nearby_current(self, city: str, raw: Dict[str, Any]) -> List[Dict[str, Any]]: + rows = _nmc_rows(raw, city) + if rows: + return rows + return _metar_cluster_rows(raw) + + def official_network_status(self, city: str, raw: Dict[str, Any]) -> Dict[str, Any]: + rows = self.official_nearby_current(city, raw) + has_nmc = bool(_nmc_rows(raw, city)) + return { + "provider_code": self.provider_code, + "provider_label": self.provider_label, + "available": has_nmc, + "mode": "official_active" if has_nmc else ("fallback_metar_cluster" if rows else "reference_only"), + "row_count": len(rows), + } + + +class HongKongHkoNetworkProvider(CountryNetworkProvider): + def __init__(self) -> None: + super().__init__("hongkong_hko", "HKO") + + def official_nearby_current(self, city: str, raw: Dict[str, Any]) -> List[Dict[str, Any]]: + anchor = _settlement_anchor_row(city, raw) + return [anchor] if anchor else [] + + +class TaiwanCwaNetworkProvider(CountryNetworkProvider): + def __init__(self) -> None: + super().__init__("taiwan_cwa", "CWA") + + def official_nearby_current(self, city: str, raw: Dict[str, Any]) -> List[Dict[str, Any]]: + anchor = _settlement_anchor_row(city, raw) + return [anchor] if anchor else [] + + +def get_country_network_provider(city: str) -> CountryNetworkProvider: + provider_code = _provider_code_for_city(city) + if provider_code == "turkey_mgm": + return TurkeyMgmNetworkProvider() + if provider_code == "china_cma": + return ChinaCmaNetworkProvider() + if provider_code == "hongkong_hko": + return HongKongHkoNetworkProvider() + if provider_code == "taiwan_cwa": + return TaiwanCwaNetworkProvider() + return GlobalMetarNetworkProvider() + + +def build_country_network_snapshot(city: str, raw: Dict[str, Any]) -> Dict[str, Any]: + provider = get_country_network_provider(city) + metadata = provider.settlement_station_metadata(city) + airport_primary = provider.airport_primary_current(city, raw) or {} + official_nearby = provider.official_nearby_current(city, raw) + status = provider.official_network_status(city, raw) + signals = _network_signals(airport_primary, official_nearby) + return { + "provider_code": provider.provider_code, + "provider_label": provider.provider_label, + "settlement_station": metadata, + "airport_primary_current": airport_primary, + "airport_primary_today_obs": ((raw.get("metar") or {}).get("today_obs") or []), + "official_nearby": official_nearby, + "official_network_source": status.get("provider_code"), + "official_network_status": status, + **signals, + } + + +def provider_coverage_summary() -> Dict[str, Any]: + providers: Dict[str, Dict[str, Any]] = {} + for city in CITY_REGISTRY: + provider_code = _provider_code_for_city(city) + entry = providers.setdefault( + provider_code, + { + "cities": [], + "cities_count": 0, + }, + ) + entry["cities"].append(city) + entry["cities_count"] += 1 + return { + "providers": providers, + "airport_anchor_coverage": sum( + 1 + for city, meta in CITY_REGISTRY.items() + if str(meta.get("icao") or "").strip() + ), + "official_station_anchor_coverage": sum( + 1 + for city in CITY_REGISTRY + if _settlement_station_metadata(city).get("is_official_station_anchor") + ), + } diff --git a/src/data_collection/nmc_sources.py b/src/data_collection/nmc_sources.py new file mode 100644 index 00000000..e18a8ab9 --- /dev/null +++ b/src/data_collection/nmc_sources.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import re +import time +from datetime import datetime +from typing import Any, Dict, List, Optional + +from loguru import logger + +from src.utils.metrics import record_source_call + + +NMC_CITY_REFERENCES: Dict[str, Dict[str, Any]] = { + "shanghai": { + "region_label": "浦东", + "page_url": "https://m.nmc.cn/publish/forecast/ASH/pudong.html", + "station_code": "atcMf", + }, + "beijing": { + "region_label": "顺义", + "page_url": "https://m.nmc.cn/publish/forecast/ABJ/shunyi.html", + "station_code": "MKoqG", + }, + "chongqing": { + "region_label": "渝北", + "page_url": "https://m.nmc.cn/publish/forecast/ACQ/yubei.html", + "station_code": "xFVYU", + }, + "chengdu": { + "region_label": "双流", + "page_url": "https://m.nmc.cn/publish/forecast/ASC/shuangliu.html", + "station_code": "grFhZ", + }, + "wuhan": { + "region_label": "武汉", + "page_url": "https://m.nmc.cn/publish/forecast/AHB/wuhan.html", + "station_code": "bSpCz", + }, + "shenzhen": { + "region_label": "深圳", + "page_url": "https://m.nmc.cn/publish/forecast/AGD/shenzhen.html", + "station_code": "59493", + }, +} + + +class NmcSourceMixin: + @staticmethod + def _nmc_optional_float(value: Any) -> Optional[float]: + if value in (None, "", "9999", 9999, 9999.0): + return None + try: + return float(value) + except Exception: + return None + + def _resolve_nmc_station_code(self, city: str) -> Optional[str]: + city_key = str(city or "").strip().lower() + meta = NMC_CITY_REFERENCES.get(city_key) or {} + station_code = str(meta.get("station_code") or "").strip() + if station_code: + return station_code + + page_url = str(meta.get("page_url") or "").strip() + if not page_url: + return None + + try: + resp = self.session.get(page_url, timeout=self.timeout) + resp.raise_for_status() + match = re.search( + r"renderWeatherRealPanel\('([^']+)',\s*'([^']+)'\)", + resp.text, + ) + if not match: + return None + station_code = str(match.group(1) or "").strip() + if station_code: + meta["station_code"] = station_code + return station_code + except Exception as exc: + logger.warning("NMC station code resolve failed city={} error={}", city_key, exc) + return None + + def fetch_nmc_region_current( + self, + city: str, + use_fahrenheit: bool = False, + ) -> Optional[Dict[str, Any]]: + started = time.perf_counter() + city_key = str(city or "").strip().lower() + meta = NMC_CITY_REFERENCES.get(city_key) or {} + if not meta: + record_source_call("nmc", "current", "unsupported_city", (time.perf_counter() - started) * 1000.0) + return None + + cache_key = f"{city_key}:{use_fahrenheit}" + now_ts = time.time() + with self._nmc_cache_lock: + cached = self._nmc_cache.get(cache_key) + if cached and now_ts - cached["t"] < self.nmc_cache_ttl_sec: + record_source_call("nmc", "current", "cache_hit", (time.perf_counter() - started) * 1000.0) + return cached["d"] + + station_code = self._resolve_nmc_station_code(city_key) + if not station_code: + record_source_call("nmc", "current", "missing_station_code", (time.perf_counter() - started) * 1000.0) + return None + + try: + url = f"https://www.nmc.cn/rest/real/{station_code}" + resp = self.session.get(url, timeout=self.timeout) + resp.raise_for_status() + payload = resp.json() + if not isinstance(payload, dict) or not isinstance(payload.get("weather"), dict): + record_source_call("nmc", "current", "empty", (time.perf_counter() - started) * 1000.0) + return None + + weather = payload.get("weather") or {} + temp_c = weather.get("temperature") + if temp_c in (None, "", "9999"): + record_source_call("nmc", "current", "no_temperature", (time.perf_counter() - started) * 1000.0) + return None + temp_c = float(temp_c) + temp = round(temp_c * 9 / 5 + 32, 1) if use_fahrenheit else round(temp_c, 1) + + station = payload.get("station") or {} + result = { + "source": "nmc", + "timestamp": datetime.utcnow().isoformat(), + "station_code": station_code, + "station_name": station.get("city") or meta.get("region_label") or city_key.title(), + "page_url": meta.get("page_url"), + "publish_time": payload.get("publish_time"), + "current": { + "temp": temp, + "humidity": self._nmc_optional_float(weather.get("humidity")), + "rain": self._nmc_optional_float(weather.get("rain")), + "airpressure": self._nmc_optional_float(weather.get("airpressure")), + "wx_desc": weather.get("info"), + "wind_direction": (payload.get("wind") or {}).get("direct"), + "wind_power": (payload.get("wind") or {}).get("power"), + }, + } + with self._nmc_cache_lock: + self._nmc_cache[cache_key] = {"d": result, "t": now_ts} + record_source_call("nmc", "current", "success", (time.perf_counter() - started) * 1000.0) + return result + except Exception as exc: + logger.warning("NMC current fetch failed city={} code={} error={}", city_key, station_code, exc) + with self._nmc_cache_lock: + stale = self._nmc_cache.get(cache_key) + if stale: + record_source_call("nmc", "current", "stale_cache", (time.perf_counter() - started) * 1000.0) + return stale["d"] + record_source_call("nmc", "current", "error", (time.perf_counter() - started) * 1000.0) + return None + + def fetch_nmc_official_nearby( + self, + city: str, + use_fahrenheit: bool = False, + ) -> List[Dict[str, Any]]: + current = self.fetch_nmc_region_current(city, use_fahrenheit=use_fahrenheit) + if not current: + return [] + meta = NMC_CITY_REFERENCES.get(str(city or "").strip().lower()) or {} + city_meta = self.CITY_REGISTRY.get(str(city or "").strip().lower()) or {} + return [ + { + "name": f"{meta.get('region_label') or current.get('station_name')} (NMC)", + "lat": city_meta.get("lat"), + "lon": city_meta.get("lon"), + "temp": current.get("current", {}).get("temp"), + "istNo": current.get("station_code"), + "icao": current.get("station_code"), + "source": "nmc", + "source_label": "NMC", + "obs_time": current.get("publish_time"), + "page_url": current.get("page_url"), + "humidity": current.get("current", {}).get("humidity"), + "rain": current.get("current", {}).get("rain"), + "airpressure": current.get("current", {}).get("airpressure"), + "wx_desc": current.get("current", {}).get("wx_desc"), + "wind_direction_text": current.get("current", {}).get("wind_direction"), + "wind_power_text": current.get("current", {}).get("wind_power"), + } + ] diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index a2a1721e..201ff413 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -9,10 +9,11 @@ from src.data_collection.open_meteo_cache import OpenMeteoCacheMixin from src.data_collection.settlement_sources import SettlementSourceMixin from src.data_collection.metar_sources import MetarSourceMixin from src.data_collection.mgm_sources import MgmSourceMixin +from src.data_collection.nmc_sources import NmcSourceMixin from src.data_collection.nws_open_meteo_sources import NwsOpenMeteoSourceMixin -class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, NwsOpenMeteoSourceMixin): +class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, NmcSourceMixin, NwsOpenMeteoSourceMixin): """ Multi-source weather data collector @@ -150,6 +151,11 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour ) self._taf_cache: Dict[str, Dict] = {} self._taf_cache_lock = threading.Lock() + self.nmc_cache_ttl_sec = int( + os.getenv("NMC_CACHE_TTL_SEC", "300") + ) + self._nmc_cache: Dict[str, Dict] = {} + self._nmc_cache_lock = threading.Lock() self.settlement_cache_ttl_sec = int( os.getenv("SETTLEMENT_SOURCE_CACHE_TTL_SEC", "120") ) @@ -655,6 +661,28 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour results["mgm_nearby"] = cluster_data results["nearby_source"] = "metar_cluster" + def _attach_china_official_nearby( + self, results: Dict, city_lower: str, use_fahrenheit: bool + ) -> None: + if city_lower not in { + "beijing", + "chengdu", + "chongqing", + "shanghai", + "shenzhen", + "wuhan", + }: + return + official_rows = self.fetch_nmc_official_nearby( + city_lower, use_fahrenheit=use_fahrenheit + ) + if not official_rows: + return + results["nmc_official_nearby"] = official_rows + if "mgm_nearby" not in results: + results["mgm_nearby"] = official_rows + results["nearby_source"] = "nmc" + def _attach_warsaw_official_nearby( self, results: Dict, use_fahrenheit: bool ) -> None: @@ -749,6 +777,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour results["taf"] = taf_data self._attach_turkish_mgm_data(results, city_lower) + self._attach_china_official_nearby(results, city_lower, use_fahrenheit) if city_lower == "warsaw": self._attach_warsaw_official_nearby(results, use_fahrenheit) self._attach_global_nearby_cluster( @@ -775,6 +804,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour results["taf"] = taf_data self._attach_turkish_mgm_data(results, city_lower) + self._attach_china_official_nearby(results, city_lower, use_fahrenheit) if city_lower == "warsaw": self._attach_warsaw_official_nearby(results, use_fahrenheit) self._attach_global_nearby_cluster( diff --git a/tests/test_country_networks.py b/tests/test_country_networks.py new file mode 100644 index 00000000..f5b0725d --- /dev/null +++ b/tests/test_country_networks.py @@ -0,0 +1,150 @@ +from src.data_collection.country_networks import build_country_network_snapshot +from web.analysis_service import _build_city_detail_payload + + +def test_turkey_mgm_provider_returns_official_nearby_rows(): + raw = { + "metar": { + "observation_time": "2026-04-06T10:00:00.000Z", + "current": {"temp": 16.0}, + }, + "mgm_nearby": [ + { + "name": "Airport (MGM/17128)", + "istNo": "17128", + "lat": 40.1, + "lon": 32.9, + "temp": 17.1, + } + ], + } + + snapshot = build_country_network_snapshot("ankara", raw) + + assert snapshot["provider_code"] == "turkey_mgm" + assert snapshot["official_network_status"]["available"] is True + assert snapshot["official_nearby"][0]["source_code"] == "mgm" + assert snapshot["official_nearby"][0]["is_official"] is True + + +def test_china_provider_falls_back_to_metar_cluster_without_replacing_airport_anchor(): + raw = { + "metar": { + "observation_time": "2026-04-06T10:00:00.000Z", + "current": {"temp": 22.5}, + }, + "mgm_nearby": [ + { + "name": "Hongqiao", + "icao": "ZSSS", + "lat": 31.2, + "lon": 121.3, + "temp": 23.1, + } + ], + } + + snapshot = build_country_network_snapshot("shanghai", raw) + + assert snapshot["provider_code"] == "china_cma" + assert snapshot["airport_primary_current"]["source_code"] == "metar" + assert snapshot["airport_primary_current"]["is_airport_station"] is True + assert snapshot["official_network_status"]["mode"] == "fallback_metar_cluster" + assert snapshot["official_nearby"][0]["source_code"] == "metar_cluster" + assert snapshot["official_nearby"][0]["is_official"] is False + + +def test_china_provider_prefers_nmc_rows_when_available(): + raw = { + "metar": { + "observation_time": "2026-04-06T10:00:00.000Z", + "current": {"temp": 22.5}, + }, + "nmc_official_nearby": [ + { + "name": "浦东 (NMC)", + "icao": "atcMf", + "lat": 31.14, + "lon": 121.80, + "temp": 17.9, + "obs_time": "2026-04-06 06:50", + } + ], + "mgm_nearby": [ + { + "name": "Hongqiao", + "icao": "ZSSS", + "lat": 31.2, + "lon": 121.3, + "temp": 23.1, + } + ], + } + + snapshot = build_country_network_snapshot("shanghai", raw) + + assert snapshot["provider_code"] == "china_cma" + assert snapshot["official_network_status"]["available"] is True + assert snapshot["official_network_status"]["mode"] == "official_active" + assert snapshot["official_nearby"][0]["source_code"] == "nmc" + assert snapshot["official_nearby"][0]["is_official"] is True + + +def test_hko_provider_marks_explicit_official_station_as_anchor(): + raw = { + "settlement_current": { + "station_code": "LFS", + "station_name": "Lau Fau Shan", + "observation_time": "2026-04-06T10:00:00+08:00", + "current": {"temp": 25.0}, + } + } + + snapshot = build_country_network_snapshot("lau fau shan", raw) + + assert snapshot["provider_code"] == "hongkong_hko" + assert snapshot["settlement_station"]["is_official_station_anchor"] is True + assert snapshot["official_nearby"][0]["is_settlement_anchor"] is True + assert snapshot["official_nearby"][0]["station_code"] == "LFS" + + +def test_city_detail_payload_exposes_airport_and_official_network_layers(): + payload = _build_city_detail_payload( + { + "name": "ankara", + "display_name": "Ankara", + "local_time": "12:00", + "local_date": "2026-04-06", + "temp_symbol": "°C", + "updated_at": "2026-04-06T04:00:00Z", + "current": { + "temp": 16.0, + "settlement_source": "metar", + "settlement_source_label": "METAR", + }, + "risk": {"icao": "LTAC", "airport": "Esenboga", "level": "medium", "warning": ""}, + "airport_primary": {"temp": 16.0, "source_code": "metar"}, + "airport_primary_today_obs": [{"time": "10:00", "temp": 16.0}], + "official_nearby": [{"station_code": "17128", "temp": 17.2, "source_code": "mgm"}], + "official_network_source": "turkey_mgm", + "official_network_status": {"provider_code": "turkey_mgm", "available": True}, + "network_lead_signal": {"available": True, "delta": 1.2}, + "network_spread_signal": {"available": True, "spread": 2.1}, + "center_station_candidate": {"station_code": "17128", "temp": 17.2}, + "airport_vs_network_delta": 1.2, + "settlement_station": { + "settlement_station_code": "LTAC", + "settlement_station_label": "Ankara Esenboga Airport", + "is_airport_anchor": True, + }, + "probabilities": {"distribution": []}, + "multi_model": {}, + "multi_model_daily": {}, + "dynamic_commentary": {"summary": "", "notes": []}, + "taf": {}, + } + ) + + assert payload["official"]["airport_primary"]["source_code"] == "metar" + assert payload["official"]["official_nearby"][0]["source_code"] == "mgm" + assert payload["settlement_station"]["settlement_station_code"] == "LTAC" diff --git a/tests/test_nmc_sources.py b/tests/test_nmc_sources.py new file mode 100644 index 00000000..c8382180 --- /dev/null +++ b/tests/test_nmc_sources.py @@ -0,0 +1,99 @@ +import threading + +from src.data_collection.nmc_sources import NmcSourceMixin + + +class _DummyResponse: + def __init__(self, *, text="", payload=None, status_code=200): + self.text = text + self._payload = payload + self.status_code = status_code + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + def json(self): + return self._payload + + +class _DummySession: + def __init__(self, mapping): + self.mapping = mapping + + def get(self, url, timeout=None): + if url not in self.mapping: + raise AssertionError(f"unexpected url {url}") + return self.mapping[url] + + +class _DummyCollector(NmcSourceMixin): + CITY_REGISTRY = { + "shanghai": {"lat": 31.1434, "lon": 121.8052}, + } + + def __init__(self, mapping): + self.session = _DummySession(mapping) + self.timeout = 5 + self.nmc_cache_ttl_sec = 300 + self._nmc_cache = {} + self._nmc_cache_lock = threading.Lock() + + +def test_fetch_nmc_region_current_parses_rest_payload(): + collector = _DummyCollector( + { + "https://www.nmc.cn/rest/real/atcMf": _DummyResponse( + payload={ + "station": {"code": "atcMf", "city": "浦东"}, + "publish_time": "2026-04-06 06:50", + "weather": { + "temperature": 17.9, + "humidity": 83.0, + "rain": 0.0, + "airpressure": 9999.0, + "info": "多云", + }, + "wind": {"direct": "东北风", "power": "3级"}, + } + ) + } + ) + + out = collector.fetch_nmc_region_current("shanghai") + + assert out is not None + assert out["source"] == "nmc" + assert out["station_code"] == "atcMf" + assert out["current"]["temp"] == 17.9 + assert out["current"]["humidity"] == 83.0 + assert out["current"]["airpressure"] is None + + +def test_fetch_nmc_official_nearby_returns_normalized_row(): + collector = _DummyCollector( + { + "https://www.nmc.cn/rest/real/atcMf": _DummyResponse( + payload={ + "station": {"code": "atcMf", "city": "浦东"}, + "publish_time": "2026-04-06 06:50", + "weather": { + "temperature": 17.9, + "humidity": 83.0, + "rain": 0.0, + "airpressure": 9999.0, + "info": "多云", + }, + "wind": {"direct": "东北风", "power": "3级"}, + } + ) + } + ) + + rows = collector.fetch_nmc_official_nearby("shanghai") + + assert len(rows) == 1 + assert rows[0]["source"] == "nmc" + assert rows[0]["temp"] == 17.9 + assert rows[0]["name"] == "浦东 (NMC)" + assert rows[0]["lat"] == 31.1434 diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 07b6f9b4..24126252 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -32,6 +32,7 @@ def test_system_status_returns_summary_shape(): assert 'rollout' in payload['probability'] assert payload['probability']['rollout']['decision']['decision'] in {'hold', 'observe', 'promote'} assert 'training_data' in payload + assert 'station_networks' in payload assert 'truth_records' in payload['training_data'] assert 'training_features' in payload['training_data'] assert 'city_coverage' in payload['training_data'] @@ -52,6 +53,7 @@ def test_cities_endpoint_uses_denver_display_name_for_aurora_market(): payload = response.json() aurora = next(item for item in payload["cities"] if item["name"] == "aurora") assert aurora["display_name"] == "Denver" + assert aurora["network_provider"] == "global_metar" assert aurora["deb_recent_tier"] in {"high", "medium", "low", "other"} assert "deb_recent_sample_count" in aurora @@ -244,3 +246,5 @@ def test_city_history_is_read_only_and_uses_sqlite_truth_and_features(monkeypatc assert row["deb"] == 16.4 assert row["mu"] == 16.2 assert row["forecasts"]["Open-Meteo"] == 15.8 + assert row["settlement_station_code"] == "LTAC" + assert row["truth_version"] == "v1" diff --git a/web/analysis_service.py b/web/analysis_service.py index 794e1880..e31590af 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -22,6 +22,7 @@ from web.core import ( ) from src.analysis.deb_algorithm import calculate_dynamic_weights from src.analysis.settlement_rounding import apply_city_settlement +from src.data_collection.country_networks import build_country_network_snapshot from src.data_collection.city_registry import ALIASES from src.models.lgbm_daily_high import predict_lgbm_daily_high @@ -817,6 +818,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: if not isinstance(mm, dict): mm = {} risk = CITY_RISK_PROFILES.get(city, {}) + network_snapshot = build_country_network_snapshot(city, raw) # ── 2. Current conditions (city-specific settlement source first, then METAR/MGM fallback) ── mc = metar.get("current", {}) if metar else {} @@ -1495,6 +1497,16 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: "raw_metar": mc.get("raw_metar"), "source_label": "METAR", }, + "settlement_station": network_snapshot.get("settlement_station") or {}, + "airport_primary": network_snapshot.get("airport_primary_current") or {}, + "airport_primary_today_obs": network_snapshot.get("airport_primary_today_obs") or [], + "official_nearby": network_snapshot.get("official_nearby") or [], + "official_network_source": network_snapshot.get("official_network_source"), + "official_network_status": network_snapshot.get("official_network_status") or {}, + "network_lead_signal": network_snapshot.get("network_lead_signal") or {}, + "network_spread_signal": network_snapshot.get("network_spread_signal") or {}, + "center_station_candidate": network_snapshot.get("center_station_candidate"), + "airport_vs_network_delta": network_snapshot.get("airport_vs_network_delta"), "mgm": mgm_data, "mgm_nearby": raw.get("mgm_nearby", []), "nearby_source": raw.get("nearby_source") or ("mgm" if city.lower() in TURKISH_MGM_CITIES else "metar_cluster"), @@ -1691,6 +1703,7 @@ def _build_city_detail_payload( "current_temp": data.get("current", {}).get("temp"), "settlement_source": data.get("current", {}).get("settlement_source"), "settlement_source_label": data.get("current", {}).get("settlement_source_label"), + "settlement_station": data.get("settlement_station") or {}, "deb_prediction": data.get("deb", {}).get("prediction"), "risk_level": data.get("risk", {}).get("level"), "risk_warning": data.get("risk", {}).get("warning"), @@ -1711,6 +1724,15 @@ def _build_city_detail_payload( "mgm": data.get("mgm") or {}, "mgm_nearby": data.get("mgm_nearby") or [], "nearby_source": data.get("nearby_source") or ("mgm" if str(data.get("name") or "").lower() in TURKISH_MGM_CITIES else "metar_cluster"), + "airport_primary": data.get("airport_primary") or {}, + "airport_primary_today_obs": data.get("airport_primary_today_obs") or [], + "official_nearby": data.get("official_nearby") or [], + "official_network_source": data.get("official_network_source"), + "official_network_status": data.get("official_network_status") or {}, + "network_lead_signal": data.get("network_lead_signal") or {}, + "network_spread_signal": data.get("network_spread_signal") or {}, + "center_station_candidate": data.get("center_station_candidate"), + "airport_vs_network_delta": data.get("airport_vs_network_delta"), }, "timeseries": { "metar_recent_obs": data.get("metar_recent_obs") or [], @@ -1731,6 +1753,15 @@ def _build_city_detail_payload( "taf": data.get("taf") or {}, "market_scan": market_scan, "risk": data.get("risk"), + "settlement_station": data.get("settlement_station") or {}, + "airport_primary": data.get("airport_primary") or {}, + "official_nearby": data.get("official_nearby") or [], + "official_network_source": data.get("official_network_source"), + "official_network_status": data.get("official_network_status") or {}, + "network_lead_signal": data.get("network_lead_signal") or {}, + "network_spread_signal": data.get("network_spread_signal") or {}, + "center_station_candidate": data.get("center_station_candidate"), + "airport_vs_network_delta": data.get("airport_vs_network_delta"), "airport_current": data.get("airport_current") or {}, "nearby_source": data.get("nearby_source") or ("mgm" if str(data.get("name") or "").lower() in TURKISH_MGM_CITIES else "metar_cluster"), "ai_analysis": data.get("ai_analysis") or "", diff --git a/web/core.py b/web/core.py index 81384145..51d6af14 100644 --- a/web/core.py +++ b/web/core.py @@ -17,6 +17,7 @@ from loguru import logger from src.utils.config_loader import load_config from src.utils.config_validation import validate_runtime_env from src.data_collection.weather_sources import WeatherDataCollector +from src.data_collection.country_networks import provider_coverage_summary from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES # noqa: F401 from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT, extract_bearer_token @@ -748,5 +749,6 @@ def build_system_status_payload() -> Dict[str, Any]: "metrics": build_metrics_summary(), "probability": _probability_summary(), "training_data": _training_data_summary(), + "station_networks": provider_coverage_summary(), "cities_count": len(CITIES), } diff --git a/web/routes.py b/web/routes.py index ec711cd7..999a1db8 100644 --- a/web/routes.py +++ b/web/routes.py @@ -12,6 +12,7 @@ from src.analysis.deb_algorithm import load_history from src.analysis.probability_snapshot_archive import load_snapshot_rows_for_day from src.database.runtime_state import TrainingFeatureRecordRepository, TruthRecordRepository from src.analysis.settlement_rounding import apply_city_settlement +from src.data_collection.country_networks import get_country_network_provider from src.data_collection.city_registry import ALIASES from src.utils.metrics import export_prometheus_metrics from web.analysis_service import ( @@ -271,6 +272,7 @@ async def list_cities(request: Request): city_meta = CITY_REGISTRY.get(name, {}) or {} deb_recent = deb_recent_index.get(name, {}) settlement_source = str(info.get("settlement_source") or "metar").strip().lower() or "metar" + provider = get_country_network_provider(name) out.append( { "name": name, @@ -288,6 +290,10 @@ async def list_cities(request: Request): settlement_source, settlement_source.upper(), ), + "settlement_station_code": city_meta.get("settlement_station_code") or city_meta.get("icao"), + "settlement_station_label": city_meta.get("settlement_station_label") or city_meta.get("airport_name"), + "network_provider": provider.provider_code, + "network_provider_label": provider.provider_label, "deb_recent_tier": deb_recent.get("tier", "other"), "deb_recent_hit_rate": deb_recent.get("hit_rate"), "deb_recent_sample_count": deb_recent.get("sample_count", 0), @@ -329,6 +335,12 @@ async def city_history(request: Request, name: str): features = feature_rows.get(day) or {} if truth.get("actual_high") is not None: record["actual_high"] = truth.get("actual_high") + record["settlement_source"] = truth.get("settlement_source") + record["settlement_station_code"] = truth.get("settlement_station_code") + record["settlement_station_label"] = truth.get("settlement_station_label") + record["truth_version"] = truth.get("truth_version") + record["updated_by"] = truth.get("updated_by") + record["truth_updated_at"] = truth.get("truth_updated_at") if isinstance(features, dict): if features.get("deb_prediction") is not None: record["deb_prediction"] = features.get("deb_prediction") @@ -379,6 +391,12 @@ async def city_history(request: Request, name: str): "mu": float(mu) if mu is not None else None, "mgm": float(mgm) if mgm is not None else None, "forecasts": forecasts, + "settlement_source": rec.get("settlement_source"), + "settlement_station_code": rec.get("settlement_station_code"), + "settlement_station_label": rec.get("settlement_station_label"), + "truth_version": rec.get("truth_version"), + "updated_by": rec.get("updated_by"), + "truth_updated_at": rec.get("truth_updated_at"), "actual_peak_time": peak_ref.get("actual_peak_time"), "deb_at_peak_minus_12h": peak_ref.get("deb_at_peak_minus_12h"), "deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"),