fix(control): stale planner_self discharges to cover load but blocks PV charge - #292
Conversation
…PV charge The morning's "hold battery at 0 W on stale plan" fail-safe (a0676e7) was too aggressive: it stopped covering load with discharge while the planner was rebuilding, so the house imported through every restart and PV surplus exported instead of charging. Live failure on 2026-05-24: 60 kWh PV, ~1.5 kW grid import, batteries half-empty by evening. Replace with a narrower clamp: when planner_self has no fresh plan, run the reactive grid-zero regulator as classic self_consumption would (discharge to cover load — always safe, no price exposure), but pin the post-PI target at ≤ 0 W so PV surplus exports rather than silently absorbing during what may have been a planned export window — the original incident that motivated a fail-safe in the first place. The plan recovery from persisted diagnostics (restoreLatestMPCDiagnostic) and the 5 s missing-plan replan retry from a0676e7 stay; they shrink the stale window further. This change is only the fail-safe shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 59eaae0d72
ℹ️ 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".
| if noSelfCharge { | ||
| targetTotal2 := currentTotal + totalCorrection | ||
| if targetTotal2 > 0 { | ||
| totalCorrection = -currentTotal |
There was a problem hiding this comment.
Enforce stale-plan no-charge before deadband return
noSelfCharge is only applied after the legacy-path deadband early return, so in planner_self with a stale plan the function can return nil while batteries are still charging if |errW| < GridToleranceW (for example, existing battery charge already keeps grid near target). In that case the previous positive command is left in place and the stale-plan safeguard fails to stop PV absorption, which recreates the planned-export theft this change is trying to prevent.
Useful? React with 👍 / 👎.
…t stored energy could cover (#294) Operator's mental model of "smart self-consumption": EITHER we cover load reactively, OR we choose to export PV and idle the battery. The "smart" part is about WHEN to refill the battery, never about importing electricity at any price the operator can't see. Until now planner_self honoured the planner's per-slot idle / export-surplus preference even when live grid was importing, so a wrong PV forecast (cloud event, model drift) would plan-into-idle and silently leave the operator importing through it. Live 2026-05-24: 251 öre/kWh slot, 60 kWh PV day, batteries idle at 0 with live grid importing 700 W and 60 % SoC to spare — the plan thought PV was 1.4 kW when it was 189 W. Reshape the gates as charge-side constraints only: - plannerSelfIdleGate: reactive PI runs as in classic self_consumption. Charge is capped by the existing threshold-filtered surplus accounting (no churn from PI noise). Discharge is unconstrained — live import is always covered. - plannerSelfExportSurplusGate: reactive PI runs. Charge is blocked outright (preserve planned export). Discharge is unconstrained. - plannerSelfNoChargeStalePlan (from #292): unchanged shape, fits the same noSelfCharge gate naturally. The previous early-switch cases that overrode totalCorrection with charge-only logic are removed; both gates now fall into the default reactive-PI branch + apply post-PI clamps. Tests reshaped: every IdleGate test that asserted "hold during import" or "ramp to 0" is replaced by a convergence test against the new contract (cover load with discharge; absorb only true threshold-exceeding surplus on the charge side). New: TestPlannerSelfIdleGateDischargesToCoverLiveImport, TestPlannerSelfExportSurplusGateStillCoversLiveImport. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
This morning's "hold battery at 0 W on stale plan" fail-safe (a0676e7) was too aggressive — it stopped covering load with discharge while the planner was rebuilding. Live failure on 2026-05-24 was the textbook outcome: 60 kWh PV, ~1.5 kW grid import, batteries half-empty by evening.
The original incident that motivated a stale-plan fail-safe was the opposite problem (charging from PV during a planned export window). The narrower fix: keep the reactive grid-zero regulator running as classic self_consumption — discharge to cover load is always safe, no price exposure — but pin the post-PI target at ≤ 0 W so PV surplus exports instead of silently absorbing.
What changes
preparePlannerSelfnow returnsnoChargeOnStalePlan(renamed fromholdBatteryZeroGate).case plannerSelfHoldZeroGateswitch branch — stale plan falls into the reactive grid-zero path like classic self_consumption.noSelfChargeclamp mirrors the existingnoSelfDischargeclamp; it pins the post-PI target ≤ 0 when the stale-plan gate is on.restoreLatestMPCDiagnostic) and the 5 s missing-plan replan retry from a0676e7 stay — they shrink the stale window further. This PR only changes the shape of the fail-safe.Tests
TestPlannerSelfWithoutPlanHoldsBatteryIdlewithTestPlannerSelfStalePlanDischargesToCoverLoad(asserts discharge on import).TestPlannerSelfWithoutPlanStopsChargingWithoutSlewwithTestPlannerSelfStalePlanBlocksPVCharging(asserts target ≤ 0 on PV export).go test ./....🤖 Generated with Claude Code