Skip to content

Commit 25088ba

Browse files
committed
test(#351): pin the whole daily-only recovery end to end
Covers the chain in one place rather than in five separate unit tests: the file parses to NaN at face value, #350's validation now rejects it, #351 recovers the codes the file already contains, the raw_weather path produces a fully finite series from them, and validation then passes clean. The fixture reproduces the SHAPE of vita's file — hourly observations at :06 with the indices on the 19:06 row only. The four daily readings are the exact values recorded in #351. Verified against a running stack the same day: POST /models/preflight returned FFMC 84.65, DMC 20.72, DC 424.90 from 2026-08-03, and the wizard carried an accepted answer through to a real FireSTARR invocation with --ffmc 84.5 --dmc 24 --dc 432.5 on the command line. Finite numbers where the original failure had --ffmc NaN and exit 139. 693 tests, 75 files. tsc clean.
1 parent 3172014 commit 25088ba

1 file changed

Lines changed: 166 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* End-to-end recovery of a daily-only CFFDRS file — issues #350, #351.
3+
*
4+
* The failure this reproduces: a FireSTARR-format CSV recording its indices
5+
* once a day parsed to NaN on every other row, those NaNs were written to
6+
* weather.csv AND placed on the FireSTARR command line, and the engine died
7+
* with SIGSEGV. vita hit it six times in a week on the CIFFC demo.
8+
*
9+
* The fixture reproduces the SHAPE of her file — hourly observations at :06
10+
* with the indices on the 19:06 row only. The first four daily readings are the
11+
* exact values recorded in #351; the rest continue the trend and are invented.
12+
*
13+
* This covers the whole recovery: detect the shape, recover the codes, run them
14+
* through the raw_weather path, and confirm nothing non-finite survives.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import { WeatherService } from '../WeatherService.js';
19+
import { hasDailyOnlyCffdrs, findStartingCodeCandidate } from '../dailyCffdrs.js';
20+
import { validateWeatherData } from '../../firestarr/WeatherCSVWriter.js';
21+
22+
const ZONE = 'America/Edmonton';
23+
const IGNITION = new Date('2026-08-04T18:00:00.000Z'); // 12:00 local
24+
25+
const DAILY_READINGS: Record<string, [number, number, number]> = {
26+
'2026-08-01': [81.66, 19.22, 412.67],
27+
'2026-08-02': [76.3, 18.52, 418.6],
28+
'2026-08-03': [84.65, 20.72, 424.9],
29+
'2026-08-04': [88.44, 23.66, 432.05],
30+
};
31+
32+
const TEMPS = [16.1, 15.4, 14.9, 14.5, 14.2, 15.0, 17.3, 19.8, 21.9, 23.4, 24.4, 25.1,
33+
25.8, 26.3, 26.6, 26.4, 25.7, 24.8, 23.8, 22.1, 20.4, 19.0, 17.9, 16.9];
34+
const RHS = [72, 75, 78, 80, 82, 79, 70, 60, 52, 48, 46, 43, 41, 39, 38, 39, 43, 50, 61, 66, 69, 71, 72, 73];
35+
36+
function vitaShapedCsv(): string {
37+
const lines = ['Scenario,Date,PREC,TEMP,RH,WS,WD,FFMC,DMC,DC,ISI,BUI,FWI'];
38+
39+
for (const [day, [ffmc, dmc, dc]] of Object.entries(DAILY_READINGS)) {
40+
for (let hour = 0; hour < 24; hour++) {
41+
const stamp = `${day} ${String(hour).padStart(2, '0')}:06:00`;
42+
const observation = `0,${stamp},0,${TEMPS[hour]},${RHS[hour]},8.0,222`;
43+
44+
lines.push(
45+
hour === 19
46+
? `${observation.replace(',222', ',217')},${ffmc},${dmc},${dc},2,34.44,4.64`
47+
: `${observation},NaN,NaN,NaN,NaN,NaN,NaN`,
48+
);
49+
}
50+
}
51+
52+
return lines.join('\n');
53+
}
54+
55+
const service = new WeatherService();
56+
57+
describe("vita's file, end to end", () => {
58+
it('parses to NaN when taken at face value — the original failure', async () => {
59+
const points = await service.resolveWeather(
60+
{ source: 'firestarr_csv', firestarrCsvContent: vitaShapedCsv(), timezone: ZONE },
61+
{ latitude: 53.5, longitude: -113.5 },
62+
{ start: IGNITION, end: IGNITION },
63+
);
64+
65+
// 92 of 96 rows carry no indices at all.
66+
expect(points.some((p) => !Number.isFinite(p.ffmc))).toBe(true);
67+
});
68+
69+
it('is now REJECTED by validation instead of reaching the engine (#350)', async () => {
70+
const points = await service.resolveWeather(
71+
{ source: 'firestarr_csv', firestarrCsvContent: vitaShapedCsv(), timezone: ZONE },
72+
{ latitude: 53.5, longitude: -113.5 },
73+
{ start: IGNITION, end: IGNITION },
74+
);
75+
76+
const result = validateWeatherData(
77+
points.map((p) => ({
78+
date: p.datetime,
79+
temp: p.temperature,
80+
rh: p.humidity,
81+
ws: p.windSpeed,
82+
wd: p.windDirection,
83+
precip: p.precipitation,
84+
ffmc: p.ffmc,
85+
dmc: p.dmc,
86+
dc: p.dc,
87+
isi: p.isi ?? 0,
88+
bui: p.bui ?? 0,
89+
fwi: p.fwi ?? 0,
90+
})) as Parameters<typeof validateWeatherData>[0],
91+
);
92+
93+
expect(result.valid).toBe(false);
94+
});
95+
96+
it('recovers the codes the file already contains (#351)', async () => {
97+
const points = await service.resolveWeather(
98+
{ source: 'firestarr_csv', firestarrCsvContent: vitaShapedCsv(), timezone: ZONE },
99+
{ latitude: 53.5, longitude: -113.5 },
100+
{ start: IGNITION, end: IGNITION },
101+
);
102+
103+
expect(hasDailyOnlyCffdrs(points)).toBe(true);
104+
105+
const candidate = findStartingCodeCandidate(points, IGNITION, ZONE);
106+
expect(candidate).not.toBeNull();
107+
expect([candidate!.ffmc, candidate!.dmc, candidate!.dc]).toEqual([84.65, 20.72, 424.9]);
108+
});
109+
110+
it('produces a fully finite series once those codes are accepted', async () => {
111+
// The payoff. Same file, raw_weather path, codes recovered from the file
112+
// itself — every row now carries real numbers.
113+
const points = await service.resolveWeather(
114+
{
115+
source: 'raw_weather',
116+
rawWeatherContent: vitaShapedCsv(),
117+
startingCodes: { ffmc: 84.65, dmc: 20.72, dc: 424.9 },
118+
latitude: 53.5,
119+
timezone: ZONE,
120+
},
121+
{ latitude: 53.5, longitude: -113.5 },
122+
{ start: IGNITION, end: IGNITION },
123+
);
124+
125+
expect(points.length).toBe(96);
126+
for (const point of points) {
127+
expect(Number.isFinite(point.ffmc)).toBe(true);
128+
expect(Number.isFinite(point.dmc)).toBe(true);
129+
expect(Number.isFinite(point.dc)).toBe(true);
130+
}
131+
});
132+
133+
it('passes the validation that used to let NaN through to the command line', async () => {
134+
const points = await service.resolveWeather(
135+
{
136+
source: 'raw_weather',
137+
rawWeatherContent: vitaShapedCsv(),
138+
startingCodes: { ffmc: 84.65, dmc: 20.72, dc: 424.9 },
139+
latitude: 53.5,
140+
timezone: ZONE,
141+
},
142+
{ latitude: 53.5, longitude: -113.5 },
143+
{ start: IGNITION, end: IGNITION },
144+
);
145+
146+
const result = validateWeatherData(
147+
points.map((p) => ({
148+
date: p.datetime,
149+
temp: p.temperature,
150+
rh: p.humidity,
151+
ws: p.windSpeed,
152+
wd: p.windDirection,
153+
precip: p.precipitation,
154+
ffmc: p.ffmc,
155+
dmc: p.dmc,
156+
dc: p.dc,
157+
isi: p.isi ?? 0,
158+
bui: p.bui ?? 0,
159+
fwi: p.fwi ?? 0,
160+
})) as Parameters<typeof validateWeatherData>[0],
161+
);
162+
163+
expect(result.issues).toEqual([]);
164+
expect(result.valid).toBe(true);
165+
});
166+
});

0 commit comments

Comments
 (0)