Summary
On high-solar days, the DP optimizer's profitability gate compares against the wrong baseline cost, computes a spuriously negative "savings" figure, and silently discards the real optimized schedule in favor of a naive all-IDLE fallback that is architecturally incapable of ever discharging the battery. This produces exactly the pattern reported in #126: battery fills to 100% via solar, exports surplus once full (even at negative injection prices), then sits fully charged through the entire evening price peak while the home imports from the grid at 3x the sell price.
Reproduction (from #126)
Reported independently on two separate days by the same user (ENTSO-e/Belpex, Luminus Dynamic contract, Growatt GEN4, demo mode — BESS never wrote to hardware, so this is purely the optimizer's own prediction):
- 9.9.0b4, 3 Jul 2026 (~37 kWh Solcast forecast): schedule was
13:00-19:59 SOLAR_EXPORT (exporting at negative Luminus injection prices, e.g. -€0.014/kWh at 15:00) → 20:00-23:59 IDLE, battery ending the export block at 100% and staying there through the evening peak (export ~€0.12-0.14/kWh available, import instead at ~€0.35-0.37/kWh).
- 9.9.0b5, 4 Jul 2026: identical shape in a fresh debug export —
SOLAR_STORAGE fills to 15.0/15.0 kWh (100%) by period 47 (11:45), SOLAR_EXPORT periods 48-79 (12:00-19:45, battery flat at 100%, solar surplus passing straight through to grid), IDLE periods 80-95 (20:00-23:45, battery still flat at 100%, no discharge at all through the evening despite buy ~€0.34-0.35 vs sell ~€0.11-0.12 that evening).
Root cause
core/bess/dp_battery_algorithm.py, function optimize_battery_schedule:
# lines 1194-1220
total_base_cost = sum(
home_consumption[i] * buy_price[i] for i in range(len(buy_price))
)
...
economic_summary = EconomicSummary(
grid_only_cost=total_base_cost,
solar_only_cost=total_base_cost, # Simplified - no solar in this scenario
battery_solar_cost=total_optimized_cost,
grid_to_solar_savings=0.0, # No solar
grid_to_battery_solar_savings=grid_to_battery_solar_savings,
...
solar_only_cost is hardcoded equal to grid_only_cost — i.e. the gate's baseline pretends zero solar exists, even when solar_production for the horizon is 37 kWh. The correct per-period solar-only-no-battery cost is already computed elsewhere in the codebase and is not reused here — see EconomicData.from_energy_data in core/bess/models.py:232-237:
solar_only_cost = (
max(0, energy_data.home_consumption - energy_data.solar_production)
* buy_price
- max(0, energy_data.solar_production - energy_data.home_consumption)
* sell_price
)
Then the gate (lines 1228-1257):
if grid_to_battery_solar_savings < effective_threshold:
logger.warning(
f"Optimization savings ({grid_to_battery_solar_savings:.2f} {currency}) below "
f"effective threshold ({effective_threshold:.2f} {currency}) ..."
f"Using all-IDLE schedule instead."
)
return _create_idle_schedule(...)
Because the optimized cost correctly bears battery wear cost (cycle_cost_per_kwh on every kWh stored) while the baseline it's compared against ignores the ~37 kWh of free solar entirely, grid_to_battery_solar_savings comes out negative even on a day the battery is clearly adding value — confirmed in Frank's b5 log, every optimization run that night logged savings between -0.06 and -1.70 EUR, well below the (already floored at 15% of nominal) effective_threshold.
Once the gate rejects the real plan, _create_idle_schedule (lines 910-940) takes over:
for t in range(horizon):
next_soe = _state_transition(
current_soe,
0.0, # <-- power hardcoded to 0.0
battery_settings,
dt=dt,
solar_production=solar_production[t],
home_consumption=home_consumption[t],
)
power=0.0 for every period means this fallback can only passively store solar surplus (via _state_transition's IDLE branch) and export overflow once full — it can never discharge, at any price, for any reason. That's exactly the shape both reports show.
Suggested fix direction
Compute solar_only_cost for the gate the same way EconomicData.from_energy_data already does per-period (solar directly offsetting consumption, valued at buy price for the shortfall / sell price for the surplus), summed across the horizon — not total_base_cost. That alone should stop the gate from misfiring on high-solar days. Worth a second look at whether the fallback should ever fire full-day when there's a legitimate reason to reject the DP result — right now it's silent and total (no partial discharge capability at all), which turns a threshold intended to prevent marginal thrashing into a hard "never discharge" mode whenever it trips.
Related
Summary
On high-solar days, the DP optimizer's profitability gate compares against the wrong baseline cost, computes a spuriously negative "savings" figure, and silently discards the real optimized schedule in favor of a naive all-IDLE fallback that is architecturally incapable of ever discharging the battery. This produces exactly the pattern reported in #126: battery fills to 100% via solar, exports surplus once full (even at negative injection prices), then sits fully charged through the entire evening price peak while the home imports from the grid at 3x the sell price.
Reproduction (from #126)
Reported independently on two separate days by the same user (ENTSO-e/Belpex, Luminus Dynamic contract, Growatt GEN4, demo mode — BESS never wrote to hardware, so this is purely the optimizer's own prediction):
13:00-19:59 SOLAR_EXPORT(exporting at negative Luminus injection prices, e.g. -€0.014/kWh at 15:00) →20:00-23:59 IDLE, battery ending the export block at 100% and staying there through the evening peak (export ~€0.12-0.14/kWh available, import instead at ~€0.35-0.37/kWh).SOLAR_STORAGEfills to 15.0/15.0 kWh (100%) by period 47 (11:45),SOLAR_EXPORTperiods 48-79 (12:00-19:45, battery flat at 100%, solar surplus passing straight through to grid),IDLEperiods 80-95 (20:00-23:45, battery still flat at 100%, no discharge at all through the evening despite buy ~€0.34-0.35 vs sell ~€0.11-0.12 that evening).Root cause
core/bess/dp_battery_algorithm.py, functionoptimize_battery_schedule:solar_only_costis hardcoded equal togrid_only_cost— i.e. the gate's baseline pretends zero solar exists, even whensolar_productionfor the horizon is 37 kWh. The correct per-period solar-only-no-battery cost is already computed elsewhere in the codebase and is not reused here — seeEconomicData.from_energy_dataincore/bess/models.py:232-237:Then the gate (lines 1228-1257):
Because the optimized cost correctly bears battery wear cost (
cycle_cost_per_kwhon every kWh stored) while the baseline it's compared against ignores the ~37 kWh of free solar entirely,grid_to_battery_solar_savingscomes out negative even on a day the battery is clearly adding value — confirmed in Frank's b5 log, every optimization run that night logged savings between -0.06 and -1.70 EUR, well below the (already floored at 15% of nominal)effective_threshold.Once the gate rejects the real plan,
_create_idle_schedule(lines 910-940) takes over:power=0.0for every period means this fallback can only passively store solar surplus (via_state_transition's IDLE branch) and export overflow once full — it can never discharge, at any price, for any reason. That's exactly the shape both reports show.Suggested fix direction
Compute
solar_only_costfor the gate the same wayEconomicData.from_energy_dataalready does per-period (solar directly offsetting consumption, valued at buy price for the shortfall / sell price for the surplus), summed across the horizon — nottotal_base_cost. That alone should stop the gate from misfiring on high-solar days. Worth a second look at whether the fallback should ever fire full-day when there's a legitimate reason to reject the DP result — right now it's silent and total (no partial discharge capability at all), which turns a threshold intended to prevent marginal thrashing into a hard "never discharge" mode whenever it trips.Related
dp_battery_algorithm.pybetweenmainand therelease/v9.9.0b6branch.