control+mpc: plan is a scheduler, not a regulator - #43
Conversation
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>
There was a problem hiding this comment.
💡 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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 👍 / 👎.
|
🎉 This PR is included in version 2.0.1 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
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:
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
🤖 Generated with Claude Code