Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/import-limit-reason.md
Original file line number Diff line number Diff line change
@@ -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".
10 changes: 9 additions & 1 deletion go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -2026,6 +2031,9 @@ func main() {
if batteryHeld {
return loadpoint.BatteryBoostStoppedBatteryHold
}
if fuseBlocked && importBlocked {
return loadpoint.BatteryBoostStoppedImportLimit
}
if fuseBlocked {
return loadpoint.BatteryBoostStoppedFuseSafety
}
Expand Down
4 changes: 3 additions & 1 deletion go/internal/api/api_control_feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
}
Expand Down
21 changes: 19 additions & 2 deletions go/internal/control/control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
14 changes: 12 additions & 2 deletions go/internal/control/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions go/internal/control/ev_fuse_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for the idle-start ceiling when naming the limit

When an idle car plugs in after a low-import part of the hour, limitW and riseW can both exceed the fuse ceiling, while the separate start ceiling still blocks charging. For example, with a 7.5 kW import setting, ample accumulated hourly slack, an 8 kW house load and a 25 A three-phase fuse, this comparison sets byImport to false. However, stepEVIdleStart rejects the start because startLevelW is capped at PeakImportCeilingW, leaving negative import headroom despite sufficient fuse headroom. The updated UI consequently says “Paused by the main fuse” when the import policy is responsible. Include the binding idle-start constraint when selecting the reason.

Useful? React with 👍 / 👎.

// 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
Expand All @@ -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{}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions go/internal/control/ev_fuse_budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
65 changes: 65 additions & 0 deletions go/internal/control/import_hour_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading