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/ev-meter-hard-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

The EV's hard limit now reads only the site meter and FTW's own commands, never the charger's power readings. A charge is cut by the overage the meter measures on the worst phase or in total import, holds while the site is inside its limits, and rises one step at a time once the meter shows the last one. After a stop it restarts only when the meter has shown room for 35 seconds, and a Core restart keeps FTW's last offer while the same charging session is still plugged in. A late, missing or lone 0 W reading from the charger can no longer stop a charge, and an import ceiling no longer makes the charger start and stop every half minute.
13 changes: 5 additions & 8 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2069,14 +2069,11 @@ 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 control
// package's fuse guard is site-TOTAL only (sum across phases);
// a single phase can still trip from house-load imbalance (e.g.
// a vacuum or oven on L1) stacked on top of the EV's per-phase
// draw. This reader feeds the loadpoint's reactive per-phase EV
// clamp (applyPerPhaseFuseClamp), which lowers max_amps_per_phase
// the instant the worst phase approaches the breaker. Reads the
// same meter_lN_a metrics the fuse_over_limit notifier uses.
// 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()
Expand Down
33 changes: 29 additions & 4 deletions go/internal/control/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,12 @@ type State struct {
// battery charging yields even when the EV cannot start yet.
evFuseDemands []evFuseDemand
evFuseDemandsSet bool
// evLimit is the EVs' meter-driven hard cap, kept between site readings.
evLimit evMeterLimit
// evOffers and batteryCommands are our own recent commands, the only
// device input the EV hard limit and the EV/battery split use.
evOffers commandLog
batteryCommands map[string]commandLog

// PI controller (outer, site-level)
PI *PIController
Expand Down Expand Up @@ -2028,7 +2034,8 @@ func ComputeDispatch(
state.FuseEVMaxW = 0
state.FuseSaturated = false
plannedEVW := scheduledEVPowerW(state)
if fuseMaxW > 0 && math.Max(state.EVChargingW, plannedEVW) > 0 {
evInMeterW := state.evInMeterW()
if fuseMaxW > 0 && math.Max(evInMeterW, plannedEVW) > 0 {
// Use the effective import ceiling (fuse minus safety margin,
// or tariff peak when tighter) so PeakImportCeilingW throttles
// the EV through the same surface as the fuse. Steady-state
Expand All @@ -2039,13 +2046,13 @@ func ComputeDispatch(
targetTotal := currentTotal + totalCorrection
B := math.Max(0, targetTotal)
Bn := math.Min(0, targetTotal)
E := math.Max(state.EVChargingW, plannedEVW)
H := rawGridW - currentTotal - state.EVChargingW
E := math.Max(evInMeterW, plannedEVW)
H := rawGridW - currentTotal - evInMeterW
projectedGrid := H + targetTotal + E
if projectedGrid > ceilingW && (B+E) > 0 {
if plannedEVW > 0 {
// A timed EV goal takes its share before optional battery charge.
available := evHeadroomW(ceilingW, rawGridW-currentTotal+Bn, state.EVChargingW)
available := evHeadroomW(ceilingW, rawGridW-currentTotal+Bn, evInMeterW)
state.FuseEVMaxW = math.Min(E, available)
newBattery := Bn + math.Min(B, math.Max(0, available-state.FuseEVMaxW))
totalCorrection = newBattery - currentTotal
Expand Down Expand Up @@ -2435,6 +2442,13 @@ func recordDispatchTargets(targets []DispatchTarget, state *State, updatePrevTar
if recordDispatch {
now := state.now()
state.LastDispatch = &now
if state.batteryCommands == nil {
state.batteryCommands = map[string]commandLog{}
}
for _, t := range targets {
state.batteryCommands[t.Driver] = state.batteryCommands[t.Driver].add(now, t.TargetW)
Comment on lines +2448 to +2449

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Log battery commands after the driver accepts them

These entries are created while computing targets, before the dispatch loop calls the driver, so a refused target becomes the battery's assumed latest command. If a 5 kW charge target is refused and the next meter sample has only 2 kW of room, capBatteryCharge treats the repeated 5 kW target as an established hold rather than an increase and can send the full target if the next retry succeeds, exceeding the measured ceiling. Feed accepted outcomes back into this log instead of recording every computed target.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

}
state.evOffers = state.evOffers.add(now, math.Max(0, state.EVCommandedW))
}
if updatePrevTargets {
for _, t := range targets {
Expand Down Expand Up @@ -4582,3 +4596,14 @@ func forceFuseDischargeWithSnapshot(
}
return out
}

// evInMeterW is EV load the meter is sure to contain: the operator's declared
// load plus the lowest managed offer of the last evSettle. This tick's new
// offer is not in it yet. Charger readings take no part in the fuse share.
func (s *State) evInMeterW() float64 {
low, _, _, ok := s.evOffers.span(s.now())
if !ok {
low = 0
}
return s.ManualEVChargingW + math.Max(0, low)
}
Loading
Loading