Skip to content

feat(control): plan/exec sign-mismatch idle floor - #216

Closed
erikarenhill wants to merge 1 commit into
tesla-ble-driver-review-fixesfrom
feat/dispatch-sign-floor
Closed

erikarenhill wants to merge 1 commit into
tesla-ble-driver-review-fixesfrom
feat/dispatch-sign-floor

Conversation

@erikarenhill

Copy link
Copy Markdown
Collaborator

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

  • New `applyPlanSignFloor` runs between `applyFuseGuard` and `forceFuseDischarge` (so the fuse-saver still overrides it — fuse is non-negotiable).
  • Plan intent sourced from `SlotDirective.BatteryEnergyWh` (energy path) or `PlanTarget` mode/grid (legacy path). Mirrors `mpc.actionToSlot`'s mapping of "self_consumption + negative grid_w = planned discharge-to-export".
  • Manual modes (self_consumption, peak_shaving, charge, priority, weighted) are bypassed — they have no plan to disagree with.
  • Idle band of 100 W matches `mpc.IdleGateThresholdW` so near-zero plans or executions count as "no signed opinion" and skip the floor.

Why idle (not "follow the plan harder")

  • 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.

Test plan

  • 8 new tests pass: charge-on-discharge clamp, discharge-on-charge clamp, signs-agree pass-through, manual-mode no-op, idle-band no-op, legacy-path intent reading, no-intent no-op, full ComputeDispatch e2e replicating today's incident.
  • Full Go suite + e2e green (29.8 s).
  • Deploy to pi@192.168.1.139 and watch logs for "plan/exec sign mismatch — clamping to idle for this tick" warnings during the next discharge-at-peak slot.

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

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