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.622 → False → gate does not block.
- A correctly-scoped gate (using
export_value only, since there's no purchase
to avoid) would check 0.438 <= 0.622 → True → 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)
Symptom
Brief
BATTERY_EXPORTdischarges (~0.1 kWh, 4% discharge rate) get scheduled insolar-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 incore/bess/dp_battery_algorithm.py(discharge branch, ~lines 353–389):
avoid_purchase_value(buy_price × efficiency_discharge) values the dischargeas 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_storedtakes themax()of the twoterms,
avoid_purchase_valuedominates wheneverbuy_price > sell_price(the normal case), making the gate's assessed value far higher than what the
discharge can actually realize (
export_valueonly). This lets marginal,unprofitable discharges slip past the
-infanti-cycling floor.The
excess_solar/capacity_after_dischargebranch just above is meant tocatch this by raising
effective_cost_basistowardsell_price— but it nevertouches
effective_value_per_kwh_stored, which is where the actual leak is. Ifcost_basisis already abovesell_price(the normal case for a battery that'sbeen 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 gistbelow), period 63 (15:45), 2026-06-28:
solar_production = 0.893 kWh,home_consumption = 0.155 kWh→ solar fullycovers 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 atcharge time).
avoid_purchase_value = 1.0568 × 0.95 = 1.004export_value = 0.46126 × 0.95 = 0.438effective_value_per_kwh_stored = max(1.004, 0.438) = 1.0041.004 <= 0.622→ False → gate does not block.export_valueonly, since there's no purchaseto avoid) would check
0.438 <= 0.622→ True → 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:intent=BATTERY_EXPORT,battery_action=-0.1 kWh,inverter TOU segment
15:45-15:59 grid_first discharge=4%.intent=SOLAR_EXPORT,battery_action≈0— this run happened to haveshadow_price=0.876 > export_value=0.438, so the backward-induction value function chose idle overthe marginal discharge even though the
-infgate 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.1discretization constant(
core/bess/dp_battery_algorithm.py:106) matches the observed 0.1 kWh actionsize — 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_solarcondition already computed in the branchabove), exclude
avoid_purchase_valuefrom themax()— useexport_valueonly for
effective_value_per_kwh_storedin that case.This is a fix to the
-infhard gate inside_compute_reward, not a newmin_action_profit_thresholdparameter (that was the design considered andwithdrawn in #196). It does not touch the separate, correctly-working
shadow_price-based apply-time gate (solar_export_discharge_rate()incore/bess/battery_system_manager.py:69-86), which governs sub-15-minutesolar-dip coverage for periods already classified
SOLAR_EXPORTand isunrelated 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 adischarge 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_costfloor as a "gross-value fallacy" (correctly —cost_basisalready 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-periodschedule JSON + system logs from 00:00 to 11:31 that day)