From 0bfa661f0fcfc99f7355b225dc7590f18c2681bb Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 11:26:26 +0200 Subject: [PATCH 1/4] fix(loadpoint): one phase choice for the fuse budget and the command The loadpoint decided the phase mode twice per tick. The fuse budget passed an empty surplus dwell and treated a saved battery-SoC unlock as active; the command used the dwell and the armed state. When they differed, the budget counted the last offer as 0 and measured new room on one phase, every tick. The cap then snapped with the config minimum, so a one-phase-sized cap went out labelled 3p. chooseTick now steps the unlock, picks the surplus steps and resolves the phase mode once per loadpoint, before the budget freezes. The budget and the command read that value. The cap snaps to the smallest legal step on the commanded phases (6 A per phase). phaseModeForFuse and tickFusePhases are removed. Charge now with an unarmed unlock on a 25 A site: 4,830 / 0 / 0 W -> 11,040 W. Surplus at 9 kW with an empty or auto phase mode: 2,300-3,450 W labelled 3p -> 8,970 W on 3p. Charge now on a one-phase site: 4,830 / 0 / 0 W -> 4,830 W. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/ev-one-phase-choice.md | 5 + go/internal/loadpoint/controller.go | 149 +++++++++++------- .../loadpoint/controller_fuse_budget.go | 66 +++----- .../loadpoint/controller_fuse_phase_test.go | 15 +- .../loadpoint/controller_fusephase_test.go | 9 +- .../loadpoint/controller_phase_choice_test.go | 138 ++++++++++++++++ .../loadpoint/controller_surplus_test.go | 26 +-- go/internal/loadpoint/manual_limits.go | 27 +++- go/internal/loadpoint/manual_limits_test.go | 2 +- 9 files changed, 301 insertions(+), 136 deletions(-) create mode 100644 .changeset/ev-one-phase-choice.md create mode 100644 go/internal/loadpoint/controller_phase_choice_test.go diff --git a/.changeset/ev-one-phase-choice.md b/.changeset/ev-one-phase-choice.md new file mode 100644 index 000000000..43b975915 --- /dev/null +++ b/.changeset/ev-one-phase-choice.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Charging no longer stalls or switches off when the phase mode is empty or auto, or when a battery-level PV unlock is saved but not active. The fuse guard now counts room on the phases the charger is told to use, and never offers less than 6 A per phase. diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 472cb253a..0d455f2eb 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -52,7 +52,6 @@ type Controller struct { perPhaseMeterAmps func() (l1, l2, l3 float64, ok bool) // tickFuseCaps belongs to the serial tick, after one shared budget read. tickFuseCaps map[string]float64 - tickFusePhases int tickDirectiveActive bool tickDirectiveOK bool tickDirective Directive @@ -600,11 +599,12 @@ const fusePhaseMarginA = 1.0 // applyFuseClampAndCooldown applies the frozen hard cap. A short recovery // wait is part of that cap; an unknown startup budget never starts a timer. -func (c *Controller) applyFuseClampAndCooldown(now time.Time, lpCfg Config, wantW float64) (float64, string) { +// It snaps to steps that are legal on the phases the command names. +func (c *Controller) applyFuseClampAndCooldown(lpCfg Config, wantW float64, phaseMode string) (float64, string) { if c == nil { return wantW, "" } - lpCfg = c.fuseConfig(now, lpCfg) + lpCfg = c.fuseConfig(lpCfg, phaseMode) if c.fuseEVMax == nil { return wantW, "" } @@ -1220,13 +1220,76 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat if dispatchAllowed { c.tickDirective, c.tickDirectiveOK = c.currentDirective(now) c.tickDirectiveActive = true + defer func() { c.tickFuseCaps = nil; c.tickDirectiveActive = false }() + } + choices := make(map[string]tickChoice, len(observations)) + for _, observed := range observations { + if !observed.sample.ConnectionUnknown { + choices[observed.config.ID] = c.chooseTick(now, observed.config, observed.sample, dispatchAllowed) + } + } + if dispatchAllowed { c.restoreOffers(now, samples) - c.freezeFuseBudget(now, samples) - defer func() { c.tickFuseCaps = nil; c.tickFusePhases = 0; c.tickDirectiveActive = false }() + c.freezeFuseBudget(now, samples, choices) } for _, observed := range observations { - c.tickOne(ctx, now, observed.config, observed.sample, dispatchAllowed, observed.wasPlugged, observed.wasDelivering) + c.tickOne(ctx, now, observed.config, observed.sample, choices[observed.config.ID], dispatchAllowed, observed.wasPlugged, observed.wasDelivering) + } +} + +// tickChoice is one loadpoint's surplus and phase decision for a tick. The +// shared fuse budget and the command read this one value. +type tickChoice struct { + surplusOn bool // live surplus replaces the plan + surplusAdds bool // live surplus may raise a scheduled charge + steps []float64 // surplus steps, picked once + phaseMode string // the command's phase_mode; "" is the driver's 3p +} + +// chooseTick advances the battery-SoC unlock and the surplus phase dwell +// once, before the shared fuse budget freezes. +func (c *Controller) chooseTick(now time.Time, cfg Config, sample EVSample, dispatchAllowed bool) tickChoice { + sched, _ := c.manager.GetSchedule(cfg.ID) + choice := tickChoice{surplusOn: c.surplusActive(cfg, sched), surplusAdds: c.surplusAddsToPlan(cfg, sched)} + if !sample.Connected || !dispatchAllowed { + return choice // No command this tick. + } + if !c.driverCanDispatch(cfg.DriverName) { + // No command, but the charger may hold its last offer on its phases. + choice.phaseMode = c.offerPeaks[cfg.ID].phase + return choice + } + if hold, held := c.GetManualHold(cfg.ID, now); held { + choice.phaseMode = holdPhaseMode(hold, cfg, choice.surplusOn) + } else { + if choice.surplusOn || choice.surplusAdds { + choice.steps = c.pickSurplusSteps(now, cfg) + } + // Easee treats an unset phase_mode as 3p and then ignores a 1Φ step. + // A schedule still overrides the surplus 1Φ lock, or a deadline + // charge stays near 3.7 kW on a cloudy day. + choice.phaseMode = resolvePhaseMode(cfg.PhaseMode, sched.HasTarget(), + c.surplusLockedTo1P(cfg.ID), choice.surplusOn, c.dwellSelectedPhaseMode(cfg.ID)) + } + if c.siteFuse().Phases() == 1 { + choice.phaseMode = "1p" // As applyInstallationLimits sends it. } + return choice +} + +// holdPhaseMode: explicit hold > explicit loadpoint config > surplus default +// ("auto") > driver default. A sticky Start hold on a 1380 W surplus slot +// must not die at the Easee driver's unset → "3p" interpretation. +func holdPhaseMode(hold ManualHold, cfg Config, surplusOn bool) string { + switch { + case hold.PhaseMode != "": + return hold.PhaseMode + case cfg.PhaseMode != "": + return cfg.PhaseMode + case surplusOn: + return "auto" + } + return "" } func (c *Controller) observeTick(now time.Time, cfg Config, sample EVSample, dispatchAllowed bool) { @@ -1305,24 +1368,17 @@ func (c *Controller) standDownBeforeStorage(ctx context.Context, now time.Time, } } -func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, sample EVSample, dispatchAllowed, wasPlugged, wasDelivering bool) { +func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, sample EVSample, choice tickChoice, dispatchAllowed, wasPlugged, wasDelivering bool) { if sample.ConnectionUnknown { delete(c.resumeOffers, lpCfg.ID) return } - // Resolve the schedule once per tick — used for the bat-SoC unlock - // (surplusActive / surplusAddsToPlan) and the phase decision below. - // Zero value when no schedule is set, which makes evalBatSoCArm a - // no-op. - var sched Schedule - if c.manager != nil { - sched, _ = c.manager.GetSchedule(lpCfg.ID) - } // surplusOn: surplus REPLACES the plan (surplus-only semantics). // surplusAdds: surplus is ADDED on top of a scheduled plan. Never // both true. - surplusOn := c.surplusActive(lpCfg, sched) - surplusAdds := c.surplusAddsToPlan(lpCfg, sched) + surplusOn, surplusAdds := choice.surplusOn, choice.surplusAdds + // The fuse budget was frozen for this phase mode. + phaseMode := choice.phaseMode enteringSurplusPaused, _ := c.getSurplusPause(lpCfg.ID) selfWithheld := surplusOn && enteringSurplusPaused // Detect the disconnected→connected edge (state.PluggedIn flips @@ -1400,18 +1456,11 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s // guard a manual hold can never override. (surplusOn still drives // the phase-mode fallback further down.) cmd["power_w"] = holdW - // Phase mode: explicit hold > explicit LP config > surplus - // default ("auto") > driver default. Same surplus-active fallback - // as the non-hold branch so a sticky Start hold on a 1380 W - // surplus slot actually delivers instead of dying at the Easee - // driver's unset → "3p" interpretation. - switch { - case hold.PhaseMode != "": - cmd["phase_mode"] = hold.PhaseMode - case lpCfg.PhaseMode != "": - cmd["phase_mode"] = lpCfg.PhaseMode - case surplusOn: - cmd["phase_mode"] = "auto" + // A hold changed after the budget froze names its own phases; the + // phase check below makes it wait for the next tick's budget. + phaseMode = holdPhaseMode(hold, lpCfg, surplusOn) + if phaseMode != "" { + cmd["phase_mode"] = phaseMode } switch { case hold.PhaseSplitW > 0: @@ -1473,7 +1522,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s if wantW <= 0 { wantW = lpCfg.MaxChargeW } - cmdW = c.computeSurplusCmd(now, lpCfg, wantW, sample.PowerW) + cmdW = c.computeSurplusCmd(now, lpCfg, wantW, choice.steps) if cmdW > 0 { cmdReason = "pv_surplus" } else { @@ -1482,7 +1531,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s } // Spare PV may raise a scheduled charge. It must not lower it. if surplusAdds { - surplusW := c.computeSurplusCmd(now, lpCfg, lpCfg.MaxChargeW, sample.PowerW) + surplusW := c.computeSurplusCmd(now, lpCfg, lpCfg.MaxChargeW, choice.steps) if surplusW > cmdW { cmdW = surplusW cmdReason = "pv_surplus" @@ -1509,16 +1558,6 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s cmdW = 0 } cmd["power_w"] = cmdW - // Easee treats an unset phase_mode as 3p and then ignores a 1Φ step. - // A schedule still overrides the surplus 1Φ lock, or a deadline - // charge stays near 3.7 kW on a cloudy day. - phaseMode := resolvePhaseMode( - lpCfg.PhaseMode, - sched.HasTarget(), - c.surplusLockedTo1P(lpCfg.ID), - surplusOn, - c.dwellSelectedPhaseMode(lpCfg.ID), - ) if phaseMode != "" { cmd["phase_mode"] = phaseMode } @@ -1549,7 +1588,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s cmdReason = "charger_limit" } if wantW, ok := cmd["power_w"].(float64); ok { - phaseMode, _ := cmd["phase_mode"].(string) + phaseMode, _ = cmd["phase_mode"].(string) c.recordFuseDemand(lpCfg.DriverName, wantW, phaseMode) // The reserve directive requires a known physical ceiling. Capture // its intent above, but never turn an unknown cap into permission. @@ -1558,16 +1597,16 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s wantW, cmdReason = 0, "fuse_limit" } } - limitedW, reason := c.applyFuseClampAndCooldown(now, lpCfg, wantW) + limitedW, reason := c.applyFuseClampAndCooldown(lpCfg, wantW, phaseMode) cmd["power_w"] = limitedW if reason != "" { cmdReason = reason } } // A new API choice can change phase after the shared budget was frozen. - // Do not spend a 3p share on one phase; the next tick uses the new choice. - if w, ok := cmd["power_w"].(float64); ok && w > 0 && c.tickFusePhases > 1 { - if mode, _ := cmd["phase_mode"].(string); mode != "" && mode != "3p" { + // Do not spend that budget on other phases; the next tick uses the choice. + if w, ok := cmd["power_w"].(float64); ok && w > 0 && c.tickFuseCaps != nil { + if mode, _ := cmd["phase_mode"].(string); fusePhase(mode) != fusePhase(choice.phaseMode) { cmd["power_w"], cmdReason = float64(0), "fuse_limit" } } @@ -1843,9 +1882,12 @@ func (c *Controller) shouldKickWallboxForResume(now time.Time, lpID string, surp // decides pause and resume. The magnitude tracks the instant value, or // a falling cloud imports before the average catches up. 0 pauses. // +// steps comes from pickSurplusSteps, called once per tick before the +// shared fuse budget freezes. +// // TODO: siteSurplusForEVW is one site budget. Two surplus loadpoints // each take all of it and can import. -func (c *Controller) computeSurplusCmd(now time.Time, lpCfg Config, wantW, currentEvW float64) float64 { +func (c *Controller) computeSurplusCmd(now time.Time, lpCfg Config, wantW float64, steps []float64) float64 { if c == nil { return wantW } @@ -1872,21 +1914,16 @@ func (c *Controller) computeSurplusCmd(now time.Time, lpCfg Config, wantW, curre // `−pvW − loadW`, i.e. PV-magnitude minus house load — invariant // under whatever the home battery is currently doing with the // surplus. The EV's own draw is part of that closure already, so - // we don't add currentEvW here. + // we don't add it here. instant := surplusW if instant < 0 { instant = 0 } avg := c.recordSurplus(lpCfg.ID, instant) - // Pick the step set: 3Φ-only by default, but fall back to all - // allowed steps (which lets the driver hand the wallbox a 1Φ- - // eligible amperage) when the forecast says we won't see enough - // surplus to sustain 3Φ for the rest of the day. The lock is - // sticky: once we've gone 1Φ for the session we stay 1Φ to - // avoid cycling the contactor across the phase-mode boundary - // each time clouds shift. - steps := c.pickSurplusSteps(now, lpCfg) + if steps == nil { + steps = c.pickSurplusSteps(now, lpCfg) // A hold ended after chooseTick. + } minStep := smallestNonZero(steps) // Compatibility variables for the rest of the function — the diff --git a/go/internal/loadpoint/controller_fuse_budget.go b/go/internal/loadpoint/controller_fuse_budget.go index 93ef1185e..42fbf12f7 100644 --- a/go/internal/loadpoint/controller_fuse_budget.go +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -8,6 +8,7 @@ import ( type fuseRequest struct { cfg Config + phase string deadline time.Time wantW float64 baseW float64 @@ -17,9 +18,9 @@ type fuseRequest struct { // freezeFuseBudget divides one cap for the whole tick, before any command. // A later callback or goal update cannot give a second car the first car's // already offered share. Metered reductions earn new room only next tick. -func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample) { +// Each car is budgeted on the phases its command will name. +func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample, choices map[string]tickChoice) { c.tickFuseCaps = nil - c.tickFusePhases = 0 if c.fuseEVMax == nil { clear(c.fuseResumeTicks) return @@ -39,11 +40,12 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample sample := samples[cfg.DriverName] st, _ := c.manager.State(cfg.ID) st.PluggedIn = sample.Connected && !sample.ConnectionUnknown - request := fuseRequest{cfg: c.fuseConfig(now, cfg), deadline: st.TargetTime} + phase := choices[cfg.ID].phaseMode + request := fuseRequest{cfg: c.fuseConfig(cfg, phase), phase: fusePhase(phase), deadline: st.TargetTime} // The meter may still show a car's highest recent offer on the same // phases. Up to that is not an increase; the car's own readings, // late or lone, never count. - if peak := c.offerPeaks[cfg.ID]; st.PluggedIn && peak.phase == c.phaseModeForFuse(now, request.cfg) { + if peak := c.offerPeaks[cfg.ID]; st.PluggedIn && peak.phase == request.phase { request.baseW = peak.w } baseTotal += request.baseW @@ -83,7 +85,7 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample }) c.tickFuseCaps = make(map[string]float64, len(requests)) remaining, increase := total, math.Max(0, total-baseTotal) - if phaseIncrease, known := c.sharedPhaseIncreaseW(now, requests); known { + if phaseIncrease, known := c.sharedPhaseIncreaseW(requests); known { increase = math.Max(0, math.Min(increase, phaseIncrease)) } for _, request := range requests { @@ -107,10 +109,8 @@ type offerPeak struct { // recordOfferPeak keeps the highest offer the meter may still show. A new // phase mode starts over: the same watts on other phases are other currents. func (c *Controller) recordOfferPeak(now time.Time, id string, w float64, cmd map[string]any) string { - phase, _ := cmd["phase_mode"].(string) - if phase == "" { - phase = "3p" // The driver's locked default, as in phaseModeForFuse. - } + mode, _ := cmd["phase_mode"].(string) + phase := fusePhase(mode) if c.offerPeaks == nil { c.offerPeaks = map[string]offerPeak{} } @@ -120,10 +120,18 @@ func (c *Controller) recordOfferPeak(now time.Time, id string, w float64, cmd ma return phase } -func (c *Controller) fuseConfig(now time.Time, cfg Config) Config { - if hold, held := c.GetManualHold(cfg.ID, now); held { - cfg.MinChargeW = manualMinChargeW(cfg, hold, c.siteFuse()) +// fusePhase names the phases a command uses. Empty is the driver's locked 3p. +func fusePhase(mode string) string { + if mode == "" { + return "3p" } + return mode +} + +// fuseConfig snaps a fuse cap to the smallest legal step on the phases the +// command names. A 3p label on a 1p-sized offer is below 6 A and never charges. +func (c *Controller) fuseConfig(cfg Config, phaseMode string) Config { + cfg.MinChargeW = phaseMinChargeW(cfg, phaseMode, c.siteFuse()) return cfg } @@ -152,7 +160,7 @@ func (c *Controller) recoverFuseCap(id string, wantW, capW float64) float64 { // A new single-phase offer can land on the busiest phase, so an increase // needs measured room there. Cutting an overage is the shared budget's job. -func (c *Controller) sharedPhaseIncreaseW(now time.Time, requests []fuseRequest) (float64, bool) { +func (c *Controller) sharedPhaseIncreaseW(requests []fuseRequest) (float64, bool) { if c.perPhaseMeterAmps == nil { return 0, false } @@ -166,44 +174,14 @@ func (c *Controller) sharedPhaseIncreaseW(now time.Time, requests []fuseRequest) } phaseCount := site.Phases() for _, request := range requests { - if c.phaseModeForFuse(now, request.cfg) != "3p" { + if request.phase != "3p" { phaseCount = 1 } } - c.tickFusePhases = phaseCount worst := math.Max(math.Abs(l1), math.Max(math.Abs(l2), math.Abs(l3))) return (site.MaxAmps - fusePhaseMarginA - worst) * site.Voltage * float64(phaseCount), true } -// Empty is the driver's locked-3p default unless the command branch selects -// auto or the surplus day lock selects 1p. Do not advance surplus hysteresis -// here: an enabled unlock may arm later in this tick, so retain its 1p bound. -func (c *Controller) phaseModeForFuse(now time.Time, cfg Config) string { - sched, _ := c.manager.GetSchedule(cfg.ID) - surplus := cfg.SurplusOnly - if !sched.HasTarget() && sched.SurplusUnlockBatSoC > 0 && c.batSoC != nil && - !(c.tickDirectiveActive && c.tickDirectiveOK && c.tickDirective.LoadpointFallback[cfg.ID]) { - surplus = true - } - mode := cfg.PhaseMode - if hold, held := c.GetManualHold(cfg.ID, now); held { - if hold.PhaseMode != "" { - mode = hold.PhaseMode - } - if mode == "" && surplus { - mode = "auto" - } - } else { - // A surplus dwell can change while the offer is computed. Only a - // fixed 3p mode earns three phases of new headroom in this guard. - mode = resolvePhaseMode(mode, sched.HasTarget(), c.surplusLockedTo1P(cfg.ID), surplus, "") - } - if mode == "" { - return "3p" - } - return mode -} - func (c *Controller) currentDirective(now time.Time) (Directive, bool) { if c.tickDirectiveActive { return c.tickDirective, c.tickDirectiveOK diff --git a/go/internal/loadpoint/controller_fuse_phase_test.go b/go/internal/loadpoint/controller_fuse_phase_test.go index 1df0ce47a..787b00bae 100644 --- a/go/internal/loadpoint/controller_fuse_phase_test.go +++ b/go/internal/loadpoint/controller_fuse_phase_test.go @@ -98,10 +98,11 @@ func TestManualPhaseChangeAfterSharedBudgetWaitsForNextTick(t *testing.T) { name, beforeMode, afterMode string beforeW, afterW, worstA, commandW float64 }{ - {"3p_to_1p", "3p", "1p", 4140, 1380, 20, 0}, - {"3p_to_auto", "3p", "auto", 4140, 1380, 20, 0}, - {"new_stop", "3p", "3p", 4140, 0, 20, 0}, - {"1p_to_3p", "1p", "3p", 1380, 4140, 17, 1380}, + {"3p_to_1p", "3p", "1p", 4140, 1380, 17, 0}, + {"3p_to_auto", "3p", "auto", 4140, 1380, 17, 0}, + {"new_stop", "3p", "3p", 4140, 0, 17, 0}, + // 1,380 W on three phases is 2 A each: no legal 3p step. + {"1p_to_3p", "1p", "3p", 1380, 4140, 17, 0}, } { t.Run(tc.name, func(t *testing.T) { now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) @@ -114,9 +115,8 @@ func TestManualPhaseChangeAfterSharedBudgetWaitsForNextTick(t *testing.T) { sender := &fakeSender{} c := newTestController(t, []Config{a, b}, d, samples, sender) c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - worstA := tc.worstA - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return worstA, worstA, worstA, true }) - c.SetFuseEVMax(func() (float64, bool) { return 2070, true }) + c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return tc.worstA, tc.worstA, tc.worstA, true }) + c.SetFuseEVMax(func() (float64, bool) { return 4140, true }) c.SetManualHold(a.ID, ManualHold{PhaseMode: "3p", Persistent: true}) c.SetManualHold(b.ID, ManualHold{PowerW: tc.beforeW, PhaseMode: tc.beforeMode, Persistent: true}) c.send = func(ctx context.Context, driver string, payload []byte) error { @@ -137,7 +137,6 @@ func TestManualPhaseChangeAfterSharedBudgetWaitsForNextTick(t *testing.T) { } } if tc.beforeMode == "3p" && tc.afterW > 0 { - worstA = 17 c.Tick(context.Background(), now.Add(5*time.Second)) command, known = lastSetCurrent(sender.calls) if !known || command.power != 1380 || command.phaseMode != tc.afterMode { diff --git a/go/internal/loadpoint/controller_fusephase_test.go b/go/internal/loadpoint/controller_fusephase_test.go index b20e34e7d..d342b5560 100644 --- a/go/internal/loadpoint/controller_fusephase_test.go +++ b/go/internal/loadpoint/controller_fusephase_test.go @@ -1,9 +1,6 @@ package loadpoint -import ( - "testing" - "time" -) +import "testing" // The phase increase gate must read hot-reloaded fuse params, not the // startup-only c.site: a worst phase with room at fuse=16 has none at 11. @@ -11,11 +8,11 @@ func TestSiteFuseHotReloadAppliesToPhaseIncreaseGate(t *testing.T) { c := &Controller{} c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 13, 5, 5, true }) c.SetSiteFuse(SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) - if got, known := c.sharedPhaseIncreaseW(time.Now(), nil); !known || got != 1380 { + if got, known := c.sharedPhaseIncreaseW(nil); !known || got != 1380 { t.Fatalf("fuse 16, worst 13 A: increase=%v known=%v, want 1380 W", got, known) } c.SetSiteFuse(SiteFuse{MaxAmps: 11, Voltage: 230, PhaseCnt: 3}) - if got, known := c.sharedPhaseIncreaseW(time.Now(), nil); !known || got >= 0 { + if got, known := c.sharedPhaseIncreaseW(nil); !known || got >= 0 { t.Fatalf("hot-reloaded fuse 11, worst 13 A: increase=%v known=%v, want none", got, known) } } diff --git a/go/internal/loadpoint/controller_phase_choice_test.go b/go/internal/loadpoint/controller_phase_choice_test.go new file mode 100644 index 000000000..7855930ae --- /dev/null +++ b/go/internal/loadpoint/controller_phase_choice_test.go @@ -0,0 +1,138 @@ +package loadpoint + +import ( + "context" + "testing" + "time" +) + +// threePhaseSteps are Easee's whole-amp offers at 230 V: 6–16 A on one +// phase, then 6–16 A on three. +var threePhaseSteps = []float64{0, 1380, 1610, 1840, 2070, 2300, 2530, 2760, 2990, 3220, 3450, 3680, + 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350, 11040} + +// R2 of the 21 Oct 2026 review: a saved battery-SoC unlock that is not +// armed made the fuse budget expect "auto" while Charge now sent the +// driver's 3p. The last offer counted as 0 and new room as one phase, so +// the charge ran 4,830 / 0 / 0 W with fuse_limit on a 25 A site. +func TestChargeNowHoldsWithUnarmedBatteryUnlock(t *testing.T) { + now := time.Date(2026, 1, 10, 19, 0, 0, 0, time.UTC) + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11040, + AllowedStepsW: []float64{0, 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350, 11040}} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.manager.SetSchedule(cfg.ID, Schedule{SurplusUnlockBatSoC: 0.9}) + c.SetBatSoCProvider(func() (float64, bool) { return 0.5, true }) + c.SetSiteSurplusForEV(func() (float64, bool) { return -2000, true }) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + c.SetFuseEVMax(func() (float64, bool) { return 11040, true }) + evW := 0.0 // The meter shows 3 A of house load plus the car's last offer. + c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { a := 3 + evW/690; return a, a, a, true }) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 11040, Persistent: true, StartedAt: now, UpdatedAt: now}) + for tick := 0; tick < 20; tick++ { + c.Tick(context.Background(), now) + command, ok := lastSetCurrent(sender.calls) + st, _ := c.manager.State(cfg.ID) + if !ok || command.power < cfg.MaxChargeW-690 || st.CommandedReason == "fuse_limit" || st.CommandedReason == "fuse_cooldown" { + t.Fatalf("tick %d: offer %+v, reason %q; want about %.0f W", tick, command, st.CommandedReason, cfg.MaxChargeW) + } + if c.IsBatSoCArmed(cfg.ID) { + t.Fatal("the unlock armed at 50% SoC") + } + evW = command.power + samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true, PowerW: evW} + now = now.Add(5 * time.Second) + } +} + +// On a one-phase site the command goes out as 1p. The budget expected the +// empty mode's 3p, so the last offer counted as 0 and Charge now ran +// 4,830 / 0 / 0 W. +func TestChargeNowHoldsOnOnePhaseSite(t *testing.T) { + now := time.Date(2026, 1, 10, 19, 0, 0, 0, time.UTC) + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 1380, MaxChargeW: 7360, + AllowedStepsW: []float64{0, 1380, 2300, 3220, 4140, 4830, 5520, 6440, 7360}} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 1}) + c.SetFuseEVMax(func() (float64, bool) { return 7360, true }) + evW := 0.0 // 3 A of house load plus the car's last offer. + c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 3 + evW/230, 0, 0, true }) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4830, Persistent: true, StartedAt: now, UpdatedAt: now}) + for tick := 0; tick < 20; tick++ { + c.Tick(context.Background(), now) + command, ok := lastSetCurrent(sender.calls) + if st, _ := c.manager.State(cfg.ID); !ok || command.power != 4830 || command.phaseMode != "1p" { + t.Fatalf("tick %d: offer %+v, reason %q; want 4830 W on 1p", tick, command, st.CommandedReason) + } + evW = command.power + samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true, PowerW: evW} + now = now.Add(5 * time.Second) + } +} + +// R1 of the 21 Oct 2026 review: with an unset or auto phase mode the +// surplus dwell sent 3p while the fuse budget measured room on one phase. +// 9 kW of surplus gave 2,300–3,450 W labelled 3p, under 6 A, so the car +// did not charge. +func TestSurplusOnlyAutoPhaseOffersLegalThreePhaseSteps(t *testing.T) { + for _, phaseMode := range []string{"", "auto"} { + t.Run("mode_"+phaseMode, func(t *testing.T) { + now := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + cfg := surplusTestConfig() + cfg.DriverName = "easee" + cfg.PhaseMode = phaseMode + cfg.AllowedStepsW = threePhaseSteps + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + const surplusW = 9000 + c.SetSiteSurplusForEV(func() (float64, bool) { return surplusW, true }) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + c.SetFuseEVMax(func() (float64, bool) { return 11040, true }) + evW := 0.0 // PV export on every phase, less the car's draw. + c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { + a := (evW - surplusW) / 690 + return a, a, a, true + }) + for tick := 0; tick < 20; tick++ { + c.Tick(context.Background(), now) + command, ok := lastSetCurrent(sender.calls) + st, _ := c.manager.State(cfg.ID) + if !ok || command.phaseMode != "3p" || command.power/(3*230) < 6 || command.power > surplusW { + t.Fatalf("tick %d: offer %+v, reason %q; want a 3p step of at least 6 A within the surplus", tick, command, st.CommandedReason) + } + // The first offer may stop short while the meter still shows + // the full export; from then on it follows the surplus. + if tick > 0 && command.power < surplusW-690 { + t.Fatalf("tick %d: offer %+v, reason %q; want within 1 A of %d W", tick, command, st.CommandedReason, surplusW) + } + evW = command.power + samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true, PowerW: evW} + now = now.Add(5 * time.Second) + } + }) + } +} + +// A fuse cap snaps to steps the commanded phases can carry. Four amps of +// room per phase is 2,760 W, but on three phases that is under 6 A. +func TestThreePhaseFuseCapNeverSnapsBelowSixAmps(t *testing.T) { + now := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC) + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 1380, MaxChargeW: 11040, + AllowedStepsW: threePhaseSteps, PhaseMode: "3p"} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + c.SetFuseEVMax(func() (float64, bool) { return 20000, true }) + c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 20, 20, 20, true }) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 11040, Persistent: true}) + c.Tick(context.Background(), now) + command, ok := lastSetCurrent(sender.calls) + if st, _ := c.manager.State(cfg.ID); !ok || command.power != 0 || st.CommandedReason != "fuse_limit" { + t.Fatalf("offer %+v, reason %q; want 0 W fuse_limit", command, st.CommandedReason) + } +} diff --git a/go/internal/loadpoint/controller_surplus_test.go b/go/internal/loadpoint/controller_surplus_test.go index 39e2223b2..cdaf6f303 100644 --- a/go/internal/loadpoint/controller_surplus_test.go +++ b/go/internal/loadpoint/controller_surplus_test.go @@ -49,7 +49,7 @@ func TestSurplusCmd_PauseResumeHysteresis(t *testing.T) { surplus = 3000 var got float64 for i := 0; i < 4; i++ { - got = c.computeSurplusCmd(now, cfg, 6900, 0) + got = c.computeSurplusCmd(now, cfg, 6900, c.pickSurplusSteps(now, cfg)) } if got != 0 { t.Fatalf("after 4 below-min ticks expected paused (got=%v)", got) @@ -64,7 +64,7 @@ func TestSurplusCmd_PauseResumeHysteresis(t *testing.T) { now = now.Add(60 * time.Second) // past surplusMinPauseHold (35s) surplus = 4140 // exactly minStep3, no margin for i := 0; i < 4; i++ { - got = c.computeSurplusCmd(now, cfg, 6900, 0) + got = c.computeSurplusCmd(now, cfg, 6900, c.pickSurplusSteps(now, cfg)) now = now.Add(5 * time.Second) } if got != 0 { @@ -77,7 +77,7 @@ func TestSurplusCmd_PauseResumeHysteresis(t *testing.T) { surplus = 5000 resumed := false for i := 0; i < 4; i++ { - got = c.computeSurplusCmd(now, cfg, 6900, 0) + got = c.computeSurplusCmd(now, cfg, 6900, c.pickSurplusSteps(now, cfg)) now = now.Add(5 * time.Second) if got > 0 { resumed = true @@ -110,7 +110,7 @@ func TestSurplusCmd_UpStepGatedOnAverage(t *testing.T) { c.SetSiteSurplusForEV(func() (float64, bool) { return surplus, true }) step := func(s float64) float64 { surplus = s - got := c.computeSurplusCmd(now, cfg, 11040, 0) + got := c.computeSurplusCmd(now, cfg, 11040, c.pickSurplusSteps(now, cfg)) now = now.Add(5 * time.Second) return got } @@ -168,7 +168,7 @@ func TestSurplusCmd_MinPauseHold(t *testing.T) { // Force pause via 4 below-min ticks. surplus = 1000 for i := 0; i < 4; i++ { - c.computeSurplusCmd(now, cfg, 6900, 0) + c.computeSurplusCmd(now, cfg, 6900, c.pickSurplusSteps(now, cfg)) } if !c.surplusPausedFor(cfg.ID) { t.Fatalf("setup: expected paused") @@ -179,7 +179,7 @@ func TestSurplusCmd_MinPauseHold(t *testing.T) { now = now.Add(10 * time.Second) surplus = 9000 for i := 0; i < 4; i++ { - got := c.computeSurplusCmd(now, cfg, 6900, 0) + got := c.computeSurplusCmd(now, cfg, 6900, c.pickSurplusSteps(now, cfg)) if got != 0 { t.Fatalf("within %v of pause edge expected still paused, got=%v", surplusMinPauseHold, got) @@ -191,7 +191,7 @@ func TestSurplusCmd_MinPauseHold(t *testing.T) { now = now.Add(35 * time.Second) resumed := false for i := 0; i < 4; i++ { - got := c.computeSurplusCmd(now, cfg, 6900, 0) + got := c.computeSurplusCmd(now, cfg, 6900, c.pickSurplusSteps(now, cfg)) now = now.Add(5 * time.Second) if got > 0 { resumed = true @@ -222,9 +222,9 @@ func TestSurplusCmd_StepSnap(t *testing.T) { surplus = 8000 for i := 0; i < 4; i++ { // prime the rolling window so the pause hysteresis stays open - c.computeSurplusCmd(now, cfg, 6900, 0) + c.computeSurplusCmd(now, cfg, 6900, c.pickSurplusSteps(now, cfg)) } - if got := c.computeSurplusCmd(now, cfg, 6900, 0); got != 6900 { + if got := c.computeSurplusCmd(now, cfg, 6900, c.pickSurplusSteps(now, cfg)); got != 6900 { t.Fatalf("with 8000 W surplus + 6900 W wantW expected snap to 6900, got=%v", got) } @@ -234,9 +234,9 @@ func TestSurplusCmd_StepSnap(t *testing.T) { // higher would leak grid import. surplus = 5500 for i := 0; i < 4; i++ { - c.computeSurplusCmd(now, cfg, 6900, 0) + c.computeSurplusCmd(now, cfg, 6900, c.pickSurplusSteps(now, cfg)) } - if got := c.computeSurplusCmd(now, cfg, 6900, 0); got != 4140 { + if got := c.computeSurplusCmd(now, cfg, 6900, c.pickSurplusSteps(now, cfg)); got != 4140 { t.Fatalf("with 5500 W surplus expected snap down to 4140, got=%v", got) } } @@ -249,7 +249,7 @@ func TestSurplusCmd_NoReader_ReturnsWantW(t *testing.T) { c := NewController(NewManager(), nil, nil, nil) cfg := surplusTestConfig() now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC) - if got := c.computeSurplusCmd(now, cfg, 4140, 0); got != 4140 { + if got := c.computeSurplusCmd(now, cfg, 4140, c.pickSurplusSteps(now, cfg)); got != 4140 { t.Fatalf("no-reader fallback should pass wantW through, got=%v", got) } } @@ -263,7 +263,7 @@ func TestSurplusCmd_StaleReader_Returns0(t *testing.T) { cfg := surplusTestConfig() c.SetSiteSurplusForEV(func() (float64, bool) { return 0, false }) now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC) - if got := c.computeSurplusCmd(now, cfg, 4140, 0); got != 0 { + if got := c.computeSurplusCmd(now, cfg, 4140, c.pickSurplusSteps(now, cfg)); got != 0 { t.Fatalf("stale reader expected pause-to-0, got=%v", got) } } diff --git a/go/internal/loadpoint/manual_limits.go b/go/internal/loadpoint/manual_limits.go index c1392c12a..c75b4be0f 100644 --- a/go/internal/loadpoint/manual_limits.go +++ b/go/internal/loadpoint/manual_limits.go @@ -40,19 +40,30 @@ func clampManualPower(cfg Config, hold ManualHold, site SiteFuse) float64 { } func manualMinChargeW(cfg Config, hold ManualHold, site SiteFuse) float64 { - floor := cfg.MinChargeW mode := hold.PhaseMode if mode == "" { mode = cfg.PhaseMode } - if (mode == "1p" || mode == "auto") && site.Phases() == 3 { - voltage := site.Voltage - if !finite(voltage) || voltage <= 0 { - voltage = 230 - } - floor = math.Max(floor/3, 6*voltage) + if mode == "1p" || mode == "auto" { + return phaseMinChargeW(cfg, mode, site) + } + return cfg.MinChargeW +} + +// phaseMinChargeW is the smallest legal offer on the phases a command names: +// 6 A on one phase for 1p or auto, 6 A on each phase for 3p or empty. +func phaseMinChargeW(cfg Config, mode string, site SiteFuse) float64 { + if site.Phases() != 3 { + return cfg.MinChargeW + } + voltage := site.Voltage + if !finite(voltage) || voltage <= 0 { + voltage = 230 + } + if mode == "1p" || mode == "auto" { + return math.Max(cfg.MinChargeW/3, 6*voltage) } - return floor + return math.Max(cfg.MinChargeW, 18*voltage) } func (c *Controller) applyInstallationLimits(cmd map[string]any) { diff --git a/go/internal/loadpoint/manual_limits_test.go b/go/internal/loadpoint/manual_limits_test.go index 3d7b5c7f2..4449c24ba 100644 --- a/go/internal/loadpoint/manual_limits_test.go +++ b/go/internal/loadpoint/manual_limits_test.go @@ -86,7 +86,7 @@ func TestFusePowerBudgetNeverRoundsUp(t *testing.T) { cfg.AllowedStepsW = tc.steps c := newTestController(t, []Config{cfg}, nil, map[string]EVSample{}, &fakeSender{}) c.SetFuseEVMax(func() (float64, bool) { return tc.cap, true }) - got, _ := c.applyFuseClampAndCooldown(time.Now(), cfg, tc.want) + got, _ := c.applyFuseClampAndCooldown(cfg, tc.want, "") if got != tc.expected || got > tc.cap || got > tc.want { t.Fatalf("want %v cap %v: sent %v, expected %v", tc.want, tc.cap, got, tc.expected) } From 1d36b0b1820c40350bf4a623792368004bca8503 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 11:39:36 +0200 Subject: [PATCH 2/4] fix(loadpoint): the site-meter limit is the only fuse guard for chargers Fredrik's direction (21 Oct 2026): only measured site-meter current counts. The loadpoint kept a second fuse layer on top of control.EVFuseBudget: its own per-phase room from the meter, the last offer per phase mode as a base, and a two-tick wait after a pause. It decided phases apart from the command, which caused R1 and R2. Remove that layer: offer peaks, sharedPhaseIncreaseW, fuseResumeTicks and the fuse_cooldown reason. freezeFuseBudget now splits the control layer's cap between chargers by priority, snapped to legal steps on each command's phases. EVFuseBudget counted phases from the config and relied on the removed layer for runtime auto or 1p offers. main.go now passes FuseConfigs: the configs with the phase mode each command names this tick, so the cap is measured on those phases. Without it, an auto hold at 20 A of house load got 2,070 W and put 29 A on one phase of a 25 A fuse. Tests that only pinned the removed layer are deleted. SetPerPhaseMeterAmps is a no-op until the last test caller, in a file another branch is editing, is gone. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/ev-one-phase-choice.md | 2 +- go/cmd/ftw/main.go | 28 +---- .../control/ev_battery_sequence_test.go | 9 +- .../control/ev_deadline_runtime_test.go | 8 +- .../control/ev_fuse_command_phase_test.go | 115 +++++++++++++++++ go/internal/loadpoint/controller.go | 68 +++------- .../loadpoint/controller_fuse_budget.go | 118 ++++-------------- .../loadpoint/controller_fuse_phase_test.go | 35 +----- .../controller_fuse_recovery_test.go | 107 +--------------- .../loadpoint/controller_fusephase_test.go | 18 --- .../loadpoint/controller_offer_restore.go | 1 - .../loadpoint/controller_phase_choice_test.go | 53 +++++--- .../loadpoint/controller_shared_fuse_test.go | 3 +- go/internal/loadpoint/loadpoint.go | 12 +- go/internal/loadpoint/manual_limits_test.go | 2 +- 15 files changed, 209 insertions(+), 370 deletions(-) create mode 100644 go/internal/control/ev_fuse_command_phase_test.go delete mode 100644 go/internal/loadpoint/controller_fusephase_test.go diff --git a/.changeset/ev-one-phase-choice.md b/.changeset/ev-one-phase-choice.md index 43b975915..f87c2ae00 100644 --- a/.changeset/ev-one-phase-choice.md +++ b/.changeset/ev-one-phase-choice.md @@ -2,4 +2,4 @@ "ftw": patch --- -Charging no longer stalls or switches off when the phase mode is empty or auto, or when a battery-level PV unlock is saved but not active. The fuse guard now counts room on the phases the charger is told to use, and never offers less than 6 A per phase. +Charging no longer stalls or switches off when the phase mode is empty or auto, when a battery-level PV unlock is saved but not active, or on a one-phase site. The site-meter limit is now the only fuse guard for chargers: it measures room on the phases each charger is told to use, and an offer is never below 6 A per phase. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index e57a0316c..4b797fcf6 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2002,7 +2002,8 @@ func main() { lpController.SetFuseEVMax(func() (float64, bool) { ctrlMu.Lock() defer ctrlMu.Unlock() - return control.EVFuseBudget(tel, ctrl, lpMgr.States(), lpMgr.Configs(), ctrl.SiteFuseAmps*ctrl.SiteFuseVoltage*float64(ctrl.SiteFusePhases)) + // 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)) }) // Every lease is preflighted and re-evaluated on each loadpoint tick. // This closure owns site/battery health because those concepts belong @@ -2130,31 +2131,6 @@ func main() { go mpcSvc.ReplanWithReason(context.Background(), "loadpoint_battery_boost_stopped") slog.Info("battery boost stopped — replan requested", "lp", id, "reason", reason) }) - // Wire the live per-phase site-meter current reader. The EV hard - // limit (control.EVFuseBudget) cuts a worst-phase overage; this - // reader lets the loadpoint gate an increase on the phase a - // runtime 1p or auto charger may land on. Reads the same - // meter_lN_a metrics the fuse_over_limit notifier uses. - lpController.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { - cfgMu.RLock() - siteMeter := cfg.SiteMeterDriver() - phaseCount := cfg.Fuse.Phases - watchdogTimeout := time.Duration(cfg.Site.WatchdogTimeoutS) * time.Second - cfgMu.RUnlock() - if siteMeter == "" { - return 0, 0, 0, false - } - if watchdogTimeout <= 0 { - watchdogTimeout = 60 * time.Second - } - amps, available, fresh := sitePhaseCurrentsAt( - tel, siteMeter, phaseCount, watchdogTimeout, time.Now(), - ) - if !available || !fresh { - return 0, 0, 0, false - } - return amps[0], amps[1], amps[2], true - }) // Wire the matched-vehicle reader for auto-wake. When the // loadpoint is commanding power but the matched Tesla // reports `Stopped` / `Disconnected` / `Complete`, the diff --git a/go/internal/control/ev_battery_sequence_test.go b/go/internal/control/ev_battery_sequence_test.go index 01eed478c..ce6abdadb 100644 --- a/go/internal/control/ev_battery_sequence_test.go +++ b/go/internal/control/ev_battery_sequence_test.go @@ -53,8 +53,7 @@ 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.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return sampledPhaseA, sampledPhaseA, sampledPhaseA, true }) - controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(store, st, mgr.States(), mgr.Configs(), 17250) }) + controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(store, st, mgr.States(), controller.FuseConfigs(), 17250) }) 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 { @@ -216,11 +215,9 @@ func newEVBatterySiteAt(t *testing.T, houseW float64, start time.Time) *evBatter }) s.ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) s.ctrl.SetManualHold(config.ID, loadpoint.ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) - s.ctrl.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { - a := s.gridW() / 690 - return a, a, a, true + s.ctrl.SetFuseEVMax(func() (float64, bool) { + return EVFuseBudget(s.store, s.state, s.mgr.States(), s.ctrl.FuseConfigs(), 17250) }) - s.ctrl.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(s.store, s.state, s.mgr.States(), s.mgr.Configs(), 17250) }) return s } diff --git a/go/internal/control/ev_deadline_runtime_test.go b/go/internal/control/ev_deadline_runtime_test.go index ff2950f04..da1c817f6 100644 --- a/go/internal/control/ev_deadline_runtime_test.go +++ b/go/internal/control/ev_deadline_runtime_test.go @@ -75,8 +75,7 @@ func TestJoinedNoBatteryNoPlanGoalContinuity(t *testing.T) { return nil }) controller.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - controller.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return phaseA, phaseA, phaseA, true }) - controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(tel, st, mgr.States(), mgr.Configs(), 17250) }) + controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(tel, st, mgr.States(), controller.FuseConfigs(), 17250) }) controller.TickWithDispatch(ctx, now, true) if len(commands) == 0 || commands[len(commands)-1] <= 0 { t.Fatalf("goal stopped at integration boundary: %v", commands) @@ -172,9 +171,7 @@ func TestJoinedMixedPhaseNoCreditBeforeMeasuredReduction(t *testing.T) { return nil }) controller.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - var sampledWorst float64 - controller.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return sampledWorst, actualB / 690, actualB / 690, true }) - controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(tel, st, mgr.States(), mgr.Configs(), 17250) }) + controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(tel, st, mgr.States(), controller.FuseConfigs(), 17250) }) 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 { @@ -192,7 +189,6 @@ func TestJoinedMixedPhaseNoCreditBeforeMeasuredReduction(t *testing.T) { at := wall.Add(-9*time.Second + time.Duration(step)*1500*time.Millisecond) emit(cfgA.DriverName, telemetry.DerEV, at, actualA, false) emit(cfgB.DriverName, telemetry.DerEV, at, actualB, false) - sampledWorst = actualA/230 + actualB/690 emit("meter", telemetry.DerMeter, at.Add(500*time.Millisecond), actualA+actualB, true) emit(cfgA.DriverName, telemetry.DerEV, at.Add(time.Second), actualA, false) emit(cfgB.DriverName, telemetry.DerEV, at.Add(time.Second), actualB, false) diff --git a/go/internal/control/ev_fuse_command_phase_test.go b/go/internal/control/ev_fuse_command_phase_test.go new file mode 100644 index 000000000..4fc62f143 --- /dev/null +++ b/go/internal/control/ev_fuse_command_phase_test.go @@ -0,0 +1,115 @@ +package control + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// phaseSite runs one loadpoint against EVFuseBudget on a 25 A, 3 × 230 V +// site, wired as main.go wires it. Each tick the meter shows the house plus +// the last offer, spread over the phases the offer used. +type phaseSite struct { + now time.Time + store *telemetry.Store + state *State + mgr *loadpoint.Manager + ctrl *loadpoint.Controller + houseA float64 + offerW float64 + offer1p bool +} + +func newPhaseSite(t *testing.T, cfg loadpoint.Config, houseA float64) *phaseSite { + t.Helper() + s := &phaseSite{now: time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC), store: telemetry.NewStore(), 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 } + s.mgr = loadpoint.NewManager() + s.mgr.Load([]loadpoint.Config{cfg}) + s.ctrl = loadpoint.NewController(s.mgr, + func(time.Time) (loadpoint.Directive, bool) { return loadpoint.Directive{}, false }, + func(string) (loadpoint.EVSample, bool) { + return loadpoint.EVSample{Connected: true, RequestActive: true, PowerW: s.offerW, PowerAt: s.now}, true + }, + func(_ context.Context, _ 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" { + s.offerW = cmd.PowerW + s.offer1p = 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) { + return EVFuseBudget(s.store, s.state, s.mgr.States(), s.ctrl.FuseConfigs(), 25*230*3) + }) + return s +} + +// worstA is the busiest phase: a one-phase offer may land on any of them. +func (s *phaseSite) worstA() float64 { + if s.offer1p { + return s.houseA + s.offerW/230 + } + return s.houseA + s.offerW/690 +} + +func (s *phaseSite) tick() { + s.now = s.now.Add(5 * time.Second) + a := s.worstA() + data, _ := json.Marshal(map[string]any{"control_power_available": true, + "control_power_observed_at": s.now.Format(time.RFC3339Nano), "l1_a": a, "l2_a": a, "l3_a": a}) + s.store.Update("meter", telemetry.DerMeter, a*690, nil, data) + s.store.Get("meter", telemetry.DerMeter).UpdatedAt = s.now + s.store.RecordDriverSuccess("meter") + s.ctrl.Tick(context.Background(), s.now) +} + +// R2 against the real cap: a saved but unarmed battery-SoC unlock must not +// change the phases the cap is measured on. +func TestChargeNowWithUnarmedUnlockHoldsUnderEVFuseBudget(t *testing.T) { + cfg := loadpoint.Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11040, + AllowedStepsW: []float64{0, 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350, 11040}} + s := newPhaseSite(t, cfg, 3) + s.mgr.SetSchedule(cfg.ID, loadpoint.Schedule{SurplusUnlockBatSoC: 0.9}) + s.ctrl.SetBatSoCProvider(func() (float64, bool) { return 0.5, true }) + s.ctrl.SetSiteSurplusForEV(func() (float64, bool) { return -2000, true }) + s.ctrl.SetManualHold(cfg.ID, loadpoint.ManualHold{PowerW: 11040, Persistent: true}) + for tick := 0; tick < 20; tick++ { + s.tick() + st, _ := s.mgr.State(cfg.ID) + if s.offerW != 11040 || st.CommandedReason == "fuse_limit" { + t.Fatalf("tick %d: offer %.0f W, reason %q; want 11040 W", tick, s.offerW, st.CommandedReason) + } + } +} + +// An auto offer below the phase split lands on one phase. At 20 A of house +// load the cap must be measured on that phase, not on the three the config +// leaves unnamed: 2,070 W on one phase would put 29 A on a 25 A fuse. +func TestAutoOfferGetsOnePhaseOfRoomFromEVFuseBudget(t *testing.T) { + cfg := loadpoint.Config{ID: "garage", DriverName: "easee", MinChargeW: 1380, MaxChargeW: 11040, + AllowedStepsW: []float64{0, 1380, 1610, 1840, 2070, 2300, 2530, 2760, 2990, 3220, 3450, 3680, + 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350, 11040}} + s := newPhaseSite(t, cfg, 20) + s.ctrl.SetManualHold(cfg.ID, loadpoint.ManualHold{PowerW: 2760, PhaseMode: "auto", Persistent: true}) + for tick := 0; tick < 12; tick++ { + s.tick() + if s.worstA() > 24 { + t.Fatalf("tick %d: offer %.0f W puts %.1f A on one phase", tick, s.offerW, s.worstA()) + } + } +} diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 0d455f2eb..4c84caaf9 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -47,23 +47,16 @@ type Controller struct { // fuseEVMax is this tick's EV share of the site fuse. nil means no cap. fuseEVMax func() (float64, bool) - // perPhaseMeterAmps returns the live site-meter per-phase currents (L1, - // L2, L3). Wired from telemetry; nil disables the phase increase gate. - perPhaseMeterAmps func() (l1, l2, l3 float64, ok bool) // tickFuseCaps belongs to the serial tick, after one shared budget read. tickFuseCaps map[string]float64 + tickChoices map[string]tickChoice // serial tick tickDirectiveActive bool tickDirectiveOK bool tickDirective Directive - // offerPeaks is each loadpoint's highest recent offer (serial tick). - offerPeaks map[string]offerPeak // savedOffers mirrors each loadpoint's last order kept across restarts. - savedOffers map[string]savedOffer - // fuseResumeTicks is owned by the serial tick. A paused charger needs - // two consecutive dispatch ticks with room for a legal step. - fuseResumeTicks map[string]int - fuseDemandMu sync.RWMutex - fuseDemands []FuseDemand + savedOffers map[string]savedOffer + fuseDemandMu sync.RWMutex + fuseDemands []FuseDemand // siteSurplusForEVW is live PV left after house load and battery soak. // A false reading pauses surplus_only rather than guessing an import. @@ -206,8 +199,6 @@ const nearTermLogCooldown = 10 * time.Minute // Easee contactor and winds up the battery PI. const phaseSwitchMinHold = 30 * time.Minute -const fuseResumeSafeTicks = 2 - // CommandSettle is how long a new current may take to reach the site meter. // Easee Cloud needs about 5-15 s (#1674). Until then the meter may still show // an earlier, higher offer. @@ -523,14 +514,9 @@ func (c *Controller) SetFuseEVMax(f func() (float64, bool)) { c.fuseEVMax = f } -// SetPerPhaseMeterAmps wires the live site-meter per-phase current reader -// used by the per-phase fuse clamp. -func (c *Controller) SetPerPhaseMeterAmps(f func() (l1, l2, l3 float64, ok bool)) { - if c == nil { - return - } - c.perPhaseMeterAmps = 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)) {} // SetSiteSurplusForEV wires a per-tick "PV surplus available to the // EV" reader for the surplus_only clamp. The function returns watts @@ -594,13 +580,9 @@ func (c *Controller) SetPeakRemainingSurplusW(f func() (float64, bool)) { c.peakRemainingSurplusW = f } -// fusePhaseMarginA is the per-phase amp headroom held below the breaker. -const fusePhaseMarginA = 1.0 - -// applyFuseClampAndCooldown applies the frozen hard cap. A short recovery -// wait is part of that cap; an unknown startup budget never starts a timer. -// It snaps to steps that are legal on the phases the command names. -func (c *Controller) applyFuseClampAndCooldown(lpCfg Config, wantW float64, phaseMode string) (float64, string) { +// applyFuseClamp applies this loadpoint's share of the frozen hard cap. It +// snaps to steps that are legal on the phases the command names. +func (c *Controller) applyFuseClamp(lpCfg Config, wantW float64, phaseMode string) (float64, string) { if c == nil { return wantW, "" } @@ -615,9 +597,6 @@ func (c *Controller) applyFuseClampAndCooldown(lpCfg Config, wantW float64, phas if !finite(cap) { cap = 0 } - if c.fuseResumeTicks[lpCfg.ID] > 0 { - return 0, "fuse_cooldown" - } if wantW <= cap { return wantW, "" } @@ -1171,11 +1150,6 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat return } c.clearFuseDemands() - if !dispatchAllowed { - for id := range c.fuseResumeTicks { - c.fuseResumeTicks[id] = 0 - } - } // Stop every affected charger before observing any session. Observation // may restore or sync state.db; a stalled disk must not delay standdown, // including the chargers after the one whose checkpoint is blocked. @@ -1220,7 +1194,7 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat if dispatchAllowed { c.tickDirective, c.tickDirectiveOK = c.currentDirective(now) c.tickDirectiveActive = true - defer func() { c.tickFuseCaps = nil; c.tickDirectiveActive = false }() + defer func() { c.tickFuseCaps = nil; c.tickChoices = nil; c.tickDirectiveActive = false }() } choices := make(map[string]tickChoice, len(observations)) for _, observed := range observations { @@ -1229,8 +1203,9 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat } } if dispatchAllowed { + c.tickChoices = choices c.restoreOffers(now, samples) - c.freezeFuseBudget(now, samples, choices) + c.freezeFuseBudget(now, samples) } for _, observed := range observations { c.tickOne(ctx, now, observed.config, observed.sample, choices[observed.config.ID], dispatchAllowed, observed.wasPlugged, observed.wasDelivering) @@ -1238,7 +1213,7 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat } // tickChoice is one loadpoint's surplus and phase decision for a tick. The -// shared fuse budget and the command read this one value. +// fuse cap is measured for its phases, and the command names them. type tickChoice struct { surplusOn bool // live surplus replaces the plan surplusAdds bool // live surplus may raise a scheduled charge @@ -1251,14 +1226,9 @@ type tickChoice struct { func (c *Controller) chooseTick(now time.Time, cfg Config, sample EVSample, dispatchAllowed bool) tickChoice { sched, _ := c.manager.GetSchedule(cfg.ID) choice := tickChoice{surplusOn: c.surplusActive(cfg, sched), surplusAdds: c.surplusAddsToPlan(cfg, sched)} - if !sample.Connected || !dispatchAllowed { + if !sample.Connected || !dispatchAllowed || !c.driverCanDispatch(cfg.DriverName) { return choice // No command this tick. } - if !c.driverCanDispatch(cfg.DriverName) { - // No command, but the charger may hold its last offer on its phases. - choice.phaseMode = c.offerPeaks[cfg.ID].phase - return choice - } if hold, held := c.GetManualHold(cfg.ID, now); held { choice.phaseMode = holdPhaseMode(hold, cfg, choice.surplusOn) } else { @@ -1377,7 +1347,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s // surplusAdds: surplus is ADDED on top of a scheduled plan. Never // both true. surplusOn, surplusAdds := choice.surplusOn, choice.surplusAdds - // The fuse budget was frozen for this phase mode. + // The fuse cap was measured for this phase mode. phaseMode := choice.phaseMode enteringSurplusPaused, _ := c.getSurplusPause(lpCfg.ID) selfWithheld := surplusOn && enteringSurplusPaused @@ -1597,7 +1567,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s wantW, cmdReason = 0, "fuse_limit" } } - limitedW, reason := c.applyFuseClampAndCooldown(lpCfg, wantW, phaseMode) + limitedW, reason := c.applyFuseClamp(lpCfg, wantW, phaseMode) cmd["power_w"] = limitedW if reason != "" { cmdReason = reason @@ -1622,8 +1592,8 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s if w, ok := cmd["power_w"].(float64); ok { offerW, haveOffer = w, true c.manager.setCommandedForManual(lpCfg.ID, w, cmdReason, manualCommandUpdatedAt) - phase := c.recordOfferPeak(now, lpCfg.ID, w, cmd) - c.saveOffer(now, lpCfg, sample, w, phase, cmdReason) + mode, _ := cmd["phase_mode"].(string) + c.saveOffer(now, lpCfg, sample, w, fusePhase(mode), cmdReason) } payload, err := json.Marshal(cmd) if err != nil { diff --git a/go/internal/loadpoint/controller_fuse_budget.go b/go/internal/loadpoint/controller_fuse_budget.go index 42fbf12f7..4ded0bf07 100644 --- a/go/internal/loadpoint/controller_fuse_budget.go +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -8,26 +8,23 @@ import ( type fuseRequest struct { cfg Config - phase string deadline time.Time wantW float64 - baseW float64 goal bool } -// freezeFuseBudget divides one cap for the whole tick, before any command. -// A later callback or goal update cannot give a second car the first car's -// already offered share. Metered reductions earn new room only next tick. -// Each car is budgeted on the phases its command will name. -func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample, choices map[string]tickChoice) { +// freezeFuseBudget splits the EV fuse cap between cars for the whole tick, +// before any command, in priority order. A later callback or goal update +// cannot give a second car the first car's share. The cap is the only fuse +// limit here: control.EVFuseBudget reads the site meter for the phases that +// FuseConfigs names. +func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample) { c.tickFuseCaps = nil if c.fuseEVMax == nil { - clear(c.fuseResumeTicks) return } total, ok := c.fuseEVMax() if !ok || total < 0 { - clear(c.fuseResumeTicks) return } if !finite(total) { @@ -35,21 +32,11 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample } d, _ := c.currentDirective(now) var requests []fuseRequest - var baseTotal float64 for _, cfg := range c.manager.Configs() { sample := samples[cfg.DriverName] st, _ := c.manager.State(cfg.ID) - st.PluggedIn = sample.Connected && !sample.ConnectionUnknown - phase := choices[cfg.ID].phaseMode - request := fuseRequest{cfg: c.fuseConfig(cfg, phase), phase: fusePhase(phase), deadline: st.TargetTime} - // The meter may still show a car's highest recent offer on the same - // phases. Up to that is not an increase; the car's own readings, - // late or lone, never count. - if peak := c.offerPeaks[cfg.ID]; st.PluggedIn && peak.phase == request.phase { - request.baseW = peak.w - } - baseTotal += request.baseW - if st.PluggedIn && c.driverCanDispatch(cfg.DriverName) { + request := fuseRequest{cfg: c.fuseConfig(cfg, c.tickChoices[cfg.ID].phaseMode), deadline: st.TargetTime} + if sample.Connected && !sample.ConnectionUnknown && c.driverCanDispatch(cfg.DriverName) { if hold, held := c.GetManualHold(cfg.ID, now); held { request.wantW = clampManualPower(cfg, hold, c.siteFuse()) } else { @@ -84,40 +71,26 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample return a.cfg.ID < b.cfg.ID }) c.tickFuseCaps = make(map[string]float64, len(requests)) - remaining, increase := total, math.Max(0, total-baseTotal) - if phaseIncrease, known := c.sharedPhaseIncreaseW(requests); known { - increase = math.Max(0, math.Min(increase, phaseIncrease)) - } + remaining := total for _, request := range requests { - target := floorChargeW(math.Min(request.wantW, remaining), request.cfg.MinChargeW, request.cfg.MaxChargeW, request.cfg.AllowedStepsW) - remaining -= target - // Another car's curtailment is spent only once it has settled. - want := math.Min(target, request.baseW+increase) - cap := floorChargeW(want, request.cfg.MinChargeW, request.cfg.MaxChargeW, request.cfg.AllowedStepsW) - cap = c.recoverFuseCap(request.cfg.ID, request.wantW, cap) + cap := floorChargeW(math.Min(request.wantW, remaining), request.cfg.MinChargeW, request.cfg.MaxChargeW, request.cfg.AllowedStepsW) c.tickFuseCaps[request.cfg.ID] = cap - increase -= math.Max(0, cap-request.baseW) + remaining -= cap } } -type offerPeak struct { - w float64 - phase string - at time.Time -} - -// recordOfferPeak keeps the highest offer the meter may still show. A new -// phase mode starts over: the same watts on other phases are other currents. -func (c *Controller) recordOfferPeak(now time.Time, id string, w float64, cmd map[string]any) string { - mode, _ := cmd["phase_mode"].(string) - phase := fusePhase(mode) - if c.offerPeaks == nil { - c.offerPeaks = map[string]offerPeak{} - } - if peak := c.offerPeaks[id]; peak.phase != phase || w >= peak.w || now.Sub(peak.at) >= CommandSettle { - c.offerPeaks[id] = offerPeak{w: w, phase: phase, at: now} +// FuseConfigs returns the loadpoints with the phase mode each command names +// this tick. control.EVFuseBudget measures room on those phases, so an auto +// or 1p offer never gets three phases of room. Outside a tick, and for a +// loadpoint that gets no command, the configured mode stands. +func (c *Controller) FuseConfigs() []Config { + configs := c.manager.Configs() + for i := range configs { + if mode := c.tickChoices[configs[i].ID].phaseMode; mode != "" { + configs[i].PhaseMode = mode + } } - return phase + return configs } // fusePhase names the phases a command uses. Empty is the driver's locked 3p. @@ -135,53 +108,6 @@ func (c *Controller) fuseConfig(cfg Config, phaseMode string) Config { return cfg } -func (c *Controller) recoverFuseCap(id string, wantW, capW float64) float64 { - if wantW <= 0 { - delete(c.fuseResumeTicks, id) - return capW - } - if capW <= 0 { - if c.fuseResumeTicks == nil { - c.fuseResumeTicks = map[string]int{} - } - c.fuseResumeTicks[id] = 0 - return 0 - } - if ready, paused := c.fuseResumeTicks[id]; paused { - ready++ - if ready < fuseResumeSafeTicks { - c.fuseResumeTicks[id] = ready - return 0 - } - delete(c.fuseResumeTicks, id) - } - return capW -} - -// A new single-phase offer can land on the busiest phase, so an increase -// needs measured room there. Cutting an overage is the shared budget's job. -func (c *Controller) sharedPhaseIncreaseW(requests []fuseRequest) (float64, bool) { - if c.perPhaseMeterAmps == nil { - return 0, false - } - l1, l2, l3, ok := c.perPhaseMeterAmps() - site := c.siteFuse() - if !ok || !finite(site.MaxAmps) || !finite(site.Voltage) || site.MaxAmps <= 0 || site.Voltage <= 0 { - return 0, false - } - if !finite(l1) || !finite(l2) || !finite(l3) { - return 0, true - } - phaseCount := site.Phases() - for _, request := range requests { - if request.phase != "3p" { - phaseCount = 1 - } - } - worst := math.Max(math.Abs(l1), math.Max(math.Abs(l2), math.Abs(l3))) - return (site.MaxAmps - fusePhaseMarginA - worst) * site.Voltage * float64(phaseCount), true -} - func (c *Controller) currentDirective(now time.Time) (Directive, bool) { if c.tickDirectiveActive { return c.tickDirective, c.tickDirectiveOK diff --git a/go/internal/loadpoint/controller_fuse_phase_test.go b/go/internal/loadpoint/controller_fuse_phase_test.go index 787b00bae..f9232a78f 100644 --- a/go/internal/loadpoint/controller_fuse_phase_test.go +++ b/go/internal/loadpoint/controller_fuse_phase_test.go @@ -31,8 +31,7 @@ func TestSharedFuseGuardUsesCommandPhase(t *testing.T) { sender := &fakeSender{} c := newTestController(t, []Config{cfg}, d, samples, sender) c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return tc.worstA, tc.worstA, tc.worstA, true }) - c.SetFuseEVMax(func() (float64, bool) { return 20000, true }) + c.SetFuseEVMax(meteredEVCap(c, func() float64 { return tc.worstA })) if tc.schedule { c.manager.SetSchedule(cfg.ID, Schedule{SoC: .8, TimeOfDayMinUTC: 13 * 60, Recurring: true}) } @@ -67,8 +66,7 @@ func TestAutomaticManualReleasePrecedesSharedPhaseBudget(t *testing.T) { sender := &fakeSender{} c := newTestController(t, []Config{cfg}, d, samples, sender) c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 20, 20, 20, true }) - c.SetFuseEVMax(func() (float64, bool) { return 2070, true }) + c.SetFuseEVMax(meteredEVCap(c, func() float64 { return 20 })) c.manager.SetSchedule(cfg.ID, Schedule{SoC: .9, TimeOfDayMinUTC: 13 * 60, Recurring: true}) hold := ManualHold{PowerW: 4140, PhaseMode: "3p", Persistent: true} if release == "soc" { @@ -115,8 +113,7 @@ func TestManualPhaseChangeAfterSharedBudgetWaitsForNextTick(t *testing.T) { sender := &fakeSender{} c := newTestController(t, []Config{a, b}, d, samples, sender) c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return tc.worstA, tc.worstA, tc.worstA, true }) - c.SetFuseEVMax(func() (float64, bool) { return 4140, true }) + c.SetFuseEVMax(meteredEVCap(c, func() float64 { return tc.worstA })) c.SetManualHold(a.ID, ManualHold{PhaseMode: "3p", Persistent: true}) c.SetManualHold(b.ID, ManualHold{PowerW: tc.beforeW, PhaseMode: tc.beforeMode, Persistent: true}) c.send = func(ctx context.Context, driver string, payload []byte) error { @@ -146,29 +143,3 @@ func TestManualPhaseChangeAfterSharedBudgetWaitsForNextTick(t *testing.T) { }) } } - -// #1674, 05:49: the car drew 10,242 W at 15 A when one 0 W reading -// arrived. Keeping the 10,350 W offer is not an increase; the phase gate -// must not treat it as one and stop the charge at 2,449.5 W of room. -func TestLoneZeroReadingKeepsOfferThroughPhaseGate(t *testing.T) { - now := time.Date(2026, 10, 17, 5, 49, 0, 0, time.UTC) - cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 10350, PhaseMode: "3p", - AllowedStepsW: []float64{0, 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350}} - samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true, PowerW: 10242}} - sender := &fakeSender{} - c := newTestController(t, []Config{cfg}, nil, samples, sender) - c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - l1 := 5.0 // The car starts while L1 has room for it. - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return l1, 2.2, 1.0, true }) - c.SetFuseEVMax(func() (float64, bool) { return 16905, true }) - c.SetManualHold(cfg.ID, ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) - c.TickWithDispatch(context.Background(), now, true) - l1 = 20.45 - for i, w := range []float64{10242, 0, 10242} { - samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true, PowerW: w} - c.TickWithDispatch(context.Background(), now.Add(time.Duration(5*(i+1))*time.Second), true) - if command, known := lastSetCurrent(sender.calls); !known || command.power != 10350 { - t.Fatalf("reading %.0f W changed the offer: %+v", w, command) - } - } -} diff --git a/go/internal/loadpoint/controller_fuse_recovery_test.go b/go/internal/loadpoint/controller_fuse_recovery_test.go index 3a9b4fdbb..2cec068c8 100644 --- a/go/internal/loadpoint/controller_fuse_recovery_test.go +++ b/go/internal/loadpoint/controller_fuse_recovery_test.go @@ -2,7 +2,6 @@ package loadpoint import ( "context" - "math" "testing" "time" ) @@ -14,58 +13,6 @@ func fuseRecoveryLoadpoint() Config { return cfg } -func TestFuseBudgetRecoversAfterTwoSafeTicks(t *testing.T) { - for _, manual := range []bool{false, true} { - for _, initial := range []struct { - name string - capW float64 - }{{"below_minimum", 3560}, {"zero", 0}, {"invalid", math.NaN()}} { - name := "scheduled/" + initial.name - if manual { - name = "manual/" + initial.name - } - t.Run(name, func(t *testing.T) { - now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) - cfg := fuseRecoveryLoadpoint() - d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), LoadpointEnergyWh: map[string]float64{cfg.ID: 2587.5}, LoadpointMaxPowerW: map[string]float64{cfg.ID: 10350}} - samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true, PowerW: 10000}} - sender := &fakeSender{} - c := newTestController(t, []Config{cfg}, d, samples, sender) - c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - if manual { - c.SetManualHold(cfg.ID, ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) - } - capW := initial.capW - c.SetFuseEVMax(func() (float64, bool) { return capW, true }) - check := func(at time.Time, allowed bool, want float64) { - t.Helper() - c.TickWithDispatch(context.Background(), at, allowed) - set, ok := lastSetCurrent(sender.calls) - if !ok || set.power != want { - t.Fatalf("at %v: command=%+v, want %.0f W", at.Sub(now), set, want) - } - } - check(now, true, 0) - capW = 13560 - check(now.Add(5*time.Second), true, 0) - check(now.Add(10*time.Second), true, 10350) - // A later loss of headroom must stop immediately and require - // two safe dispatch ticks again, never a five-minute lock. - capW = 3000 - check(now.Add(15*time.Second), true, 0) - capW = 13560 - check(now.Add(20*time.Second), true, 0) - check(now.Add(25*time.Second), false, 0) - check(now.Add(30*time.Second), true, 0) - check(now.Add(35*time.Second), true, 10350) - // A saved Stop remains a Stop after the fuse budget recovers. - c.SetManualHold(cfg.ID, ManualHold{Persistent: true}) - check(now.Add(40*time.Second), true, 0) - }) - } - } -} - func TestFirstConnectionIsObservedBeforeFuseBudget(t *testing.T) { now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) cfg := fuseRecoveryLoadpoint() @@ -90,33 +37,6 @@ func TestFirstConnectionIsObservedBeforeFuseBudget(t *testing.T) { } } -func TestSharedPhaseBudgetDoesNotIntegrateSameOverload(t *testing.T) { - now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) - cfg := fuseRecoveryLoadpoint() - samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true, PowerW: 8280}} - sender := &fakeSender{} - c := newTestController(t, []Config{cfg}, nil, samples, sender) - c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - c.SetManualHold(cfg.ID, ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) - // Our own 12 A offer is established before the overload; a reading - // from the car is never what the guard credits. - worstA, otherA, budgetW := 12.0, 12.0, 8280.0 - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return worstA, otherA, otherA, true }) - c.SetFuseEVMax(func() (float64, bool) { return budgetW, true }) - c.Tick(context.Background(), now.Add(-5*time.Second)) - worstA, otherA, budgetW = 27, 20, 6210 - for tick := 0; tick < 12; tick++ { - c.Tick(context.Background(), now.Add(time.Duration(tick)*5*time.Second)) - set, ok := lastSetCurrent(sender.calls) - // Offered EV=12 A, worst site phase=27 A. A 9 A EV offer - // leaves that phase at 24 A, below the 25 A fuse. Re-reading - // the same sample cannot justify a second accumulated trim. - if !ok || set.power != 6210 { - t.Fatalf("tick %d: command=%+v, want stable legal 6210 W", tick, set) - } - } -} - func TestManualSinglePhaseUsesItsLegalMinimumInSharedFuseBudget(t *testing.T) { for _, phaseMode := range []string{"1p", "auto"} { t.Run(phaseMode, func(t *testing.T) { @@ -138,30 +58,6 @@ func TestManualSinglePhaseUsesItsLegalMinimumInSharedFuseBudget(t *testing.T) { } } -func TestNewConnectionDoesNotInheritFuseRecovery(t *testing.T) { - now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) - cfg := fuseRecoveryLoadpoint() - sender := &fakeSender{} - samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} - c := newTestController(t, []Config{cfg}, nil, samples, sender) - c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - c.SetManualHold(cfg.ID, ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) - capW := 0.0 - c.SetFuseEVMax(func() (float64, bool) { return capW, true }) - c.Tick(context.Background(), now) - capW = 13560 - c.Tick(context.Background(), now.Add(5*time.Second)) - samples[cfg.DriverName] = EVSample{Connected: false} - c.Tick(context.Background(), now.Add(10*time.Second)) - samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true} - c.SetManualHold(cfg.ID, ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) - c.Tick(context.Background(), now.Add(15*time.Second)) - set, ok := lastSetCurrent(sender.calls) - if !ok || set.power != 10350 { - t.Fatalf("new session inherited the previous session's recovery wait: %+v", set) - } -} - func TestManualPhaseOverrideKeepsSixAmpMinimum(t *testing.T) { now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) cfg := fuseRecoveryLoadpoint() @@ -187,11 +83,10 @@ func TestUnspecifiedSurplusPhaseKeepsSinglePhaseFuseBound(t *testing.T) { sender := &fakeSender{} c := newTestController(t, []Config{cfg}, nil, samples, sender) c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 20, 20, 20, true }) // A hold without a phase override inherits surplus mode's auto choice. // At2760 W that may be a12 A offer on the already20 A phase. c.SetManualHold(cfg.ID, ManualHold{PowerW: 2760, Persistent: true}) - c.SetFuseEVMax(func() (float64, bool) { return 2760, true }) + c.SetFuseEVMax(meteredEVCap(c, func() float64 { return 20 })) c.Tick(context.Background(), now) set, ok := lastSetCurrent(sender.calls) if !ok || set.power != 0 { diff --git a/go/internal/loadpoint/controller_fusephase_test.go b/go/internal/loadpoint/controller_fusephase_test.go deleted file mode 100644 index d342b5560..000000000 --- a/go/internal/loadpoint/controller_fusephase_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package loadpoint - -import "testing" - -// The phase increase gate must read hot-reloaded fuse params, not the -// startup-only c.site: a worst phase with room at fuse=16 has none at 11. -func TestSiteFuseHotReloadAppliesToPhaseIncreaseGate(t *testing.T) { - c := &Controller{} - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 13, 5, 5, true }) - c.SetSiteFuse(SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) - if got, known := c.sharedPhaseIncreaseW(nil); !known || got != 1380 { - t.Fatalf("fuse 16, worst 13 A: increase=%v known=%v, want 1380 W", got, known) - } - c.SetSiteFuse(SiteFuse{MaxAmps: 11, Voltage: 230, PhaseCnt: 3}) - if got, known := c.sharedPhaseIncreaseW(nil); !known || got >= 0 { - t.Fatalf("hot-reloaded fuse 11, worst 13 A: increase=%v known=%v, want none", got, known) - } -} diff --git a/go/internal/loadpoint/controller_offer_restore.go b/go/internal/loadpoint/controller_offer_restore.go index f44a0de30..994a421e7 100644 --- a/go/internal/loadpoint/controller_offer_restore.go +++ b/go/internal/loadpoint/controller_offer_restore.go @@ -89,7 +89,6 @@ func (c *Controller) restoreOffers(now time.Time, samples map[string]EVSample) { continue } c.manager.setCommandedForManual(cfg.ID, saved.PowerW, saved.Reason, time.Time{}) - c.recordOfferPeak(now, cfg.ID, saved.PowerW, map[string]any{"phase_mode": saved.PhaseMode}) if c.savedOffers == nil { c.savedOffers = map[string]savedOffer{} } diff --git a/go/internal/loadpoint/controller_phase_choice_test.go b/go/internal/loadpoint/controller_phase_choice_test.go index 7855930ae..9b6b4cc47 100644 --- a/go/internal/loadpoint/controller_phase_choice_test.go +++ b/go/internal/loadpoint/controller_phase_choice_test.go @@ -2,6 +2,7 @@ package loadpoint import ( "context" + "math" "testing" "time" ) @@ -11,10 +12,29 @@ import ( var threePhaseSteps = []float64{0, 1380, 1610, 1840, 2070, 2300, 2530, 2760, 2990, 3220, 3450, 3680, 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350, 11040} +// meteredEVCap stands in for control.EVFuseBudget: the plugged cars' last +// offers plus the room the meter shows below the fuse less 1 A, on the +// phases FuseConfigs names for this tick. +func meteredEVCap(c *Controller, worstA func() float64) func() (float64, bool) { + return func() (float64, bool) { + site := c.siteFuse() + phases, offersW := float64(site.Phases()), 0.0 + for _, cfg := range c.FuseConfigs() { + if st, ok := c.manager.State(cfg.ID); ok && st.PluggedIn { + if cfg.PhaseMode != "" && cfg.PhaseMode != "3p" { + phases = 1 + } + offersW += math.Max(0, st.CommandedW) + } + } + return math.Max(0, offersW+(site.MaxAmps-1-worstA())*site.Voltage*phases), true + } +} + // R2 of the 21 Oct 2026 review: a saved battery-SoC unlock that is not -// armed made the fuse budget expect "auto" while Charge now sent the -// driver's 3p. The last offer counted as 0 and new room as one phase, so -// the charge ran 4,830 / 0 / 0 W with fuse_limit on a 25 A site. +// armed made the loadpoint's own fuse layer expect "auto" while Charge now +// sent the driver's 3p. The last offer counted as 0 and new room as one +// phase, so the charge ran 4,830 / 0 / 0 W with fuse_limit on a 25 A site. func TestChargeNowHoldsWithUnarmedBatteryUnlock(t *testing.T) { now := time.Date(2026, 1, 10, 19, 0, 0, 0, time.UTC) cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11040, @@ -26,15 +46,14 @@ func TestChargeNowHoldsWithUnarmedBatteryUnlock(t *testing.T) { c.SetBatSoCProvider(func() (float64, bool) { return 0.5, true }) c.SetSiteSurplusForEV(func() (float64, bool) { return -2000, true }) c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - c.SetFuseEVMax(func() (float64, bool) { return 11040, true }) evW := 0.0 // The meter shows 3 A of house load plus the car's last offer. - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { a := 3 + evW/690; return a, a, a, true }) + c.SetFuseEVMax(meteredEVCap(c, func() float64 { return 3 + evW/690 })) c.SetManualHold(cfg.ID, ManualHold{PowerW: 11040, Persistent: true, StartedAt: now, UpdatedAt: now}) for tick := 0; tick < 20; tick++ { c.Tick(context.Background(), now) command, ok := lastSetCurrent(sender.calls) st, _ := c.manager.State(cfg.ID) - if !ok || command.power < cfg.MaxChargeW-690 || st.CommandedReason == "fuse_limit" || st.CommandedReason == "fuse_cooldown" { + if !ok || command.power < cfg.MaxChargeW-690 || st.CommandedReason == "fuse_limit" { t.Fatalf("tick %d: offer %+v, reason %q; want about %.0f W", tick, command, st.CommandedReason, cfg.MaxChargeW) } if c.IsBatSoCArmed(cfg.ID) { @@ -46,9 +65,9 @@ func TestChargeNowHoldsWithUnarmedBatteryUnlock(t *testing.T) { } } -// On a one-phase site the command goes out as 1p. The budget expected the -// empty mode's 3p, so the last offer counted as 0 and Charge now ran -// 4,830 / 0 / 0 W. +// On a one-phase site the command goes out as 1p. The loadpoint's own fuse +// layer expected the empty mode's 3p, so the last offer counted as 0 and +// Charge now ran 4,830 / 0 / 0 W. func TestChargeNowHoldsOnOnePhaseSite(t *testing.T) { now := time.Date(2026, 1, 10, 19, 0, 0, 0, time.UTC) cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 1380, MaxChargeW: 7360, @@ -57,9 +76,8 @@ func TestChargeNowHoldsOnOnePhaseSite(t *testing.T) { sender := &fakeSender{} c := newTestController(t, []Config{cfg}, nil, samples, sender) c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 1}) - c.SetFuseEVMax(func() (float64, bool) { return 7360, true }) evW := 0.0 // 3 A of house load plus the car's last offer. - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 3 + evW/230, 0, 0, true }) + c.SetFuseEVMax(meteredEVCap(c, func() float64 { return 3 + evW/230 })) c.SetManualHold(cfg.ID, ManualHold{PowerW: 4830, Persistent: true, StartedAt: now, UpdatedAt: now}) for tick := 0; tick < 20; tick++ { c.Tick(context.Background(), now) @@ -74,9 +92,9 @@ func TestChargeNowHoldsOnOnePhaseSite(t *testing.T) { } // R1 of the 21 Oct 2026 review: with an unset or auto phase mode the -// surplus dwell sent 3p while the fuse budget measured room on one phase. +// surplus dwell sent 3p while the fuse room was measured on one phase. // 9 kW of surplus gave 2,300–3,450 W labelled 3p, under 6 A, so the car -// did not charge. +// did not charge. The cap must be measured for the dwell's 3p. func TestSurplusOnlyAutoPhaseOffersLegalThreePhaseSteps(t *testing.T) { for _, phaseMode := range []string{"", "auto"} { t.Run("mode_"+phaseMode, func(t *testing.T) { @@ -91,12 +109,8 @@ func TestSurplusOnlyAutoPhaseOffersLegalThreePhaseSteps(t *testing.T) { const surplusW = 9000 c.SetSiteSurplusForEV(func() (float64, bool) { return surplusW, true }) c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - c.SetFuseEVMax(func() (float64, bool) { return 11040, true }) evW := 0.0 // PV export on every phase, less the car's draw. - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { - a := (evW - surplusW) / 690 - return a, a, a, true - }) + c.SetFuseEVMax(meteredEVCap(c, func() float64 { return math.Abs(evW-surplusW) / 690 })) for tick := 0; tick < 20; tick++ { c.Tick(context.Background(), now) command, ok := lastSetCurrent(sender.calls) @@ -127,8 +141,7 @@ func TestThreePhaseFuseCapNeverSnapsBelowSixAmps(t *testing.T) { sender := &fakeSender{} c := newTestController(t, []Config{cfg}, nil, samples, sender) c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - c.SetFuseEVMax(func() (float64, bool) { return 20000, true }) - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 20, 20, 20, true }) + c.SetFuseEVMax(meteredEVCap(c, func() float64 { return 20 })) c.SetManualHold(cfg.ID, ManualHold{PowerW: 11040, Persistent: true}) c.Tick(context.Background(), now) command, ok := lastSetCurrent(sender.calls) diff --git a/go/internal/loadpoint/controller_shared_fuse_test.go b/go/internal/loadpoint/controller_shared_fuse_test.go index 7117e0179..3fcd495c7 100644 --- a/go/internal/loadpoint/controller_shared_fuse_test.go +++ b/go/internal/loadpoint/controller_shared_fuse_test.go @@ -104,8 +104,7 @@ func TestOnePhaseCarsShareWorstPhaseBudget(t *testing.T) { sender := &fakeSender{} c := newTestController(t, configs, d, map[string]EVSample{"charger-a": {Connected: true, RequestActive: true}, "charger-b": {Connected: true, RequestActive: true}}, sender) c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 1000.0 / 230, 1000.0 / 230, 1000.0 / 230, true }) - c.SetFuseEVMax(func() (float64, bool) { return 14250, true }) + c.SetFuseEVMax(meteredEVCap(c, func() float64 { return 1000.0 / 230 })) c.Tick(context.Background(), now) total := 0.0 commands := map[string]float64{} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index d27993533..05b8f27b2 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -263,9 +263,9 @@ type State struct { // CommandedReason names the dispatch branch that decided CommandedW: // "plan", "no_plan_budget", "pv_surplus", "pv_surplus_pause", - // "fuse_limit", "fuse_cooldown", "site_meter_stale", "manual_hold", - // "wake_kick". Empty until the first dispatch tick. The UI renders - // the specific cause instead of a generic "paused by the box". + // "fuse_limit", "site_meter_stale", "manual_hold", "wake_kick". Empty + // until the first dispatch tick. The UI renders the specific cause + // instead of a generic "paused by the box". CommandedReason string `json:"commanded_reason,omitempty"` // GridDeferred is true while MPC has deferred grid-funded planning @@ -561,9 +561,9 @@ func (m *Manager) SetCommandedW(id string, w float64) { // dispatch branch chose that value — the fact operators were left to // reverse-engineer from amp readings (#1009). Reasons are short stable // tokens: "plan", "no_plan_budget", "pv_surplus", "pv_surplus_pause", -// "fuse_limit", "fuse_cooldown", "site_meter_stale", "manual_hold", -// "wake_kick". Empty keeps whatever was recorded before (used by the -// legacy SetCommandedW wrapper). No-op for an unknown id. +// "fuse_limit", "site_meter_stale", "manual_hold", "wake_kick". Empty keeps +// whatever was recorded before (used by the legacy SetCommandedW wrapper). +// No-op for an unknown id. func (m *Manager) SetCommanded(id string, w float64, reason string) { m.setCommandedForManual(id, w, reason, time.Time{}) } diff --git a/go/internal/loadpoint/manual_limits_test.go b/go/internal/loadpoint/manual_limits_test.go index 4449c24ba..429cdd258 100644 --- a/go/internal/loadpoint/manual_limits_test.go +++ b/go/internal/loadpoint/manual_limits_test.go @@ -86,7 +86,7 @@ func TestFusePowerBudgetNeverRoundsUp(t *testing.T) { cfg.AllowedStepsW = tc.steps c := newTestController(t, []Config{cfg}, nil, map[string]EVSample{}, &fakeSender{}) c.SetFuseEVMax(func() (float64, bool) { return tc.cap, true }) - got, _ := c.applyFuseClampAndCooldown(cfg, tc.want, "") + got, _ := c.applyFuseClamp(cfg, tc.want, "") if got != tc.expected || got > tc.cap || got > tc.want { t.Fatalf("want %v cap %v: sent %v, expected %v", tc.want, tc.cap, got, tc.expected) } From a5a3657b051aa638c91d99319cddebcf26928d09 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 13:20:46 +0200 Subject: [PATCH 3/4] fix(loadpoint): an empty phase mode keeps the charger's own minimum The 6 A-per-phase floor treated an empty phase mode as 3p. A one-phase charger left on empty with a 3,680 W maximum then had a 4,140 W floor and never charged. An empty mode names no phases; the charger decides, so its configured minimum stands. Only an explicit 3p needs 6 A on each phase. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/ev-one-phase-choice.md | 2 +- .../loadpoint/controller_phase_choice_test.go | 26 +++++++++++++++++++ go/internal/loadpoint/manual_limits.go | 10 ++++--- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.changeset/ev-one-phase-choice.md b/.changeset/ev-one-phase-choice.md index f87c2ae00..ce3bedf99 100644 --- a/.changeset/ev-one-phase-choice.md +++ b/.changeset/ev-one-phase-choice.md @@ -2,4 +2,4 @@ "ftw": patch --- -Charging no longer stalls or switches off when the phase mode is empty or auto, when a battery-level PV unlock is saved but not active, or on a one-phase site. The site-meter limit is now the only fuse guard for chargers: it measures room on the phases each charger is told to use, and an offer is never below 6 A per phase. +Charging no longer stalls or switches off when the phase mode is empty or auto, when a battery-level PV unlock is saved but not active, or on a one-phase site. The site-meter limit is now the only fuse guard for chargers: it measures room on the phases each charger is told to use, and a three-phase offer is never below 6 A per phase. diff --git a/go/internal/loadpoint/controller_phase_choice_test.go b/go/internal/loadpoint/controller_phase_choice_test.go index 9b6b4cc47..2c82752e8 100644 --- a/go/internal/loadpoint/controller_phase_choice_test.go +++ b/go/internal/loadpoint/controller_phase_choice_test.go @@ -131,6 +131,32 @@ func TestSurplusOnlyAutoPhaseOffersLegalThreePhaseSteps(t *testing.T) { } } +// A one-phase charger left on an empty phase mode names no phases. Its own +// 6 A minimum stands; a three-phase floor of 4,140 W is above its 3,680 W +// maximum, and it would never charge. +func TestOnePhaseChargerWithEmptyModeCharges(t *testing.T) { + now := time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC) + cfg := Config{ID: "garage", DriverName: "charger", MinChargeW: 1380, MaxChargeW: 3680, + AllowedStepsW: []float64{0, 1380, 1610, 1840, 2070, 2300, 2530, 2760, 2990, 3220, 3450, 3680}} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + evW := 0.0 // 3 A of house load plus the car on one phase. + c.SetFuseEVMax(meteredEVCap(c, func() float64 { return 3 + evW/230 })) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 3680, Persistent: true}) + for tick := 0; tick < 12; tick++ { + c.Tick(context.Background(), now) + command, ok := lastSetCurrent(sender.calls) + if st, _ := c.manager.State(cfg.ID); !ok || command.power != 3680 { + t.Fatalf("tick %d: offer %+v, reason %q; want 3680 W", tick, command, st.CommandedReason) + } + evW = command.power + samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true, PowerW: evW} + now = now.Add(5 * time.Second) + } +} + // A fuse cap snaps to steps the commanded phases can carry. Four amps of // room per phase is 2,760 W, but on three phases that is under 6 A. func TestThreePhaseFuseCapNeverSnapsBelowSixAmps(t *testing.T) { diff --git a/go/internal/loadpoint/manual_limits.go b/go/internal/loadpoint/manual_limits.go index c75b4be0f..bc14f74e7 100644 --- a/go/internal/loadpoint/manual_limits.go +++ b/go/internal/loadpoint/manual_limits.go @@ -51,7 +51,8 @@ func manualMinChargeW(cfg Config, hold ManualHold, site SiteFuse) float64 { } // phaseMinChargeW is the smallest legal offer on the phases a command names: -// 6 A on one phase for 1p or auto, 6 A on each phase for 3p or empty. +// 6 A on one phase for 1p or auto, 6 A on each phase for 3p. An empty mode +// names no phases; the charger decides, and its configured minimum stands. func phaseMinChargeW(cfg Config, mode string, site SiteFuse) float64 { if site.Phases() != 3 { return cfg.MinChargeW @@ -60,10 +61,13 @@ func phaseMinChargeW(cfg Config, mode string, site SiteFuse) float64 { if !finite(voltage) || voltage <= 0 { voltage = 230 } - if mode == "1p" || mode == "auto" { + switch mode { + case "1p", "auto": return math.Max(cfg.MinChargeW/3, 6*voltage) + case "3p": + return math.Max(cfg.MinChargeW, 18*voltage) } - return math.Max(cfg.MinChargeW, 18*voltage) + return cfg.MinChargeW } func (c *Controller) applyInstallationLimits(cmd map[string]any) { From 04d80273213fae3b33831ce7ca37b3380b05d99a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 13:41:04 +0200 Subject: [PATCH 4/4] test(control): the site simulation measures room on the commanded phases The adapter passes FuseConfigs, as main.go does, and drops the no-op per-phase reader. Three known rows now pass and lose their known line: phase mode empty and auto with 9 kW surplus, and Charge now with a saved but unarmed surplus_unlock_bat_soc. The auto row needs FuseConfigs; with the configured "auto" the cap counts one phase. The export rows still fail on control's own unsigned phase current; their known line no longer cites the deleted loadpoint layer. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- go/internal/control/sitesim_test.go | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 15dc828e6..8b8f9a54b 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -88,13 +88,7 @@ func (s *simSite) boot() { s.ctrl.SetDriverOnline(func(d string) bool { h := s.store.DriverHealth(d); return h != nil && h.IsOnline() }) s.ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: s.p.Fuse.RatingA, Voltage: s.p.Voltage, PhaseCnt: 3}) s.ctrl.SetFuseEVMax(func() (float64, bool) { - return EVFuseBudget(s.store, s.state, s.mgr.States(), s.mgr.Configs(), s.fuseMaxW()) - }) - s.ctrl.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { - if !s.meterFresh() { - return 0, 0, 0, false - } - return s.meter.PhaseA[0], s.meter.PhaseA[1], s.meter.PhaseA[2], true + return EVFuseBudget(s.store, s.state, s.mgr.States(), s.ctrl.FuseConfigs(), s.fuseMaxW()) }) s.ctrl.SetSiteSurplusForEV(func() (float64, bool) { if !s.meterFresh() { @@ -488,9 +482,9 @@ func simScenarios() []simScenario { } } return []simScenario{ - {name: "export start", known: "export counted as import, fuse_heat.go:120 and controller_fuse_budget.go:174", run: 5 * time.Minute, + {name: "export start", known: "export counted as import, fuse_heat.go:120", run: 5 * time.Minute, plant: func(p *sitesim.Plant) { p.HouseW, p.PVW = houseA(2, 2, 2), -22*690 }}, - {name: "export start, unsigned phase currents", known: "export counted as import, fuse_heat.go:120 and controller_fuse_budget.go:174", run: 5 * time.Minute, + {name: "export start, unsigned phase currents", known: "export counted as import, fuse_heat.go:120", run: 5 * time.Minute, plant: func(p *sitesim.Plant) { p.HouseW, p.PVW, p.Meter.Unsigned = houseA(2, 2, 2), -22*690, true }}, {name: "charger's own load balancer", known: "a cut comes off an offer the car does not draw, ev_fuse_budget.go:172", run: 20 * time.Minute, plant: func(p *sitesim.Plant) { @@ -531,13 +525,13 @@ func simScenarios() []simScenario { } }}, {name: "phase mode 3p, 9 kW surplus", run: 5 * time.Minute, surplus: true, plant: surplus, lp: surplusPhase("3p")}, - {name: "phase mode empty, 9 kW surplus", known: "empty phase mode gets one phase of room, controller_fuse_budget.go:181", run: 5 * time.Minute, surplus: true, + {name: "phase mode empty, 9 kW surplus", run: 5 * time.Minute, surplus: true, plant: surplus, lp: surplusPhase("")}, - {name: "phase mode auto, 9 kW surplus", known: "auto phase mode gets one phase of room, controller_fuse_budget.go:181", run: 5 * time.Minute, surplus: true, + {name: "phase mode auto, 9 kW surplus", run: 5 * time.Minute, surplus: true, plant: surplus, lp: surplusPhase("auto")}, {name: "9 kW surplus, readback 10 s late", known: "surplus adds back the charger's late readback, surplus_reserve.go:128", run: 5 * time.Minute, surplus: true, plant: func(p *sitesim.Plant) { surplus(p); p.EVSE.ReadbackLag = 10 * time.Second }, lp: surplusPhase("3p")}, - {name: "surplus_unlock_bat_soc saved, Charge now", known: "a saved unlock that is not armed sets the fuse phase mode, controller_fuse_budget.go:184", run: 5 * time.Minute, + {name: "surplus_unlock_bat_soc saved, Charge now", run: 5 * time.Minute, plant: func(p *sitesim.Plant) { p.HouseW = houseA(3, 3, 3) p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 5000, MaxDischargeW: 5000, CapacityWh: 10000, SoC: .5}