Skip to content

refactor(loadpoint): extract EV dispatch into Controller (#172, PR 1/3) - #173

Merged
frahlg merged 1 commit into
masterfrom
172-loadpoint-controller-refactor
Apr 22, 2026
Merged

refactor(loadpoint): extract EV dispatch into Controller (#172, PR 1/3)#173
frahlg merged 1 commit into
masterfrom
172-loadpoint-controller-refactor

Conversation

@frahlg

@frahlg frahlg commented Apr 22, 2026

Copy link
Copy Markdown
Member

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.Controller with zero behaviour change, so the follow-ups can iterate on the controller without touching the main loop again.

What

Moves:

  • control.SnapChargeW + control.EnergyBudgetToPowerWloadpoint (they were only used by EV dispatch; control is the site-PI package)
  • The ~65-line inline block in main.goloadpoint.Controller.Tick

Wiring:

  • Controller takes function-typed deps (PlanFunc, TelemetryFunc, SenderFunc) to keep loadpoint independent of mpc and telemetry. mpc already imports loadpoint for DP planning — the cycle must go this way.
  • main.go builds short adapter closures over mpcSvc.SlotDirectiveAt, tel.Get, and reg.Send, then constructs the controller once MPC is fully wired.
  • Tick is nil-safe, so MPC-less deployments behave exactly as before (no EV dispatch fires).

Behaviour equivalence

Same on every axis that matters:

  • 5 s tick cadence (fires from the same ticker.C case)
  • Energy-allocation contract: remaining_wh × 3600 / remaining_s → snap to allowed steps
  • Explicit 0 W standdown when the plan has no allocation (prevents riding the previous setpoint)
  • Unplugged loadpoints skip the command step entirely
  • loadpoint.Manager.Observe called with the same plug/session/power values on every tick
  • Exact same JSON payload: {\"action\": \"ev_set_current\", \"power_w\": …}

Tests

New behaviour-equivalence suite in loadpoint/controller_test.go:

  • unplugged → no command sent
  • plugged + no plan → 0 W sent (not skipped)
  • plugged + budget → snapped W
  • plan exists but no allocation for this loadpoint → 0 W
  • mid-slot already-delivered subtraction
  • Observe pipeline carries plug/power/session through
  • no-loadpoints no-op
  • driver send error doesn't panic

SnapChargeW + EnergyBudgetToPowerW test file moved verbatim. make test + make e2e both green.

Out of scope

  • PR 2: per-driver command_min_interval_s + goroutine-per-loadpoint
  • PR 3: phase/step state machine with hysteresis
  • V2X driver: separate track (blocked on DC2 MQTT spec)

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.
@frahlg

frahlg commented Apr 22, 2026

Copy link
Copy Markdown
Member Author

@erikarenhill — would appreciate a field test on this one before we merge.

What's in the PR

Pure refactor: the EV-charger dispatch that used to live inline in the 5 s control loop now lives in a dedicated loadpoint.Controller. Zero behaviour change is the design goal — same cadence, same energy-allocation math, same snap-to-allowed-steps, same explicit 0 W standdown when the planner has nothing for this slot, same JSON payload over the wire.

Why it matters

This is the foundation for a bigger rework of how we control EV chargers, which I think hurts real installs today:

  • Tempo mismatch. We currently command the charger every 5 s. Battery inverters respond in <200 ms and genuinely need that cadence (grid balancing is a per-second physics problem). AC EV chargers don't — Easee cloud has ~30 s latency via the REST API, OCPP-local is maybe 10 s, Zap is ~5 s. Commanding every 5 s is wasteful and invites thrashing when the target drifts across a step boundary.
  • No actuator state. The current dispatch doesn't know which phase (1/3) or which current step (6–16 A) the charger is on. So no hysteresis, no "don't thrash on A-steps," no "phase-switching is expensive so wait 5 minutes." Every tick is memoryless.
  • One loop for very different things. Battery and EV share the same tick today, which couples their error modes. A slow Easee cloud call can delay the battery path.

The plan (discussed with @frahlg):

  1. PR 1 (this one): lift dispatch into a Controller, no behaviour change. Locks in the abstraction before we change anything risky.
  2. PR 2: driver-declared command_min_interval_s + one goroutine per loadpoint, so Easee-slow doesn't starve Zap-fast.
  3. PR 3: phase/step state machine with hysteresis (A-step: 10 % for 60 s; phase switch: 5 min dwell + target out of current phase's reach).
  4. V2X track (separate): DC2 V2X sits on the EnergyHub DC bus and is bidirectional — we're going to drive it through the battery capability interface, not the EV path. Waiting on DC2 MQTT spec before we start.

What to test

Since this PR is behaviour-identical, the test is essentially "does my current EV charging still work exactly the same way?" Specifically:

  • Plug in, watch the Easee charger get commands at the usual 5 s cadence
  • Watch session_wh, plug state, and estimated SoC update in the /api/loadpoints view
  • Unplug mid-session — the charger should stop receiving ev_set_current commands (not 0 W spam, just no commands)
  • Plug back in — fresh session anchor, SoC starts from plugin_soc_pct
  • Watch a planner slot transition — the snap to allowed steps should hit exactly the same levels as before
  • If you ever run without a planner configured (unlikely but): no EV dispatch activity at all

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: loadpoint dispatch warnings would be new and would indicate the driver returned an error (they existed before, same wording).

Thanks 🙏

@frahlg

frahlg commented Apr 22, 2026

Copy link
Copy Markdown
Member Author

@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

Finding Type Ticket
1 Downstream of #2 — no action
2 Real UI gap #175
3 Collapses into #4 — no action
4 Real rendering bug #174

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
frahlg merged commit a8f0989 into master Apr 22, 2026
1 check passed
@frahlg
frahlg deleted the 172-loadpoint-controller-refactor branch April 22, 2026 14:29
@erikarenhill

Copy link
Copy Markdown
Collaborator

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

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.

refactor: extract EV dispatch into loadpoint.Controller (PR 1/3)

2 participants