Skip to content

fix(loadpoint): PV surplus adds to a scheduled charge once the home battery is above the threshold - #1061

Merged
frahlg merged 1 commit into
masterfrom
1060-surplus-unlock-with-schedule
Sep 4, 2026
Merged

fix(loadpoint): PV surplus adds to a scheduled charge once the home battery is above the threshold#1061
frahlg merged 1 commit into
masterfrom
1060-surplus-unlock-with-schedule

Conversation

@frahlg

@frahlg frahlg commented Sep 4, 2026

Copy link
Copy Markdown
Member

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.go
    • surplusActive: doc only, plus sched.SoC > 0sched.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, SurplusOnly is off and the bat-SoC arm is on. Exactly one of the two predicates calls evalBatSoCArm per tick, so the arm's hysteresis counters advance once.
    • tickOne: computes surplusAdds next to surplusOn; in the non-hold branch, after the surplus-only clamp and before the wake-kick, takes max(plan, computeSurplusCmd(MaxChargeW)) when surplusAdds. CommandedReason becomes pv_surplus only when surplus lifted the watts; otherwise the plan's reason stands. resolvePhaseMode gets sched.HasTarget() instead of sched.SoC > 0.
    • IsBatSoCArmed: doc only.
  • go/internal/loadpoint/schedule.go: Schedule.HasTarget(), the one predicate the controller and main.go share.
  • go/cmd/ftw/main.go, MPC loadpoint spec builder: batSoCArmed is now IsBatSoCArmed(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. SurplusOnly on behaves exactly as before, schedule or not. No web/ changes; the UI already sends the right fields.

Why

The Scheduled tab saves soc, surplus_unlock_bat_soc, time_of_day_min_utc and recurring in one POST /api/loadpoints/{id}/target. surplusActive returned false for any schedule with a target before reaching evalBatSoCArm, 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 covered Schedule{SurplusUnlockBatSoC: 0.8} with SoC == 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

  • Surplus never throttles a planned grid charge: the command is max(plan, surplus).
  • Stale surplus reader: computeSurplusCmd returns 0, so the plan stands. Stale battery SoC: the arm keeps its previous state, as before.
  • Stale site meter: the standdown runs before any of this and still sends 0 W.
  • Phase: resolvePhaseMode still sees the schedule as active, so a grid charge keeps its 3p/auto behaviour. pickSurplusSteps already refuses the day-long 1Φ lock when SurplusOnly is off, so the additive path cannot set it.
  • Planner: the arm under a schedule target is no longer reported as surplus-only, so a plan computed at noon still carries tonight's grid charge.
  • Out of scope: the leftover-PV-beside-battery-grid-charge formula in fix(mpc): surplus-only EV can take leftover PV while the home battery grid-charges #957; SurplusReserveW; the UI.

Verification

From the worktree:

cd go && go test ./internal/loadpoint/ ./internal/mpc/ ./internal/api/ -count=1
ok  github.com/srcfl/ftw/go/internal/loadpoint
ok  github.com/srcfl/ftw/go/internal/mpc
ok  github.com/srcfl/ftw/go/internal/api
cd go && go vet ./...        # clean
make verify                  # passed (exit 0: Go + Python suites, compose migration, container boundary, release workflow, vet, build)

New tests in go/internal/loadpoint/controller_bat_soc_unlock_test.go, all through Controller.Tick with 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), reason pv_surplus, phase_mode auto.
  • TestTickScheduleUnlockNeverThrottlesPlan: same, plan 11 kW (2750 Wh over 15 min) → 11000 W, reason plan.
  • 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, TestAnyLoadpointSurplusActive and the controller_surplus*_test.go suites pass unchanged.

Not covered by a unit test: the main.go spec-builder gate lives inside main(); it is one boolean and reads as written.

Closes #1060

Checklist

🤖 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 (surplusAddsToPlan when a target is set, SurplusOnly is off, and the bat-SoC arm is on). During tickOne, the commanded watts become max(plan, snapped surplus); surplus never reduces a deadline-driven grid charge, and pv_surplus is 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 SurplusOnly when 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.

…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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T05:39:44.172375Z 9aa2d72 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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)

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 👍 / 👎.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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)

Copy link
Copy Markdown

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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Left a non-blocking comment: Cursor Bugbot reported 1 potential issue (check also completed as skipped), so this PR is not auto-approved. Reviewers were assigned for human review.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@cursor
cursor Bot requested a review from erikarenhill September 4, 2026 05:42
@frahlg
frahlg merged commit f4430e5 into master Sep 4, 2026
17 checks passed
@frahlg
frahlg deleted the 1060-surplus-unlock-with-schedule branch September 4, 2026 05:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scheduled charge ignores "Also charge from PV surplus" unless PV-only is on

1 participant