Show Gaussian probability buckets in model summary
This commit is contained in:
@@ -6,11 +6,14 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||||
import {
|
import {
|
||||||
MODEL_SUMMARY_MODEL_COLUMNS,
|
MODEL_SUMMARY_MODEL_COLUMNS,
|
||||||
|
buildModelSummaryProbabilityColumns,
|
||||||
buildModelSummaryRows,
|
buildModelSummaryRows,
|
||||||
filterModelSummaryRows,
|
filterModelSummaryRows,
|
||||||
|
formatModelSummaryProbability,
|
||||||
formatModelSummaryLocalTime,
|
formatModelSummaryLocalTime,
|
||||||
formatModelSummaryTemp,
|
formatModelSummaryTemp,
|
||||||
hasModelSummaryForecastData,
|
hasModelSummaryForecastData,
|
||||||
|
type ModelSummaryProbabilityColumn,
|
||||||
type ModelSummaryRow,
|
type ModelSummaryRow,
|
||||||
} from "@/lib/model-summary";
|
} from "@/lib/model-summary";
|
||||||
|
|
||||||
@@ -30,6 +33,7 @@ const SUMMARY_TEXT = {
|
|||||||
city: { en: "City", zh: "城市" },
|
city: { en: "City", zh: "城市" },
|
||||||
region: { en: "Region", zh: "区域" },
|
region: { en: "Region", zh: "区域" },
|
||||||
localTime: { en: "Local Time", zh: "当地时间" },
|
localTime: { en: "Local Time", zh: "当地时间" },
|
||||||
|
gaussianMu: { en: "Gaussian μ", zh: "高斯 μ" },
|
||||||
median: { en: "Median", zh: "模型中位数" },
|
median: { en: "Median", zh: "模型中位数" },
|
||||||
spread: { en: "Spread", zh: "分歧范围" },
|
spread: { en: "Spread", zh: "分歧范围" },
|
||||||
empty: { en: "No model summary rows match the current filters.", zh: "当前筛选下没有模型汇总数据。" },
|
empty: { en: "No model summary rows match the current filters.", zh: "当前筛选下没有模型汇总数据。" },
|
||||||
@@ -97,9 +101,11 @@ function FilterToggle({
|
|||||||
function ModelSummaryRowView({
|
function ModelSummaryRowView({
|
||||||
row,
|
row,
|
||||||
nowMs,
|
nowMs,
|
||||||
|
probabilityColumns,
|
||||||
}: {
|
}: {
|
||||||
row: ModelSummaryRow;
|
row: ModelSummaryRow;
|
||||||
nowMs: number | null;
|
nowMs: number | null;
|
||||||
|
probabilityColumns: ModelSummaryProbabilityColumn[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<tr className="group border-b border-slate-100 hover:bg-blue-50/40">
|
<tr className="group border-b border-slate-100 hover:bg-blue-50/40">
|
||||||
@@ -109,7 +115,7 @@ function ModelSummaryRowView({
|
|||||||
>
|
>
|
||||||
<span className="block truncate">{row.cityName}</span>
|
<span className="block truncate">{row.cityName}</span>
|
||||||
</th>
|
</th>
|
||||||
<td className="min-w-[150px] px-3 py-2 text-xs font-semibold text-slate-600">
|
<td className="min-w-[96px] max-w-[112px] px-2 py-2 text-xs font-semibold text-slate-600">
|
||||||
<span className="block truncate">{row.regionLabel}</span>
|
<span className="block truncate">{row.regionLabel}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="min-w-[96px] px-3 py-2 text-right font-mono text-[11px] font-bold text-slate-700">
|
<td className="min-w-[96px] px-3 py-2 text-right font-mono text-[11px] font-bold text-slate-700">
|
||||||
@@ -129,6 +135,29 @@ function ModelSummaryRowView({
|
|||||||
<td className="min-w-[100px] px-3 py-2 text-right">
|
<td className="min-w-[100px] px-3 py-2 text-right">
|
||||||
<TemperatureCell value={row.modelSpread} symbol={row.tempSymbol} />
|
<TemperatureCell value={row.modelSpread} symbol={row.tempSymbol} />
|
||||||
</td>
|
</td>
|
||||||
|
<td className="min-w-[92px] px-3 py-2 text-right">
|
||||||
|
<TemperatureCell value={row.gaussianMu} symbol={row.tempSymbol} emphasis="median" />
|
||||||
|
</td>
|
||||||
|
{probabilityColumns.map((column) => {
|
||||||
|
const bucket = row.probabilityBucketMap[column.key] || null;
|
||||||
|
const isTopBucket = bucket?.key === row.topProbabilityBucketKey;
|
||||||
|
return (
|
||||||
|
<td key={column.key} className="min-w-[86px] px-2 py-2 text-right">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"inline-flex min-w-[42px] justify-end rounded px-1.5 py-0.5 font-mono tabular-nums",
|
||||||
|
bucket == null
|
||||||
|
? "font-semibold text-slate-300"
|
||||||
|
: isTopBucket
|
||||||
|
? "bg-violet-50 font-black text-violet-800 ring-1 ring-violet-200"
|
||||||
|
: "font-bold text-violet-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatModelSummaryProbability(bucket?.probability)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -204,6 +233,11 @@ export function ModelSummaryDashboard({
|
|||||||
}
|
}
|
||||||
}, [incomingSummaryRows, incomingHasForecastData]);
|
}, [incomingSummaryRows, incomingHasForecastData]);
|
||||||
|
|
||||||
|
const probabilityColumns = useMemo(
|
||||||
|
() => buildModelSummaryProbabilityColumns(summaryRows),
|
||||||
|
[summaryRows],
|
||||||
|
);
|
||||||
|
|
||||||
const visibleRows = useMemo(
|
const visibleRows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
filterModelSummaryRows(summaryRows, {
|
filterModelSummaryRows(summaryRows, {
|
||||||
@@ -259,13 +293,16 @@ export function ModelSummaryDashboard({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto">
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
<table className="w-full min-w-[1460px] border-collapse text-xs">
|
<table
|
||||||
|
className="w-full border-collapse text-xs"
|
||||||
|
style={{ minWidth: `${1560 + probabilityColumns.length * 86}px` }}
|
||||||
|
>
|
||||||
<thead className="sticky top-0 z-20 bg-slate-50 text-[10px] font-black uppercase tracking-wide text-slate-500 shadow-[0_1px_0_0_#e2e8f0]">
|
<thead className="sticky top-0 z-20 bg-slate-50 text-[10px] font-black uppercase tracking-wide text-slate-500 shadow-[0_1px_0_0_#e2e8f0]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="sticky left-0 z-30 w-[160px] min-w-[160px] border-r border-slate-200 bg-slate-50 px-3 py-2 text-left">
|
<th className="sticky left-0 z-30 w-[160px] min-w-[160px] border-r border-slate-200 bg-slate-50 px-3 py-2 text-left">
|
||||||
{copy("city", isEn)}
|
{copy("city", isEn)}
|
||||||
</th>
|
</th>
|
||||||
<th className="min-w-[150px] px-3 py-2 text-left">{copy("region", isEn)}</th>
|
<th className="min-w-[96px] max-w-[112px] px-2 py-2 text-left">{copy("region", isEn)}</th>
|
||||||
<th className="min-w-[96px] px-3 py-2 text-right">{copy("localTime", isEn)}</th>
|
<th className="min-w-[96px] px-3 py-2 text-right">{copy("localTime", isEn)}</th>
|
||||||
<th className="min-w-[90px] px-3 py-2 text-right text-orange-600">DEB</th>
|
<th className="min-w-[90px] px-3 py-2 text-right text-orange-600">DEB</th>
|
||||||
{MODEL_SUMMARY_MODEL_COLUMNS.map((column) => (
|
{MODEL_SUMMARY_MODEL_COLUMNS.map((column) => (
|
||||||
@@ -277,11 +314,28 @@ export function ModelSummaryDashboard({
|
|||||||
{copy("median", isEn)}
|
{copy("median", isEn)}
|
||||||
</th>
|
</th>
|
||||||
<th className="min-w-[100px] px-3 py-2 text-right">{copy("spread", isEn)}</th>
|
<th className="min-w-[100px] px-3 py-2 text-right">{copy("spread", isEn)}</th>
|
||||||
|
<th className="min-w-[92px] px-3 py-2 text-right text-violet-700">
|
||||||
|
{copy("gaussianMu", isEn)}
|
||||||
|
</th>
|
||||||
|
{probabilityColumns.map((column) => (
|
||||||
|
<th
|
||||||
|
key={column.key}
|
||||||
|
className="min-w-[86px] px-2 py-2 text-right text-violet-700"
|
||||||
|
title={column.label}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100 bg-white">
|
<tbody className="divide-y divide-slate-100 bg-white">
|
||||||
{visibleRows.map((row) => (
|
{visibleRows.map((row) => (
|
||||||
<ModelSummaryRowView key={row.cityKey} row={row} nowMs={nowMs} />
|
<ModelSummaryRowView
|
||||||
|
key={row.cityKey}
|
||||||
|
row={row}
|
||||||
|
nowMs={nowMs}
|
||||||
|
probabilityColumns={probabilityColumns}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
MODEL_SUMMARY_MODEL_COLUMNS,
|
MODEL_SUMMARY_MODEL_COLUMNS,
|
||||||
|
buildModelSummaryProbabilityColumns,
|
||||||
buildModelSummaryRows,
|
buildModelSummaryRows,
|
||||||
filterModelSummaryRows,
|
filterModelSummaryRows,
|
||||||
|
formatModelSummaryProbability,
|
||||||
formatModelSummaryLocalTime,
|
formatModelSummaryLocalTime,
|
||||||
formatModelSummaryTemp,
|
formatModelSummaryTemp,
|
||||||
hasModelSummaryForecastData,
|
hasModelSummaryForecastData,
|
||||||
@@ -52,6 +54,12 @@ export function runTests() {
|
|||||||
JMA: 30.9,
|
JMA: 30.9,
|
||||||
"AROME HD": 32.1,
|
"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",
|
city: "madrid",
|
||||||
@@ -67,6 +75,10 @@ export function runTests() {
|
|||||||
ECMWF: 37,
|
ECMWF: 37,
|
||||||
GFS: 38.2,
|
GFS: 38.2,
|
||||||
},
|
},
|
||||||
|
distribution_preview: [
|
||||||
|
{ value: 35, probability: 0.18 },
|
||||||
|
{ value: 36, probability: 0.52 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
city: "amsterdam",
|
city: "amsterdam",
|
||||||
@@ -112,6 +124,22 @@ export function runTests() {
|
|||||||
assert(parisRow.models.HRRR === null, "missing models should be normalized to null");
|
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.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.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(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(formatModelSummaryTemp(32.16, "°C") === "32.2°C", "model temperatures should render to one decimal");
|
||||||
assert(hasModelSummaryForecastData(summaryRows), "model summary should recognize populated forecast rows");
|
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("MODEL_SUMMARY_MODEL_COLUMNS") &&
|
||||||
modelSummarySource.includes("lastGoodSummaryRowsRef") &&
|
modelSummarySource.includes("lastGoodSummaryRowsRef") &&
|
||||||
modelSummarySource.includes("hasModelSummaryForecastData") &&
|
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("Local Time") &&
|
||||||
modelSummarySource.includes("当地时间") &&
|
modelSummarySource.includes("当地时间") &&
|
||||||
!modelSummarySource.includes("Current High") &&
|
!modelSummarySource.includes("Current High") &&
|
||||||
|
|||||||
@@ -20,6 +20,23 @@ export const MODEL_SUMMARY_MODEL_COLUMNS = [
|
|||||||
|
|
||||||
export type ModelSummaryColumnKey = (typeof MODEL_SUMMARY_MODEL_COLUMNS)[number]["key"];
|
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 = {
|
export type ModelSummaryRow = {
|
||||||
cityKey: string;
|
cityKey: string;
|
||||||
cityName: string;
|
cityName: string;
|
||||||
@@ -33,6 +50,11 @@ export type ModelSummaryRow = {
|
|||||||
models: Record<ModelSummaryColumnKey, number | null>;
|
models: Record<ModelSummaryColumnKey, number | null>;
|
||||||
modelMedian: number | null;
|
modelMedian: number | null;
|
||||||
modelSpread: number | null;
|
modelSpread: number | null;
|
||||||
|
probabilityBuckets: ModelSummaryProbabilityBucket[];
|
||||||
|
probabilityBucketMap: Record<string, ModelSummaryProbabilityBucket>;
|
||||||
|
gaussianMu: number | null;
|
||||||
|
probabilityEngine: string | null;
|
||||||
|
topProbabilityBucketKey: string | null;
|
||||||
searchText: string;
|
searchText: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,6 +90,80 @@ function spread(values: number[]) {
|
|||||||
return roundToOneDecimal(Math.max(...values) - Math.min(...values));
|
return roundToOneDecimal(Math.max(...values) - Math.min(...values));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function probabilityFromBucket(bucket: Record<string, unknown>) {
|
||||||
|
const raw = finiteNumber(bucket.probability ?? bucket.model_probability);
|
||||||
|
if (raw == null) return null;
|
||||||
|
return raw > 1 ? raw / 100 : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeFromBucket(bucket: Record<string, unknown>, 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<Record<string, unknown>>;
|
||||||
|
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) {
|
function normalizeCityKey(row: ScanOpportunityRow, index: number) {
|
||||||
const rawKey = row.city || row.city_display_name || row.display_name || `row-${index}`;
|
const rawKey = row.city || row.city_display_name || row.display_name || `row-${index}`;
|
||||||
return String(rawKey).trim().toLowerCase();
|
return String(rawKey).trim().toLowerCase();
|
||||||
@@ -111,6 +207,12 @@ export function formatModelSummaryTemp(value: number | null | undefined, symbol
|
|||||||
return `${numericValue.toFixed(1)}${symbol || "°C"}`;
|
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(
|
export function formatModelSummaryLocalTime(
|
||||||
row: Pick<ModelSummaryRow, "localTime" | "timezoneOffsetSeconds">,
|
row: Pick<ModelSummaryRow, "localTime" | "timezoneOffsetSeconds">,
|
||||||
nowMs: number | null | undefined = Date.now(),
|
nowMs: number | null | undefined = Date.now(),
|
||||||
@@ -152,6 +254,19 @@ export function buildModelSummaryRows(
|
|||||||
)
|
)
|
||||||
.map((column) => column.label)
|
.map((column) => column.label)
|
||||||
.join(" ");
|
.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, {
|
byCity.set(cityKey, {
|
||||||
cityKey,
|
cityKey,
|
||||||
@@ -166,7 +281,13 @@ export function buildModelSummaryRows(
|
|||||||
models,
|
models,
|
||||||
modelMedian: median(modelValues),
|
modelMedian: median(modelValues),
|
||||||
modelSpread: spread(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);
|
return MODEL_SUMMARY_MODEL_COLUMNS.some((column) => row.models[column.key] != null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildModelSummaryProbabilityColumns(
|
||||||
|
rows: ModelSummaryRow[],
|
||||||
|
): ModelSummaryProbabilityColumn[] {
|
||||||
|
const byKey = new Map<string, ModelSummaryProbabilityColumn>();
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user