气温走势图:多模型逐小时预测曲线 + API 新增 models_hourly 字段
city_payloads 新增 models_hourly 包含 per-model hourly_forecasts。LiveTemperatureThresholdChart 渲染多模型曲线替代点预测卡片。CityDetail 类型新增 models_hourly。
This commit is contained in:
@@ -101,6 +101,7 @@ type HourlyForecast = {
|
|||||||
localTime?: string | null;
|
localTime?: string | null;
|
||||||
times: string[];
|
times: string[];
|
||||||
temps: Array<number | null>;
|
temps: Array<number | null>;
|
||||||
|
modelCurves?: Record<string, Array<number | null>>;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
function buildModelCurves(row: ScanOpportunityRow | null, length: number, hourly: HourlyForecast) {
|
function buildModelCurves(row: ScanOpportunityRow | null, length: number, hourly: HourlyForecast) {
|
||||||
@@ -133,6 +134,33 @@ function buildModelCurves(row: ScanOpportunityRow | null, length: number, hourly
|
|||||||
values,
|
values,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-model hourly curves from Open-Meteo multi-model API
|
||||||
|
if (hourly.modelCurves) {
|
||||||
|
const modelColors = ["#2563eb", "#7c3aed", "#059669", "#d97706", "#dc2626", "#0891b2"];
|
||||||
|
Object.keys(hourly.modelCurves).forEach((model, idx) => {
|
||||||
|
const modelTemps = hourly.modelCurves![model];
|
||||||
|
if (!modelTemps?.length) return;
|
||||||
|
const values = Array.from({ length }, (): number | null => null);
|
||||||
|
hourly.times.forEach((t, i) => {
|
||||||
|
const slot = parseTimeSlot(t);
|
||||||
|
if (slot !== null && slot >= 0 && slot < length && i < modelTemps.length) {
|
||||||
|
values[slot] = validNumber(modelTemps[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (values.some((v) => v !== null)) {
|
||||||
|
result.push({
|
||||||
|
key: `model_curve_${model}`,
|
||||||
|
label: model,
|
||||||
|
source: "Multi-model hourly",
|
||||||
|
color: modelColors[idx % modelColors.length],
|
||||||
|
dashed: true,
|
||||||
|
smooth: true,
|
||||||
|
values,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -365,6 +393,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
localTime: json.local_time || null,
|
localTime: json.local_time || null,
|
||||||
times: json.hourly.times || [],
|
times: json.hourly.times || [],
|
||||||
temps: json.hourly.temps || [],
|
temps: json.hourly.temps || [],
|
||||||
|
modelCurves: json.models_hourly?.curves || undefined,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -374,7 +403,13 @@ export function LiveTemperatureThresholdChart({
|
|||||||
const { data, series } = useMemo(() => buildEvidenceChart(row, hourly), [row, hourly]);
|
const { data, series } = useMemo(() => buildEvidenceChart(row, hourly), [row, hourly]);
|
||||||
const visibleData = useMemo(() => buildMovingWindowData(data, row, hourly), [data, row, hourly]);
|
const visibleData = useMemo(() => buildMovingWindowData(data, row, hourly), [data, row, hourly]);
|
||||||
const threshold = validNumber(row?.target_threshold) ?? validNumber(row?.target_value);
|
const threshold = validNumber(row?.target_threshold) ?? validNumber(row?.target_value);
|
||||||
const modelSummaryCards = useMemo(() => buildModelSummaryCards(row), [row]);
|
const modelSummaryCards = useMemo(() => {
|
||||||
|
const cards = buildModelSummaryCards(row);
|
||||||
|
// Exclude models that already show as hourly curves (from buildModelCurves)
|
||||||
|
if (!hourly?.modelCurves) return cards;
|
||||||
|
const curveKeys = new Set(Object.keys(hourly.modelCurves));
|
||||||
|
return cards.filter((card) => !curveKeys.has(card.label));
|
||||||
|
}, [row, hourly]);
|
||||||
const tableRows = [...series, ...modelSummaryCards]
|
const tableRows = [...series, ...modelSummaryCards]
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map((item) => ({ ...item, ...seriesStats(item.values) }));
|
.map((item) => ({ ...item, ...seriesStats(item.values) }));
|
||||||
|
|||||||
@@ -45,8 +45,29 @@ function barColor(hr: number) {
|
|||||||
return "#dc2626";
|
return "#dc2626";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRAINING_CACHE_KEY = "polyweather_training_accuracy_v1";
|
||||||
|
const TRAINING_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
function readTrainingCache(): TrainingCity[] | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(TRAINING_CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const cached = JSON.parse(raw);
|
||||||
|
if (cached.ts && Date.now() - cached.ts < TRAINING_CACHE_TTL_MS && Array.isArray(cached.data)) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTrainingCache(data: TrainingCity[]) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TRAINING_CACHE_KEY, JSON.stringify({ ts: Date.now(), data }));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||||
const [data, setData] = useState<TrainingCity[] | null>(null);
|
const [data, setData] = useState<TrainingCity[] | null>(() => readTrainingCache());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -57,7 +78,9 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
|||||||
})
|
})
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled || !payload?.accuracy) return;
|
if (cancelled || !payload?.accuracy) return;
|
||||||
setData(payload.accuracy.filter((c) => (c.deb || c.mu) && ((c.deb?.total_days ?? 0) + (c.mu?.total_days ?? 0)) >= 5));
|
const filtered = payload.accuracy.filter((c) => (c.deb || c.mu) && ((c.deb?.total_days ?? 0) + (c.mu?.total_days ?? 0)) >= 5);
|
||||||
|
setData(filtered);
|
||||||
|
writeTrainingCache(filtered);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
@@ -70,8 +93,8 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
|||||||
if (!debSorted.length) return null;
|
if (!debSorted.length) return null;
|
||||||
const avgHit = debSorted.reduce((s, c) => s + (c.deb?.hit_rate ?? 0), 0) / debSorted.length;
|
const avgHit = debSorted.reduce((s, c) => s + (c.deb?.hit_rate ?? 0), 0) / debSorted.length;
|
||||||
const avgMae = debSorted.reduce((s, c) => s + (c.deb?.mae ?? 0), 0) / debSorted.length;
|
const avgMae = debSorted.reduce((s, c) => s + (c.deb?.mae ?? 0), 0) / debSorted.length;
|
||||||
const totalDays = debSorted.reduce((s, c) => s + (c.deb?.total_days ?? 0), 0);
|
const avgDays = Math.round(debSorted.reduce((s, c) => s + (c.deb?.total_days ?? 0), 0) / Math.max(debSorted.length, 1));
|
||||||
return { avgHit, avgMae, totalDays, cities: debSorted.length };
|
return { avgHit, avgMae, avgDays, cities: debSorted.length };
|
||||||
}, [debSorted]);
|
}, [debSorted]);
|
||||||
|
|
||||||
const muStats = useMemo(() => {
|
const muStats = useMemo(() => {
|
||||||
@@ -79,8 +102,8 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
|||||||
const avgHit = muSorted.reduce((s, c) => s + (c.mu?.hit_rate ?? 0), 0) / muSorted.length;
|
const avgHit = muSorted.reduce((s, c) => s + (c.mu?.hit_rate ?? 0), 0) / muSorted.length;
|
||||||
const avgMae = muSorted.reduce((s, c) => s + (c.mu?.mae ?? 0), 0) / muSorted.length;
|
const avgMae = muSorted.reduce((s, c) => s + (c.mu?.mae ?? 0), 0) / muSorted.length;
|
||||||
const avgBrier = muSorted.reduce((s, c) => s + (c.mu?.brier_score ?? 0), 0) / muSorted.length;
|
const avgBrier = muSorted.reduce((s, c) => s + (c.mu?.brier_score ?? 0), 0) / muSorted.length;
|
||||||
const totalDays = muSorted.reduce((s, c) => s + (c.mu?.total_days ?? 0), 0);
|
const avgDays = Math.round(muSorted.reduce((s, c) => s + (c.mu?.total_days ?? 0), 0) / Math.max(muSorted.length, 1));
|
||||||
return { avgHit, avgMae, avgBrier, totalDays, cities: muSorted.length };
|
return { avgHit, avgMae, avgBrier, avgDays, cities: muSorted.length };
|
||||||
}, [muSorted]);
|
}, [muSorted]);
|
||||||
|
|
||||||
const debHitChart = useMemo(
|
const debHitChart = useMemo(
|
||||||
@@ -125,7 +148,7 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
|||||||
{ icon: Hash, label: isEn ? "Cities" : "城市数", value: debStats.cities, tone: "blue" },
|
{ icon: Hash, label: isEn ? "Cities" : "城市数", value: debStats.cities, tone: "blue" },
|
||||||
{ icon: Target, label: isEn ? "Avg Hit" : "平均命中", value: `${debStats.avgHit.toFixed(1)}%`, tone: "emerald" },
|
{ icon: Target, label: isEn ? "Avg Hit" : "平均命中", value: `${debStats.avgHit.toFixed(1)}%`, tone: "emerald" },
|
||||||
{ icon: Thermometer, label: isEn ? "Avg Error" : "平均误差", value: `${debStats.avgMae.toFixed(1)}°`, tone: "amber" },
|
{ icon: Thermometer, label: isEn ? "Avg Error" : "平均误差", value: `${debStats.avgMae.toFixed(1)}°`, tone: "amber" },
|
||||||
{ icon: TrendingUp, label: isEn ? "Total Days" : "训练天数", value: debStats.totalDays.toLocaleString(), tone: "purple" },
|
{ icon: TrendingUp, label: isEn ? "Avg Days/City" : "每城平均天数", value: debStats.avgDays.toLocaleString(), tone: "purple" },
|
||||||
].map(({ icon: Icon, label, value, tone }) => (
|
].map(({ icon: Icon, label, value, tone }) => (
|
||||||
<div key={label} className={`flex items-center gap-3 rounded-lg border ${STAT_CARD_CLASSES[tone]} p-3`}>
|
<div key={label} className={`flex items-center gap-3 rounded-lg border ${STAT_CARD_CLASSES[tone]} p-3`}>
|
||||||
<Icon size={20} className={STAT_ICON_CLASSES[tone]} />
|
<Icon size={20} className={STAT_ICON_CLASSES[tone]} />
|
||||||
|
|||||||
@@ -844,6 +844,10 @@ export interface CityDetail {
|
|||||||
times?: string[];
|
times?: string[];
|
||||||
temps?: Array<number | null>;
|
temps?: Array<number | null>;
|
||||||
};
|
};
|
||||||
|
models_hourly?: {
|
||||||
|
times?: string[];
|
||||||
|
curves?: Record<string, Array<number | null>>;
|
||||||
|
};
|
||||||
hourly_next_48h?: HourlySeries;
|
hourly_next_48h?: HourlySeries;
|
||||||
metar_recent_obs?: Array<{
|
metar_recent_obs?: Array<{
|
||||||
time?: string;
|
time?: string;
|
||||||
|
|||||||
@@ -181,6 +181,16 @@ def build_city_detail_payload(
|
|||||||
for k, v in (data.get("multi_model") or {}).items()
|
for k, v in (data.get("multi_model") or {}).items()
|
||||||
if not _is_excluded_model_name(k)
|
if not _is_excluded_model_name(k)
|
||||||
},
|
},
|
||||||
|
"models_hourly": {
|
||||||
|
"times": (data.get("multi_model") or {}).get("hourly_times", []),
|
||||||
|
"curves": {
|
||||||
|
model: values
|
||||||
|
for model, values in (
|
||||||
|
(data.get("multi_model") or {}).get("hourly_forecasts", {})
|
||||||
|
).items()
|
||||||
|
if not _is_excluded_model_name(model)
|
||||||
|
},
|
||||||
|
},
|
||||||
"deb": data.get("deb") or {},
|
"deb": data.get("deb") or {},
|
||||||
"multi_model_daily": data.get("multi_model_daily") or {},
|
"multi_model_daily": data.get("multi_model_daily") or {},
|
||||||
"probabilities": data.get("probabilities") or {"mu": None, "distribution": []},
|
"probabilities": data.get("probabilities") or {"mu": None, "distribution": []},
|
||||||
|
|||||||
Reference in New Issue
Block a user