From 02521d6fdb96c5f518e0a4b754009fa050e82d5d Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 29 Jun 2026 21:35:08 +0800 Subject: [PATCH] Show Gaussian probability buckets in model summary --- .../scan-terminal/ModelSummaryDashboard.tsx | 62 +++++++- .../__tests__/modelSummaryDashboard.test.ts | 33 ++++ frontend/lib/model-summary.ts | 147 +++++++++++++++++- 3 files changed, 237 insertions(+), 5 deletions(-) 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({
- +
- + {MODEL_SUMMARY_MODEL_COLUMNS.map((column) => ( @@ -277,11 +314,28 @@ export function ModelSummaryDashboard({ {copy("median", isEn)} + + {probabilityColumns.map((column) => ( + + ))} {visibleRows.map((row) => ( - + ))}
{copy("city", isEn)} {copy("region", isEn)}{copy("region", isEn)} {copy("localTime", isEn)} DEB {copy("spread", isEn)} + {copy("gaussianMu", isEn)} + + {column.label} +
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; + }); +}