Skip to content

Commit 261d4b6

Browse files
THUNDER PHUMclaude
andcommitted
fix(cron): instantaneous-field provenance only — lifetime/day energies excluded, absent SOC contributes nothing (codex P2)
- composeObservedAt(inv, stationTs, stationSocKnown): fromInverter = any of gen/use/grid/batt power or SOC overridden; fromStation = any power still station-valued, or SOC station-known and not overridden; genTotal (history) never gates freshness; SOC absent on both sources is stored NULL and is no contributor - 7 composition tests mirroring the real getLatestOpen cases (117 total) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 0d8537e commit 261d4b6

4 files changed

Lines changed: 72 additions & 14 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Review 0d8537e — HOLD (1 P2)
2+
3+
## P2: Count actual instantaneous sources, not missing lifetime energy or unknown SOC
4+
5+
Location: `src/worker/deye.ts:416-427`.
6+
7+
The conservative oldest-contributor rule is sound, and the preceding SOC-only / unrelated-point cases are fixed. But the contributor classification adds a station/latest source when ANY of six inverter fields is absent, regardless of whether that field is actually a station/latest observation:
8+
9+
- `genTotal` comes from `tt.generationTotal` (day history, line 393), not `d` (station/latest). When history does not provide it it is a zero placeholder. Absence of `TotalActiveProduction` must not make an otherwise complete, fresh realtime inverter reading depend on stale/missing station/latest time.
10+
- When neither source reports SOC, `socKnown=false` and the worker stores SQL NULL. That is absence of an observation, not station evidence requiring a station timestamp.
11+
12+
Actual worker+SQLite reproductions:
13+
14+
1. Four powers + SOC all fresh from inverter, station timestamp four days old, no inverter lifetime meter -> original valid-fresh-recovery case regresses: `observedAt` becomes four days old, sample/telemetry dropped and frozen state remains. Supplying the unrelated lifetime point as a positive control lets the identical fresh realtime measurements store.
15+
2. Same complete five realtime fields, station timestamp absent -> `observedAt=null`, no samples; freshness remains unprovable indefinitely even with a consistently fresh inverter.
16+
3. Fresh four powers + lifetime meter, SOC absent on both sources (`socKnown=false`), station timestamp old -> drops the sample solely because the NULL SOC is counted as a station contribution.
17+
18+
Scope the realtime contributor set to the values actually sourced and persisted as measurements. Keep lifetime/day energy provenance separate from instantaneous freshness; exclude absent SOC from source requirements. Preserve a station contribution for real fallback power/SOC values, including verified zero. Add actual composition/ingestion tests, not only tests of `observedAtOf` with precomputed `used` flags.
19+
20+
Verified: 110/110 tests, typecheck, build, diff-check; prior partial/no-override cases and pure age cases pass; six-field inverter positive control and normal recovery/purge controls pass. Harness `.summary/review-088cb16.cjs` extended. No production or app source changes.

‎src/lib/__tests__/freeze.test.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { isFrozenReading, observedAtOf } from "../freeze";
2+
import { isFrozenReading, observedAtOf, composeObservedAt } from "../freeze";
33

44
const NOW = 1_790_000_000, STALE = 720;
55
describe("isFrozenReading — freshness of the value source, independent of monotonicity", () => {
@@ -27,3 +27,30 @@ describe("observedAtOf — oldest contributing source bounds freshness", () => {
2727
});
2828
it("nothing contributed → null", () => expect(observedAtOf([{ used: false, ts: S }])).toBeNull());
2929
});
30+
31+
describe("composeObservedAt — actual Latest composition cases", () => {
32+
const S = 1_790_000_000, OLD = 1_789_650_000; // fresh vs 4 days old
33+
const full = (ts: number, extra: Partial<import("../freeze").InvFlow> = {}) => ({ genPower: 4000, usePower: 900, gridPower: -3000, battPower: 100, soc: 75, observedAt: ts, ...extra });
34+
it("old station + fresh inverter with ALL 4 powers + SOC, NO lifetime point → fresh (lifetime is not instantaneous)", () => {
35+
expect(composeObservedAt(full(S), OLD, true)).toBe(S);
36+
});
37+
it("old station + fresh inverter powers, SOC absent on BOTH → fresh (absent SOC contributes nothing)", () => {
38+
expect(composeObservedAt(full(S, { soc: undefined }), OLD, false)).toBe(S);
39+
});
40+
it("old station + fresh inverter powers, SOC absent on inverter but known on station → station contributes → old", () => {
41+
expect(composeObservedAt(full(S, { soc: undefined }), OLD, true)).toBe(OLD);
42+
});
43+
it("fresh station + 4-day-old device that only has SOC → device contributes → old", () => {
44+
expect(composeObservedAt({ soc: 75, observedAt: OLD }, S, true)).toBe(OLD);
45+
});
46+
it("fresh station + device with no mapped field (e.g. only BatteryRatedCapacity) → station only → fresh", () => {
47+
expect(composeObservedAt({ observedAt: null }, S, true)).toBe(S);
48+
expect(composeObservedAt(null, S, true)).toBe(S);
49+
});
50+
it("zero values are real contributions (gen 0 at night from the inverter counts as inverter-sourced)", () => {
51+
expect(composeObservedAt(full(S, { genPower: 0, gridPower: 0, battPower: 0 }), OLD, true)).toBe(S);
52+
});
53+
it("partial override with a timestamp-less device → unprovable", () => {
54+
expect(composeObservedAt({ genPower: 4000, observedAt: null }, S, true)).toBeNull();
55+
});
56+
});

‎src/lib/freeze.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,20 @@ export function observedAtOf(contributors: { used: boolean; ts: number | null }[
2727
if (used.some((c) => c.ts == null || !Number.isFinite(c.ts) || c.ts <= 0)) return null;
2828
return Math.min(...used.map((c) => c.ts as number));
2929
}
30+
31+
// Provenance of the INSTANTANEOUS fields of a Latest record (gen/use/grid/batt
32+
// power + SOC). Lifetime/day-energy fields have their own (history) provenance
33+
// and never gate instantaneous freshness. SOC that neither source reported is
34+
// stored NULL and contributes nothing.
35+
export interface InvFlow { genPower?: number; usePower?: number; gridPower?: number; battPower?: number; soc?: number; observedAt: number | null }
36+
export function composeObservedAt(inv: InvFlow | null, stationTs: number | null, stationSocKnown: boolean): number | null {
37+
const powers = ["genPower", "usePower", "gridPower", "battPower"] as const;
38+
const invHas = (k: keyof InvFlow) => !!inv && (inv as any)[k] != null;
39+
const fromInverter = !!inv && (powers.some(invHas) || invHas("soc"));
40+
const socFromStation = !invHas("soc") && stationSocKnown;
41+
const fromStation = !inv || powers.some((k) => !invHas(k)) || socFromStation;
42+
return observedAtOf([
43+
{ used: fromStation, ts: stationTs },
44+
{ used: fromInverter, ts: inv ? inv.observedAt : null },
45+
]);
46+
}

‎src/worker/deye.ts‎

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ export async function getStationMeta(env: Env): Promise<Station> {
331331
return picked;
332332
}
333333

334-
import { observedAtOf } from "../lib/freeze";
334+
import { composeObservedAt } from "../lib/freeze";
335335

336336
// Open API is the only data source.
337337
export async function getLatest(env: Env, stationId?: string): Promise<Latest> {
@@ -409,26 +409,20 @@ async function getLatestOpen(env: Env, stationId?: string): Promise<Latest> {
409409
// (verified live): battery −=charge/+=discharge, grid +=import/−=export.
410410
try {
411411
const inv = await getInverterFlow(env, id);
412-
// Each stored realtime field keeps a provenance flag: overridden by the
413-
// inverter or left as the station value. The record's observation time is
414-
// the OLDEST source that actually contributed a field (lib/freeze.ts) — a
415-
// device snapshot that overrides nothing does not vouch for station values.
416-
const realtime: (keyof typeof out)[] = ["genPower", "usePower", "gridPower", "battPower", "soc", "genTotal"];
417-
let fromInverter = false;
412+
// Instantaneous fields keep per-field provenance (inverter override vs.
413+
// station value); the record's observation time is the OLDEST source that
414+
// actually contributed one (lib/freeze.ts composeObservedAt). Lifetime
415+
// genTotal and the day energies come from history and are NOT part of it.
416+
const stationSocKnown = out.socKnown;
418417
if (inv) {
419418
if (inv.genPower != null) out.genPower = inv.genPower;
420419
if (inv.usePower != null) out.usePower = inv.usePower;
421420
if (inv.gridPower != null) { out.gridPower = inv.gridPower; out.gridStatus = inv.gridPower >= 0 ? "PURCHASE" : "REVERSE"; }
422421
if (inv.battPower != null) { out.battPower = inv.battPower; out.battStatus = inv.battPower > 20 ? "DISCHARGE" : inv.battPower < -20 ? "CHARGE" : "STATIC"; }
423422
if (inv.soc != null) { out.soc = inv.soc; out.socKnown = true; }
424423
if (inv.genTotal != null) out.genTotal = inv.genTotal; // lifetime kWh (not in station API)
425-
fromInverter = realtime.some((k) => (inv as any)[k] != null);
426424
}
427-
const fromStation = !inv || realtime.some((k) => (inv as any)[k] == null); // at least one field still station-sourced
428-
out.observedAt = observedAtOf([
429-
{ used: fromStation, ts: toSec(d.lastUpdateTime) },
430-
{ used: fromInverter, ts: inv ? inv.observedAt : null },
431-
]);
425+
out.observedAt = composeObservedAt(inv, toSec(d.lastUpdateTime), stationSocKnown);
432426
} catch {}
433427
return out;
434428
}

0 commit comments

Comments
 (0)