flux branch peak-from-readings commits 8 (vs origin/main) files 26 touched lines +1782 / -144

Pre-push review: peak-from-readings

Server-computed peakGridImportKwh replaces the iOS app's noisy eInput − offpeakGridImportKwh residual. Backend integration + storage + API + renamed backfill-grid CLI, plus the iOS History consumer.

At a glance

  • Peak grid import is now a direct trapezoidal integration of max(Pgrid,0) over the two windows bracketing off-peak — symmetric with off-peak, so each carries only its own ~1.5% sampling noise instead of stacking it all on peak.
  • Stored on flux-daily-energy behind an independent peakComputedAt sentinel so the field can be filled onto rows that already have derivedStats without clobbering them.
  • Review caught a real spec defect: original Decision 3 claimed the hourly pass would backfill history, but the pass only ever processes "yesterday". Resolved per the author's call by consolidating historical backfill into the renamed cmd/backfill-grid CLI (Decision 7) and correcting the decision log.
  • iOS History prefers the server value, falls back to the residual for today and pre-backfill days. DayCosts / Day-Detail intentionally untouched (out of the spec's iOS scope).

Verdict

Ready to push

Four parallel review agents (reuse, quality, efficiency, spec) found no critical or major issues. Implementation matches every MUST/SHOULD requirement with distributed tests; the MAY drift-log is intentionally omitted. Minor/nit findings (ambiguous cross-spec Decision N references, a stale doc comment, prose count drift, a brief intent comment on the CLI's separate query) were fixed in this review. make check and the iOS/macOS suites pass.

Review findings

7 raised · 5 fixed · 2 skipped

Jump to findings →

Commits

Three-level explanation

What this does

The History screen's daily "peak" grid-import number used to be worked out by subtraction: whole-day import minus the off-peak part. Subtracting a slightly-wrong number from an accurate one dumps all the error onto peak (~43% on small days). Now the backend adds up the 10-second grid-power readings directly over the hours outside the off-peak window, so peak and off-peak are measured the same way.

Why it matters

The peak figure is what you check against your electricity bill. When it's visibly wrong it erodes trust in the rest of the dashboard; this brings it within a few percent of reality.

Key concepts

  • Off-peak window: cheap-tariff hours (11:00–14:00) when the battery charges from the grid.
  • Peak: everything outside that — a morning slice and an evening slice.
  • Sentinel: a small "done" marker so the hourly job skips rows it already finished.
  • Fallback: if the backend hasn't produced a peak value (today, or very old days), the app quietly uses the old subtraction so the screen is never blank.

Changes overview

One new field across three layers.

  • Compute: derivedstats.IntegratePeakGridImportKwh calls the existing single-window integrator twice ([dayStart,offpeakStart) and [offpeakEnd,dayEnd)), sums each window's GridImportKwh, returns ok only when both pass the usability gate.
  • Store: DailyEnergyItem gains peakGridImportKwh + a peakComputedAt sentinel; UpdateDailyEnergyDerived writes the derivedStats group and the peak group independently.
  • Pass: runSummarisationPass adds a parallel peak block gated on the new sentinel; skips a row only when both sentinels are set.
  • API: peakGridImportKwh on /day and /history (omitempty); no real-time today path.
  • Backfill: cmd/backfill-offpeakcmd/backfill-grid, extended to also write peak (GET-before-write, no phantom rows).
  • iOS: DayEnergy/CachedDayEnergy decode the field; gridEntry uses day.peakGridImportKwh ?? max(0, eInput − offpeakImport).

Approach

Guiding idea is symmetry — peak and off-peak share one integrator over complementary windows, so error scales with each window's own throughput. Reusing IntegrateOffpeakDeltas keeps the peak gate byte-for-byte identical to off-peak's.

Trade-offs

Direct integration over residual / calibration (Decision 1); store on the existing per-day row (Decision 2); two orthogonal sentinels (Decision 3); CLI backfill over per-tick TTL scan (Decision 7); grid-import-only (Decision 5).

Deep dive

  • Gate composition: early ok=false if either sub-window is unusable; the morning [00:00, offpeakStart) is the fragile one (overnight outages). No partial-day peak — the day omits and iOS falls back.
  • DST: bounds are absolute unix timestamps from ParseInLocation + minute offsets, so 23h/25h Sydney days span the correct real time.
  • Write independence: UpdateDailyEnergyDerived accumulates per-group sets/values and no-ops on empty (DynamoDB rejects empty UpdateExpression), so a pre-feature row receives only the peak group later.
  • Gate-failure stamps the sentinel: a permanently sparse past day isn't retried hourly; past-day readings are immutable once the day closes.
  • Phantom-row guard: UpdateItem upserts, so the CLI GETs first; the GET's prior value also feeds the prev→new operator line, so a ConditionExpression would be strictly worse and there's no concurrent writer (CLI skips today).

Architecture impact

Two-sentinel pattern generalises to future per-field derived stats. CLI now spans two write tables; off-peak recompute byte-for-byte unchanged. Storage naming divergence accepted (Decision 6).

To monitor

  • Rolling 30-day History discontinuity until the backfill CLI runs (accepted, never worse than current).
  • peak + offpeak ≠ eInput exactly (~1.5%); 3% bound asserted in tests; operator-visible only.
  • IntegratePeakGridImportKwh computes all five channels twice, uses one — negligible at daily cadence, first target if cadence changes.

Important changes — detailed

derivedstats: peak grid import by twin-window integration

internal__derivedstats__integrate_offpeak.go

Why it matters. Core of the feature. Correctness hinges on the ok-logic: peak is usable only when BOTH bracketing windows pass the gate, otherwise the day omits and iOS falls back. Reuses the off-peak integrator so the gate is identical.

What to look at. IntegratePeakGridImportKwh

Takeaway. Composing a new metric from an existing integrator (rather than a fresh primitive) buys an identical usability gate for free — worth the redundant channel work at bounded volume.
Rationale. Decision 1 — direct integration keeps provenance honest vs. a calibration multiplier or snapshot scaling.

dynamo: independent per-group SET writes via twin sentinels

internal__dynamo__dynamostore.go

Why it matters. Enables filling peak onto rows that already have derivedStats without clobbering, and lets the hourly pass and the CLI share one write path. The empty-sets no-op guard avoids DynamoDB's empty-UpdateExpression rejection.

What to look at. UpdateDailyEnergyDerived (dynamic SET builder)

Takeaway. Gate each independently-computed attribute group on its own sentinel and build the UpdateExpression dynamically — the minimal mechanism for backfilling one derived field without touching its neighbours.
Rationale. Decision 3 — two orthogonal sentinels avoid a coordinated deploy or a versioning field.

poller: parallel peak block, skip only when both sentinels set

internal__poller__dailysummary.go

Why it matters. The pass now forward-fills peak independently of derivedStats. Gate-failure still stamps the sentinel so a sparse past day isn't retried hourly.

What to look at. runSummarisationPass needDerived/needPeak

Takeaway. When adding a second derived output to an idempotent pass, gate it on a second sentinel and only short-circuit when all are set — old rows then heal one field at a time.
Rationale. Decision 3; the today/forward-fill scoping is Decision 4.

cmd/backfill-grid: historical backfill (rename + extend)

cmd__backfill-grid__main.go

Why it matters. Resolves the review-caught defect: the hourly pass only visits yesterday, so history needs a one-shot CLI. GET-before-write avoids phantom flux-daily-energy rows; off-peak recompute stays unchanged.

What to look at. backfillPeak + --table-daily-energy

Takeaway. UpdateItem upserts — when a write must not create a row, GET first; here the GET also feeds the operator summary, making it preferable to a conditional write.
Rationale. Decision 7 — consolidate into the renamed CLI matching the established backfill-offpeak / backfill-solar pattern.

iOS: prefer server peak, residual fallback

Flux__Flux__History__HistoryDerivedState.swift

Why it matters. User-visible payoff. Replaces the computed residual with the server value when present; keeps the residual for today and pre-backfill days so the card is never blank.

What to look at. gridEntry: day.peakGridImportKwh ?? max(0, eInput − offpeakImport)

Takeaway. A nil-coalescing fallback lets a server-computed improvement roll out incrementally without a hard cutover or a blank UI during backfill.
Rationale. Decision 4 — today and pre-30-day rows use the iOS fallback by design.

Key decisions

Direct integration over residual (D1).

Compute peak by integrating Pgrid over the two bracketing windows rather than subtracting off-peak from eInput. Keeps provenance honest; rejected calibration multiplier and snapshot scaling.

Store on flux-daily-energy (D2).

Reuse the existing per-day summary row and hourly write path; no new table.

Independent peakComputedAt sentinel (D3).

Second sentinel gives peak an orthogonal lifecycle; pass skips only when both are set. Original framing that the hourly pass backfills history was corrected during this review.

Today / pre-30-day rows use the iOS fallback (D4).

No real-time today path and no cross-TTL persistence; the residual fallback applies.

Grid-import only (D5).

No peak solar/export/battery channels — YAGNI, no UI consumer.

Accept GridUsageKwh / PeakGridImportKwh storage naming divergence (D6).

Renaming the off-peak field is out of scope; both surface as *GridImportKwh at the API boundary.

Consolidate historical backfill into renamed backfill-grid CLI (D7).

Added during this review after the hourly-pass-can't-reach-history defect surfaced; matches the repo's backfill-offpeak / backfill-solar pattern, per the author's direction to rename and consolidate.

Review findings

SeverityAreaFindingResolution
minorcross-spec Decision N referencesNew 'Decision 3' comments (this spec) sit next to legacy 'Decision 8'/'Decision 9' (off-peak / daily-derived-stats) in dailysummary.go, dynamostore.go — a reader would resolve them to the wrong decision log.Qualified the new references as 'peak-from-readings Decision 3' and the adjacent legacy one as 'daily-derived-stats Decision 8'.
minordynamostore.go doc commentUpdateDailyEnergyDerived doc still described 'the four derivedStats attributes' and a single writer, but it now writes two independent groups and has a second caller (backfill-grid).Rewrote the doc to describe both groups and note backfill-grid as the peak group's writer.
minormodels.go commentField comment repeated the incorrect original Decision 3 claim that a pre-feature row 'gets peak filled on the next hourly tick' — the pass only visits yesterday.Corrected to forward-fill (hourly) + cmd/backfill-grid for history (Decision 7).
nitcmd/backfill-grid efficiencybackfillPeak runs a separate full-day readings query in addition to backfillOffpeak's off-peak-window query (a superset). Justified (off-peak stays byte-for-byte unchanged; ≤30 manual runs) but undocumented.Added a comment noting the separate full-day query is intentional so off-peak behaviour is preserved.
nitCHANGELOG prose driftSmolspec Documentation entry said '6 decisions / 5-task plan' and 'automatic backfill within the TTL' — stale after Decision 7 and task 5 were added.Updated to '7 decisions / 6-task' and corrected the backfill phrasing (forward-fill + CLI).
minorDayCosts / Day-Detail (iOS)DayCosts and ComparisonSnapshot still compute peak as their own residual, and Swift DaySummary does not decode peakGridImportKwh, so the Day-Detail cost view keeps using the noisy residual.Left as-is — within the spec's iOS scope (History only). Flagged as a future follow-up ticket rather than expanding scope here.
nitIntegratePeakGridImportKwh channel wasteComputes all five energy channels twice and consumes only grid import (8 of 10 integration passes wasted).Left as-is — spec prescribes reusing the off-peak integrator for an identical gate; bounded daily volume makes it negligible. Documented in implementation.md as the first optimization target if cadence changes.

Per-file diffs

Click to expand.

internal/derivedstats/integrate_offpeak.go Modified +35 / -0
diff --git a/internal/derivedstats/integrate_offpeak.go b/internal/derivedstats/integrate_offpeak.goindex 8ba8133..26e1238 100644--- a/internal/derivedstats/integrate_offpeak.go+++ b/internal/derivedstats/integrate_offpeak.go@@ -100,6 +100,41 @@ func IntegrateOffpeakDeltas(readings []Reading, startUnix, endUnix int64) (Offpe 	return out, true } +// IntegratePeakGridImportKwh returns the grid-import energy (∫ max(pgrid, 0))+// over the two windows bracketing the off-peak window: [dayStartUnix,+// offpeakStartUnix) and [offpeakEndUnix, dayEndUnix). It is the peak-tariff+// complement of the off-peak grid import IntegrateOffpeakDeltas computes for+// the [offpeakStart, offpeakEnd) window.+//+// Window args are unix timestamps to match IntegrateOffpeakDeltas; the caller+// converts the HH:MM SSM config to absolute boundaries (DST-correct because+// the boundaries are derived from time.ParseInLocation / AddDate). DST-length+// (23h / 25h) days are handled naturally — the helper only sees timestamps.+//+// Returns the summed kWh plus combined provenance (sampleCount, skippedPairs)+// across both sub-windows. ok is true only when BOTH sub-windows pass the+// usability gate of IntegrateOffpeakDeltas; if either is unusable (e.g. sparse+// readings on an overnight poller outage), ok is false and the caller omits+// the field. Both windows reuse the same trapezoidal integrator — no new+// numerical method is introduced.+//+// Precondition: readings must be sorted by Timestamp ascending (same as+// IntegrateOffpeakDeltas).+func IntegratePeakGridImportKwh(readings []Reading, dayStartUnix, offpeakStartUnix, offpeakEndUnix, dayEndUnix int64) (kwh float64, sampleCount int, skippedPairs int, ok bool) {+	morning, mornOK := IntegrateOffpeakDeltas(readings, dayStartUnix, offpeakStartUnix)+	if !mornOK {+		return 0, 0, 0, false+	}+	evening, evenOK := IntegrateOffpeakDeltas(readings, offpeakEndUnix, dayEndUnix)+	if !evenOK {+		return 0, 0, 0, false+	}+	return morning.GridImportKwh + evening.GridImportKwh,+		morning.SampleCount + evening.SampleCount,+		morning.SkippedPairs + evening.SkippedPairs,+		true+}+ // bracketIndices returns: //   - iL: largest index with readings[iL].Timestamp < startUnix, or -1 when none. //   - iR: smallest index > iL with readings[iR].Timestamp >= endUnix, or len(readings).
internal/derivedstats/integrate_peak_test.go Added +153
diff --git a/internal/derivedstats/integrate_peak_test.go b/internal/derivedstats/integrate_peak_test.gonew file mode 100644index 0000000..f34f486--- /dev/null+++ b/internal/derivedstats/integrate_peak_test.go@@ -0,0 +1,153 @@+package derivedstats++import (+	"testing"+	"time"++	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++func TestIntegratePeakGridImportKwh(t *testing.T) {+	const base int64 = 1_700_000_000++	t.Run("sums both sub-windows", func(t *testing.T) {+		// Morning window [base, base+40): 1000 W constant grid import over the+		// four in-window points (0,10,20,30) → 30s of 1000 W = 30000 Ws.+		// Evening window [base+1000, base+1040): same shape, another 30000 Ws.+		// The off-peak window between them carries no grid import. The windows+		// are spaced far apart (>60s) so no edge synthesis bleeds across them.+		readings := mkReadings(base,+			spec{0, 0, 1000, 0},+			spec{10, 0, 1000, 0},+			spec{20, 0, 1000, 0},+			spec{30, 0, 1000, 0},+			// evening peak window, well clear of the off-peak gap+			spec{1000, 0, 1000, 0},+			spec{1010, 0, 1000, 0},+			spec{1020, 0, 1000, 0},+			spec{1030, 0, 1000, 0},+		)+		kwh, samples, skipped, ok := IntegratePeakGridImportKwh(readings, base, base+40, base+960, base+1040)+		require.True(t, ok)+		want := (30_000.0 + 30_000.0) / 3_600_000.0+		assert.InDelta(t, want, kwh, 1e-9)+		assert.Equal(t, 8, samples, "four samples per peak sub-window")+		assert.Equal(t, 0, skipped)+	})++	t.Run("only positive grid clamped per sample", func(t *testing.T) {+		// Negative pgrid (export) contributes zero to grid import.+		readings := mkReadings(base,+			spec{0, 0, -2000, 0},+			spec{10, 0, -2000, 0},+			spec{20, 0, 1000, 0},+			spec{30, 0, 1000, 0},+			spec{100, 0, -500, 0},+			spec{110, 0, -500, 0},+			spec{120, 0, 0, 0},+			spec{130, 0, 0, 0},+		)+		kwh, _, _, ok := IntegratePeakGridImportKwh(readings, base, base+40, base+100, base+140)+		require.True(t, ok)+		assert.GreaterOrEqual(t, kwh, 0.0, "import is non-negative by construction")+	})++	t.Run("gate failure in morning sub-window yields not-usable", func(t *testing.T) {+		// Morning window has a single sample (sparse — overnight outage);+		// evening window is fine. The combined result must be not-usable.+		readings := mkReadings(base,+			spec{10, 0, 1000, 0}, // single sample in [base, base+40)+			spec{100, 0, 1000, 0},+			spec{110, 0, 1000, 0},+			spec{120, 0, 1000, 0},+			spec{130, 0, 1000, 0},+		)+		_, _, _, ok := IntegratePeakGridImportKwh(readings, base, base+40, base+100, base+140)+		assert.False(t, ok, "sparse morning sub-window must produce ok=false")+	})++	t.Run("gate failure in evening sub-window yields not-usable", func(t *testing.T) {+		readings := mkReadings(base,+			spec{0, 0, 1000, 0},+			spec{10, 0, 1000, 0},+			spec{20, 0, 1000, 0},+			spec{30, 0, 1000, 0},+			spec{110, 0, 1000, 0}, // single sample in [base+100, base+140)+		)+		_, _, _, ok := IntegratePeakGridImportKwh(readings, base, base+40, base+100, base+140)+		assert.False(t, ok, "sparse evening sub-window must produce ok=false")+	})++	t.Run("empty readings yields not-usable", func(t *testing.T) {+		_, _, _, ok := IntegratePeakGridImportKwh(nil, base, base+40, base+100, base+140)+		assert.False(t, ok)+	})++	t.Run("DST 25h day handled via unix window args", func(t *testing.T) {+		// Sydney 2026-04-05 is a 25-hour day (DST ends, clocks back at 03:00).+		// Off-peak 11:00-14:00. Build hourly readings with 1000 W import in+		// peak hours and 0 in off-peak, then confirm boundaries derived from+		// the local calendar integrate without error and exclude off-peak.+		loc, err := time.LoadLocation("Australia/Sydney")+		require.NoError(t, err)+		dayStart := time.Date(2026, 4, 5, 0, 0, 0, 0, loc)+		dayEnd := dayStart.AddDate(0, 0, 1)+		assert.Equal(t, 25*time.Hour, dayEnd.Sub(dayStart), "2026-04-05 must be a 25h Sydney day")++		offStart := time.Date(2026, 4, 5, 11, 0, 0, 0, loc)+		offEnd := time.Date(2026, 4, 5, 14, 0, 0, 0, loc)++		var readings []Reading+		for t := dayStart; t.Before(dayEnd); t = t.Add(time.Minute) {+			pgrid := 1000.0+			if !t.Before(offStart) && t.Before(offEnd) {+				pgrid = 0+			}+			readings = append(readings, Reading{Timestamp: t.Unix(), Pgrid: pgrid})+		}++		kwh, _, _, ok := IntegratePeakGridImportKwh(readings, dayStart.Unix(), offStart.Unix(), offEnd.Unix(), dayEnd.Unix())+		require.True(t, ok)+		// 25h day minus 3h off-peak = 22h of 1000 W ≈ 22 kWh.+		assert.InDelta(t, 22.0, kwh, 0.05)+	})++	t.Run("peak + offpeak within 3% of eInput on a representative day", func(t *testing.T) {+		// Full day with grid import all day: integrating the whole day (peak+		// sub-windows + off-peak window) must reconstruct the day's total grid+		// import — the eInput analogue — to within 3% (Requirement: same+		// numerical method, same per-window artifact).+		loc, err := time.LoadLocation("Australia/Sydney")+		require.NoError(t, err)+		dayStart := time.Date(2026, 5, 20, 0, 0, 0, 0, loc)+		dayEnd := dayStart.AddDate(0, 0, 1)+		offStart := time.Date(2026, 5, 20, 11, 0, 0, 0, loc)+		offEnd := time.Date(2026, 5, 20, 14, 0, 0, 0, loc)++		// 10s readings, varying but always-positive grid import.+		var readings []Reading+		var trueWattSeconds float64+		prev := -1.0+		var prevTS int64+		for ts := dayStart.Unix(); ts < dayEnd.Unix(); ts += 10 {+			p := 400.0 + 300.0*float64((ts/10)%5) // 400..1600 W+			readings = append(readings, Reading{Timestamp: ts, Pgrid: p})+			if prev >= 0 {+				trueWattSeconds += (prev + p) / 2 * 10+			}+			prev = p+			prevTS = ts+		}+		_ = prevTS+		dayKwh := trueWattSeconds / 3_600_000++		peak, _, _, ok := IntegratePeakGridImportKwh(readings, dayStart.Unix(), offStart.Unix(), offEnd.Unix(), dayEnd.Unix())+		require.True(t, ok)+		off, offOK := IntegrateOffpeakDeltas(readings, offStart.Unix(), offEnd.Unix())+		require.True(t, offOK)++		total := peak + off.GridImportKwh+		assert.InDelta(t, dayKwh, total, dayKwh*0.03, "peak+offpeak must be within 3%% of the full-day integral")+	})+}
internal/dynamo/models.go Modified +23
diff --git a/internal/dynamo/models.go b/internal/dynamo/models.goindex 7b29fd2..5cf1446 100644--- a/internal/dynamo/models.go+++ b/internal/dynamo/models.go@@ -48,6 +48,21 @@ type DailyEnergyItem struct { 	SocLow                 *SocLowAttr      `dynamodbav:"socLow,omitempty"` 	PeakPeriods            []PeakPeriodAttr `dynamodbav:"peakPeriods,omitempty"` 	DerivedStatsComputedAt string           `dynamodbav:"derivedStatsComputedAt,omitempty"`++	// PeakGridImportKwh is the trapezoidal integration of max(pgrid,0) over the+	// two windows bracketing off-peak (peak-from-readings spec). It is computed+	// independently of the derivedStats block above and gated on its own+	// PeakComputedAt sentinel (Decision 3): the hourly pass forward-fills it+	// onto each day's row as that day becomes "yesterday", and the+	// cmd/backfill-grid CLI fills pre-deploy historical rows within the readings+	// TTL (Decision 7) — neither redoes the other derived stats. Absent when the+	// integration's usability gate fails for either sub-window.+	//+	// Storage naming note (Decision 6): the off-peak counterpart is stored as+	// OffpeakItem.GridUsageKwh; this field uses "Import" to match the API key+	// peakGridImportKwh. The divergence is intentional and not worth a rename.+	PeakGridImportKwh *float64 `dynamodbav:"peakGridImportKwh,omitempty"`+	PeakComputedAt    string   `dynamodbav:"peakComputedAt,omitempty"` }  // DailyUsageAttr is the storage shape for derivedstats.DailyUsage.@@ -92,6 +107,15 @@ type DerivedStats struct { 	SocLow                 *SocLowAttr 	PeakPeriods            []PeakPeriodAttr 	DerivedStatsComputedAt string++	// PeakGridImportKwh / PeakComputedAt carry the peak-from-readings result.+	// They have an independent lifecycle from the four fields above: the+	// summarisation pass may set only these (on a row that already has derived+	// stats) or only the four above (on a row whose readings fail the peak+	// usability gate). UpdateDailyEnergyDerived writes each group only when its+	// sentinel is non-empty.+	PeakGridImportKwh *float64+	PeakComputedAt    string }  // DailyPowerItem represents a row in the flux-daily-power table.
internal/dynamo/dynamostore.go Modified +66 / -16
diff --git a/internal/dynamo/dynamostore.go b/internal/dynamo/dynamostore.goindex c323d2e..c935900 100644--- a/internal/dynamo/dynamostore.go+++ b/internal/dynamo/dynamostore.go@@ -6,6 +6,7 @@ import ( 	"fmt" 	"log/slog" 	"strconv"+	"strings"  	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" 	"github.com/aws/aws-sdk-go-v2/service/dynamodb"@@ -78,41 +79,78 @@ func (s *DynamoStore) WriteDailyEnergy(ctx context.Context, item DailyEnergyItem 	return nil } -// UpdateDailyEnergyDerived sets the four derivedStats attributes-// (dailyUsage, socLow, peakPeriods, derivedStatsComputedAt) on a-// flux-daily-energy row in a single UpdateItem SET expression. The energy-// attributes (epv, eInput, …) are left untouched; running this against a row-// that does not yet exist will create the row with only derivedStats and no-// energy totals — callers must precheck via GetDailyEnergy when that is-// undesirable (the daily-derived-stats summarisation pass does so per AC 1.4).+// UpdateDailyEnergyDerived writes two independent attribute groups on a+// flux-daily-energy row via a single UpdateItem SET expression, each gated on+// its own sentinel being non-empty (peak-from-readings Decision 3):+//   - the derivedStats group (dailyUsage, socLow, peakPeriods,+//     derivedStatsComputedAt), gated on stats.DerivedStatsComputedAt;+//   - the peak group (peakGridImportKwh, peakComputedAt), gated on+//     stats.PeakComputedAt.+//+// Either group may be written without the other, so a row that already has+// derivedStats can have peak filled later (and vice versa) without clobbering.+// The energy attributes (epv, eInput, …) are left untouched; running this+// against a row that does not yet exist will create the row with only these+// derived attributes and no energy totals — callers must precheck via+// GetDailyEnergy when that is undesirable (the daily-derived-stats+// summarisation pass does so per AC 1.4, and cmd/backfill-grid does so for the+// peak group to avoid phantom rows). // // Invariant: this is the only write path for the dailyUsage attribute outside-// the cmd/backfill-solar CLI. New writers must not be added without revisiting-// the backfill's idempotency assumptions (specs/solar-by-block/design.md).+// the cmd/backfill-solar CLI. The peak group is additionally written by+// cmd/backfill-grid (gated on peakComputedAt). New writers of the derivedStats+// group must not be added without revisiting the backfill's idempotency+// assumptions (specs/solar-by-block/design.md). func (s *DynamoStore) UpdateDailyEnergyDerived(ctx context.Context, sysSn, date string, stats DerivedStats) error { 	tableName := s.tables.DailyEnergy -	dailyUsageAV, err := attributevalue.Marshal(stats.DailyUsage)-	if err != nil {-		return fmt.Errorf("marshal dailyUsage (sysSn=%s, date=%s): %w", sysSn, date, err)-	}-	socLowAV, err := attributevalue.Marshal(stats.SocLow)-	if err != nil {-		return fmt.Errorf("marshal socLow (sysSn=%s, date=%s): %w", sysSn, date, err)+	// The derivedStats group and the peak group have independent lifecycles+	// (peak-from-readings Decision 3). Each is written only when its own sentinel is non-empty,+	// so the summarisation pass can fill peak on a row that already has derived+	// stats — and vice versa — without clobbering the other group with zero+	// values. At least one group is always present in a real call; an empty+	// stats produces a no-op write guarded below.+	sets := make([]string, 0, 6)+	values := map[string]types.AttributeValue{}++	if stats.DerivedStatsComputedAt != "" {+		dailyUsageAV, err := attributevalue.Marshal(stats.DailyUsage)+		if err != nil {+			return fmt.Errorf("marshal dailyUsage (sysSn=%s, date=%s): %w", sysSn, date, err)+		}+		socLowAV, err := attributevalue.Marshal(stats.SocLow)+		if err != nil {+			return fmt.Errorf("marshal socLow (sysSn=%s, date=%s): %w", sysSn, date, err)+		}+		peakPeriodsAV, err := attributevalue.Marshal(stats.PeakPeriods)+		if err != nil {+			return fmt.Errorf("marshal peakPeriods (sysSn=%s, date=%s): %w", sysSn, date, err)+		}+		sets = append(sets, "dailyUsage = :du", "socLow = :sl", "peakPeriods = :pp", "derivedStatsComputedAt = :ts")+		values[":du"] = dailyUsageAV+		values[":sl"] = socLowAV+		values[":pp"] = peakPeriodsAV+		values[":ts"] = &types.AttributeValueMemberS{Value: stats.DerivedStatsComputedAt} 	}-	peakPeriodsAV, err := attributevalue.Marshal(stats.PeakPeriods)-	if err != nil {-		return fmt.Errorf("marshal peakPeriods (sysSn=%s, date=%s): %w", sysSn, date, err)++	if stats.PeakComputedAt != "" {+		peakAV, err := attributevalue.Marshal(stats.PeakGridImportKwh)+		if err != nil {+			return fmt.Errorf("marshal peakGridImportKwh (sysSn=%s, date=%s): %w", sysSn, date, err)+		}+		sets = append(sets, "peakGridImportKwh = :pk", "peakComputedAt = :pkts")+		values[":pk"] = peakAV+		values[":pkts"] = &types.AttributeValueMemberS{Value: stats.PeakComputedAt} 	} -	updateExpr := "SET dailyUsage = :du, socLow = :sl, peakPeriods = :pp, derivedStatsComputedAt = :ts"-	values := map[string]types.AttributeValue{-		":du": dailyUsageAV,-		":sl": socLowAV,-		":pp": peakPeriodsAV,-		":ts": &types.AttributeValueMemberS{Value: stats.DerivedStatsComputedAt},+	if len(sets) == 0 {+		// Nothing to write — neither sentinel set. Treat as a no-op rather+		// than issuing an empty UpdateExpression (which DynamoDB rejects).+		return nil 	}-	_, err = s.client.UpdateItem(ctx, &dynamodb.UpdateItemInput{++	updateExpr := "SET " + strings.Join(sets, ", ")+	_, err := s.client.UpdateItem(ctx, &dynamodb.UpdateItemInput{ 		TableName: &tableName, 		Key: map[string]types.AttributeValue{ 			"sysSn": &types.AttributeValueMemberS{Value: sysSn},@@ -221,10 +259,7 @@ func (s *DynamoStore) WriteDailyPower(ctx context.Context, items []DailyPowerIte 	}  	for i := 0; i < len(items); i += batchWriteMax {-		end := i + batchWriteMax-		if end > len(items) {-			end = len(items)-		}+		end := min(i+batchWriteMax, len(items))  		requests := make([]types.WriteRequest, 0, end-i) 		for _, item := range items[i:end] {
internal/dynamo/derived_store_test.go Modified +115
diff --git a/internal/dynamo/derived_store_test.go b/internal/dynamo/derived_store_test.goindex 7a6a8ab..71cd832 100644--- a/internal/dynamo/derived_store_test.go+++ b/internal/dynamo/derived_store_test.go@@ -180,6 +180,8 @@ func TestWriteDailyEnergy_StructTagCoverage(t *testing.T) { 		"socLow":                 true, 		"peakPeriods":            true, 		"derivedStatsComputedAt": true,+		"peakGridImportKwh":      true,+		"peakComputedAt":         true, 	} 	keyTags := map[string]bool{ 		"sysSn": true,@@ -200,9 +202,8 @@ func TestWriteDailyEnergy_StructTagCoverage(t *testing.T) { 	require.NotNil(t, gotInput.UpdateExpression) 	expr := *gotInput.UpdateExpression -	rt := reflect.TypeOf(DailyEnergyItem{})-	for i := range rt.NumField() {-		fld := rt.Field(i)+	rt := reflect.TypeFor[DailyEnergyItem]()+	for fld := range rt.Fields() { 		tag := fld.Tag.Get("dynamodbav") 		if tag == "" { 			continue@@ -220,6 +221,114 @@ func TestWriteDailyEnergy_StructTagCoverage(t *testing.T) { 	} } +// TestDynamoStore_UpdateDailyEnergyDerived_Peak covers the independent peak+// group (Decision 3): peak written without derivedStats, derivedStats written+// without peak, and an absent peak value (nil pointer) marshalled as NULL.+func TestDynamoStore_UpdateDailyEnergyDerived_Peak(t *testing.T) {+	t.Run("peak only — derivedStats attributes absent", func(t *testing.T) {+		peak := 0.42+		var gotInput *dynamodb.UpdateItemInput+		mock := &fakeDynamoAPIv2{+			updateItemFn: func(_ context.Context, params *dynamodb.UpdateItemInput) (*dynamodb.UpdateItemOutput, error) {+				gotInput = params+				return &dynamodb.UpdateItemOutput{}, nil+			},+		}+		store := NewDynamoStore(mock, testTables())+		require.NoError(t, store.UpdateDailyEnergyDerived(context.Background(), "AB1234", "2026-04-12", DerivedStats{+			PeakGridImportKwh: &peak,+			PeakComputedAt:    "2026-04-13T00:30:00Z",+		}))++		require.NotNil(t, gotInput)+		expr := *gotInput.UpdateExpression+		assert.Contains(t, expr, "peakGridImportKwh")+		assert.Contains(t, expr, "peakComputedAt")+		// derivedStats attributes must NOT appear when that group is absent.+		for _, name := range []string{"dailyUsage", "socLow", "peakPeriods", "derivedStatsComputedAt"} {+			assert.NotContains(t, expr, name, "derivedStats attribute %s must be absent when its sentinel is unset", name)+		}+		// The peak value marshals as a number.+		_, isNum := gotInput.ExpressionAttributeValues[":pk"].(*types.AttributeValueMemberN)+		assert.True(t, isNum, "non-nil peak must marshal as a number")+	})++	t.Run("derivedStats only — peak attributes absent", func(t *testing.T) {+		var gotInput *dynamodb.UpdateItemInput+		mock := &fakeDynamoAPIv2{+			updateItemFn: func(_ context.Context, params *dynamodb.UpdateItemInput) (*dynamodb.UpdateItemOutput, error) {+				gotInput = params+				return &dynamodb.UpdateItemOutput{}, nil+			},+		}+		store := NewDynamoStore(mock, testTables())+		require.NoError(t, store.UpdateDailyEnergyDerived(context.Background(), "AB1234", "2026-04-12", DerivedStats{+			DerivedStatsComputedAt: "2026-04-13T00:30:00Z",+		}))++		require.NotNil(t, gotInput)+		expr := *gotInput.UpdateExpression+		assert.Contains(t, expr, "derivedStatsComputedAt")+		assert.NotContains(t, expr, "peakGridImportKwh")+		assert.NotContains(t, expr, "peakComputedAt")+	})++	t.Run("nil peak with sentinel set marshals as NULL", func(t *testing.T) {+		var gotInput *dynamodb.UpdateItemInput+		mock := &fakeDynamoAPIv2{+			updateItemFn: func(_ context.Context, params *dynamodb.UpdateItemInput) (*dynamodb.UpdateItemOutput, error) {+				gotInput = params+				return &dynamodb.UpdateItemOutput{}, nil+			},+		}+		store := NewDynamoStore(mock, testTables())+		require.NoError(t, store.UpdateDailyEnergyDerived(context.Background(), "AB1234", "2026-04-12", DerivedStats{+			PeakComputedAt: "2026-04-13T00:30:00Z",+		}))+		require.NotNil(t, gotInput)+		_, isNull := gotInput.ExpressionAttributeValues[":pk"].(*types.AttributeValueMemberNULL)+		assert.True(t, isNull, "nil peak must marshal as NULL")+	})+}++// TestDailyEnergyItem_PeakRoundTrip confirms the peakGridImportKwh attribute+// is present after marshalling when set, and absent (omitempty) when nil.+func TestDailyEnergyItem_PeakRoundTrip(t *testing.T) {+	t.Run("set field present in marshalled item and round-trips", func(t *testing.T) {+		peak := 0.37+		in := DailyEnergyItem{+			SysSn: "AB1234", Date: "2026-04-12", EInput: 4.2,+			PeakGridImportKwh: &peak,+			PeakComputedAt:    "2026-04-13T00:30:00Z",+		}+		av, err := attributevalue.MarshalMap(in)+		require.NoError(t, err)+		_, present := av["peakGridImportKwh"]+		assert.True(t, present, "peakGridImportKwh must be present when set")++		var out DailyEnergyItem+		require.NoError(t, attributevalue.UnmarshalMap(av, &out))+		require.NotNil(t, out.PeakGridImportKwh)+		assert.InDelta(t, peak, *out.PeakGridImportKwh, 1e-9)+		assert.Equal(t, "2026-04-13T00:30:00Z", out.PeakComputedAt)+	})++	t.Run("nil field omitted from marshalled item", func(t *testing.T) {+		in := DailyEnergyItem{SysSn: "AB1234", Date: "2026-04-12", EInput: 4.2}+		av, err := attributevalue.MarshalMap(in)+		require.NoError(t, err)+		_, present := av["peakGridImportKwh"]+		assert.False(t, present, "peakGridImportKwh must be omitted when nil (omitempty)")+		_, tsPresent := av["peakComputedAt"]+		assert.False(t, tsPresent, "peakComputedAt must be omitted when empty")++		var out DailyEnergyItem+		require.NoError(t, attributevalue.UnmarshalMap(av, &out))+		assert.Nil(t, out.PeakGridImportKwh)+		assert.Empty(t, out.PeakComputedAt)+	})+}+ // TestLogStore_DerivedStatsStubs verifies the dry-run LogStore implements // the new write methods without crashing. func TestLogStore_DerivedStatsStubs(t *testing.T) {
internal/poller/dailysummary.go Modified +69 / -19
diff --git a/internal/poller/dailysummary.go b/internal/poller/dailysummary.goindex 323ee3f..23a3467 100644--- a/internal/poller/dailysummary.go+++ b/internal/poller/dailysummary.go@@ -50,15 +50,26 @@ func (p *Poller) runSummarisationPass(ctx context.Context, date string) string { 		// energy poll create the row. 		slog.Info("summary skipped: no daily-energy row yet", "date", date) 		return PassResultSkippedNoRow-	case item.DerivedStatsComputedAt != "":-		// AC 1.10 / Decision 8 — sentinel present means a prior pass succeeded.+	}++	// Two orthogonal sentinels gate two independent compute blocks+	// (peak-from-readings Decision 3). Skip the whole pass only when BOTH are+	// set; otherwise compute whichever group is still missing. A row with+	// derived stats but no peak (e.g. pre-feature row picked up after deploy)+	// gets only peak written.+	needDerived := item.DerivedStatsComputedAt == ""+	needPeak := item.PeakComputedAt == ""+	if !needDerived && !needPeak {+		// AC 1.10 / daily-derived-stats Decision 8 — both sentinels present+		// means a prior pass computed everything. 		return PassResultSkippedAlreadyDone 	} -	// 2. Off-peak window resolution (AC 1.6 / 1.14).+	// 2. Off-peak window resolution (AC 1.6 / 1.14). Needed by both blocks. 	offpeakStart := config.FormatHHMM(p.cfg.OffpeakStart) 	offpeakEnd := config.FormatHHMM(p.cfg.OffpeakEnd)-	if _, _, ok := derivedstats.ParseOffpeakWindow(offpeakStart, offpeakEnd); !ok {+	startMin, endMin, ok := derivedstats.ParseOffpeakWindow(offpeakStart, offpeakEnd)+	if !ok { 		slog.Warn("summary skipped: off-peak window unresolved", "date", date) 		return PassResultSkippedSSMUnresolved 	}@@ -76,30 +87,51 @@ func (p *Poller) runSummarisationPass(ctx context.Context, date string) string { 		return PassResultSkippedNoReadings 	} 	readings := summaryToDerivedReadings(rawReadings)--	// 4. Compute derivedStats. Pass `today=date` so the today-gate cannot-	// fire on a completed date (AC 1.2 + the "today" parameter contract on-	// derivedstats.Blocks). 	now := p.now()-	socLow, socLowTS, socFound := derivedstats.MinSOC(readings)-	derived := dynamo.DerivedStats{-		DailyUsage:             dynamo.DailyUsageToAttr(derivedstats.Blocks(readings, offpeakStart, offpeakEnd, date, date, now)),-		PeakPeriods:            dynamo.PeakPeriodsToAttr(derivedstats.PeakPeriods(readings, offpeakStart, offpeakEnd)),-		DerivedStatsComputedAt: now.UTC().Format(time.RFC3339),++	var derived dynamo.DerivedStats++	// 4a. derivedStats block — gated on its own sentinel. Pass `today=date` so+	// the today-gate cannot fire on a completed date (AC 1.2 + the "today"+	// parameter contract on derivedstats.Blocks).+	if needDerived {+		socLow, socLowTS, socFound := derivedstats.MinSOC(readings)+		derived.DailyUsage = dynamo.DailyUsageToAttr(derivedstats.Blocks(readings, offpeakStart, offpeakEnd, date, date, now))+		derived.PeakPeriods = dynamo.PeakPeriodsToAttr(derivedstats.PeakPeriods(readings, offpeakStart, offpeakEnd))+		derived.DerivedStatsComputedAt = now.UTC().Format(time.RFC3339)+		if socFound {+			derived.SocLow = &dynamo.SocLowAttr{+				Soc:       socLow,+				Timestamp: time.Unix(socLowTS, 0).UTC().Format(time.RFC3339),+			}+		} 	}-	if socFound {-		derived.SocLow = &dynamo.SocLowAttr{-			Soc:       socLow,-			Timestamp: time.Unix(socLowTS, 0).UTC().Format(time.RFC3339),++	// 4b. Peak grid import block — gated on its own sentinel. The off-peak+	// window bounds the two peak sub-windows. Boundaries are derived from the+	// DST-correct dayStart so 23h/25h Sydney days integrate correctly. When+	// the usability gate fails for either sub-window the field is left absent+	// (PeakGridImportKwh stays nil), but the sentinel is still set so the row+	// is not re-attempted every hour.+	if needPeak {+		offpeakStartUnix := dayStart.Add(time.Duration(startMin) * time.Minute).Unix()+		offpeakEndUnix := dayStart.Add(time.Duration(endMin) * time.Minute).Unix()+		kwh, _, _, peakOK := derivedstats.IntegratePeakGridImportKwh(+			readings, dayStart.Unix(), offpeakStartUnix, offpeakEndUnix, dayEnd.Unix())+		if peakOK {+			rounded := derivedstats.RoundEnergy(kwh)+			derived.PeakGridImportKwh = &rounded 		}+		derived.PeakComputedAt = now.UTC().Format(time.RFC3339) 	} -	// 5. Write — single SET expression covers all four attributes atomically.+	// 5. Write — UpdateDailyEnergyDerived writes each group only when its+	// sentinel is non-empty, so an already-computed group is never clobbered. 	if err := p.store.UpdateDailyEnergyDerived(ctx, p.cfg.Serial, date, derived); err != nil { 		slog.Error("summary write failed", "date", date, "error", err) 		return PassResultError 	}-	slog.Info("summary written", "date", date)+	slog.Info("summary written", "date", date, "wroteDerived", needDerived, "wrotePeak", needPeak) 	return PassResultSuccess } 
internal/poller/dailysummary_peak_test.go Added +106
diff --git a/internal/poller/dailysummary_peak_test.go b/internal/poller/dailysummary_peak_test.gonew file mode 100644index 0000000..6487fdb--- /dev/null+++ b/internal/poller/dailysummary_peak_test.go@@ -0,0 +1,106 @@+package poller++import (+	"context"+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// makePeakReadings builds 10s-spaced readings across the whole day with a+// constant positive grid import, so both peak sub-windows pass the usability+// gate and the integrated peak value is non-zero.+func makePeakReadings(date string, loc *time.Location, pgrid float64) []dynamo.ReadingItem {+	dayStart, _ := time.ParseInLocation("2006-01-02", date, loc)+	dayEnd := dayStart.AddDate(0, 0, 1)+	var out []dynamo.ReadingItem+	for t := dayStart; t.Before(dayEnd); t = t.Add(10 * time.Second) {+		out = append(out, dynamo.ReadingItem{+			Timestamp: t.Unix(),+			Pgrid:     pgrid,+			Soc:       50,+		})+	}+	return out+}++// TestSummarisation_PeakWrittenWhenDerivedAlreadyDone covers the Decision 3+// backfill path: a row that already has derivedStats but no peak gets peak+// written on the next tick, and the derivedStats group is left untouched.+func TestSummarisation_PeakWrittenWhenDerivedAlreadyDone(t *testing.T) {+	loc, _ := time.LoadLocation("Australia/Sydney")+	ms := &mockStore{+		getDailyEnergyResult: &dynamo.DailyEnergyItem{+			SysSn: "TEST123", Date: "2026-04-14",+			DerivedStatsComputedAt: "2026-04-14T22:00:00Z",+			// PeakComputedAt absent → peak still needed.+		},+		queryReadingsResult: makePeakReadings("2026-04-14", loc, 1000),+	}+	p, _ := summarisationFixturePoller(t, ms)++	result := p.runSummarisationPass(context.Background(), "2026-04-14")+	assert.Equal(t, PassResultSuccess, result)+	require.Equal(t, 1, ms.derivedUpdates)+	require.NotNil(t, ms.lastDerived)++	// Only the peak group was written; the derivedStats group is left empty so+	// UpdateDailyEnergyDerived does not re-touch it.+	assert.Empty(t, ms.lastDerived.DerivedStatsComputedAt, "derivedStats group must not be rewritten")+	assert.Nil(t, ms.lastDerived.DailyUsage, "derivedStats group must not be rewritten")+	assert.NotEmpty(t, ms.lastDerived.PeakComputedAt, "peak sentinel must be set")+	require.NotNil(t, ms.lastDerived.PeakGridImportKwh)+	assert.Greater(t, *ms.lastDerived.PeakGridImportKwh, 0.0, "constant 1000W import yields positive peak kWh")+}++// TestSummarisation_PeakSkippedWhenBothSentinelsSet confirms the pass skips+// (and does not query readings) only when BOTH sentinels are present.+func TestSummarisation_PeakSkippedWhenBothSentinelsSet(t *testing.T) {+	ms := &mockStore{+		getDailyEnergyResult: &dynamo.DailyEnergyItem{+			SysSn: "TEST123", Date: "2026-04-14",+			DerivedStatsComputedAt: "2026-04-14T22:00:00Z",+			PeakComputedAt:         "2026-04-14T22:00:00Z",+		},+	}+	p, _ := summarisationFixturePoller(t, ms)++	result := p.runSummarisationPass(context.Background(), "2026-04-14")+	assert.Equal(t, PassResultSkippedAlreadyDone, result)+	assert.Zero(t, ms.derivedUpdates)+	assert.Nil(t, ms.queryReadingsResult, "no readings query when both sentinels set")+}++// TestSummarisation_PeakGateFailureLeavesFieldUnwritten verifies that when a+// peak sub-window fails the usability gate, the peak value is left absent but+// the sentinel is still set (so the row is not retried every hour).+func TestSummarisation_PeakGateFailureLeavesFieldUnwritten(t *testing.T) {+	loc, _ := time.LoadLocation("Australia/Sydney")+	// Build readings only in the off-peak and evening windows, leaving the+	// morning window [00:00, 11:00) with no usable readings → gate fails.+	dayStart, _ := time.ParseInLocation("2006-01-02", "2026-04-14", loc)+	var readings []dynamo.ReadingItem+	for h := 11; h < 24; h++ {+		for m := range 60 {+			t := dayStart.Add(time.Duration(h)*time.Hour + time.Duration(m)*time.Minute)+			readings = append(readings, dynamo.ReadingItem{Timestamp: t.Unix(), Pgrid: 500, Soc: 50})+		}+	}+	ms := &mockStore{+		getDailyEnergyResult: &dynamo.DailyEnergyItem{+			SysSn: "TEST123", Date: "2026-04-14",+			DerivedStatsComputedAt: "2026-04-14T22:00:00Z",+		},+		queryReadingsResult: readings,+	}+	p, _ := summarisationFixturePoller(t, ms)++	result := p.runSummarisationPass(context.Background(), "2026-04-14")+	assert.Equal(t, PassResultSuccess, result)+	require.NotNil(t, ms.lastDerived)+	assert.NotEmpty(t, ms.lastDerived.PeakComputedAt, "sentinel set even on gate failure to avoid hourly retries")+	assert.Nil(t, ms.lastDerived.PeakGridImportKwh, "gate failure leaves the peak value absent")+}
internal/poller/dailysummary_test.go Modified +5 / -2
diff --git a/internal/poller/dailysummary_test.go b/internal/poller/dailysummary_test.goindex dd2294f..f203833 100644--- a/internal/poller/dailysummary_test.go+++ b/internal/poller/dailysummary_test.go@@ -110,17 +110,19 @@ func TestSummarisation_NoRow(t *testing.T) { }  func TestSummarisation_AlreadyPopulated(t *testing.T) {+	// Both sentinels present → the pass skips entirely (Decision 3). 	ms := &mockStore{ 		getDailyEnergyResult: &dynamo.DailyEnergyItem{ 			SysSn: "TEST123", Date: "2026-04-14", 			DerivedStatsComputedAt: "2026-04-14T22:00:00Z",+			PeakComputedAt:         "2026-04-14T22:00:00Z", 		}, 	} 	p, _ := summarisationFixturePoller(t, ms)  	result := p.runSummarisationPass(context.Background(), "2026-04-14") 	assert.Equal(t, PassResultSkippedAlreadyDone, result)-	// Critical: must NOT issue a readings query when sentinel is present+	// Critical: must NOT issue a readings query when both sentinels are present 	// (per AC 1.10 and the design — the precheck saves the query cost). 	assert.Nil(t, ms.queryReadingsResult, "queryReadingsResult unset means QueryReadings must not have been called for default") }@@ -298,6 +300,7 @@ func TestSummarisation_PrecheckShortCircuits_NoReadingsQuery(t *testing.T) { 		getDailyEnergyResult: &dynamo.DailyEnergyItem{ 			SysSn: "TEST123", Date: "2026-04-14", 			DerivedStatsComputedAt: "2026-04-14T22:00:00Z",+			PeakComputedAt:         "2026-04-14T22:00:00Z", 		}, 	} 	p, _ := summarisationFixturePoller(t, ms)
internal/api/response.go Modified +2
diff --git a/internal/api/response.go b/internal/api/response.goindex f680714..6ce10c3 100644--- a/internal/api/response.go+++ b/internal/api/response.go@@ -106,6 +106,7 @@ type DayEnergy struct { 	EDischarge           float64                   `json:"eDischarge"` 	OffpeakGridImportKwh *float64                  `json:"offpeakGridImportKwh,omitempty"` 	OffpeakGridExportKwh *float64                  `json:"offpeakGridExportKwh,omitempty"`+	PeakGridImportKwh    *float64                  `json:"peakGridImportKwh,omitempty"` 	DailyUsage           *derivedstats.DailyUsage  `json:"dailyUsage,omitempty"` 	SocLow               *float64                  `json:"socLow,omitempty"` 	SocLowTime           *string                   `json:"socLowTime,omitempty"`@@ -175,4 +176,5 @@ type DaySummary struct { 	SocLowTime           *string  `json:"socLowTime"` 	OffpeakGridImportKwh *float64 `json:"offpeakGridImportKwh,omitempty"` 	OffpeakGridExportKwh *float64 `json:"offpeakGridExportKwh,omitempty"`+	PeakGridImportKwh    *float64 `json:"peakGridImportKwh,omitempty"` }
internal/api/day.go Modified +6
diff --git a/internal/api/day.go b/internal/api/day.goindex f092faa..1695839 100644--- a/internal/api/day.go+++ b/internal/api/day.go@@ -202,6 +202,12 @@ func (h *Handler) handleDay(ctx context.Context, req events.LambdaFunctionURLReq 				summary.OffpeakGridExportKwh = floatPtr(exp) 			} 		}+		// Peak grid import is the stored server-computed value only — no+		// real-time compute path for today (Decision 4); today's row is absent+		// and the iOS fallback applies.+		if deItem != nil && deItem.PeakGridImportKwh != nil {+			summary.PeakGridImportKwh = floatPtr(*deItem.PeakGridImportKwh)+		} 		resp.Summary = summary 	} 
internal/api/history.go Modified +5
diff --git a/internal/api/history.go b/internal/api/history.goindex 70d1fcd..93b4203 100644--- a/internal/api/history.go+++ b/internal/api/history.go@@ -155,6 +155,11 @@ func (h *Handler) handleHistory(ctx context.Context, req events.LambdaFunctionUR 				day.OffpeakGridExportKwh = floatPtr(exp) 			} 		}+		// Peak grid import is the stored server-computed value only (Decision 4):+		// today's row has no stored peak, so the iOS fallback applies there.+		if item.PeakGridImportKwh != nil {+			day.PeakGridImportKwh = floatPtr(*item.PeakGridImportKwh)+		} 		if note, ok := notesByDate[item.Date]; ok { 			n := note 			day.Note = &n
internal/api/peak_grid_import_test.go Added +104
diff --git a/internal/api/peak_grid_import_test.go b/internal/api/peak_grid_import_test.gonew file mode 100644index 0000000..e7f7ffc--- /dev/null+++ b/internal/api/peak_grid_import_test.go@@ -0,0 +1,104 @@+package api++import (+	"context"+	"strings"+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// TestHandleDayPeakGridImport covers the /day peakGridImportKwh field: present+// with the stored value when the daily-energy row carries it, and absent from+// the JSON when the stored value is nil (Decision 4 — no real-time path).+func TestHandleDayPeakGridImport(t *testing.T) {+	// A past date so the stored value is authoritative (today would have no+	// stored peak and fall through to the iOS fallback).+	date := "2026-04-10"++	t.Run("present uses stored value", func(t *testing.T) {+		peak := 0.42+		mr := &mockReader{+			getDailyEnergyFn: func(_ context.Context, _, _ string) (*dynamo.DailyEnergyItem, error) {+				return &dynamo.DailyEnergyItem{+					Date: date, EInput: 4.2, PeakGridImportKwh: &peak,+				}, nil+			},+		}+		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h.nowFunc = fixedNow++		resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))+		require.NoError(t, err)+		require.Equal(t, 200, resp.StatusCode)++		dr := parseDayResponse(t, resp)+		require.NotNil(t, dr.Summary)+		require.NotNil(t, dr.Summary.PeakGridImportKwh)+		assert.InDelta(t, 0.42, *dr.Summary.PeakGridImportKwh, 1e-9)+		assert.Contains(t, resp.Body, "peakGridImportKwh")+	})++	t.Run("absent omits the key", func(t *testing.T) {+		mr := &mockReader{+			getDailyEnergyFn: func(_ context.Context, _, _ string) (*dynamo.DailyEnergyItem, error) {+				return &dynamo.DailyEnergyItem{Date: date, EInput: 4.2}, nil+			},+		}+		h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+		h.nowFunc = fixedNow++		resp, err := h.Handle(context.Background(), dayRequest(map[string]string{"date": date}))+		require.NoError(t, err)+		require.Equal(t, 200, resp.StatusCode)++		dr := parseDayResponse(t, resp)+		require.NotNil(t, dr.Summary)+		assert.Nil(t, dr.Summary.PeakGridImportKwh)+		assert.NotContains(t, resp.Body, "peakGridImportKwh", "omitempty must drop the key when nil")+	})+}++// TestHandleHistoryPeakGridImport covers the /history peakGridImportKwh field+// across rows: one carries the stored value, one does not.+func TestHandleHistoryPeakGridImport(t *testing.T) {+	now := fixedNow()+	peak := 0.37++	mr := &mockReader{+		queryDailyEnergyFn: func(_ context.Context, _, _, _ string) ([]dynamo.DailyEnergyItem, error) {+			return []dynamo.DailyEnergyItem{+				{Date: "2026-04-10", EInput: 2.3, PeakGridImportKwh: &peak},+				{Date: "2026-04-11", EInput: 3.0}, // no peak+			}, nil+		},+	}+	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")+	h.nowFunc = func() time.Time { return now }++	resp, err := h.Handle(context.Background(), historyRequest(map[string]string{"days": "30"}))+	require.NoError(t, err)+	require.Equal(t, 200, resp.StatusCode)++	hr := parseHistoryResponse(t, resp)+	require.Len(t, hr.Days, 2)++	byDate := map[string]DayEnergy{}+	for _, d := range hr.Days {+		byDate[d.Date] = d+	}++	withPeak := byDate["2026-04-10"]+	require.NotNil(t, withPeak.PeakGridImportKwh)+	assert.InDelta(t, 0.37, *withPeak.PeakGridImportKwh, 1e-9)++	without := byDate["2026-04-11"]+	assert.Nil(t, without.PeakGridImportKwh, "row without stored peak must omit the field")++	// The key appears (for the populated row) but exactly once.+	assert.Equal(t, 1, strings.Count(resp.Body, "peakGridImportKwh"),+		"only the populated row should carry peakGridImportKwh")+}
cmd/backfill-grid/main.go Renamed+Modified +264 / -...
diff --git a/cmd/backfill-grid/main.go b/cmd/backfill-grid/main.gonew file mode 100644index 0000000..7173163--- /dev/null+++ b/cmd/backfill-grid/main.go@@ -0,0 +1,540 @@+// Package main is the standalone backfill CLI for the two readings-derived+// grid-import channels: off-peak (off-peak from readings, T-1341) and peak+// (peak from readings; Decision 7 renamed this tool from backfill-offpeak).+//+// For each non-today date in a range it does two things:+//+//  1. Off-peak (unchanged): recomputes the five flux-offpeak energy deltas+//     (gridUsageKwh, solarKwh, batteryChargeKwh, batteryDischargeKwh,+//     gridExportKwh) by integrating the power channels from flux-readings over+//     the SSM off-peak window, writing via WriteOffpeakIfComplete so a row+//     mid-poll (pending or absent) is never overwritten (AC 7.8).+//+//  2. Peak: computes peakGridImportKwh by integrating max(pgrid,0) over the+//     two windows bracketing off-peak ([dayStart, offpeakStart) and+//     [offpeakEnd, dayEnd)) and writes it plus the peakComputedAt sentinel to+//     the corresponding flux-daily-energy row via UpdateDailyEnergyDerived+//     (peak group only — the derived-stats group is left untouched). The+//     daily-energy row is fetched first; if it is absent the date is skipped+//     for peak (no phantom-row creation, Decision 7). If the integration's+//     usability gate fails for either sub-window the date keeps the iOS+//     fallback (Decision 4).+//+// Today's row is always skipped on both sides — the poller is the single+// authoritative writer for today (AC 7.2 / Decision 4).+//+// Usage (with operator AWS credentials):+//+//	go run ./cmd/backfill-grid \+//	    --serial=AB1234 \+//	    --from=2026-04-19 --to=2026-05-18 \+//	    --offpeak-start=11:00 --offpeak-end=14:00 \+//	    --table-offpeak=flux-offpeak \+//	    --table-readings=flux-readings \+//	    --table-daily-energy=flux-daily-energy \+//	    [--dry-run]+//+// Defaults: from = today - 30d, to = yesterday (the practical readings TTL+// window). Set --dry-run to log intended writes and print summaries without+// invoking any DynamoDB writes.+package main++import (+	"context"+	"errors"+	"flag"+	"fmt"+	"log/slog"+	"math"+	"os"+	"strconv"+	"time"++	_ "time/tzdata"++	awsconfig "github.com/aws/aws-sdk-go-v2/config"+	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"++	"github.com/ArjenSchwarz/flux/internal/derivedstats"+	"github.com/ArjenSchwarz/flux/internal/dynamo"+)++// dynamoAPI is the subset of the DynamoDB client this CLI uses. It mirrors+// dynamo.DynamoAPI so the CLI can construct a real *dynamo.DynamoStore from+// the same client and route conditional writes through the canonical+// condition-expression source (see writeOffpeakIfComplete in dynamostore.go).+type dynamoAPI interface {+	Query(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error)+	PutItem(ctx context.Context, params *dynamodb.PutItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error)+	DeleteItem(ctx context.Context, params *dynamodb.DeleteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error)+	GetItem(ctx context.Context, params *dynamodb.GetItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error)+	UpdateItem(ctx context.Context, params *dynamodb.UpdateItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.UpdateItemOutput, error)+	BatchWriteItem(ctx context.Context, params *dynamodb.BatchWriteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.BatchWriteItemOutput, error)+}++type backfillOpts struct {+	serial           string+	tableOffpeak     string+	tableReadings    string+	tableDailyEnergy string+	from             string+	to               string+	offpeakStart     string+	offpeakEnd       string+	location         *time.Location+	dryRun           bool+	now              func() time.Time+}++type backfillResult struct {+	RowsScanned         int+	RowsSkipped         int // today's row skipped per AC 7.2+	RowsWritten         int+	RowsDryRun          int+	RowsSparseSkipped   int // <2 usable readings in window per AC 7.4+	RowsConditionFailed int // WriteOffpeakIfComplete condition rejected++	// Peak accounting (Decision 7). Independent of the off-peak counters+	// above: a date can have its off-peak row written and its peak skipped, or+	// vice versa.+	PeakWritten       int // peakGridImportKwh written (or would be, in dry-run)+	PeakSkippedAbsent int // flux-daily-energy row absent — skipped (no phantom row)+	PeakSkippedSparse int // peak integration usability gate failed for a sub-window++	Summary []string+}++func main() {+	slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))++	loc, err := time.LoadLocation("Australia/Sydney")+	if err != nil {+		slog.Error("load Sydney timezone", "error", err)+		os.Exit(1)+	}+	today := time.Now().In(loc)+	defaultTo := today.AddDate(0, 0, -1).Format("2006-01-02")+	defaultFrom := today.AddDate(0, 0, -30).Format("2006-01-02")++	opts := backfillOpts{location: loc, now: time.Now}+	flag.StringVar(&opts.serial, "serial", os.Getenv("SYSTEM_SERIAL"), "AlphaESS system serial number (or env SYSTEM_SERIAL)")+	flag.StringVar(&opts.tableOffpeak, "table-offpeak", os.Getenv("TABLE_OFFPEAK"), "flux-offpeak table name (or env TABLE_OFFPEAK)")+	flag.StringVar(&opts.tableReadings, "table-readings", os.Getenv("TABLE_READINGS"), "flux-readings table name (or env TABLE_READINGS)")+	flag.StringVar(&opts.tableDailyEnergy, "table-daily-energy", os.Getenv("TABLE_DAILY_ENERGY"), "flux-daily-energy table name (or env TABLE_DAILY_ENERGY)")+	flag.StringVar(&opts.from, "from", defaultFrom, "start date inclusive (YYYY-MM-DD, Sydney TZ)")+	flag.StringVar(&opts.to, "to", defaultTo, "end date inclusive (YYYY-MM-DD, Sydney TZ)")+	flag.StringVar(&opts.offpeakStart, "offpeak-start", os.Getenv("OFFPEAK_START"), "off-peak window start HH:MM (or env OFFPEAK_START)")+	flag.StringVar(&opts.offpeakEnd, "offpeak-end", os.Getenv("OFFPEAK_END"), "off-peak window end HH:MM (or env OFFPEAK_END)")+	flag.BoolVar(&opts.dryRun, "dry-run", false, "log intended writes without invoking PutItem")+	flag.Parse()++	if err := validateOpts(opts); err != nil {+		slog.Error("invalid options", "error", err)+		os.Exit(2)+	}++	ctx := context.Background()+	awsCfg, err := awsconfig.LoadDefaultConfig(ctx)+	if err != nil {+		slog.Error("load AWS config", "error", err)+		os.Exit(1)+	}+	client := dynamodb.NewFromConfig(awsCfg)++	res, err := runBackfill(ctx, client, opts)+	if err != nil {+		slog.Error("backfill failed", "error", err)+		os.Exit(1)+	}+	for _, line := range res.Summary {+		fmt.Println(line)+	}+	slog.Info("backfill complete",+		"scanned", res.RowsScanned,+		"written", res.RowsWritten,+		"dryRun", res.RowsDryRun,+		"todaySkipped", res.RowsSkipped,+		"sparseSkipped", res.RowsSparseSkipped,+		"conditionFailed", res.RowsConditionFailed,+		"peakWritten", res.PeakWritten,+		"peakSkippedAbsentRow", res.PeakSkippedAbsent,+		"peakSkippedSparse", res.PeakSkippedSparse,+	)+}++func validateOpts(o backfillOpts) error {+	if o.serial == "" {+		return fmt.Errorf("--serial is required")+	}+	if o.tableOffpeak == "" {+		return fmt.Errorf("--table-offpeak is required")+	}+	if o.tableReadings == "" {+		return fmt.Errorf("--table-readings is required")+	}+	if o.tableDailyEnergy == "" {+		return fmt.Errorf("--table-daily-energy is required")+	}+	if o.offpeakStart == "" || o.offpeakEnd == "" {+		return fmt.Errorf("--offpeak-start and --offpeak-end are required")+	}+	from, err := time.ParseInLocation("2006-01-02", o.from, o.location)+	if err != nil {+		return fmt.Errorf("invalid --from %q: %w", o.from, err)+	}+	to, err := time.ParseInLocation("2006-01-02", o.to, o.location)+	if err != nil {+		return fmt.Errorf("invalid --to %q: %w", o.to, err)+	}+	if from.After(to) {+		return fmt.Errorf("--from %s is after --to %s", o.from, o.to)+	}+	if _, _, ok := derivedstats.ParseOffpeakWindow(o.offpeakStart, o.offpeakEnd); !ok {+		return fmt.Errorf("invalid --offpeak-start %q / --offpeak-end %q", o.offpeakStart, o.offpeakEnd)+	}+	return nil+}++// runBackfill is the testable core: it scans the offpeak table for the+// configured date range, recomputes the five deltas via+// derivedstats.IntegrateOffpeakDeltas, emits a drift log entry, and writes+// the patched row via the WriteOffpeakIfComplete conditional put. Today's+// row (per Sydney-local opts.now) is always skipped (AC 7.2). Days with+// fewer than two usable readings in the window emit a SKIPPED summary line+// and are left unchanged (AC 7.4).+//+// For each non-today date it also backfills peakGridImportKwh on the+// corresponding flux-daily-energy row (Decision 7): it queries the full+// Sydney-local day's readings, integrates max(pgrid,0) over the two windows+// bracketing off-peak, and — only when the daily-energy row already exists —+// writes the peak group via UpdateDailyEnergyDerived. An absent row is skipped+// for peak (no phantom-row creation); a failed usability gate leaves the date+// on the iOS fallback (Decision 4). The off-peak recompute above is unchanged.+func runBackfill(ctx context.Context, client dynamoAPI, opts backfillOpts) (*backfillResult, error) {+	res := &backfillResult{}+	rows, err := queryOffpeakRange(ctx, client, opts.tableOffpeak, opts.serial, opts.from, opts.to)+	if err != nil {+		return nil, fmt.Errorf("query offpeak (%s): %w", opts.tableOffpeak, err)+	}++	today := opts.now().In(opts.location).Format("2006-01-02")+	store := dynamo.NewDynamoStore(client, dynamo.TableNames{+		Offpeak:     opts.tableOffpeak,+		DailyEnergy: opts.tableDailyEnergy,+	})++	for _, row := range rows {+		res.RowsScanned++++		if row.Date == today {+			res.RowsSkipped+++			slog.Info("skip: today's row is the poller's responsibility", "date", row.Date)+			continue+		}++		day, err := time.ParseInLocation("2006-01-02", row.Date, opts.location)+		if err != nil {+			res.RowsSkipped+++			slog.Warn("skip: invalid row date", "date", row.Date, "error", err)+			continue+		}+		windowStart, windowEnd, ok := offpeakBoundaries(day, opts.location, opts.offpeakStart, opts.offpeakEnd)+		if !ok {+			res.RowsSkipped+++			slog.Warn("skip: invalid offpeak window", "date", row.Date)+			continue+		}++		// Off-peak recompute (unchanged behaviour). Its own query, its own+		// usability gate, its own conditional write — none of which gate the+		// peak side below (Decision 7).+		if err := backfillOffpeak(ctx, store, client, opts, row, windowStart, windowEnd, res); err != nil {+			return res, err+		}++		// Peak backfill. Independent of the off-peak outcome above: a sparse or+		// condition-rejected off-peak row does not stop peak from being written+		// (and vice versa). dayStart is Sydney-local midnight, dayEnd the next+		// local midnight (DST-correct via AddDate).+		dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, opts.location)+		dayEnd := dayStart.AddDate(0, 0, 1)+		if err := backfillPeak(ctx, store, client, opts, row.Date,+			dayStart, windowStart, windowEnd, dayEnd, res); err != nil {+			return res, err+		}+	}++	return res, nil+}++// backfillOffpeak recomputes the five off-peak deltas for one date and writes+// the patched flux-offpeak row via WriteOffpeakIfComplete. Behaviour is+// unchanged from the original backfill-offpeak CLI: sparse readings and+// conditional-write rejections are recorded on res and skipped (not fatal);+// only a readings-query error is fatal.+func backfillOffpeak(ctx context.Context, store *dynamo.DynamoStore, client dynamoAPI,+	opts backfillOpts, row dynamo.OffpeakItem, windowStart, windowEnd time.Time, res *backfillResult,+) error {+	readings, err := queryReadingsRange(ctx, client, opts.tableReadings, opts.serial,+		windowStart.Unix(), windowEnd.Unix())+	if err != nil {+		return fmt.Errorf("query readings (date=%s): %w", row.Date, err)+	}++	deltas, ok := derivedstats.IntegrateOffpeakDeltas(+		toDerivedReadings(readings), windowStart.Unix(), windowEnd.Unix())+	if !ok {+		res.RowsSparseSkipped+++		line := fmt.Sprintf("%s  SKIPPED (sparse readings; <2 usable samples in window)", row.Date)+		res.Summary = append(res.Summary, line)+		slog.Info("skip: sparse readings", "date", row.Date, "readings", len(readings))+		return nil+	}++	patched := patchOffpeakRow(row, deltas, opts.now().UTC())+	summary := summaryLine(row.Date, row, patched)+	res.Summary = append(res.Summary, summary)++	dynamo.LogOffpeakDrift(row.Date, patched)++	if opts.dryRun {+		res.RowsDryRun+++		slog.Info("dry-run patch", "date", row.Date,+			"gridUsageKwh", patched.GridUsageKwh,+			"solarKwh", patched.SolarKwh,+			"sampleCount", patched.IntegrationSampleCount)+		return nil+	}++	if err := store.WriteOffpeakIfComplete(ctx, patched); err != nil {+		if errors.Is(err, dynamo.ErrOffpeakConditionFailed) {+			res.RowsConditionFailed+++			slog.Warn("conditional-write rejected (row state changed); skipping", "date", row.Date)+			return nil+		}+		return fmt.Errorf("write offpeak (date=%s): %w", row.Date, err)+	}+	res.RowsWritten+++	slog.Info("patched offpeak deltas", "date", row.Date,+		"gridUsageKwh", patched.GridUsageKwh,+		"sampleCount", patched.IntegrationSampleCount)+	return nil+}++// backfillPeak computes peakGridImportKwh over the two windows bracketing+// off-peak ([dayStart, offpeakStart) and [offpeakEnd, dayEnd)) and writes it,+// plus the peakComputedAt sentinel, to the flux-daily-energy row for date — but+// only when that row already exists (GET first; an absent row is skipped, never+// created, per Decision 7). When the integration's usability gate fails for+// either sub-window the date is skipped for peak and keeps the iOS fallback+// (Decision 4). Only the peak group is written (DerivedStatsComputedAt left+// empty), so the derived-stats group is untouched. Honours --dry-run.+func backfillPeak(ctx context.Context, store *dynamo.DynamoStore, client dynamoAPI,+	opts backfillOpts, date string, dayStart, offpeakStart, offpeakEnd, dayEnd time.Time, res *backfillResult,+) error {+	// Separate full-day readings query, kept independent of backfillOffpeak's+	// off-peak-window query on purpose: peak integrates the two windows+	// bracketing off-peak, so it needs the whole day. Off-peak recompute stays+	// byte-for-byte the original tool's behaviour (its own query, own write).+	readings, err := queryReadingsRange(ctx, client, opts.tableReadings, opts.serial,+		dayStart.Unix(), dayEnd.Unix())+	if err != nil {+		return fmt.Errorf("query readings for peak (date=%s): %w", date, err)+	}++	kwh, sampleCount, skippedPairs, ok := derivedstats.IntegratePeakGridImportKwh(+		toDerivedReadings(readings), dayStart.Unix(), offpeakStart.Unix(), offpeakEnd.Unix(), dayEnd.Unix())+	if !ok {+		res.PeakSkippedSparse+++		slog.Info("skip peak: sparse readings in a bracketing window", "date", date, "readings", len(readings))+		return nil+	}++	// GET the daily-energy row first: peak must never create a phantom row+	// (Decision 7). An absent row is skipped and keeps the iOS fallback.+	existing, err := store.GetDailyEnergy(ctx, opts.serial, date)+	if err != nil {+		return fmt.Errorf("get daily energy for peak (date=%s): %w", date, err)+	}+	if existing == nil {+		res.PeakSkippedAbsent+++		slog.Info("skip peak: flux-daily-energy row absent (no phantom-row creation)", "date", date)+		return nil+	}++	peakKwh := derivedstats.RoundEnergy(kwh)+	res.Summary = append(res.Summary, peakSummaryLine(date, existing.PeakGridImportKwh, peakKwh, sampleCount, skippedPairs))++	if opts.dryRun {+		res.PeakWritten+++		slog.Info("dry-run peak", "date", date, "peakGridImportKwh", peakKwh,+			"sampleCount", sampleCount, "skippedPairs", skippedPairs)+		return nil+	}++	stats := dynamo.DerivedStats{+		PeakGridImportKwh: &peakKwh,+		PeakComputedAt:    opts.now().UTC().Format(time.RFC3339),+	}+	if err := store.UpdateDailyEnergyDerived(ctx, opts.serial, date, stats); err != nil {+		return fmt.Errorf("write peak (date=%s): %w", date, err)+	}+	res.PeakWritten+++	slog.Info("wrote peak grid import", "date", date, "peakGridImportKwh", peakKwh,+		"sampleCount", sampleCount, "skippedPairs", skippedPairs)+	return nil+}++// offpeakBoundaries returns the absolute Sydney-local times of the off-peak+// window on the given day. False indicates an unparseable window.+func offpeakBoundaries(day time.Time, loc *time.Location, start, end string) (time.Time, time.Time, bool) {+	startMin, endMin, ok := derivedstats.ParseOffpeakWindow(start, end)+	if !ok {+		return time.Time{}, time.Time{}, false+	}+	midnight := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)+	return midnight.Add(time.Duration(startMin) * time.Minute),+		midnight.Add(time.Duration(endMin) * time.Minute), true+}++// patchOffpeakRow returns a copy of stored with the five integration-sourced+// deltas, the three provenance fields, and status=complete. Every other+// field — the diagnostic startE*/endE* snapshots, socStart/socEnd,+// batteryDeltaPercent — is preserved verbatim per Decision 2.+func patchOffpeakRow(stored dynamo.OffpeakItem, deltas derivedstats.OffpeakDeltas, integratedAt time.Time) dynamo.OffpeakItem {+	out := stored+	out.Status = dynamo.OffpeakStatusComplete+	out.GridUsageKwh = derivedstats.RoundEnergy(deltas.GridImportKwh)+	out.SolarKwh = derivedstats.RoundEnergy(deltas.SolarKwh)+	out.BatteryChargeKwh = derivedstats.RoundEnergy(deltas.BatteryChargeKwh)+	out.BatteryDischargeKwh = derivedstats.RoundEnergy(deltas.BatteryDischargeKwh)+	out.GridExportKwh = derivedstats.RoundEnergy(deltas.GridExportKwh)+	out.IntegrationSampleCount = deltas.SampleCount+	out.IntegrationSkippedPairs = deltas.SkippedPairs+	out.IntegratedAt = integratedAt.Format(time.RFC3339)+	return out+}++// summaryLine formats a per-day comparison of the prior row and the patched+// row so the operator can sanity-check the magnitude of each correction+// before it propagates to the clients (AC 7.5). Surfaces all five deltas+// (grid import, solar, battery charge, battery discharge, grid export) with+// the prior value, the new value, and the absolute difference so the+// operator can see at a glance which channels moved most.+func summaryLine(date string, prev, next dynamo.OffpeakItem) string {+	col := func(label string, p, n float64) string {+		return fmt.Sprintf("%s %.2f→%.2f |Δ|=%.2f", label, p, n, math.Abs(n-p))+	}+	return fmt.Sprintf(+		"%s  %s  %s  %s  %s  %s  samples=%d skipped=%d",+		date,+		col("grid", prev.GridUsageKwh, next.GridUsageKwh),+		col("solar", prev.SolarKwh, next.SolarKwh),+		col("chg", prev.BatteryChargeKwh, next.BatteryChargeKwh),+		col("dis", prev.BatteryDischargeKwh, next.BatteryDischargeKwh),+		col("exp", prev.GridExportKwh, next.GridExportKwh),+		next.IntegrationSampleCount,+		next.IntegrationSkippedPairs,+	)+}++// peakSummaryLine formats a per-day peak line: the prior stored+// peakGridImportKwh (or "none" when the row had no peak yet), the newly+// integrated value, and the combined provenance counts across the two+// bracketing windows. Mirrors summaryLine's prev→new shape so the operator+// can scan peak alongside the off-peak deltas.+func peakSummaryLine(date string, prev *float64, next float64, sampleCount, skippedPairs int) string {+	prevStr := "none"+	if prev != nil {+		prevStr = fmt.Sprintf("%.2f", *prev)+		return fmt.Sprintf("%s  peak %s→%.2f |Δ|=%.2f  samples=%d skipped=%d",+			date, prevStr, next, math.Abs(next-*prev), sampleCount, skippedPairs)+	}+	return fmt.Sprintf("%s  peak %s→%.2f  samples=%d skipped=%d",+		date, prevStr, next, sampleCount, skippedPairs)+}++// queryOffpeakRange paginates flux-offpeak for one serial and a closed date+// range. Mirrors dynamo.DynamoReader.QueryOffpeak but uses only the small+// dynamoAPI surface so the CLI's tests don't have to satisfy the full Reader.+func queryOffpeakRange(ctx context.Context, client dynamoAPI, table, serial, from, to string) ([]dynamo.OffpeakItem, error) {+	keyCondition := "sysSn = :serial AND #d BETWEEN :start AND :end"+	exprNames := map[string]string{"#d": "date"}+	exprValues := map[string]types.AttributeValue{+		":serial": &types.AttributeValueMemberS{Value: serial},+		":start":  &types.AttributeValueMemberS{Value: from},+		":end":    &types.AttributeValueMemberS{Value: to},+	}+	return paginate[dynamo.OffpeakItem](ctx, client, table, keyCondition, exprNames, exprValues)+}++// queryReadingsRange paginates flux-readings for one serial and a closed+// timestamp range.+func queryReadingsRange(ctx context.Context, client dynamoAPI, table, serial string, fromTS, toTS int64) ([]dynamo.ReadingItem, error) {+	keyCondition := "sysSn = :serial AND #ts BETWEEN :from AND :to"+	exprNames := map[string]string{"#ts": "timestamp"}+	exprValues := map[string]types.AttributeValue{+		":serial": &types.AttributeValueMemberS{Value: serial},+		":from":   &types.AttributeValueMemberN{Value: strconv.FormatInt(fromTS, 10)},+		":to":     &types.AttributeValueMemberN{Value: strconv.FormatInt(toTS, 10)},+	}+	return paginate[dynamo.ReadingItem](ctx, client, table, keyCondition, exprNames, exprValues)+}++func paginate[T any](+	ctx context.Context,+	client dynamoAPI,+	table, keyCondition string,+	exprNames map[string]string,+	exprValues map[string]types.AttributeValue,+) ([]T, error) {+	forward := true+	input := &dynamodb.QueryInput{+		TableName:                 &table,+		KeyConditionExpression:    &keyCondition,+		ExpressionAttributeValues: exprValues,+		ScanIndexForward:          &forward,+	}+	if len(exprNames) > 0 {+		input.ExpressionAttributeNames = exprNames+	}+	var out []T+	for {+		page, err := client.Query(ctx, input)+		if err != nil {+			return nil, err+		}+		decoded := make([]T, len(page.Items))+		for i, av := range page.Items {+			if err := attributevalue.UnmarshalMap(av, &decoded[i]); err != nil {+				return nil, fmt.Errorf("unmarshal %s row: %w", table, err)+			}+		}+		out = append(out, decoded...)+		if page.LastEvaluatedKey == nil {+			break+		}+		input.ExclusiveStartKey = page.LastEvaluatedKey+	}+	return out, nil+}++// toDerivedReadings mirrors api.toDerivedReadings / poller.summaryToDerivedReadings+// — kept duplicated for the same Decision 9 reason: derivedstats must not+// import dynamo, and this is the only consumer in cmd/backfill-grid.+func toDerivedReadings(in []dynamo.ReadingItem) []derivedstats.Reading {+	out := make([]derivedstats.Reading, len(in))+	for i, r := range in {+		out[i] = derivedstats.Reading{+			Timestamp: r.Timestamp,+			Ppv:       r.Ppv,+			Pload:     r.Pload,+			Soc:       r.Soc,+			Pbat:      r.Pbat,+			Pgrid:     r.Pgrid,+		}+	}+	return out+}
cmd/backfill-grid/main_test.go Renamed+Modified +257 / -...
diff --git a/cmd/backfill-grid/main_test.go b/cmd/backfill-grid/main_test.gonew file mode 100644index 0000000..6ee7867--- /dev/null+++ b/cmd/backfill-grid/main_test.go@@ -0,0 +1,764 @@+package main++import (+	"bytes"+	"context"+	"errors"+	"fmt"+	"log/slog"+	"math"+	"strconv"+	"testing"+	"time"++	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"++	"github.com/ArjenSchwarz/flux/internal/derivedstats"+	"github.com/ArjenSchwarz/flux/internal/dynamo"+)++const (+	testSerial           = "TEST123"+	testOffpeakTable     = "flux-offpeak-test"+	testReadingsTable    = "flux-readings-test"+	testDailyEnergyTable = "flux-daily-energy-test"+)++// fakeDynamo is a lightweight in-memory stand-in for the DynamoDB client.+// It returns canned offpeak and readings rows from Query, and records every+// PutItem call so tests can assert on the persisted payload and condition+// expression.+type fakeDynamo struct {+	offpeakRows       map[string][]dynamo.OffpeakItem   // keyed by "*"+	readingsByDate    map[string][]dynamo.ReadingItem   // keyed by Sydney YYYY-MM-DD+	dailyEnergyByDate map[string]dynamo.DailyEnergyItem // keyed by Sydney YYYY-MM-DD; absent = no row+	location          *time.Location+	queries           []*dynamodb.QueryInput+	puts              []*dynamodb.PutItemInput+	updates           []*dynamodb.UpdateItemInput // records UpdateDailyEnergyDerived calls+	queryErrForTable  map[string]error+	putErr            error+}++func (f *fakeDynamo) Query(_ context.Context, params *dynamodb.QueryInput, _ ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) {+	f.queries = append(f.queries, params)+	if err, ok := f.queryErrForTable[*params.TableName]; ok && err != nil {+		return nil, err+	}+	switch *params.TableName {+	case testOffpeakTable:+		return marshalQueryItems(f.offpeakRows["*"])+	case testReadingsTable:+		date := readingsQueryDate(params, f.location)+		return marshalQueryItems(f.readingsByDate[date])+	}+	return &dynamodb.QueryOutput{}, nil+}++func (f *fakeDynamo) PutItem(_ context.Context, params *dynamodb.PutItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) {+	f.puts = append(f.puts, params)+	if f.putErr != nil {+		return nil, f.putErr+	}+	return &dynamodb.PutItemOutput{}, nil+}++// The CLI now constructs a *dynamo.DynamoStore (Fix 6 of the pre-push review)+// so the conditional-write expression lives in one place. The store's+// interface (dynamo.DynamoAPI) requires Delete/Get/Update/BatchWrite — the CLI+// itself never calls them, so the fake returns benign zero values.++func (f *fakeDynamo) DeleteItem(_ context.Context, _ *dynamodb.DeleteItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) {+	return &dynamodb.DeleteItemOutput{}, nil+}++// GetItem serves the daily-energy GET the peak path issues before writing.+// The date is read from the key; a missing fixture entry returns an empty+// Item (DynamoDB's "not found"), which GetDailyEnergy maps to nil.+func (f *fakeDynamo) GetItem(_ context.Context, params *dynamodb.GetItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) {+	date := ""+	if d, ok := params.Key["date"].(*types.AttributeValueMemberS); ok {+		date = d.Value+	}+	row, ok := f.dailyEnergyByDate[date]+	if !ok {+		return &dynamodb.GetItemOutput{}, nil+	}+	av, err := attributevalue.MarshalMap(row)+	if err != nil {+		return nil, err+	}+	return &dynamodb.GetItemOutput{Item: av}, nil+}++func (f *fakeDynamo) UpdateItem(_ context.Context, params *dynamodb.UpdateItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.UpdateItemOutput, error) {+	f.updates = append(f.updates, params)+	return &dynamodb.UpdateItemOutput{}, nil+}++func (f *fakeDynamo) BatchWriteItem(_ context.Context, _ *dynamodb.BatchWriteItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.BatchWriteItemOutput, error) {+	return &dynamodb.BatchWriteItemOutput{}, nil+}++// readingsQueryDate inverts the :from timestamp on a readings Query back to+// the Sydney YYYY-MM-DD date the fixture is keyed on. The CLI queries+// readings with [windowStart, windowEnd-1] in Sydney TZ, so the from value+// lands inside the off-peak window of that date.+func readingsQueryDate(params *dynamodb.QueryInput, loc *time.Location) string {+	from := params.ExpressionAttributeValues[":from"].(*types.AttributeValueMemberN).Value+	ts, err := strconv.ParseInt(from, 10, 64)+	if err != nil {+		panic("readingsQueryDate: malformed :from value " + from + ": " + err.Error())+	}+	return time.Unix(ts, 0).In(loc).Format("2006-01-02")+}++func marshalQueryItems[T any](items []T) (*dynamodb.QueryOutput, error) {+	avs := make([]map[string]types.AttributeValue, 0, len(items))+	for i := range items {+		av, err := attributevalue.MarshalMap(items[i])+		if err != nil {+			return nil, err+		}+		avs = append(avs, av)+	}+	return &dynamodb.QueryOutput{Items: avs}, nil+}++// chargeReadings builds dense (10s) readings across the 11:00-14:00 Sydney+// window with a constant 4 kW grid import — exercises a heavy-charge day so+// the integration produces a non-trivial gridUsageKwh.+func chargeReadings(t *testing.T, date string, loc *time.Location) []dynamo.ReadingItem {+	t.Helper()+	day, err := time.ParseInLocation("2006-01-02", date, loc)+	require.NoError(t, err)+	start := day.Add(11 * time.Hour)+	end := day.Add(14 * time.Hour)+	out := make([]dynamo.ReadingItem, 0, 3*60*60/10+1)+	for ts := start.Unix(); ts <= end.Unix(); ts += 10 {+		out = append(out, dynamo.ReadingItem{+			SysSn:     testSerial,+			Timestamp: ts,+			Pgrid:     4000,+			Pbat:      -3800,+			Ppv:       0,+			Soc:       30,+		})+	}+	return out+}++// fullDayReadings builds dense (10s) readings across the entire Sydney-local+// day [00:00, 24:00) with a constant 1 kW grid import. Both bracketing peak+// sub-windows ([00:00, 11:00) and [14:00, 24:00)) are densely covered so the+// peak usability gate passes. Peak energy = 1 kW over 11h + 10h = 21 kWh.+func fullDayReadings(t *testing.T, date string, loc *time.Location) []dynamo.ReadingItem {+	t.Helper()+	day, err := time.ParseInLocation("2006-01-02", date, loc)+	require.NoError(t, err)+	end := day.AddDate(0, 0, 1)+	out := make([]dynamo.ReadingItem, 0, 24*60*60/10)+	for ts := day.Unix(); ts < end.Unix(); ts += 10 {+		out = append(out, dynamo.ReadingItem{+			SysSn:     testSerial,+			Timestamp: ts,+			Pgrid:     1000,+			Pbat:      0,+			Ppv:       0,+			Soc:       50,+		})+	}+	return out+}++// existingDailyEnergyRow returns a populated flux-daily-energy row for date,+// with no peak yet (PeakGridImportKwh nil). The peak backfill writes peak onto+// this row.+func existingDailyEnergyRow(date string) dynamo.DailyEnergyItem {+	return dynamo.DailyEnergyItem{+		SysSn:                  testSerial,+		Date:                   date,+		EInput:                 20.0,+		DerivedStatsComputedAt: "2026-05-19T03:00:00Z",+	}+}++// existingPendingRow returns a row in pending state — must NOT be overwritten+// by the CLI per AC 7.8.+func existingPendingRow(date string) dynamo.OffpeakItem {+	return dynamo.OffpeakItem{+		SysSn:        testSerial,+		Date:         date,+		Status:       dynamo.OffpeakStatusPending,+		StartEInput:  10.0,+		StartEpv:     0,+		StartECharge: 5.0,+	}+}++// existingCompleteRow returns a row in complete state with the lagged+// snapshot-diff values from the bug. The CLI overwrites this with+// readings-integrated values.+func existingCompleteRow(date string) dynamo.OffpeakItem {+	return dynamo.OffpeakItem{+		SysSn:               testSerial,+		Date:                date,+		Status:              dynamo.OffpeakStatusComplete,+		StartEInput:         10.0,+		EndEInput:           28.95,+		StartEpv:            0,+		EndEpv:              0,+		StartECharge:        5.0,+		EndECharge:          18.0,+		StartEDischarge:     2.0,+		EndEDischarge:       2.5,+		StartEOutput:        1.0,+		EndEOutput:          1.5,+		GridUsageKwh:        18.95, // lagged snapshot-diff+		SolarKwh:            0,+		BatteryChargeKwh:    13.0,+		BatteryDischargeKwh: 0.5,+		GridExportKwh:       0.5,+		BatteryDeltaPercent: 60,+	}+}++func sydney(t *testing.T) *time.Location {+	t.Helper()+	loc, err := time.LoadLocation("Australia/Sydney")+	require.NoError(t, err)+	return loc+}++func backfillOptsForTest(loc *time.Location, dates ...string) backfillOpts {+	from := dates[0]+	to := dates[0]+	if len(dates) > 1 {+		to = dates[len(dates)-1]+	}+	// Pin clock so the today-skip gate (AC 7.2) is stable. "Today" is+	// 2026-05-20 here so the historical dates 2026-05-18/19 are past.+	now := time.Date(2026, 5, 20, 12, 0, 0, 0, loc)+	return backfillOpts{+		serial:           testSerial,+		tableOffpeak:     testOffpeakTable,+		tableReadings:    testReadingsTable,+		tableDailyEnergy: testDailyEnergyTable,+		from:             from,+		to:               to,+		offpeakStart:     "11:00",+		offpeakEnd:       "14:00",+		location:         loc,+		now:              func() time.Time { return now },+	}+}++// captureSlog swaps slog.Default for a JSON-buffered logger so tests can+// assert log lines without coupling to the binary's output handler.+func captureSlog() (*bytes.Buffer, func()) {+	var buf bytes.Buffer+	logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))+	old := slog.Default()+	slog.SetDefault(logger)+	return &buf, func() { slog.SetDefault(old) }+}++func TestBackfill_DryRun_NoPutItemCalled(t *testing.T) {+	loc := sydney(t)+	date := "2026-05-18"+	row := existingCompleteRow(date)+	f := &fakeDynamo{+		location:       loc,+		offpeakRows:    map[string][]dynamo.OffpeakItem{"*": {row}},+		readingsByDate: map[string][]dynamo.ReadingItem{date: chargeReadings(t, date, loc)},+	}+	opts := backfillOptsForTest(loc, date)+	opts.dryRun = true++	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)+	assert.Empty(t, f.puts, "dry-run must not invoke PutItem")+	assert.Equal(t, 1, res.RowsScanned)+	assert.Equal(t, 1, res.RowsDryRun)+	assert.Zero(t, res.RowsWritten)+	assert.NotEmpty(t, res.Summary, "dry-run should emit at least one summary line")+}++func TestBackfill_LiveRun_WritesRecomputedDeltas(t *testing.T) {+	loc := sydney(t)+	date := "2026-05-18"+	row := existingCompleteRow(date)+	readings := chargeReadings(t, date, loc)+	f := &fakeDynamo{+		location:       loc,+		offpeakRows:    map[string][]dynamo.OffpeakItem{"*": {row}},+		readingsByDate: map[string][]dynamo.ReadingItem{date: readings},+	}+	opts := backfillOptsForTest(loc, date)++	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)+	require.Len(t, f.puts, 1, "live run must call PutItem exactly once")+	assert.Equal(t, 1, res.RowsWritten)++	put := f.puts[0]+	require.NotNil(t, put.ConditionExpression)+	assert.Contains(t, *put.ConditionExpression, "#status = :complete",+		"PutItem must use WriteOffpeakIfComplete's condition (AC 7.8)")++	persisted := decodeOffpeakItem(t, put.Item)+	assert.Equal(t, dynamo.OffpeakStatusComplete, persisted.Status)+	// 4000W × 10800s = 43_200_000Ws = 12.0 kWh exactly.+	assert.InDelta(t, 12.0, persisted.GridUsageKwh, 0.01)+	assert.InDelta(t, 11.4, persisted.BatteryChargeKwh, 0.01)+	// Half-open [start, end): the reading at exactly endUnix is not counted as+	// an interior sample. chargeReadings emits 1081 timestamps from start to+	// end inclusive; SampleCount reflects the 1080 inside the window.+	assert.Equal(t, 1080, persisted.IntegrationSampleCount)+	assert.Equal(t, 0, persisted.IntegrationSkippedPairs)+	assert.NotEmpty(t, persisted.IntegratedAt)+	// Diagnostic snapshot preserved verbatim from the existing row (Decision 2).+	assert.Equal(t, row.StartEInput, persisted.StartEInput)+	assert.Equal(t, row.EndEInput, persisted.EndEInput)+}++func TestBackfill_Idempotent_DeltaFieldsBitEqualAcrossRuns(t *testing.T) {+	// AC 7.3 + AC 7.7: re-running the CLI against the same readings produces+	// identical values for the five delta fields and the two count fields.+	// integratedAt MAY differ — Decision 10 — so we pin two distinct clocks+	// for the two runs and assert the deltas match exactly while integratedAt+	// reflects each run's time.+	loc := sydney(t)+	date := "2026-05-18"+	row := existingCompleteRow(date)+	readings := chargeReadings(t, date, loc)++	clock1 := time.Date(2026, 5, 20, 12, 0, 0, 0, loc)+	clock2 := clock1.Add(time.Hour)++	run := func(now time.Time) dynamo.OffpeakItem {+		f := &fakeDynamo{+			location:       loc,+			offpeakRows:    map[string][]dynamo.OffpeakItem{"*": {row}},+			readingsByDate: map[string][]dynamo.ReadingItem{date: readings},+		}+		opts := backfillOptsForTest(loc, date)+		opts.now = func() time.Time { return now }+		_, err := runBackfill(context.Background(), f, opts)+		require.NoError(t, err)+		require.Len(t, f.puts, 1)+		return decodeOffpeakItem(t, f.puts[0].Item)+	}++	first := run(clock1)+	second := run(clock2)++	assert.Equal(t, first.GridUsageKwh, second.GridUsageKwh)+	assert.Equal(t, first.SolarKwh, second.SolarKwh)+	assert.Equal(t, first.BatteryChargeKwh, second.BatteryChargeKwh)+	assert.Equal(t, first.BatteryDischargeKwh, second.BatteryDischargeKwh)+	assert.Equal(t, first.GridExportKwh, second.GridExportKwh)+	assert.Equal(t, first.IntegrationSampleCount, second.IntegrationSampleCount)+	assert.Equal(t, first.IntegrationSkippedPairs, second.IntegrationSkippedPairs)+	// integratedAt is excluded from the idempotence guarantee per Decision 10.+	assert.NotEqual(t, first.IntegratedAt, second.IntegratedAt,+		"integratedAt is the time of integration; the two runs were pinned to different clocks")+}++func TestBackfill_RoundingConsistency_PersistedFieldsRoundedTwoDecimals(t *testing.T) {+	// AC 7.7: the persisted delta values are rounded to two decimal places so+	// the poller and the backfill CLI produce byte-equal values for the same+	// readings. A reading series with sub-millikWh precision would otherwise+	// drift across runs once the values are compared via marshalled rows.+	loc := sydney(t)+	date := "2026-05-18"+	row := existingCompleteRow(date)+	readings := chargeReadings(t, date, loc)+	f := &fakeDynamo{+		location:       loc,+		offpeakRows:    map[string][]dynamo.OffpeakItem{"*": {row}},+		readingsByDate: map[string][]dynamo.ReadingItem{date: readings},+	}+	opts := backfillOptsForTest(loc, date)+	_, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)+	require.Len(t, f.puts, 1)+	persisted := decodeOffpeakItem(t, f.puts[0].Item)+	for _, v := range []float64{+		persisted.GridUsageKwh,+		persisted.SolarKwh,+		persisted.BatteryChargeKwh,+		persisted.BatteryDischargeKwh,+		persisted.GridExportKwh,+	} {+		assert.Equal(t, derivedstats.RoundEnergy(v), v,+			"persisted delta %v must already be rounded to 2 decimal places", v)+	}+}++func TestBackfill_DefaultTo_SkipsToday(t *testing.T) {+	// AC 7.2: --to defaults to yesterday; today's row must never be processed+	// even when the query result includes a row dated today (e.g. the poller+	// pre-staged a pending row at offpeak-start).+	loc := sydney(t)+	today := time.Date(2026, 5, 20, 12, 0, 0, 0, loc).Format("2006-01-02")+	yesterday := time.Date(2026, 5, 19, 0, 0, 0, 0, loc).Format("2006-01-02")+	yesterdayRow := existingCompleteRow(yesterday)+	todayRow := existingPendingRow(today)+	f := &fakeDynamo{+		location: loc,+		offpeakRows: map[string][]dynamo.OffpeakItem{"*": {+			yesterdayRow, todayRow,+		}},+		readingsByDate: map[string][]dynamo.ReadingItem{+			yesterday: chargeReadings(t, yesterday, loc),+			today:     chargeReadings(t, today, loc),+		},+	}+	opts := backfillOptsForTest(loc, yesterday)+	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)+	require.Len(t, f.puts, 1, "only yesterday's row must be written")+	persisted := decodeOffpeakItem(t, f.puts[0].Item)+	assert.Equal(t, yesterday, persisted.Date)+	assert.Equal(t, 1, res.RowsWritten)+	assert.Equal(t, 1, res.RowsSkipped, "today's row must be skipped")+}++func TestBackfill_TodayExplicitlyRequested_StillSkipped(t *testing.T) {+	// AC 7.2 also covers the case where the operator passes --to=today+	// explicitly. The CLI's runtime gate skips today regardless of the flag.+	loc := sydney(t)+	today := time.Date(2026, 5, 20, 12, 0, 0, 0, loc).Format("2006-01-02")+	todayRow := existingPendingRow(today)+	f := &fakeDynamo{+		location:       loc,+		offpeakRows:    map[string][]dynamo.OffpeakItem{"*": {todayRow}},+		readingsByDate: map[string][]dynamo.ReadingItem{today: chargeReadings(t, today, loc)},+	}+	opts := backfillOptsForTest(loc, today)+	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)+	assert.Empty(t, f.puts, "today's row must not be written even when --to=today")+	assert.Equal(t, 1, res.RowsSkipped)+}++func TestBackfill_SparseReadings_SkippedNoWrite(t *testing.T) {+	// AC 7.4: a day with fewer than two usable readings in the window is+	// reported SKIPPED and the row is left unchanged.+	loc := sydney(t)+	date := "2026-05-18"+	row := existingCompleteRow(date)+	day, _ := time.ParseInLocation("2006-01-02", date, loc)+	sparseTs := day.Add(12 * time.Hour).Unix()+	f := &fakeDynamo{+		location:    loc,+		offpeakRows: map[string][]dynamo.OffpeakItem{"*": {row}},+		readingsByDate: map[string][]dynamo.ReadingItem{+			date: {{SysSn: testSerial, Timestamp: sparseTs, Pgrid: 1000}},+		},+	}+	opts := backfillOptsForTest(loc, date)+	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)+	assert.Empty(t, f.puts, "sparse-readings day must not be written")+	assert.Equal(t, 1, res.RowsSparseSkipped)+	require.Len(t, res.Summary, 1)+	assert.Contains(t, res.Summary[0], "SKIPPED",+		"sparse-readings days must emit a SKIPPED summary line")+}++func TestBackfill_ConditionalWriteFailure_RowReportedExitsZero(t *testing.T) {+	// A row that has transitioned to pending between Query and PutItem (or a+	// row absent at the moment of the put) trips the WriteOffpeakIfComplete+	// condition. The CLI logs and continues — it does NOT exit non-zero.+	loc := sydney(t)+	date := "2026-05-18"+	row := existingCompleteRow(date)+	f := &fakeDynamo{+		location:       loc,+		offpeakRows:    map[string][]dynamo.OffpeakItem{"*": {row}},+		readingsByDate: map[string][]dynamo.ReadingItem{date: chargeReadings(t, date, loc)},+		putErr:         &types.ConditionalCheckFailedException{},+	}+	opts := backfillOptsForTest(loc, date)+	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err, "conditional-write failure must not propagate as a fatal error")+	assert.Equal(t, 1, res.RowsConditionFailed)+	assert.Zero(t, res.RowsWritten)+}++func TestBackfill_DriftLogEmittedPerRow(t *testing.T) {+	// AC 6.1: the writer emits a structured INFO log entry per row, with the+	// five drift values. The backfill CLI shares the LogOffpeakDrift function+	// with the poller (Decision 6).+	buf, restore := captureSlog()+	defer restore()++	loc := sydney(t)+	date := "2026-05-18"+	row := existingCompleteRow(date)+	f := &fakeDynamo{+		location:       loc,+		offpeakRows:    map[string][]dynamo.OffpeakItem{"*": {row}},+		readingsByDate: map[string][]dynamo.ReadingItem{date: chargeReadings(t, date, loc)},+	}+	opts := backfillOptsForTest(loc, date)+	_, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)++	out := buf.String()+	for _, key := range []string{+		"driftGrid", "driftSolar", "driftCharge", "driftDischarge", "driftExport",+		"snapshotGrid", "integratedGrid",+	} {+		assert.True(t, bytes.Contains([]byte(out), []byte(key)),+			"drift log must include %q. got: %s", key, out)+	}+	assert.Contains(t, out, "offpeak drift")+}++func TestBackfill_QueryError_PropagatedAsFatal(t *testing.T) {+	loc := sydney(t)+	f := &fakeDynamo{+		location:         loc,+		queryErrForTable: map[string]error{testOffpeakTable: errors.New("throttled")},+	}+	opts := backfillOptsForTest(loc, "2026-05-18")+	_, err := runBackfill(context.Background(), f, opts)+	require.Error(t, err)+	assert.Empty(t, f.puts)+}++// TestBackfill_SummaryLine_ShowsAbsDifferencePerDelta covers AC 7.5: the+// per-day summary line includes a |Δ|=X.XX column for each of the five+// deltas, holding the absolute difference between the prior stored value+// and the newly-integrated value rounded to two decimal places.+func TestBackfill_SummaryLine_ShowsAbsDifferencePerDelta(t *testing.T) {+	loc := sydney(t)+	date := "2026-05-18"+	row := existingCompleteRow(date)+	readings := chargeReadings(t, date, loc)+	f := &fakeDynamo{+		location:       loc,+		offpeakRows:    map[string][]dynamo.OffpeakItem{"*": {row}},+		readingsByDate: map[string][]dynamo.ReadingItem{date: readings},+	}+	opts := backfillOptsForTest(loc, date)+	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)+	require.Len(t, res.Summary, 1, "expect one summary line for one row")+	line := res.Summary[0]++	// Decode the persisted row to read the new values exactly as the writer+	// rounded them, then compute the expected |Δ| against the stored prior.+	require.Len(t, f.puts, 1)+	persisted := decodeOffpeakItem(t, f.puts[0].Item)++	for _, c := range []struct {+		label string+		prev  float64+		next  float64+	}{+		{"grid", row.GridUsageKwh, persisted.GridUsageKwh},+		{"solar", row.SolarKwh, persisted.SolarKwh},+		{"chg", row.BatteryChargeKwh, persisted.BatteryChargeKwh},+		{"dis", row.BatteryDischargeKwh, persisted.BatteryDischargeKwh},+		{"exp", row.GridExportKwh, persisted.GridExportKwh},+	} {+		want := math.Abs(c.next - c.prev)+		// Format matches summaryLine: "label prev→next |Δ|=X.XX".+		fragment := fmt.Sprintf("%s %.2f→%.2f |Δ|=%.2f", c.label, c.prev, c.next, want)+		assert.Contains(t, line, fragment,+			"summary line must contain %q. got: %s", fragment, line)+	}+}++func TestBackfill_Peak_PresentDailyEnergyRow_WritesPeak(t *testing.T) {+	// Decision 7: a date whose flux-daily-energy row exists gets+	// peakGridImportKwh written via UpdateDailyEnergyDerived (peak group only).+	loc := sydney(t)+	date := "2026-05-18"+	f := &fakeDynamo{+		location:    loc,+		offpeakRows: map[string][]dynamo.OffpeakItem{"*": {existingCompleteRow(date)}},+		readingsByDate: map[string][]dynamo.ReadingItem{+			date: fullDayReadings(t, date, loc),+		},+		dailyEnergyByDate: map[string]dynamo.DailyEnergyItem{+			date: existingDailyEnergyRow(date),+		},+	}+	opts := backfillOptsForTest(loc, date)+	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)++	assert.Equal(t, 1, res.PeakWritten)+	assert.Zero(t, res.PeakSkippedAbsent)+	assert.Zero(t, res.PeakSkippedSparse)+	require.Len(t, f.updates, 1, "peak must be written via exactly one UpdateItem")++	up := f.updates[0]+	require.NotNil(t, up.UpdateExpression)+	// Peak group only: the derived-stats group must NOT be touched.+	assert.Contains(t, *up.UpdateExpression, "peakGridImportKwh")+	assert.Contains(t, *up.UpdateExpression, "peakComputedAt")+	assert.NotContains(t, *up.UpdateExpression, "derivedStatsComputedAt")+	assert.NotContains(t, *up.UpdateExpression, "dailyUsage")++	// 1 kW over the 21h of peak window (24h minus the 3h off-peak) = 21.0 kWh.+	peakAV, ok := up.ExpressionAttributeValues[":pk"]+	require.True(t, ok, "peak update must set :pk")+	var peak float64+	require.NoError(t, attributevalue.Unmarshal(peakAV, &peak))+	assert.InDelta(t, 21.0, peak, 0.01)+}++func TestBackfill_Peak_AbsentDailyEnergyRow_SkippedNoWrite(t *testing.T) {+	// Decision 7: an absent flux-daily-energy row must be skipped for peak with+	// no write — no phantom-row creation.+	loc := sydney(t)+	date := "2026-05-18"+	f := &fakeDynamo{+		location:    loc,+		offpeakRows: map[string][]dynamo.OffpeakItem{"*": {existingCompleteRow(date)}},+		readingsByDate: map[string][]dynamo.ReadingItem{+			date: fullDayReadings(t, date, loc),+		},+		dailyEnergyByDate: map[string]dynamo.DailyEnergyItem{}, // no row for date+	}+	opts := backfillOptsForTest(loc, date)+	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)++	assert.Equal(t, 1, res.PeakSkippedAbsent)+	assert.Zero(t, res.PeakWritten)+	assert.Empty(t, f.updates, "absent daily-energy row must not produce an UpdateItem (no phantom row)")+}++func TestBackfill_Peak_SparseBracketingWindow_SkippedNoWrite(t *testing.T) {+	// When a bracketing sub-window fails the usability gate the date is skipped+	// for peak (keeps the iOS fallback). chargeReadings covers only 11:00-14:00,+	// so both peak sub-windows are empty.+	loc := sydney(t)+	date := "2026-05-18"+	f := &fakeDynamo{+		location:    loc,+		offpeakRows: map[string][]dynamo.OffpeakItem{"*": {existingCompleteRow(date)}},+		readingsByDate: map[string][]dynamo.ReadingItem{+			date: chargeReadings(t, date, loc),+		},+		dailyEnergyByDate: map[string]dynamo.DailyEnergyItem{+			date: existingDailyEnergyRow(date),+		},+	}+	opts := backfillOptsForTest(loc, date)+	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)++	assert.Equal(t, 1, res.PeakSkippedSparse)+	assert.Zero(t, res.PeakWritten)+	assert.Empty(t, f.updates, "sparse bracketing window must not write peak")+}++func TestBackfill_Peak_DryRun_NoWriteToEitherTable(t *testing.T) {+	// --dry-run must issue no PutItem (off-peak) and no UpdateItem (peak), while+	// still counting and summarising the intended peak write.+	loc := sydney(t)+	date := "2026-05-18"+	f := &fakeDynamo{+		location:    loc,+		offpeakRows: map[string][]dynamo.OffpeakItem{"*": {existingCompleteRow(date)}},+		readingsByDate: map[string][]dynamo.ReadingItem{+			date: fullDayReadings(t, date, loc),+		},+		dailyEnergyByDate: map[string]dynamo.DailyEnergyItem{+			date: existingDailyEnergyRow(date),+		},+	}+	opts := backfillOptsForTest(loc, date)+	opts.dryRun = true+	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)++	assert.Empty(t, f.puts, "dry-run must not write off-peak")+	assert.Empty(t, f.updates, "dry-run must not write peak")+	assert.Equal(t, 1, res.PeakWritten, "dry-run still counts the intended peak write")+	hasPeakLine := false+	for _, line := range res.Summary {+		if bytes.Contains([]byte(line), []byte("peak")) {+			hasPeakLine = true+		}+	}+	assert.True(t, hasPeakLine, "dry-run summary should include a peak line. got: %v", res.Summary)+}++func TestBackfill_Peak_OffpeakRecomputeStillWorks(t *testing.T) {+	// Adding the peak side must not change off-peak behaviour: the off-peak row+	// is still written with the recomputed deltas alongside the peak write.+	loc := sydney(t)+	date := "2026-05-18"+	f := &fakeDynamo{+		location:    loc,+		offpeakRows: map[string][]dynamo.OffpeakItem{"*": {existingCompleteRow(date)}},+		readingsByDate: map[string][]dynamo.ReadingItem{+			date: fullDayReadings(t, date, loc),+		},+		dailyEnergyByDate: map[string]dynamo.DailyEnergyItem{+			date: existingDailyEnergyRow(date),+		},+	}+	opts := backfillOptsForTest(loc, date)+	res, err := runBackfill(context.Background(), f, opts)+	require.NoError(t, err)++	require.Len(t, f.puts, 1, "off-peak row must still be written")+	assert.Equal(t, 1, res.RowsWritten)+	persisted := decodeOffpeakItem(t, f.puts[0].Item)+	// 1 kW over the 3h off-peak window (11:00-14:00) = 3.0 kWh.+	assert.InDelta(t, 3.0, persisted.GridUsageKwh, 0.01)+	// And peak was written independently.+	assert.Equal(t, 1, res.PeakWritten)+	require.Len(t, f.updates, 1)+}++func TestValidateOpts_RejectsMissingDailyEnergyTable(t *testing.T) {+	loc := sydney(t)+	opts := backfillOptsForTest(loc, "2026-05-18")+	opts.tableDailyEnergy = ""++	err := validateOpts(opts)+	require.Error(t, err)+	assert.Contains(t, err.Error(), "table-daily-energy")+}++func TestValidateOpts_RejectsReversedDateRange(t *testing.T) {+	loc := sydney(t)+	opts := backfillOptsForTest(loc, "2026-05-18")+	opts.from = "2026-04-20"+	opts.to = "2026-04-10"++	err := validateOpts(opts)+	require.Error(t, err)+	assert.Contains(t, err.Error(), "after")+}++func TestValidateOpts_RejectsMissingWindow(t *testing.T) {+	loc := sydney(t)+	opts := backfillOptsForTest(loc, "2026-05-18")+	opts.offpeakStart = ""++	err := validateOpts(opts)+	require.Error(t, err)+}++func decodeOffpeakItem(t *testing.T, av map[string]types.AttributeValue) dynamo.OffpeakItem {+	t.Helper()+	var got dynamo.OffpeakItem+	require.NoError(t, attributevalue.UnmarshalMap(av, &got))+	return got+}
Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift Modified +18
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swiftindex d4d4912..51235c9 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Models/APIModels.swift@@ -188,6 +188,13 @@ public struct DayEnergy: Codable, Sendable, Identifiable {     /// off-peak portion. Kept on the model so the field is available for a     /// future "off-peak vs peak export" view without another schema change.     public let offpeakGridExportKwh: Double?+    /// Server-computed peak (non-off-peak) grid import for the day, integrated+    /// directly from `Pgrid` readings over the windows bracketing off-peak.+    /// Omitted from the wire payload (and so `nil` here) for today, pre-30-day+    /// rows, and any day where the integration's usability gate failed — those+    /// fall back to the `eInput − offpeakGridImportKwh` residual at the call+    /// site (see `HistoryDerivedState.gridEntry`).+    public let peakGridImportKwh: Double?     public let note: String?      // Derived per-day stats, populated by the daily-derived-stats backend pass@@ -203,15 +210,6 @@ public struct DayEnergy: Codable, Sendable, Identifiable {      public var id: String { date } -    /// Grid imports outside the off-peak window, derived by subtracting the-    /// off-peak portion from the day's total. Returns `nil` when no off-peak-    /// data is available for the day, so callers can distinguish "unknown"-    /// from a true zero.-    public var peakGridImportKwh: Double? {-        guard let offpeak = offpeakGridImportKwh else { return nil }-        return max(0, eInput - offpeak)-    }-     public init(         date: String,         epv: Double,@@ -221,6 +219,7 @@ public struct DayEnergy: Codable, Sendable, Identifiable {         eDischarge: Double,         offpeakGridImportKwh: Double? = nil,         offpeakGridExportKwh: Double? = nil,+        peakGridImportKwh: Double? = nil,         note: String? = nil,         dailyUsage: DailyUsage? = nil,         socLow: Double? = nil,@@ -235,6 +234,7 @@ public struct DayEnergy: Codable, Sendable, Identifiable {         self.eDischarge = eDischarge         self.offpeakGridImportKwh = offpeakGridImportKwh         self.offpeakGridExportKwh = offpeakGridExportKwh+        self.peakGridImportKwh = peakGridImportKwh         self.note = note         self.dailyUsage = dailyUsage         self.socLow = socLow
Flux/Packages/FluxCore/Tests/FluxCoreTests/DayEnergyDecodingTests.swift Modified +6
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/DayEnergyDecodingTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/DayEnergyDecodingTests.swiftindex aa8483b..47b5ad5 100644--- a/Flux/Packages/FluxCore/Tests/FluxCoreTests/DayEnergyDecodingTests.swift+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/DayEnergyDecodingTests.swift@@ -25,6 +25,7 @@ struct DayEnergyDecodingTests {           "eDischarge": 6.8,           "socLow": 18.0,           "socLowTime": "2026-04-14T19:45:00Z",+          "peakGridImportKwh": 0.42,           "dailyUsage": {             "blocks": [               {@@ -64,6 +65,7 @@ struct DayEnergyDecodingTests {          #expect(day.socLow == 18.0)         #expect(day.socLowTime == "2026-04-14T19:45:00Z")+        #expect(day.peakGridImportKwh == 0.42)          let dailyUsage = try #require(day.dailyUsage)         #expect(dailyUsage.blocks.count == 2)@@ -97,6 +99,8 @@ struct DayEnergyDecodingTests {         #expect(day.socLow == nil)         #expect(day.socLowTime == nil)         #expect(day.peakPeriods == nil)+        // Omitted peak field decodes to nil so the iOS residual fallback applies.+        #expect(day.peakGridImportKwh == nil)         // Existing fields still decode correctly (AC 5.3).         #expect(day.date == "2026-04-14")         #expect(day.eInput == 0.25)@@ -116,6 +120,7 @@ struct DayEnergyDecodingTests {           "eDischarge": 6.8,           "socLow": null,           "socLowTime": null,+          "peakGridImportKwh": null,           "dailyUsage": null,           "peakPeriods": null         }@@ -127,6 +132,7 @@ struct DayEnergyDecodingTests {         #expect(day.socLow == nil)         #expect(day.socLowTime == nil)         #expect(day.peakPeriods == nil)+        #expect(day.peakGridImportKwh == nil)     }      // Existing init must still accept callers that omit the new params (AC 5.3
Flux/Flux/Models/CachedDayEnergy.swift Modified +3
diff --git a/Flux/Flux/Models/CachedDayEnergy.swift b/Flux/Flux/Models/CachedDayEnergy.swiftindex e2a86d0..f3572ba 100644--- a/Flux/Flux/Models/CachedDayEnergy.swift+++ b/Flux/Flux/Models/CachedDayEnergy.swift@@ -11,6 +11,7 @@ final class CachedDayEnergy {     var eDischarge: Double     var offpeakGridImportKwh: Double?     var offpeakGridExportKwh: Double?+    var peakGridImportKwh: Double?     var note: String?      // Derived stats persisted as optional Codable values (per@@ -32,6 +33,7 @@ final class CachedDayEnergy {         eDischarge = dayEnergy.eDischarge         offpeakGridImportKwh = dayEnergy.offpeakGridImportKwh         offpeakGridExportKwh = dayEnergy.offpeakGridExportKwh+        peakGridImportKwh = dayEnergy.peakGridImportKwh         note = dayEnergy.note         dailyUsage = dayEnergy.dailyUsage         socLow = dayEnergy.socLow@@ -49,6 +51,7 @@ final class CachedDayEnergy {             eDischarge: eDischarge,             offpeakGridImportKwh: offpeakGridImportKwh,             offpeakGridExportKwh: offpeakGridExportKwh,+            peakGridImportKwh: peakGridImportKwh,             note: note,             dailyUsage: dailyUsage,             socLow: socLow,
Flux/Flux/History/HistoryViewModel.swift Modified +1
diff --git a/Flux/Flux/History/HistoryViewModel.swift b/Flux/Flux/History/HistoryViewModel.swiftindex 70531c7..069de6a 100644--- a/Flux/Flux/History/HistoryViewModel.swift+++ b/Flux/Flux/History/HistoryViewModel.swift@@ -102,6 +102,7 @@ final class HistoryViewModel {                 cached.eDischarge = day.eDischarge                 cached.offpeakGridImportKwh = day.offpeakGridImportKwh                 cached.offpeakGridExportKwh = day.offpeakGridExportKwh+                cached.peakGridImportKwh = day.peakGridImportKwh                 cached.note = day.note                 warnIfClearing(cached: cached, day: day)                 cached.dailyUsage = day.dailyUsage
Flux/Flux/History/HistoryDerivedState.swift Modified +1 / -1
diff --git a/Flux/Flux/History/HistoryDerivedState.swift b/Flux/Flux/History/HistoryDerivedState.swiftindex 8086f0e..6a3c61a 100644--- a/Flux/Flux/History/HistoryDerivedState.swift+++ b/Flux/Flux/History/HistoryDerivedState.swift@@ -227,7 +227,7 @@ extension HistoryViewModel {          private static func gridEntry(day: DayEnergy, parsedDate: Date, isToday: Bool) -> GridEntry? {             guard let offpeakImport = day.offpeakGridImportKwh else { return nil }-            let peak = max(0, day.eInput - offpeakImport)+            let peak = day.peakGridImportKwh ?? max(0, day.eInput - offpeakImport)             return GridEntry(                 date: parsedDate,                 dayID: day.date,
Flux/FluxTests/CachedDayEnergyTests.swift Modified +6
diff --git a/Flux/FluxTests/CachedDayEnergyTests.swift b/Flux/FluxTests/CachedDayEnergyTests.swiftindex c9376d8..2972a38 100644--- a/Flux/FluxTests/CachedDayEnergyTests.swift+++ b/Flux/FluxTests/CachedDayEnergyTests.swift@@ -23,6 +23,7 @@ struct CachedDayEnergyTests {             eDischarge: 6.8,             offpeakGridImportKwh: 0.5,             offpeakGridExportKwh: 0.1,+            peakGridImportKwh: 0.42,             note: "sunny day",             dailyUsage: DailyUsage(blocks: [                 DailyUsageBlock(@@ -66,6 +67,7 @@ struct CachedDayEnergyTests {         #expect(roundTripped.note == original.note)         #expect(roundTripped.socLow == 18.0)         #expect(roundTripped.socLowTime == "2026-04-14T19:45:00Z")+        #expect(roundTripped.peakGridImportKwh == 0.42)          let dailyUsage = try #require(roundTripped.dailyUsage)         #expect(dailyUsage.blocks.count == 2)@@ -102,6 +104,7 @@ struct CachedDayEnergyTests {         #expect(roundTripped.socLow == nil)         #expect(roundTripped.socLowTime == nil)         #expect(roundTripped.peakPeriods == nil)+        #expect(roundTripped.peakGridImportKwh == nil)     }      // AC 5.4: persisting and re-fetching through SwiftData preserves the@@ -188,6 +191,9 @@ struct CachedDayEnergyTests {         #expect(restored.socLow == nil)         #expect(restored.socLowTime == nil)         #expect(restored.peakPeriods == nil)+        // New optional defaults to nil on a row stored before the field existed+        // (SwiftData lightweight migration / no-op).+        #expect(restored.peakGridImportKwh == nil)         // Pre-feature properties still decode unchanged.         #expect(restored.date == "2026-04-14")         #expect(restored.eInput == 0.25)
Flux/FluxTests/HistoryViewModelOverviewTests.swift Modified +29
diff --git a/Flux/FluxTests/HistoryViewModelOverviewTests.swift b/Flux/FluxTests/HistoryViewModelOverviewTests.swiftindex d8c863e..cfd3612 100644--- a/Flux/FluxTests/HistoryViewModelOverviewTests.swift+++ b/Flux/FluxTests/HistoryViewModelOverviewTests.swift@@ -263,6 +263,33 @@ struct HistoryViewModelOverviewTests {         #expect(derived.summary.gridDayCount == 1)     } +    @Test("(l′) server peakGridImportKwh present → used directly, not the eInput residual")+    func serverPeakUsedWhenPresent() throws {+        // Residual would be max(0, 2.0 − 0.5) = 1.5; the server value 0.9 must win.+        let dayA = day(+            "2026-04-14", epv: 5.0, eInput: 2.0, eOutput: 0.5, eCharge: 1.0, eDischarge: 0.5,+            offpeakGridImportKwh: 0.5,+            peakGridImportKwh: 0.9+        )+        let derived = HistoryViewModel.DerivedState(days: [dayA], now: now(month: 4, day: 16))+        let entry = try #require(derived.grid.first)+        #expect(entry.peakImportKwh == 0.9)+        #expect(derived.summary.peakImportTotalKwh == 0.9)+    }++    @Test("(l″) server peakGridImportKwh nil → falls back to max(0, eInput − offpeak) residual")+    func residualUsedWhenServerPeakNil() throws {+        let dayA = day(+            "2026-04-14", epv: 5.0, eInput: 2.0, eOutput: 0.5, eCharge: 1.0, eDischarge: 0.5,+            offpeakGridImportKwh: 0.5,+            peakGridImportKwh: nil+        )+        let derived = HistoryViewModel.DerivedState(days: [dayA], now: now(month: 4, day: 16))+        let entry = try #require(derived.grid.first)+        #expect(entry.peakImportKwh == 1.5)+        #expect(derived.summary.peakImportTotalKwh == 1.5)+    }+     @Test("(m) dailyUsage present but no offpeak → contributes to usage cohort, not grid")     func dailyUsageWithoutOffpeakMixedCohort() {         let mixed = day(@@ -324,6 +351,7 @@ struct HistoryViewModelOverviewTests {         eCharge: Double,         eDischarge: Double,         offpeakGridImportKwh: Double? = nil,+        peakGridImportKwh: Double? = nil,         socLow: Double? = nil,         socLowTime: String? = nil,         dailyUsage: DailyUsage? = nil@@ -337,6 +365,7 @@ struct HistoryViewModelOverviewTests {             eDischarge: eDischarge,             offpeakGridImportKwh: offpeakGridImportKwh,             offpeakGridExportKwh: nil,+            peakGridImportKwh: peakGridImportKwh,             note: nil,             dailyUsage: dailyUsage,             socLow: socLow,
CHANGELOG.md Modified +4 / -2
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 4563125..814b9b0 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Added +- **Peak grid import from readings** (Peak From Readings). History's daily peak grid import is now a server-computed `peakGridImportKwh` rather than the noisy residual `eInput − offpeakGridImportKwh` the app derived before (verified ~43% relative error vs an OVO meter export over 23 days of May 2026, on peak totals averaging ~0.45 kWh/day). A new `derivedstats.IntegratePeakGridImportKwh` integrates `max(pgrid,0)` over the two windows bracketing off-peak (`[day-start, offpeak-start)` and `[offpeak-end, next-day-start)`) by calling the existing single-window integrator twice; it returns usable only when both sub-windows pass the gate, so peak and off-peak each carry their own ~1.5% sampling artifact instead of stacking the whole day's loss onto peak. `dynamo.DailyEnergyItem` gains optional `peakGridImportKwh` and an independent `peakComputedAt` sentinel (Decision 3): `UpdateDailyEnergyDerived` writes the peak group and the derived-stats group separately, so the hourly summarisation pass can fill peak on a row that already has derived stats without clobbering it, and skips a row only when both sentinels are set. `/day` and `/history` expose `peakGridImportKwh` (omitted when absent); today has no real-time path and uses the iOS fallback (Decision 4). The hourly pass forward-fills each day as it becomes "yesterday"; the pre-deploy 30-day history is backfilled by the renamed `cmd/backfill-grid` CLI (formerly `cmd/backfill-offpeak`, Decision 7), which now also computes peak and writes it to the matching `flux-daily-energy` row — GETting the row first so it never creates a phantom row — with off-peak recompute behaviour unchanged. On the client, `DayEnergy` / `CachedDayEnergy` decode and cache the optional field and the History grid entry prefers the server value, falling back to `max(0, eInput − offpeakGridImportKwh)` only when it is absent (today and pre-backfill historical days). No chart axes, colours, or labels change. - **Better macOS Interface** (T-1342). The macOS build now reuses the iPad adaptive multi-column body: flipping `IPadLayoutGate.isActive` to `true` on macOS automatically routes Dashboard, Day Detail, and History through their existing `*Regular` branches, dropping the in-content `legacyHeader` on Dashboard and the `DayNavigationHeader` mustache on Day Detail. Day Detail's chrome moves into the window itself — the date renders in `.navigationTitle` (via a new `macPageTitle` using `DayDetailEyebrow.full`, resolving to "Today" on the current date) and prev/next chevron buttons live in a trailing `ToolbarItemGroup(.primaryAction)` with `accessibilityLabel` + `.help`; the next-day button is disabled when viewing today. The existing `.onKeyPress(.leftArrow/.rightArrow)` handlers continue to drive keyboard navigation without `.keyboardShortcut` on the toolbar buttons (would double-fire). The macOS scene clamps to `minWidth: 960, minHeight: 600` and opens at `defaultSize(1200, 800)` via `.windowResizability(.contentSize)` on the `WindowGroup` (the resizability modifier is required — `.frame` alone does not clamp the NSWindow). iOS and iPad layouts are unchanged. - **iPad adaptive layout** (T-1150). At regular horizontal size class on iPad — full-screen and ≥½ Split View on any iPad in the lineup — Flux now renders a `NavigationSplitView(.balanced)` with a Dashboard / Today / History sidebar and a detail column hosting the selected screen. Settings stays as a sheet, opened via a toolbar gear in the detail column. The three screens reflow into multi-column arrangements via a new `AdaptiveColumnsLayout` helper: Dashboard puts the battery hero and live trio side by side and reflows Summary / Battery (+ future blocks) through 2-col (≥ 700pt) or 3-col (≥ 1000pt) grids; History keeps the full-width stats overview on top and grids the Solar / Grid Usage / Daily Usage / selected-day summary cards; Day Detail switches to a two-column Grid with summary panels (Day-in-Five-Blocks, Summary, Battery, Daily Usage, Peak Usage) on the left and three stacked chart panels (Power, Battery Power, State of Charge) on the right, with the day navigation header, note section, and Compare control full width above the columns. The grid drops one column at Dynamic Type ≥ AX4 so cards retain a readable per-column width. The Settings sheet on iPad regular is capped to a 640pt-wide column centred in the sheet so form rows are not stretched edge-to-edge. At compact widths (Slide Over, ⅓ Split View) iPad falls back to the existing tab-bar shell, and iPhone Plus/Max landscape (which reports `.regular` horizontal but is not iPad) stays on the tab-bar shell via a `userInterfaceIdiom == .pad` gate. iPhone and macOS layouts are unchanged. - **Battery Can't Empty Before Off-Peak indicator** (T-1327). The Dashboard hero now shows "Won't empty before HH:MM" when, even at a 5 kW sustained-discharge ceiling, the battery cannot reach the 5 % cutoff before the next off-peak window opens — a `pbat`-independent answer to "is there any way?" rather than "given current rate, when?". The check is server-computed: `/status` carries a new nullable `battery.cantEmptyBeforeOffpeak` (true or null, encoded like `estimatedCutoffTime`), evaluated inside the existing `liveFresh` branch and reusing `derivedstats.ParseOffpeakWindow` (so the no-midnight-spanning-window limit carries over). Swift `BatteryInfo` gains a defaulted optional so the five existing constructors compile unchanged; `DashboardHeroPanel` composes a new `secondaryText` subview alongside the existing status line (no `Mode` enum changes) with a literal VoiceOver label `"Battery won't empty before off-peak at <HH:MM>"`. iOS and macOS share the panel via FluxCore.@@ -19,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Documentation +- **Peak From Readings smolspec**. Lightweight spec to stop the iOS app showing daily peak grid import as the noisy residual `eInput − offpeakGridImportKwh`. Adds a server-computed `peakGridImportKwh`, integrated from 10-second `Pgrid` samples over the two windows bracketing off-peak (`[day-start, offpeak-start)` and `[offpeak-end, next-day-start)`) using the same trapezoidal method and usability gate as the off-peak integrator, so peak and off-peak each carry their own ~1.5% sampling noise instead of stacking it all on peak (verified ~43% relative error vs an OVO meter export over 23 days of May 2026). Stored on `flux-daily-energy` with an independent `peakComputedAt` sentinel; the hourly pass forward-fills each day as it becomes "yesterday" and the renamed `cmd/backfill-grid` CLI fills pre-deploy history within the 30-day readings TTL. Exposed on `/day` and `/history`; the iOS History card prefers the server value and falls back to the residual when absent. Captures 7 decisions (direct integration over residual; storage location; the second sentinel; today/pre-30-day fallback; grid-import-only scope; the `GridUsageKwh`/`PeakGridImportKwh` naming divergence; consolidating historical backfill into the renamed `backfill-grid` CLI) and a 6-task plan. See `specs/peak-from-readings/`. - **Off-Peak From Readings spec** (T-1341). The spec captures the 2026-05-18 reference incident, the four-phase task plan (Foundation → Poller → API Live Path → Backfill + regression), and the design decisions that drove the implementation — most notably the deferred window-end finalisation (Decision around `waitForReadingAtOrAfter`) that prevents the readings-layer from re-introducing the boundary-loss bug class. See `specs/offpeak-from-readings/`. - **iPad adaptive layout spec** (T-1150). Implementation spec for swapping the stretched iPhone V5 shell on iPad for a `NavigationSplitView(.balanced)` sidebar at regular size class, with compact widths (Slide Over / narrow Split View) falling back to the existing tab-bar shell. Covers Dashboard / History / Day Detail multi-column layouts above 700 / 1000 pt detail widths, midnight rollover on the Today entry via a new `DayDetailViewModel.setDate(_:)`, and a `userInterfaceIdiom == .pad && hSizeClass == .regular` gate so iPhone Plus/Max landscape stays on the iPhone shell. See `specs/ipad-adaptive-layout/`. - **Daily Costs spec** (T-1326). The spec captures the 18+ design decisions and the TDD task plan (Backend stream + FluxCore/UI stream) that drove the implementation. Decision 24 standardises the multi-row pricing endpoints on a `{"pricing": [...]}` JSON envelope. See `specs/daily-costs/`.
specs/peak-from-readings/smolspec.md Modified +71
diff --git a/specs/peak-from-readings/smolspec.md b/specs/peak-from-readings/smolspec.mdnew file mode 100644index 0000000..2f02f14--- /dev/null+++ b/specs/peak-from-readings/smolspec.md@@ -0,0 +1,71 @@+# Peak From Readings++## Overview++The iOS app shows daily peak grid import as `eInput − offpeakGridImportKwh` (Flux/Flux/History/HistoryDerivedState.swift:230) — a subtractive residual of two values measured by different methods. For **past days**, `eInput` comes from AlphaESS's sub-second internal counter (matches the household meter to within ~7 Wh/day) while `offpeakGridImportKwh` is Flux's trapezoidal integration of 10-second `Pgrid` samples (loses ~1.5% on continuous high-current charging). The sampling loss therefore lands entirely on peak — verified ~43% relative error against an OVO Energy meter export on 23 days of May 2026, on peak totals averaging ~0.45 kWh/day.++This spec adds a server-computed `peakGridImportKwh` field for past days, derived by integrating `Pgrid` over the non-off-peak portion of the day using the same trapezoidal method as `derivedstats.IntegrateOffpeakDeltas`. Peak and off-peak then each carry their own ~1.5% noise floor proportional to their own kWh, instead of the noise stacking entirely on peak.++## Empirical baseline++24 days, May 2026 (verified comparator vs OVO meter CSV; tool kept in repo via this branch's history). Δ = Flux − OVO, kWh.++| Metric     | mean Δ  | mean\|Δ\| | stddev | max\|Δ\| |+|------------|---------|-----------|--------|----------|+| gridImport | +0.007  | 0.104     | 0.118  | 0.225    |+| gridExport | −0.420  | 0.420     | 0.058  | 0.563    |+| peak       | +0.196  | 0.196     | 0.049  | 0.323    |+| offpeak    | −0.182  | 0.182     | 0.111  | 0.414    |++Peak Δ ≈ −offpeak Δ with gridImport Δ ≈ 0 → bucketing artifact, not a measurement bug. Mean |Δ| on off-peak grows ~linearly with off-peak kWh, supporting the proportional-loss model.++## Requirements++- The system **MUST** produce `peakGridImportKwh` per local-Sydney day as the sum of `max(Pgrid, 0)` integrations over `[day-start, offpeak-start)` and `[offpeak-end, next-day-start)`.+- The system **MUST** produce `peakGridImportKwh + offpeakGridImportKwh` within 3% of `eInput` on any day where all three are populated, reflecting that both integrations use the same numerical method with the same ~1.5% per-window sampling artifact.+- The system **MUST** omit `peakGridImportKwh` from the DynamoDB row and from the API JSON on days where the integration's usability gate fails for either sub-window.+- The system **MUST** expose `peakGridImportKwh` at JSON key `peakGridImportKwh` (omitted when absent) on both `/day` and `/history` responses, alongside the existing `offpeakGridImportKwh`.+- The system **MUST** forward-fill `peakGridImportKwh` on each day's row as it becomes "yesterday" via the hourly summarisation pass, gated on an independent `peakComputedAt` sentinel (Decision 3), and **MUST** provide a one-shot operator CLI that backfills the pre-deploy historical window within the `flux-readings` TTL (Decision 7).+- The iOS app **SHOULD** prefer the server-provided `peakGridImportKwh` when non-nil and fall back to `max(0, day.eInput − offpeakImport)` otherwise.+- The system **MAY** emit a structured log entry per peak write comparing the integrated value against the `eInput − offpeakGridImportKwh` residual, analogous to the existing `offpeak drift` line (internal/dynamo/offpeak_drift.go), for ongoing drift visibility.++## Implementation Approach++### Compute & store (Go, server)++- Add helper `IntegratePeakGridImportKwh(readings []Reading, dayStartUnix, offpeakStartUnix, offpeakEndUnix, dayEndUnix int64) (kwh float64, sampleCount int, skippedPairs int, ok bool)` in `internal/derivedstats/integrate_offpeak.go`. Calls the existing single-window integrator twice (over `[dayStart, offpeakStart)` and `[offpeakEnd, dayEnd)`), each with selector `max(Pgrid, 0)`, and returns sum + combined provenance + true iff both sub-windows pass the usability gate.+- Window args are unix timestamps to match the existing `IntegrateOffpeakDeltas` signature; the caller converts the HH:MM SSM config exactly as `cmd/backfill-grid/main.go:offpeakBoundaries` (renamed from `backfill-offpeak`, Decision 7) already does.+- Add `PeakGridImportKwh *float64 dynamodbav:"peakGridImportKwh,omitempty"` to `dynamo.DailyEnergyItem` (internal/dynamo/models.go:37) and `PeakComputedAt string` (idempotency sentinel; see Decision 3).+- Add matching fields to `dynamo.DerivedStats`. Extend `UpdateDailyEnergyDerived` (internal/dynamo/dynamostore.go:92) to set both attributes when non-nil.+- In `poller.runSummarisationPass` (internal/poller/dailysummary.go:41), gate the existing derived-stats block on `item.DerivedStatsComputedAt == ""` (as today) and add a parallel block gated on `item.PeakComputedAt == ""` that calls the new helper and writes `PeakGridImportKwh` + `PeakComputedAt`. Skip-result returns only when **both** sentinels are set. The pass runs for yesterday only, so this forward-fills each day's row as it rolls; it does not reach pre-deploy historical rows (those are the CLI's job — see below).++### Historical backfill (Go, server)++- Rename `cmd/backfill-offpeak` → `cmd/backfill-grid` (Decision 7) and extend its per-date loop so that, alongside recomputing the `flux-offpeak` row (unchanged), it computes peak via `IntegratePeakGridImportKwh` over the two windows bracketing off-peak and writes `peakGridImportKwh` + `peakComputedAt` to the corresponding `flux-daily-energy` row through `dynamo.UpdateDailyEnergyDerived` (peak group only). The CLI must GET the daily-energy row first and skip the date for peak if it is absent (no phantom-row creation). Add a `--table-daily-energy` flag. Today is skipped on both sides; off-peak behaviour is unchanged.++### API surface++- Add `PeakGridImportKwh *float64 json:"peakGridImportKwh,omitempty"` to the day-summary struct (internal/api/response.go around :107) and the history-day struct (around :176).+- Populate from `deItem.PeakGridImportKwh` in `internal/api/day.go` at day.go:198 (after the live/stored energy reconcile) and in `internal/api/history.go` near history.go:154. No real-time computation path for today; today's row uses the iOS fallback (see Decision 4).++### iOS consumption++- Add optional `peakGridImportKwh: Double?` to `DayEnergy` and `CachedDayEnergy` (Flux/Flux/Models/CachedDayEnergy.swift:12-14 alongside the off-peak fields).+- Update `HistoryDerivedState.gridEntry` (Flux/Flux/History/HistoryDerivedState.swift:228-239): when `day.peakGridImportKwh` is non-nil, use it directly; otherwise keep the existing `max(0, day.eInput − offpeakImport)`.++### Out of scope++- Only the `peakGridImportKwh` channel is added. Peak versions of solar / export / battery charge / battery discharge are deliberately not computed (see Decision 5).+- Off-peak computation and storage are unchanged; the `flux-offpeak` row shape and the `flux-offpeak.GridUsageKwh` field name (see Decision 6) are untouched. The off-peak backfill CLI is renamed `cmd/backfill-offpeak` → `cmd/backfill-grid` and extended to also write peak to `flux-daily-energy`, but its off-peak recompute behaviour is unchanged (Decision 7).+- No real-time peak computation path for today; today's row uses the iOS fallback (see Decision 4).+- No persistence beyond the 30-day `flux-readings` TTL; rows older than 30 days at deploy never get peak populated (see Decision 4).+- No change to `derivedstats.PeakPeriods` (top-3 high-load clusters — unrelated to peak grid import).+- No change to AlphaESS polling cadence, API authentication, or response envelope shape.+- No display changes beyond switching `gridEntry`'s source field; chart axes, colours, and labels stay as they are.++## Risks and Assumptions++- **Risk: the morning sub-window `[00:00, off-peak-start)` may have sparse readings on days when the poller was offline overnight.** Mitigation: the helper requires both sub-windows to pass the usability gate; either failing produces `ok=false` and the field is omitted. The iOS fallback then applies.+- **Risk: DST day boundaries (23h / 25h days in Sydney).** Mitigation: pass already computes `dayStart`/`dayEnd` via `time.ParseInLocation(dateLayout, date, p.cfg.Location).AddDate(0,0,1)` (dailysummary.go:67-69); the new helper takes unix args so the call site is naturally DST-correct.+- **Risk: rows older than 30 days at deploy will never receive `peakGridImportKwh`** because `flux-readings` (the integration input) has a 30-day TTL. Mitigation: this is accepted — the iOS fallback applies indefinitely for those rows, displaying the noisy-residual value the app already shows today. The discontinuity in History rolls forward with the TTL window but the user never sees a worse number than the current production calculation. Decision 4 captures the trade-off.+- **Assumption: the residual sampling loss is roughly proportional to energy throughput per window** (validated by the table above — peak Δ tightly bounded at 0.196 ± 0.049 across 23 days). Success criterion for the post-deploy comparison is "peak Δ proportional to peak kWh, similar magnitude to off-peak Δ", not "peak Δ near zero".
specs/peak-from-readings/decision_log.md Modified +261
diff --git a/specs/peak-from-readings/decision_log.md b/specs/peak-from-readings/decision_log.mdnew file mode 100644index 0000000..5c58b65--- /dev/null+++ b/specs/peak-from-readings/decision_log.md@@ -0,0 +1,261 @@+# Decision Log: Peak From Readings++## Decision 1: Compute peak via direct integration instead of as a residual++**Date**: 2026-05-30+**Status**: accepted++### Context++The off-peak-from-readings feature (T-1341) introduced trapezoidal integration of 10-second `Pgrid` samples to compute `flux-offpeak.gridUsageKwh`. Empirical comparison against an OVO Energy meter CSV (May 2026, 24 days) showed the integration loses ~1.5% of energy relative to OVO's continuous meter — a sampling artifact: AlphaESS's internal counter integrates at sub-second resolution while the 10-second samples we poll are instantaneous power. AlphaESS's daily cumulative counter (`eInput`) is only exposed at the daily level.++For past days the iOS app reads stored `eInput` (high-accuracy, ~7 Wh/day vs meter) and stored `offpeakGridImportKwh` (low-accuracy, ~1.5% under), then displays peak as `eInput − offpeakGridImportKwh`. With peak values averaging ~0.45 kWh/day, the absolute 0.18 kWh sampling-loss error becomes a ~43% relative error on the displayed peak number.++### Decision++Compute `peakGridImportKwh` server-side as a direct trapezoidal integration of `Pgrid` over the two windows bracketing the off-peak window: `[day-start, offpeak-start)` and `[offpeak-end, next-day-start)`. Expose it as a new field so the iOS app stops subtracting.++### Rationale++Both peak and off-peak now use the same integration method over complementary windows; the sampling error is proportional to each window's own kWh throughput rather than concentrated on the residual. The peak window typically carries far less grid import than off-peak (no grid charging happens outside the cheap-tariff window), so 1.5% of peak is ~7-30 Wh/day — within sensor noise.++### Alternatives Considered++- **Accept the current pattern.** Absolute dollar impact is ~$1/month at typical AU feed-in rates. Rejected: the chart display of peak as a fraction of grid usage is visibly wrong relative to the bill, and the user-visible inconsistency erodes trust in the rest of the dashboard.+- **Snapshot-anchored scaling.** Scale integration by `eInput / fullDayIntegratedImport` to anchor to the authoritative daily counter. Rejected: muddies provenance (the stored value would no longer be a clean integration of what was measured), and "calibration multiplier" is an indirection that's hard to reason about and hard to test.+- **Calibration multiplier (×1.015).** Trivial code change. Rejected: the bias is unlikely to be uniform across days or across the five integrated channels, and lying about what was measured is the wrong way to fix a small systematic error.+- **Compute peak from `getOneDayPowerBySn` 5-minute snapshots.** AlphaESS exposes 5-min power snapshots via this endpoint. Rejected: 5-min sampling is coarser than the 10-second readings we already have — it would worsen the artifact we're mitigating.++### Consequences++**Positive:**+- Peak and off-peak treated symmetrically; the iOS display is no longer the noisy residual of two larger numbers.+- Reuses existing integration code with no new numerical methods.+- No schema migration, no new table.++**Negative:**+- One more derived field to maintain on `DailyEnergyItem`.+- `peakGridImportKwh + offpeakGridImportKwh ≠ eInput` exactly; they differ by ~1.5% of `eInput` due to the sampling artifact. No UI surface currently displays all three side-by-side, so this is an operator-visible anomaly only. Documented in the requirements as the 3% tolerance bound.++---++## Decision 2: Store on `flux-daily-energy`, not `flux-offpeak`++**Date**: 2026-05-30+**Status**: accepted++### Context++The new `peakGridImportKwh` value needs a home in DynamoDB. Candidates: extend `flux-offpeak`, extend `flux-daily-energy`, or create a new `flux-peak` table.++### Decision++Add `peakGridImportKwh` as an optional attribute on `dynamo.DailyEnergyItem` (the `flux-daily-energy` row), computed and written by the existing hourly daily-summarisation pass alongside `DailyUsage` / `SocLow` / `PeakPeriods` (internal/poller/dailysummary.go:83).++### Rationale++`flux-daily-energy` is the per-day summary row, and peak grid import is a per-day summary quantity. The summarisation pass already runs hourly, already loads the full day's readings, and already writes a multi-attribute `UpdateItem`. Adding one more attribute is the lowest-friction integration point.++### Alternatives Considered++- **Extend `flux-offpeak`.** Co-locates peak with off-peak (computed from same readings). Rejected: `flux-offpeak` is semantically "what happened in the off-peak window" — putting peak data there is naming-confusing, and the row is written at off-peak-end (14:00:00) which is before the evening peak window is complete.+- **New `flux-peak` table.** Cleanest separation. Rejected: storage shape would be identical to `flux-daily-energy` (one row per `(sysSn, date)`), so a separate table adds infrastructure for no gain.++### Consequences++**Positive:**+- Reuses an existing hourly pass and an existing write path.++**Negative:**+- `DailyEnergyItem` continues to accumulate optional fields; readers must handle each one being absent.++---++## Decision 3: Use a dedicated `peakComputedAt` sentinel for backfill++**Date**: 2026-05-30+**Status**: accepted++### Context++The summarisation pass uses `derivedStatsComputedAt` as an idempotency sentinel — rows where it is set are skipped entirely (dailysummary.go:53-56). After deploying the new field, every existing row's sentinel is already set, so the pass would not populate `peakGridImportKwh` on any historical row unless something else triggers a recompute.++### Decision++Add a second sentinel field `PeakComputedAt` to `DailyEnergyItem`. The pass's skip-result check becomes "both `DerivedStatsComputedAt != ""` AND `PeakComputedAt != ""`". The existing derived-stats block stays gated on the existing sentinel; a new peak-compute block is gated on the new sentinel.++The hourly pass runs for "yesterday" only (Decision 4), so the second sentinel makes the pass **forward-fill** peak onto each day's row as it becomes yesterday, independently of the derived-stats block. It does **not** reach rows that are already older than yesterday at deploy time — those are covered by a one-shot backfill CLI (Decision 7). The sentinel also keeps both writers (hourly pass and CLI) idempotent and lets either write the peak group without clobbering the derived-stats group.++### Rationale++The two sentinels are orthogonal: peak can be written on a row that already has derived stats (and vice versa) without re-doing or clobbering the other group, so the same write path (`UpdateDailyEnergyDerived`) serves both the hourly forward-fill and the historical CLI. Future "derived field X with independent lifecycle" additions follow the same pattern.++The original framing of this decision claimed the hourly pass would backfill the whole 30-day window automatically. That was wrong: the pass only ever processes yesterday (Decision 4), so historical rows older than yesterday at deploy would never be visited. Historical coverage is therefore delegated to the CLI of Decision 7; the sentinel's role here is forward-fill plus cross-writer idempotency, not historical backfill.++### Alternatives Considered++- **Make the hourly pass scan the whole TTL window each tick** and fill peak wherever `PeakComputedAt` is empty. Rejected: adds a per-tick range scan to a pass that is otherwise a single-row operation, and diverges from how every other readings-derived field in this repo is backfilled (one-shot CLI — see `cmd/backfill-offpeak`, `cmd/backfill-solar`).+- **Operator runbook: one-off `UpdateItem` to clear `DerivedStatsComputedAt` on each row.** Rejected: clearing the derived-stats sentinel forces the other stats (DailyUsage, SocLow, PeakPeriods) to be recomputed unnecessarily, and there is no safety against re-clearing and re-running. (`flux-daily-energy` is one row per day, ~30 rows in the TTL window — the operational concern is correctness, not row count.)+- **Repurpose `derivedStatsComputedAt` to mean "all derived stats including peak".** Rejected: would require either a coordinated deploy (clear all sentinels on rollout) or a versioning field. The two-sentinel design avoids both.++### Consequences++**Positive:**+- Peak is written independently of derived stats by both the hourly pass and the backfill CLI, with no clobbering and idempotent re-runs.+- Forward-fill of each new day is automatic.+- Pattern generalises to future per-field derived stats.++**Negative:**+- One more sentinel field on `DailyEnergyItem`.+- The pass's skip-result logic is slightly more complex (two conditions instead of one).+- Historical rows (older than yesterday at deploy) are not filled by the pass; they require the one-shot CLI of Decision 7. Until it is run — or those rows age out of the TTL — those days use the iOS residual fallback (Decision 4).++---++## Decision 4: Today and pre-30-day rows use the iOS fallback++**Date**: 2026-05-30+**Status**: accepted++### Context++The summarisation pass runs for "yesterday" only (dailysummary.go:25) so today's row is never reached by the pass during the same day. Independently, `flux-readings` has a 30-day TTL, so rows older than 30 days at deploy have no readings to integrate. Both cases result in `peakGridImportKwh` being absent from the API response.++The iOS app falls back to `max(0, day.eInput − offpeakImport)` when the new field is absent — i.e. the existing production calculation.++### Decision++Accept this. Do not add a real-time peak path in the API for today, and do not add cross-TTL persistence for pre-30-day rows.++### Rationale++For **today**, `day.eInput` in the API response is `max(stored, computed)` (compute.go:328-334, `reconcileEnergy`). The computed side is the same trapezoidal integration the new field uses, the stored side is the AlphaESS counter at the most recent hourly poll. Within the active hour, computed is fresher than stored; outside the active hour, stored wins. In either case the iOS fallback's error is bounded: it is at-worst the current production behaviour and at-best (when both sides are live integration) the errors cancel to ~1.5% on peak instead of ~43%. The user's complaint is about historical days; today's display is unchanged from today's behaviour.++For **rows older than 30 days at deploy**, the iOS fallback displays the same value the app currently shows (no regression). The discontinuity in the History chart between recent days (using the integrated field) and pre-30-day days (using the residual) rolls forward with the TTL window but never produces a value worse than today's production. Persisting peak in a way that survives the readings TTL would require either a separate peak-snapshot table (rejected — Decision 2 logic) or a backfill from the snapshot fields that's known to have its own correctness issues (T-1341 decision log).++### Alternatives Considered++- **Add a real-time peak computation path in `internal/api/day.go` for today.** Mirrors the existing `offpeakSplit` pattern (day.go:200-204). Rejected: ~30 LOC of API-layer complexity, and the worst-case today behaviour is no-worse than current production — see rationale. Worth revisiting if user feedback shows today's peak being notably wrong.+- **Persist peak with a longer TTL than `flux-readings` (e.g. recompute and store at the end of each day, keep forever).** Rejected: would eliminate the rolling-30-day discontinuity but adds a new write path with its own correctness questions, and the discontinuity is invisible to the user during normal use (they don't generally re-open History from 60 days ago to compare with last week).++### Consequences++**Positive:**+- Implementation stays small and additive.+- No new API-layer compute path, no new TTL-resistant storage.++**Negative:**+- Today's peak in History uses a different calculation method than yesterday's onward. Documented but not surfaced to the user.+- Historical chart has a slowly-rolling discontinuity at deploy-time minus 30 days. Documented but not surfaced.++---++## Decision 5: Only `peakGridImportKwh`; not peak versions of solar / export / battery channels++**Date**: 2026-05-30+**Status**: accepted++### Context++`IntegrateOffpeakDeltas` returns five channels (grid import, grid export, solar, battery charge, battery discharge). Adding peak versions of all five would cost no additional compute (same readings, same integrator) and only marginal storage.++### Decision++Only compute and store `peakGridImportKwh`. The other four channels are not added.++### Rationale++YAGNI. The user-visible problem this spec addresses is the peak grid import display on the History card. No current iOS view displays "peak solar generation" or "peak battery discharge". Adding fields with no consumer adds API surface, iOS model fields, and tests for code paths that no human will look at.++### Alternatives Considered++- **Add all five channels at the storage layer, expose only `peakGridImportKwh` in the API.** Rejected: the storage write path needs the same test coverage as the API path; the marginal cost of "free" channels is not actually zero once tests and read-side code are included.+- **Add all five channels everywhere (storage and API).** Rejected: speculative API surface. If a future spec needs peak-solar or peak-discharge, it can extend this in the same hourly pass without breaking anything.++### Consequences++**Positive:**+- Smaller change, smaller test surface.+- Future addition of other peak channels remains trivial and can be motivated by an actual UI requirement.++**Negative:**+- Slight asymmetry with `OffpeakItem`, which carries all five channels.++---++## Decision 6: Accept storage naming inconsistency between `GridUsageKwh` and `PeakGridImportKwh`++**Date**: 2026-05-30+**Status**: accepted++### Context++The off-peak feature stores grid import as `OffpeakItem.GridUsageKwh` (storage layer) but exposes it as `offpeakGridImportKwh` (API layer). The peak feature stores grid import as `DailyEnergyItem.PeakGridImportKwh` (storage layer) and exposes it as `peakGridImportKwh` (API layer). So at storage, off-peak uses "Usage" and peak uses "Import"; at API, both use "Import".++### Decision++Accept the inconsistency. Keep `OffpeakItem.GridUsageKwh` as-is; introduce `DailyEnergyItem.PeakGridImportKwh`. Document the divergence in implementation notes for future readers.++### Rationale++Renaming `OffpeakItem.GridUsageKwh` is out of scope: it touches the table, the off-peak feature's integration tests, the backfill CLI, the design doc, and the production-deployed JSON marshalling. The naming is internally consistent (off-peak storage is purely "what happened during the off-peak window", hence "usage" rather than "import"). At the API boundary both fields use "import" because the API is the user-facing surface and "import" is the term the iOS app and the OVO bill use.++### Alternatives Considered++- **Rename `OffpeakItem.GridUsageKwh` → `OffpeakItem.GridImportKwh` for consistency.** Rejected: deferred rename, low marginal value, large blast radius (off-peak tests, backfill CLI, design docs). Could be its own future spec if storage-layer naming becomes important.+- **Name the new field `DailyEnergyItem.PeakGridUsageKwh` for parity with off-peak storage.** Rejected: even less consistent at the API layer ("offpeakGridImportKwh" vs "peakGridUsageKwh" is worse than the current divergence at the storage layer only).++### Consequences++**Positive:**+- Smaller blast radius for this spec.++**Negative:**+- Storage layer has two synonyms for "grid import" depending on which feature wrote them. Implementation notes will document.++---++## Decision 7: Backfill the historical window by extending and renaming the off-peak CLI to `cmd/backfill-grid`++**Date**: 2026-05-30+**Status**: accepted++### Context++The hourly summarisation pass only ever processes "yesterday" (Decision 4), so the `PeakComputedAt` sentinel forward-fills peak from deploy day onward but never reaches rows that are already older than yesterday at deploy time. Those are the bulk of the History view — and historical days are exactly the user-visible problem this spec set out to fix. Decision 3 originally assumed the hourly pass would backfill them; it does not.++The repo already has the right pattern for "populate a new readings-derived field on existing rows": `cmd/backfill-offpeak` (modelled on `cmd/backfill-solar`) walks the daily rows over a `today-30d → yesterday` range in a single operator-run invocation, recomputing from `flux-readings` and skipping today. Off-peak deltas and peak grid import are both readings-derived quantities over the same set of days, computed from the same `Pgrid` series.++### Decision++Rename `cmd/backfill-offpeak` → `cmd/backfill-grid` and extend it so that, for each date in the range, in addition to recomputing the off-peak `flux-offpeak` row (unchanged behaviour), it computes `peakGridImportKwh` over the two windows bracketing off-peak and writes it (plus `peakComputedAt`) to the corresponding `flux-daily-energy` row via the same `UpdateDailyEnergyDerived` peak-group write the hourly pass uses.++To avoid creating a phantom `flux-daily-energy` row, the CLI MUST confirm the daily-energy row exists (GET) before writing peak; if it is absent the date is skipped for peak. The off-peak side keeps its existing `WriteOffpeakIfComplete` conditional write. Today is skipped on both sides.++### Rationale++One operator command for both readings-derived grid quantities over the same days is less surface than a second near-identical CLI, and the name `backfill-grid` describes what it now does (both grid-import channels). It matches the established T-1341 backfill pattern, so operators already know the flag shape (`--from`/`--to`/`--dry-run`/table names). The peak write reuses the independent-sentinel write path from Decision 3, so there is no new write semantics.++### Alternatives Considered++- **A separate `cmd/backfill-peak`.** Rejected: duplicates the date-range walk, readings query, Sydney-TZ handling, and flag parsing already in the off-peak CLI for two fields computed from the same readings over the same days. The user directed consolidation.+- **No CLI; accept that only yesterday-onward gets peak** and amend the requirement to drop pre-deploy historical coverage. Rejected: leaves the History view — the feature's whole motivation — on the noisy residual for ~30 days after deploy.+- **Scan the TTL window inside the hourly pass.** Rejected for the reasons in Decision 3's alternatives (per-tick scan, diverges from repo norm).++### Consequences++**Positive:**+- The pre-deploy historical window gets accurate peak in one operator run, matching the off-peak rollout flow.+- No new CLI to learn or maintain; one tool covers both grid-import channels.+- Reuses the Decision 3 write path and the existing readings/date-range machinery.++**Negative:**+- The CLI name no longer mentions off-peak; the rename touches the package, its usage docs, and its tests. (No Makefile/infra target references it — it is a `go run ./cmd/...` tool.)+- The CLI now reads two tables and writes two tables, so its result accounting and summary output grow to cover peak alongside the five off-peak deltas.+- A `flux-daily-energy` row missing at backfill time is skipped for peak (no phantom-row creation); that day stays on the iOS fallback until re-run.++### Impact++`cmd/backfill-offpeak` → `cmd/backfill-grid` (package, docs, tests). Reuses `derivedstats.IntegratePeakGridImportKwh`, `dynamo.UpdateDailyEnergyDerived`, and `dynamo.GetDailyEnergy`. The CLI gains a `--table-daily-energy` flag.++---
specs/peak-from-readings/tasks.md Added +50
diff --git a/specs/peak-from-readings/tasks.md b/specs/peak-from-readings/tasks.mdnew file mode 100644index 0000000..b600cfa--- /dev/null+++ b/specs/peak-from-readings/tasks.md@@ -0,0 +1,50 @@+---+references:+    - specs/peak-from-readings/smolspec.md+    - specs/peak-from-readings/decision_log.md+---+# Peak From Readings++## Backend++- [x] 1. Peak grid import is computed from readings over the two windows bracketing off-peak <!-- id:1am1857 -->+  - A new integration helper sums max(Pgrid,0) over [day-start, offpeak-start) and [offpeak-end, next-day-start) using the same trapezoidal method and usability gate as the existing off-peak integrator.+  - Returns a combined value plus provenance and a usable flag that is true only when both sub-windows pass the gate.+  - Verify with unit tests: both sub-windows summed correctly; gate failure when one sub-window has sparse readings yields not-usable; DST-length (23h/25h) days handled via the unix-timestamp window args; peak+offpeak lands within 3% of eInput on a representative full day.+  - References: specs/peak-from-readings/smolspec.md++- [x] 2. The daily-energy row carries and persists the peak grid import value <!-- id:1am1858 -->+  - The stored daily-energy item gains an optional peak grid import field plus an independent compute sentinel; the daily-energy write path persists both when set and omits them when nil.+  - Storage naming divergence with the off-peak field is intentional (Decision 6).+  - Verify with a store-layer test that round-trips a row with the field set and a row with it unset, confirming the attribute is absent from the persisted item when nil.+  - Blocked-by: 1am1857 (Peak grid import is computed from readings over the two windows bracketing off-peak)+  - References: specs/peak-from-readings/smolspec.md++- [x] 3. The hourly summarisation pass backfills peak grid import via an independent sentinel <!-- id:1am1859 -->+  - The pass populates peak grid import on eligible rows gated on its own sentinel, leaving the existing derived-stats block and its sentinel untouched (Decision 3).+  - A row that already has derived stats but no peak gets peak written on the next tick; a row is skipped only when both sentinels are set; a sub-window gate failure leaves the field absent.+  - Verify with pass-level tests: a row with derived stats but no peak gets peak written; a row with both sentinels set is skipped; a gate failure leaves the field unwritten.+  - Blocked-by: 1am1858 (The daily-energy row carries and persists the peak grid import value)+  - References: specs/peak-from-readings/smolspec.md++- [x] 4. The /day and /history responses expose peakGridImportKwh <!-- id:1am185a -->+  - Both the day-summary and history-day responses include peakGridImportKwh sourced from the stored value, at JSON key peakGridImportKwh alongside offpeakGridImportKwh, omitted when absent.+  - No real-time compute path for today (Decision 4).+  - Verify with API tests asserting the field appears with the stored value when present and is absent from the JSON when unset, on both endpoints.+  - Blocked-by: 1am1859 (The hourly summarisation pass backfills peak grid import via an independent sentinel)+  - References: specs/peak-from-readings/smolspec.md++- [x] 5. Historical peak grid import is backfilled by the renamed backfill-grid CLI+  - Rename cmd/backfill-offpeak to cmd/backfill-grid (Decision 7) and extend its per-date loop so that, alongside the unchanged flux-offpeak recompute, it computes peakGridImportKwh via derivedstats.IntegratePeakGridImportKwh over the two windows bracketing off-peak and writes peakGridImportKwh + peakComputedAt to the corresponding flux-daily-energy row through UpdateDailyEnergyDerived (peak group only).+  - The CLI must GET the daily-energy row first and skip the date for peak when it is absent (no phantom-row creation); today is skipped on both sides; off-peak recompute behaviour is unchanged. Add a --table-daily-energy flag.+  - Verify with CLI-core tests: a date with a present daily-energy row gets peak written; an absent daily-energy row is skipped for peak without creating a row; dry-run writes nothing to either table; off-peak recompute still works.+  - References: specs/peak-from-readings/smolspec.md, specs/peak-from-readings/decision_log.md++## iOS++- [x] 6. The History grid entry prefers server peak grid import with a residual fallback <!-- id:1am185b -->+  - The iOS day-energy model and its cached form decode the optional peakGridImportKwh field.+  - The History card grid entry uses the server value when non-nil and falls back to max(0, eInput − offpeakImport) when absent; no display changes beyond the source of the peak number.+  - Verify with tests asserting the server value is used when present and the residual is used when the field is nil.+  - Blocked-by: 1am185a (The /day and /history responses expose peakGridImportKwh)+  - References: specs/peak-from-readings/smolspec.md
specs/peak-from-readings/implementation.md Added new
diff --git a/specs/peak-from-readings/implementation.md b/specs/peak-from-readings/implementation.mdnew file mode 100644index 0000000..4280a83--- /dev/null+++ b/specs/peak-from-readings/implementation.md@@ -0,0 +1,232 @@+# Implementation: Peak From Readings++Explanation of the implementation that shipped on branch `peak-from-readings`+(commits `531032b`..`HEAD`), written at three expertise levels, followed by a+Completeness Assessment mapping each smolspec requirement to code and tests.++---++## Beginner Level++### What This Does++The app's History screen shows how much grid electricity you imported during+"peak" hours each day. Until now the app worked that number out by subtraction:+it took the whole day's grid import (a very accurate number from the battery+inverter) and subtracted the off-peak portion (a less accurate number the+backend calculates). Subtracting a slightly-wrong number from an accurate one+dumps *all* of the error onto what's left — peak. On small peak days that error+was around 43% of the displayed value.++This change makes the backend calculate peak grid import **directly** instead of+by subtraction. It adds up the grid power readings (taken every 10 seconds)+during the two stretches of the day that sit *outside* the off-peak window —+before it and after it. Off-peak and peak are now measured the same way, so each+carries only its own small (~1.5%) measurement noise instead of one carrying+both.++### Why It Matters++The peak number on the History card is what you compare against your electricity+bill. When it's visibly wrong, it erodes trust in everything else on the+dashboard. After this change the peak figure lines up with reality to within a+few percent.++### Key Concepts++- **Grid import**: electricity you pull *from* the grid (as opposed to feeding+  solar back into it).+- **Off-peak window**: the cheap-tariff hours (configured as 11:00–14:00) when+  the battery is allowed to charge from the grid.+- **Peak**: everything outside the off-peak window — split into a morning piece+  (midnight → off-peak start) and an evening piece (off-peak end → next+  midnight).+- **Integration**: adding up many small instantaneous power readings to get+  total energy (kWh), like summing the area under a graph.+- **Sentinel**: a small "done" marker stored next to a row so a repeating job+  knows it already did the work and can skip it.+- **Fallback**: if the backend hasn't produced a peak value for a day (today, or+  very old days), the app quietly uses the old subtraction method so the screen+  is never blank.++---++## Intermediate Level++### Changes Overview++Three layers, one new field (`peakGridImportKwh`).++**Go backend — compute & store**+- `internal/derivedstats/integrate_offpeak.go`: new+  `IntegratePeakGridImportKwh(readings, dayStart, offpeakStart, offpeakEnd,+  dayEnd)` — calls the existing single-window integrator `IntegrateOffpeakDeltas`+  twice (over `[dayStart, offpeakStart)` and `[offpeakEnd, dayEnd)`), sums the+  `GridImportKwh` of each, and returns `ok=true` only when *both* sub-windows+  pass the usability gate.+- `internal/dynamo/models.go`: `DailyEnergyItem` and `DerivedStats` gain an+  optional `PeakGridImportKwh *float64` and a `PeakComputedAt` sentinel string.+- `internal/dynamo/dynamostore.go`: `UpdateDailyEnergyDerived` now builds its+  `SET` expression dynamically and writes the *derivedStats group* and the+  *peak group* independently, each gated on its own sentinel being non-empty.+- `internal/poller/dailysummary.go`: the hourly summarisation pass keeps the+  existing derivedStats block (gated on `DerivedStatsComputedAt`) and adds a+  parallel peak block (gated on `PeakComputedAt`). The pass skips a row only+  when *both* sentinels are set.++**Go backend — API surface**+- `internal/api/response.go`, `day.go`, `history.go`: `DaySummary` and+  `DayEnergy` expose `peakGridImportKwh` (JSON `omitempty`), populated from the+  stored value. No real-time computation for today.++**Go backend — historical backfill**+- `cmd/backfill-offpeak` → `cmd/backfill-grid`: the existing off-peak backfill+  CLI is renamed and extended. Per date it still recomputes the `flux-offpeak`+  row unchanged, and *additionally* computes peak over the full day and writes+  `peakGridImportKwh` + `peakComputedAt` to the matching `flux-daily-energy`+  row — after a `GetDailyEnergy` check so it never creates a phantom row. New+  `--table-daily-energy` flag.++**iOS**+- `FluxCore/Models/APIModels.swift`, `Flux/Models/CachedDayEnergy.swift`,+  `HistoryViewModel.swift`: `DayEnergy` and its cached form decode and persist+  the optional field.+- `Flux/History/HistoryDerivedState.swift`: `gridEntry` uses+  `day.peakGridImportKwh ?? max(0, day.eInput - offpeakImport)`.++### Implementation Approach++The guiding idea is **symmetry**: peak and off-peak are computed by the same+trapezoidal integrator over complementary windows, so their errors are+proportional to their own throughput rather than concentrated on a residual.+Reusing `IntegrateOffpeakDeltas` (rather than writing a fresh single-channel+integrator) guarantees the peak usability gate is byte-for-byte identical to the+off-peak one.++The **two-sentinel** design lets the peak field be filled onto rows that already+have derivedStats without recomputing or clobbering them. The same write path+(`UpdateDailyEnergyDerived`) serves both the hourly forward-fill and the+historical CLI.++Backfill is split by reach: the hourly pass only ever processes "yesterday", so+it *forward-fills* each new day; the renamed CLI handles the pre-deploy 30-day+history in one operator run, matching the established `backfill-offpeak` /+`backfill-solar` pattern.++### Trade-offs++- **Direct integration vs. calibration multiplier vs. snapshot scaling**+  (Decision 1): direct integration keeps provenance honest — the stored value is+  a clean integration of what was measured, not a fudged residual.+- **Store on `flux-daily-energy` vs. `flux-offpeak` vs. new table** (Decision 2):+  reuses the existing per-day summary row and its hourly write path.+- **Two sentinels vs. clearing the existing sentinel / versioning**+  (Decision 3): orthogonal lifecycles, no coordinated deploy, no unnecessary+  recompute of other stats.+- **CLI backfill vs. per-tick TTL scan vs. accepting reduced scope**+  (Decision 7): the CLI matches repo convention and keeps the hourly pass a+  single-row operation. This corrected an error in the original Decision 3,+  which had assumed the hourly pass would backfill history (it cannot — it only+  visits yesterday).+- **Grid-import only vs. all five channels** (Decision 5): YAGNI — no UI consumes+  peak solar/export/battery.++---++## Expert Level++### Technical Deep Dive++- **Gate composition.** `IntegratePeakGridImportKwh` returns early with+  `ok=false` if either sub-window's `IntegrateOffpeakDeltas` is unusable. The+  morning sub-window `[00:00, offpeakStart)` is the fragile one (overnight poller+  outages → sparse readings); a single sparse sub-window omits the whole day's+  peak and the iOS fallback applies. There is no partial-day peak.+- **DST correctness.** The window bounds are absolute unix timestamps derived+  from `time.ParseInLocation(dateLayout, date, Location)` + minute offsets, so+  23h/25h Sydney days integrate over the correct real-time span. The integrator+  never sees wall-clock; it only sees timestamps.+- **Write-path independence.** `UpdateDailyEnergyDerived` accumulates `sets`/+  `values` per group and no-ops when neither sentinel is set (DynamoDB rejects an+  empty `UpdateExpression`). This is what lets a pre-feature row — which already+  has `derivedStatsComputedAt` — receive only the peak group on a later pass.+- **Gate-failure still stamps the sentinel.** On a usability-gate miss the pass+  sets `PeakComputedAt` but leaves `PeakGridImportKwh` nil, so a permanently+  sparse past day is not retried every hour. Past-day readings are immutable once+  the day closes, so this is safe.+- **Phantom-row guard.** `UpdateItem` upserts, so `cmd/backfill-grid` issues a+  `GetDailyEnergy` first and skips peak when the row is absent. The GET's return+  value is also *used* — the prior peak prints in the `prev→new` operator summary+  line — so a `ConditionExpression(attribute_exists)` would be strictly worse+  here (it wouldn't surface the old value), and there is no concurrent writer+  (the CLI skips today, the poller's domain).+- **API has no today path.** For today `day.eInput` is already `max(stored,+  computed)`; the iOS fallback's error is bounded at no-worse-than-current. Peak+  is intentionally absent on today's row (Decision 4).++### Architecture Impact++- One more optional field and one more sentinel on `DailyEnergyItem`; readers+  must treat each as independently absent. The two-sentinel pattern generalises+  to future per-field derived stats with independent lifecycles.+- The renamed CLI now reads two tables (`flux-offpeak`, `flux-daily-energy`) and+  the readings table, and writes two tables. Off-peak recompute is byte-for-byte+  unchanged; peak runs as an independent per-date step.+- Storage-layer naming divergence is accepted (Decision 6): off-peak stores+  `OffpeakItem.GridUsageKwh`, peak stores `DailyEnergyItem.PeakGridImportKwh`;+  both surface as `*GridImportKwh` at the API boundary.++### Potential Issues / To Monitor++- **Rolling discontinuity** in History at deploy-time-minus-30-days until the+  backfill CLI is run: recent days use the integrated field, older days the iOS+  residual. Accepted (Decision 4); never worse than current production.+- **`peakGridImportKwh + offpeakGridImportKwh ≠ eInput` exactly** — they differ+  by ~1.5% of eInput (the shared sampling artifact). The 3% tolerance bound is+  asserted in tests; no UI shows all three side-by-side, so this is operator-+  visible only.+- **Day Detail / DayCosts unchanged**: `DayCosts` and `ComparisonSnapshot` still+  compute peak as their own residual and `DaySummary` (Swift) does not decode the+  new field. This is within the spec's iOS scope (History only). If a future+  Day-Detail peak/cost view wants the accurate figure, add the field to the Swift+  `DaySummary` and route `DayCosts` through it — worth a follow-up ticket.+- **Redundant channel work**: `IntegratePeakGridImportKwh` computes all five+  energy channels twice and uses only grid import. Bounded daily volume makes it+  negligible; it is the first target if this ever moves off the per-day cadence.++---++## Completeness Assessment++### Fully implemented (code + tests)++| Smolspec requirement | Implementation | Tests |+|---|---|---|+| MUST: peak = Σ `max(Pgrid,0)` over the two bracketing windows | `IntegratePeakGridImportKwh` (`integrate_offpeak.go`) | `integrate_peak_test.go` — both windows summed, per-sample clamp |+| MUST: peak + offpeak within 3% of `eInput` | same numerical method both windows | `integrate_peak_test.go` — `InDelta(dayKwh, total, dayKwh*0.03)` |+| MUST: omit field from row & JSON when the gate fails for either sub-window | nil `PeakGridImportKwh` + `omitempty` (dynamo + JSON tags) | store round-trip + API `NotContains` test; pass gate-failure test |+| MUST: expose at JSON key `peakGridImportKwh` on `/day` and `/history` | `response.go`, `day.go`, `history.go` | `peak_grid_import_test.go` — present + absent on both endpoints |+| MUST: forward-fill via hourly pass on independent sentinel + one-shot CLI backfill | `dailysummary.go` two-block pass; `cmd/backfill-grid` | `dailysummary_peak_test.go`; `cmd/backfill-grid/main_test.go` |+| SHOULD: iOS prefers server value, residual fallback | `HistoryDerivedState.gridEntry` | `HistoryViewModelOverviewTests`; `DayEnergyDecodingTests`; `CachedDayEnergyTests` |++### Intentionally not implemented (in scope decisions)++- **MAY: per-write peak drift log** — not implemented; MAY-level. The CLI still+  emits a per-day peak summary line, partially covering the intent.+- **Peak solar / export / battery channels** — excluded (Decision 5).+- **Real-time today peak path in the API** — excluded (Decision 4); iOS fallback.+- **Cross-TTL persistence for pre-30-day rows** — excluded (Decision 4).+- **`DayCosts` / Day-Detail `DaySummary` consuming the new field** — outside the+  spec's iOS scope (History only); flagged above as a future follow-up.++### Verification status++- Go: `make check` (fmt, vet, lint, full test suite) passes.+- iOS/macOS: `make ios-test` / `make macos-test` pass; SwiftLint clean on touched+  files (pre-existing baseline violations elsewhere unchanged).++### Operational follow-up (not code)++After deploying the new Lambda + poller image, run `cmd/backfill-grid` once+(with `--table-daily-energy`) to populate peak on the existing 30-day history.+Until then those days display the iOS residual fallback.

Things to double-check

Run the backfill CLI after deploy.

The hourly pass only forward-fills from deploy onward. After the new Lambda + poller image are live, run cmd/backfill-grid once with --table-daily-energy to populate peak on the existing 30-day history; until then those days show the iOS residual fallback.

iOS edits initially landed in the wrong worktree.

The iOS subagent reported it first edited the main worktree, then moved the change to this worktree and reverted main. Verified: the iOS commit is on this branch and the working tree is clean — but worth a glance at git status in the main checkout before pushing.