Skip to content

DP holds charge for a known-worse tomorrow price instead of a known-better today price (real 2-day horizon branch) #275

Description

@johanzander

Summary

Once tomorrow's real day-ahead prices enter the optimization horizon, the DP can hold battery charge back from tonight's known, better sell price and export it instead at tomorrow's known, worse sell price. Confirmed with synthetic testing built from a real user's (Frank, #126) exact config, today's real prices, real Solcast tomorrow-solar forecast, and his real consumption pattern — varying only tomorrow's price array.

Belpex/ENTSO-e day-ahead prices are published values once available, not forecasts. Choosing a lower known price over a higher known price is a quantifiable revenue loss, not a legitimate risk/uncertainty tradeoff.

Background

This traces from the "74% SOC at midnight" thread in #126 (see this comment onward). Two earlier theories were raised there and both were checked against real debug bundles and ruled out:

  1. Buy/sell price asymmetry ("DP holds charge because avoiding tomorrow's import beats tonight's export") — ruled out: Frank's actual 07:00 plan on 11 Jul already showed correct drain-to-floor behavior for tonight's peak, using the same asymmetric prices that would apply at any time of day. The asymmetry alone doesn't explain the later drift.
  2. Terminal-value placeholder overestimating future value — already fixed by fix: cap DP terminal-value estimate at best in-horizon export price (replaces #245) #251 (merged 2026-07-09, present in Frank's v9.9.0b11) for the no-tomorrow-data branch of _calculate_terminal_value(). That fix caps the placeholder at the best in-horizon sell price. It does not touch the branch used once tomorrow's real prices are present, where terminal value is forced to 0.0 and the DP directly optimizes the real 2-day horizon (core/bess/battery_system_manager.py:1716-1788).

This issue documents synthetic testing that isolates a real defect in that second, previously-untested branch.

Reproduction

Built via mock-run.sh against a scenario generated from Frank's real debug bundle (attached to #126, exported 2026-07-12 06:07):

.venv/bin/python scripts/mock_ha/scenarios/from_debug_log.py bess-debug-2026-07-12-060741.md
# → scripts/mock_ha/scenarios/bess-debug-060741.json
#   (verbatim entity snapshot: real config, real today prices, real Solcast
#   tomorrow-solar forecast, real historical consumption)

Two edits to the generated scenario, applied per test:

  1. sensors["sensor.belpex_h_average_electricity_price"].attributes.prices_tomorrow — populated with a synthetic 24-hour array (varied per test below; empty in the original export since Belpex hadn't published yet at 06:07).
  2. bess_config.home.consumption_strategy"fixed" with default_hourly set to Frank's real historical average (0.517 kWh/h, computed from the bundle's own historical_periods). Required because mock-HA doesn't implement the recorder/list_statistic_ids WS command Frank's real ha_statistics strategy depends on — without this the schedule build fails outright (Failed to update battery schedule: WS command recorder/list_statistic_ids failed).

mock_time set to @2026-07-12 06:10:00 (Europe/Brussels) so the very first optimization cycle already sees the injected tomorrow array. Then:

./mock-run.sh <scenario-name>
curl -s http://localhost:<BESS_DEV_PORT>/api/export-debug-data

...and read the ### Period Decisions table for periods 84–95 (21:00–23:45 tonight) and 180–191 (21:00–23:45 tomorrow).

Battery: 15 kWh capacity, 5 kW charge/discharge, min_soc=47%min_soe_kwh=7.05. Today's real evening peak: sell ≈0.36–0.37 (buy-price column shown; export_spot_multiplier=1.018, no export markup) around 21:00–23:00.

Results

Scenario Tomorrow's evening peak SOE at 23:45 tonight (floor = 7.05/7.1) Held charge's fate
Baseline — no tomorrow data (placeholder terminal-value branch, #251's fix) n/a 7.1 kWh — drains to floor, correct
Tied — tomorrow cloned from today's real shape same as tonight 13.3 kWh Exported at tomorrow's identical peak (periods 180–191, drains to 7.1 there)
Worse — tomorrow's peak dulled ~21% (0.145 → 0.114) known, worse 12.4 kWh Exported at tomorrow's worse peak (periods 180–191, drains to 7.1 there)
Flat — tomorrow flat 0.02 EUR/kWh all day, no peak at all no opportunity at all 9.6 kWh Consumed locally overnight via LOAD_SUPPORT (avoids ~0.32 EUR/kWh grid import)

Analysis

The Worse scenario is the smoking gun. With tomorrow's price known (not forecast) to be ~21% below tonight's, a revenue-maximizing optimizer with full information should front-load the export onto tonight's better price — there's no uncertainty being hedged, both numbers are already fixed day-ahead auction results. Instead the DP holds ~5.3 kWh back from tonight (12.4 kWh vs. the 7.1 kWh floor-baseline) and sells it at tomorrow's confirmed-worse peak. That's a direct, quantifiable loss: roughly 5.3 kWh × (0.147 − 0.114) ≈ €0.17 in this dulled-by-21% test alone, and the gap scales with how much worse tomorrow's peak is — Belpex/day-ahead prices routinely vary by far more than 21% day to day, and Frank's original real-world observation (07:00 plan correctly draining to floor → midnight actual still ~74% SOC) is consistent with a much larger version of this same pattern.

The Flat scenario is not the same bug. There, the ~2.5 kWh held above the floor is spent overnight via LOAD_SUPPORT against Frank's real (tiny) consumption load, avoiding grid import at ~0.32 EUR/kWh — that's a legitimate self-consumption call (avoided-import cost beats a near-zero sell price) and plausibly reflects the extended 2-day horizon correctly provisioning for a second night's early-morning low-solar consumption that a 1-day horizon can't see at all. This part is not obviously wrong and is scoped separately from the export-to-worse-future-price defect above.

What's not yet known

  • The exact code-level cause inside the DP's per-period reward/value computation (core/bess/dp_battery_algorithm.py, _compute_reward at line 233, battery_wear_cost = energy_stored * cycle_cost_per_kwh at lines 309/349/434) for why it doesn't correctly rank a higher known near price above a lower known far price once the horizon spans two peaks. cycle_cost_per_kwh (0.035) is in the right order of magnitude to be interacting with the price differential in the Worse scenario, but this hasn't been traced through the actual DP state transitions yet.
  • Whether this is a discretization/state-space artifact (e.g. period granularity or battery-power-rate modeling) or a genuine bug in how marginal value is compared across non-adjacent periods.
  • Whether the fix belongs in the DP's reward function, in how the extended real-forecast horizon is constructed, or in a discount applied to farther-horizon periods so nearer certain revenue is preferred when peaks are comparable.

Next steps

Trace _compute_reward / the backward-induction value comparison in dp_battery_algorithm.py for the Worse-scenario inputs to find why a period at 21:00 today (price 0.145) doesn't dominate a period at 21:00 tomorrow (price 0.114) net of cycle_cost_per_kwh, then propose a targeted fix.

The three synthetic scenario JSONs used here (frank-126-synthetic-tomorrow{,-worse,-flat}.json, all derived from Frank's original export-debug-data bundle) are available to attach/commit as regression fixtures if useful for a follow-up PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions