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/schedule-surplus-unlock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

"Also charge from PV surplus" on a scheduled charge now works. Once the home battery is at or above the threshold you set, spare solar is added on top of the planned charge. The planned charge itself is never cut back, and loadpoints set to PV-only behave as before.
8 changes: 7 additions & 1 deletion go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1707,9 +1707,15 @@ func main() {
// dispatch then has to censor — producing
// misleading slot entries the operator sees in
// /api/mpc/plan that never actually execute.
// The arm only replaces the plan while no schedule
// target is set. Under a target it adds spare PV on top
// of the plan (loadpoint.Controller.surplusAddsToPlan,
// #1060), so the planner must keep planning the grid
// charge the deadline needs and is not told surplus-only.
batSoCArmed := false
if lpController != nil {
batSoCArmed = lpController.IsBatSoCArmed(st.ID)
sched, _ := lpMgr.GetSchedule(st.ID)
batSoCArmed = lpController.IsBatSoCArmed(st.ID) && !sched.HasTarget()
}
// NoBatteryToEV mirrors the site-wide ctrl.BatteryCoversEV
// flag (inverted). Plumbing the constraint into the DP
Expand Down
74 changes: 63 additions & 11 deletions go/internal/loadpoint/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -974,13 +974,18 @@ func (c *Controller) gridDeferredFor(lpID string) bool {
return c.gridDeferred[lpID]
}

// surplusActive reports whether surplus-only dispatch semantics apply
// to this loadpoint right now. True when ANY of:
// surplusActive reports whether surplus-only dispatch semantics REPLACE
// the plan for this loadpoint right now: the commanded W is snapped to
// live PV surplus and the plan budget is at most a ceiling. True when
// ANY of:
// - the operator's configured SurplusOnly flag is on
// - MPC has deferred grid-funded planning (forecast-vs-real divergence
// guard: even if the cached plan said "charge 2 kW now", live PV
// might have collapsed since the last replan)
// - the bat-SoC unlock is armed for this LP
// - no schedule target is set AND MPC has deferred grid-funded planning
// (forecast-vs-real divergence guard: even if the cached plan said
// "charge 2 kW now", live PV might have collapsed since the last replan)
// - no schedule target is set AND the bat-SoC unlock is armed for this LP
//
// With a schedule target and SurplusOnly off, surplus never replaces the
// plan; the bat-SoC unlock then ADDS to it instead — see surplusAddsToPlan.
//
// The caller passes the loadpoint's schedule so we read the threshold
// without re-locking the Manager.
Expand All @@ -996,7 +1001,7 @@ func (c *Controller) surplusActive(lpCfg Config, sched Schedule) bool {
// available surplus and the deadline is missed. The explicit SurplusOnly
// config above still wins, so a "surplus-preferred with a deadline floor"
// combo is unaffected. Operator directive 2026-05-30.
if sched.SoC > 0 {
if sched.HasTarget() {
return false
}
if c.gridDeferredFor(lpCfg.ID) {
Expand All @@ -1005,6 +1010,24 @@ func (c *Controller) surplusActive(lpCfg Config, sched Schedule) bool {
return c.evalBatSoCArm(lpCfg.ID, sched.SurplusUnlockBatSoC)
}

// surplusAddsToPlan reports whether spare PV may be added ON TOP of the
// plan this tick: a schedule target is set, SurplusOnly is off, and the
// bat-SoC unlock is armed. The plan's grid charge is the floor and the
// command becomes max(plan, surplus); surplus never throttles the plan
// (the 2026-05-30 directive above still holds). This is what the
// Scheduled tab's "Also charge from PV surplus" + "Home battery ≥ %"
// controls mean, since the UI always saves them together with a target
// (#1060).
//
// Exactly one of surplusActive and surplusAddsToPlan evaluates the arm on
// a given tick, so its hysteresis counters advance once per tick.
func (c *Controller) surplusAddsToPlan(lpCfg Config, sched Schedule) bool {
if lpCfg.SurplusOnly || !sched.HasTarget() {
return false
}
return c.evalBatSoCArm(lpCfg.ID, sched.SurplusUnlockBatSoC)
}

// AnyLoadpointSurplusActive reports whether any configured loadpoint
// is currently treating PV surplus as priority — via the configured
// SurplusOnly flag, the MPC grid-deferral flag, or a runtime-armed
Expand Down Expand Up @@ -1222,6 +1245,13 @@ func (c *Controller) wakeVehicleAuto(ctx context.Context, lpID string, reason st
// misleading "battery discharges to feed EV" entries in the plan UI
// that never actually happen.
//
// The arm is raw state: it says nothing about whether surplus replaces
// the plan or adds to it. main.go only marks the planner spec
// surplus-only when the loadpoint has no schedule target (the case
// where the arm replaces the plan, surplusActive); under a target the
// arm adds to the plan (surplusAddsToPlan) and the planner must keep
// planning the grid charge the deadline needs (#1060).
//
// Returns false if the controller is nil, no arm map yet exists, or
// the LP id isn't tracked. Safe to call concurrently with Tick.
func (c *Controller) IsBatSoCArmed(lpID string) bool {
Expand Down Expand Up @@ -1424,14 +1454,19 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d
if c.tel != nil {
sample, _ = c.tel(lpCfg.DriverName)
}
// Resolve the schedule once per tick — used for bat-SoC unlock
// (surplusActive) below. Zero value when no schedule is set,
// which makes evalBatSoCArm a no-op.
// Resolve the schedule once per tick — used for the bat-SoC unlock
// (surplusActive / surplusAddsToPlan) and the phase decision below.
// Zero value when no schedule is set, which makes evalBatSoCArm a
// no-op.
var sched Schedule
if c.manager != nil {
sched, _ = c.manager.GetSchedule(lpCfg.ID)
}
// surplusOn: surplus REPLACES the plan (surplus-only semantics).
// surplusAdds: surplus is ADDED on top of a scheduled plan. Never
// both true.
surplusOn := c.surplusActive(lpCfg, sched)
surplusAdds := c.surplusAddsToPlan(lpCfg, sched)

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 Preserve self-withheld state for additive surplus pauses

When a scheduled target has an empty plan slot and live surplus drops below the charging floor, this new additive mode calls computeSurplusCmd, which records the loadpoint as surplus-paused, but on the following ticks selfWithheld remains false because surplusOn is false whenever the schedule has a target. Chargers such as CTEK report RequestActive=false after this controller-induced pause, so Manager.Observe treats it as a vehicle refusal and, after 90 seconds, latches the session complete; subsequent MPC plans then allocate no more energy and the scheduled deadline can be missed even when PV or the planned grid window returns. The additive mode needs to participate in the self-withheld bookkeeping whenever it actually paused a zero-plan command.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Surplus pause can complete scheduled session

High Severity

When surplusAddsToPlan is true, computeSurplusCmd can pause below the 3Φ floor, but selfWithheld still keys only off surplusOn. A cloudy spell after daytime surplus-add can look like a vehicle decline, latch sessionComplete, and pin SoC to the target so the planner drops the remaining grid charge.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9aa2d72. Configure here.

// Detect the disconnected→connected edge (state.PluggedIn flips
// from false to true) so we can reset session-scoped state
// before the new session's first dispatch tick. Without this
Expand Down Expand Up @@ -1680,6 +1715,23 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d
cmdReason = "pv_surplus_pause"
}
}
// Schedule + bat-SoC unlock (#1060): spare PV is added ON TOP of
// the plan. The plan's watts are the floor — a scheduled grid
// charge is never throttled to live surplus (directive
// 2026-05-30) — and surplus may only lift the command above it,
// snapped to the same steps the surplus-only path uses. The
// reason names surplus only when it actually raised the watts;
// otherwise the plan's own reason stands. Phase selection below
// still sees the schedule as active, so a 3Φ grid charge keeps
// its phase behaviour and the additive path never flips the
// surplus 1Φ lock.
if surplusAdds {
surplusW := c.computeSurplusCmd(now, lpCfg, lpCfg.MaxChargeW, sample.PowerW)
if surplusW > cmdW {
cmdW = surplusW
cmdReason = "pv_surplus"
}
}
// Wake-kick AFTER the surplus clamp: when an auto-wake just
// fired and the surplus clamp paused us to 0, force the
// wallbox to signal at least min 3Φ current for a few
Expand Down Expand Up @@ -1741,7 +1793,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d
// rationale in one testable place. Operator directive 2026-05-30.
phaseMode := resolvePhaseMode(
lpCfg.PhaseMode,
sched.SoC > 0,
sched.HasTarget(),
c.surplusLockedTo1P(lpCfg.ID),
surplusOn,
c.dwellSelectedPhaseMode(lpCfg.ID),
Expand Down
145 changes: 145 additions & 0 deletions go/internal/loadpoint/controller_bat_soc_unlock_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package loadpoint

import (
"context"
"testing"
"time"
)
Expand Down Expand Up @@ -184,3 +185,147 @@ func TestAnyLoadpointSurplusActive(t *testing.T) {
t.Error("after disarm, aggregator must report false")
}
}

// TestSurplusAddsToPlan_OnlyWithTargetAndArm pins the split between the
// two surplus questions (#1060): surplusActive answers "does surplus
// REPLACE the plan", surplusAddsToPlan answers "may surplus be ADDED on
// top of it". With a schedule target and SurplusOnly off the arm adds;
// without a target it replaces (today's path); SurplusOnly always wins.
func TestSurplusAddsToPlan_OnlyWithTargetAndArm(t *testing.T) {
soc := 0.85
surplus := 1500.0
c := armCtrl(&soc, &surplus)
cfg := Config{ID: "garage"}
withTarget := Schedule{SoC: 0.8, SurplusUnlockBatSoC: 0.8}
noTarget := Schedule{SurplusUnlockBatSoC: 0.8}

if !c.surplusAddsToPlan(cfg, withTarget) {
t.Error("target + bat 85% + PV: surplus must add to the plan")
}
if c.surplusActive(cfg, withTarget) {
t.Error("target set, surplus_only off: surplus must never replace the plan")
}

if c.surplusAddsToPlan(cfg, noTarget) {
t.Error("no target: the arm replaces the plan, it does not add to it")
}
if !c.surplusActive(cfg, noTarget) {
t.Error("no target + armed: surplus must replace the plan (unchanged path)")
}

cfg.SurplusOnly = true
if c.surplusAddsToPlan(cfg, withTarget) {
t.Error("surplus_only on: never additive")
}
if !c.surplusActive(cfg, withTarget) {
t.Error("surplus_only on: always surplus-active, schedule or not")
}
cfg.SurplusOnly = false

if c.surplusAddsToPlan(cfg, Schedule{SoC: 0.8}) {
t.Error("no threshold: nothing to arm")
}

soc = 0.5 // below threshold − hysteresis → releases
if c.surplusAddsToPlan(cfg, withTarget) {
t.Error("home battery 50% < 75% release floor: plan only")
}
}

// scheduledUnlockConfig is the shape the Scheduled tab produces: a target,
// "Also charge from PV surplus" and a "Home battery ≥ %" threshold saved
// together, with the PV-only flag off. Steps mirror an Easee at 230 V:
// 1380 W is 1Φ-only, {4140, 6900, 11000} are 3Φ-eligible under the
// default 3680 W phase split.
func scheduledUnlockConfig() Config {
return Config{
ID: "garage",
DriverName: "easee",
MinChargeW: 1380,
MaxChargeW: 11000,
AllowedStepsW: []float64{0, 1380, 4140, 6900, 11000},
}
}

// scheduledUnlockTick wires a plugged-in scheduled loadpoint (target 80 %
// by 07:00, unlock at home battery ≥ 80 %) with a plan budget for the
// current 15-minute slot, a home-battery SoC and a live PV surplus, ticks
// once, and returns the command that reached the driver plus what the
// manager recorded as commanded.
func scheduledUnlockTick(t *testing.T, cfg Config, budgetWh, batSoC, surplusW float64) (sentCommand, float64, string) {
t.Helper()
base := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
sender := &fakeSender{}
dir := &Directive{
SlotStart: base.Add(-1 * time.Second),
SlotEnd: base.Add(15 * time.Minute),
LoadpointEnergyWh: map[string]float64{cfg.ID: budgetWh},
}
samples := map[string]EVSample{cfg.DriverName: {Connected: true, PowerW: 0, RequestActive: true}}
c := newTestController(t, []Config{cfg}, dir, samples, sender)
c.manager.SetSchedule(cfg.ID, Schedule{
SoC: 0.8, TimeOfDayMinUTC: 7 * 60, Recurring: true, SurplusUnlockBatSoC: 0.8,
})
c.SetBatSoCProvider(func() (float64, bool) { return batSoC, true })
c.SetSiteSurplusForEV(func() (float64, bool) { return surplusW, true })

c.Tick(context.Background(), base)

if len(sender.calls) != 1 {
t.Fatalf("want exactly one command, got %d", len(sender.calls))
}
w, r := commandedReason(t, c, cfg.ID)
return sender.calls[0], w, r
}

// TestTickScheduleUnlockAddsSurplusOverEmptyPlan is the bug in #1060: with
// a target set, the threshold control was inert. Home battery 85 % ≥ 80 %,
// 4.5 kW of spare PV, the plan has 0 W for this slot → the car gets the
// snapped surplus step (4140 W, nearest 3Φ-eligible step), not 0 W, and
// the reason says the watts came from surplus.
func TestTickScheduleUnlockAddsSurplusOverEmptyPlan(t *testing.T) {
cfg := scheduledUnlockConfig()
sent, w, r := scheduledUnlockTick(t, cfg, 0, 0.85, 4500)
if sent.power != 4140 || w != 4140 || r != "pv_surplus" {
t.Fatalf("want 4140 W / pv_surplus; sent %.0f W, recorded (%.0f W, %q)", sent.power, w, r)
}
// A scheduled charge keeps the schedule's phase behaviour: "auto" for
// an unset operator mode, never the surplus 1Φ lock.
if sent.phaseMode != "auto" {
t.Errorf("phase_mode = %q, want auto while a schedule is active", sent.phaseMode)
}
}

// TestTickScheduleUnlockNeverThrottlesPlan keeps the 2026-05-30 directive:
// the plan wants 11 kW of grid charge for the slot (2750 Wh over 15 min);
// 4.5 kW of surplus must not clamp it. The plan wins and keeps its reason.
func TestTickScheduleUnlockNeverThrottlesPlan(t *testing.T) {
cfg := scheduledUnlockConfig()
sent, w, r := scheduledUnlockTick(t, cfg, 2750, 0.85, 4500)
if sent.power != 11000 || w != 11000 || r != "plan" {
t.Fatalf("want 11000 W / plan; sent %.0f W, recorded (%.0f W, %q)", sent.power, w, r)
}
}

// TestTickScheduleUnlockBelowThresholdIsPlanOnly: home battery at 50 % is
// below the 80 % threshold, so the arm stays off and the empty plan slot
// commands 0 W as before. Nothing may be attributed to surplus.
func TestTickScheduleUnlockBelowThresholdIsPlanOnly(t *testing.T) {
cfg := scheduledUnlockConfig()
sent, w, r := scheduledUnlockTick(t, cfg, 0, 0.5, 4500)
if sent.power != 0 || w != 0 || r == "pv_surplus" {
t.Fatalf("want 0 W from the plan; sent %.0f W, recorded (%.0f W, %q)", sent.power, w, r)
}
}

// TestTickScheduleWithSurplusOnlyStillClampsToSurplus: with the PV-only
// flag on, a schedule changes nothing — surplus-only wins and the 11 kW
// plan is clamped to the live surplus step, exactly as before #1060.
func TestTickScheduleWithSurplusOnlyStillClampsToSurplus(t *testing.T) {
cfg := scheduledUnlockConfig()
cfg.SurplusOnly = true
sent, w, r := scheduledUnlockTick(t, cfg, 2750, 0.85, 4500)
if sent.power != 4140 || w != 4140 || r != "pv_surplus" {
t.Fatalf("want 4140 W / pv_surplus; sent %.0f W, recorded (%.0f W, %q)", sent.power, w, r)
}
}
7 changes: 7 additions & 0 deletions go/internal/loadpoint/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ func (s *Schedule) UnmarshalJSON(b []byte) error {
return nil
}

// HasTarget reports whether the schedule commits to a SoC by a deadline.
// A target makes the plan the floor of automatic dispatch: the runtime
// surplus clamps may add to it but never throttle it (see
// Controller.surplusActive and surplusAddsToPlan, and the planner spec
// gate in main.go).
func (s Schedule) HasTarget() bool { return s.SoC > 0 }

// Empty reports whether the schedule carries no operator intent. The
// persistence layer writes nothing on Empty so a stale-loadpoint
// schedule on disk is naturally GC'd when the operator clears it via
Expand Down