Skip to content

fix(mpc) + feat(web): plumb Loadpoint into diagnostic + EV target UI (#174, #175) - #176

Merged
frahlg merged 4 commits into
masterfrom
174-diagnostic-loadpoint-fields
Apr 25, 2026
Merged

frahlg merged 4 commits into
masterfrom
174-diagnostic-loadpoint-fields

Conversation

@frahlg

@frahlg frahlg commented Apr 22, 2026 •

Copy link
Copy Markdown
Member

Closes #174 and #175. Two related EV-charging finishes Erik flagged in field test, kept in one PR because together they make the planner's EV behaviour comprehensible end-to-end.


#174 — fix(mpc): plumb Loadpoint fields into diagnostic slots

Symptom

Erik's screenshot — a `cheap_charge` plan at 22:00 with `LOAD 1.60 kW`, `BATTERY −5.60 kW`, `GRID 137 W`. Math looks like 4 kW battery-driven export. Mode contract says "never exports via battery." Smells like a broken DP.

Reality

The DP is fine. `mpc.go:377` folds EV into the grid equation:

```go
gridW := slot.LoadW + slot.PVW + battW + evW
```

Slot actually looks like `LOAD 1.6 + EV 4.0 = 5.6 kW covered by battery, grid ≈ 0`. Contract honoured.

Fix

`DiagnosticSlot` didn't carry the EV decision even though `Action.LoadpointW` was sitting right there on the plan. The plan table in `web/diagnose.js` already has conditional EV columns gated on `slots.some(x => x.loadpoint_w || x.loadpoint_soc_pct)` — they just never appeared because the fields were always zero.

Two fields added to `DiagnosticSlot`, both `omitempty`, copied verbatim from `Action` in `buildDiagnostic`. No DP math changes. No UI changes.

Tests

`TestDiagnoseCarriesLoadpointFields` — builds a four-slot cheap-price horizon with a deadline-bound EV, asserts the diagnostic row's `LoadpointW` and `LoadpointSoCPct` match the planner's action for every slot, and that at least one slot reports non-zero charging (otherwise the UI's `lpActive` gate would stay false anyway).


#175 — feat(web): EV target SoC + deadline UI

Why

Without a `target_soc_pct` + `target_time` on a loadpoint, the MPC has no urgency penalty for the EV and rationally defers charging — especially in arbitrage mode, where idle PV gets exported for revenue rather than going into the car. Backend was wired (POST `/api/loadpoints/{id}/target`, `Manager.SetTarget`, replan trigger), but the only way to set the target was curl.

What

Per loadpoint, the EV modal now shows two rows:

```
SoC now: [val] [Set] (existing — re-anchors session)
Target: [SoC] % by [datetime] [Save] [Clear] (new)
```

The target row is reachable even when unplugged — target is user intent, valid to set ahead of plugging in. Empty datetime → no deadline (still sets the SoC target, which alone has no effect since the DP only applies the shortfall penalty when a deadline is present). Clear posts zeroes so the manager drops both fields.

Implementation notes

  • Vanilla DOM (matches the rest of `app.js` — no framework)
  • `parseTargetTime` treats anything before year 2000 as "unset" so Go's `omitempty`-on-`time.Time` quirk (zero time still serializes as `0001-01-01T00:00:00Z`) doesn't show a year-1 deadline in the picker
  • `toLocalInputValue` does the local-TZ shift for the `datetime-local` input so the picker shows local wall-clock instead of UTC

Verification status

Go side: full 571-test suite green. JS syntax-checked with `node --check`. Wire format verified against a live API (`POST /api/loadpoints/{id}/target` with `{soc_pct, target_time_ms}` returns the expected shape).

UI not yet visually verified in a real browser session — the dev server I had access to was serving a stale `app.js` from an older deployment. Field test on @erikarenhill's setup will close that loop.


Knock-on

These two fixes resolve the symptom-cluster Erik reported:

Field finding Resolution
"cheap charge plans more export than arbitrage" #174 — diagnostic now shows EV in the table, the apparent export disappears
"battery −5.6 kW for local load 1.6 kW" #174 — same
"EV doesn't charge in arbitrage" #175 — set a target deadline, DP gets shortfall penalty, charges
"no GUI for posting SoC target" #175

Field-reported by @erikarenhill.

frahlg added 2 commits April 22, 2026 16:35
When the DP plans EV charging alongside battery dispatch, the
plan-table UI would render `BATTERY -5.6 kW` against `LOAD 1.6 kW`
and look like a 4 kW battery-driven export. Reality: LOAD 1.6 +
EV 4.0 = 5.6 kW total covered by battery, grid ≈ 0. The `gridW`
equation in mpc.go:377 already included EV, but the diagnostic
row only carried household LoadW.

DiagnosticSlot now carries `LoadpointW` and `LoadpointSoCPct`
copied from the matching Action, both `omitempty`. The web
renderer (`web/diagnose.js`) already had conditional EV W + EV SoC
columns gated on `slots.some(x => x.loadpoint_w ||
x.loadpoint_soc_pct)`; plumbing the fields through is all that
was needed to make them appear on plans that actually include a
loadpoint.

Field-reported by @erikarenhill.
Field test surfaced that without a target_soc_pct + target_time
on a loadpoint, the MPC has no urgency penalty for the EV and
defers charging — especially in arbitrage mode, where idle PV
gets exported for revenue rather than going into the car. Backend
was wired (POST /api/loadpoints/{id}/target, Manager.SetTarget,
replan trigger), but the only way to set the target was curl.

Per loadpoint, the EV modal now shows two rows:

  SoC now: [val] [Set]               (existing — re-anchors session)
  Target:  [SoC] % by [datetime] [Save] [Clear]   (new)

The target row is reachable even when unplugged — target is user
intent, valid to set ahead of plugging in. Empty datetime → no
deadline (still sets the SoC target, which alone has no effect
since the DP only applies the shortfall penalty when a deadline
is present). Clear posts zeroes so the manager drops both fields.

Implementation notes:
- Vanilla DOM (matches the rest of app.js — no framework)
- parseTargetTime treats anything before year 2000 as "unset" so
  Go's omitempty-on-time.Time quirk (zero time still serializes
  as 0001-01-01T00:00:00Z) doesn't show a year-1 deadline in the
  picker
- toLocalInputValue does the local-TZ shift for the
  datetime-local input so the picker shows "Apr 23 07:00" instead
  of UTC

Not yet visually verified in a real browser session — the dev
server I had access to was serving a stale app.js from an older
deployment. Syntax-checked with node, fetch payload shape
verified to match handleLoadpointTarget against a live API. UI
verification will land via field test on @erikarenhill's setup.
@frahlg frahlg changed the title fix(mpc): plumb Loadpoint fields into diagnostic slots (#174) fix(mpc) + feat(web): plumb Loadpoint into diagnostic + EV target UI (#174, #175) Apr 22, 2026
@frahlg

frahlg commented Apr 22, 2026

Copy link
Copy Markdown
Member Author

@erikarenhill — this is the follow-up to your field findings from earlier. Both fixes you reported on are in here, would appreciate a verification pass before we merge.

What changed

#174 — Diagnostic shows EV charging now. When the planner has a loadpoint, the plan table at `/diagnose` should now show two extra columns ("EV W" and "EV SoC") between "SoC end" and "Cost". The "EV in plan" pill in the header should also light up. This is the fix for the "battery discharges 5.6 kW to cover 1.6 kW load" mystery — once you can see the EV column, the math will add up.

#175 — Target SoC + deadline UI. Open the EV modal (click the EV card on the dashboard). For each loadpoint you should now see two rows:

```
SoC now: [val] [Set]
Target: [SoC] % by [datetime] [Save] [Clear]
```

The target row is reachable whether the car is plugged in or not. Empty datetime = no deadline (charge opportunistically). Clear button drops both fields.

What to test

#174

  • Plan with no loadpoint configured: table looks identical to before (no EV columns, no "EV in plan" pill)
  • Plan with EV plugged in and a target set: EV W + EV SoC columns appear, slot rows show the planned charge power
  • The grid math now adds up: `LOAD + EV + BATTERY ≈ GRID` per row (within rounding)
  • EV SoC column climbs slot-over-slot as the plan charges the car

#175

  • Setting target SoC + deadline + Save → button flashes ✓ → modal refreshes → values persist on next open
  • Setting only SoC (empty datetime): saves, but planner won't apply shortfall penalty (no deadline). Verify by watching the plan — EV charges opportunistically only.
  • Setting both SoC and a deadline a few hours out: planner should commit to charging (look for non-zero EV W in early slots). This is the unblock for arbitrage mode you flagged.
  • Clear button: zeroes out both fields, planner drops the deadline, EV goes back to opportunistic.
  • datetime picker shows local wall-clock time (not UTC). E.g. if you set "23:00" you should see "23:00" on next reopen, not 22:00 / 00:00 depending on TZ.

Caveats

I couldn't visually verify the UI in a browser before pushing — the local dev server I had was serving a stale `app.js` from an older deployment, so the new function names didn't show up. Syntax-checked with `node --check` and the wire format matches the API, but real browser interaction is on you for this one. Yell if anything's off — I'd rather catch it now than after a flaky deploy.

Thanks 🙏

@frahlg
frahlg merged commit d2b818e into master Apr 25, 2026
1 check passed
@frahlg
frahlg deleted the 174-diagnostic-loadpoint-fields branch April 25, 2026 05:14
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.

bug(mpc): diagnostic LOAD column misses EV charge power — operators see phantom exports

1 participant