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/surplus-late-readback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Surplus charging now holds a steady level when the charger reports its draw late, as Easee Cloud does. A raise on its way already uses the room it was sent for, so Core waits for a reading to show it before raising again. A charger told to stop no longer counts its old draw as spare solar. Before, the car stepped up past the surplus, landed in import and paused about every minute and a half.
2 changes: 1 addition & 1 deletion go/internal/control/sitesim_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ func simScenarios() []simScenario {
plant: surplus, lp: surplusPhase("")},
{name: "phase mode auto, 9 kW surplus", run: 5 * time.Minute, surplus: true,
plant: surplus, lp: surplusPhase("auto")},
{name: "9 kW surplus, readback 10 s late", known: "surplus adds back the charger's late readback, surplus_reserve.go:128", run: 5 * time.Minute, surplus: true,
{name: "9 kW surplus, readback 10 s late", run: 5 * time.Minute, surplus: true,
plant: func(p *sitesim.Plant) { surplus(p); p.EVSE.ReadbackLag = 10 * time.Second }, lp: surplusPhase("3p")},
{name: "surplus_unlock_bat_soc saved, Charge now", run: 5 * time.Minute,
plant: func(p *sitesim.Plant) {
Expand Down
26 changes: 1 addition & 25 deletions go/internal/control/testdata/sitesim_field_known.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,4 @@
# and when a row fails that is not listed. FTW_FIELD_KNOWN=write rewrites
# this file from a run, keeps each label and marks unlisted failures "new".
#
# B srcfl/ftw#1799: planned charging and Charge now run on three
# phases; the planner gets steps a three-phase order can deliver.
# #1795 srcfl/ftw#1795: a charger drawing against its order reads not
# following.
# readback no fix yet: surplus raises again before the charger applied the
# last raise, then pauses when both land (surplus_reserve.go:128).
probe/25A/auto/mixed/easee40s/car3p/balanced/surplus+goal+export2500W idle readback
timing/16A/3p/3p/cloud60s/car3p/L1/surplus idle readback
timing/16A/3p/3p/easee120s/car3p/L1/surplus idle readback
timing/16A/3p/3p/easee40s/car3p/L1/surplus idle readback
timing/16A/auto/mixed/cloud60s/car3p/L1/surplus idle readback
timing/16A/auto/mixed/easee120s/car3p/L1/surplus idle readback
timing/16A/auto/mixed/easee40s/car3p/L1/surplus idle readback
timing/16A/empty/none/cloud60s/car3p/L1/surplus idle readback
timing/16A/empty/none/easee120s/car3p/L1/surplus idle readback
timing/16A/empty/none/easee40s/car3p/L1/surplus idle readback
timing/25A/3p/3p/cloud60s/car3p/L1/surplus idle readback
timing/25A/3p/3p/easee120s/car3p/L1/surplus idle readback
timing/25A/3p/3p/easee40s/car3p/L1/surplus idle readback
timing/25A/auto/mixed/cloud60s/car3p/L1/surplus idle readback
timing/25A/auto/mixed/easee120s/car3p/L1/surplus idle readback
timing/25A/auto/mixed/easee40s/car3p/L1/surplus idle readback
timing/25A/empty/none/cloud60s/car3p/L1/surplus idle readback
timing/25A/empty/none/easee120s/car3p/L1/surplus idle readback
timing/25A/empty/none/easee40s/car3p/L1/surplus idle readback
# No row fails: every case keeps the invariants.
71 changes: 69 additions & 2 deletions go/internal/loadpoint/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ type Controller struct {
// transient surplus spike can't ratchet the EV up a step it can't hold
// (which would whipsaw the home battery's PI). See computeSurplusCmd.
surplusStepW map[string]float64
// surplusOrders is each loadpoint's latest order as surplus reads it.
surplusOrders map[string]surplusOrder

// vehicleStatus is the bound vehicle driver and its charging state.
// nil disables auto-wake.
Expand Down Expand Up @@ -1661,6 +1663,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s
if w, ok := cmd["power_w"].(float64); ok {
offerW, haveOffer = w, true
c.manager.setCommandedForManual(lpCfg.ID, w, cmdReason, manualCommandUpdatedAt)
c.noteSurplusOrder(lpCfg.ID, now, w)
mode, _ := cmd["phase_mode"].(string)
split, _ := cmd["phase_split_w"].(float64)
c.manager.setCommandedPhases(lpCfg.ID, c.commandPhases(mode, w, split))
Expand Down Expand Up @@ -1962,8 +1965,10 @@ func (c *Controller) computeSurplusCmd(now time.Time, lpCfg Config, wantW float6
// `−pvW − loadW`, i.e. PV-magnitude minus house load — invariant
// under whatever the home battery is currently doing with the
// surplus. The EV's own draw is part of that closure already, so
// we don't add it here.
instant := surplusW
// we don't add it here. The chargers' readings lag their draw, so the
// closure's EV draw is replaced by what our orders let the meter show.
adjW, raisingW := c.surplusOrderAdjustW(now, lpCfg.ID)
instant := surplusW + adjW
Comment on lines +1970 to +1971

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the order adjustment before flooring surplus at zero

When solar disappears after a restart but before the charger's readback catches up, this adjustment can hide grid import entirely. The production reader in go/cmd/ftw/main.go calls SurplusAvailableForEVW, which already floors negative surplus to zero (go/internal/loadpoint/surplus_reserve.go:127–135). For example, with a pending 6,900 W order, a late 0 W EV reading, no battery flow and 6,900 W grid import, the reader returns zero and this adjustment turns it back into 6,900 W of apparent surplus. Surplus-only charging therefore continues importing until readback catches up or the settle timeout expires, instead of reducing immediately. Apply the adjustment to the signed surplus before its zero floor so the import remains visible.

Useful? React with 👍 / 👎.

if instant < 0 {
instant = 0
}
Expand Down Expand Up @@ -2036,10 +2041,72 @@ func (c *Controller) computeSurplusCmd(now time.Time, lpCfg Config, wantW float6
}
}
}
// A raise on its way already claims its room: no further raise until a
// reading shows it.
if raisingW > 0 && snapped > raisingW {
snapped = math.Max(prev, raisingW)
}
c.setSurplusStepW(lpCfg.ID, snapped)
return snapped
}

// surplusRaiseSettle bounds how long a raise counts as on its way when no
// reading shows it: the slowest charger we know, Easee Cloud's 180 s.
const surplusRaiseSettle = 3 * time.Minute

// surplusOrder is a loadpoint's latest order and what its readings have
// shown of it since. A reading lags the draw, so one past halfway from the
// order before to this one shows it only after one short of halfway.
type surplusOrder struct {
w, prevW float64
at time.Time
below, shown bool
}

func (c *Controller) noteSurplusOrder(id string, now time.Time, w float64) {
if c.surplusOrders == nil {
c.surplusOrders = map[string]surplusOrder{}
}
if o, ok := c.surplusOrders[id]; !ok || o.w != w {
c.surplusOrders[id] = surplusOrder{w: w, prevW: o.w, at: now}
}
}

// surplusOrderAdjustW is what surplus adds to the closure's EV readings so
// it counts each charger at what the meter may show of our orders, and the
// raise this loadpoint has on its way (0 for none). A raise counts at its
// order until a reading shows it: the meter shows its room as export until
// it lands, then the draw while the reading still lags, so only import
// lowers it. A charger ordered off counts at 0: its reading still shows the
// draw after the stop has landed. A smaller cut keeps the reading, which
// shows the draw the meter still has until the cut lands.
func (c *Controller) surplusOrderAdjustW(now time.Time, id string) (adjW, raisingW float64) {
for _, st := range c.manager.States() {
o, ok := c.surplusOrders[st.ID]
if !st.PluggedIn || !ok || !finite(st.CurrentPowerW) {
continue
}
reading, order := math.Max(0, st.CurrentPowerW), math.Max(0, o.w)
if reading < (math.Max(0, o.prevW)+order)/2 {
o.below = true
} else if o.below {
o.shown = true
}
c.surplusOrders[st.ID] = o
if order > o.prevW && !o.shown && now.Sub(o.at) < surplusRaiseSettle {
adjW += order - reading
if st.ID == id {
raisingW = order
}
continue
}
if order == 0 {
adjW -= reading // A charger ordered off is leaving.
}
}
return adjW, raisingW
}

// pickSurplusSteps returns the step set surplus_only should snap to
// for this loadpoint. Default is 3Φ-eligible only: three phases from 6 A
// each. Below that minimum surplus may use all allowed steps, so one
Expand Down
49 changes: 49 additions & 0 deletions go/internal/loadpoint/controller_surplus_readback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package loadpoint

import (
"testing"
"time"
)

// A charger reports its draw late: Easee Cloud about 10 s after the draw.
// Surplus counts each charger at what the meter may show of our orders, not
// at its late reading. A raise on its way already claims its room; a
// charger ordered off is leaving; a smaller cut keeps the reading until it
// lands.
func TestSurplusCountsOurOrdersNotALateReading(t *testing.T) {
start := time.Date(2026, 10, 21, 22, 15, 0, 0, time.UTC)
for _, tc := range []struct {
name string
orders []float64 // each sent 2 s apart; the last is the latest
readings []float64 // each read 1 s after the latest order
wantAdj, wantRaise float64
}{
{"ordered off, the reading still shows the draw", []float64{6210, 0}, []float64{6210}, -6210, 0},
{"a raise on its way", []float64{4830, 6900}, []float64{4830}, 2070, 6900},
{"a raise the reading has shown", []float64{4830, 6900}, []float64{4830, 6900}, 0, 0},
{"one step up from 9 A, on its way", []float64{6210, 6900}, []float64{6210}, 690, 6900},
{"one step up from 9 A, shown", []float64{6210, 6900}, []float64{6210, 6900}, 0, 0},
{"a raise after a cut that has not landed", []float64{8970, 4830, 6210}, []float64{8970}, -2760, 6210},
{"a restart under the draw read before the stop", []float64{6210, 0, 4830}, []float64{6210}, -1380, 4830},
{"a cut on its way keeps the reading", []float64{8970, 3450}, []float64{8970}, 0, 0},
} {
t.Run(tc.name, func(t *testing.T) {
c := newTestController(t, []Config{{ID: "garage", DriverName: "easee", SurplusOnly: true, MinChargeW: 4140, MaxChargeW: 11040}}, nil, nil, &fakeSender{})
now := start
for _, w := range tc.orders {
now = now.Add(2 * time.Second)
c.manager.SetCommanded("garage", w, "pv_surplus")
c.noteSurplusOrder("garage", now, w)
}
var adj, raise float64
for _, r := range tc.readings {
now = now.Add(time.Second)
c.manager.Observe("garage", true, r, 0, true)
adj, raise = c.surplusOrderAdjustW(now, "garage")
}
if adj != tc.wantAdj || raise != tc.wantRaise {
t.Fatalf("adjust %.0f W, raise on its way %.0f W; want %.0f and %.0f", adj, raise, tc.wantAdj, tc.wantRaise)
}
})
}
}
Loading