fix(loadpoint): PV surplus adds to a scheduled charge once the home battery is above the threshold - #1061
Conversation
…attery is above the threshold 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 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9aa2d728b0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // surplusAdds: surplus is ADDED on top of a scheduled plan. Never | ||
| // both true. | ||
| surplusOn := c.surplusActive(lpCfg, sched) | ||
| surplusAdds := c.surplusAddsToPlan(lpCfg, sched) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9aa2d72. Configure here.
| // surplusAdds: surplus is ADDED on top of a scheduled plan. Never | ||
| // both true. | ||
| surplusOn := c.surplusActive(lpCfg, sched) | ||
| surplusAdds := c.surplusAddsToPlan(lpCfg, sched) |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 9aa2d72. Configure here.




Accepted text proposal
Issue or Discussion: #1060
Maintainer comment that accepted this scope: none needed; opened from the maintainer's own session, and the issue states the scope.
What changed
Functions touched, so a rebase against #957 either way is cheap:
go/internal/loadpoint/controller.gosurplusActive: doc only, plussched.SoC > 0→sched.HasTarget(). Same meaning as before: "surplus REPLACES the plan". Still returns false under a schedule target with PV-only off (directive 2026-05-30).surplusAddsToPlan(new, right below it): true when a schedule target is set,SurplusOnlyis off and the bat-SoC arm is on. Exactly one of the two predicates callsevalBatSoCArmper tick, so the arm's hysteresis counters advance once.tickOne: computessurplusAddsnext tosurplusOn; in the non-hold branch, after the surplus-only clamp and before the wake-kick, takesmax(plan, computeSurplusCmd(MaxChargeW))whensurplusAdds.CommandedReasonbecomespv_surplusonly when surplus lifted the watts; otherwise the plan's reason stands.resolvePhaseModegetssched.HasTarget()instead ofsched.SoC > 0.IsBatSoCArmed: doc only.go/internal/loadpoint/schedule.go:Schedule.HasTarget(), the one predicate the controller andmain.goshare.go/cmd/ftw/main.go, MPC loadpoint spec builder:batSoCArmedis nowIsBatSoCArmed(id) && !sched.HasTarget(). An arm under a schedule target adds to the plan, so the planner must not be told the loadpoint is surplus-only or it plans away the grid charge the deadline needs. fix(mpc): surplus-only EV can take leftover PV while the home battery grid-charges #957 rewrites this same line (PlannerTreatsLoadpointAsSurplusOnly); both drop the arm from the spec, so whichever lands second merges in one line.Unchanged:
computeSurplusCmd,pickSurplusSteps,surplusLockedTo1P,resolvePhaseMode,evalBatSoCArm,AnyLoadpointSurplusActive, manual holds, fuse clamp and cooldown, stale-meter standdown, wake-kick,maybeWakeVehicle.SurplusOnlyon behaves exactly as before, schedule or not. Noweb/changes; the UI already sends the right fields.Why
The Scheduled tab saves
soc,surplus_unlock_bat_soc,time_of_day_min_utcandrecurringin onePOST /api/loadpoints/{id}/target.surplusActivereturned false for any schedule with a target before reachingevalBatSoCArm, so the "Home battery ≥ %" threshold did nothing unless the operator also flipped the loadpoint to PV-only in the PV tab; and then the schedule's grid charge was clamped to live surplus. The existing tests only coveredSchedule{SurplusUnlockBatSoC: 0.8}withSoC == 0, a shape the UI never produces.PV surplus is now a modifier of a schedule, not an early no. The plan is the floor; spare solar can only add to it.
Boundaries and safety
max(plan, surplus).computeSurplusCmdreturns 0, so the plan stands. Stale battery SoC: the arm keeps its previous state, as before.resolvePhaseModestill sees the schedule as active, so a grid charge keeps its 3p/auto behaviour.pickSurplusStepsalready refuses the day-long 1Φ lock whenSurplusOnlyis off, so the additive path cannot set it.SurplusReserveW; the UI.Verification
From the worktree:
New tests in
go/internal/loadpoint/controller_bat_soc_unlock_test.go, all throughController.Tickwith a real plan directive and the fake sender:TestTickScheduleUnlockAddsSurplusOverEmptyPlan: target 80 %, threshold 80 %, home battery 85 %, 4.5 kW surplus, plan 0 W for the slot → driver receives 4140 W (nearest 3Φ-eligible step), reasonpv_surplus,phase_modeauto.TestTickScheduleUnlockNeverThrottlesPlan: same, plan 11 kW (2750 Wh over 15 min) → 11000 W, reasonplan.TestTickScheduleUnlockBelowThresholdIsPlanOnly: home battery 50 % → 0 W from the plan, nothing attributed to surplus.TestTickScheduleWithSurplusOnlyStillClampsToSurplus: PV-only on + schedule → 11 kW plan clamped to 4140 W,pv_surplus, exactly as before.TestSurplusAddsToPlan_OnlyWithTargetAndArm: the predicate matrix (target/no target, PV-only on/off, no threshold, battery below release floor).Existing
TestSurplusActive_ScheduleOverridesRuntimeClamp,TestSurplusActive_NilProviderGracefullyOff,TestPickSurplusSteps_BatSoCArmedSkipsDailyLock,TestAnyLoadpointSurplusActiveand thecontroller_surplus*_test.gosuites pass unchanged.Not covered by a unit test: the
main.gospec-builder gate lives insidemain(); it is one boolean and reads as written.Closes #1060
Checklist
controller.godoc comments and the samemain.goline; fix(mpc): replay every plan and represent a true zero PV cap #971 touchessite_power.goonly).Co-Authored-Bytrailer, matching master's recent history; add-son squash if the DCO check requires it).🤖 Generated with Claude Code
Note
Medium Risk
Changes live EV charge dispatch and MPC loadpoint specs for scheduled charging; behavior is constrained by max(plan, surplus) and existing PV-only paths, with new tests.
Overview
Fixes #1060: scheduled charging with “Also charge from PV surplus” and a home battery ≥ % threshold was effectively ignored unless the loadpoint was in PV-only mode—and PV-only still clamped the planned grid charge to live surplus.
The dispatch layer now distinguishes surplus replacing the plan (
surplusActive, unchanged when there is no SoC target) from surplus stacking on the plan (surplusAddsToPlanwhen a target is set,SurplusOnlyis off, and the bat-SoC arm is on). DuringtickOne, the commanded watts becomemax(plan, snapped surplus); surplus never reduces a deadline-driven grid charge, andpv_surplusis only recorded when surplus actually raised power.Schedule.HasTarget()centralizes the “committed schedule” check for phase mode and surplus gating.MPC only treats a bat-SoC arm as
SurplusOnlywhen there is no schedule target, so the planner keeps scheduling the grid energy the deadline needs instead of surplus-only plans that diverge from execution.New tick-level tests cover empty plan slots, plan floor vs surplus, below-threshold behavior, and PV-only with a schedule. Patch changeset included.
Reviewed by Cursor Bugbot for commit 9aa2d72. Bugbot is set up for automated code reviews on this repo. Configure here.