diff --git a/.changeset/forget-rejected-hold.md b/.changeset/forget-rejected-hold.md new file mode 100644 index 00000000..5906ec48 --- /dev/null +++ b/.changeset/forget-rejected-hold.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep a rejected held value unknown until its source reports again. A charger that finished while silent no longer comes back as a phantom charge when the house later uses more power. diff --git a/go/internal/state/history_source_bridge_test.go b/go/internal/state/history_source_bridge_test.go index 8f79b324..645bbd65 100644 --- a/go/internal/state/history_source_bridge_test.go +++ b/go/internal/state/history_source_bridge_test.go @@ -109,6 +109,41 @@ func TestSourceLoadDayForgivesShortGaps(t *testing.T) { } } +// A held value the balance rejects stays rejected until its source reports +// again. A later rise in house use must not revive it as a phantom charge. +func TestSourceLoadForgetsARejectedHold(t *testing.T) { + const minute = int64(60000) + s := sourceStore(t) + base := time.Now().UTC().Truncate(24*time.Hour).UnixMilli() - 24*3600000 + for at := base; at <= base+60*minute; at += 5000 { + off := at - base + // A 1 kW house and a 2 kW charge. The charger falls silent at 10 + // minutes, the car finishes at 20, a heater adds 3 kW at 30, and the + // charger reports again, at 0 W, after 50. + v := HistorySources{GridW: 3000, EVW: 2000} + switch { + case off >= 30*minute: + v.GridW, v.EVW = 4000, 0 + case off >= 20*minute: + v.GridW, v.EVW = 1000, 0 + } + if off > 10*minute && off <= 50*minute { + v.Missing, v.EVW = MissingEV, 0 + } + sourceTick(t, s, at, v) + } + var phantomWh float64 + var unknownMinutes int + if err := s.history.QueryRow(`SELECT COALESCE(SUM(e.ev_charge_wh),0),COALESCE(SUM(COALESCE(q.missing,0)&?<>0),0) FROM history_source_energy e + LEFT JOIN history_quality q ON q.kind='source_energy' AND q.start_ms=e.start_ms AND q.resolution_ms=e.resolution_ms + WHERE e.start_ms>=? AND e.start_ms maxLoadEstimateTickMS || v.Topology != cur.Topology { missing |= EstimatedLoad } - return writeSourceIntervalTx(ctx, tx, v.AtMS, cur.AtMS, v, missing) + return rejected, writeSourceIntervalTx(ctx, tx, v.AtMS, cur.AtMS, v, missing) } func writeSourceIntervalTx(ctx context.Context, tx *sql.Tx, from, until int64, v HistorySources, missing HistoryMissing) error {