feat: add hourly peak correction for DEB charts

This commit is contained in:
2569718930@qq.com
2026-05-27 22:41:16 +08:00
parent 14141314d7
commit 1a7d2a487f
8 changed files with 614 additions and 9 deletions
@@ -1,6 +1,7 @@
import { getTemperatureChartData } from "@/lib/chart-utils";
import type { CityDetail } from "@/lib/dashboard-types";
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
import { buildFullDayChartData } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
@@ -46,6 +47,65 @@ export function runTests() {
"temperature chart should keep normalized hourly temperatures shifted by offset",
);
const correctedHourlyPathChart = buildFullDayChartData(
{
city: "shanghai",
local_date: "2026-05-16",
local_time: "12:00",
temp_symbol: "°C",
deb_prediction: 30,
tz_offset_seconds: 8 * 3600,
} as any,
{
forecastTodayHigh: 29,
debPrediction: 30,
localDate: "2026-05-16",
localTime: "12:00",
times: ["10:00", "12:00", "14:00"],
temps: [24, 29, 25],
debHourlyPath: {
source: "deb_hourly_peak_corrected.v1",
times: ["10:00", "12:00", "14:00"],
temps: [25.1, 28.0, 26.0],
},
} as any,
true,
);
const correctedDebSeries = correctedHourlyPathChart.series.find((item) => item.key === "hourly_forecast");
const correctedDebValues = correctedHourlyPathChart.data
.map((item) => item.hourly_forecast)
.filter((value) => value !== null && value !== undefined);
assert(correctedDebSeries, "corrected hourly DEB path should still render as DEB Forecast");
assert(correctedDebValues.includes(28.0), `chart should use backend deb.hourly_path before rebuilding a shifted curve; got ${correctedDebValues.join(",")}`);
const correctedDetailChart = getTemperatureChartData(
{
name: "shanghai",
display_name: "Shanghai",
local_date: "2026-05-16",
local_time: "12:00",
temp_symbol: "°C",
hourly: {
times: ["10:00", "12:00", "14:00"],
temps: [24, 29, 25],
},
forecast: { today_high: 29 },
deb: {
prediction: 30,
hourly_path: {
source: "deb_hourly_peak_corrected.v1",
times: ["10:00", "12:00", "14:00"],
temps: [25.1, 28.0, 26.0],
},
},
} as CityDetail,
"zh-CN",
);
assert(
correctedDetailChart?.datasets.debSeries.some((point) => point.labelTime === "12:00" && point.y === 28.0),
"detail temperature chart should use backend deb.hourly_path before fallback baseline",
);
const ankaraChartData = getTemperatureChartData(
{
name: "ankara",
@@ -5,6 +5,7 @@ import type {
ScanOpportunityRow,
ForecastDay,
DailyModelForecast,
DebHourlyPath,
ProbabilityBucket,
} from "@/lib/dashboard-types";
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
@@ -735,6 +736,7 @@ function runwayPatchPointsFromRunwayObs(runwayObs: any) {
type HourlyForecast = {
forecastTodayHigh?: number | null;
debPrediction?: number | null;
debHourlyPath?: DebHourlyPath | null;
localDate?: string | null;
localTime?: string | null;
times: string[];
@@ -759,6 +761,7 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
return {
forecastTodayHigh: null,
debPrediction: validNumber(row.deb_prediction),
debHourlyPath: null,
localDate: row.local_date || null,
localTime: row.local_time || null,
times: [],
@@ -793,6 +796,7 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
return {
forecastTodayHigh: json.forecast?.today_high ?? null,
debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null,
debHourlyPath: json.deb?.hourly_path || null,
localDate: json.local_date || (json as any)?.overview?.local_date || null,
localTime: json.local_time || null,
times: hourlySource.times || [],
@@ -1591,16 +1595,27 @@ function buildFullDayChartData(
if (shouldRenderMetar) metarObs.forEach((point) => timelineSet.add(point.ts));
addLocalDayAxisSlots(timelineSet, localDayBounds);
let debPath: ReturnType<typeof buildDebBaselinePath> | null = null;
if (hourly?.times?.length && hourly?.temps?.length) {
debPath = buildDebBaselinePath(
const correctedDebPath = hourly?.debHourlyPath;
const correctedDebTimes = Array.isArray(correctedDebPath?.times) ? correctedDebPath?.times || [] : [];
const correctedDebTemps = Array.isArray(correctedDebPath?.temps) ? correctedDebPath?.temps || [] : [];
let debTimes: string[] = [];
let debTemps: Array<number | null | undefined> = [];
if (correctedDebTimes.length && correctedDebTemps.length) {
debTimes = correctedDebTimes;
debTemps = correctedDebTemps;
} else if (hourly?.times?.length && hourly?.temps?.length) {
const debPath = buildDebBaselinePath(
hourly.times,
hourly.temps,
validNumber(hourly?.debPrediction) ?? row?.deb_prediction,
hourly.localTime || row?.local_time,
hourly.forecastTodayHigh,
);
addHourlyTimesToTimeline(timelineSet, hourly.times, debPath.debTemps, tzOffset, localDateStr, localDayBounds);
debTimes = hourly.times;
debTemps = debPath.debTemps;
}
if (debTimes.length && debTemps.length) {
addHourlyTimesToTimeline(timelineSet, debTimes, debTemps, tzOffset, localDateStr, localDayBounds);
}
if (hourly?.times?.length && hourly?.modelCurves) {
Object.values(hourly.modelCurves).forEach((modelTemps) => {
@@ -1693,8 +1708,8 @@ function buildFullDayChartData(
}
// ── DEB forecast curve ──
if (hourly?.times?.length && debPath?.debTemps.length) {
const debVals = valuesForHourlyTimes(n, indexByTs, hourly.times, debPath.debTemps, tzOffset, localDateStr, localDayBounds);
if (debTimes.length && debTemps.length) {
const debVals = valuesForHourlyTimes(n, indexByTs, debTimes, debTemps, tzOffset, localDateStr, localDayBounds);
if (debVals.some((v) => v !== null)) {
series.push({
key: "hourly_forecast",
@@ -1708,7 +1723,7 @@ function buildFullDayChartData(
}
// Per-model curves
if (hourly.modelCurves) {
if (hourly?.times?.length && hourly.modelCurves) {
const modelColors = ["#2563eb", "#7c3aed", "#059669", "#d97706", "#dc2626", "#0891b2"];
Object.keys(hourly.modelCurves).forEach((model, idx) => {
const modelTemps = hourly.modelCurves![model];
@@ -1795,7 +1810,6 @@ function probabilityOverlayValues(probabilityOverlay?: ProbabilityOverlay | null
...probabilityOverlay.bands.flatMap((band) => [band.lower, band.upper]),
];
}
function buildIntDegreeTicks(
series: EvidenceSeries[],
data?: Array<Record<string, string | number | null>>,