Repository navigation
feat(control): plan/exec sign-mismatch idle floor - #216
Closed
erikarenhill wants to merge 1 commit into
Closed
erikarenhill wants to merge 1 commit into
erikarenhill wants to merge 1 commit into
Conversation
Operator report 2026-04-28 08:00–08:15 CEST: planner_arbitrage peak
slot wanted battery_w = -2400 W (discharge to export at 334 öre/kWh
peak). Dispatch produced +1640..+1860 W charge, swallowing 1.7 kW of
PV surplus that should have been sold at peak. Root cause was
upstream (the negative grid target from actionToSlot didn't reach
the PI setpoint); this commit installs the safety rail that makes
that whole class of bug a no-op going forward, regardless of which
upstream path produced the wrong sign:
plan says discharge, exec produces charge → idle this tick
plan says charge, exec produces discharge → idle this tick
Idle is the right floor for both directions:
- Discharging a charge slot would burn cycles against operator intent.
Idling and waiting for the next replan is harmless.
- Charging a discharge slot would buy energy at the exact slot the
planner intended to SELL it. Idling and letting PV export naturally
captures most of the lost revenue without any risk.
applyPlanSignFloor runs between applyFuseGuard and forceFuseDischarge,
so a real fuse overflow can still drive battery discharge regardless
of plan intent. Manual modes (self_consumption, peak_shaving, charge,
priority, weighted) skip the floor entirely — they have no plan to
disagree with.
Plan intent comes from (first non-empty wins):
1. SlotDirective.BatteryEnergyWh — the energy-allocation path
2. PlanTarget mode_string + grid_target — the legacy path. Mirrors
mpc.actionToSlot's mapping of "self_consumption + negative
grid_w = planned discharge-to-export".
Idle band of 100 W matches mpc.IdleGateThresholdW so a near-zero plan
or near-zero exec count as "no signed opinion" and skip the floor.
Tests cover: charge-on-discharge-slot clamp, discharge-on-charge-slot
clamp, signs-agree pass-through, manual-mode no-op, idle-band no-op,
legacy-path intent reading, no-intent no-op, and a full ComputeDispatch
end-to-end reproducing the 06:00-06:15 UTC scenario.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merged
5 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stacked on #213. Installs the safety rail discussed after today's 08:00–08:15 CEST incident — when sign(plan_intent) disagrees with sign(executed total) under a planner mode, every battery target is forced to idle for that tick.
What happened (the trigger)
Planner_arbitrage peak slot wanted `battery_w = -2400 W` (discharge to export at 334 öre/kWh peak). Dispatch produced `+1640..+1860 W` charge instead, swallowing 1.7 kW of PV surplus that should have been sold. Root cause was upstream — the negative grid target from `mpc.actionToSlot` didn't reach the PI setpoint — but the operator-facing damage is the same regardless of which path produced the wrong sign.
What this changes
Why idle (not "follow the plan harder")
Test plan
Follow-up (out of scope)
PR #2 from the discussion: replace the legacy PI's blind `grid=plan_target` chase with directional self_consumption (discharge slot allows only `[-maxDischarge, 0]`, charge slot allows only `[0, +maxCharge]`). That's the proper fix for the underlying execution model; this PR is the rail underneath it.
🤖 Generated with Claude Code