Skip to content

Commit e79e607

Browse files
committed
feat(#351): implement daily-only CFFDRS detection — GREEN
Follows b32ccfd (RED) to green. Implements the two pure functions whose contracts and tests were committed there. hasDailyOnlyCffdrs — true only when some rows carry codes and others do not. All-codes is a conforming firestarr_csv; no-codes is a different failure with nothing to recover. findStartingCodeCandidate — the latest row that carries all three codes, sits at local hour 12 or 13 in the supplied zone, and falls strictly before ignition. Minutes are ignored; stations poll at :00, :05, :06 or :10 and all are the same daily reading. Corrected one test. 'excludes a daily reading exactly at ignition' passed the full row set, where Aug 1 and Aug 2 also carry readings strictly before the boundary — so the correct answer was 76.3, not null, and no rule could return null there without breaking two other tests. Narrowed to a single row so it isolates the boundary it names. Invalid-timezone handling returns null rather than failing fast. Filed as 353, not fixed here — untested behaviour does not belong mid-cycle. 15 tests green. Backend suite 646 passed (was 631). tsc --noEmit exit 0.
1 parent b32ccfd commit e79e607

2 files changed

Lines changed: 42 additions & 12 deletions

File tree

backend/src/infrastructure/weather/__tests__/dailyCffdrs.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,12 @@ describe('findStartingCodeCandidate', () => {
136136
});
137137

138138
it('excludes a daily reading exactly at ignition — strictly before', () => {
139+
// A single row, so nothing earlier can be returned instead. This isolates
140+
// the boundary: the reading is at a daily hour and carries codes, and is
141+
// rejected solely for being at ignition rather than before it.
139142
const atIgnition = new Date('2026-08-03T19:06:00Z');
140-
expect(findStartingCodeCandidate(vitasRows(), atIgnition, ZONE)).toBeNull();
143+
const rows = [daily('2026-08-03T19:06:00Z', 84.65, 20.72, 424.9)];
144+
expect(findStartingCodeCandidate(rows, atIgnition, ZONE)).toBeNull();
141145
});
142146

143147
it('returns null when no daily reading precedes ignition', () => {

backend/src/infrastructure/weather/dailyCffdrs.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,14 @@ function hasCodes(row: CffdrsRow): boolean {
6464
* Returns false when every row has codes (a conforming firestarr_csv) and false
6565
* when no row has codes (nothing to recover — a different failure).
6666
*/
67-
export function hasDailyOnlyCffdrs(_rows: CffdrsRow[]): boolean {
68-
throw new Error('not implemented');
67+
export function hasDailyOnlyCffdrs(rows: CffdrsRow[]): boolean {
68+
if (rows.length === 0) return false;
69+
70+
const withCodes = rows.filter(hasCodes).length;
71+
72+
// Both sides must be non-empty. All-codes is a conforming firestarr_csv;
73+
// no-codes is a different failure with nothing to recover.
74+
return withCodes > 0 && withCodes < rows.length;
6975
}
7076

7177
/**
@@ -79,14 +85,34 @@ export function hasDailyOnlyCffdrs(_rows: CffdrsRow[]): boolean {
7985
* Returns null when no such reading exists — the caller must not invent one.
8086
*/
8187
export function findStartingCodeCandidate(
82-
_rows: CffdrsRow[],
83-
_ignition: Date,
84-
_timezone: string,
88+
rows: CffdrsRow[],
89+
ignition: Date,
90+
timezone: string,
8591
): StartingCodeCandidate | null {
86-
throw new Error('not implemented');
87-
}
92+
let best: CffdrsRow | null = null;
93+
94+
for (const row of rows) {
95+
if (!hasCodes(row)) continue;
96+
if (row.datetime.getTime() >= ignition.getTime()) continue;
97+
98+
const local = DateTime.fromJSDate(row.datetime, { zone: timezone });
99+
if (!DAILY_READING_LOCAL_HOURS.includes(local.hour)) continue;
88100

89-
// Referenced by the implementation; declared above to keep the contract visible.
90-
void DAILY_READING_LOCAL_HOURS;
91-
void hasCodes;
92-
void DateTime;
101+
if (best === null || row.datetime.getTime() > best.datetime.getTime()) {
102+
best = row;
103+
}
104+
}
105+
106+
if (best === null) return null;
107+
108+
const local = DateTime.fromJSDate(best.datetime, { zone: timezone });
109+
110+
return {
111+
ffmc: best.ffmc,
112+
dmc: best.dmc,
113+
dc: best.dc,
114+
observedAt: best.datetime,
115+
// Minutes are deliberately dropped — the reading names an hour, not an instant.
116+
localLabel: `${local.toFormat('yyyy-MM-dd, HH')}00`,
117+
};
118+
}

0 commit comments

Comments
 (0)