diff --git a/.changeset/import-limit-reason.md b/.changeset/import-limit-reason.md new file mode 100644 index 00000000..bc23be79 --- /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 2d1fa404..18cc4c43 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 f1547bd5..ca84538e 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 ab426069..e3de918f 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 28b5ca5a..eed8315e 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 2ffb42a5..093e62ae 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{} @@ -342,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 { @@ -370,12 +378,22 @@ 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) } +// 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 +542,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 1360591d..f073c371 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 b91ff11b..06741868 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. @@ -405,6 +428,48 @@ 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. +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 diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 1f5bb003..d94c99bb 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 < 15*time.Minute || 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 266dc9b4..40a2e292 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 7c9811fc..2848e30b 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 57084ea2..035597cd 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 b03444ab..e81c3df0 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 2e5b6b5f..765197a6 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 42716087..c008c5c8 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 29621381..9a795e1e 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 18a184e6..69c97b1e 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 bdef3a5e..b504dd07 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 745ec3bc..dd2a094e 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 67d4e3b3..822b29d5 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 dcd40c84..031159e0 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', };