feat: implement real-time observation patch normalization and live temperature threshold visualization logic

This commit is contained in:
2569718930@qq.com
2026-05-27 10:17:33 +08:00
parent e6a673e27d
commit 820dabfbf3
23 changed files with 709 additions and 29 deletions
@@ -5,6 +5,7 @@ import type {
ScanOpportunityRow,
ForecastDay,
DailyModelForecast,
ProbabilityBucket,
} from "@/lib/dashboard-types";
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
@@ -209,6 +210,35 @@ type EvidenceSeries = {
values: Array<number | null>;
};
type LegacyGaussianProbabilitySource = {
mu?: number | null;
engine?: string | null;
calibration_mode?: string | null;
distribution?: ProbabilityBucket[];
distribution_all?: ProbabilityBucket[];
};
type ProbabilityTemperatureBand = {
key: string;
value: number;
lower: number;
upper: number;
probability: number;
label: string;
opacity: number;
};
type ProbabilityMuLine = {
value: number;
label: string;
};
type ProbabilityOverlay = {
engine: string | null;
muLine: ProbabilityMuLine | null;
bands: ProbabilityTemperatureBand[];
};
type PeakGlowState = "none" | "watch" | "near_peak" | "breakout" | "cooling";
type PeakGlowMeta = {
@@ -674,6 +704,7 @@ type HourlyForecast = {
airportPrimary?: AirportCurrentConditions | null;
forecastDaily?: ForecastDay[];
multiModelDaily?: Record<string, DailyModelForecast>;
probabilities?: LegacyGaussianProbabilitySource | null;
settlementTodayObs?: ObsPoint[];
settlementStationLabel?: string | null;
metarTodayObs?: ObsPoint[];
@@ -697,6 +728,11 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
airportPrimary: null,
forecastDaily: [],
multiModelDaily: {},
probabilities: {
engine: row.probability_engine || null,
distribution: row.distribution_preview || [],
distribution_all: row.distribution_full || row.distribution_preview || [],
},
settlementTodayObs: row.settlement_today_obs || row.metar_context?.settlement_today_obs || undefined,
metarTodayObs: row.metar_today_obs || row.metar_context?.today_obs || row.metar_recent_obs || row.metar_context?.recent_obs || undefined,
airportPrimaryTodayObs: undefined,
@@ -726,6 +762,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
airportPrimary: json.airport_primary || null,
forecastDaily: json.forecast?.daily || [],
multiModelDaily: json.multi_model_daily || {},
probabilities: json.probabilities || null,
settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined,
settlementStationLabel: (json as any)?.settlement_station?.settlement_station_label || null,
metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined,
@@ -883,7 +920,8 @@ function mergePatchIntoHourly(
): HourlyForecast {
const changes = patch.changes || {};
const tempValue = validNumber(changes.temp);
const obsTime = typeof changes.obs_time === "string" ? changes.obs_time : null;
const observedAtUtc = typeof changes.observed_at_utc === "string" ? changes.observed_at_utc : null;
const obsTime = observedAtUtc || (typeof changes.obs_time === "string" ? changes.obs_time : null);
const source = typeof changes.source === "string" ? changes.source : "";
const explicitHourlyPatch = changes.hourly && typeof changes.hourly === "object"
? changes.hourly as Partial<NonNullable<HourlyForecast>>
@@ -899,6 +937,7 @@ function mergePatchIntoHourly(
temps: [],
forecastDaily: [],
multiModelDaily: {},
probabilities: null,
}),
...explicitHourlyPatch,
};
@@ -906,6 +945,9 @@ function mergePatchIntoHourly(
if (typeof (changes as any).local_date === "string") {
next.localDate = (changes as any).local_date;
}
if (typeof (changes as any).city_local_date === "string") {
next.localDate = (changes as any).city_local_date;
}
if (changes.amos && typeof changes.amos === "object") {
const oldAmos = prev?.amos || {};
@@ -976,7 +1018,7 @@ function mergePatchIntoHourly(
if (tempValue !== null) {
next.airportCurrent = {
...(next.airportCurrent || {}),
obs_time: next.airportCurrent?.obs_time ?? null,
obs_time: obsTime || next.airportCurrent?.obs_time || null,
temp: tempValue,
max_so_far: Math.max(
tempValue,
@@ -985,7 +1027,7 @@ function mergePatchIntoHourly(
};
next.airportPrimary = {
...(next.airportPrimary || {}),
obs_time: next.airportPrimary?.obs_time ?? null,
obs_time: obsTime || next.airportPrimary?.obs_time || null,
temp: tempValue,
max_so_far: Math.max(
tempValue,
@@ -1327,11 +1369,90 @@ function addHourlyTimesToTimeline(
});
}
function probabilityBucketValue(bucket: ProbabilityBucket) {
return validNumber(bucket.value ?? (bucket as any).temp ?? (bucket as any).temperature);
}
function probabilityBucketProbability(bucket: ProbabilityBucket) {
const raw = validNumber(bucket.probability ?? (bucket as any).model_probability);
if (raw === null) return null;
return raw > 1 ? raw / 100 : raw;
}
function probabilityBucketRange(bucket: ProbabilityBucket, value: number) {
const rawRange = String(bucket.range || bucket.bucket || "").trim();
const rangeMatch = rawRange.match(/(-?\d+(?:\.\d+)?)\s*~\s*(-?\d+(?:\.\d+)?)/);
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 buildLegacyGaussianProbabilityOverlay(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
): ProbabilityOverlay | null {
const source = hourly?.probabilities || null;
const rowBuckets = ((row as any)?.distribution_full || (row as any)?.distribution_preview || []) as ProbabilityBucket[];
const buckets = (
source?.distribution_all?.length
? source.distribution_all
: source?.distribution?.length
? source.distribution
: rowBuckets
) || [];
const engine = source?.engine || row?.probability_engine || (buckets.length ? "legacy" : null);
if (engine && String(engine).toLowerCase() !== "legacy") return null;
const tempSymbol = row?.temp_symbol || "°C";
const bands = buckets
.map((bucket, index) => {
const value = probabilityBucketValue(bucket);
const probability = probabilityBucketProbability(bucket);
if (value === null || probability === null || probability <= 0) return null;
const { lower, upper } = probabilityBucketRange(bucket, value);
return {
key: `legacy_probability_${value}_${index}`,
value,
lower,
upper,
probability,
label: `${value}${tempSymbol} ${Math.round(probability * 100)}%`,
opacity: Number(Math.min(0.16, Math.max(0.035, 0.04 + probability * 0.22)).toFixed(3)),
};
})
.filter((band): band is ProbabilityTemperatureBand => band !== null)
.sort((a, b) => a.value - b.value);
const mu = validNumber(source?.mu);
const muLine = mu === null
? null
: {
value: mu,
label: `Gaussian μ ${mu.toFixed(1)}${tempSymbol}`,
};
if (!bands.length && !muLine) return null;
return {
engine: engine || "legacy",
muLine,
bands,
};
}
function buildFullDayChartData(
row: ScanOpportunityRow | null,
hourly: HourlyForecast,
isEn: boolean,
): { data: Array<Record<string, any>>; series: EvidenceSeries[] } {
): { data: Array<Record<string, any>>; series: EvidenceSeries[]; probabilityOverlay: ProbabilityOverlay | null } {
const tzOffset = row?.tz_offset_seconds ?? 0;
const localDateStr = resolveChartLocalDate(row, hourly);
const localDayBounds = getLocalDayBounds(localDateStr);
@@ -1591,7 +1712,9 @@ function buildFullDayChartData(
return point;
});
return { data, series };
const probabilityOverlay = buildLegacyGaussianProbabilityOverlay(row, hourly);
return { data, series, probabilityOverlay };
}
// ── Model summary cards (daily high point predictions) ─────────────────
@@ -1613,13 +1736,27 @@ function buildModelSummaryCards(row: ScanOpportunityRow | null): EvidenceSeries[
// ── Integer-degree ticks for Y-axis ──────────────────────────────────
function buildIntDegreeTicks(series: EvidenceSeries[], data?: Array<Record<string, string | number | null>>): number[] | null {
function probabilityOverlayValues(probabilityOverlay?: ProbabilityOverlay | null) {
if (!probabilityOverlay) return [];
return [
...(probabilityOverlay.muLine ? [probabilityOverlay.muLine.value] : []),
...probabilityOverlay.bands.flatMap((band) => [band.lower, band.upper]),
];
}
function buildIntDegreeTicks(
series: EvidenceSeries[],
data?: Array<Record<string, string | number | null>>,
probabilityOverlay?: ProbabilityOverlay | null,
): number[] | null {
const vals = data?.length
? data.flatMap((point) => series.map((s) => point[s.key])).filter((v): v is number => validNumber(v) !== null)
: series.flatMap((s) => s.values).filter((v): v is number => validNumber(v) !== null);
if (!vals.length) return null;
const min = Math.floor(Math.min(...vals));
const max = Math.ceil(Math.max(...vals));
const overlayVals = probabilityOverlayValues(probabilityOverlay);
const allVals = [...vals, ...overlayVals];
if (!allVals.length) return null;
const min = Math.floor(Math.min(...allVals));
const max = Math.ceil(Math.max(...allVals));
const ticks: number[] = [];
for (let d = min; d <= max; d++) ticks.push(d);
return ticks.length > 0 ? ticks : null;
@@ -1628,13 +1765,16 @@ function buildIntDegreeTicks(series: EvidenceSeries[], data?: Array<Record<strin
function buildChartDomain(
series: EvidenceSeries[],
data?: Array<Record<string, string | number | null>>,
probabilityOverlay?: ProbabilityOverlay | null,
): [number, number] | ["auto", "auto"] {
const vals = data?.length
? data.flatMap((point) => series.map((s) => point[s.key])).filter((v): v is number => validNumber(v) !== null)
: series.flatMap((s) => s.values).filter((v): v is number => validNumber(v) !== null);
if (!vals.length) return ["auto", "auto"];
const min = Math.min(...vals);
const max = Math.max(...vals);
const overlayVals = probabilityOverlayValues(probabilityOverlay);
const allVals = [...vals, ...overlayVals];
if (!allVals.length) return ["auto", "auto"];
const min = Math.min(...allVals);
const max = Math.max(...allVals);
const span = Math.max(1, max - min);
const pad = Math.max(0.5, span * 0.08);
return [Number((min - pad).toFixed(1)), Number((max + pad).toFixed(1))];
@@ -1913,4 +2053,4 @@ export {
validNumber,
};
export type { EvidenceSeries, HourlyForecast, PeakGlowMeta, PeakGlowState };
export type { EvidenceSeries, HourlyForecast, PeakGlowMeta, PeakGlowState, ProbabilityOverlay };