Skip to content

fix(control): stale planner_self discharges to cover load but blocks PV charge - #292

Merged
frahlg merged 1 commit into
masterfrom
fix/planner-self-stale-discharge-only
May 24, 2026
Merged

fix(control): stale planner_self discharges to cover load but blocks PV charge#292
frahlg merged 1 commit into
masterfrom
fix/planner-self-stale-discharge-only

Conversation

@frahlg

@frahlg frahlg commented May 24, 2026

Copy link
Copy Markdown
Member

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

  • preparePlannerSelf now returns noChargeOnStalePlan (renamed from holdBatteryZeroGate).
  • Dispatch loses the case plannerSelfHoldZeroGate switch branch — stale plan falls into the reactive grid-zero path like classic self_consumption.
  • New noSelfCharge clamp mirrors the existing noSelfDischarge clamp; it pins the post-PI target ≤ 0 when the stale-plan gate is on.
  • Post-slew force-zero shortcut loses the hold-zero term — stale plan rides normal slew now.
  • Plan recovery (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

  • Replaced TestPlannerSelfWithoutPlanHoldsBatteryIdle with TestPlannerSelfStalePlanDischargesToCoverLoad (asserts discharge on import).
  • Replaced TestPlannerSelfWithoutPlanStopsChargingWithoutSlew with TestPlannerSelfStalePlanBlocksPVCharging (asserts target ≤ 0 on PV export).
  • Full suite green: go test ./....

🤖 Generated with Claude Code

…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>

@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: 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".

Comment on lines +1393 to +1396
if noSelfCharge {
targetTotal2 := currentTotal + totalCorrection
if targetTotal2 > 0 {
totalCorrection = -currentTotal

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

@frahlg
frahlg merged commit 8cabffe into master May 24, 2026
1 check passed
@frahlg
frahlg deleted the fix/planner-self-stale-discharge-only branch May 24, 2026 17:55
frahlg added a commit that referenced this pull request May 24, 2026
…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>
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.

1 participant