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/boost-with-charge-now.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Boost from home battery now works together with Charge now. Charge now sets how much the car draws; a boost lets the home battery cover that draw above the reserve you choose, for the time you choose. Before, the boost button was disabled while Charge now ran, and starting Charge now ended a running boost, so a quick top-up before leaving always came from the grid. Fuse, battery and charger limits apply as before.
11 changes: 3 additions & 8 deletions go/internal/loadpoint/battery_boost.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ const (
BatteryBoostStoppedVehicleUnplugged BatteryBoostStopReason = "vehicle_unplugged"
BatteryBoostStoppedEVTargetReached BatteryBoostStopReason = "ev_target_reached"
BatteryBoostStoppedDepartureReached BatteryBoostStopReason = "departure_reached"
BatteryBoostStoppedOperatorHold BatteryBoostStopReason = "operator_hold"
BatteryBoostStoppedSurplusOnly BatteryBoostStopReason = "surplus_only"
BatteryBoostStoppedSiteSafety BatteryBoostStopReason = "site_safety_block"
BatteryBoostStoppedLoadpointDriver BatteryBoostStopReason = "loadpoint_driver_unavailable"
Expand Down Expand Up @@ -216,9 +215,9 @@ func (c *Controller) batteryBoostLivePreflight(id string, lease BatteryBoostLeas
if st.SurplusOnly {
return BatteryBoostStoppedSurplusOnly, errors.New("surplus_only is an operator clamp")
}
if _, held := c.GetManualHold(id, now); held {
return BatteryBoostStoppedOperatorHold, errors.New("loadpoint operator hold is active")
}
// A manual hold sets how much the car draws; a boost only lets the home
// battery cover that draw. They answer different questions, so Charge
// now and a boost run together.

c.batteryBoostMu.Lock()
safety := c.batteryBoostSafety
Expand Down Expand Up @@ -348,10 +347,6 @@ func (c *Controller) evaluateBatteryBoost(id string, now time.Time, connected, d
c.stopBatteryBoost(id, BatteryBoostStoppedSiteSafety, now)
return
}
if _, held := c.GetManualHold(id, now); held {
c.stopBatteryBoost(id, BatteryBoostStoppedOperatorHold, now)
return
}
if st, ok := c.manager.State(id); ok {
if st.SurplusOnly {
c.stopBatteryBoost(id, BatteryBoostStoppedSurplusOnly, now)
Expand Down
49 changes: 36 additions & 13 deletions go/internal/loadpoint/battery_boost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,28 +76,54 @@ func TestBatteryBoostValidationAndOperatorClamps(t *testing.T) {
name string
lease BatteryBoostLease
surplusOnly bool
hold bool
}{
{"short duration", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(30 * time.Second), MinBatterySoC: 0.3}, false, false},
{"long duration", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(MaxBatteryBoostDuration + time.Second), MinBatterySoC: 0.3}, false, false},
{"low reserve", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(time.Hour), MinBatterySoC: 0.04}, false, false},
{"departure after expiry", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(time.Hour), DepartureAt: now.Add(2 * time.Hour), MinBatterySoC: 0.3}, false, false},
{"surplus only", validBatteryBoost(now), true, false},
{"manual hold", validBatteryBoost(now), false, true},
{"short duration", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(30 * time.Second), MinBatterySoC: 0.3}, false},
{"long duration", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(MaxBatteryBoostDuration + time.Second), MinBatterySoC: 0.3}, false},
{"low reserve", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(time.Hour), MinBatterySoC: 0.04}, false},
{"departure after expiry", BatteryBoostLease{StartedAt: now, ExpiresAt: now.Add(time.Hour), DepartureAt: now.Add(2 * time.Hour), MinBatterySoC: 0.3}, false},
{"surplus only", validBatteryBoost(now), true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctrl, _, _ := newBatteryBoostController(t, tc.surplusOnly)
if tc.hold {
ctrl.SetManualHold("garage", ManualHold{Persistent: true, PowerW: 1380})
}
if _, err := ctrl.EnableBatteryBoost("garage", tc.lease, now); err == nil {
t.Fatal("expected validation error")
}
})
}
}

// Charge now sets how much the car draws; a boost lets the home battery cover
// that draw. Starting either one keeps the other, and the boost counts what the
// held charge actually draws.
func TestBatteryBoostRunsWithChargeNow(t *testing.T) {
now := time.Now()
for _, order := range []string{"hold first", "boost first"} {
t.Run(order, func(t *testing.T) {
ctrl, mgr, _ := newBatteryBoostController(t, false)
hold := func() { ctrl.SetManualHold("garage", ManualHold{Persistent: true, PowerW: 11000}) }
if order == "hold first" {
hold()
}
if _, err := ctrl.EnableBatteryBoost("garage", validBatteryBoost(now), now); err != nil {
t.Fatalf("boost refused beside Charge now: %v", err)
}
if order == "boost first" {
hold()
}
ctrl.TickWithDispatch(context.Background(), now.Add(time.Minute), true)
if _, status := ctrl.BatteryBoost("garage", now.Add(time.Minute)); !status.Active {
t.Fatalf("Charge now ended the boost: %+v", status)
}
mgr.Observe("garage", true, 11000, 0, true)
st, _ := mgr.State("garage")
if w, reserve := ctrl.ActiveBatteryBoostTotals([]State{st}, now.Add(time.Minute)); w != 11000 || reserve != 0.3 {
t.Fatalf("boost covers %.0f W above %.2f, want the held 11000 W above 0.30", w, reserve)
}
})
}
}

func TestBatteryBoostAutoStopsAndClearsPersistedLease(t *testing.T) {
now := time.Now()
tests := []struct {
Expand All @@ -110,9 +136,6 @@ func TestBatteryBoostAutoStopsAndClearsPersistedLease(t *testing.T) {
{"expiry", nil, now.Add(time.Hour), true, BatteryBoostStoppedExpired},
{"unplug", func(_ *Controller, s *EVSample) { s.Connected = false }, now.Add(time.Minute), true, BatteryBoostStoppedVehicleUnplugged},
{"site safety", nil, now.Add(time.Minute), false, BatteryBoostStoppedSiteSafety},
{"operator hold", func(c *Controller, _ *EVSample) {
c.SetManualHold("garage", ManualHold{Persistent: true, PowerW: 1380})
}, now.Add(time.Minute), true, BatteryBoostStoppedOperatorHold},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
Expand Down
2 changes: 0 additions & 2 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3203,7 +3203,6 @@
vehicle_unplugged: "car unplugged",
ev_target_reached: "car target reached",
departure_reached: "departure time reached",
operator_hold: "a manual charge took priority",
surplus_only: "PV only took priority",
site_safety_block: "site-meter safety stopped it",
loadpoint_driver_unavailable: "charger driver unavailable",
Expand Down Expand Up @@ -3406,7 +3405,6 @@
} else {
var why = "";
if (!lpNow.plugged_in) why = "Plug in the car first.";
else if (lpNow.manual_active) why = "Stop the manual charge first — it already takes what it needs.";
else if (lpNow.surplus_only) why = "Turn off PV only first.";
startBtn.disabled = !!why;
startBtn.style.opacity = why ? "0.5" : "1";
Expand Down
5 changes: 3 additions & 2 deletions web/loadpoint-battery-boost.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ test('the EV modal owns the battery boost lease', () => {
assert.doesNotMatch(boostView, /departure/);
// Start is refused for the same reasons the controller refuses it.
assert.match(boostView, /Plug in the car first/);
assert.match(boostView, /Stop the manual charge first/);
// Charge now sets the car's draw; a boost only lets the battery cover it.
assert.doesNotMatch(boostView, /Stop the manual charge first/);
assert.match(boostView, /Turn off PV only first/);
// Active state and stop.
assert.match(boostView, /method: "DELETE"/);
Expand All @@ -33,7 +34,7 @@ test('the EV modal owns the battery boost lease', () => {
test('every controller stop reason has words', () => {
for (const reason of [
'cancelled', 'expired', 'vehicle_unplugged', 'ev_target_reached', 'departure_reached',
'operator_hold', 'surplus_only', 'site_safety_block', 'loadpoint_driver_unavailable',
'surplus_only', 'site_safety_block', 'loadpoint_driver_unavailable',
'battery_unavailable', 'battery_reserve_reached', 'battery_hold', 'core_mode',
'fuse_safety_block', 'restart_lease_invalid',
]) {
Expand Down
1 change: 0 additions & 1 deletion web/loadpoints.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@
vehicle_unplugged: 'Vehicle unplugged',
ev_target_reached: 'EV target reached',
departure_reached: 'Departure time reached',
operator_hold: 'Charger hold took priority',
surplus_only: 'Surplus-only policy took priority',
site_safety_block: 'Site meter safety stopped boost',
loadpoint_driver_unavailable: 'Charger driver unavailable',
Expand Down
Loading