refactor(loadpoint): extract EV dispatch into Controller (#172, PR 1/3) - #173
Conversation
Pure-refactor step 1 of the EV architecture reshape. The per-tick EV dispatch that used to live inline in main.go's control loop now lives in loadpoint.Controller. Behaviour is identical: same 5 s cadence, same energy-allocation contract (remaining_wh × 3600 / remaining_s), same snap to allowed steps, same explicit standdown to 0 W when the planner has no allocation. The separation exists so follow-up PRs can give each loadpoint its own goroutine + driver-declared cadence and a phase/step state machine without disturbing battery dispatch. Moves: - SnapChargeW + EnergyBudgetToPowerW: control → loadpoint. They were only used by EV dispatch; control is the site-PI package. - The ~65-line inline dispatch block in main.go → Controller.Tick. Wiring: - Controller takes function-typed deps (PlanFunc, TelemetryFunc, SenderFunc) rather than importing mpc/telemetry. mpc already imports loadpoint for DP planning — the cycle must go this way. - main.go builds short adapters over mpcSvc.SlotDirectiveAt + tel.Get + reg.Send and constructs the controller once mpc is fully wired. Tick is nil-safe, so mpc-less deployments behave exactly as before (no EV dispatch). Tests: - Behaviour-equivalence test suite covering: unplugged skips, plugged-no-plan → 0 W, plugged-with-budget → snapped W, missing loadpoint ID → 0 W, mid-slot already-delivered subtraction, Observe pipeline, no-loadpoints no-op, send-error no-panic. - Snap + energy helpers move their existing test file verbatim. - e2e + api + mpc packages all pass unchanged.
|
@erikarenhill — would appreciate a field test on this one before we merge. What's in the PRPure refactor: the EV-charger dispatch that used to live inline in the 5 s control loop now lives in a dedicated Why it mattersThis is the foundation for a bigger rework of how we control EV chargers, which I think hurts real installs today:
The plan (discussed with @frahlg):
What to testSince this PR is behaviour-identical, the test is essentially "does my current EV charging still work exactly the same way?" Specifically:
If anything looks different from your usual experience, that's a bug — the whole point of this PR is "I changed nothing observable." Logs to watch: Thanks 🙏 |
|
@erikarenhill — circling back on the findings you posted earlier in Discord. Worth noting none of them are caused by this PR (this one is a pure refactor, no behaviour change), but we went through the code to diagnose them so we can fix the real ones cleanly. Finding 1: "EV doesn't charge in arbitrage, requires self_consumption"Not a bug — expected behaviour. Arbitrage is unrestricted in the DP (`mpc.go:730`), while self_consumption + cheap_charge have mode gates that force the DP to cover local load, which means EV effectively gets PV surplus "for free." In arbitrage, that same surplus can be exported for money, so the DP sees no reason to charge the car unless there's a deadline. The real root is Finding 2. Finding 2: "No GUI or automation for EV SoC target"Real UI gap. Backend is fully wired — `POST /api/loadpoints/{id}/target` in `api.go:1758`, `Manager.SetTarget`, replan trigger, state persistence. What's missing is any web UI to call it. That's why you saw EV charging "turn off" without a target — no urgency penalty means the DP defers. Opened #175 to track the UI work. Once that lands, arbitrage will schedule EV charging too. Finding 3: "Cheap charging plans more export than arbitrage"Not a DP bug — it's Finding 4 in disguise. The code correctly folds EV into both the grid equation (`mpc.go:377`) and the mode baseline (`mpc.go:383`). "Never exports via battery" is actually being honoured. The math you saw was `BATTERY −5.6 kW` vs `LOAD 1.6 kW` → looks like 4 kW export. Reality: `LOAD 1.6 + EV 4.0 = 5.6 kW total, battery covers all, grid ≈ 0`. Finding 4: "EV charging missing from LOAD, not in the diagnosis"Real rendering bug. `diagnose.go:113` writes `LoadW: slot.LoadW` — household only. `Action.LoadpointW` is on the planner's Action struct but never makes it into `DiagnosticSlot`, so the UI table has nowhere to display it. That's the whole reason the grid math looks wrong. Opened #174 to fix this. Collapses #3 on the way — you'll be able to see `LOAD 1.6 + EV 4.0` and the battery discharge will suddenly make sense. Summary
The diagnostic fix (#174) is the first one we'll pick up — it'll make your plan screenshots self-explanatory and make #3 disappear as a side effect. The EV target UI (#175) is the bigger piece but unblocks arbitrage-mode EV charging. Thanks for the detailed report — way easier to diagnose with the screenshots and the exact numbers. 🙏 |
|
@frahlg for finding 1 , in my opinion the EV charging should indeed be able to charge from surplus also in arbitrage mode. So what happens when price is 0 for 8 hours on a sunny day? The typical home battery of 10-20kWh will be full in 2-3 hours, then the rest could (and should) go into a connected car if possible, better than selling for 0 and charge over night paying grid transfers. |
Closes #172 — Phase 1 (pure refactor, no behaviour change).
Why
EV charging currently runs inline in the 5 s battery control tick (
main.go:1075–1138). We want each loadpoint to own its own goroutine with a driver-declared cadence (Easee cloud ≈ 30 s, Zap local ≈ 5 s), plus a phase/step state machine with hysteresis. That reshape is risky to do in one shot.This PR is the foundational step: lift the inline dispatch into a new
loadpoint.Controllerwith zero behaviour change, so the follow-ups can iterate on the controller without touching the main loop again.What
Moves:
control.SnapChargeW+control.EnergyBudgetToPowerW→loadpoint(they were only used by EV dispatch;controlis the site-PI package)main.go→loadpoint.Controller.TickWiring:
Controllertakes function-typed deps (PlanFunc,TelemetryFunc,SenderFunc) to keeploadpointindependent ofmpcandtelemetry.mpcalready importsloadpointfor DP planning — the cycle must go this way.main.gobuilds short adapter closures overmpcSvc.SlotDirectiveAt,tel.Get, andreg.Send, then constructs the controller once MPC is fully wired.Tickis nil-safe, so MPC-less deployments behave exactly as before (no EV dispatch fires).Behaviour equivalence
Same on every axis that matters:
ticker.Ccase)remaining_wh × 3600 / remaining_s→ snap to allowed stepsloadpoint.Manager.Observecalled with the same plug/session/power values on every tick{\"action\": \"ev_set_current\", \"power_w\": …}Tests
New behaviour-equivalence suite in
loadpoint/controller_test.go:SnapChargeW+EnergyBudgetToPowerWtest file moved verbatim.make test+make e2eboth green.Out of scope
command_min_interval_s+ goroutine-per-loadpoint