Skip to content

bug: discharge gate reads a raw snapped V grid difference while the policy uses _interpolate_value — shadow price 42% above the optimizer's own objective #571

Description

@johanzander

Reported by @Frank-Leysen on #520 — Growatt MOD 5000TL3-XH, BESS 10.1.0b7, debug bundle.

Symptom

2026-08-13, SOLAR_STORAGE / Load First, EV charging + pool pump. ems_discharging_rate was 100% for every period 13:00→14:15, 0% for period 58 (14:30) only, then 100% again at 14:45 — one isolated period importing ~1.1 kW from grid with SOC at 65% and 8 kWh of headroom.

The seam

Policy selection (_best_action_at_continuous_state, dp_battery_algorithm.py:1518) and reported future_value (:1774, :2021) evaluate the value function through _interpolate_value at the continuous SOE. _record_marginal_value (:913) alone uses a raw backward difference between snapped grid cells:

shadow_price = (V[t, i] - V[t, i - 1]) / SOE_STEP_KWH   # :913
allowed      = buy_price_t * efficiency_discharge >= shadow_price   # :915

For SOLAR_STORAGE the planned discharge rate is 0, so max(planned, gate) (battery_system_manager.py:2686) makes this the entire hardware decision.

_record_marginal_value's own docstring cites the P1 mirrored-implementation bug class as the reason both call sites route through one function. The two value estimators were never unified the same way.

Evidence

Replay at the production-faithful horizon (134 periods, terminal value 0.20289 via _calculate_terminal_value, battery_system_manager.py:1923-2013) reproduces the recorded shadow price bit-for-bit. The result is identical across horizon 38/134 × terminal 0/0.20289.

1. Production's own objective contradicts it. Finite-differencing reward_objective_cost over the exact cell the shadow price claims to measure, on the production grid itself:

cell production objective _record_marginal_value reports
[9.8750, 9.9000] +0.17978 +0.25554

A +42% error — not a coarse-vs-fine comparison, an internal inconsistency.

2. Concavity violation in V[0].

cell [9.8250, 9.8500] -> +0.23602
cell [9.8500, 9.8750] -> +0.23602
cell [9.8750, 9.9000] -> +0.25554   <-- reported shadow_price
cell [9.9000, 9.9250] -> +0.17978
cell [9.9250, 9.9500] -> +0.17978

A single interior cell exceeding both neighbours. V(t,·) is concave in SOE for this problem (piecewise-linear reward, linear transition, convex feasible set), so this is np.round grid snapping (:1320, :1415) leaking into a one-cell difference.

3. Independent derivation from prices and physics. The plan from 14:30 charges on solar and saturates at max_soe = 15.0 kWh at period 71 (17:45). A marginal kWh stored at 14:30 therefore never reaches the evening BATTERY_EXPORT block — it displaces solar charging. A +0.25 kWh perturbation changes exactly one period's flows in 134 periods: period 71 grid_exported 0.052 → 0.310 kWh at sell 0.1404, less avoided charging wear.

sell(17:45) / efficiency_charge + cycle_cost
  = 0.1404 / 0.97 + 0.035
  = 0.1797       ≈ 0.17978

0.25554 corresponds to no price/physics combination that exists in this horizon.

4. The closed region is exactly one grid cell. Sweeping initial_soe at production resolution, all else fixed:

initial_soe (kWh) gate
9.8850 OPEN
9.8900 – 9.9100 CLOSED
9.9150 OPEN

CLOSED for soe ∈ [9.8875, 9.9125) — precisely the set where round((soe − 1.8) / 0.025) == 324. 25 Wh wide, OPEN on both sides, with the objective exactly linear at 0.17978 across it and beyond in both directions. No kink, so no economic boundary.

SoC reaches the optimizer as whole percent (every initial SoE in the bundle is an exact integer % of 15.0 kWh: 13.000, 14.000, … 66.000, 65.000), a 150 Wh quantum. 9.900 kWh = 66.000% lands dead-centre on the corrupted node.

Correct gate: 0.2581 × 0.95 = 0.24516 > 0.17978 ⇒ should have been OPEN, margin +0.06538 EUR/kWh.

Cost impact — small, file as correctness not money

There is a genuine kink at SOE ≈ 9.85: below it the battery no longer saturates at period 71, the kWh does reach the evening export, and the true marginal value rises to 0.23602 (which is exactly the resolution-stable value at 14:45 — a useful cross-check), collapsing the margin to +0.00914.

Integrating the real value function against what was actually metered for period 58 (grid_imported 0.2 kWh):

covered import avoided import opportunity loss net
0.2 kWh (metered) 0.05161 0.04684 +0.0048 EUR

Under half a eurocent for the one period that is in the data.

Scope caveat: the bundle was exported at 14:52, so it covers period 58 (CLOSED) and period 59 (OPEN, 100%) only. The reporter observed 0% again at 15:00 and 15:16 — periods 60/61 — which are not in this bundle and are therefore unverified. Whether those are the same artifact or a different cause needs a fresh export before the per-incident cost can be totalled.

Why existing issues don't cover this

Direction (not prototyped; docs/agents/optimizer-architecture.md is normative)

Make the gate consume the same value estimate the policy does.

Do not switch to a forward difference. _local_value_slope (:1447) already uses a forward difference at int(idx) while _record_marginal_value uses a backward difference at round(idx). For concave V a forward difference systematically under-values stored energy and would over-open the gate everywhere — it is only correct here by accident.

Any candidate fix should be measured against the fixture corpus for gate-flip rate between adjacent constant-price periods.

Separate concern, not this bug

The load forecast for this period was 1.96 kW against 4.8 kW actual (unmodelled EV charger + pool pump). That belongs with the vpp_power load-tracking discussion with @ridax67 on #520.

Metadata

Metadata

Assignees

No one assigned

    Labels

    analyzedRoot-cause diagnosis posted, awaiting @claude-bot fixbugSomething isn't workinghas-fix-prDraft PR opened by Stage 3 fix bot

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions