diff --git a/.changeset/ev-fuse-slow-charger-rises.md b/.changeset/ev-fuse-slow-charger-rises.md new file mode 100644 index 00000000..e74e9190 --- /dev/null +++ b/.changeset/ev-fuse-slow-charger-rises.md @@ -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. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 4b797fcf..2d1fa404 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -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. diff --git a/go/internal/control/ev_battery_sequence_test.go b/go/internal/control/ev_battery_sequence_test.go index ce6abdad..8100ee00 100644 --- a/go/internal/control/ev_battery_sequence_test.go +++ b/go/internal/control/ev_battery_sequence_test.go @@ -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) @@ -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 } diff --git a/go/internal/control/ev_deadline_runtime_test.go b/go/internal/control/ev_deadline_runtime_test.go index da1c817f..1f4f1204 100644 --- a/go/internal/control/ev_deadline_runtime_test.go +++ b/go/internal/control/ev_deadline_runtime_test.go @@ -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) @@ -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 { diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index ff0755b1..2ffb42a5 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -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 @@ -36,6 +41,7 @@ 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 @@ -43,9 +49,14 @@ type sentCommand struct { 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 { @@ -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 { @@ -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 @@ -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) } @@ -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 @@ -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) @@ -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) @@ -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)) } } @@ -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) @@ -257,21 +379,23 @@ 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) @@ -279,6 +403,7 @@ func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW } amps, present, valid := phaseCurrentsA(meter, s) if present && !valid { + r.phaseRoomKnown = true // No room on phases it cannot read. return r } r.measured = true @@ -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 } diff --git a/go/internal/control/ev_fuse_budget_test.go b/go/internal/control/ev_fuse_budget_test.go index 0a036461..1360591d 100644 --- a/go/internal/control/ev_fuse_budget_test.go +++ b/go/internal/control/ev_fuse_budget_test.go @@ -3,7 +3,9 @@ package control import ( "context" "encoding/json" + "fmt" "math" + "slices" "testing" "time" @@ -39,6 +41,7 @@ type evMeterSite struct { mgr *loadpoint.Manager ctrl *loadpoint.Controller chargerDelay, telemetryDelay time.Duration + chargerMaxAgeS float64 // the driver's power_max_age_s, as Easee Cloud declares 180 readings evReadings houseW float64 housePhaseA [3]float64 @@ -123,6 +126,7 @@ func (s *evMeterSite) boot() { s.ctrl.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(s.store, s.state, s.mgr.States(), s.mgr.Configs(), 17250) }) + s.ctrl.SetFusePhaseRoomA(func() (float64, bool) { return EVFusePhaseRoomA(s.state) }) s.ctrl.SetManualHold(cfg.ID, loadpoint.ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) if s.onBoot != nil { s.onBoot() @@ -157,7 +161,11 @@ func (s *evMeterSite) emit(driver string, kind telemetry.DerType, w float64, ext // report delivers one poll. The receipt time is all the driver knows. func (s *evMeterSite) report(w float64) { s.reportedW, s.reportedAt = w, s.now - s.emit("easee", telemetry.DerEV, w, nil) + var extra map[string]any + if s.chargerMaxAgeS > 0 { + extra = map[string]any{"power_max_age_s": s.chargerMaxAgeS} + } + s.emit("easee", telemetry.DerEV, w, extra) } func (s *evMeterSite) cloudW() float64 { @@ -393,48 +401,135 @@ func TestEVHardLimitCutsOnNextReadingWhenCarResumesIntoHouseLoad(t *testing.T) { } } -// After a cut the cap rises one step at a time. The next rise follows as -// soon as the meter shows import rising with the last one; a car that does -// not follow gets one step per settle time at most. -func TestEVHardLimitRiseWaitsForMeterOrDwell(t *testing.T) { - for _, paused := range []bool{false, true} { - s := newEVMeterSite(t, 7500, 900) - s.steady(5520) - s.setHouse(2900) // 920 W over: cut to 4,140 W - s.run(10 * time.Second) - if s.offer() != 4140 { - t.Fatalf("offer %.0f W after a 920 W overage", s.offer()) - } - s.setHouse(900) - s.carPaused = paused - recoveredAt := s.now - s.run(2 * time.Minute) - var rises []evTimed - got := s.offersSince(recoveredAt) - for i := 1; i < len(got); i++ { - if got[i].w-got[i-1].w > 690 { - t.Fatalf("paused=%v: rose more than one step at once: %v", paused, offerWatts(got)) - } - if got[i].w > got[i-1].w { - rises = append(rises, got[i]) - } - } - if len(rises) < 2 { - t.Fatalf("paused=%v: rises %v, want at least two", paused, offerWatts(got)) +// After a cut the cap rises once evSettle has passed, then one step per +// reading. It does not wait for the meter to show each rise: every rise +// counts as flowing until the charger can have applied it, so the car +// stops a step below the room. +func TestEVHardLimitRisesOnceCutApplied(t *testing.T) { + s := newEVMeterSite(t, 7500, 900) + s.steady(5520) + stepAt := s.now + s.setHouse(2900) // 920 W over: cut to 4,140 W + s.run(10 * time.Second) + cuts := s.offersSince(stepAt) + if len(cuts) == 0 || s.offer() != 4140 { + t.Fatalf("offer %.0f W after a 920 W overage", s.offer()) + } + var cutAt time.Time + for _, o := range cuts { + if o.w < 5520 { + cutAt = o.at + break } - gap := rises[1].at.Sub(rises[0].at) - if !paused && gap >= evSettle { - t.Fatalf("a rise the meter confirmed still waited %v", gap) + } + s.setHouse(900) + recoveredAt := s.now + s.run(time.Minute) + got := s.offersSince(recoveredAt) + var firstRise time.Time + for i := 1; i < len(got); i++ { + if got[i].w-got[i-1].w > 690 { + t.Fatalf("rose more than one step at once: %v", offerWatts(got)) } - if paused && gap < evSettle { - t.Fatalf("an unconfirmed rise followed after %v", gap) + if got[i].w > got[i-1].w && firstRise.IsZero() { + firstRise = got[i].at } - if !paused && (s.offer() != 5520 || s.houseW+s.evW > 7500-690) { - t.Fatalf("recovered to %.0f W at %.0f W grid, want 5,520 W with a step of margin", s.offer(), s.houseW+s.evW) + } + if firstRise.IsZero() || firstRise.Sub(cutAt) < evSettle { + t.Fatalf("first rise %v after the cut, want at least %v", firstRise.Sub(cutAt), evSettle) + } + if s.offer() != 5520 || s.houseW+s.evW > 7500-690 { + t.Fatalf("recovered to %.0f W at %.0f W grid, want 5,520 W with a step of margin", s.offer(), s.houseW+s.evW) + } +} + +// Easee Cloud may take one to three minutes to apply a new current and +// declares power_max_age_s 180. Beside 15 A of house load on the 25 A fuse, +// rises sent before the meter showed the last one stacked: a 60 s delay +// peaked at 25.0 A, a 120 s delay at 28.0 A. Each rise now counts as flowing +// until the charger can have applied it, and a charger that declares nothing +// counts as just as slow. The car settles in the measured room and climbs +// again when the house lets go. +func TestEVHardLimitSlowChargerRisesDoNotStack(t *testing.T) { + for _, declaredS := range []float64{180, 0} { + for _, delay := range []time.Duration{60 * time.Second, 120 * time.Second} { + t.Run(fmt.Sprintf("declared_%.0fs/delay_%v", declaredS, delay), func(t *testing.T) { + s := newEVMeterSite(t, 0, 15*690) + s.chargerDelay, s.chargerMaxAgeS = delay, declaredS + limitA := s.state.SiteFuseAmps - evPhaseMarginA + var maxA float64 + var overSince time.Time + watch := func(d time.Duration) { + t.Helper() + for end := s.now.Add(d); s.now.Before(end); { + s.step() + a := s.phaseA() + worst := math.Max(a[0], math.Max(a[1], a[2])) + maxA = math.Max(maxA, worst) + switch { + case worst <= limitA: + overSince = time.Time{} + case overSince.IsZero(): + overSince = s.now + case s.now.Sub(overSince) > delay+3*time.Second: + t.Fatalf("worst phase %.1f A above %.0f A for %v", worst, limitA, s.now.Sub(overSince)) + } + } + } + watch(10 * time.Minute) + // 24 - 15 A leaves 9 A; one step stays free. + if s.offer() != 5520 || s.evW != 5520 { + t.Fatalf("offer %.0f W draw %.0f W, want 5,520 W", s.offer(), s.evW) + } + s.setHouse(10 * 690) + watch(15 * time.Minute) + // 24 - 10 A leaves 14 A; one step stays free. + if s.offer() != 8970 || s.evW != 8970 { + t.Fatalf("after the house fell: offer %.0f W draw %.0f W, want 8,970 W", s.offer(), s.evW) + } + t.Logf("worst phase peaked at %.1f A", maxA) + }) } } } +// A fast local charger that declares no response time counts its offers +// as flowing for evUnknownSettle, beside what the meter already shows. It +// still takes the whole room in one move, and the pending count never cuts +// it: only a measured overage does. +func TestEVHardLimitUndeclaredFastChargerTakesRoomAtOnce(t *testing.T) { + s := newEVMeterSite(t, 0, 15*690) + for s.offer() == 0 { + s.step() + } + startedAt := s.now + s.run(s.chargerDelay + 2*time.Second) + // 24 - 15 A leaves 9 A; one step stays free. + if s.offer() != 5520 || s.evW != 5520 { + t.Fatalf("started at %.0f W, drawing %.0f W; want the 5,520 W room in one move", s.offer(), s.evW) + } + // 23.5 A measured, under the 24 A working limit, while the start still + // counts as flowing. + s.setHouse(15.5 * 690) + s.run(time.Minute) + if got := offerWatts(s.offersSince(startedAt)); slices.Min(got) != 5520 { + t.Fatalf("the pending start cut the car: %v", got) + } + + // Under a 7.5 kW import ceiling the house falls by 1,380 W: one run of + // rises, a step per reading, takes the car to the room less a step. + s = newEVMeterSite(t, 7500, 2280) + s.run(5 * time.Minute) + if s.offer() != 4140 { + t.Fatalf("setup: offer %.0f W", s.offer()) + } + s.setHouse(900) + s.run(s.chargerDelay + 5*time.Second) + if s.offer() != 5520 || s.evW != 5520 { + t.Fatalf("offer %.0f W draw %.0f W after the house fell, want 5,520 W", s.offer(), s.evW) + } +} + // A charger that ignores a cut gets the next one once its old current // should have reached the meter. func TestEVHardLimitCutsAgainAfterSettleWhenChargerIgnores(t *testing.T) { diff --git a/go/internal/control/ev_fuse_command_phase_test.go b/go/internal/control/ev_fuse_command_phase_test.go index 4fc62f14..9db9876c 100644 --- a/go/internal/control/ev_fuse_command_phase_test.go +++ b/go/internal/control/ev_fuse_command_phase_test.go @@ -56,6 +56,7 @@ func newPhaseSite(t *testing.T, cfg loadpoint.Config, houseA float64) *phaseSite s.ctrl.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(s.store, s.state, s.mgr.States(), s.ctrl.FuseConfigs(), 25*230*3) }) + s.ctrl.SetFusePhaseRoomA(func() (float64, bool) { return EVFusePhaseRoomA(s.state) }) return s } diff --git a/go/internal/control/ev_fuse_phase_room_test.go b/go/internal/control/ev_fuse_phase_room_test.go new file mode 100644 index 00000000..2121d7e0 --- /dev/null +++ b/go/internal/control/ev_fuse_phase_room_test.go @@ -0,0 +1,258 @@ +package control + +import ( + "context" + "encoding/json" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// roomCharger applies each command delay after it was sent, on the phases +// the command names: a single-phase command lands on L1. +type roomCharger struct { + cfg loadpoint.Config + delay time.Duration + connected bool + queue []roomCommand + drawW float64 + onePhase bool +} + +type roomCommand struct { + at time.Time + w float64 + onePhase bool +} + +// roomSite is a 25 A three-phase site with chargers that declare their +// delay as power_max_age_s. The meter reads every second; the loadpoint +// ticks every 5 s, wired as main.go wires it. +type roomSite struct { + t *testing.T + now time.Time + start time.Time + store *telemetry.Store + state *State + mgr *loadpoint.Manager + ctrl *loadpoint.Controller + houseA [3]float64 + chargers []*roomCharger + static bool // EVFuseBudget reads the configured phase modes + worstA float64 +} + +func newRoomSite(t *testing.T, houseA float64, chargers ...*roomCharger) *roomSite { + t.Helper() + s := &roomSite{t: t, now: time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC), store: telemetry.NewStore(), chargers: chargers} + s.start = s.now + s.houseA = [3]float64{houseA, houseA, houseA} + s.state = NewState(0, 0, "meter") + s.state.SiteFuseAmps, s.state.SiteFuseVoltage, s.state.SiteFusePhases = 25, 230, 3 + s.state.clock = func() time.Time { return s.now } + byDriver := map[string]*roomCharger{} + var configs []loadpoint.Config + for _, c := range chargers { + byDriver[c.cfg.DriverName] = c + configs = append(configs, c.cfg) + } + s.mgr = loadpoint.NewManager() + s.mgr.Load(configs) + s.ctrl = loadpoint.NewController(s.mgr, + func(time.Time) (loadpoint.Directive, bool) { return loadpoint.Directive{}, false }, + func(driver string) (loadpoint.EVSample, bool) { + c := byDriver[driver] + return loadpoint.EVSample{Connected: c.connected, RequestActive: c.connected, PowerW: c.drawW, PowerAt: s.now}, true + }, + func(_ context.Context, driver string, payload []byte) error { + var cmd struct { + Action string `json:"action"` + PowerW float64 `json:"power_w"` + PhaseMode string `json:"phase_mode"` + } + if err := json.Unmarshal(payload, &cmd); err != nil { + return err + } + if cmd.Action == "ev_set_current" { + c := byDriver[driver] + c.queue = append(c.queue, roomCommand{s.now, cmd.PowerW, loadpoint.PhaseFor(cmd.PhaseMode, cmd.PowerW, 3680) == 1}) + } + return nil + }) + s.ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + s.ctrl.SetFuseEVMax(func() (float64, bool) { + configs := s.ctrl.FuseConfigs() + if s.static { + configs = s.mgr.Configs() + } + return EVFuseBudget(s.store, s.state, s.mgr.States(), configs, 25*230*3) + }) + s.ctrl.SetFusePhaseRoomA(func() (float64, bool) { return EVFusePhaseRoomA(s.state) }) + return s +} + +func (s *roomSite) phaseA() [3]float64 { + a := s.houseA + for _, c := range s.chargers { + switch { + case !c.connected: + case c.onePhase: + a[0] += c.drawW / 230 + default: + for i := range a { + a[i] += c.drawW / 690 + } + } + } + return a +} + +func (s *roomSite) run(d time.Duration) { + s.t.Helper() + for end := s.now.Add(d); s.now.Before(end); { + s.now = s.now.Add(time.Second) + for _, c := range s.chargers { + for len(c.queue) > 0 && !s.now.Before(c.queue[0].at.Add(c.delay)) { + c.drawW, c.onePhase, c.queue = c.queue[0].w, c.queue[0].onePhase, c.queue[1:] + } + if !c.connected { + c.drawW = 0 + } + data, _ := json.Marshal(map[string]any{"power_max_age_s": c.delay.Seconds()}) + s.store.Update(c.cfg.DriverName, telemetry.DerEV, c.drawW, nil, data) + } + a := s.phaseA() + data, _ := json.Marshal(map[string]any{"control_power_available": true, + "control_power_observed_at": s.now.Format(time.RFC3339Nano), "l1_a": a[0], "l2_a": a[1], "l3_a": a[2]}) + s.store.Update("meter", telemetry.DerMeter, (a[0]+a[1]+a[2])*230, nil, data) + s.store.Get("meter", telemetry.DerMeter).UpdatedAt = s.now + s.store.RecordDriverSuccess("meter") + s.worstA = math.Max(s.worstA, max(a[0], a[1], a[2])) + if s.now.Sub(s.start)%(5*time.Second) == 0 { + s.ctrl.Tick(context.Background(), s.now) + } + } +} + +// requireWithinLimit fails when a phase passed the 24 A working limit. +func (s *roomSite) requireWithinLimit(after string) { + s.t.Helper() + if limitA := s.state.SiteFuseAmps - evPhaseMarginA; s.worstA > limitA+1e-6 { + s.t.Fatalf("%s: a phase reached %.1f A, over the %.0f A working limit", after, s.worstA, limitA) + } +} + +func roomTestCharger(id string, delay time.Duration) *roomCharger { + steps := []float64{0, 1380, 1610, 1840, 2070, 2300, 2530, 2760, 2990, 3220, 3450, 3680, + 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350, 11040} + return &roomCharger{cfg: loadpoint.Config{ID: id, DriverName: id, MinChargeW: 1380, MaxChargeW: 11040, AllowedStepsW: steps}, + delay: delay, connected: true} +} + +// A 3p offer moved to 1p at nearly the same watts triples the current on +// one phase. The budget counts current per phase, so the switch needs room +// for the rise on that phase. Before, 7,360 W went out on L1 beside 12 A: +// 36 A for 50 s with a 5 s charger and for 90 s with a 90 s charger. +func TestEVFusePhaseSwitchNeedsRoomOnThePhase(t *testing.T) { + for _, delay := range []time.Duration{5 * time.Second, 90 * time.Second} { + t.Run(delay.String(), func(t *testing.T) { + c := roomTestCharger("a", delay) + s := newRoomSite(t, 5, c) + s.houseA[0] = 12 + s.ctrl.SetManualHold("a", loadpoint.ManualHold{PowerW: 8280, PhaseMode: "3p", Persistent: true}) + s.run(5 * time.Minute) + s.ctrl.SetManualHold("a", loadpoint.ManualHold{PowerW: 7360, PhaseMode: "1p", Persistent: true}) + s.run(6 * time.Minute) + s.requireWithinLimit("after 3p to 1p") + if !c.onePhase || c.drawW < 1380 { + t.Fatalf("the car draws %.0f W (1p %v), want a 1p charge", c.drawW, c.onePhase) + } + }) + } +} + +// Two cars at 8 A; the first is raised. Room a slow charger's cut frees is +// not there until that charger can have applied it, and a rise takes only +// the room net of every charger's reservation. Before, the raised car took +// the other's share in one tick: 27 A for 60 s. +func TestEVFuseRaisedCarTakesOnlyFreeRoom(t *testing.T) { + for _, tc := range []struct { + name string + aDelay, bDelay time.Duration + }{ + {"a_fast_b_slow", 5 * time.Second, 90 * time.Second}, + {"both_slow", 60 * time.Second, 120 * time.Second}, + } { + t.Run(tc.name, func(t *testing.T) { + a, b := roomTestCharger("a", tc.aDelay), roomTestCharger("b", tc.bDelay) + s := newRoomSite(t, 3, a, b) + s.ctrl.SetManualHold("a", loadpoint.ManualHold{PowerW: 5520, PhaseMode: "3p", Persistent: true}) + s.ctrl.SetManualHold("b", loadpoint.ManualHold{PowerW: 5520, PhaseMode: "3p", Persistent: true}) + s.run(10 * time.Minute) + s.ctrl.SetManualHold("a", loadpoint.ManualHold{PowerW: 11040, PhaseMode: "3p", Persistent: true}) + s.run(8 * time.Minute) + s.requireWithinLimit("after car a was raised") + if a.drawW <= 5520 { + t.Fatalf("car a stayed at %.0f W beside 3 A of house and car b at %.0f W", a.drawW, b.drawW) + } + }) + } +} + +// A 1p hold over a 3p loadpoint: each offer counts on the phases its own +// command names, never the configured ones, even when the budget is read +// with the configured phase mode. Before, 1p offers counted as 3p and L1 +// reached 39 A. +func TestEVFuseOnePhaseOffersCountOnTheirPhase(t *testing.T) { + for _, delay := range []time.Duration{5 * time.Second, 90 * time.Second} { + t.Run(delay.String(), func(t *testing.T) { + c := roomTestCharger("a", delay) + c.cfg.PhaseMode = "3p" + s := newRoomSite(t, 5, c) + s.static = true + s.houseA[0] = 15 + s.ctrl.SetManualHold("a", loadpoint.ManualHold{PowerW: 5520, PhaseMode: "1p", Persistent: true}) + s.run(5 * time.Minute) + s.houseA[0] = 5 + s.run(6 * time.Minute) + s.requireWithinLimit("after L1 fell") + if !c.onePhase || c.drawW < 4140 { + t.Fatalf("the car draws %.0f W (1p %v), want it to climb on L1", c.drawW, c.onePhase) + } + }) + } +} + +// A silent meter never frees room. An offer sent after the last reading +// counts against it until a newer reading could show the offer. +func TestEVFusePhaseRoomHoldsOnSilentMeter(t *testing.T) { + store, state, points, configs := evMeterBudgetFixture(t, 15*690, []float64{15, 15, 15}, 0, "3p") + now := time.Now().Truncate(time.Second) + state.clock = func() time.Time { return now } + store.Get("meter", telemetry.DerMeter).UpdatedAt = now + points[0].CommandedPhases = 3 + room := func() float64 { + t.Helper() + EVFuseBudget(store, state, points, configs, 17250) + roomA, ok := EVFusePhaseRoomA(state) + if !ok { + t.Fatal("no phase room from a meter with phase currents") + } + return roomA + } + if got := room(); math.Abs(got-9) > 1e-9 { + t.Fatalf("room %.2f A beside 15 A, want 9 A", got) + } + points[0].CommandedW = 5520 + now = now.Add(time.Second) + if got := room(); math.Abs(got-1) > 1e-9 { + t.Fatalf("room %.2f A after an 8 A offer, want 1 A", got) + } + now = now.Add(10 * time.Minute) + if got := room(); math.Abs(got-1) > 1e-9 { + t.Fatalf("room %.2f A after ten silent minutes, want 1 A", got) + } +} diff --git a/go/internal/control/fuse_heat.go b/go/internal/control/fuse_heat.go index f6fcffbd..305d9f5a 100644 --- a/go/internal/control/fuse_heat.go +++ b/go/internal/control/fuse_heat.go @@ -71,12 +71,13 @@ func (h *fuseHeat) overA(amps []float64, limitA, instantA float64) float64 { } // levelA is the highest phase current, taking each phase at the higher of -// the reading and its heat. Rises and starts read it, so a rise never spends -// heat the last minute has already used. -func (h *fuseHeat) levelA(amps []float64) float64 { +// the reading and its heat, plus what we sent that it may not show yet. +// Rises and starts read it, so a rise never spends heat the last minute has +// already used, nor room an earlier rise will take. +func (h *fuseHeat) levelA(amps []float64, pendingA [3]float64) float64 { level := 0.0 for i, a := range amps { - level = math.Max(level, math.Max(a, math.Sqrt(h.sq[i]))) + level = math.Max(level, math.Max(a, math.Sqrt(h.sq[i]))+pendingA[i]) } return level } diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 8b8f9a54..1f5bb003 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -90,6 +90,7 @@ func (s *simSite) boot() { s.ctrl.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(s.store, s.state, s.mgr.States(), s.ctrl.FuseConfigs(), s.fuseMaxW()) }) + s.ctrl.SetFusePhaseRoomA(func() (float64, bool) { return EVFusePhaseRoomA(s.state) }) s.ctrl.SetSiteSurplusForEV(func() (float64, bool) { if !s.meterFresh() { return 0, false @@ -495,9 +496,9 @@ func simScenarios() []simScenario { {At: 15 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(20, 20, 20) }}, } }}, - {name: "slow cloud charger 60 s", known: "rises stack on a charger slower than evSettle, ev_fuse_budget.go:204", run: 15 * time.Minute, + {name: "slow cloud charger 60 s", run: 15 * time.Minute, plant: slowCharger(time.Minute)}, - {name: "slow cloud charger 120 s", known: "rises stack on a charger slower than evSettle, ev_fuse_budget.go:204", run: 15 * time.Minute, + {name: "slow cloud charger 120 s", run: 15 * time.Minute, plant: slowCharger(2 * time.Minute)}, {name: "#1761 replay", run: 20 * time.Minute, plant: func(p *sitesim.Plant) { @@ -541,7 +542,7 @@ func simScenarios() []simScenario { planBatteryCharge(0)(s) s.mgr.SetSchedule(s.lp.ID, loadpoint.Schedule{SurplusUnlockBatSoC: .9}) }}, - {name: "battery reaches its floor under Charge now", known: "the battery stops at its 5 % floor with no room for that, dispatch.go:3540", run: 5 * time.Minute, + {name: "battery reaches its floor under Charge now", run: 5 * time.Minute, plant: func(p *sitesim.Plant) { p.HouseW = houseA(13.7, 12, 12) p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 5000, MaxDischargeW: 5000, CapacityWh: 10000, SoC: .07, Latency: 2 * time.Second} diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 4c84caaf..7c9811fc 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -46,6 +46,9 @@ type Controller struct { // fuseEVMax is this tick's EV share of the site fuse. nil means no cap. fuseEVMax func() (float64, bool) + // fusePhaseRoomA is the current chargers may still add on the busiest + // phase. nil, or false, leaves the cap alone to limit them. + fusePhaseRoomA func() (float64, bool) // tickFuseCaps belongs to the serial tick, after one shared budget read. tickFuseCaps map[string]float64 @@ -514,6 +517,16 @@ func (c *Controller) SetFuseEVMax(f func() (float64, bool)) { c.fuseEVMax = f } +// SetFusePhaseRoomA wires control.EVFusePhaseRoomA: the current the +// chargers may still add on the busiest phase, net of every charger's +// commands the meter may not show yet. Read once per tick, with the cap. +func (c *Controller) SetFusePhaseRoomA(f func() (float64, bool)) { + if c == nil { + return + } + c.fusePhaseRoomA = f +} + // SetPerPhaseMeterAmps does nothing. Deprecated: control.EVFuseBudget reads // the phase currents; this stays until the last test caller is gone. func (c *Controller) SetPerPhaseMeterAmps(func() (l1, l2, l3 float64, ok bool)) {} @@ -1593,6 +1606,8 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s offerW, haveOffer = w, true c.manager.setCommandedForManual(lpCfg.ID, w, cmdReason, manualCommandUpdatedAt) mode, _ := cmd["phase_mode"].(string) + split, _ := cmd["phase_split_w"].(float64) + c.manager.setCommandedPhases(lpCfg.ID, c.commandPhases(mode, w, split)) c.saveOffer(now, lpCfg, sample, w, fusePhase(mode), cmdReason) } payload, err := json.Marshal(cmd) diff --git a/go/internal/loadpoint/controller_fuse_budget.go b/go/internal/loadpoint/controller_fuse_budget.go index 4ded0bf0..b03444ab 100644 --- a/go/internal/loadpoint/controller_fuse_budget.go +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -70,13 +70,87 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample } return a.cfg.ID < b.cfg.ID }) + roomA, perPhase := 0.0, false + if c.fusePhaseRoomA != nil { + roomA, perPhase = c.fusePhaseRoomA() + perPhase = perPhase && finite(roomA) + } c.tickFuseCaps = make(map[string]float64, len(requests)) remaining := total for _, request := range requests { - cap := floorChargeW(math.Min(request.wantW, remaining), request.cfg.MinChargeW, request.cfg.MaxChargeW, request.cfg.AllowedStepsW) + want := math.Min(request.wantW, remaining) + mode := c.tickChoices[request.cfg.ID].phaseMode + split := c.commandSplitW(now, request.cfg) + if perPhase { + want = math.Min(want, c.phaseRoomCapW(request.cfg.ID, mode, want, split, roomA)) + } + cap := floorChargeW(want, request.cfg.MinChargeW, request.cfg.MaxChargeW, request.cfg.AllowedStepsW) c.tickFuseCaps[request.cfg.ID] = cap remaining -= cap + if perPhase { + // A cut frees room only once the meter shows it. + roomA -= math.Max(0, c.phaseRiseA(request.cfg.ID, mode, cap, split)) + } + } +} + +// commandPhases is where a command of w lands: 1 or 3 phases, as the +// driver resolves its phase_mode and split. A 1-phase site has one. +func (c *Controller) commandPhases(mode string, w, split float64) int { + if c.siteFuse().Phases() == 1 { + return 1 + } + if split <= 0 { + split = defaultPhaseSplitW + } + return PhaseFor(mode, w, split) +} + +// commandSplitW is the 1p/3p split this tick's command names. +func (c *Controller) commandSplitW(now time.Time, cfg Config) float64 { + if hold, held := c.GetManualHold(cfg.ID, now); held && hold.PhaseSplitW > 0 { + return hold.PhaseSplitW + } + return cfg.PhaseSplitW +} + +// phaseRiseA is how much a command of w adds to the busiest phase over the +// loadpoint's last command. A single-phase command may land on any phase, +// so the last command's current comes off only where both use the same +// phases or the last one used all three. +func (c *Controller) phaseRiseA(id, mode string, w, split float64) float64 { + site := c.siteFuse() + if w <= 0 || site.Voltage <= 0 { + return 0 + } + phases := c.commandPhases(mode, w, split) + return w/(site.Voltage*float64(phases)) - c.creditA(id, phases) +} + +func (c *Controller) creditA(id string, phases int) float64 { + st, _ := c.manager.State(id) + site := c.siteFuse() + if !st.CommandedKnown || st.CommandedW <= 0 || site.Voltage <= 0 || (st.CommandedPhases != 3 && st.CommandedPhases != phases) { + return 0 + } + return st.CommandedW / (site.Voltage * float64(min(st.CommandedPhases, site.Phases()))) +} + +// phaseRoomCapW is the most a command may offer when it can add at most +// roomA to the busiest phase. It always keeps the last command's current. +// An auto command below the split lands on one phase. +func (c *Controller) phaseRoomCapW(id, mode string, wantW, split, roomA float64) float64 { + site := c.siteFuse() + if site.Voltage <= 0 { + return wantW + } + roomA = math.Max(0, roomA) + phases := c.commandPhases(mode, wantW, split) + capW := (c.creditA(id, phases) + roomA) * site.Voltage * float64(phases) + if phases == 3 && c.commandPhases(mode, capW, split) == 1 { + capW = math.Min(capW, (c.creditA(id, 1)+roomA)*site.Voltage) } + return capW } // FuseConfigs returns the loadpoints with the phase mode each command names diff --git a/go/internal/loadpoint/controller_offer_restore.go b/go/internal/loadpoint/controller_offer_restore.go index 994a421e..1361da27 100644 --- a/go/internal/loadpoint/controller_offer_restore.go +++ b/go/internal/loadpoint/controller_offer_restore.go @@ -89,6 +89,7 @@ func (c *Controller) restoreOffers(now time.Time, samples map[string]EVSample) { continue } c.manager.setCommandedForManual(cfg.ID, saved.PowerW, saved.Reason, time.Time{}) + c.manager.setCommandedPhases(cfg.ID, c.commandPhases(saved.PhaseMode, saved.PowerW, cfg.PhaseSplitW)) if c.savedOffers == nil { c.savedOffers = map[string]savedOffer{} } diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 05b8f27b..2e5b6b5f 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -260,6 +260,9 @@ type State struct { CommandedSinceMs int64 `json:"commanded_since_ms,omitempty"` // Internal identity of the manual choice used to compute the order. ManualCommandUpdatedAt time.Time `json:"-"` + // CommandedPhases is 1 or 3: the phases the last command lands on, from + // its phase_mode and split. Zero before the first command. + CommandedPhases int `json:"-"` // CommandedReason names the dispatch branch that decided CommandedW: // "plan", "no_plan_budget", "pv_surplus", "pv_surplus_pause", @@ -507,6 +510,7 @@ type loadpointRuntime struct { commandedW float64 commandedKnown bool commandedReason string + commandedPhases int // 1 or 3: where the last command lands; 0 before it // commandedSince is when the current (commandedW, commandedReason) pair // was first ordered. The manual status counts elapsed time from it. commandedSince time.Time @@ -586,6 +590,15 @@ func (m *Manager) setCommandedForManual(id string, w float64, reason string, upd } } +// setCommandedPhases records the phases the last command lands on. +func (m *Manager) setCommandedPhases(id string, phases int) { + m.mu.Lock() + defer m.mu.Unlock() + if lp, ok := m.byID[id]; ok { + lp.commandedPhases = phases + } +} + // Load replaces the configured set. Idempotent: existing state is // carried across when the ID is kept; removed IDs are dropped. func (m *Manager) Load(cfgs []Config) { @@ -1354,6 +1367,7 @@ func (lp *loadpointRuntime) snapshot() State { CommandedW: lp.commandedW, CommandedReason: lp.commandedReason, CommandedKnown: lp.commandedKnown, + CommandedPhases: lp.commandedPhases, } if lp.VehicleCapacityWh <= 0 { st.VehicleCapacityWh = 60000 diff --git a/go/internal/telemetry/command_evidence.go b/go/internal/telemetry/command_evidence.go index 6523c2bf..1216e685 100644 --- a/go/internal/telemetry/command_evidence.go +++ b/go/internal/telemetry/command_evidence.go @@ -379,7 +379,7 @@ func (s *Store) observeCommand(driver string, kind DerType, power float64, data s.commands[k] = c return } - c.MaxAge = controlPowerDeclaredMaxAge(data) + c.MaxAge = DeclaredPowerMaxAge(data) if now.Sub(c.LastObservation) > max(time.Minute, c.MaxAge) { c.ReadbackMismatchSince, c.PowerMismatchSince, c.PowerMatchSince = time.Time{}, time.Time{}, time.Time{} c.PowerObservedSince = time.Time{} diff --git a/go/internal/telemetry/control_observations.go b/go/internal/telemetry/control_observations.go index 59612953..2da93654 100644 --- a/go/internal/telemetry/control_observations.go +++ b/go/internal/telemetry/control_observations.go @@ -183,13 +183,15 @@ func (p ControlObservation) Seen() time.Time { // source's power_max_age_s when it declares one, at most three minutes, // otherwise one minute. func ControlPowerMaxAge(data json.RawMessage) time.Duration { - if age := controlPowerDeclaredMaxAge(data); age > 0 { + if age := DeclaredPowerMaxAge(data); age > 0 { return age } return time.Minute } -func controlPowerDeclaredMaxAge(data json.RawMessage) time.Duration { +// DeclaredPowerMaxAge is the power_max_age_s a source declares, at most +// three minutes, otherwise 0. +func DeclaredPowerMaxAge(data json.RawMessage) time.Duration { var d struct { PowerMaxAgeS float64 `json:"power_max_age_s"` }