diff --git a/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx b/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx
index aecf4884..d7563e20 100644
--- a/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx
+++ b/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx
@@ -6,11 +6,14 @@ import { useEffect, useMemo, useRef, useState } from "react";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import {
MODEL_SUMMARY_MODEL_COLUMNS,
+ buildModelSummaryProbabilityColumns,
buildModelSummaryRows,
filterModelSummaryRows,
+ formatModelSummaryProbability,
formatModelSummaryLocalTime,
formatModelSummaryTemp,
hasModelSummaryForecastData,
+ type ModelSummaryProbabilityColumn,
type ModelSummaryRow,
} from "@/lib/model-summary";
@@ -30,6 +33,7 @@ const SUMMARY_TEXT = {
city: { en: "City", zh: "城市" },
region: { en: "Region", zh: "区域" },
localTime: { en: "Local Time", zh: "当地时间" },
+ gaussianMu: { en: "Gaussian μ", zh: "高斯 μ" },
median: { en: "Median", zh: "模型中位数" },
spread: { en: "Spread", zh: "分歧范围" },
empty: { en: "No model summary rows match the current filters.", zh: "当前筛选下没有模型汇总数据。" },
@@ -97,9 +101,11 @@ function FilterToggle({
function ModelSummaryRowView({
row,
nowMs,
+ probabilityColumns,
}: {
row: ModelSummaryRow;
nowMs: number | null;
+ probabilityColumns: ModelSummaryProbabilityColumn[];
}) {
return (
@@ -109,7 +115,7 @@ function ModelSummaryRowView({
>
{row.cityName}
- |
+ |
{row.regionLabel}
|
@@ -129,6 +135,29 @@ function ModelSummaryRowView({
|
|
+
+
+ |
+ {probabilityColumns.map((column) => {
+ const bucket = row.probabilityBucketMap[column.key] || null;
+ const isTopBucket = bucket?.key === row.topProbabilityBucketKey;
+ return (
+
+
+ {formatModelSummaryProbability(bucket?.probability)}
+
+ |
+ );
+ })}
);
}
@@ -204,6 +233,11 @@ export function ModelSummaryDashboard({
}
}, [incomingSummaryRows, incomingHasForecastData]);
+ const probabilityColumns = useMemo(
+ () => buildModelSummaryProbabilityColumns(summaryRows),
+ [summaryRows],
+ );
+
const visibleRows = useMemo(
() =>
filterModelSummaryRows(summaryRows, {
@@ -259,13 +293,16 @@ export function ModelSummaryDashboard({
-
+
|
{copy("city", isEn)}
|
- {copy("region", isEn)} |
+ {copy("region", isEn)} |
{copy("localTime", isEn)} |
DEB |
{MODEL_SUMMARY_MODEL_COLUMNS.map((column) => (
@@ -277,11 +314,28 @@ export function ModelSummaryDashboard({
{copy("median", isEn)}
{copy("spread", isEn)} |
+
+ {copy("gaussianMu", isEn)}
+ |
+ {probabilityColumns.map((column) => (
+
+ {column.label}
+ |
+ ))}
{visibleRows.map((row) => (
-
+
))}
diff --git a/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts
index 720e398a..2e0aca9e 100644
--- a/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts
+++ b/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts
@@ -2,8 +2,10 @@ import fs from "node:fs";
import path from "node:path";
import {
MODEL_SUMMARY_MODEL_COLUMNS,
+ buildModelSummaryProbabilityColumns,
buildModelSummaryRows,
filterModelSummaryRows,
+ formatModelSummaryProbability,
formatModelSummaryLocalTime,
formatModelSummaryTemp,
hasModelSummaryForecastData,
@@ -52,6 +54,12 @@ export function runTests() {
JMA: 30.9,
"AROME HD": 32.1,
},
+ distribution_full: [
+ { value: 31, model_probability: 0.16, range: "[30.5~31.5)" },
+ { value: 32, model_probability: 0.42, range: "[31.5~32.5)" },
+ { value: 33, model_probability: 0.31, range: "[32.5~33.5)" },
+ ],
+ probability_engine: "legacy",
},
{
city: "madrid",
@@ -67,6 +75,10 @@ export function runTests() {
ECMWF: 37,
GFS: 38.2,
},
+ distribution_preview: [
+ { value: 35, probability: 0.18 },
+ { value: 36, probability: 0.52 },
+ ],
},
{
city: "amsterdam",
@@ -112,6 +124,22 @@ export function runTests() {
assert(parisRow.models.HRRR === null, "missing models should be normalized to null");
assert(parisRow.modelMedian === 32.1, "model median should use available model values only");
assert(parisRow.modelSpread === 2.5, "model spread should use available model min/max only");
+ assert(parisRow.gaussianMu === 32.2, "model summary should compute Gaussian mu from probability buckets");
+ assert(parisRow.probabilityEngine === "legacy", "model summary should preserve probability engine metadata");
+ assert(parisRow.probabilityBuckets.length === 3, "model summary should keep every probability bucket");
+ assert(
+ parisRow.probabilityBucketMap["31.5-32.5°C"]?.probability === 0.42 &&
+ parisRow.topProbabilityBucketKey === "31.5-32.5°C",
+ "model summary should map probability buckets and identify the top bucket",
+ );
+ const probabilityColumns = buildModelSummaryProbabilityColumns(summaryRows);
+ assert(
+ probabilityColumns.map((column) => column.key).join("|") ===
+ "30.5-31.5°C|31.5-32.5°C|32.5-33.5°C|34.5-35.5°C|35.5-36.5°C",
+ "model summary should expose dynamic probability bucket columns sorted by temperature",
+ );
+ assert(formatModelSummaryProbability(null) === "—", "missing probability should render as an em dash");
+ assert(formatModelSummaryProbability(0.424) === "42%", "probability buckets should render as rounded percentages");
assert(formatModelSummaryTemp(null, "°C") === "—", "missing model temperatures should render as an em dash");
assert(formatModelSummaryTemp(32.16, "°C") === "32.2°C", "model temperatures should render to one decimal");
assert(hasModelSummaryForecastData(summaryRows), "model summary should recognize populated forecast rows");
@@ -176,6 +204,11 @@ export function runTests() {
modelSummarySource.includes("MODEL_SUMMARY_MODEL_COLUMNS") &&
modelSummarySource.includes("lastGoodSummaryRowsRef") &&
modelSummarySource.includes("hasModelSummaryForecastData") &&
+ modelSummarySource.includes("buildModelSummaryProbabilityColumns") &&
+ modelSummarySource.includes("Gaussian μ") &&
+ modelSummarySource.includes("高斯 μ") &&
+ modelSummarySource.includes("topProbabilityBucketKey") &&
+ modelSummarySource.includes("min-w-[96px]") &&
modelSummarySource.includes("Local Time") &&
modelSummarySource.includes("当地时间") &&
!modelSummarySource.includes("Current High") &&
diff --git a/frontend/lib/model-summary.ts b/frontend/lib/model-summary.ts
index 330df1aa..0b2216aa 100644
--- a/frontend/lib/model-summary.ts
+++ b/frontend/lib/model-summary.ts
@@ -20,6 +20,23 @@ export const MODEL_SUMMARY_MODEL_COLUMNS = [
export type ModelSummaryColumnKey = (typeof MODEL_SUMMARY_MODEL_COLUMNS)[number]["key"];
+export type ModelSummaryProbabilityBucket = {
+ key: string;
+ label: string;
+ value: number;
+ lower: number;
+ upper: number;
+ probability: number;
+};
+
+export type ModelSummaryProbabilityColumn = {
+ key: string;
+ label: string;
+ lower: number;
+ upper: number;
+ unit: string;
+};
+
export type ModelSummaryRow = {
cityKey: string;
cityName: string;
@@ -33,6 +50,11 @@ export type ModelSummaryRow = {
models: Record;
modelMedian: number | null;
modelSpread: number | null;
+ probabilityBuckets: ModelSummaryProbabilityBucket[];
+ probabilityBucketMap: Record;
+ gaussianMu: number | null;
+ probabilityEngine: string | null;
+ topProbabilityBucketKey: string | null;
searchText: string;
};
@@ -68,6 +90,80 @@ function spread(values: number[]) {
return roundToOneDecimal(Math.max(...values) - Math.min(...values));
}
+function probabilityFromBucket(bucket: Record) {
+ const raw = finiteNumber(bucket.probability ?? bucket.model_probability);
+ if (raw == null) return null;
+ return raw > 1 ? raw / 100 : raw;
+}
+
+function rangeFromBucket(bucket: Record, value: number) {
+ const rawRange = String(bucket.range || bucket.bucket || bucket.label || "").trim();
+ const rangeMatch = rawRange.match(/(-?\d+(?:\.\d+)?)\s*(?:~|-|to)\s*(-?\d+(?:\.\d+)?)/i);
+ if (rangeMatch) {
+ const lower = Number(rangeMatch[1]);
+ const upper = Number(rangeMatch[2]);
+ if (Number.isFinite(lower) && Number.isFinite(upper) && upper > lower) {
+ return { lower, upper };
+ }
+ }
+ return {
+ lower: Number((value - 0.5).toFixed(2)),
+ upper: Number((value + 0.5).toFixed(2)),
+ };
+}
+
+function formatBucketBound(value: number) {
+ return Number(value.toFixed(1)).toString();
+}
+
+function probabilityBucketKey(lower: number, upper: number, unit: string) {
+ return `${formatBucketBound(lower)}-${formatBucketBound(upper)}${unit || "°C"}`;
+}
+
+function probabilityBucketLabel(lower: number, upper: number, unit: string) {
+ return probabilityBucketKey(lower, upper, unit);
+}
+
+function buildProbabilityBuckets(row: ScanOpportunityRow): ModelSummaryProbabilityBucket[] {
+ const rawBuckets = (
+ Array.isArray(row.distribution_full) && row.distribution_full.length
+ ? row.distribution_full
+ : Array.isArray(row.distribution_preview)
+ ? row.distribution_preview
+ : []
+ ) as Array>;
+ const unit = row.temp_symbol || "°C";
+
+ return rawBuckets
+ .map((bucket) => {
+ const value = finiteNumber(bucket.value ?? bucket.temp ?? bucket.temperature);
+ const probability = probabilityFromBucket(bucket);
+ if (value == null || probability == null || probability <= 0) return null;
+ const { lower, upper } = rangeFromBucket(bucket, value);
+ const key = probabilityBucketKey(lower, upper, unit);
+ return {
+ key,
+ label: probabilityBucketLabel(lower, upper, unit),
+ value,
+ lower,
+ upper,
+ probability,
+ };
+ })
+ .filter((bucket): bucket is ModelSummaryProbabilityBucket => bucket !== null)
+ .sort((a, b) => a.lower - b.lower || a.upper - b.upper);
+}
+
+function weightedProbabilityMu(buckets: ModelSummaryProbabilityBucket[]) {
+ const totalProbability = buckets.reduce((sum, bucket) => sum + bucket.probability, 0);
+ if (totalProbability <= 0) return null;
+ const weightedValue = buckets.reduce(
+ (sum, bucket) => sum + bucket.value * bucket.probability,
+ 0,
+ );
+ return roundToOneDecimal(weightedValue / totalProbability);
+}
+
function normalizeCityKey(row: ScanOpportunityRow, index: number) {
const rawKey = row.city || row.city_display_name || row.display_name || `row-${index}`;
return String(rawKey).trim().toLowerCase();
@@ -111,6 +207,12 @@ export function formatModelSummaryTemp(value: number | null | undefined, symbol
return `${numericValue.toFixed(1)}${symbol || "°C"}`;
}
+export function formatModelSummaryProbability(value: number | null | undefined) {
+ const numericValue = finiteNumber(value);
+ if (numericValue == null) return "—";
+ return `${Math.round((numericValue > 1 ? numericValue / 100 : numericValue) * 100)}%`;
+}
+
export function formatModelSummaryLocalTime(
row: Pick,
nowMs: number | null | undefined = Date.now(),
@@ -152,6 +254,19 @@ export function buildModelSummaryRows(
)
.map((column) => column.label)
.join(" ");
+ const probabilityBuckets = buildProbabilityBuckets(row);
+ const probabilityBucketMap = Object.fromEntries(
+ probabilityBuckets.map((bucket) => [bucket.key, bucket]),
+ );
+ const topProbabilityBucket =
+ probabilityBuckets.length > 0
+ ? probabilityBuckets.reduce((best, bucket) =>
+ bucket.probability > best.probability ? bucket : best,
+ )
+ : null;
+ const probabilitySearchText = probabilityBuckets
+ .map((bucket) => `${bucket.label} ${formatModelSummaryProbability(bucket.probability)}`)
+ .join(" ");
byCity.set(cityKey, {
cityKey,
@@ -166,7 +281,13 @@ export function buildModelSummaryRows(
models,
modelMedian: median(modelValues),
modelSpread: spread(modelValues),
- searchText: `${cityName} ${row.city || ""} ${region.labelEn} ${region.labelZh} ${modelSearchText}`.toLowerCase(),
+ probabilityBuckets,
+ probabilityBucketMap,
+ gaussianMu: weightedProbabilityMu(probabilityBuckets),
+ probabilityEngine: row.probability_engine || (probabilityBuckets.length ? "legacy" : null),
+ topProbabilityBucketKey: topProbabilityBucket?.key || null,
+ searchText:
+ `${cityName} ${row.city || ""} ${region.labelEn} ${region.labelZh} ${modelSearchText} ${probabilitySearchText}`.toLowerCase(),
});
});
@@ -203,3 +324,27 @@ export function hasModelSummaryForecastData(rows: ModelSummaryRow[]) {
return MODEL_SUMMARY_MODEL_COLUMNS.some((column) => row.models[column.key] != null);
});
}
+
+export function buildModelSummaryProbabilityColumns(
+ rows: ModelSummaryRow[],
+): ModelSummaryProbabilityColumn[] {
+ const byKey = new Map();
+ rows.forEach((row) => {
+ row.probabilityBuckets.forEach((bucket) => {
+ if (byKey.has(bucket.key)) return;
+ const unitMatch = bucket.key.match(/[^\d.\-\s]+$/);
+ byKey.set(bucket.key, {
+ key: bucket.key,
+ label: bucket.label,
+ lower: bucket.lower,
+ upper: bucket.upper,
+ unit: unitMatch?.[0] || row.tempSymbol || "°C",
+ });
+ });
+ });
+
+ return [...byKey.values()].sort((a, b) => {
+ if (a.unit !== b.unit) return a.unit.localeCompare(b.unit);
+ return a.lower - b.lower || a.upper - b.upper;
+ });
+}