Skip to content

fix: anti-cycling discharge gate over-values stored energy when solar already covers all load #204

Description

@johanzander

Symptom

Brief BATTERY_EXPORT discharges (~0.1 kWh, 4% discharge rate) get scheduled in
solar-surplus periods where the battery is full and solar already covers 100% of
home load — i.e. there is no grid import happening either way. Net financial
effect is a small loss (≈ −0.04 to −0.05 SEK per occurrence), not a gain, and
no inverter safety concern (4% of max discharge power is trivial). Low severity,
but a real and precisely-located bug, reproducible on any sunny day matching the
state below.

Supersedes #196, which reached this same conclusion but left the thread
self-contradictory (see "Relationship to #196" below).

Root cause

_compute_reward's anti-cycling discharge gate in
core/bess/dp_battery_algorithm.py
(discharge branch, ~lines 353–389):

avoid_purchase_value = current_buy_price * battery_settings.efficiency_discharge
export_value = current_sell_price * battery_settings.efficiency_discharge
effective_value_per_kwh_stored = max(avoid_purchase_value, export_value)

effective_cost_basis = cost_basis
if current_sell_price > current_buy_price:
    effective_cost_basis = max(effective_cost_basis, current_sell_price)
else:
    excess_solar = max(0.0, solar_production - home_consumption)
    capacity_after_discharge = battery_settings.max_soe_kwh - next_soe
    if excess_solar > POWER_TOLERANCE_KW and capacity_after_discharge > SOE_STEP_KWH:
        effective_cost_basis = max(effective_cost_basis, current_sell_price)

if effective_value_per_kwh_stored <= effective_cost_basis:
    return float("-inf"), cost_basis

avoid_purchase_value (buy_price × efficiency_discharge) values the discharge
as if it avoids a grid purchase. But in a period where solar already covers all
home load, there is no purchase to avoid — the discharged kWh can only be
exported. Because effective_value_per_kwh_stored takes the max() of the two
terms, avoid_purchase_value dominates whenever buy_price > sell_price
(the normal case), making the gate's assessed value far higher than what the
discharge can actually realize (export_value only). This lets marginal,
unprofitable discharges slip past the -inf anti-cycling floor.

The excess_solar / capacity_after_discharge branch just above is meant to
catch this by raising effective_cost_basis toward sell_price — but it never
touches effective_value_per_kwh_stored, which is where the actual leak is. If
cost_basis is already above sell_price (the normal case for a battery that's
been sitting full for a while), that branch is a no-op and the leak goes
unguarded.

Evidence

From docs/bess-debug-2026-06-28-113159.md (full bundle attached as a gist
below), period 63 (15:45), 2026-06-28:

  • solar_production = 0.893 kWh, home_consumption = 0.155 kWh → solar fully
    covers load, zero grid import regardless of battery action.
  • buy_price = 1.0568, sell_price = 0.46126, efficiency_discharge = 0.95,
    cost_basis = 0.6219 (FIFO-tracked, includes wear cost already paid at
    charge time).
  • avoid_purchase_value = 1.0568 × 0.95 = 1.004
  • export_value = 0.46126 × 0.95 = 0.438
  • effective_value_per_kwh_stored = max(1.004, 0.438) = 1.004
  • Gate check: 1.004 <= 0.622False → gate does not block.
  • A correctly-scoped gate (using export_value only, since there's no purchase
    to avoid) would check 0.438 <= 0.622True → blocks, correctly.

This pattern held across 6 consecutive re-optimization runs that day
(10:16, 10:31, 10:46, 11:01, 11:16, 11:31) — captured via
scripts/extract_decision_evidence.py <bundle> --period 63:

  • 5 of 6 runs (10:16–11:16): intent=BATTERY_EXPORT, battery_action=-0.1 kWh,
    inverter TOU segment 15:45-15:59 grid_first discharge=4%.
  • 1 of 6 runs (11:31, the latest in this bundle): intent=SOLAR_EXPORT,
    battery_action≈0 — this run happened to have shadow_price=0.876 > export_value=0.438, so the backward-induction value function chose idle over
    the marginal discharge even though the -inf gate itself still didn't fire.
    In other words: the gate itself was permissive in all 6 runs; only the
    value-function comparison saved the last one.

The SOE_STEP_KWH = 0.1 discretization constant
(core/bess/dp_battery_algorithm.py:106) matches the observed 0.1 kWh action
size — this is the DP's smallest possible discharge step, consistent with a
value function where keeping capacity open is worth slightly more than a single
marginal step's export value, but not by much (hence the flip-flopping across
runs as forecasts shifted).

Proposed fix direction (verify before implementing)

When the period has zero counterfactual grid import for the discharge to
displace (the same excess_solar condition already computed in the branch
above), exclude avoid_purchase_value from the max() — use export_value
only for effective_value_per_kwh_stored in that case.

This is a fix to the -inf hard gate inside _compute_reward, not a new
min_action_profit_threshold parameter (that was the design considered and
withdrawn in #196). It does not touch the separate, correctly-working
shadow_price-based apply-time gate (solar_export_discharge_rate() in
core/bess/battery_system_manager.py:69-86), which governs sub-15-minute
solar-dip coverage for periods already classified SOLAR_EXPORT and is
unrelated to this bug.

Suggested regression test

Solar > home load, battery full, export_value < cost_basis < avoid_purchase_value × efficiency_discharge — assert the DP does not select a
discharge action for that period.

Relationship to #196

#196 investigated this same symptom and landed on the same two candidate causes
(shadow-price volatility across runs, and the anti-cycling gate over-valuing
stored energy in solar-surplus slots), but the issue thread became
self-contradictory: the body was edited to withdraw a proposed
sell_price − cycle_cost floor as a "gross-value fallacy" (correctly — cost_basis
already bakes in wear cost via FIFO accounting at charge time, so a second flat
margin floor is a disconnected heuristic that doesn't address the actual bug),
but a comment posted minutes later still argued for exactly that withdrawn
approach, and no later comment reconciled the two. #196 is being closed in favor
of this issue, which confirms candidate B as the actual, provable root cause
(not just a plausible candidate) with a specific code location and fix
direction.

Full debug bundle

Root evidence for the numbers above:
https://gist.github.com/johanzander/bfa40b76632f7b44ded109abb8de2ea3
(docs/bess-debug-2026-06-28-113159.md, 2026-06-28 11:31:59 export — 96-period
schedule JSON + system logs from 00:00 to 11:31 that day)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bot-analyzedTriage bot has processed this issuebugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions