"use client"; import { useEffect, useMemo, useState } from "react"; import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { TrendingUp, Target, Thermometer, Hash, BarChart3, Crosshair } from "lucide-react"; import { buildDebRecentRankingRows } from "@/lib/deb-training-ranking"; type MetricPayload = { hit_rate: number; mae: number; total_days: number; brier_score?: number; }; type DebWindowSummary = { start_date?: string | null; end_date?: string | null; samples?: number; hits?: number; hit_rate?: number | null; mae?: number | null; bias?: number | null; city_count?: number; }; type DebHistoricalSummary = { city_count?: number; avg_hit_rate?: number | null; weighted_hit_rate?: number | null; avg_mae?: number | null; avg_days_per_city?: number; sample_days?: number; hits?: number; }; type DebUsableRecentSummary = { window?: "recent_7d" | "recent_14d" | string; city_count?: number; samples?: number; hits?: number; hit_rate?: number | null; avg_mae?: number | null; recommendations?: { primary?: number; supporting?: number; }; }; type DebVersionSummary = { version?: string; samples?: number; mae?: number | null; rmse?: number | null; bias?: number | null; bucket_hit_rate?: number | null; }; type DebSummaryPayload = { historical?: DebHistoricalSummary; usable_recent?: DebUsableRecentSummary; recent_7d?: DebWindowSummary; recent_14d?: DebWindowSummary; versions?: Record; }; type DebRecentStrategy = { recent_7d?: DebWindowSummary; recent_14d?: DebWindowSummary; trust_tier?: "high" | "medium" | "low" | "insufficient" | string; recommendation?: "primary" | "supporting" | "context_only" | "insufficient" | string; bias_direction?: "under" | "over" | "neutral" | "unknown" | string; reason?: string; }; type TrainingCity = { city_id: string; name: string; deb?: MetricPayload; deb_recent?: DebRecentStrategy | null; mu?: MetricPayload; }; type TrainingAccuracyPayload = { accuracy: TrainingCity[]; deb_summary?: DebSummaryPayload; }; const STAT_CARD_CLASSES: Record = { blue: "bg-blue-50 border-blue-200", emerald: "bg-emerald-50 border-emerald-200", amber: "bg-amber-50 border-amber-200", purple: "bg-purple-50 border-purple-200", }; const STAT_ICON_CLASSES: Record = { blue: "text-blue-600", emerald: "text-emerald-600", amber: "text-amber-600", purple: "text-purple-600", }; function barColor(hr: number) { if (hr >= 65) return "#059669"; if (hr >= 45) return "#d97706"; return "#dc2626"; } function trustBadgeClass(tier?: string) { if (tier === "high") return "border-emerald-200 bg-emerald-50 text-emerald-700"; if (tier === "medium") return "border-amber-200 bg-amber-50 text-amber-700"; if (tier === "low") return "border-rose-200 bg-rose-50 text-rose-700"; return "border-slate-200 bg-slate-50 text-slate-500"; } function trustLabel(tier: string | undefined, isEn: boolean) { if (tier === "high") return isEn ? "High" : "高"; if (tier === "medium") return isEn ? "Medium" : "中"; if (tier === "low") return isEn ? "Low" : "低"; return isEn ? "Thin" : "少"; } function recommendationLabel(value: string | undefined, isEn: boolean) { if (value === "primary") return isEn ? "Primary" : "主用"; if (value === "supporting") return isEn ? "Support" : "辅助"; if (value === "context_only") return isEn ? "Context" : "参考"; return isEn ? "Insufficient" : "样本少"; } const TRAINING_CACHE_KEY = "polyweather_training_accuracy_v1"; const TRAINING_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours function readTrainingCache(): TrainingAccuracyPayload | 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) { if (Array.isArray(cached.data)) return { accuracy: cached.data }; if (cached.data && Array.isArray(cached.data.accuracy)) return cached.data; } } catch { /* ignore */ } return null; } function writeTrainingCache(data: TrainingAccuracyPayload) { try { localStorage.setItem(TRAINING_CACHE_KEY, JSON.stringify({ ts: Date.now(), data })); } catch { /* ignore */ } } export function TrainingDashboard({ isEn }: { isEn: boolean }) { const [payload, setPayload] = useState(() => readTrainingCache()); useEffect(() => { let cancelled = false; fetch("/api/ops/training/accuracy", { cache: "no-store", headers: { Accept: "application/json" } }) .then(async (res) => { if (!res.ok) return null; return res.json() as Promise; }) .then((nextPayload) => { if (cancelled || !nextPayload?.accuracy) return; const filtered = nextPayload.accuracy.filter((c) => (c.deb || c.mu) && ((c.deb?.total_days ?? 0) + (c.mu?.total_days ?? 0)) >= 5); const next = { ...nextPayload, accuracy: filtered }; setPayload(next); writeTrainingCache(next); }) .catch(() => {}); return () => { cancelled = true; }; }, []); const data = payload?.accuracy ?? null; const debSummary = payload?.deb_summary; const debSorted = useMemo(() => (data || []).filter((c) => c.deb).sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0)), [data]); const debRecentRanked = useMemo(() => buildDebRecentRankingRows(data || []), [data]); const debRecentRankIndex = useMemo( () => new Map(debRecentRanked.map((row, index) => [row.cityId, index])), [debRecentRanked], ); const muSorted = useMemo(() => (data || []).filter((c) => c.mu).sort((a, b) => (b.mu?.hit_rate ?? 0) - (a.mu?.hit_rate ?? 0)), [data]); const debStats = useMemo(() => { 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 avgDays = Math.round(debSorted.reduce((s, c) => s + (c.deb?.total_days ?? 0), 0) / Math.max(debSorted.length, 1)); return { avgHit: debSummary?.historical?.avg_hit_rate ?? avgHit, avgMae: debSummary?.historical?.avg_mae ?? avgMae, avgDays: debSummary?.historical?.avg_days_per_city ?? avgDays, cities: debSummary?.historical?.city_count ?? debSorted.length, sampleDays: debSummary?.historical?.sample_days, weightedHit: debSummary?.historical?.weighted_hit_rate, usableRecent: debSummary?.usable_recent, }; }, [debSorted, debSummary]); const muStats = useMemo(() => { if (!muSorted.length) return null; 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 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( () => debRecentRanked.slice(0, 18).map((c) => ({ name: c.name, value: c.hitRate })), [debRecentRanked], ); const debMaeChart = useMemo( () => [...debRecentRanked] .sort((a, b) => { if (a.usableScore !== b.usableScore) return b.usableScore - a.usableScore; if (a.trustScore !== b.trustScore) return b.trustScore - a.trustScore; return a.mae - b.mae; }) .slice(0, 18) .map((c) => ({ name: c.name, value: c.mae })), [debRecentRanked], ); const muHitChart = useMemo( () => muSorted.slice(0, 18).map((c) => ({ name: c.name, value: Number((c.mu?.hit_rate ?? 0).toFixed(1)) })), [muSorted], ); const muBrierChart = useMemo( () => [...muSorted].sort((a, b) => (a.mu?.brier_score ?? 99) - (b.mu?.brier_score ?? 99)).slice(0, 18).map((c) => ({ name: c.name, value: Number((c.mu?.brier_score ?? 0).toFixed(3)) })), [muSorted], ); const debVersionRows = useMemo(() => { const versions = debSummary?.versions || {}; return [ { key: "deb_v1_raw", label: isEn ? "Raw DEB" : "原始 DEB" }, { key: "deb_v1_recent_bias_corrected", label: isEn ? "Mean Bias" : "均值偏差" }, { key: "deb_v2_bucket_calibrated", label: isEn ? "Bucket v2" : "桶校准 v2" }, { key: "deb_v3_guarded_calibrated", label: isEn ? "Guarded v3" : "保护 v3" }, ].map(({ key, label }) => ({ key, label, value: versions[key] })).filter((row) => row.value); }, [debSummary?.versions, isEn]); const formatPct = (value: number | null | undefined) => value == null ? "--" : `${value.toFixed(1)}%`; const formatMaybeDeg = (value: number | null | undefined, digits = 1) => value == null ? "--" : `${value.toFixed(digits)}°`; const usableWindowLabel = (window?: string) => { if (window === "recent_14d") return isEn ? "Usable 14d" : "可用近14天"; return isEn ? "Usable 7d" : "可用近7天"; }; return (

{isEn ? "Model Training Accuracy" : "模型训练准确率"}

{isEn ? "DEB temperature forecast vs. Probability Mu calibration — per-city backtesting metrics." : "DEB 气温预报 与 概率 μ 校准 — 各城市回测指标。"}

{/* ── DEB Section ── */} {debStats && ( <>

{isEn ? "DEB Temperature Forecast" : "DEB 气温预报"}

{[ { icon: Hash, label: isEn ? "Cities" : "城市数", value: debStats.cities, tone: "blue" }, { icon: Target, label: usableWindowLabel(debStats.usableRecent?.window), value: formatPct(debStats.usableRecent?.hit_rate), tone: "emerald" }, { icon: TrendingUp, label: isEn ? "Recent 7d" : "近7天", value: formatPct(debSummary?.recent_7d?.hit_rate), tone: "emerald" }, { icon: TrendingUp, label: isEn ? "Recent 14d" : "近14天", value: formatPct(debSummary?.recent_14d?.hit_rate), tone: "purple" }, { icon: Thermometer, label: isEn ? "Avg Error" : "平均误差", value: `${debStats.avgMae.toFixed(1)}°`, tone: "amber" }, { icon: Hash, label: isEn ? "Samples" : "样本天数", value: (debStats.sampleDays ?? debStats.avgDays).toLocaleString(), tone: "blue" }, ].map(({ icon: Icon, label, value, tone }) => (
{label}
{String(value)}
))}
{debVersionRows.length ? (
{debVersionRows.map((row) => { const bucketRate = row.value?.bucket_hit_rate == null ? null : row.value.bucket_hit_rate * 100; return (
{row.label} {formatPct(bucketRate)}
{isEn ? "MAE" : "误差"} {formatMaybeDeg(row.value?.mae, 2)} {isEn ? "Samples" : "样本"} {row.value?.samples ?? 0}
); })}
) : null}
`${v}%`} /> [`${Number(v)}%`, isEn ? "Hit Rate" : "命中率"]} /> `${v}°`} /> [`${Number(v)}°`, isEn ? "Error" : "误差"]} />
)} {/* ── Mu Section ── */} {muStats && ( <>

{isEn ? "Probability Mu Calibration" : "概率 μ 校准"}

{[ { icon: Hash, label: isEn ? "Cities" : "城市数", value: muStats.cities, tone: "blue" }, { icon: Target, label: isEn ? "Avg Hit" : "平均命中", value: `${muStats.avgHit.toFixed(1)}%`, tone: "emerald" }, { icon: Thermometer, label: isEn ? "Avg Error" : "平均误差", value: `${muStats.avgMae.toFixed(2)}°`, tone: "amber" }, { icon: Crosshair, label: isEn ? "Avg Brier" : "平均 Brier", value: muStats.avgBrier.toFixed(4), tone: "purple" }, ].map(({ icon: Icon, label, value, tone }) => (
{label}
{String(value)}
))}
`${v}%`} /> [`${Number(v)}%`, isEn ? "Hit Rate" : "命中率"]} /> `${Number(v).toFixed(2)}`} /> [`${Number(v).toFixed(4)}`, "Brier"]} />
)} {/* ── Combined Table ── */}
{debSorted.length || muSorted.length ? ( (() => { const cities = new Map(); for (const c of debSorted) cities.set(c.city_id, { deb: c.deb, debRecent: c.deb_recent, mu: c.mu, name: c.name }); for (const c of muSorted) { const existing = cities.get(c.city_id); if (existing) existing.mu = c.mu; else cities.set(c.city_id, { deb: c.deb, debRecent: c.deb_recent, mu: c.mu, name: c.name }); } const merged = [...cities.entries()] .sort((a, b) => { const aDebRank = debRecentRankIndex.get(a[0]) ?? 9999; const bDebRank = debRecentRankIndex.get(b[0]) ?? 9999; if (aDebRank !== bDebRank) return aDebRank - bDebRank; const aMax = Math.max(a[1].deb?.hit_rate ?? 0, a[1].mu?.hit_rate ?? 0); const bMax = Math.max(b[1].deb?.hit_rate ?? 0, b[1].mu?.hit_rate ?? 0); return bMax - aMax; }) .slice(0, 30); return merged.map(([cityId, { deb, debRecent, mu, name }], i) => { const debHit = deb?.hit_rate ?? 0; const muHit = mu?.hit_rate ?? 0; const brier = mu?.brier_score; const recent7 = debRecent?.recent_7d?.hit_rate; const recent14 = debRecent?.recent_14d?.hit_rate; return ( ); }); })() ) : ( )}
# {isEn ? "City" : "城市"} {isEn ? "DEB Trust" : "DEB 信任"} {isEn ? "7d" : "7天"} {isEn ? "14d" : "14天"} {isEn ? "DEB Hit" : "DEB 命中"} {isEn ? "DEB Error" : "DEB 误差"} {isEn ? "μ Hit" : "μ 命中"} Brier {isEn ? "Days" : "天数"}
{i + 1} {name} {debRecent ? ( {trustLabel(debRecent.trust_tier, isEn)} · {recommendationLabel(debRecent.recommendation, isEn)} ) : ( -- )} {recent7 == null ? "--" : `${recent7.toFixed(0)}%`} {recent14 == null ? "--" : `${recent14.toFixed(0)}%`} {deb ? `${debHit.toFixed(0)}%` : "--"} {deb ? `${deb.mae.toFixed(1)}°` : "--"} {mu ? `${muHit.toFixed(0)}%` : "--"} {brier != null ? brier.toFixed(4) : "--"} {(deb?.total_days ?? 0) + (mu?.total_days ?? 0)}
{data === null ? (isEn ? "Loading..." : "加载中...") : (isEn ? "No training data" : "暂无训练数据")}

{isEn ? "DEB = temperature forecast accuracy. μ = probability calibration. Brier = lower is better. Updated daily." : "DEB = 气温预报准确率。μ = 概率校准。Brier = 越低越好。每日更新。"}

); } function ChartCard({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); }