From cb55a26b9c025404c8738c2dcdb1935ae1a5b73a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 16:38:59 +0200 Subject: [PATCH 1/3] fix(control): the hourly import ceiling reports import_limit, not fuse_limit Core folded the clock hour's import ceiling into the EV cap and the battery allocator under the fuse's name. A car held back by the hour's budget said fuse_limit, a boost it stopped said "Fuse protection", and the web page and apps blamed the main fuse while the phases were low. EVFuseBudget now records which limit left less room at each site reading, and EVLimitReason names it. The loadpoint reads it once per tick with the cap (SetFuseLimitReason) and labels a capped command import_limit or fuse_limit. The allocator marks FuseSaturatedByImport when the hour's limit is the ceiling it hit, and the boost stops with import_limit_block. fuse_limit now means the main fuse alone; the web page says so and gives the hour's limit its own words. The site simulation's new row is a night with a 6 kW hourly ceiling, a heavy first 50 minutes, a Core restart at 02:09 and phases at 1.5 A near the end of the hour. On master the idle car says fuse_limit; now it says import_limit and starts within 16 s of the new hour. Co-Authored-By: Claude Opus 5.5 --- .changeset/import-limit-reason.md | 5 ++ go/cmd/ftw/main.go | 10 +++- go/internal/api/api_control_feedback.go | 4 +- go/internal/control/control_test.go | 21 ++++++++- go/internal/control/dispatch.go | 14 +++++- go/internal/control/ev_fuse_budget.go | 20 +++++++- go/internal/control/ev_fuse_budget_test.go | 1 + go/internal/control/import_hour_test.go | 23 ++++++++++ go/internal/control/sitesim_test.go | 46 ++++++++++++++++--- go/internal/loadpoint/battery_boost.go | 1 + go/internal/loadpoint/controller.go | 32 +++++++++++-- .../controller_commanded_reason_test.go | 21 +++++++++ .../loadpoint/controller_fuse_budget.go | 3 +- go/internal/loadpoint/loadpoint.go | 8 ++-- go/internal/loadpoint/manual_status.go | 4 +- web/app.js | 10 +++- web/control-feedback.js | 6 ++- web/control-feedback.test.mjs | 8 +++- web/ev-commanded-reason.test.mjs | 9 ++-- web/loadpoint-battery-boost.test.mjs | 2 +- web/loadpoints.js | 1 + 21 files changed, 215 insertions(+), 34 deletions(-) create mode 100644 .changeset/import-limit-reason.md diff --git a/.changeset/import-limit-reason.md b/.changeset/import-limit-reason.md new file mode 100644 index 000000000..bc23be79d --- /dev/null +++ b/.changeset/import-limit-reason.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +When the hourly import limit holds a car back, FTW now says so instead of blaming the main fuse. The charger's reason is `import_limit` ("Limited by the hourly import limit") whenever this hour's import budget leaves less room than the fuse, and a battery boost stopped by that budget says "Hourly import limit stopped boost". `fuse_limit` now means the main fuse alone, and the web page says "Limited by the main fuse". diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 2d1fa4047..18cc4c43d 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2010,13 +2010,18 @@ func main() { defer ctrlMu.Unlock() return control.EVFusePhaseRoomA(ctrl) }) + lpController.SetFuseLimitReason(func() string { + ctrlMu.Lock() + defer ctrlMu.Unlock() + return control.EVLimitReason(ctrl) + }) // Every lease is preflighted and re-evaluated on each loadpoint tick. // This closure owns site/battery health because those concepts belong // to core, not to the protocol-neutral loadpoint package. lpController.SetBatteryBoostSafety(func(id string, lease loadpoint.BatteryBoostLease) loadpoint.BatteryBoostStopReason { ctrlMu.Lock() mode := ctrl.Mode - fuseBlocked := ctrl.FuseSaturated + fuseBlocked, importBlocked := ctrl.FuseSaturated, ctrl.FuseSaturatedByImport _, batteryHeld := ctrl.GetBatteryManualHold(time.Now()) siteMeter := ctrl.SiteMeterDriver ctrlMu.Unlock() @@ -2026,6 +2031,9 @@ func main() { if batteryHeld { return loadpoint.BatteryBoostStoppedBatteryHold } + if fuseBlocked && importBlocked { + return loadpoint.BatteryBoostStoppedImportLimit + } if fuseBlocked { return loadpoint.BatteryBoostStoppedFuseSafety } diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index f1547bd54..ca84538ed 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -339,6 +339,8 @@ func setControlStatus(f *ControlFeedback) { case "battery_full", "battery_nearly_full", "battery_nearly_empty", "core_limit", "fuse_limit", "fuse_cooldown", "charger_limit": status = "limited" + case "import_limit": + status = "limited" case "device_limit", "offered_current_lower": status, severity = "limited", "warning" case "setpoint_changed", "power_below_target", "power_above_target", "power_wrong_direction", @@ -430,7 +432,7 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("limited", "device_limit", "warning") return } - if coreReason == "site_meter_stale" || coreReason == "site_phase_currents_stale" || coreReason == "fuse_cooldown" || coreReason == "fuse_limit" || coreReason == "charger_limit" { + if coreReason == "site_meter_stale" || coreReason == "site_phase_currents_stale" || coreReason == "fuse_cooldown" || coreReason == "fuse_limit" || coreReason == "import_limit" || coreReason == "charger_limit" { set("limited", coreReason, "warning") return } diff --git a/go/internal/control/control_test.go b/go/internal/control/control_test.go index ab4260693..e3de918f2 100644 --- a/go/internal/control/control_test.go +++ b/go/internal/control/control_test.go @@ -1656,8 +1656,8 @@ func TestJointFuseAllocatorScalesBothBatteryAndEV(t *testing.T) { if got < 0 { t.Errorf("TargetW = %.0f W — battery shouldn't be discharging here", got) } - if !st.FuseSaturated { - t.Errorf("FuseSaturated = false; want true after joint scaling") + if !st.FuseSaturated || st.FuseSaturatedByImport { + t.Errorf("FuseSaturated = %v, by import %v; want the fuse after joint scaling", st.FuseSaturated, st.FuseSaturatedByImport) } // EV cap is the post-republish verdict: after applyFuseGuard + // forceFuseDischarge constrain battery further (and may swing it @@ -1676,6 +1676,23 @@ func TestJointFuseAllocatorScalesBothBatteryAndEV(t *testing.T) { } } +// The same site under a 9 kW hourly import ceiling: the hour's limit, not +// the fuse, sets the ceiling the allocator hits, and says so. +func TestJointFuseAllocatorNamesTheHoursImportLimit(t *testing.T) { + now := time.Now() + dir := SlotDirective{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), BatteryEnergyWh: 1250, Strategy: "arbitrage"} + store := seedStore(8200, []struct { + name string + currentW, soc float64 + }{{"ferroamp", 0, 0.5}}) + st := newStateWithEnergyDispatch(dir, "ferroamp") + st.EVChargingW, st.BatteryCoversEV, st.PeakImportCeilingW = 8000, true, 9000 + ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11000) + if !st.FuseSaturated || !st.FuseSaturatedByImport { + t.Fatalf("FuseSaturated = %v, by import %v; want the hour's import limit", st.FuseSaturated, st.FuseSaturatedByImport) + } +} + // Counter-test: when battery wants to discharge, no scaling — discharge // helps the fuse rather than competing with the EV. func TestJointFuseAllocatorIgnoresDischarge(t *testing.T) { diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 28b5ca5a8..eed8315e8 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -551,6 +551,9 @@ type State struct { // loadpoint controller's read of FuseEVMaxW and as the trigger for // an MPC reactive replan from main.go. FuseSaturated bool + // FuseSaturatedByImport says the clock hour's import limit, not the + // fuse, set the ceiling FuseSaturated hit. + FuseSaturatedByImport bool // DriverLimits maps driver name → per-battery charge/discharge cap. // Missing entries and unset fields fall through to the global @@ -2040,7 +2043,7 @@ func ComputeDispatch( // The ceiling gives up the worst phase's imbalance: a charger's own load // balancer cuts the car on that phase while the total still fits. state.FuseEVMaxW = 0 - state.FuseSaturated = false + state.FuseSaturated, state.FuseSaturatedByImport = false, false plannedEVW := scheduledEVPowerW(state) evInMeterW := state.evInMeterW() if fuseMaxW > 0 && math.Max(evInMeterW, plannedEVW) > 0 { @@ -2069,7 +2072,7 @@ func ComputeDispatch( totalCorrection = Bn + B*scale - currentTotal state.FuseEVMaxW = E * scale } - state.FuseSaturated = true + state.FuseSaturated, state.FuseSaturatedByImport = true, state.importLimitSets(ceilingW) } } @@ -3772,6 +3775,13 @@ func (s *State) fuseSafetyMarginW() float64 { return s.SiteFuseSafetyA * s.SiteFuseVoltage * float64(s.SiteFusePhases) } +// importLimitSets reports whether the clock hour's import limit is the +// ceiling ceilingW, rather than the fuse. +func (s *State) importLimitSets(ceilingW float64) bool { + limitW, _, ok := s.importHourLimitsW() + return ok && limitW <= ceilingW +} + // effectiveImportCeilingW returns the binding ceiling for grid import in // watts: the fuse limit minus its safety margin, further capped by the // clock hour's import limit when the operator has opted into a tariff diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 2ffb42a5c..7709e52bc 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -211,6 +211,7 @@ type evMeterLimit struct { overSince time.Time // first reading of a run over the hour's import limit overOffers map[string]float64 // each charger's offer when that run began idleWh float64 // import since roomSince, for the ceiling's start test + byImport bool // the hour's import limit, not the fuse, left less room at readingAt } type evPool struct { @@ -282,6 +283,8 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo lim.phaseRoomA = r.phaseRoomA } step := evPhaseMarginA * s.SiteFuseVoltage * pool.phases + // The label follows whichever limit leaves less room. + lim.byImport = math.Min(r.limitW, r.riseW) < r.fuseW // The fuse cuts on every reading, by its heat budget where the meter // reports phase currents. The hour's budget absorbs a short overage of // less than a step: it cuts once the overage has lasted evSettle, so one @@ -298,6 +301,7 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo } } if cutW < 0 { + lim.byImport = cutW < r.fuseCutW // The meter may still show any offer of the last evSettle. Taking // the overage from the highest cuts each measured watt once. lim.capW, lim.roomSince = math.Min(prev, math.Max(0, peakW+cutW)), time.Time{} @@ -376,6 +380,16 @@ func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first lim.capW = math.Min(lim.capW, ceilingRoomW) } +// EVLimitReason names the limit behind the managed EVs' cap for the +// loadpoints' reason: "import_limit" when the clock hour's import ceiling +// left less room than the fuse at the last site reading, else "fuse_limit". +func EVLimitReason(state *State) string { + if state != nil && state.evLimit.set && state.evLimit.byImport { + return "import_limit" + } + return "fuse_limit" +} + // evMeterRoom is the measured room in EV watts. The fuse's part holds on // every reading. The import ceiling's parts are +Inf while it is off. type evMeterRoom struct { @@ -524,13 +538,15 @@ func qualifiedFuseObservation(r *telemetry.DerReading, now time.Time) (telemetry func publishBatterylessEVFuseBudget(store *telemetry.Store, state *State, fuseMaxW float64) { meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - state.FuseEVMaxW, state.FuseSaturated = 0, false + state.FuseEVMaxW, state.FuseSaturated, state.FuseSaturatedByImport = 0, false, false evInMeterW := state.evInMeterW() demand := math.Max(evInMeterW, scheduledEVPowerW(state)) if meter == nil || demand <= 0 { return } - cap := evHeadroomW(state.phaseImportCeilingW(fuseMaxW, meter), math.Max(meter.RawW, meter.SmoothedW), evInMeterW) + ceilingW := state.phaseImportCeilingW(fuseMaxW, meter) + cap := evHeadroomW(ceilingW, math.Max(meter.RawW, meter.SmoothedW), evInMeterW) state.FuseEVMaxW = math.Min(demand, cap) state.FuseSaturated = demand > cap + state.FuseSaturatedByImport = state.FuseSaturated && state.importLimitSets(ceilingW) } diff --git a/go/internal/control/ev_fuse_budget_test.go b/go/internal/control/ev_fuse_budget_test.go index 1360591d9..f073c3719 100644 --- a/go/internal/control/ev_fuse_budget_test.go +++ b/go/internal/control/ev_fuse_budget_test.go @@ -127,6 +127,7 @@ func (s *evMeterSite) boot() { return EVFuseBudget(s.store, s.state, s.mgr.States(), s.mgr.Configs(), 17250) }) s.ctrl.SetFusePhaseRoomA(func() (float64, bool) { return EVFusePhaseRoomA(s.state) }) + s.ctrl.SetFuseLimitReason(func() string { return EVLimitReason(s.state) }) s.ctrl.SetManualHold(cfg.ID, loadpoint.ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) if s.onBoot != nil { s.onBoot() diff --git a/go/internal/control/import_hour_test.go b/go/internal/control/import_hour_test.go index b91ff11bf..0d793190c 100644 --- a/go/internal/control/import_hour_test.go +++ b/go/internal/control/import_hour_test.go @@ -152,6 +152,29 @@ func TestImportCeilingHouseStepLateInHourStepsCarDown(t *testing.T) { } } +// The car held below its want by the hour's import ceiling says +// import_limit, not fuse_limit: at 23:00 in #1674 the phases sit near 12 A +// on a 25 A fuse. Held by the fuse instead, it says fuse_limit. +func TestImportCeilingSaysImportLimitNotFuse(t *testing.T) { + for _, tc := range []struct { + name string + ceilingW, houseW float64 + want string + }{ + {"hour's ceiling", 7500, 900, "import_limit"}, + {"fuse", 0, 9000, "fuse_limit"}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHourSite(t, tc.ceilingW, tc.houseW, clock(16, 22, 0)) + h.run(70 * time.Minute) + st, _ := h.mgr.State("garage") + if h.evW <= 0 || h.evW >= 10350 || st.CommandedReason != tc.want { + t.Fatalf("car draws %.0f W of 10,350 W, reason %q, want %q", h.evW, st.CommandedReason, tc.want) + } + }) + } +} + // A Core restart mid-hour keeps the hour's import from state.db. A sauna // spent most of the budget; without the record the box would count the // first half-hour at the ceiling and let the car overrun the hour. diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 1f5bb0039..5319360c7 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -46,6 +46,9 @@ type simSite struct { defaulted bool commands []simCommand + hourStart time.Time // the plant's clock hour + hourWh float64 // its import, as the plant measured it + heat [3]float64 // the phases' I², filtered as Core's heat model does heatSet bool overSince, needSince time.Time @@ -91,6 +94,7 @@ func (s *simSite) boot() { return EVFuseBudget(s.store, s.state, s.mgr.States(), s.ctrl.FuseConfigs(), s.fuseMaxW()) }) s.ctrl.SetFusePhaseRoomA(func() (float64, bool) { return EVFusePhaseRoomA(s.state) }) + s.ctrl.SetFuseLimitReason(func() string { return EVLimitReason(s.state) }) s.ctrl.SetSiteSurplusForEV(func() (float64, bool) { if !s.meterFresh() { return 0, false @@ -287,6 +291,10 @@ func (s *simSite) responseTime(slowest bool) time.Duration { // check holds the invariants for this second. func (s *simSite) check() { p, now := s.p, s.p.Now + if hour := ImportHourStart(now.Add(-sitesim.Step)); !hour.Equal(s.hourStart) { + s.hourStart, s.hourWh = hour, 0 + } + s.hourWh += math.Max(0, (p.A[0]+p.A[1]+p.A[2])*p.Voltage) * sitesim.Step.Hours() if p.Fuse.AnyBlown() { s.fail("the main fuse blew: damage %.2f", p.Fuse.Damage) } @@ -346,8 +354,9 @@ func (s *simSite) check() { // wantsCharge reports whether the car should charge now: it is plugged // and asks, Core and the meter are running, and the uncontrolled load -// leaves room for the car's minimum on its phases (and, for surplus -// charging, exports at least that much). +// leaves room for the car's minimum on its phases and under the hour's +// import ceiling (and, for surplus charging, exports at least that much). +// Under the ceiling Core starts nothing in an hour's last quarter. func (s *simSite) wantsCharge() bool { p, e := s.p, s.p.EVSE if p.Car == nil || !p.Car.Plugged || p.Offline("evse") || !s.downUntil.IsZero() || p.Now.Sub(s.meterAt) > max(p.Meter.Every, sitesim.Step)+sitesim.Step { @@ -362,7 +371,14 @@ func (s *simSite) wantsCharge() bool { return false } } - return !s.surplus || -baseW >= e.MinA*p.Voltage*float64(phases) + minW := e.MinA * p.Voltage * float64(phases) + if ceilingW := s.state.PeakImportCeilingW; ceilingW > 0 { + left := s.hourStart.Add(time.Hour).Sub(p.Now) + if left < importHourRiseMinLeft || baseW+minW > (ceilingW-s.hourWh)/left.Hours() { + return false + } + } + return !s.surplus || -baseW >= minW } func (s *simSite) fail(format string, args ...any) { @@ -398,7 +414,7 @@ type simScenario struct { type simExpect struct { at time.Duration - check func(*sitesim.Plant) error + check func(*simSite) error } var simStart = time.Date(2026, 10, 21, 12, 0, 0, 0, time.Local) @@ -510,7 +526,8 @@ func simScenarios() []simScenario { c.MaxChargeW, c.AllowedStepsW = 10350, c.AllowedStepsW[:len(c.AllowedStepsW)-1] }, setup: planBatteryCharge(9400), - expect: []simExpect{{9*time.Minute + 50*time.Second, func(p *sitesim.Plant) error { + expect: []simExpect{{9*time.Minute + 50*time.Second, func(s *simSite) error { + p := s.p offerW := p.EVSE.OfferA * p.Voltage * float64(p.EVSE.OfferPhases) if offerW != 10350 || math.Abs(p.EVSE.DrawW-4200) > 50 || math.Abs(p.Battery.W-9400) > 200 { return fmt.Errorf("not the #1761 state before the car takes its offer: offer %.0f W, car %.0f W, battery %.0f W", offerW, p.EVSE.DrawW, p.Battery.W) @@ -547,6 +564,23 @@ func simScenarios() []simScenario { p.HouseW = houseA(13.7, 12, 12) p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 5000, MaxDischargeW: 5000, CapacityWh: 10000, SoC: .07, Latency: 2 * time.Second} }}, + {name: "end of a heavy hour, low phases", + start: time.Date(2026, 10, 21, 2, 0, 0, 0, time.Local), run: 65 * time.Minute, + plant: func(p *sitesim.Plant) { + p.HouseW = houseA(10, 10, 10) + p.Events = []sitesim.Event{ + {At: 9 * time.Minute, Kind: sitesim.CoreRestart, For: 20 * time.Second}, + {At: 50 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(1.5, 1.5, 1.5) }}, + } + }, + setup: func(s *simSite) { s.state.PeakImportCeilingW = 6000 }, + expect: []simExpect{{57 * time.Minute, func(s *simSite) error { + st, _ := s.mgr.State(s.lp.ID) + if s.p.EVSE.DrawW > 0 || st.CommandedReason != "import_limit" { + return fmt.Errorf("near the end of the hour: car draws %.0f W, reason %q, want idle on import_limit", s.p.EVSE.DrawW, st.CommandedReason) + } + return nil + }}}}, {name: "a day", start: time.Date(2026, 10, 21, 0, 0, 0, 0, time.Local), run: 24 * time.Hour, plant: simDay}, } } @@ -566,7 +600,7 @@ func runSimScenario(sc simScenario, start time.Time) *simSite { s.boot() for _, e := range sc.expect { s.run(start.Add(e.at).Sub(p.Now)) - if err := e.check(p); err != nil { + if err := e.check(s); err != nil { s.fail("%v", err) } } diff --git a/go/internal/loadpoint/battery_boost.go b/go/internal/loadpoint/battery_boost.go index 266dc9b4e..40a2e2926 100644 --- a/go/internal/loadpoint/battery_boost.go +++ b/go/internal/loadpoint/battery_boost.go @@ -32,6 +32,7 @@ const ( BatteryBoostStoppedBatteryHold BatteryBoostStopReason = "battery_hold" BatteryBoostStoppedCoreMode BatteryBoostStopReason = "core_mode" BatteryBoostStoppedFuseSafety BatteryBoostStopReason = "fuse_safety_block" + BatteryBoostStoppedImportLimit BatteryBoostStopReason = "import_limit_block" BatteryBoostStoppedRestartInvalid BatteryBoostStopReason = "restart_lease_invalid" ) diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 7c9811fcf..2848e30b1 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -49,9 +49,12 @@ type Controller struct { // fusePhaseRoomA is the current chargers may still add on the busiest // phase. nil, or false, leaves the cap alone to limit them. fusePhaseRoomA func() (float64, bool) + // fuseLimitReason names the site limit behind the cap. + fuseLimitReason func() string // tickFuseCaps belongs to the serial tick, after one shared budget read. tickFuseCaps map[string]float64 + tickFuseReason string tickChoices map[string]tickChoice // serial tick tickDirectiveActive bool tickDirectiveOK bool @@ -527,6 +530,29 @@ func (c *Controller) SetFusePhaseRoomA(f func() (float64, bool)) { c.fusePhaseRoomA = f } +// SetFuseLimitReason wires control.EVLimitReason: "import_limit" when the +// clock hour's import ceiling holds the cap, else "fuse_limit". Read once +// per tick, after the cap. +func (c *Controller) SetFuseLimitReason(f func() string) { + if c == nil { + return + } + c.fuseLimitReason = f +} + +// capReason is the reason a command the cap lowered carries. +func (c *Controller) capReason() string { + if c.tickFuseReason != "" { + return c.tickFuseReason + } + if c.fuseLimitReason != nil { + if r := c.fuseLimitReason(); r != "" { + return r + } + } + return "fuse_limit" +} + // 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)) {} @@ -618,12 +644,12 @@ func (c *Controller) applyFuseClamp(lpCfg Config, wantW float64, phaseMode strin if snapped > 0 && snapped >= lpCfg.MinChargeW { slog.Info("loadpoint fuse-clamp: ramped down", "lp", lpCfg.ID, "want_w", wantW, "fuse_cap_w", cap, "snapped_w", snapped) - return snapped, "fuse_limit" + return snapped, c.capReason() } slog.Info("loadpoint fuse-clamp: below minimum step", "lp", lpCfg.ID, "want_w", wantW, "fuse_cap_w", cap, "min_step_w", lpCfg.MinChargeW) - return 0, "fuse_limit" + return 0, c.capReason() } // SetNearTermPeakSurplusW wires the short-horizon "peak surplus over @@ -1207,7 +1233,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.tickChoices = nil; c.tickDirectiveActive = false }() + defer func() { c.tickFuseCaps, c.tickFuseReason = nil, ""; c.tickChoices = nil; c.tickDirectiveActive = false }() } choices := make(map[string]tickChoice, len(observations)) for _, observed := range observations { diff --git a/go/internal/loadpoint/controller_commanded_reason_test.go b/go/internal/loadpoint/controller_commanded_reason_test.go index 57084ea28..035597cd2 100644 --- a/go/internal/loadpoint/controller_commanded_reason_test.go +++ b/go/internal/loadpoint/controller_commanded_reason_test.go @@ -101,6 +101,27 @@ func TestCommandedReasonFuseCapZero(t *testing.T) { } } +// A cap the hour's import ceiling sets carries its own name, never the +// fuse's, also when it overrides a hold. +func TestCommandedReasonImportLimit(t *testing.T) { + base := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC) + for _, capW := range []float64{0, 6210} { + sender := &fakeSender{} + cfg := reasonLoadpoint() + samples := map[string]EVSample{cfg.DriverName: {Connected: true, PowerW: 0, RequestActive: true}} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 11040, PhaseMode: "3p", Persistent: true}) + c.SetFuseEVMax(func() (float64, bool) { return capW, true }) + c.SetFuseLimitReason(func() string { return "import_limit" }) + + c.Tick(context.Background(), base) + + if w, r := commandedReason(t, c, cfg.ID); w != capW || r != "import_limit" { + t.Errorf("cap %.0f W from the hour's ceiling: got (%.0f, %q), want (%.0f, import_limit)", capW, w, r, capW) + } + } +} + func TestCommandedReasonFuseLimitOverridesManualHold(t *testing.T) { base := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC) sender := &fakeSender{} diff --git a/go/internal/loadpoint/controller_fuse_budget.go b/go/internal/loadpoint/controller_fuse_budget.go index b03444ab7..e81c3df03 100644 --- a/go/internal/loadpoint/controller_fuse_budget.go +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -19,7 +19,7 @@ type fuseRequest struct { // 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.tickFuseCaps, c.tickFuseReason = nil, "" if c.fuseEVMax == nil { return } @@ -27,6 +27,7 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample if !ok || total < 0 { return } + c.tickFuseReason = c.capReason() if !finite(total) { total = 0 } diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 2e5b6b5f6..765197a6c 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -266,8 +266,8 @@ type State struct { // CommandedReason names the dispatch branch that decided CommandedW: // "plan", "no_plan_budget", "pv_surplus", "pv_surplus_pause", - // "fuse_limit", "site_meter_stale", "manual_hold", "wake_kick". Empty - // until the first dispatch tick. The UI renders the specific cause + // "fuse_limit", "import_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"` @@ -565,8 +565,8 @@ 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", "site_meter_stale", "manual_hold", "wake_kick". Empty keeps -// whatever was recorded before (used by the legacy SetCommandedW wrapper). +// "fuse_limit", "import_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_status.go b/go/internal/loadpoint/manual_status.go index 427160876..c008c5c8c 100644 --- a/go/internal/loadpoint/manual_status.go +++ b/go/internal/loadpoint/manual_status.go @@ -40,7 +40,7 @@ type ManualStatus struct { // current, in its words (reason_no_current_label). Empty when it has none. ChargerReason string `json:"charger_reason,omitempty"` // LimitReason names the clamp behind ManualLimited: "fuse_limit", - // "fuse_cooldown" or "site_meter_stale". + // "import_limit", "fuse_cooldown" or "site_meter_stale". LimitReason string `json:"limit_reason,omitempty"` } @@ -108,7 +108,7 @@ const ( // before the hold was installed and says nothing about it. func holdClampReason(reason string) bool { switch reason { - case "fuse_limit", "fuse_cooldown", "site_meter_stale", "charger_limit": + case "fuse_limit", "import_limit", "fuse_cooldown", "site_meter_stale", "charger_limit": return true } return false diff --git a/web/app.js b/web/app.js index 29621381d..9a795e1ee 100644 --- a/web/app.js +++ b/web/app.js @@ -2968,7 +2968,9 @@ ? "Charging on plan until " + evFmtClock(lp.plan_next_end_ms) + "." + kwPlanned : "Charging."; if (lp.commanded_reason === "fuse_limit") { - text += " Rate is limited by site power limits right now."; + text += " Rate is limited by the main fuse right now."; + } else if (lp.commanded_reason === "import_limit") { + text += " Rate is limited by the hourly import limit right now."; } } else if (lp.goal_complete === true) { text = "The car confirmed this charging goal is complete."; @@ -2993,7 +2995,10 @@ // The specific pauses beat the generic branches — this is the // line that stops an operator debugging cable and charger while // the box is protecting the main fuse (#1009). - text = "Paused by site power limits — the house is using the headroom; charging resumes on its own." + kwPlanned; + text = "Paused by the main fuse — the house is using the headroom; charging resumes on its own." + kwPlanned; + tone = "var(--text)"; + } else if (lp.commanded_known && !lp.commanded_w && lp.commanded_reason === "import_limit") { + text = "Paused by the hourly import limit — this hour’s import budget is used; charging resumes on its own." + kwPlanned; tone = "var(--text)"; } else if (lp.commanded_known && !lp.commanded_w && lp.commanded_reason === "site_meter_stale") { text = "Paused for safety: site-meter data is stale — charging resumes when telemetry recovers."; @@ -3338,6 +3343,7 @@ battery_hold: "a battery hold took priority", core_mode: "the site mode does not allow it", fuse_safety_block: "fuse protection stopped it", + import_limit_block: "the hourly import limit stopped it", restart_lease_invalid: "the saved lease was no longer safe after a restart", }; diff --git a/web/control-feedback.js b/web/control-feedback.js index 18a184e63..69c97b1ea 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -63,7 +63,8 @@ const REASON_TITLES = { no_plan_budget: 'Waiting for the plan', pv_surplus_pause: 'Waiting for spare solar', wake_kick: 'Starting the charger', vehicle_complete: 'Car is full', vehicle_limit_completion: 'Car reached its limit', vehicle_not_requesting: 'Car not taking power', battery_full: 'Battery full', battery_nearly_full: 'Battery nearly full', battery_nearly_empty: 'Battery nearly empty', - core_limit: 'Limited by FTW', fuse_limit: 'Limited by site power limits', fuse_cooldown: 'Waiting after a site power limit', + core_limit: 'Limited by FTW', fuse_limit: 'Limited by the main fuse', fuse_cooldown: 'Waiting after a site power limit', + import_limit: 'Limited by the hourly import limit', charger_limit: 'Limited by FTW’s charger setting', device_limit: 'Charger limit', offered_current_lower: 'Charger offers less current', device_fault: 'Device fault', telemetry_stale: 'No fresh readings', readings_lost: 'Lost control', command_failed: 'Commands failing', command_unconfirmed: 'Command not confirmed', default_failed: 'Cannot hand back control', @@ -92,7 +93,8 @@ function sentence(row) { case 'battery_nearly_full': return `Taking ${controlPower(actual)} of ${controlPower(row.sent_w)} at ${percent(row.battery_soc)}. A battery charges slower when it is nearly full.`; case 'battery_nearly_empty': return `Giving ${controlPower(actual)} of ${controlPower(row.sent_w)} at ${percent(row.battery_soc)}. A battery limits discharge when it is nearly empty.`; case 'core_limit': return `FTW’s safety limits set the target to ${target(row)}.`; - case 'fuse_limit': return `FTW lowered the rate to ${target(row)} to stay within the site’s fuse and import limits.`; + case 'fuse_limit': return `FTW lowered the rate to ${target(row)} to protect the main fuse.`; + case 'import_limit': return `FTW lowered the rate to ${target(row)} to keep this hour’s grid import within its limit.`; case 'fuse_cooldown': return 'FTW waits a moment after a site power limit before it raises the rate.'; case 'charger_limit': return 'The charger limit in FTW’s settings caps the rate.'; case 'device_limit': return `The charger’s own limit is ${amps(row.device_limit_a)}, below the ${amps(row.requested_a)} FTW asked for.`; diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs index bdef3a5ea..b504dd07c 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -147,6 +147,10 @@ test('limited positive EV offer does not explain prolonged zero delivery', () => assert.equal(missing.title,'Not following'); assert.match(missing.text,/6.9 kW charge.*no power/); const capped=controlStatus(row({kind:'ev',status:'limited',reason:'fuse_limit',sent_w:6900,actual_w:6800})); - assert.equal(capped.title,'Limited by site power limits'); - assert.match(capped.text,/fuse and import limits/); + assert.equal(capped.title,'Limited by the main fuse'); + assert.match(capped.text,/protect the main fuse/); + const hour=controlStatus(row({kind:'ev',status:'limited',reason:'import_limit',sent_w:4140,actual_w:4100})); + assert.equal(hour.title,'Limited by the hourly import limit'); + assert.match(hour.text,/this hour’s grid import/); + assert.doesNotMatch(hour.text,/fuse/); }); diff --git a/web/ev-commanded-reason.test.mjs b/web/ev-commanded-reason.test.mjs index 745ec3bc5..dd2a094e0 100644 --- a/web/ev-commanded-reason.test.mjs +++ b/web/ev-commanded-reason.test.mjs @@ -9,7 +9,9 @@ const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); test('every pause reason has its own sentence', () => { assert.match(source, /commanded_reason === "fuse_cooldown" \|\| lp\.commanded_reason === "fuse_limit"/); - assert.match(source, /Paused by site power limits/); + assert.match(source, /Paused by the main fuse/); + assert.match(source, /commanded_reason === "import_limit"/); + assert.match(source, /Paused by the hourly import limit/); assert.match(source, /commanded_reason === "site_meter_stale"/); assert.match(source, /Paused for safety: site-meter data is stale/); assert.match(source, /commanded_reason === "pv_surplus_pause"/); @@ -19,6 +21,7 @@ test('every pause reason has its own sentence', () => { assert.match(source, /commanded_reason === "no_plan_budget"/); assert.match(source, /The ready time has passed, so FTW is not charging/); assert.match(source, /Battery level is assumed, not read from the car/); - // A fuse-limited ongoing charge says so too. - assert.match(source, /Rate is limited by site power limits right now/); + // A limited ongoing charge names its limit too. + assert.match(source, /Rate is limited by the main fuse right now/); + assert.match(source, /Rate is limited by the hourly import limit right now/); }); diff --git a/web/loadpoint-battery-boost.test.mjs b/web/loadpoint-battery-boost.test.mjs index 67d4e3b36..822b29d5a 100644 --- a/web/loadpoint-battery-boost.test.mjs +++ b/web/loadpoint-battery-boost.test.mjs @@ -36,7 +36,7 @@ test('every controller stop reason has words', () => { 'cancelled', 'expired', 'vehicle_unplugged', 'ev_target_reached', 'departure_reached', 'surplus_only', 'site_safety_block', 'loadpoint_driver_unavailable', 'battery_unavailable', 'battery_reserve_reached', 'battery_hold', 'core_mode', - 'fuse_safety_block', 'restart_lease_invalid', + 'fuse_safety_block', 'import_limit_block', 'restart_lease_invalid', ]) { assert.match(app, new RegExp(reason + ': "')); } diff --git a/web/loadpoints.js b/web/loadpoints.js index dcd40c84a..031159e0d 100644 --- a/web/loadpoints.js +++ b/web/loadpoints.js @@ -109,6 +109,7 @@ battery_hold: 'Battery hold took priority', core_mode: 'Core mode does not allow boost', fuse_safety_block: 'Fuse protection stopped boost', + import_limit_block: 'Hourly import limit stopped boost', restart_lease_invalid: 'Saved lease was no longer safe', }; From 1a79330a252abbe996c0b61523a2d138b1871bf1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 17:47:13 +0200 Subject: [PATCH 2/3] fix(control): an idle car held by the hour's start test says import_limit Review of #1783 (Codex): after a quiet part of the hour, the hour's limits can sit above what the fuse allows, so the label compared them and said fuse_limit. An idle car is then kept off by the ceiling's own start test, which caps the start level at the ceiling itself. Under a measured hour the idle start now names the limit by that test: the start level against this reading, and import_limit when the dwell's mean import fails it. TestImportCeilingStartTestSaysImportLimit: 40 quiet minutes under a 7.5 kW ceiling, then an 8 kW house and a car plugged in. It said fuse_limit; it says import_limit. Co-Authored-By: Claude Opus 5.5 --- go/internal/control/ev_fuse_budget.go | 6 +++++- go/internal/control/import_hour_test.go | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 7709e52bc..093e62aeb 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -346,6 +346,10 @@ func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first perReadingW := r.fuseW if !r.hourly { perReadingW = math.Min(perReadingW, r.riseW) + } else { + // The start test below holds an idle car to the ceiling itself, + // whatever slack the hour has: name it by this reading. + lim.byImport = r.startLevelW-gridW < r.fuseW } room := 0.0 if fresh { @@ -374,7 +378,7 @@ func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first } ceilingRoomW := r.startLevelW - lim.idleWh/dwell.Hours() - pending if ceilingRoomW < pool.minW { - lim.capW, lim.roomSince, lim.idleWh = 0, at, 0 // Judge the next dwell. + lim.capW, lim.roomSince, lim.idleWh, lim.byImport = 0, at, 0, true // Judge the next dwell. return } lim.capW = math.Min(lim.capW, ceilingRoomW) diff --git a/go/internal/control/import_hour_test.go b/go/internal/control/import_hour_test.go index 0d793190c..627f5933c 100644 --- a/go/internal/control/import_hour_test.go +++ b/go/internal/control/import_hour_test.go @@ -428,6 +428,24 @@ func TestImportCeilingNoStartOnSlackAlone(t *testing.T) { } } +// A quiet first 40 minutes leave the hour so much slack that its limits sit +// above what the 25 A fuse allows. An 8 kW house still keeps an idle car from +// starting, by the ceiling's own start test, and the car says import_limit. +func TestImportCeilingStartTestSaysImportLimit(t *testing.T) { + h := newHourSite(t, 7500, 0, clock(16, 21, 0)) + h.unplugged = true + h.run(40 * time.Minute) + h.setHouse(8000) + h.plugIn() + h.run(5 * time.Minute) + if h.offer() != 0 { + t.Fatalf("the car started at %.0f W beside 8 kW under a 7.5 kW ceiling", h.offer()) + } + if st, _ := h.mgr.State("garage"); st.CommandedReason != "import_limit" { + t.Fatalf("idle under the ceiling says %q, want import_limit", st.CommandedReason) + } +} + // A reading after a clock jump of years counts only the hour it falls in. func TestImportCeilingClockJumpCountsOnlyItsHour(t *testing.T) { var now time.Time From 24a53fa4ca11602b4e21d2d80140bff7ee12f5fc Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 18:03:56 +0200 Subject: [PATCH 3/3] test(control): a night under an hourly ceiling never says fuse_limit Review of #1783: the field case as a test. A 7.5 kW hourly ceiling, a 25 A fuse, a light first half hour and a car plugged in at 22:40 beside 6 kW, and 8 kW, of house load. Every tick to 23:00 is checked; on cb55a26b it said fuse_limit from 22:40:01, with 1a79330a it says import_limit throughout. The site simulation's last-quarter rule now states its 15 minutes instead of copying importHourRiseMinLeft, so a wrong constant shows. Co-Authored-By: Claude Opus 5.5 --- go/internal/control/import_hour_test.go | 24 ++++++++++++++++++++++++ go/internal/control/sitesim_test.go | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/go/internal/control/import_hour_test.go b/go/internal/control/import_hour_test.go index 627f5933c..06741868c 100644 --- a/go/internal/control/import_hour_test.go +++ b/go/internal/control/import_hour_test.go @@ -428,6 +428,30 @@ func TestImportCeilingNoStartOnSlackAlone(t *testing.T) { } } +// The field case from the review of #1783: a 7.5 kW hourly ceiling, a 25 A +// fuse, a light first half hour, and a car plugged in at 22:40 beside 6 kW +// (and 8 kW) of house load. The ceiling keeps it idle to the end of the +// hour, and not one tick of that says fuse_limit. +func TestImportCeilingNightNeverSaysFuse(t *testing.T) { + for _, houseW := range []float64{6000, 8000} { + h := newHourSite(t, 7500, 500, clock(16, 22, 0)) + h.unplugged = true + h.run(40 * time.Minute) + h.setHouse(houseW) + h.plugIn() + for h.now.Before(clock(16, 23, 0)) { + h.step() + if st, _ := h.mgr.State("garage"); st.CommandedReason == "fuse_limit" { + t.Fatalf("house %.0f W: %s says fuse_limit with the phases low under a 7.5 kW hourly ceiling", + houseW, h.now.In(hourTestZone).Format("15:04:05")) + } + } + if h.offer() != 0 { + t.Fatalf("house %.0f W: the car started at %.0f W under the ceiling", houseW, h.offer()) + } + } +} + // A quiet first 40 minutes leave the hour so much slack that its limits sit // above what the 25 A fuse allows. An 8 kW house still keeps an idle car from // starting, by the ceiling's own start test, and the car says import_limit. diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 5319360c7..d94c99bb1 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -374,7 +374,7 @@ func (s *simSite) wantsCharge() bool { minW := e.MinA * p.Voltage * float64(phases) if ceilingW := s.state.PeakImportCeilingW; ceilingW > 0 { left := s.hourStart.Add(time.Hour).Sub(p.Now) - if left < importHourRiseMinLeft || baseW+minW > (ceilingW-s.hourWh)/left.Hours() { + if left < 15*time.Minute || baseW+minW > (ceilingW-s.hourWh)/left.Hours() { return false } }