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-cut-counts-cut-on-its-way.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Charging is no longer cut twice when a cloud charger is slow to apply a cut. Easee Cloud takes 30–120 s to apply a new current, but FTW cut again after 20 s, before the first cut could show on the site meter, so a kettle on top could take the car from 10,400 W to 0 W. FTW now lets a cut it has sent cover the overage until the charger can have applied it: the time the driver declares, or 3 minutes. Only what a new load adds is cut. A raise still on its way now counts in a cut, so the phase is not left over the limit when the raise lands. Once a phase's averaged current passes 1.25 times the fuse rating, which a gG fuse carries for an hour, FTW cuts at once whatever is on its way.
196 changes: 171 additions & 25 deletions go/internal/control/ev_fuse_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,18 @@ const evRestartDwell = 35 * time.Second
// and rise thresholds.
const evPhaseMarginA = 1.0

// fuseHourFactor is the phase current, as a multiple of the fuse rating,
// that a gG fuse of 16-63 A carries for an hour. A cut on its way covers the
// overage it was sent for only while every phase's filtered current stays
// at or below it.
const fuseHourFactor = 1.25

// sentCommand is one of our own commands, never a device reading.
type sentCommand struct {
at time.Time
w float64
a float64 // EV offers: current on each phase it may land on
n int // EV offers: the phases it lands on, 1 or the site's
}

// commandLog keeps a device's commands for evSettle, plus the one in force
Expand Down Expand Up @@ -119,11 +126,55 @@ func (l commandLog) unshown(at time.Time, settle time.Duration) (w, a float64) {
return math.Max(0, highW-lowW), math.Max(0, highA-lowA)
}

// toCome is what a charger's latest offer will still change in a reading
// taken at `at`, as a cut counts it, in watts and in current per phase:
// positive is a raise on its way, negative a cut on its way. The reading may
// show the offer in force at from or one sent since, up to `at`, but a cut
// and every offer after it count as not shown yet. Of the offers before the
// first cut, a raise counts from the lowest. An offer on other phases than
// the latest says nothing of the latest's phases, so the latest then counts
// in full as a raise on each. Before the first logged offer the charger
// counts as before.
func (l commandLog) toCome(from, at time.Time, before sentCommand) (w, a float64) {
settled, latest := before, before
for _, c := range l {
latest = c
if !c.at.After(from) {
settled = c
}
}
low, prev, cutW, cutA := settled, settled, false, false
otherPhases := settled.a > 0 && settled.n != latest.n
for _, c := range l {
if !c.at.After(from) || c.at.After(at) {
continue
}
otherPhases = otherPhases || c.a > 0 && c.n != latest.n
cutW, cutA = cutW || c.w < prev.w, cutA || c.a < prev.a
if !cutW {
low.w = math.Min(low.w, c.w)
}
if !cutA {
low.a = math.Min(low.a, c.a)
}
prev = c
}
if otherPhases {
low.a = 0
}
return latest.w - low.w, latest.a - low.a
}

// 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
// found is the offer in force before the limit's first site reading,
// after a restart: Core did not send it in this run. A rise counts the
// charger as 0 before it; a cut takes it as shown, since the reading is
// all Core knows of it.
found sentCommand
}

// pendingLoad is load we ordered that a site reading may not show yet:
Expand All @@ -147,6 +198,49 @@ func (s *State) pendingLoad(at time.Time) pendingLoad {
return p
}

// unshownOrders is what a cut counts beside a reading taken at `at`: rise,
// the load our latest offers add that the reading may not show yet, and
// cover, the load it may still show that our cuts on their way take off
// (toCome). latestW is the latest offers. A cut covers only within the run
// over the limit it was sent in, for its charger's response time: once the
// meter has shown every phase under the limit for evSettle, a cut sent
// before counts as shown. Once the run's first cut has outlasted that time, a cut covers for
// evSettle, as before; so it does without phase currents, where Core
// cannot see the hottest phase. A single-phase cut covers on the phase
// it lands on, which Core does not know, so with other chargers in the pool
// it covers no phase.
func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingLoad, latestW float64) {
lim := &s.evLimit
for _, c := range lim.offers {
w, a := c.log.toCome(at.Add(-c.settle), at, c.found)
coverFor := c.settle
if !perPhase || !lim.fuseCutAt.IsZero() && at.Sub(lim.fuseCutAt) > c.settle {
coverFor = evSettle
}
from := at.Add(-coverFor)
if lim.fuseOverAt.After(from) {
from = lim.fuseOverAt
}
runW, runA := c.log.toCome(from, at, c.found)
coverA := math.Max(0, math.Min(-a, -runA))
var latest sentCommand
if n := len(c.log); n > 0 {
latest = c.log[n-1]
}
if latest.n == 1 && len(lim.offers) > 1 {
coverA = 0
}
rise.w += math.Max(0, w)
cover.w += math.Max(0, math.Min(-w, -runW))
for i := range rise.phaseA {
rise.phaseA[i] += math.Max(0, a)
cover.phaseA[i] += coverA
}
latestW += latest.w
}
return rise, cover, latestW
}

// 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
Expand All @@ -170,8 +264,12 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State)
}
}
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 {
a, phases := s.offerPhaseA(w, point.CommandedPhases)
sent := sentCommand{at: now, w: w, a: a, n: phases}
if n := len(c.log); n == 0 || c.log[n-1].w != sent.w || c.log[n-1].a != sent.a || c.log[n-1].n != sent.n {
if n == 0 && !s.evLimit.set {
c.found = sent
}
c.log = c.log.keep(sent, read.Add(-c.settle))
}
offers[point.ID] = c
Expand All @@ -180,16 +278,16 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State)
}

// 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
}
// on, and how many phases it lands on. Unknown phases count as one.
func (s *State) offerPhaseA(w float64, phases int) (float64, int) {
n := 1
if phases == 3 && s.SiteFusePhases > 1 {
n = s.SiteFusePhases
}
return w / (s.SiteFuseVoltage * float64(n))
if s.SiteFuseVoltage <= 0 {
return 0, n
}
return w / (s.SiteFuseVoltage * float64(n)), n
}

// EVFusePhaseRoomA is the current the chargers may still add on the
Expand Down Expand Up @@ -217,6 +315,9 @@ type evMeterLimit struct {
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
fuseOverAt time.Time // first reading of a run with a phase, or the total, over the fuse's working limit
fuseCutAt time.Time // the first cut of that run
fuseUnderAt time.Time // first reading of a stretch under that limit
overOffers map[string]float64 // each charger's offer when that run began
idleWh float64 // import since roomSince, for the ceiling's start test
byImport bool // the hour's import limit, not the fuse, left less room at readingAt
Expand All @@ -236,8 +337,13 @@ type evPool struct {
// 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
// can have applied it; a cut never frees room before the meter shows it.
// A cut counts our orders both ways: a raise on its way adds to the overage,
// and a cut on its way covers the overage it was sent for until its charger
// can have applied it, within its run over the limit and while the phases
// stay at or below fuseHourFactor (unshownOrders).
// 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) {
Expand Down Expand Up @@ -293,31 +399,67 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo
step := evPhaseMarginA * s.SiteFuseVoltage * pool.phases
// The label follows whichever limit leaves less room.
lim.byImport = math.Min(r.limitW, r.riseW) < r.fuseW
// 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
// less than a step: it cuts once the overage has lasted evSettle, so one
// reading over the ceiling changes nothing.
cutW := r.fuseCutW
// The fuse cuts on every reading over it, by its heat budget where the
// meter reports phase currents. It takes off the latest offers only what
// the cuts the reading may still show do not cover, plus the rises it may
// not show yet. Over the fuse, the cap never rises. The hour's budget
// absorbs a short overage of less than a step: it cuts once the overage
// has lasted evSettle, so one reading over the ceiling changes nothing.
// A run over the fuse's working limit ends once every phase has read
// under it for evSettle; a short dip, as battery relief gives, does not.
switch {
case r.overLimit:
lim.fuseUnderAt = time.Time{}
if lim.fuseOverAt.IsZero() || gap {
lim.fuseOverAt, lim.fuseCutAt = at, time.Time{}
}
case lim.fuseUnderAt.IsZero():
lim.fuseUnderAt = at
case at.Sub(lim.fuseUnderAt) >= evSettle:
lim.fuseOverAt, lim.fuseCutAt = time.Time{}, time.Time{}
}
rise, cover, latestW := s.unshownOrders(at, r.perPhase)
fuseCutW := r.fuseOverW + rise.w - cover.w
if r.perPhase {
// A single-phase order may land on any phase, so the cut takes the
// most a rise adds to a phase and the least a cut takes off one.
// Past the current a gG fuse carries for an hour, nothing on its
// way holds back a cut.
coverA := min(cover.phaseA[0], cover.phaseA[1], cover.phaseA[2])
if s.fuseHeat.worstA() > fuseHourFactor*s.SiteFuseAmps {
coverA = 0
}
fuseCutW = (r.fuseOverA + max(rise.phaseA[0], rise.phaseA[1], rise.phaseA[2]) - coverA) * s.SiteFuseVoltage * pool.phases
}
over, capW, byImport := r.fuseOverW > 0, math.Inf(1), false
if over {
capW = latestW - math.Max(0, fuseCutW)
}
if r.limitW >= 0 {
lim.overSince = time.Time{}
} else {
if lim.overSince.IsZero() || gap {
lim.overSince, lim.overOffers = at, pool.offers
}
if !r.hourly || gap || -r.limitW >= step || at.Sub(lim.overSince) >= evSettle {
cutW = math.Min(cutW, r.limitW)
// The meter may still show any offer of the last evSettle.
// Taking the overage from the highest cuts each measured watt once.
hourW := peakW + r.limitW
byImport, capW, over = !over || hourW < capW, math.Min(capW, hourW), true
}
}
if cutW < 0 {
lim.byImport = cutW < r.fuseCutW
// The meter may still show any offer of the last evSettle. Taking
// 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 over {
lim.byImport = byImport
lim.capW, lim.roomSince = math.Min(prev, math.Max(0, capW)), time.Time{}
if lim.capW < prev {
lim.cutAt = now
if r.fuseOverW > 0 && lim.fuseCutAt.IsZero() {
lim.fuseCutAt = 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)
"overage_w", r.fuseOverW, "import_overage_w", math.Max(0, -r.limitW),
"unshown_rise_w", rise.w, "unshown_cut_w", cover.w, "offer_w", latestW, "cap_w", lim.capW)
}
return
}
Expand Down Expand Up @@ -406,7 +548,10 @@ func EVLimitReason(state *State) string {
// 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, and what we sent; rises and starts
fuseCutW float64 // what the fuse needs cut now, negative; see fuseHeat
fuseOverW float64 // the fuse's measured overage that cuts now, 0 under it; see fuseHeat
fuseOverA float64 // the same on the worst phase, with perPhase
perPhase bool // the phase currents measure the fuse
overLimit bool // a phase, or the total without phases, reads over the fuse's working limit
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
Expand All @@ -419,7 +564,7 @@ type evMeterRoom struct {

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 - sent.w, fuseCutW: totalW, limitW: inf, riseW: inf, startLevelW: inf}
r := evMeterRoom{fuseW: totalW - sent.w, fuseOverW: math.Max(0, -totalW), overLimit: totalW < 0, limitW: inf, riseW: inf, startLevelW: inf}
if limitW, riseW, ok := s.importHourLimitsW(); ok {
r.limitW, r.riseW = limitW-gridW, riseW-gridW-sent.w
// A car that could run only on one hour's slack would stop when
Expand All @@ -441,7 +586,8 @@ func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW
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
r.fuseOverA = s.fuseHeat.overA(amps, limitA, fuseInstantFactor*s.SiteFuseAmps)
r.fuseOverW, r.perPhase, r.overLimit = r.fuseOverA*s.SiteFuseVoltage*phases, true, r.worstA > limitA
}
return r
}
Expand Down
Loading
Loading