Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/forget-rejected-hold.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions go/internal/state/history_source_bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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<?`, MissingEV, base+21*minute, base+50*minute).Scan(&phantomWh, &unknownMinutes); err != nil {
t.Fatal(err)
}
if phantomWh != 0 || unknownMinutes != 29 {
t.Fatalf("rejected charge revived: %v Wh charged, %d of 29 minutes unknown", phantomWh, unknownMinutes)
}
}

// Training reads only measured minutes, so every minute the display holds
// or bridges keeps its marker, also after retention merges minutes.
func TestSourceLoadMarksEveryEstimatedMinute(t *testing.T) {
Expand Down
15 changes: 11 additions & 4 deletions go/internal/state/history_sources.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,14 @@ func (s *Store) recordSourceEnergyTx(ctx context.Context, tx *sql.Tx, p *History
last.Start = at
}
default:
if err := holdSourceIntervalTx(ctx, tx, last, cur); err != nil {
rejected, err := holdSourceIntervalTx(ctx, tx, last, cur)
if err != nil {
return err
}
// A rejected value stays unknown until its source reports again.
if last.Held != nil {
last.Held.Missing |= rejected
}
}
if last.Held == nil {
last.Held = &HistorySources{Missing: MissingPower &^ MissingLoad}
Expand Down Expand Up @@ -153,9 +158,10 @@ func (s *Store) recordSourceEnergyTx(ctx context.Context, tx *sql.Tx, p *History
// for at most the bridge budget. Only two fully measured snapshots a safe
// tick apart, of one topology and one process, make a measured interval;
// every other interval carries EstimatedLoad, which readers never clear.
func holdSourceIntervalTx(ctx context.Context, tx *sql.Tx, last sourceCursor, cur sourceReceipt) error {
// It returns the held sources the balance rejected.
func holdSourceIntervalTx(ctx context.Context, tx *sql.Tx, last sourceCursor, cur sourceReceipt) (HistoryMissing, error) {
v := last.Sources
var missing, held HistoryMissing
var missing, held, rejected HistoryMissing
flows := sourceFlows(&v)
for flag, w := range flows {
switch {
Expand All @@ -170,6 +176,7 @@ func holdSourceIntervalTx(ctx context.Context, tx *sql.Tx, last sourceCursor, cu
// A household never feeds the site. If a held value says it does, the
// source changed while unknown, such as a charger that finished.
if held != 0 && v.GridW-v.PVW-v.BatteryW-v.EVW-v.V2XW < 0 {
rejected = held
for flag, w := range flows {
if held&flag != 0 {
missing, *w = missing|flag, 0
Expand All @@ -183,7 +190,7 @@ func holdSourceIntervalTx(ctx context.Context, tx *sql.Tx, last sourceCursor, cu
if (v.Missing|cur.Missing)&(MissingPower|EstimatedLoad) != 0 || cur.Stopped || cur.AtMS-v.AtMS > 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 {
Expand Down
Loading