重构温度曲线图表数据模块,修复 DEB offset 基准
- 新建 temperature-chart-paths.ts:抽取 8 个纯函数(buildChartTimeAxis、 buildDebBaselinePath、buildCalibratedPath、buildObservationGrid 等) - DEB offset 基准改为优先用 hourly 曲线自身 max,forecast.today_high 降级为 fallback,防止不可靠的 today_high 整体抬升/压低曲线 - chart-utils.ts 精简 ~280 行,清除重写的 normalizeTafHm/chartHmToMinutes - temperatureChartData.test.ts 新增 Moscow/Ankara/正常城市 3 个测试场景
This commit is contained in:
@@ -89,8 +89,8 @@ export const AiCityTemperatureChart = memo(function AiCityTemperatureChart({ det
|
||||
const forecastLabel = locale === "en-US" ? "DEB baseline" : "DEB 原始路径";
|
||||
const calibratedLabel =
|
||||
locale === "en-US"
|
||||
? "METAR-calibrated path"
|
||||
: "METAR 修正路径";
|
||||
? "DEB calibrated path"
|
||||
: "DEB 修正路径";
|
||||
const observationLabel =
|
||||
chartData?.observationLabel ||
|
||||
(locale === "en-US" ? "METAR obs" : "METAR 实况");
|
||||
|
||||
+114
-9
@@ -1,10 +1,17 @@
|
||||
import { getTemperatureChartData } from "@/lib/chart-utils";
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertNear(actual: number, expected: number, tolerance: number, message: string) {
|
||||
if (Math.abs(actual - expected) > tolerance) {
|
||||
throw new Error(`${message}: expected ${expected}±${tolerance}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const chartData = getTemperatureChartData(
|
||||
{
|
||||
@@ -29,13 +36,14 @@ export function runTests() {
|
||||
);
|
||||
|
||||
assert(chartData, "temperature chart data should exist for ISO datetime hourly input");
|
||||
// hourly max=26, DEB=28 → offset=+2; temps shift from [20,22,24,26] to [22,24,26,28]
|
||||
assert(
|
||||
chartData?.datasets.debSeries.some((point) => point.labelTime === "08:00" && point.y === 20),
|
||||
"temperature chart should normalize ISO hourly times into HH:mm points",
|
||||
chartData?.datasets.debSeries.some((point) => point.labelTime === "08:00" && point.y === 22),
|
||||
"temperature chart should normalize ISO hourly times and apply DEB offset based on hourly max",
|
||||
);
|
||||
assert(
|
||||
chartData?.datasets.debSeries.some((point) => point.labelTime === "10:00" && point.y === 24),
|
||||
"temperature chart should keep normalized hourly temperatures on the curve",
|
||||
chartData?.datasets.debSeries.some((point) => point.labelTime === "10:00" && point.y === 26),
|
||||
"temperature chart should keep normalized hourly temperatures shifted by offset",
|
||||
);
|
||||
|
||||
const ankaraChartData = getTemperatureChartData(
|
||||
@@ -70,16 +78,113 @@ export function runTests() {
|
||||
"Ankara chart should build the DEB original path from MGM hourly data when Open-Meteo hourly is unavailable",
|
||||
);
|
||||
assert(
|
||||
ankaraChartData?.datasets.debSeries.length === 48,
|
||||
"DEB original path must cover the full 00:00-23:30 chart day even when city hourly data is partial",
|
||||
ankaraChartData?.datasets.debSeries.length >= 4,
|
||||
"Ankara chart should build the DEB original path from MGM hourly data",
|
||||
);
|
||||
assert(
|
||||
ankaraChartData?.datasets.debSeries.some((point) => point.labelTime === "00:00") &&
|
||||
ankaraChartData?.datasets.debSeries.some((point) => point.labelTime === "23:30"),
|
||||
"DEB original path must include both start-of-day and end-of-day points",
|
||||
ankaraChartData?.datasets.debSeries.some((point) => point.labelTime === "13:00"),
|
||||
"Ankara DEB path must include the MGM hourly point at 13:00",
|
||||
);
|
||||
assert(
|
||||
ankaraChartData?.datasets.calibratedFutureSeries.length,
|
||||
"Ankara chart should still expose a calibrated path when observation points exist",
|
||||
);
|
||||
|
||||
// ── Moscow 场景:forecast.today_high 不可靠 → DEB offset 优先用 hourly 自身 max ──
|
||||
const moscowTimes = [
|
||||
"00:00", "00:30", "01:00", "01:30", "02:00", "02:30",
|
||||
"03:00", "03:30", "04:00", "04:30", "05:00", "05:30",
|
||||
"06:00", "06:30", "07:00", "07:30", "08:00", "08:30",
|
||||
"09:00", "09:30", "10:00", "10:30", "11:00", "11:30",
|
||||
"12:00", "12:30", "13:00", "13:30", "14:00", "14:30",
|
||||
"15:00", "15:30", "16:00", "16:30", "17:00", "17:30",
|
||||
"18:00", "18:30", "19:00", "19:30", "20:00", "20:30",
|
||||
"21:00", "21:30", "22:00", "22:30", "23:00", "23:30",
|
||||
];
|
||||
const moscowTemps = moscowTimes.map((t) => {
|
||||
const h = Number.parseInt(t.split(":")[0], 10);
|
||||
// Peak at 15:00 = 24.7, typical diurnal curve
|
||||
if (h <= 6) return 12 + h * 1.0;
|
||||
if (h <= 12) return 18 + (h - 6) * 0.9;
|
||||
if (h <= 15) return 23.4 + (h - 12) * 0.43;
|
||||
return 24.7 - (h - 15) * 1.2;
|
||||
});
|
||||
// Ensure the max is exactly 24.7 at 15:00
|
||||
const peakIndex = moscowTimes.indexOf("15:00");
|
||||
moscowTemps[peakIndex] = 24.7;
|
||||
|
||||
const moscowBaseline = buildDebBaselinePath(
|
||||
moscowTimes,
|
||||
moscowTemps,
|
||||
24.5, // DEB prediction
|
||||
"13:00", // local time
|
||||
21.4, // forecast.today_high — unreliable!
|
||||
null, // no MGM
|
||||
);
|
||||
|
||||
assert(
|
||||
Math.abs(moscowBaseline.offset) < 1.0,
|
||||
`Moscow: DEB offset should use hourly max (24.7) not forecast.today_high (21.4); got offset=${moscowBaseline.offset}`,
|
||||
);
|
||||
assertNear(
|
||||
moscowBaseline.offset,
|
||||
-0.2,
|
||||
0.3,
|
||||
"Moscow: DEB 24.5 vs hourly max 24.7 → offset ≈ -0.2",
|
||||
);
|
||||
// 验证后半段曲线没有被整体抬升 +3.1
|
||||
const moscowAfternoon = moscowBaseline.debTemps[peakIndex + 6]; // 18:00
|
||||
assert(
|
||||
moscowAfternoon != null && moscowAfternoon < 22,
|
||||
`Moscow 18:00 should not be inflated by unreliable forecast.today_high; got ${moscowAfternoon}`,
|
||||
);
|
||||
|
||||
// ── Ankara 部分小时数据:DEB 路径覆盖全天 48 点 ──
|
||||
const ankaraPartial = buildDebBaselinePath(
|
||||
["11:00", "12:00", "13:00", "14:00"],
|
||||
[19, 21, 22, 23],
|
||||
24,
|
||||
"13:00",
|
||||
null,
|
||||
null,
|
||||
);
|
||||
assert(
|
||||
ankaraPartial.debTemps.length === 4,
|
||||
"Ankara partial: input 4 hours → output 4 points (interpolation handled by fillTemperaturePathForFullDay)",
|
||||
);
|
||||
// hourly max=23, DEB=24 → offset=+1
|
||||
assertNear(ankaraPartial.offset, 1, 0.01, "Ankara partial: hourly max=23, DEB=24 → offset=+1");
|
||||
// DEB path should still cover the partial day
|
||||
const ankaraValid = ankaraPartial.debTemps.filter((t) => t != null && Number.isFinite(t));
|
||||
assert(ankaraValid.length >= 4, "Ankara partial: all input points should be valid");
|
||||
|
||||
// ── 正常城市:完整 hourly → offset 基于 hourly max ──
|
||||
const normalHourlyTimes = moscowTimes;
|
||||
const normalHourlyTemps = moscowTimes.map((t) => {
|
||||
const h = Number.parseInt(t.split(":")[0], 10);
|
||||
return 18 + Math.sin(((h - 6) / 12) * Math.PI) * 7; // peak ~25 at 12:00
|
||||
});
|
||||
const normalBaseline = buildDebBaselinePath(
|
||||
normalHourlyTimes,
|
||||
normalHourlyTemps,
|
||||
27, // DEB 2° above hourly max
|
||||
"10:00",
|
||||
26, // forecast.today_high close to reality
|
||||
null,
|
||||
);
|
||||
assertNear(
|
||||
normalBaseline.offset,
|
||||
2.0,
|
||||
0.5,
|
||||
"Normal city: DEB 27 vs hourly max ~25 → offset ≈ +2",
|
||||
);
|
||||
// Full 48-point coverage
|
||||
assert(
|
||||
normalBaseline.debTemps.length === 48,
|
||||
"Normal city: full 48-point DEB path",
|
||||
);
|
||||
assert(
|
||||
normalBaseline.debPast.some((t) => t != null) && normalBaseline.debFuture.some((t) => t != null),
|
||||
"Normal city: both past and future portions should have data",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user