气温走势图:多模型逐小时预测曲线 + 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;
|
||||
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]} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": []},
|
||||
|
||||
Reference in New Issue
Block a user