diff --git a/.changeset/boost-with-charge-now.md b/.changeset/boost-with-charge-now.md new file mode 100644 index 000000000..42dccd08a --- /dev/null +++ b/.changeset/boost-with-charge-now.md @@ -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. diff --git a/go/internal/loadpoint/battery_boost.go b/go/internal/loadpoint/battery_boost.go index a9d6c68cc..266dc9b4e 100644 --- a/go/internal/loadpoint/battery_boost.go +++ b/go/internal/loadpoint/battery_boost.go @@ -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" @@ -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 @@ -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) diff --git a/go/internal/loadpoint/battery_boost_test.go b/go/internal/loadpoint/battery_boost_test.go index 2f993400e..f28d886c9 100644 --- a/go/internal/loadpoint/battery_boost_test.go +++ b/go/internal/loadpoint/battery_boost_test.go @@ -76,21 +76,16 @@ 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") } @@ -98,6 +93,37 @@ func TestBatteryBoostValidationAndOperatorClamps(t *testing.T) { } } +// 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 { @@ -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) { diff --git a/web/app.js b/web/app.js index 95e0f2cb5..ee0b4e5ea 100644 --- a/web/app.js +++ b/web/app.js @@ -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", @@ -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"; diff --git a/web/loadpoint-battery-boost.test.mjs b/web/loadpoint-battery-boost.test.mjs index 3890cdfc3..67d4e3b36 100644 --- a/web/loadpoint-battery-boost.test.mjs +++ b/web/loadpoint-battery-boost.test.mjs @@ -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"/); @@ -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', ]) { diff --git a/web/loadpoints.js b/web/loadpoints.js index 8917e6a9a..dcd40c84a 100644 --- a/web/loadpoints.js +++ b/web/loadpoints.js @@ -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',