diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 4a995493..157db067 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -101,6 +101,7 @@ type HourlyForecast = { localTime?: string | null; times: string[]; temps: Array; + modelCurves?: Record>; } | 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) })); diff --git a/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx b/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx index b9d1f512..9582d17e 100644 --- a/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx +++ b/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx @@ -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(null); + const [data, setData] = useState(() => 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 }) => (
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 8af86c60..41299a5e 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -844,6 +844,10 @@ export interface CityDetail { times?: string[]; temps?: Array; }; + models_hourly?: { + times?: string[]; + curves?: Record>; + }; hourly_next_48h?: HourlySeries; metar_recent_obs?: Array<{ time?: string; diff --git a/web/services/city_payloads.py b/web/services/city_payloads.py index f0819ec2..c48e4167 100644 --- a/web/services/city_payloads.py +++ b/web/services/city_payloads.py @@ -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": []},