Skip to content

Commit ed66473

Browse files
frahlgclaude
andauthored
fix(control): one log of FTW's own EV offers (#1881)
* fix(control): one log of FTW's own EV offers The battery side read a 20 s pool log of EV offers (State.evOffers), while the EV fuse limit keeps each charger's offers for its declared response time, or 180 s when it declares none. After 20 s the battery counted a slow charger's unshown raise as already in the meter and charged into that room; when the car started, a 25 A site read 32.6 A per phase for 3 s. Delete State.evOffers and its two writers. evInMeterW and clampEVBatteryHeadroom read evShownW, each charger's latest offer less what it may not show yet. The hourly peak in stepEVMeterLimit takes each charger's highest offer of the last evSettle from the same log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(control): battery waits only for a declared charger response time The first A6 change gave the battery side the EV limit's 180 s guess for every charger that declares no response time. Only Easee Cloud declares one, so on every other charger the battery reserved the car's whole demand on top of a reading that already showed the car, and stopped charging for 3 minutes after each car start or raise. On the #1868 shape (rig s18) battery charge stayed at 0 W until 180 s. evShownW now uses the charger's declared power_max_age_s, and evSettle when it declares none, as master did. Easee Cloud declares 180 s, so the slow-charger case stays fixed. Tests: the slow-charger test now declares 180 s, and a new plant test runs the s18 shape with a fast charger that declares nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a217d77 commit ed66473

9 files changed

Lines changed: 145 additions & 26 deletions

‎.changeset/a6-one-ev-offer-log.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ftw": patch
3+
---
4+
5+
Keep home battery charging out of the room a slow charger's raise still needs. When a charger's driver declares its response time, as Easee Cloud declares 180 s, the battery now counts a car's offer as not yet in the meter for that time, as the car's fuse limit already did, instead of 20 s. A charger that takes two minutes to start no longer pushes a 25 A phase to 32.6 A. A charger that declares no response time keeps the 20 s, so the battery and a car on a local charger still charge together within a minute of the car's start.

‎go/internal/control/dispatch.go‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -395,9 +395,8 @@ type State struct {
395395
evLimit evMeterLimit
396396
// fuseHeat is the main fuse's heat per phase, from site readings only.
397397
fuseHeat fuseHeat
398-
// evOffers and batteryCommands are our own recent commands, the only
399-
// device input the EV hard limit and the EV/battery split use.
400-
evOffers commandLog
398+
// batteryCommands and evLimit.offers are our own recent commands, the
399+
// only device input the EV hard limit and the EV/battery split use.
401400
batteryCommands map[string]commandLog
402401

403402
// PI controller (outer, site-level)
@@ -2505,7 +2504,6 @@ func recordDispatchTargets(targets []DispatchTarget, state *State, updatePrevTar
25052504
for _, t := range targets {
25062505
state.batteryCommands[t.Driver] = state.batteryCommands[t.Driver].add(now, t.TargetW)
25072506
}
2508-
state.evOffers = state.evOffers.add(now, math.Max(0, state.EVCommandedW))
25092507
}
25102508
if updatePrevTargets {
25112509
for _, t := range targets {
@@ -4708,12 +4706,8 @@ func forceFuseDischargeWithSnapshot(
47084706
}
47094707

47104708
// evInMeterW is EV load the meter is sure to contain: the operator's declared
4711-
// load plus the lowest managed offer of the last evSettle. This tick's new
4712-
// offer is not in it yet. Charger readings take no part in the fuse share.
4709+
// load plus the managed offers already shown (evShownW). Charger readings
4710+
// take no part in the fuse share.
47134711
func (s *State) evInMeterW() float64 {
4714-
low, _, _, ok := s.evOffers.span(s.now())
4715-
if !ok {
4716-
low = 0
4717-
}
4718-
return s.ManualEVChargingW + math.Max(0, low)
4712+
return s.ManualEVChargingW + s.evShownW(s.now())
47194713
}

‎go/internal/control/ev_battery_headroom.go‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,13 @@ import (
1010
// still needs. It reads only the site meter and our own commands; no charger
1111
// reading takes part, and a battery's own reading only bounds how much of a
1212
// discharge we ordered it may keep. The EV still needs its demand less the
13-
// lowest offer of the last evSettle, which the meter surely shows.
13+
// offers the meter surely shows (evShownW).
1414
func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, state *State, fuseMaxW float64) []DispatchTarget {
1515
if store == nil || state == nil || len(targets) == 0 || fuseMaxW <= 0 {
1616
return targets
1717
}
1818
now := state.now()
19-
// This tick's new offer is not in the meter yet; the lowest logged one is.
20-
offeredW, _, _, ok := state.evOffers.span(now)
21-
if !ok {
22-
offeredW = 0
23-
}
19+
offeredW := state.evShownW(now)
2420
var demandW float64
2521
phases := 3.0 // No phase placement promise for 1p or auto chargers.
2622
for _, demand := range state.evFuseDemands {

‎go/internal/control/ev_battery_headroom_test.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ func sent(state *State, driver string, watts ...float64) {
4747
log = log.add(at, w)
4848
}
4949
if driver == "ev" {
50-
state.evOffers, state.EVCommandedW = log, watts[len(watts)-1]
50+
state.evLimit.offers = map[string]chargerOffers{"car": {log: log, settle: evSettle}}
51+
state.EVCommandedW = watts[len(watts)-1]
5152
return
5253
}
5354
state.batteryCommands[driver] = log

‎go/internal/control/ev_battery_priority_test.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ func issue1868Site(t *testing.T) (*telemetry.Store, *State, []loadpoint.State, [
2222
tel.Update("battery", telemetry.DerBattery, 4000, &soc, nil)
2323
tel.RecordDriverSuccess("battery")
2424
sent(st, "ev", 4140)
25+
// The car's 4140 W offer has run for longer than any charger's response time.
26+
a, n := st.offerPhaseA(4140, 3)
27+
st.evLimit.offers = map[string]chargerOffers{"rav4": {log: commandLog{{at: st.now().Add(-evUnknownSettle - time.Second), w: 4140, a: a, n: n}}}}
2528
sent(st, "battery", 4000)
2629
points := []loadpoint.State{{ID: "rav4", DriverName: "car", PluggedIn: true, CommandedKnown: true, CommandedW: 4140, CommandedPhases: 3}}
2730
configs := []loadpoint.Config{{ID: "rav4", DriverName: "car", PhaseMode: "3p", MinChargeW: 4140, MaxChargeW: 4140, AllowedStepsW: []float64{0, 4140}}}

‎go/internal/control/ev_fuse_budget.go‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,9 @@ func (l commandLog) toCome(from, at time.Time, before sentCommand) (w, a float64
170170
type chargerOffers struct {
171171
log commandLog
172172
settle time.Duration
173+
// declared is true when the driver declares that time; settle is then
174+
// not evUnknownSettle's guess (evShownW).
175+
declared bool
173176
// found is the offer in force before the limit's first site reading,
174177
// after a restart: Core did not send it in this run. A rise counts the
175178
// charger as 0 before it; a cut takes it as shown, since the reading is
@@ -187,6 +190,30 @@ type pendingLoad struct {
187190
phaseA [3]float64 // on each phase
188191
}
189192

193+
// evShownW is the EV load a reading taken at `at` surely shows: each
194+
// plugged charger's latest offer less what it may not show yet. The battery
195+
// side reads this. A raise stays unshown for the response time the
196+
// charger's driver declares, so the battery waits for a slow charger's raise
197+
// as long as the EV limit does. A charger that declares none gets evSettle,
198+
// not evUnknownSettle: the battery would otherwise reserve a local charger's
199+
// raise on top of the reading that already shows it, and stop charging for
200+
// three minutes after each one. Easee Cloud, the slow charger we know,
201+
// declares its 180 s.
202+
func (s *State) evShownW(at time.Time) float64 {
203+
var w float64
204+
for _, c := range s.evLimit.offers {
205+
if n := len(c.log); n > 0 {
206+
settle := evSettle
207+
if c.declared {
208+
settle = c.settle
209+
}
210+
unshown, _ := c.log.unshown(at, settle)
211+
w += math.Max(0, c.log[n-1].w-unshown)
212+
}
213+
}
214+
return w
215+
}
216+
190217
func (s *State) pendingLoad(at time.Time) pendingLoad {
191218
var p pendingLoad
192219
for _, c := range s.evLimit.offers {
@@ -258,10 +285,10 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State)
258285
continue
259286
}
260287
c := s.evLimit.offers[point.ID]
261-
c.settle = evUnknownSettle
288+
c.settle, c.declared = evUnknownSettle, false
262289
if r := store.Get(point.DriverName, telemetry.DerEV); r != nil {
263290
if declared := telemetry.DeclaredPowerMaxAge(r.Data); declared > 0 {
264-
c.settle = max(evSettle, declared)
291+
c.settle, c.declared = max(evSettle, declared), true
265292
}
266293
}
267294
w := math.Max(0, point.CommandedW)
@@ -368,9 +395,6 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State
368395
*lim = evMeterLimit{}
369396
return 0, true
370397
}
371-
if pool.offerKnown {
372-
state.evOffers = state.evOffers.add(state.now(), pool.offerW)
373-
}
374398
state.recordEVOffers(store, points)
375399
if !lim.set || meter.UpdatedAt.After(lim.readingAt) {
376400
state.stepEVMeterLimit(store, meter, gridW, pool, fuseMaxW)
@@ -389,9 +413,13 @@ func (s *State) stepEVMeterLimit(store *telemetry.Store, meter *telemetry.DerRea
389413
}
390414
lim.set = true
391415
peakW := pool.offerW
392-
if _, high, _, ok := s.evOffers.span(now); ok {
393-
peakW = math.Max(peakW, high)
416+
var logPeakW float64
417+
for _, c := range s.evLimit.offers {
418+
if _, high, _, ok := c.log.span(now); ok {
419+
logPeakW += high
420+
}
394421
}
422+
peakW = math.Max(peakW, logPeakW)
395423
// Our own battery orders the meter may not show yet count beside the
396424
// chargers' offers, in total import and on each phase.
397425
pending := s.pendingBatteryCommandsW(now)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package control
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
"time"
7+
8+
"github.com/srcfl/ftw/go/internal/loadpoint"
9+
"github.com/srcfl/ftw/go/internal/sitesim"
10+
)
11+
12+
// A charger that declares a 180 s response time, as Easee Cloud does, and
13+
// takes delay to start, beside a planned 9.4 kW battery charge on a 25 A
14+
// site with 3 A of house load per phase. The car's 11 kW offer stays
15+
// unshown until the charger applies it, and the battery must not take that
16+
// room: when the car starts, no phase may pass the instant limit, and the
17+
// car must not sit idle with room.
18+
func TestSlowChargerUnshownOfferKeepsBatteryOut(t *testing.T) {
19+
for _, delay := range []time.Duration{5 * time.Second, 30 * time.Second, 40 * time.Second, 2 * time.Minute} {
20+
sc := simScenario{name: "slow charger", run: 15 * time.Minute,
21+
plant: func(p *sitesim.Plant) {
22+
p.HouseW, p.EVSE.Delay = houseA(3, 3, 3), delay
23+
p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 9400, MaxDischargeW: 9400, CapacityWh: 20000, SoC: .3, Latency: 3 * time.Second}
24+
},
25+
setup: func(s *simSite) {
26+
s.evMaxAge = evUnknownSettle
27+
planBatteryCharge(9400)(s)
28+
}}
29+
s := runSimScenario(sc, simStart)
30+
t.Logf("delay %v: peak phase %.1f A, %v over %.0f A, idle with room %v, car %.0f W, battery %.0f W",
31+
delay, s.peakA, s.overLimit, s.p.Fuse.RatingA-evPhaseMarginA, s.idleWithRoom, s.p.EVSE.DrawW, s.p.Battery.W)
32+
if s.failure != "" {
33+
t.Errorf("delay %v: %s", delay, s.failure)
34+
}
35+
if limitA := fuseInstantFactor * s.p.Fuse.RatingA; s.peakA > limitA {
36+
t.Errorf("delay %v: peak phase %.1f A above the instant limit %.1f A", delay, s.peakA, limitA)
37+
}
38+
}
39+
}
40+
41+
// #1868's rig shape (s18): a 16 A site with 3 A of house load, a local
42+
// charger that declares no response time and draws within 2 s, a car that
43+
// takes only [0, 4140] W, and a 6000 W battery charge. Once the car draws,
44+
// the reading shows it, and the battery shares the rest of the fuse well
45+
// before evUnknownSettle; s18's judge needs it above 1000 W 100-120 s after
46+
// Charge now.
47+
func TestFastChargerYoungOfferLetsBatteryShare(t *testing.T) {
48+
for _, tc := range []struct {
49+
name string
50+
setup func(*simSite)
51+
}{
52+
{"manual hold", func(s *simSite) {
53+
s.state.SetBatteryManualHold(BatteryManualHold{PowerW: 6000, ExpiresAt: s.p.Now.Add(time.Hour)})
54+
}},
55+
{"plan", planBatteryCharge(6000)},
56+
} {
57+
t.Run(tc.name, func(t *testing.T) {
58+
var expect []simExpect
59+
for _, at := range []time.Duration{60 * time.Second, 100 * time.Second, 120 * time.Second, 170 * time.Second} {
60+
expect = append(expect, simExpect{at, func(s *simSite) error {
61+
if s.p.EVSE.DrawW < 4100 || s.p.Battery.W < 3000 {
62+
return fmt.Errorf("car %.0f W and battery %.0f W at %v; both should charge", s.p.EVSE.DrawW, s.p.Battery.W, at)
63+
}
64+
return nil
65+
}})
66+
}
67+
sc := simScenario{name: "s18 " + tc.name, run: 4 * time.Minute,
68+
plant: func(p *sitesim.Plant) {
69+
p.Fuse.RatingA, p.HouseW, p.EVSE.Delay, p.EVSE.ReadbackLag = 16, houseA(3, 3, 3), 2*time.Second, 0
70+
p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 6000, MaxDischargeW: 6000, CapacityWh: 20000, SoC: .4, Latency: 2 * time.Second}
71+
},
72+
lp: func(c *loadpoint.Config) {
73+
c.MinChargeW, c.MaxChargeW, c.AllowedStepsW = 4140, 4140, []float64{0, 4140}
74+
},
75+
setup: tc.setup,
76+
expect: expect}
77+
s := runSimScenario(sc, simStart)
78+
if s.failure != "" {
79+
t.Error(s.failure)
80+
}
81+
if limitA := fuseInstantFactor * s.p.Fuse.RatingA; s.peakA > limitA {
82+
t.Errorf("peak phase %.1f A above the instant limit %.1f A", s.peakA, limitA)
83+
}
84+
})
85+
}
86+
}

‎go/internal/control/sitesim_field_test.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,14 +460,17 @@ func (s *simSite) coreEvery() time.Duration {
460460

461461
func (s *simSite) declaredMaxAge() time.Duration {
462462
if s.field == nil {
463-
return 0
463+
return s.evMaxAge
464464
}
465465
return s.field.c.timing.declared
466466
}
467467

468468
// evData is what a charger driver adds to its reading.
469469
func (s *simSite) evData() map[string]any {
470470
if s.field == nil {
471+
if s.evMaxAge > 0 {
472+
return map[string]any{"power_max_age_s": s.evMaxAge.Seconds()}
473+
}
471474
return nil
472475
}
473476
d := map[string]any{"connected": s.ev.Plugged, "control_power_confirmed": true}

‎go/internal/control/sitesim_test.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ type simSite struct {
3939

4040
every time.Duration // control_interval_s; zero: simCoreEvery
4141
field *fieldRun // the field matrix's own checks; nil outside it
42+
// evMaxAge is the power_max_age_s the charger driver declares outside
43+
// the field matrix; zero declares none.
44+
evMaxAge time.Duration
4245

4346
downUntil, nextTick time.Time
4447
meter sitesim.MeterReading

0 commit comments

Comments
 (0)