Skip to content

feat(control): plan-grid soft reactive cap on energy-dispatch path - #285

Merged
erikarenhill merged 4 commits into
masterfrom
feat/dispatch-energy-path-respects-plan-grid-v2
May 20, 2026
Merged

erikarenhill merged 4 commits into
masterfrom
feat/dispatch-energy-path-respects-plan-grid-v2

Conversation

@erikarenhill

@erikarenhill erikarenhill commented May 20, 2026 •

Copy link
Copy Markdown
Collaborator

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. useEnergyPath executes BatteryEnergyWh as remainingWh × 3600 / remainingS without consulting live gridW. When forecast PV doesn't materialise, the formula still demands the planned charge power and grid fills the gap. The reactive replan in mpc/service.go:266-290 would 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 gridW forecast (already computed by the DP as mpc.Action.GridW) as a charge-direction-only soft reactive cap on targetTotalW. When targetTotalW > 0 (charge slot) AND live gridW − 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 gridW more negative than plan) is intentionally NOT clamped:

  • The battery still delivers its planned discharge Wh — the "extra" export comes from load undershooting forecast, not over-discharging. Backing off would leave Wh in the battery for a future slot the DP already evaluated and rejected against this one.
  • Economics are asymmetric: extra import during a charge slot costs real money (paying for energy the plan assumed PV would supply, often at peak prices the DP would never have chosen for charging); extra export during a discharge slot is bonus revenue at the slot's chosen export price.
  • The discharge-direction divergence (live import > plan during a discharge slot, e.g. load surged) is left to the reactive replan + downstream clamps (fuse, SoC floor, EV-discharge cap).

The opposite-direction case for charge slots (live gridW more negative than plan, PV came in higher than forecast) is handled by the existing PV surplus absorber that opportunistically adds charge.

PlannedGridW is a pointer on control.SlotDirective so existing tests and legacy callers opt out via nil. Wired through main.go's SlotDirectiveFunc adapter.

Files

  • internal/control/dispatch.go — PlannedGridW *float64 field + charge-only cap logic in useEnergyPath
  • internal/mpc/service.go — SlotDirective.GridW populated from a.GridW
  • internal/mpc/service_test.go — assertion that SlotDirectiveAt propagates GridW (silent-regression guard)
  • cmd/forty-two-watts/main.go — bridges mpc.SlotDirective.GridW → control.SlotDirective.PlannedGridW
  • internal/control/control_test.go — regression tests
  • docs/safety.md — new §8 "Plan-grid soft reactive cap" documenting mechanics + asymmetry rationale

Test plan

  • go test ./internal/control/... — green
  • go test ./... — green including e2e
  • Regression: cap fires when PV drops mid-charge-slot
  • Regression: cap does NOT fire on discharge slot with extra export (guards against re-symmetrising)
  • Regression: deadband (≤100 W divergence) keeps cap silent
  • Regression: nil PlannedGridW (legacy callers) bypasses cap
  • Wiring test: SlotDirectiveAt propagates GridW from Action.GridW
  • Field-deployed to dev Pi running planner_arbitrage (v0.78.1-2-g64d07dc, container healthy)
  • Field validation during a cloud-pass charge slot — confirm battery target backs off and grid stays near plan

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, added docs/safety.md §8
  • 228b99c — Copilot review pass: docstring drift fixes, dropped duplicate ptr helper, added SlotDirectiveAt.GridW test

🤖 Generated with Claude Code

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread go/internal/control/dispatch.go Outdated
Comment thread go/internal/mpc/service.go Outdated
Comment thread go/internal/mpc/service.go
Comment thread go/cmd/forty-two-watts/main.go
Comment thread go/internal/control/control_test.go Outdated
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.
@erikarenhill
erikarenhill merged commit 9231df1 into master May 20, 2026
1 of 2 checks passed
@frahlg
frahlg deleted the feat/dispatch-energy-path-respects-plan-grid-v2 branch August 7, 2026 08:16
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.

2 participants