Repository navigation
feat(control): plan-grid soft reactive cap on energy-dispatch path - #285
Merged
erikarenhill merged 4 commits intoMay 20, 2026
Merged
Conversation
Community report (planner_arbitrage): when a cloud cuts PV mid-charge-slot the EMS imports from grid to maintain the planned battery_w. Pre-energy-path behaviour was the opposite — battery absorbed only what surplus existed and grid stayed near plan. Root cause: useEnergyPath executes BatteryEnergyWh as `remainingWh × 3600 / remainingS` without consulting live gridW, so when forecast PV doesn't materialise the formula still demands the planned charge power and grid fills the gap until the reactive replan trigger fires (gated by the 500 Wh PV-error integral + 60 s cooldown — typically ≥10 min). Adds the plan's gridW forecast (mpc.Action.GridW, already computed by the DP) as a soft reactive cap on targetTotalW. When live gridW exceeds plan by more than 100 W in the dispatch direction (charge slot + extra import, or discharge slot + extra export), back off the target by the gap, floored at zero so the cap can't flip dispatch direction. PlannedGridW is a pointer on control.SlotDirective so existing tests / legacy callers opt out via nil — wired through from main.go's SlotDirectiveFunc adapter. Direction-asymmetric on purpose: the opposite-direction cases are handled upstream (PV surplus absorber adds charge on unexpected export during a charge slot; the next replan picks up unexpected import during discharge). Regression tests (control/control_test.go): - PV-drops-mid-charge-slot → battery target backs off to match available PV - planned-import slot (cheap_charge) → cap does not fire, plan honoured - live divergence inside 100 W deadband → cap stays off - nil PlannedGridW (legacy callers) → behaviour unchanged - symmetric discharge case: extra export → discharge backs off
Pre-merge review (PR #285) caught a semantic asymmetry: the symmetric discharge branch backed off when live gridW was more negative than plan, but that's bonus export revenue, not a problem. Battery delivers the planned Wh either way — the "extra" export comes from load undershooting forecast. Backing off would leave Wh in the battery for a future slot the DP already evaluated and rejected, undermining the plan's choice. Economics are asymmetric: extra import during a charge slot costs real money; extra export during a discharge slot is bonus revenue at the slot's chosen price. Removes the discharge branch from the cap in dispatch.go. Flips the matching test to assert the cap does NOT fire on discharge — that's now the regression guard against re-symmetrising it. Documents the clamp + asymmetry rationale as section 8 in docs/safety.md. Discharge-direction divergence (live import > plan, e.g. load surged) remains handled by the reactive replan + downstream clamps (fuse, SoC floor, EV-discharge cap).
Contributor
There was a problem hiding this comment.
Pull request overview
This PR addresses a regression in the energy-allocation (“energy dispatch”) execution path where, during a planned charge slot, unexpected PV drop could be compensated by unintended grid import to maintain the planned charge power. The fix plumbs the plan’s per-slot grid power forecast into dispatch and uses it as a soft reactive cap to reduce battery charge when live grid import exceeds the plan.
Changes:
- Add a per-slot planned grid power forecast (
GridW→PlannedGridW) and wire it from MPC → main adapter → control dispatch. - Apply a soft cap in
ComputeDispatch’s energy path to back off charging when live grid import exceeds the plan by a deadband. - Add regression tests for the cap behavior and document the mechanism in
docs/safety.md.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| go/internal/mpc/service.go | Adds SlotDirective.GridW and populates it from mpc.Action.GridW. |
| go/internal/control/dispatch.go | Introduces SlotDirective.PlannedGridW and applies the plan-grid soft cap in the energy-dispatch path. |
| go/cmd/forty-two-watts/main.go | Bridges mpc.SlotDirective.GridW into control.SlotDirective.PlannedGridW. |
| go/internal/control/control_test.go | Adds regression tests for the planned-grid cap behavior. |
| docs/safety.md | Documents the plan-grid cap rationale, mechanics, and intended asymmetry. |
Comments suppressed due to low confidence (1)
go/internal/control/dispatch.go:988
- The cap logic is explicitly gated by
targetTotalW > 0(charge slots only). The PR description mentions a symmetric discharge/export cap as well; either update the PR description (if charge-only is intended) or extend the implementation/tests to match the described discharge behavior.
if currentDirective.PlannedGridW != nil && targetTotalW > 0 {
const planGridDeadband = 100.0
gridErr := rawGridW - *currentDirective.PlannedGridW
if gridErr > planGridDeadband {
adjusted := targetTotalW - gridErr
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Five low-risk cleanups from the Copilot pre-merge review, no behaviour
change:
- dispatch.go: PlannedGridW docstring claimed the cap also covers
discharge slots ("(or, on a discharge slot, extra export)") — a
leftover from before the charge-only fixup. Rewritten to match the
charge-only implementation + pointer to docs/safety.md §8 for the
asymmetry rationale.
- mpc/service.go: SlotDirective.GridW docstring extended with the
charge-only caveat so the wiring's documented intent matches the
consumer.
- mpc/service_test.go: TestSlotDirectiveAt now asserts GridW
propagates from Action.GridW. Without this guard the cap could
silently never fire if the wiring broke — the control-side tests
wouldn't catch it because they construct SlotDirective directly.
- control_test.go: dropped the duplicate ptr[T any] helper; uses the
existing ptrF64 (line 72) for all four PlannedGridW test cases.
Item 4 from the review (heap allocation of *float64 per dispatch
tick in main.go) is deferred — minor GC pressure, and the suggested
fix (PlannedGridW float64 + HasPlannedGridW bool) is an API change
worth taking on its own.
Addresses Copilot review item #4 on PR #285: `PlannedGridW *float64` forced the SlotDirective bridge in main.go to take the address of a local variable, escape-allocating a *float64 every dispatch tick (≈17k/day on the Pi at the default 5 s control interval — small but unnecessary GC pressure). Replaces the pointer with `PlannedGridW float64 + HasPlannedGridW bool`. HasPlannedGridW=false is the new opt-out signal; the zero-value SlotDirective used by legacy callers / older tests behaves identically to the previous nil-pointer case. No behaviour change — the cap fires under the same conditions, just gates on a bool instead of a nil check. Wiring update in main.go's directive bridge: drops the `&plannedGridW` local, sets `PlannedGridW: d.GridW, HasPlannedGridW: true` directly. Tests updated: four PlannedGridW callsites switch from `ptrF64(X)` to `PlannedGridW: X, HasPlannedGridW: true`. The nil-bypass regression test (renamed `TestEnergyDispatchUnsetPlannedGridWBypassesCap`) now asserts the zero-value (HasPlannedGridW=false) path stays bypassed.
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
Fixes a regression reported on
planner_arbitrage: when a cloud cuts PV mid-charge-slot, the EMS imports from grid to maintain the plan's charge power instead of letting the battery absorb only what PV is actually delivering. Pre-energy-path behaviour was the opposite — battery soaked up only the available surplus, grid stayed near the plan.Root cause.
useEnergyPathexecutesBatteryEnergyWhasremainingWh × 3600 / remainingSwithout consulting livegridW. When forecast PV doesn't materialise, the formula still demands the planned charge power and grid fills the gap. The reactive replan inmpc/service.go:266-290would eventually catch up, but it's gated by a 500 Wh PV-error integral and a 60 s cooldown — typically ≥10 min of grid import before a fresh plan re-decides.Fix. Use the plan's own
gridWforecast (already computed by the DP asmpc.Action.GridW) as a charge-direction-only soft reactive cap ontargetTotalW. WhentargetTotalW > 0(charge slot) AND livegridW − plannedGridW > 100 W(importing more than plan, outside deadband), back off the target by the gap, floored at zero so the cap can never flip dispatch direction.Why charge-only. The mirror case (discharge slot, live
gridWmore negative than plan) is intentionally NOT clamped:The opposite-direction case for charge slots (live
gridWmore negative than plan, PV came in higher than forecast) is handled by the existing PV surplus absorber that opportunistically adds charge.PlannedGridWis a pointer oncontrol.SlotDirectiveso existing tests and legacy callers opt out via nil. Wired through main.go'sSlotDirectiveFuncadapter.Files
internal/control/dispatch.go—PlannedGridW *float64field + charge-only cap logic inuseEnergyPathinternal/mpc/service.go—SlotDirective.GridWpopulated froma.GridWinternal/mpc/service_test.go— assertion thatSlotDirectiveAtpropagatesGridW(silent-regression guard)cmd/forty-two-watts/main.go— bridgesmpc.SlotDirective.GridW→control.SlotDirective.PlannedGridWinternal/control/control_test.go— regression testsdocs/safety.md— new §8 "Plan-grid soft reactive cap" documenting mechanics + asymmetry rationaleTest plan
go test ./internal/control/...— greengo test ./...— green including e2ePlannedGridW(legacy callers) bypasses capSlotDirectiveAtpropagatesGridWfromAction.GridWplanner_arbitrage(v0.78.1-2-g64d07dc, container healthy)Review history
0219cae— original feature (charge + discharge cap)64d07dc— self-review caught the discharge branch was semantically wrong; removed it, flipped test to regression guard, addeddocs/safety.md§8228b99c— Copilot review pass: docstring drift fixes, dropped duplicateptrhelper, addedSlotDirectiveAt.GridWtest🤖 Generated with Claude Code