Expose station network coverage and settlement station details
This commit is contained in:
@@ -1014,34 +1014,56 @@ export function FutureForecastModal() {
|
|||||||
? "Settlement station"
|
? "Settlement station"
|
||||||
: "结算站点"
|
: "结算站点"
|
||||||
: t("section.airport");
|
: 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 =
|
const settlementProfileValue =
|
||||||
settlementSourceCode === "noaa"
|
settlementStationLabel
|
||||||
? locale === "en-US"
|
? `${settlementStationLabel}${settlementStationCode ? ` (${settlementStationCode})` : ""}`
|
||||||
? `${noaaStationName}${noaaStationCode ? ` (${noaaStationCode})` : ""}`
|
: "--";
|
||||||
: `${noaaStationName}${noaaStationCode ? `(${noaaStationCode})` : ""}`
|
const airportPrimary = detail.airport_primary || detail.airport_current;
|
||||||
: detail.current?.station_name
|
|
||||||
? `${detail.current.station_name}${
|
|
||||||
detail.current?.station_code ? ` (${detail.current.station_code})` : ""
|
|
||||||
}`
|
|
||||||
: risk.airport
|
|
||||||
? `${risk.airport}${risk.icao ? ` (${risk.icao})` : ""}`
|
|
||||||
: "--";
|
|
||||||
const airportCurrentText =
|
const airportCurrentText =
|
||||||
detail.airport_current?.temp != null
|
airportPrimary?.temp != null
|
||||||
? `${detail.airport_current.temp}${detail.temp_symbol}${
|
? `${airportPrimary.temp}${detail.temp_symbol}${
|
||||||
detail.airport_current?.obs_time
|
airportPrimary?.obs_time
|
||||||
? ` @${detail.airport_current.obs_time}`
|
? ` @${airportPrimary.obs_time}`
|
||||||
: ""
|
: ""
|
||||||
}`
|
}`
|
||||||
: "--";
|
: "--";
|
||||||
const airportMaxText =
|
const airportMaxText =
|
||||||
detail.airport_current?.max_so_far != null
|
airportPrimary?.max_so_far != null
|
||||||
? `${detail.airport_current.max_so_far}${detail.temp_symbol}${
|
? `${airportPrimary.max_so_far}${detail.temp_symbol}${
|
||||||
detail.airport_current?.max_temp_time
|
airportPrimary?.max_temp_time
|
||||||
? ` @${detail.airport_current.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 =
|
const displayedUpperAirSummary =
|
||||||
marketAwareUpperAirCue?.summary || view.front.upperAirSummary;
|
marketAwareUpperAirCue?.summary || view.front.upperAirSummary;
|
||||||
const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map(
|
const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map(
|
||||||
@@ -1390,6 +1412,40 @@ export function FutureForecastModal() {
|
|||||||
</span>
|
</span>
|
||||||
<span>{settlementProfileValue}</span>
|
<span>{settlementProfileValue}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="risk-row">
|
||||||
|
<span className="risk-label">
|
||||||
|
{locale === "en-US" ? "Airport primary" : "机场主站"}
|
||||||
|
</span>
|
||||||
|
<span>{airportCurrentText}</span>
|
||||||
|
</div>
|
||||||
|
<div className="risk-row">
|
||||||
|
<span className="risk-label">
|
||||||
|
{locale === "en-US" ? "Airport max today" : "机场今日最高"}
|
||||||
|
</span>
|
||||||
|
<span>{airportMaxText}</span>
|
||||||
|
</div>
|
||||||
|
<div className="risk-row">
|
||||||
|
<span className="risk-label">
|
||||||
|
{locale === "en-US" ? "Official nearby" : "官方周边站"}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{officialNearbyCount} · {officialNetworkSourceText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="risk-row">
|
||||||
|
<span className="risk-label">
|
||||||
|
{locale === "en-US" ? "Network mode" : "站网模式"}
|
||||||
|
</span>
|
||||||
|
<span>{officialNetworkStatusText}</span>
|
||||||
|
</div>
|
||||||
|
<div className="risk-row">
|
||||||
|
<span className="risk-label">
|
||||||
|
{locale === "en-US"
|
||||||
|
? "Airport vs network"
|
||||||
|
: "机场相对站网偏差"}
|
||||||
|
</span>
|
||||||
|
<span>{airportVsNetworkDeltaText}</span>
|
||||||
|
</div>
|
||||||
<div className="risk-row">
|
<div className="risk-row">
|
||||||
<span className="risk-label">
|
<span className="risk-label">
|
||||||
{t("section.distance")}
|
{t("section.distance")}
|
||||||
|
|||||||
@@ -122,6 +122,17 @@ type SystemStatusPayload = {
|
|||||||
lgbm_validation_deb_mae?: number | null;
|
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 = {
|
type PaymentRuntimePayload = {
|
||||||
@@ -421,7 +432,19 @@ export function OpsDashboard() {
|
|||||||
const cityCoverage = trainingData?.city_coverage;
|
const cityCoverage = trainingData?.city_coverage;
|
||||||
const modelCityCoverage = trainingData?.model_city_coverage;
|
const modelCityCoverage = trainingData?.model_city_coverage;
|
||||||
const trainingArtifacts = trainingData?.artifacts;
|
const trainingArtifacts = trainingData?.artifacts;
|
||||||
|
const stationNetworks = status?.station_networks;
|
||||||
const truthSources = Object.entries(truthRecords?.source_counts || {});
|
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 cityCoverageRows = useMemo(() => {
|
||||||
const modelIndex = new Map(
|
const modelIndex = new Map(
|
||||||
[...(modelCityCoverage?.strongest || []), ...(modelCityCoverage?.weakest || [])].map((entry) => [entry.city, entry]),
|
[...(modelCityCoverage?.strongest || []), ...(modelCityCoverage?.weakest || [])].map((entry) => [entry.city, entry]),
|
||||||
@@ -561,6 +584,7 @@ export function OpsDashboard() {
|
|||||||
|
|
||||||
const opsNav = [
|
const opsNav = [
|
||||||
{ id: "overview", label: "总览" },
|
{ id: "overview", label: "总览" },
|
||||||
|
{ id: "station-networks", label: "站网覆盖" },
|
||||||
{ id: "funnel", label: "转化漏斗" },
|
{ id: "funnel", label: "转化漏斗" },
|
||||||
{ id: "probability", label: "EMOS 门禁" },
|
{ id: "probability", label: "EMOS 门禁" },
|
||||||
{ id: "training-data", label: "训练数据" },
|
{ id: "training-data", label: "训练数据" },
|
||||||
@@ -660,6 +684,68 @@ export function OpsDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="station-networks"
|
||||||
|
className="grid gap-4 xl:grid-cols-[1.1fr_1.4fr]"
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>站网协议覆盖</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
统一查看机场锚点覆盖、官方站锚点覆盖,以及新的国家 provider 分布。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm text-slate-300">
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span>机场锚点覆盖</span>
|
||||||
|
<span>{stationNetworks?.airport_anchor_coverage ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span>官方站锚点覆盖</span>
|
||||||
|
<span>{stationNetworks?.official_station_anchor_coverage ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span>国家 provider 数</span>
|
||||||
|
<span>{providerRows.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-3 text-xs text-slate-400">
|
||||||
|
规则固定为“机场主站 + 官方增强 + 模型层”。官方站网只做增强,除明确官方结算站外不会替代机场锚点。
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>国家 Provider 分布</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
这些 provider 已进入统一协议层,业务侧不再直接按国家写分支。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{providerRows.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-800 p-4 text-sm text-slate-500">
|
||||||
|
暂无站网 provider 数据。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
providerRows.map((provider) => (
|
||||||
|
<div
|
||||||
|
key={provider.code}
|
||||||
|
className="rounded-2xl border border-slate-800 bg-slate-950/60 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-sm font-semibold text-slate-100">{provider.code}</div>
|
||||||
|
<Badge variant="secondary">{provider.citiesCount} 城</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs leading-6 text-slate-400">
|
||||||
|
{provider.cities.length > 0 ? provider.cities.join(", ") : "无城市"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Card className="border-rose-500/30 bg-rose-500/10">
|
<Card className="border-rose-500/30 bg-rose-500/10">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -312,10 +312,20 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
|
|||||||
],
|
],
|
||||||
shanghai: [
|
shanghai: [
|
||||||
{
|
{
|
||||||
label: "中国天气网",
|
label: "NMC 全国主要机场天气预报",
|
||||||
href: "https://www.weather.com.cn/",
|
href: "https://www.nmc.cn/publish/weather_forecast/swssr.htm",
|
||||||
kind: "agency",
|
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",
|
label: "ZSPD METAR",
|
||||||
href: "https://aviationweather.gov/data/metar/?id=ZSPD&decoded=1&taf=1",
|
href: "https://aviationweather.gov/data/metar/?id=ZSPD&decoded=1&taf=1",
|
||||||
@@ -504,10 +514,20 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
|
|||||||
],
|
],
|
||||||
chengdu: [
|
chengdu: [
|
||||||
{
|
{
|
||||||
label: "中国天气网",
|
label: "NMC 全国主要机场天气预报",
|
||||||
href: "https://www.weather.com.cn/",
|
href: "https://www.nmc.cn/publish/weather_forecast/swssr.htm",
|
||||||
kind: "agency",
|
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",
|
label: "ZUUU METAR",
|
||||||
href: "https://aviationweather.gov/data/metar/?id=ZUUU&decoded=1&taf=1",
|
href: "https://aviationweather.gov/data/metar/?id=ZUUU&decoded=1&taf=1",
|
||||||
@@ -516,10 +536,20 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
|
|||||||
],
|
],
|
||||||
chongqing: [
|
chongqing: [
|
||||||
{
|
{
|
||||||
label: "中国天气网",
|
label: "NMC 全国主要机场天气预报",
|
||||||
href: "https://www.weather.com.cn/",
|
href: "https://www.nmc.cn/publish/weather_forecast/swssr.htm",
|
||||||
kind: "agency",
|
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",
|
label: "ZUCK METAR",
|
||||||
href: "https://aviationweather.gov/data/metar/?id=ZUCK&decoded=1&taf=1",
|
href: "https://aviationweather.gov/data/metar/?id=ZUCK&decoded=1&taf=1",
|
||||||
@@ -528,8 +558,13 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
|
|||||||
],
|
],
|
||||||
shenzhen: [
|
shenzhen: [
|
||||||
{
|
{
|
||||||
label: "Wunderground ZGSZ",
|
label: "NMC 全国主要机场天气预报",
|
||||||
href: "https://www.wunderground.com/history/daily/cn/shenzhen/ZGSZ",
|
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",
|
kind: "agency",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -630,10 +665,20 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
|
|||||||
],
|
],
|
||||||
beijing: [
|
beijing: [
|
||||||
{
|
{
|
||||||
label: "中国天气网",
|
label: "NMC 全国主要机场天气预报",
|
||||||
href: "https://www.weather.com.cn/",
|
href: "https://www.nmc.cn/publish/weather_forecast/swssr.htm",
|
||||||
kind: "agency",
|
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",
|
label: "ZBAA METAR",
|
||||||
href: "https://aviationweather.gov/data/metar/?id=ZBAA&decoded=1&taf=1",
|
href: "https://aviationweather.gov/data/metar/?id=ZBAA&decoded=1&taf=1",
|
||||||
@@ -642,10 +687,20 @@ const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
|
|||||||
],
|
],
|
||||||
wuhan: [
|
wuhan: [
|
||||||
{
|
{
|
||||||
label: "中国天气网",
|
label: "NMC 全国主要机场天气预报",
|
||||||
href: "https://www.weather.com.cn/",
|
href: "https://www.nmc.cn/publish/weather_forecast/swssr.htm",
|
||||||
kind: "agency",
|
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",
|
label: "ZHHH METAR",
|
||||||
href: "https://aviationweather.gov/data/metar/?id=ZHHH&decoded=1&taf=1",
|
href: "https://aviationweather.gov/data/metar/?id=ZHHH&decoded=1&taf=1",
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export interface CityListItem {
|
|||||||
is_major?: boolean;
|
is_major?: boolean;
|
||||||
settlement_source?: string;
|
settlement_source?: string;
|
||||||
settlement_source_label?: string;
|
settlement_source_label?: string;
|
||||||
|
settlement_station_code?: string;
|
||||||
|
settlement_station_label?: string;
|
||||||
|
network_provider?: string;
|
||||||
|
network_provider_label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProbabilityBucket {
|
export interface ProbabilityBucket {
|
||||||
@@ -90,17 +94,29 @@ export interface AirportCurrentConditions {
|
|||||||
wx_desc?: string | null;
|
wx_desc?: string | null;
|
||||||
raw_metar?: string | null;
|
raw_metar?: string | null;
|
||||||
source_label?: 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 {
|
export interface NearbyStation {
|
||||||
name?: string;
|
name?: string;
|
||||||
icao?: string;
|
icao?: string;
|
||||||
|
station_code?: string | null;
|
||||||
|
station_label?: string | null;
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
temp: number | null;
|
temp: number | null;
|
||||||
wind_dir?: number | null;
|
wind_dir?: number | null;
|
||||||
wind_speed?: number | null;
|
wind_speed?: number | null;
|
||||||
wind_speed_kt?: 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 {
|
export interface HourlyTrendPoint {
|
||||||
@@ -316,10 +332,49 @@ export interface CityDetail {
|
|||||||
local_date: string;
|
local_date: string;
|
||||||
risk: DashboardRisk;
|
risk: DashboardRisk;
|
||||||
current: CurrentConditions;
|
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_current?: AirportCurrentConditions;
|
||||||
|
airport_primary?: AirportCurrentConditions;
|
||||||
|
airport_primary_today_obs?: Array<{
|
||||||
|
time?: string;
|
||||||
|
temp?: number | null;
|
||||||
|
}>;
|
||||||
mgm?: MgmData;
|
mgm?: MgmData;
|
||||||
mgm_nearby?: NearbyStation[];
|
mgm_nearby?: NearbyStation[];
|
||||||
|
official_nearby?: NearbyStation[];
|
||||||
nearby_source?: string;
|
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;
|
forecast?: ForecastData;
|
||||||
multi_model?: Record<string, number | null>;
|
multi_model?: Record<string, number | null>;
|
||||||
deb?: DebForecast;
|
deb?: DebForecast;
|
||||||
@@ -419,6 +474,12 @@ export interface HistoryPoint {
|
|||||||
mu?: number | null;
|
mu?: number | null;
|
||||||
mgm?: number | null;
|
mgm?: number | null;
|
||||||
forecasts?: Record<string, number | null>;
|
forecasts?: Record<string, number | null>;
|
||||||
|
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;
|
actual_peak_time?: string | null;
|
||||||
deb_at_peak_minus_12h?: number | null;
|
deb_at_peak_minus_12h?: number | null;
|
||||||
deb_at_peak_minus_12h_time?: string | null;
|
deb_at_peak_minus_12h_time?: string | null;
|
||||||
|
|||||||
@@ -828,7 +828,11 @@ function distanceKm(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function pickMapNearbyStations(detail: CityDetail) {
|
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 || "")
|
const city = String(detail.name || detail.display_name || "")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|||||||
@@ -328,6 +328,16 @@ export interface CityDetail {
|
|||||||
local_date: string;
|
local_date: string;
|
||||||
temp_symbol: string;
|
temp_symbol: string;
|
||||||
current_temp: number | null;
|
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;
|
deb_prediction: number | null;
|
||||||
risk_level: string;
|
risk_level: string;
|
||||||
risk_warning: string;
|
risk_warning: string;
|
||||||
@@ -340,6 +350,15 @@ export interface CityDetail {
|
|||||||
mgm: any;
|
mgm: any;
|
||||||
mgm_nearby: any[];
|
mgm_nearby: any[];
|
||||||
nearby_source?: string;
|
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: {
|
timeseries: {
|
||||||
metar_recent_obs: any[];
|
metar_recent_obs: any[];
|
||||||
@@ -356,6 +375,15 @@ export interface CityDetail {
|
|||||||
};
|
};
|
||||||
market_scan: MarketScan;
|
market_scan: MarketScan;
|
||||||
risk: any;
|
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;
|
ai_analysis: string;
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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.settlement_sources import SettlementSourceMixin
|
||||||
from src.data_collection.metar_sources import MetarSourceMixin
|
from src.data_collection.metar_sources import MetarSourceMixin
|
||||||
from src.data_collection.mgm_sources import MgmSourceMixin
|
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
|
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
|
Multi-source weather data collector
|
||||||
|
|
||||||
@@ -150,6 +151,11 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
)
|
)
|
||||||
self._taf_cache: Dict[str, Dict] = {}
|
self._taf_cache: Dict[str, Dict] = {}
|
||||||
self._taf_cache_lock = threading.Lock()
|
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(
|
self.settlement_cache_ttl_sec = int(
|
||||||
os.getenv("SETTLEMENT_SOURCE_CACHE_TTL_SEC", "120")
|
os.getenv("SETTLEMENT_SOURCE_CACHE_TTL_SEC", "120")
|
||||||
)
|
)
|
||||||
@@ -655,6 +661,28 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
results["mgm_nearby"] = cluster_data
|
results["mgm_nearby"] = cluster_data
|
||||||
results["nearby_source"] = "metar_cluster"
|
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(
|
def _attach_warsaw_official_nearby(
|
||||||
self, results: Dict, use_fahrenheit: bool
|
self, results: Dict, use_fahrenheit: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -749,6 +777,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
results["taf"] = taf_data
|
results["taf"] = taf_data
|
||||||
|
|
||||||
self._attach_turkish_mgm_data(results, city_lower)
|
self._attach_turkish_mgm_data(results, city_lower)
|
||||||
|
self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
if city_lower == "warsaw":
|
if city_lower == "warsaw":
|
||||||
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
||||||
self._attach_global_nearby_cluster(
|
self._attach_global_nearby_cluster(
|
||||||
@@ -775,6 +804,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
results["taf"] = taf_data
|
results["taf"] = taf_data
|
||||||
|
|
||||||
self._attach_turkish_mgm_data(results, city_lower)
|
self._attach_turkish_mgm_data(results, city_lower)
|
||||||
|
self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
if city_lower == "warsaw":
|
if city_lower == "warsaw":
|
||||||
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
||||||
self._attach_global_nearby_cluster(
|
self._attach_global_nearby_cluster(
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -32,6 +32,7 @@ def test_system_status_returns_summary_shape():
|
|||||||
assert 'rollout' in payload['probability']
|
assert 'rollout' in payload['probability']
|
||||||
assert payload['probability']['rollout']['decision']['decision'] in {'hold', 'observe', 'promote'}
|
assert payload['probability']['rollout']['decision']['decision'] in {'hold', 'observe', 'promote'}
|
||||||
assert 'training_data' in payload
|
assert 'training_data' in payload
|
||||||
|
assert 'station_networks' in payload
|
||||||
assert 'truth_records' in payload['training_data']
|
assert 'truth_records' in payload['training_data']
|
||||||
assert 'training_features' in payload['training_data']
|
assert 'training_features' in payload['training_data']
|
||||||
assert 'city_coverage' 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()
|
payload = response.json()
|
||||||
aurora = next(item for item in payload["cities"] if item["name"] == "aurora")
|
aurora = next(item for item in payload["cities"] if item["name"] == "aurora")
|
||||||
assert aurora["display_name"] == "Denver"
|
assert aurora["display_name"] == "Denver"
|
||||||
|
assert aurora["network_provider"] == "global_metar"
|
||||||
assert aurora["deb_recent_tier"] in {"high", "medium", "low", "other"}
|
assert aurora["deb_recent_tier"] in {"high", "medium", "low", "other"}
|
||||||
assert "deb_recent_sample_count" in aurora
|
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["deb"] == 16.4
|
||||||
assert row["mu"] == 16.2
|
assert row["mu"] == 16.2
|
||||||
assert row["forecasts"]["Open-Meteo"] == 15.8
|
assert row["forecasts"]["Open-Meteo"] == 15.8
|
||||||
|
assert row["settlement_station_code"] == "LTAC"
|
||||||
|
assert row["truth_version"] == "v1"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from web.core import (
|
|||||||
)
|
)
|
||||||
from src.analysis.deb_algorithm import calculate_dynamic_weights
|
from src.analysis.deb_algorithm import calculate_dynamic_weights
|
||||||
from src.analysis.settlement_rounding import apply_city_settlement
|
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.data_collection.city_registry import ALIASES
|
||||||
from src.models.lgbm_daily_high import predict_lgbm_daily_high
|
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):
|
if not isinstance(mm, dict):
|
||||||
mm = {}
|
mm = {}
|
||||||
risk = CITY_RISK_PROFILES.get(city, {})
|
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) ──
|
# ── 2. Current conditions (city-specific settlement source first, then METAR/MGM fallback) ──
|
||||||
mc = metar.get("current", {}) if metar else {}
|
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"),
|
"raw_metar": mc.get("raw_metar"),
|
||||||
"source_label": "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": mgm_data,
|
||||||
"mgm_nearby": raw.get("mgm_nearby", []),
|
"mgm_nearby": raw.get("mgm_nearby", []),
|
||||||
"nearby_source": raw.get("nearby_source") or ("mgm" if city.lower() in TURKISH_MGM_CITIES else "metar_cluster"),
|
"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"),
|
"current_temp": data.get("current", {}).get("temp"),
|
||||||
"settlement_source": data.get("current", {}).get("settlement_source"),
|
"settlement_source": data.get("current", {}).get("settlement_source"),
|
||||||
"settlement_source_label": data.get("current", {}).get("settlement_source_label"),
|
"settlement_source_label": data.get("current", {}).get("settlement_source_label"),
|
||||||
|
"settlement_station": data.get("settlement_station") or {},
|
||||||
"deb_prediction": data.get("deb", {}).get("prediction"),
|
"deb_prediction": data.get("deb", {}).get("prediction"),
|
||||||
"risk_level": data.get("risk", {}).get("level"),
|
"risk_level": data.get("risk", {}).get("level"),
|
||||||
"risk_warning": data.get("risk", {}).get("warning"),
|
"risk_warning": data.get("risk", {}).get("warning"),
|
||||||
@@ -1711,6 +1724,15 @@ def _build_city_detail_payload(
|
|||||||
"mgm": data.get("mgm") or {},
|
"mgm": data.get("mgm") or {},
|
||||||
"mgm_nearby": data.get("mgm_nearby") 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"),
|
"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": {
|
"timeseries": {
|
||||||
"metar_recent_obs": data.get("metar_recent_obs") or [],
|
"metar_recent_obs": data.get("metar_recent_obs") or [],
|
||||||
@@ -1731,6 +1753,15 @@ def _build_city_detail_payload(
|
|||||||
"taf": data.get("taf") or {},
|
"taf": data.get("taf") or {},
|
||||||
"market_scan": market_scan,
|
"market_scan": market_scan,
|
||||||
"risk": data.get("risk"),
|
"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 {},
|
"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"),
|
"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 "",
|
"ai_analysis": data.get("ai_analysis") or "",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from loguru import logger
|
|||||||
from src.utils.config_loader import load_config
|
from src.utils.config_loader import load_config
|
||||||
from src.utils.config_validation import validate_runtime_env
|
from src.utils.config_validation import validate_runtime_env
|
||||||
from src.data_collection.weather_sources import WeatherDataCollector
|
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.city_risk_profiles import CITY_RISK_PROFILES # noqa: F401
|
||||||
from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer
|
from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer
|
||||||
from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT, extract_bearer_token
|
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(),
|
"metrics": build_metrics_summary(),
|
||||||
"probability": _probability_summary(),
|
"probability": _probability_summary(),
|
||||||
"training_data": _training_data_summary(),
|
"training_data": _training_data_summary(),
|
||||||
|
"station_networks": provider_coverage_summary(),
|
||||||
"cities_count": len(CITIES),
|
"cities_count": len(CITIES),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.analysis.probability_snapshot_archive import load_snapshot_rows_for_day
|
||||||
from src.database.runtime_state import TrainingFeatureRecordRepository, TruthRecordRepository
|
from src.database.runtime_state import TrainingFeatureRecordRepository, TruthRecordRepository
|
||||||
from src.analysis.settlement_rounding import apply_city_settlement
|
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.data_collection.city_registry import ALIASES
|
||||||
from src.utils.metrics import export_prometheus_metrics
|
from src.utils.metrics import export_prometheus_metrics
|
||||||
from web.analysis_service import (
|
from web.analysis_service import (
|
||||||
@@ -271,6 +272,7 @@ async def list_cities(request: Request):
|
|||||||
city_meta = CITY_REGISTRY.get(name, {}) or {}
|
city_meta = CITY_REGISTRY.get(name, {}) or {}
|
||||||
deb_recent = deb_recent_index.get(name, {})
|
deb_recent = deb_recent_index.get(name, {})
|
||||||
settlement_source = str(info.get("settlement_source") or "metar").strip().lower() or "metar"
|
settlement_source = str(info.get("settlement_source") or "metar").strip().lower() or "metar"
|
||||||
|
provider = get_country_network_provider(name)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -288,6 +290,10 @@ async def list_cities(request: Request):
|
|||||||
settlement_source,
|
settlement_source,
|
||||||
settlement_source.upper(),
|
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_tier": deb_recent.get("tier", "other"),
|
||||||
"deb_recent_hit_rate": deb_recent.get("hit_rate"),
|
"deb_recent_hit_rate": deb_recent.get("hit_rate"),
|
||||||
"deb_recent_sample_count": deb_recent.get("sample_count", 0),
|
"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 {}
|
features = feature_rows.get(day) or {}
|
||||||
if truth.get("actual_high") is not None:
|
if truth.get("actual_high") is not None:
|
||||||
record["actual_high"] = truth.get("actual_high")
|
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 isinstance(features, dict):
|
||||||
if features.get("deb_prediction") is not None:
|
if features.get("deb_prediction") is not None:
|
||||||
record["deb_prediction"] = features.get("deb_prediction")
|
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,
|
"mu": float(mu) if mu is not None else None,
|
||||||
"mgm": float(mgm) if mgm is not None else None,
|
"mgm": float(mgm) if mgm is not None else None,
|
||||||
"forecasts": forecasts,
|
"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"),
|
"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": peak_ref.get("deb_at_peak_minus_12h"),
|
||||||
"deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"),
|
"deb_at_peak_minus_12h_time": peak_ref.get("deb_at_peak_minus_12h_time"),
|
||||||
|
|||||||
Reference in New Issue
Block a user