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 SEK → 11.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.
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 SEKvs simulated-realized−40.11 SEK→ 11.85% of the planned savings is not realized, concentrated inSOLAR_STORAGEperiods.Concrete period — P44 (11:00), solar 1.9, home 0.2, sell 0.96:
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_firstblock (export works there). The bug bites in the laterload_firstwindow (10:30–18:00in 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_ARBITRAGEscalesdischarge_ratefrom the planned battery power, so discharge is faithfully applied. ButSOLAR_STORAGE/GRID_CHARGINGapply a fixedcharge_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 forgrid_first), andload_firstdoes export the excess once the battery is full or surplus exceeds the charge rate — so this is not about export being impossible inload_first. It is purely that applied charge ≠ planned charge.Proposed fix
Make charge faithfully controllable: scale
charge_ratefrom the planned charge power, mirroring the existingdischarge_ratescaling forEXPORT_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 waydischarge_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_faithfulnessshould giveR == 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.