Skip to content

bug: optimizer profitability gate uses wrong baseline, falls back to all-IDLE schedule (blocks discharge) on high-solar days #231

Description

@johanzander

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bot-analyzedTriage bot has processed this issuebugSomething isn't workingready-for-analysisDebug log present, awaiting @claude-bot analyze

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions