diff --git a/.changeset/ev-one-phase-choice.md b/.changeset/ev-one-phase-choice.md new file mode 100644 index 000000000..ce3bedf99 --- /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, 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/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/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} diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 472cb253a..4c84caaf9 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -47,24 +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 - tickFusePhases int + 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. @@ -207,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. @@ -524,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 @@ -595,16 +580,13 @@ 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. -func (c *Controller) applyFuseClampAndCooldown(now time.Time, lpCfg Config, wantW float64) (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, "" } - lpCfg = c.fuseConfig(now, lpCfg) + lpCfg = c.fuseConfig(lpCfg, phaseMode) if c.fuseEVMax == nil { return wantW, "" } @@ -615,9 +597,6 @@ func (c *Controller) applyFuseClampAndCooldown(now time.Time, lpCfg Config, want 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,15 +1194,74 @@ 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.tickChoices = 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.tickChoices = choices c.restoreOffers(now, samples) c.freezeFuseBudget(now, samples) - defer func() { c.tickFuseCaps = nil; c.tickFusePhases = 0; c.tickDirectiveActive = false }() } 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 +// 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 + 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 || !c.driverCanDispatch(cfg.DriverName) { + return choice // No command this tick. + } + 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) { c.observeEnergy(cfg, sample, now) c.manager.observeConnectionProof(cfg.ID, sample.ConnectionGeneration, sample.ConnectionUnknown) @@ -1305,24 +1338,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 cap was measured 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 +1426,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 +1492,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 +1501,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 +1528,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 +1558,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 +1567,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.applyFuseClamp(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" } } @@ -1583,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 { @@ -1843,9 +1852,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 +1884,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..4ded0bf07 100644 --- a/go/internal/loadpoint/controller_fuse_budget.go +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -10,23 +10,21 @@ type fuseRequest struct { cfg Config 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. +// 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 - c.tickFusePhases = 0 if c.fuseEVMax == nil { - clear(c.fuseResumeTicks) return } total, ok := c.fuseEVMax() if !ok || total < 0 { - clear(c.fuseResumeTicks) return } if !finite(total) { @@ -34,20 +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 - request := fuseRequest{cfg: c.fuseConfig(now, cfg), 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) { - 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 { @@ -82,128 +71,43 @@ 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(now, 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) - } -} - -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 { - phase, _ := cmd["phase_mode"].(string) - if phase == "" { - phase = "3p" // The driver's locked default, as in phaseModeForFuse. - } - if c.offerPeaks == nil { - c.offerPeaks = map[string]offerPeak{} + remaining -= cap } - 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} - } - 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()) - } - 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 +// 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 } - delete(c.fuseResumeTicks, id) } - return capW + return configs } -// 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) { - 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 c.phaseModeForFuse(now, request.cfg) != "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, "") - } +// 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 +} + 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..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" { @@ -98,10 +96,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 +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}) - worstA := tc.worstA - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return worstA, worstA, worstA, true }) - c.SetFuseEVMax(func() (float64, bool) { return 2070, 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 { @@ -137,7 +134,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 { @@ -147,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 b20e34e7d..000000000 --- a/go/internal/loadpoint/controller_fusephase_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package loadpoint - -import ( - "testing" - "time" -) - -// 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(time.Now(), 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 { - 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 new file mode 100644 index 000000000..2c82752e8 --- /dev/null +++ b/go/internal/loadpoint/controller_phase_choice_test.go @@ -0,0 +1,177 @@ +package loadpoint + +import ( + "context" + "math" + "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} + +// 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 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, + 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}) + evW := 0.0 // The meter shows 3 A of house load plus the car's last offer. + 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" { + 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 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, + 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}) + evW := 0.0 // 3 A of house load plus the car's last offer. + 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) + 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 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. 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) { + 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}) + evW := 0.0 // PV export on every phase, less the car's draw. + 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) + 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 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) { + 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(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) + 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_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/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/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.go b/go/internal/loadpoint/manual_limits.go index c1392c12a..bc14f74e7 100644 --- a/go/internal/loadpoint/manual_limits.go +++ b/go/internal/loadpoint/manual_limits.go @@ -40,19 +40,34 @@ 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. 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 + } + voltage := site.Voltage + if !finite(voltage) || voltage <= 0 { + voltage = 230 + } + switch mode { + case "1p", "auto": + return math.Max(cfg.MinChargeW/3, 6*voltage) + case "3p": + return math.Max(cfg.MinChargeW, 18*voltage) } - return floor + return cfg.MinChargeW } 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..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(time.Now(), 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) }