From 9aa2d728b00c6ebcf0fb37b82226f495cd4ebb91 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 4 Sep 2026 07:33:34 +0200 Subject: [PATCH] fix(loadpoint): PV surplus adds to a scheduled charge once the home battery is above the threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field finding (#1060): the Scheduled tab saves a target together with "Also charge from PV surplus" and the "Home battery ≥ %" threshold, but Controller.surplusActive returned false for any schedule with a target before it reached evalBatSoCArm. The threshold control was inert unless the operator also switched the loadpoint to PV-only, and then the schedule's grid charge was clamped to live surplus instead. surplusActive keeps its meaning — surplus REPLACES the plan — and still says no under a schedule target (operator directive 2026-05-30). A new surplusAddsToPlan answers the other question: with a target set, SurplusOnly off and the bat-SoC arm on, tickOne takes max(plan, computeSurplusCmd(MaxChargeW)). Surplus can only lift the command above the plan, never throttle it; commanded_reason says pv_surplus only when surplus raised the watts. Exactly one of the two predicates evaluates the arm per tick, so its hysteresis counters advance once. Phase selection still sees the schedule as active, so a 3Φ grid charge keeps its phase and the additive path never sets the surplus 1Φ lock. Manual holds, the fuse clamp, the stale-meter standdown and the wake-kick are untouched and still run after. main.go's planner spec no longer marks the loadpoint surplus-only for an arm under a schedule target: the planner must keep planning the grid charge the deadline needs. Schedule.HasTarget is the one predicate both sides read. Co-Authored-By: Claude Fable 5.1 --- .changeset/schedule-surplus-unlock.md | 5 + go/cmd/ftw/main.go | 8 +- go/internal/loadpoint/controller.go | 74 +++++++-- .../controller_bat_soc_unlock_test.go | 145 ++++++++++++++++++ go/internal/loadpoint/schedule.go | 7 + 5 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 .changeset/schedule-surplus-unlock.md diff --git a/.changeset/schedule-surplus-unlock.md b/.changeset/schedule-surplus-unlock.md new file mode 100644 index 00000000..a6ee1bb8 --- /dev/null +++ b/.changeset/schedule-surplus-unlock.md @@ -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. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index c8d35f14..c577c7e3 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -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 diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 9f1fae94..f123912c 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -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. @@ -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) { @@ -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 @@ -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 { @@ -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) // 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 @@ -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 @@ -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), diff --git a/go/internal/loadpoint/controller_bat_soc_unlock_test.go b/go/internal/loadpoint/controller_bat_soc_unlock_test.go index ed89cdda..6c929098 100644 --- a/go/internal/loadpoint/controller_bat_soc_unlock_test.go +++ b/go/internal/loadpoint/controller_bat_soc_unlock_test.go @@ -1,6 +1,7 @@ package loadpoint import ( + "context" "testing" "time" ) @@ -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) + } +} diff --git a/go/internal/loadpoint/schedule.go b/go/internal/loadpoint/schedule.go index 75c1ee83..9ef00dcf 100644 --- a/go/internal/loadpoint/schedule.go +++ b/go/internal/loadpoint/schedule.go @@ -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