Skip to content

Optimizer over-credits solar export: planned charge power is not applied (charge_rate fixed at 100%) #145

Description

@johanzander

Summary

The optimizer's reported savings are inflated because it credits solar export that the applied inverter control cannot actually deliver. Root cause: the control layer encodes the optimizer's planned discharge power but not its planned charge power — charging periods always apply charge_rate = 100%, so the battery charges all available surplus solar instead of the planned partial amount. The surplus the optimizer planned to export is stored instead, and the export revenue it booked never materializes.

Surfaced by the closed-loop savings simulator (PR #144) on its first run: executing the optimizer's own plan via the control modes does not reproduce the planned economics (R ≠ P).

Evidence (real reproduction day, 2026-06-16)

Full-day: planned −45.50 SEK vs simulated-realized −40.11 SEK11.85% of the planned savings is not realized, concentrated in SOLAR_STORAGE periods.

Concrete period — P44 (11:00), solar 1.9, home 0.2, sell 0.96:

plan:  charge 0.10 kWh, EXPORT 1.60 kWh @ 0.96  → books ~1.5 SEK export revenue
real:  SOLAR_STORAGE → load_first, charge_rate 100% → charges ~1.7 kWh, EXPORT 0

Across the morning the battery climbs 2.4 → 20 kWh (full by ~13:00) instead of holding near the floor and exporting; then, full, it is forced to export the cheaper midday solar the plan wanted to store. Net: it stores expensive solar and exports cheap — the inversion of the optimal strategy.

Actual measured SoE in the log held at ~2.6–3.2 kWh through 06:00–09:00 because that window was applied as a solid EXPORT_ARBITRAGE/grid_first block (export works there). The bug bites in the later load_first window (10:30–18:00 in the applied schedule); the log ends at 09:01 so the midday actual SoC isn't captured.

Root cause

InverterController._map_intent_to_rates: EXPORT_ARBITRAGE scales discharge_rate from the planned battery power, so discharge is faithfully applied. But SOLAR_STORAGE/GRID_CHARGING apply a fixed charge_rate = 100% — the planned charge power is discarded. The optimizer's reward model (_compute_reward) then computes grid export from the energy balance assuming the planned (partial) charge, crediting export of the remainder — export that the 100%-charge execution never produces.

Modes are priority orders (home → battery → grid for load_first; home → grid → battery for grid_first), and load_first does export the excess once the battery is full or surplus exceeds the charge rate — so this is not about export being impossible in load_first. It is purely that applied charge ≠ planned charge.

Proposed fix

Make charge faithfully controllable: scale charge_rate from the planned charge power, mirroring the existing discharge_rate scaling for EXPORT_ARBITRAGE. Then applied charge == planned charge, and grid export (in any mode) falls out of the energy balance correctly — no export-by-mode gating and no edge-case special-casing needed.

Invariant to restore: applied battery power == planned battery power, every period.

Assumption to confirm: the Growatt MIN charge_rate % limits charge power the same way discharge_rate % limits discharge power (EXPORT_ARBITRAGE already relies on the discharge side).

Verification

This is an optimizer/control-contract economics change — covered by the slow suite, and now directly verifiable by the simulator (PR #144): after the fix, verify_plan_faithfulness should give R == P (realized == planned) on representative scenarios.

Relationship to #141

This is the root cause behind the morning behaviour #141 targeted. #141's hysteresis treats the cosmetic mode-dither; this issue is the underlying economic defect. Fixing charge faithfulness makes the dither economically harmless (it may still need a separate, lower-priority cosmetic fix). Recommend addressing this first and re-evaluating #141 afterward.

Refs: PR #144 (simulator), docs/investigations/simulator-plan-faithfulness-finding.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions