|
| 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