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/ev-fuse-slow-charger-rises.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

EV charging no longer pushes a phase past the fuse when a charger is slow to apply a new current, when a charge moves between three phases and one, or when one car is raised while another is cut. Each raise now counts as current on the phases its command names until the charger can have applied it: the time its driver declares, or 3 minutes when it declares none. A car takes only room that no other charger's raise holds. After a rise, a charger that declares no response time may wait up to 3 minutes before it climbs further.
5 changes: 5 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2005,6 +2005,11 @@ func main() {
// The cap is measured for the phases this tick's commands name.
return control.EVFuseBudget(tel, ctrl, lpMgr.States(), lpController.FuseConfigs(), ctrl.SiteFuseAmps*ctrl.SiteFuseVoltage*float64(ctrl.SiteFusePhases))
})
lpController.SetFusePhaseRoomA(func() (float64, bool) {
ctrlMu.Lock()
defer ctrlMu.Unlock()
return control.EVFusePhaseRoomA(ctrl)
})
// Every lease is preflighted and re-evaluated on each loadpoint tick.
// This closure owns site/battery health because those concepts belong
// to core, not to the protocol-neutral loadpoint package.
Expand Down
5 changes: 5 additions & 0 deletions go/internal/control/ev_battery_sequence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,15 @@ func TestJoinedEVWaitsForMeasuredBatteryBackoffThenResumes(t *testing.T) {
controller.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3})
controller.SetManualHold(config.ID, loadpoint.ManualHold{PowerW: desiredEVW, PhaseMode: "3p", Persistent: true})
controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(store, st, mgr.States(), controller.FuseConfigs(), 17250) })
controller.SetFusePhaseRoomA(func() (float64, bool) { return EVFusePhaseRoomA(st) })
emit := func(driver string, kind telemetry.DerType, watts float64) {
d := map[string]any{"control_power_available": true, "control_power_observed_at": now.Format(time.RFC3339Nano)}
if kind == telemetry.DerMeter {
d["l1_a"], d["l2_a"], d["l3_a"] = sampledPhaseA, sampledPhaseA, sampledPhaseA
}
if kind == telemetry.DerEV {
d["power_max_age_s"] = 5 // A local charger that answers within seconds.
}
data, _ := json.Marshal(d)
soc := .5
store.Update(driver, kind, watts, &soc, data)
Expand Down Expand Up @@ -218,6 +222,7 @@ func newEVBatterySiteAt(t *testing.T, houseW float64, start time.Time) *evBatter
s.ctrl.SetFuseEVMax(func() (float64, bool) {
return EVFuseBudget(s.store, s.state, s.mgr.States(), s.ctrl.FuseConfigs(), 17250)
})
s.ctrl.SetFusePhaseRoomA(func() (float64, bool) { return EVFusePhaseRoomA(s.state) })
return s
}

Expand Down
2 changes: 2 additions & 0 deletions go/internal/control/ev_deadline_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ func TestJoinedNoBatteryNoPlanGoalContinuity(t *testing.T) {
})
controller.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3})
controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(tel, st, mgr.States(), controller.FuseConfigs(), 17250) })
controller.SetFusePhaseRoomA(func() (float64, bool) { return EVFusePhaseRoomA(st) })
controller.TickWithDispatch(ctx, now, true)
if len(commands) == 0 || commands[len(commands)-1] <= 0 {
t.Fatalf("goal stopped at integration boundary: %v", commands)
Expand Down Expand Up @@ -172,6 +173,7 @@ func TestJoinedMixedPhaseNoCreditBeforeMeasuredReduction(t *testing.T) {
})
controller.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3})
controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(tel, st, mgr.States(), controller.FuseConfigs(), 17250) })
controller.SetFusePhaseRoomA(func() (float64, bool) { return EVFusePhaseRoomA(st) })
emit := func(driver string, kind telemetry.DerType, at time.Time, w float64, amps bool) {
d := map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano), "control_power_available": true}
if amps {
Expand Down
216 changes: 171 additions & 45 deletions go/internal/control/ev_fuse_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ func evHeadroomW(ceiling, gridW, poolW float64) float64 {

func finiteFuseValue(w float64) bool { return !math.IsNaN(w) && !math.IsInf(w, 0) }

// evSettle is how long one of our commands may take to reach the site meter.
// evSettle is how long one of our commands may take to reach the site meter
// when the device declares nothing longer.
const evSettle = loadpoint.CommandSettle

// evUnknownSettle is how long an EV rise counts as flowing when the charger
// declares no response time: the slowest we know, Easee Cloud's 180 s.
const evUnknownSettle = 3 * time.Minute

// evRestartDwell is how long the meter must keep showing room before an idle
// pool starts again. It stays above Easee's ~30 s contactor minimum.
const evRestartDwell = 35 * time.Second
Expand All @@ -36,16 +41,22 @@ const evPhaseMarginA = 1.0
type sentCommand struct {
at time.Time
w float64
a float64 // EV offers: current on each phase it may land on
}

// commandLog keeps a device's commands for evSettle, plus the one in force
// when that window opened. The meter may show any of them.
type commandLog []sentCommand

func (l commandLog) add(now time.Time, w float64) commandLog {
l = append(l, sentCommand{now, w})
return l.keep(sentCommand{at: now, w: w}, now.Add(-evSettle))
}

// keep appends c and drops the commands replaced before from.
func (l commandLog) keep(c sentCommand, from time.Time) commandLog {
l = append(l, c)
i := 0
for i+1 < len(l) && now.Sub(l[i+1].at) >= evSettle {
for i+1 < len(l) && !l[i+1].at.After(from) {
i++
}
if i > 0 {
Expand Down Expand Up @@ -76,20 +87,130 @@ func (l commandLog) span(now time.Time) (low, high, latest float64, ok bool) {
return low, high, latest, ok
}

// unshown is how much more a charger may draw than a reading taken at `at`
// shows, in watts and in current per phase: the highest offer sent since
// settle before the reading, less the lowest offer the reading may show.
// An offer set before that window is in the reading. A cut needs nothing:
// the reading shows the draw until the cut lands. Before our first offer
// the charger counts as 0.
func (l commandLog) unshown(at time.Time, settle time.Duration) (w, a float64) {
from := at.Add(-settle)
var settledW, settledA float64
lowW, lowA, highW, highA := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
for _, c := range l {
if !c.at.After(from) {
settledW, settledA = c.w, c.a
continue
}
highW, highA = math.Max(highW, c.w), math.Max(highA, c.a)
if !c.at.After(at) {
lowW, lowA = math.Min(lowW, c.w), math.Min(lowA, c.a)
}
}
lowW, lowA = math.Min(lowW, settledW), math.Min(lowA, settledA)
return math.Max(0, highW-lowW), math.Max(0, highA-lowA)
}

// chargerOffers is one charger's offers, kept for the time its driver
// declares its power takes to show.
type chargerOffers struct {
log commandLog
settle time.Duration
}

// pendingLoad is load we ordered that a site reading may not show yet:
// each EV offer sent above what the reading may show, until its charger
// can have applied it. A single-phase offer may land on any phase. Rises
// and starts count it on top of the meter; a cut never waits for it.
type pendingLoad struct {
w float64 // in total import
phaseA [3]float64 // on each phase
}

func (s *State) pendingLoad(at time.Time) pendingLoad {
var p pendingLoad
for _, c := range s.evLimit.offers {
w, a := c.log.unshown(at, c.settle)
p.w += w
for i := range p.phaseA {
p.phaseA[i] += a
}
}
return p
}

// recordEVOffers keeps each plugged charger's offers, with the current each
// puts on the phases its command names, for the longer of evSettle and the
// power_max_age_s its driver declares, or evUnknownSettle when it declares
// none. Our own commands only: a charger's power reading never counts.
func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) {
now := s.now()
read := now
if !s.evLimit.readingAt.IsZero() && s.evLimit.readingAt.Before(now) {
read = s.evLimit.readingAt
}
offers := make(map[string]chargerOffers, len(points))
for _, point := range points {
if !point.PluggedIn || !point.CommandedKnown || !finiteFuseValue(point.CommandedW) {
continue
}
c := s.evLimit.offers[point.ID]
c.settle = evUnknownSettle
if r := store.Get(point.DriverName, telemetry.DerEV); r != nil {
if declared := telemetry.DeclaredPowerMaxAge(r.Data); declared > 0 {
c.settle = max(evSettle, declared)
}
}
w := math.Max(0, point.CommandedW)
sent := sentCommand{at: now, w: w, a: s.offerPhaseA(w, point.CommandedPhases)}
if n := len(c.log); n == 0 || c.log[n-1].w != sent.w || c.log[n-1].a != sent.a {
c.log = c.log.keep(sent, read.Add(-c.settle))
}
offers[point.ID] = c
}
s.evLimit.offers = offers
}

// offerPhaseA is the current an offer of w puts on each phase it may land
// on. Unknown phases count as one.
func (s *State) offerPhaseA(w float64, phases int) float64 {
if s.SiteFuseVoltage <= 0 {
return 0
}
n := 1
if phases == 3 && s.SiteFusePhases > 1 {
n = s.SiteFusePhases
}
return w / (s.SiteFuseVoltage * float64(n))
}

// EVFusePhaseRoomA is the current the chargers may still add on the
// busiest phase: below the fuse less its margin, the last reading's
// current or heat, and every charger's offers that reading may not show.
// false when the site meter reports no phase currents.
func EVFusePhaseRoomA(state *State) (float64, bool) {
if state == nil || !state.evLimit.set || !state.evLimit.phaseRoomKnown {
return 0, false
}
p := state.pendingLoad(state.evLimit.readingAt)
return state.evLimit.phaseRoomA - max(p.phaseA[0], p.phaseA[1], p.phaseA[2]), true
}

// evMeterLimit is the managed EVs' common hard cap. It reads only the site
// meter, which measures the protected quantities, and our own commands. No
// charger, car or battery reading takes part in a cut, a rise or a start.
type evMeterLimit struct {
set bool
capW float64
changedAt time.Time // last cut, rise or idle level
readingAt time.Time // newest site reading seen
roomSince time.Time // first reading of the current run with room to start
raised bool
raisedGridW float64 // site import when the last rise was sent
overSince time.Time // first reading of a run over the hour's import limit
overOffers map[string]float64 // each charger's offer when that run began
idleWh float64 // import since roomSince, for the ceiling's start test
set bool
capW float64
cutAt time.Time // last cut
readingAt time.Time // newest site reading seen
roomSince time.Time // first reading of the current run with room to start
offers map[string]chargerOffers // each plugged charger's, by loadpoint
phaseRoomA float64 // below the working limit on the busiest phase at readingAt
phaseRoomKnown bool // the meter reports phase currents
overSince time.Time // first reading of a run over the hour's import limit
overOffers map[string]float64 // each charger's offer when that run began
idleWh float64 // import since roomSince, for the ceiling's start test
}

type evPool struct {
Expand All @@ -103,10 +224,13 @@ type evPool struct {
// shared freshness gate must already allow dispatch. A fuse overage in total
// import cuts at once; a phase overage cuts once that phase's heat budget is
// spent, or at once above fuseInstantFactor. Otherwise the cap holds; it
// rises one step only on a fresh reading with room for two, once the meter
// has shown the last change. An idle pool starts only after the meter has
// kept showing room for its minimum plus a step. Each reading acts once, so
// a silent meter can never raise or restart a charge.
// rises one step on a fresh reading with room for two, at least evSettle
// after the last cut. Room counts every
// offer sent above what the reading may show as flowing until its charger
// can have applied it; cuts read the meter alone. EVFusePhaseRoomA gives
// the same room per phase to the split between cars. An idle pool starts only after the meter has kept showing
// room for its minimum plus a step. Each reading acts once, so a silent
// meter can never raise or restart a charge.
func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State, configs []loadpoint.Config, fuseMaxW float64) (float64, bool) {
if store == nil || state == nil || !finiteFuseValue(fuseMaxW) || fuseMaxW <= 0 {
return 0, true
Expand All @@ -129,6 +253,7 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State
if pool.offerKnown {
state.evOffers = state.evOffers.add(state.now(), pool.offerW)
}
state.recordEVOffers(store, points)
if !lim.set || meter.UpdatedAt.After(lim.readingAt) {
state.stepEVMeterLimit(meter, gridW, pool, fuseMaxW)
}
Expand All @@ -149,7 +274,13 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo
if _, high, _, ok := s.evOffers.span(now); ok {
peakW = math.Max(peakW, high)
}
r := s.evMeterRoom(meter, gridW, pool.phases, fuseMaxW)
r := s.evMeterRoom(meter, gridW, pool.phases, fuseMaxW, s.pendingLoad(at))
_, fresh := qualifiedFuseObservation(meter, now)
fresh = fresh && r.measured
lim.phaseRoomA, lim.phaseRoomKnown = 0, r.phaseRoomKnown
if fresh {
lim.phaseRoomA = r.phaseRoomA
}
step := evPhaseMarginA * s.SiteFuseVoltage * pool.phases
// The fuse cuts on every reading, by its heat budget where the meter
// reports phase currents. The hour's budget absorbs a short overage of
Expand All @@ -171,7 +302,7 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo
// the overage from the highest cuts each measured watt once.
lim.capW, lim.roomSince = math.Min(prev, math.Max(0, peakW+cutW)), time.Time{}
if lim.capW < prev {
lim.changedAt, lim.raised = now, false
lim.cutAt = now
slog.Info("EV hard limit: site meter over limit", "grid_w", gridW, "worst_phase_a", r.worstA,
"fuse_heat_a", s.fuseHeat.worstA(), "import_limit_w", s.effectiveImportCeilingW(fuseMaxW),
"overage_w", -cutW, "offer_w", peakW, "cap_w", lim.capW)
Expand All @@ -182,8 +313,6 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo
// may reach, left after our own battery commands that the meter may not
// show yet. Between that level and the import limit the cap holds.
pending := s.pendingBatteryCommandsW(now)
_, fresh := qualifiedFuseObservation(meter, now)
fresh = fresh && r.measured
room := 0.0
if fresh {
room = math.Max(0, math.Min(r.fuseW, r.riseW)-pending)
Expand All @@ -195,17 +324,10 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo
// Keeping the current offers is never a cut. Headroom above them stays
// within what this reading measured.
lim.capW, lim.roomSince = math.Min(prev, peakW+room), time.Time{}
if first {
lim.changedAt = now
return
}
// The meter shows the last change once import rose with the step, or
// in any reading taken evSettle after it.
seen := at.Sub(lim.changedAt) >= evSettle ||
(lim.raised && at.After(lim.changedAt) && gridW >= lim.raisedGridW+step/2)
if lim.capW < pool.wantW && room >= 2*step && seen {
lim.capW += step
lim.changedAt, lim.raised, lim.raisedGridW = now, true, gridW
// Right after a cut the meter may show battery relief that covers it.
if lim.capW < pool.wantW && room >= 2*step && now.Sub(lim.cutAt) >= evSettle {
// One step at most, and a step of room stays free.
lim.capW = math.Max(lim.capW, math.Min(lim.capW+step, peakW+room-step))
}
}

Expand All @@ -216,7 +338,7 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo
// the minimum needs no step of margin there: a short overage waits.
func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first, gap, fresh bool, at, prevAt time.Time, pending, step float64) {
lim := &s.evLimit
lim.capW, lim.changedAt, lim.raised = 0, s.now(), false
lim.capW = 0
perReadingW := r.fuseW
if !r.hourly {
perReadingW = math.Min(perReadingW, r.riseW)
Expand Down Expand Up @@ -257,28 +379,31 @@ func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first
// evMeterRoom is the measured room in EV watts. The fuse's part holds on
// every reading. The import ceiling's parts are +Inf while it is off.
type evMeterRoom struct {
fuseW float64 // below the fuse less its margin and the worst phase, read or heat; rises and starts
fuseCutW float64 // what the fuse needs cut now, negative; see fuseHeat
limitW float64 // below the hour's import limit; negative is an overage
riseW float64 // below the level an increase may reach
startLevelW float64 // import an idle start may reach: the rise level, at most the ceiling
hourly bool // the hour's import is measured, so a short overage may wait
measured bool // false when the meter reports phase currents that cannot be read
worstA float64
fuseW float64 // below the fuse less its margin and the worst phase, read or heat, and what we sent; rises and starts
fuseCutW float64 // what the fuse needs cut now, negative; see fuseHeat
limitW float64 // below the hour's import limit; negative is an overage
riseW float64 // below the level an increase may reach, less what we sent
startLevelW float64 // import an idle start may reach: the rise level, at most the ceiling
hourly bool // the hour's import is measured, so a short overage may wait
measured bool // false when the meter reports phase currents that cannot be read
worstA float64
phaseRoomA float64 // below the working limit on the busiest phase, read or heat
phaseRoomKnown bool // the meter reports phase currents
}

func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW float64) evMeterRoom {
func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW float64, sent pendingLoad) evMeterRoom {
inf, totalW := math.Inf(1), math.Max(0, fuseMaxW-s.fuseSafetyMarginW())-gridW
r := evMeterRoom{fuseW: totalW, fuseCutW: totalW, limitW: inf, riseW: inf, startLevelW: inf}
r := evMeterRoom{fuseW: totalW - sent.w, fuseCutW: totalW, limitW: inf, riseW: inf, startLevelW: inf}
if limitW, riseW, ok := s.importHourLimitsW(); ok {
r.limitW, r.riseW = limitW-gridW, riseW-gridW
r.limitW, r.riseW = limitW-gridW, riseW-gridW-sent.w
// A car that could run only on one hour's slack would stop when
// the next hour starts at the ceiling.
r.startLevelW = math.Min(riseW, s.PeakImportCeilingW)
r.hourly = s.importHourMeasured()
}
amps, present, valid := phaseCurrentsA(meter, s)
if present && !valid {
r.phaseRoomKnown = true // No room on phases it cannot read.
return r
}
r.measured = true
Expand All @@ -287,7 +412,8 @@ func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW
for _, a := range amps {
r.worstA = math.Max(r.worstA, a)
}
r.fuseW = math.Min(r.fuseW, (limitA-s.fuseHeat.levelA(amps))*s.SiteFuseVoltage*phases)
r.fuseW = math.Min(r.fuseW, (limitA-s.fuseHeat.levelA(amps, sent.phaseA))*s.SiteFuseVoltage*phases)
r.phaseRoomA, r.phaseRoomKnown = limitA-s.fuseHeat.levelA(amps, [3]float64{}), true
// The phases measure the fuse itself; total import stands in only without them.
r.fuseCutW = -s.fuseHeat.overA(amps, limitA, fuseInstantFactor*s.SiteFuseAmps) * s.SiteFuseVoltage * phases
}
Expand Down
Loading
Loading