Skip to content

bug: DP terminal value uses buy price, overvaluing holding charge vs. exporting on asymmetric buy/sell contracts #244

Description

@johanzander

Summary

The rolling-horizon DP re-optimizes only through the end of the current day (horizon == remaining_today), so _calculate_terminal_value()'s "today only" fallback (battery_system_manager.py:1704-1754) fires on every hourly re-optimization, not just an edge case. That fallback estimates the value of a kWh still in the battery at midnight using the median buy price:

median_price = statistics.median(buy_prices)
terminal_value = median_price * efficiency_discharge - cycle_cost_per_kwh

On contracts where buy and sell diverge a lot (e.g. Belgian Luminus Dynamic: buy ~€0.30-0.38/kWh, sell ~€0.10-0.16/kWh), this produces a terminal value (~€0.245/kWh in the reported case) well above any real, achievable evening export price. Backward induction then prefers holding the battery to "collect" this fictitious bonus at midnight over exporting at the real, known, better price available right now — every single re-optimization, all day.

Evidence (from #126 — Frank Leysen, 2026-07-07 14:30 debug export, v9.9.0b9)

  • Optimization at period 58 (14:30), horizon=38 = exactly remaining_today → fallback branch active.
  • Remaining buy prices for the day ranged ~€0.21-0.38/kWh; median ≈ €0.30 → terminal_value ≈ 0.30 × 0.95 - 0.04 ≈ €0.245/kWh.
  • Actual achievable export price (Luminus formula) at 20:00-22:00 was only ~€0.13-0.16/kWh.
  • Resulting predicted schedule: battery sits at 14.9-15.0 kWh (IDLE) through 20:00-20:45 despite rising evening sell price, then only trickles out -0.15 kWh/period afterward (matches home load, barely crosses into export) instead of discharging meaningfully into the evening peak.

This is distinct from #234 (cost-basis threading), #240 (load-first export miscrediting, already fixed in b9), and #236 (DP perf). It reproduces on every re-optimization for any user with a meaningfully asymmetric buy/sell contract, not just as a one-off.

Suggested direction

_calculate_terminal_value only has access to buy_prices today; the caller (battery_system_manager.py:1871-1873) already computes sell_prices in the same scope. Base the "today only" terminal estimate on the known, real value achievable via export (sell price) rather than an optimistic "you'll definitely avoid a purchase tomorrow" assumption — consistent with how _compute_reward already treats discharge value elsewhere in the DP (max(avoid_purchase_value, export_value), but grounded in what's actually knowable at the horizon boundary).

Filed per investigation in #126 (see comments there for full debug-log analysis).

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