Skip to content

control+mpc: plan is a scheduler, not a regulator - #43

Merged
frahlg merged 1 commit into
masterfrom
fix/planner-self-feedforward
Apr 16, 2026
Merged

control+mpc: plan is a scheduler, not a regulator#43
frahlg merged 1 commit into
masterfrom
fix/planner-self-feedforward

Conversation

@frahlg

@frahlg frahlg commented Apr 16, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the broken planner_self mode that was commanding batteries to charge at 100% SoC and thrashing between charge/discharge every cycle.

Root cause

The old wiring used the plan's forecast `grid_w` as the PI setpoint. When actual load drifted from the forecast (household appliances cycling on/off), the PI over-corrected in the wrong direction — commanding charge when it should idle, or discharge when SoC was empty.

New design

The plan is a scheduler (decides WHEN), not a regulator (decides HOW). `SlotAt()` translates each slot's `(battery_w, planner_mode)` into an EMS mode string + grid target that dispatch already knows how to execute:

Planner mode Plan says charge Plan says idle Plan says discharge
`planner_self` self_consumption (target=0) self_consumption (target=0) self_consumption (target=0)
`planner_cheap` charge self_consumption (target=0) self_consumption (target=0)
`planner_arbitrage` charge self_consumption (target=0) peak_shaving (target=plan grid_w)

For `planner_self`, the effective mode is ALWAYS `self_consumption` with `grid_target=0`. The PI drives batteries to cover load from SoC and charge from PV surplus — the same behavior as bare self_consumption. The planner's schedule decides when to switch strategies (e.g. cheap_charge at night), but never overrides the PI's real-time regulation.

Breaking change

`PlanTargetFunc` signature changed from `func(time.Time) (float64, bool)` to `func(time.Time) (string, float64, bool)` — returns `(mode_string, grid_w, ok)` instead of just `(grid_w, ok)`. Only consumer is `main.go` which is updated.

Test plan

  • `go test ./internal/control/` — all pass including EV + slew tests.
  • `go test ./internal/mpc/` — all pass.
  • Full `go test ./...` green including e2e.
  • Deploy to homelab-rpi, switch to "Smart self-consumption (planner)", verify batteries discharge to cover load with grid near 0.

🤖 Generated with Claude Code

The planner's per-slot output now translates to an EMS MODE + grid
target — the dispatch's existing mode logic decides HOW batteries
respond. Before this change, the planner emitted grid_w as a PI
setpoint and the dispatch chased it. When reality (actual load) drifted
from the forecast, the PI over-corrected: commanding batteries to
charge at full SoC, or thrashing between charge/discharge every cycle.

New flow:
  1. Planner optimises battery_w per slot (DP, unchanged).
  2. SlotAt() translates (battery_w, planner_mode) → (ems_mode, grid_target):
     - planner_self  → always self_consumption + grid_target=0
     - planner_cheap → "charge" when plan charges, self_consumption otherwise
     - planner_arb   → "charge" / "peak_shaving" / self_consumption
  3. Dispatch runs its normal PI + distribution + slew + fuse guard using
     the effective mode. No special planner code path downstream.

Result: planner_self behaves like self_consumption (grid=0, battery
covers load from SoC, charges from PV surplus) with the planner
deciding WHEN to switch modes, not HOW to regulate every cycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@frahlg
frahlg merged commit 1908500 into master Apr 16, 2026
@frahlg
frahlg deleted the fix/planner-self-feedforward branch April 16, 2026 04:13

@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: e478b89007

ℹ️ 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".

return "charge", 0, true
}
if a.BatteryW < -100 {
return "peak_shaving", a.GridW, true

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 Map arbitrage discharge to a target-following mode

Translating arbitrage discharge slots to peak_shaving here breaks planned export behavior, because ComputeDispatch's ModePeakShaving path does not regulate toward GridTargetW and instead only reacts to over-peak import or existing export; as a result, planned negative-grid arbitrage slots typically will not drive discharge to the scheduled target, which can nullify export/arbitrage outcomes in production.

Useful? React with 👍 / 👎.

end := a.SlotStartMs + int64(a.SlotLenMin)*60*1000
if nowMs >= a.SlotStartMs && nowMs < end {
return a.GridW, true
return actionToSlot(a, s.Defaults.Mode)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use cached plan mode when translating slot actions

SlotAt interprets actions using s.Defaults.Mode rather than the cached plan's mode, so after SetMode changes defaults, any failed/partial replan (which can return early without replacing s.last) causes old actions to be reinterpreted under a different policy; this can issue materially different commands than the stored plan (for example turning prior self-consumption charge actions into forced charge mode) until the plan is refreshed or ages out.

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.0.1 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant