Skip to content

mpc: discharge in self-consumption instead of holding SoC forever - #40

Merged
frahlg merged 1 commit into
masterfrom
fix/self-consumption-discharge
Apr 16, 2026
Merged

mpc: discharge in self-consumption instead of holding SoC forever#40
frahlg merged 1 commit into
masterfrom
fix/self-consumption-discharge

Conversation

@frahlg

@frahlg frahlg commented Apr 15, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the second half of the "Smart self-consumption (planner)" bug we saw on homelab-rpi this afternoon. After #39 fixed `pv_w=0` leaking into the plan, this PR fixes `battery_w=0` — the DP was still emitting "idle — import to cover load" on every slot even with SoC=84%.

Root cause

`TerminalSoCPrice` defaulted to the mean retail import price regardless of mode. But in `ModeSelfConsumption` the battery cannot export (enforced by `modeAllows`). So stored energy is worth what it SAVES on future import (retail) MINUS what you'd otherwise have earned exporting that surplus (spot + bonus − fee).

Using the full retail price makes idle always beat discharge:

```
idle cost = P_retail × load × dt
discharge cost = 0 + P_retail × load × dt / η_discharge (terminal credit loss)
```

For discharge to win, `P_retail × η_discharge > P_retail` — impossible with η < 1. So the DP parks SoC forever.

Fix

For `ModeSelfConsumption` and `ModeCheapCharge` (neither can export battery energy), the new default terminal SoC price is mean(retail import) − mean(export rate), floored at 0. That's what one stored kWh actually earns in these constrained games.

`ModeArbitrage` keeps the old full-retail default — its battery CAN export, so retail is the right upper bound.

Verification

  • `TestSelfConsumptionTerminalPriceIsImportMinusExport` — math unit.
  • `TestSelfConsumptionTerminalPriceClampsToZero` — subsidy edge case.
  • `TestSelfConsumptionTerminalPriceEmpty` — no prices → 0.
  • `TestOptimizeSelfConsumptionDischargesWithSpreadTerminalPrice` — end-to-end DP regression: SoC=80%, PV=500W, load=3000W, four slots. Discharges on every slot.
  • `TestOptimizeSelfConsumptionDoesNotDischargeWithOldTerminalPrice` — guardrail: same scenario with the OLD value produces the OLD (wrong) behavior. So reverts flag immediately.
  • `go test ./...` green including e2e.
  • Deploy to homelab-rpi, switch to "Smart self-consumption (planner)", verify the plan emits `battery_w < 0` for evening-load slots.

Stack: #39 already merged, this builds on that.

🤖 Generated with Claude Code

The planner-self bug on live hardware: with SoC=84%, valid PV+load, the
DP still emitted battery_w=0 on every slot ("idle — import to cover
load"). Root cause: TerminalSoCPrice defaulted to the mean retail import
price for ALL modes, but in self_consumption the battery CANNOT export
(enforced by modeAllows). The only way to realize stored SoC is to cover
future load, which saves you retail import MINUS what you'd otherwise
have earned exporting that surplus.

Using the full retail price as SoC value means the DP compares:
  idle:     P_retail × load
  discharge: 0 + P_retail × load / η_discharge (terminal credit loss)
and idle always wins when η < 1.

Fix: for ModeSelfConsumption and ModeCheapCharge (neither can export via
battery), default TerminalSoCPrice to mean(TotalOreKwh) − mean(export
rate). That's what one stored kWh is actually worth in these constrained
games. Arbitrage mode keeps the old full-retail default — its battery
CAN export, so retail is the right upper bound on SoC value.

Floored at 0 so if export rates ever exceed retail (rare subsidy cases)
the planner goes SoC-neutral rather than actively draining.

Verified with a four-slot scenario: SoC=80%, PV 500W, load 3000W. Old
behavior: zero discharge on every slot. New behavior: discharges every
slot. Also kept a regression guard that the OLD value produces the OLD
(wrong) behavior, so we can't accidentally revert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@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: 218c5e323e

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

var importSum, exportSum float64
for _, pr := range prices {
importSum += pr.TotalOreKwh
exp := pr.SpotOreKwh + bonus - fee

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 Respect fixed export tariff in terminal SoC valuation

selfConsumptionTerminalPrice always derives export value from spot + bonus - fee, but Optimize gives ExportOrePerKWh precedence when a fixed feed-in tariff is configured. In replan, this helper is used as the default terminal price for self_consumption/cheap_charge, so setups with planner.export_ore_per_kwh get a mismatched terminal valuation and can bias battery hold/discharge decisions (especially near horizon end). The terminal spread calculation should follow the same export-price precedence as the optimizer.

Useful? React with 👍 / 👎.

@frahlg
frahlg merged commit a90d525 into master Apr 16, 2026
@frahlg
frahlg deleted the fix/self-consumption-discharge branch April 16, 2026 04:23
github-actions Bot pushed a commit to erikarenhill/forty-two-watts that referenced this pull request Apr 16, 2026
## [1.1.0](v1.0.1...v1.1.0) (2026-04-16)

### Bug Fixes

* 5 Go-side P1 bugs from Codex review ([srcfl#46](https://github.com/erikarenhill/forty-two-watts/issues/46)) ([0cd2885](0cd2885))
* 5 Go-side P1 bugs from Codex review ([srcfl#47](https://github.com/erikarenhill/forty-two-watts/issues/47)) ([4f2eaf6](4f2eaf6))
* **solaredge_pv:** read SunSpec scale factors every poll, not cached ([srcfl#38](https://github.com/erikarenhill/forty-two-watts/issues/38)) ([26f8793](26f8793))

### Drivers

* fix 9 P1 bugs flagged by Codex review ([srcfl#44](https://github.com/erikarenhill/forty-two-watts/issues/44)) ([b20e485](b20e485))

### UI

* show mode band in plan chart + grid target on status card ([877e0bd](877e0bd))

### Control loop

* slew-rate anchors on actual battery power, not stale command ([srcfl#41](https://github.com/erikarenhill/forty-two-watts/issues/41)) ([4f73f19](4f73f19))

### MPC planner

* log optimize params + ems_mode per action for plan chart ([9e8c14b](9e8c14b))
* value SoC at import−export spread in self-consumption modes ([srcfl#40](https://github.com/erikarenhill/forty-two-watts/issues/40)) ([a90d525](a90d525))
@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 📦🚀

github-actions Bot pushed a commit that referenced this pull request Apr 16, 2026
## [0.4.0](v0.3.0...v0.4.0) (2026-04-16)

### Features

* config/UI improvements — kWh display, secure EV password, planner tab ([#65](#65)) ([35ab03d](35ab03d))
* Easee Cloud driver + host.http_get/post for Lua drivers ([#56](#56)) ([4cdc942](4cdc942))
* EV charger config + credential masking in API responses ([#58](#58)) ([c22cb80](c22cb80))

### Bug Fixes

* 5 Go-side P1 bugs from Codex review ([#46](#46)) ([0cd2885](0cd2885))
* 5 Go-side P1 bugs from Codex review ([#47](#47)) ([4f2eaf6](4f2eaf6))
* address P2 review comments across control, MPC, drivers, and UI ([#64](#64)) ([fcafa88](fcafa88))
* **ci:** disable @semantic-release/github PR annotation features ([4020d46](4020d46)), closes [#32](#32) [#33](#33) [#34](#34) [#35](#35) [#36](#36) [#39](#39)
* **ci:** switch semantic-release to conventionalcommits preset ([7e0bb89](7e0bb89))
* **ci:** upgrade GitHub Actions to Node.js 24 (drop deprecated Node 20) ([4005bd8](4005bd8))
* Lua driver Command() reading wrong field — Sungrow ignored targets ([9237156](9237156))
* populate EV Charger tab from driver config when ev_charger is empty ([5e6b116](5e6b116))
* remove dead evSlider event listeners that crash app.js ([8ae76c7](8ae76c7))
* replace wonky Catmull-Rom spline with simple linear forecast ([abea431](abea431))
* show '...' instead of stale v0.1.0 while JS loads version ([dc65065](dc65065))
* **solaredge_pv:** read SunSpec scale factors every poll, not cached ([#38](#38)) ([26f8793](26f8793))

### Drivers

* add Eastron SDM630 Lua driver ([#18](#18)) ([d5ad806](d5ad806))
* add Ferroamp Modbus driver (alt transport to ferroamp.lua) ([#31](#31)) ([03b802c](03b802c))
* fix 9 P1 bugs flagged by Codex review ([#44](#44)) ([b20e485](b20e485))
* port Deye SUN-SG hybrid inverter to 42W v2.1 Lua host ([#29](#29)) ([df8fbc0](df8fbc0))
* port Fronius GEN24 (SunSpec) to Lua ([#19](#19)) ([c1fc875](c1fc875))
* port Fronius Smart Meter (SunSpec Modbus, read-only) ([#24](#24)) ([575895c](575895c))
* port GoodWe hybrid inverter (ET-Plus / EH) to Lua v2.1 ([#28](#28)) ([e43d2d9](e43d2d9))
* port Growatt hybrid inverter driver (read-only) ([#20](#20)) ([92524ac](92524ac))
* port Huawei SUN2000 hybrid inverter ([#15](#15)) ([09a8855](09a8855))
* port Kostal Plenticore / Piko IQ (Lua, read-only) ([#21](#21)) ([bdeca96](bdeca96))
* port Pixii PowerShaper battery driver to v2.1 Lua host ([#22](#22)) ([70a96d1](70a96d1))
* port SMA hybrid inverter Lua driver ([#23](#23)) ([dd34555](dd34555))
* port Sofar HYD-ES/HYD-EP from hugin to Lua v2.1 ([#26](#26)) ([14f6131](14f6131))
* port SolarEdge SunSpec inverter + meter to Lua (read-only) ([#30](#30)) ([1007e63](1007e63))
* port Solis hybrid inverter ([#27](#27)) ([98b2a50](98b2a50))
* port Victron Energy GX Modbus driver ([#25](#25)) ([ad71db2](ad71db2))

### UI

* add status bar with driver health indicators ([b048d60](b048d60))
* EV status card + Easee control commands ([#59](#59)) ([b03749a](b03749a))
* fix summary cards grid for 7 cards + raise side-by-side breakpoint ([6e19973](6e19973))
* inline target on hover + driver card + collapsible model cards ([de88f43](de88f43))
* legend wrap + nice-tick y-axis + cleaner chart labels ([#33](#33)) ([aeb1d1c](aeb1d1c))
* remove manual EV charging slider ([063174c](063174c))
* show mode band in plan chart + grid target on status card ([877e0bd](877e0bd))
* smooth Catmull-Rom spline for forecast + 15min forecast zone ([dba51a5](dba51a5))

### Control loop

* fold live DerEV readings into the EV clamp ([#36](#36)) ([5d57d68](5d57d68))
* slew-rate anchors on actual battery power, not stale command ([#41](#41)) ([4f73f19](4f73f19))

### MPC planner

* fall back to forecast when learned PV twin collapses ([#39](#39)) ([f3062ac](f3062ac))
* log optimize params + ems_mode per action for plan chart ([9e8c14b](9e8c14b))
* value SoC at import−export spread in self-consumption modes ([#40](#40)) ([a90d525](a90d525))

### Telemetry

* add DerEV type for EV charger readings ([#34](#34)) ([65c9e2c](65c9e2c))

### TSDB

* long-format SQLite (14d) + Parquet rolloff for older ([c53c964](c53c964))

### Safety

* watchdog marks stale drivers offline + reverts to autonomous ([519196c](519196c))
frahlg added a commit that referenced this pull request Jul 28, 2026
Brings the whole absent-register campaign into the recovery snapshot:
every bundled driver now gives up on a register the device does not
answer instead of paying a failed read for it on every poll.

Generated by scripts/sync-bundled-drivers.sh. The SemVer gate added in
#709 verified the move across all 37 bundled drivers.

HELD: go test ./internal/drivers/ fails on this snapshot.
TestSungrowZeroBatteryCommandForcesIdle expects a battery command
issued straight after load to write idle; device-drivers #40 made the
driver refuse a battery command unless the model named itself a hybrid
or a battery register has answered, and neither is true before the
first poll. That disagreement is a control-path decision and is not
mine to settle -- see the pull request for both positions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
frahlg added a commit that referenced this pull request Jul 29, 2026
TestSungrowZeroBatteryCommandForcesIdle sent a battery setpoint to a
freshly loaded driver with no poll in between. The mock answers 0
everywhere, so register 4999 gives no device type, detection settles on
"unknown", and device-drivers #40 refuses the write.

The refusal is right. Sungrow ships two families behind one driver and
an SG string inverter implements none of 13049-13051; writing them
anyway is the SG12RT bug, and the read-back that would catch it fails
on that device too, so the driver reports success.

Nothing is lost by confirming first. The test's own assertions show it
was never testing a release: it expects EMS mode 2 -- forced -- with
command 0xCC and setpoint 0. That is a dispatch to a battery pinned at
zero, not a device handed back. The release is driver_default_mode,
which FTW reaches through SendDefault on shutdown, lease expiry and the
watchdog, and which no guard touches.

So the mock now answers 4999 as an SH8.0RT and the driver polls once
before the command. What the test checks is unchanged.

Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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