气温走势图:多模型逐小时预测曲线 + API 新增 models_hourly 字段

city_payloads 新增 models_hourly 包含 per-model hourly_forecasts。LiveTemperatureThresholdChart 渲染多模型曲线替代点预测卡片。CityDetail 类型新增 models_hourly。
This commit is contained in:
2569718930@qq.com
2026-05-25 05:58:15 +08:00
parent e478a1f08d
commit ce667ba1b6
4 changed files with 80 additions and 8 deletions
@@ -101,6 +101,7 @@ type HourlyForecast = {
localTime?: string | null;
times: string[];
temps: Array<number | null>;
modelCurves?: Record<string, Array<number | null>>;
} | null;
function buildModelCurves(row: ScanOpportunityRow | null, length: number, hourly: HourlyForecast) {
@@ -133,6 +134,33 @@ function buildModelCurves(row: ScanOpportunityRow | null, length: number, hourly
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;
}
@@ -365,6 +393,7 @@ export function LiveTemperatureThresholdChart({
localTime: json.local_time || null,
times: json.hourly.times || [],
temps: json.hourly.temps || [],
modelCurves: json.models_hourly?.curves || undefined,
});
})
.catch(() => {});
@@ -374,7 +403,13 @@ export function LiveTemperatureThresholdChart({
const { data, series } = useMemo(() => buildEvidenceChart(row, hourly), [row, hourly]);
const visibleData = useMemo(() => buildMovingWindowData(data, row, hourly), [data, row, hourly]);
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]
.slice(0, 5)
.map((item) => ({ ...item, ...seriesStats(item.values) }));
@@ -45,8 +45,29 @@ function barColor(hr: number) {
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 }) {
const [data, setData] = useState<TrainingCity[] | null>(null);
const [data, setData] = useState<TrainingCity[] | null>(() => readTrainingCache());
useEffect(() => {
let cancelled = false;
@@ -57,7 +78,9 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
})
.then((payload) => {
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(() => {});
return () => { cancelled = true; };
@@ -70,8 +93,8 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
if (!debSorted.length) return null;
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 totalDays = debSorted.reduce((s, c) => s + (c.deb?.total_days ?? 0), 0);
return { avgHit, avgMae, totalDays, cities: debSorted.length };
const avgDays = Math.round(debSorted.reduce((s, c) => s + (c.deb?.total_days ?? 0), 0) / Math.max(debSorted.length, 1));
return { avgHit, avgMae, avgDays, cities: debSorted.length };
}, [debSorted]);
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 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 totalDays = muSorted.reduce((s, c) => s + (c.mu?.total_days ?? 0), 0);
return { avgHit, avgMae, avgBrier, totalDays, cities: muSorted.length };
const avgDays = Math.round(muSorted.reduce((s, c) => s + (c.mu?.total_days ?? 0), 0) / Math.max(muSorted.length, 1));
return { avgHit, avgMae, avgBrier, avgDays, cities: muSorted.length };
}, [muSorted]);
const debHitChart = useMemo(
@@ -125,7 +148,7 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
{ icon: Hash, label: isEn ? "Cities" : "城市数", value: debStats.cities, tone: "blue" },
{ 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: 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 }) => (
<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]} />
+4
View File
@@ -844,6 +844,10 @@ export interface CityDetail {
times?: string[];
temps?: Array<number | null>;
};
models_hourly?: {
times?: string[];
curves?: Record<string, Array<number | null>>;
};
hourly_next_48h?: HourlySeries;
metar_recent_obs?: Array<{
time?: string;
+10
View File
@@ -181,6 +181,16 @@ def build_city_detail_payload(
for k, v in (data.get("multi_model") or {}).items()
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 {},
"multi_model_daily": data.get("multi_model_daily") or {},
"probabilities": data.get("probabilities") or {"mu": None, "distribution": []},