Skip to content

fix(plan): retain self-consumption buffer from cheapest export slot, not the peak#4294

Merged
springfall2008 merged 5 commits into
mainfrom
fix/trim-export-import-split
Jul 24, 2026
Merged

fix(plan): retain self-consumption buffer from cheapest export slot, not the peak#4294
springfall2008 merged 5 commits into
mainfrom
fix/trim-export-import-split

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

Problem

Reported in #4230 (and the same shape as the now-closed #4272): on Agile-style export tariffs Predbat stops exporting during the most profitable slot and resumes later on a cheaper one.

Root cause traced to optimise_detailed_pass:

  • optimise_levels_pass switches every export window above the price threshold fully on. When the battery is power limited and a cheap charge is several hours away, this over-exports and forces expensive house import before the next charge, so the detailed pass must claw some export back.
  • The single trim sub-pass walked price bands highest-first, so the first window it handed to optimise_export was the highest-priced peak — and shrinking it relieved the over-export. Result: the peak (e.g. a 19.67p slot) was clipped while cheaper 12–16p slots stayed fully exported. The buffer was carved out of the most valuable slot instead of the least valuable one.

Fix

Split trim into two directional, reduce-only passes:

  • trim_export — runs first, price bands low→high, export windows only, reduce-only. The cheapest slots absorb the levels over-export, leaving the high-priced peak fully exported.
  • trim_import — after it, high→low, charge windows only, reduce-only (the correct direction for charge: drop the priciest charging first; matches prior behaviour).

Both passes derive their iteration order locally rather than mutating price_set in place.

Result

On the #4230 debug data, the 19.67p peak is now exported for its full duration (previously clipped 5 min at the start), improving the plan metric by ~3.2p. #4272 is unchanged (that one was a metric_min_improvement_plan stickiness/config issue, not the optimiser).

Tests

  • New test_trim_export — synthetic buffer scenario; verified it fails before the change (peak clipped) and passes after.
  • optimise_windows_kernel (Python + C++ kernel), full --quick suite, and debug_cases all pass.
  • The two debug_cases baselines that shifted (agile1, pre_saving1) are metric-identical — benign charge-window representation changes — and have been re-baselined.
  • pre-commit clean.

Notes / caveats

  • The trim_import half is symmetry/formalisation — the bug is entirely export-side. It is metric-neutral on every case tested but does introduce minor charge-window churn; happy to drop it and keep only trim_export if preferred.
  • Still per-window greedy: this fixes the "levels over-selected, claw back from the cheap end" family (this bug). A case needing a simultaneous two-window swap with nothing over-selected is out of scope.

🤖 Generated with Claude Code

…not the peak

Split the export/import trim sub-pass of optimise_detailed_pass into trim_export
and trim_import so each sheds its least valuable slot first.

Coming out of the levels pass every export window above the price threshold is
switched fully on, which over-exports and forces expensive house import before the
next charge. The old single "trim" pass then clawed the excess back highest-price
first, so it clipped the most valuable peak export window (e.g. an Agile 19.67p slot)
while leaving cheaper 12-16p slots fully exported.

trim_export now runs first, walks price bands low to high, is reduce-only and only
touches export windows, so the cheapest slots absorb the over-export and the peak is
left fully exported. trim_import runs after it, high to low, reduce-only, charge only
(the correct direction for charge - drop the priciest charging first). Both passes
derive their iteration order locally instead of mutating price_set in place.

On the reporter's data (issue #4230) the 19.67p peak is now exported for its full
duration instead of being clipped, improving the plan metric by ~3.2p.

Adds test_trim_export covering the buffer-from-cheapest-slot behaviour. The two debug
regression cases that shift are metric-identical (benign charge-window representation
changes) and have been re-baselined.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 23, 2026 20:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adjusts the detailed optimisation phase so that when export must be clawed back to preserve a self-consumption buffer, the reduction is taken from the cheapest export slots first (keeping the most valuable peak fully exported). It introduces distinct export vs import trim passes and adds a regression test plus updated debug-case baselines.

Changes:

  • Split the prior trim logic into trim_export (low→high, export-only, reduce-only) and trim_import (high→low, charge-only, reduce-only), each deriving its own iteration order without mutating price_set.
  • Add and register test_trim_export to validate that the peak export slot is not clipped when trimming is needed.
  • Update expected outputs for impacted debug cases (agile1, pre_saving1).

Reviewed changes

Copilot reviewed 3 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
apps/predbat/plan.py Splits trim passes and changes price-band iteration order to trim export from cheapest first.
apps/predbat/tests/test_trim_export.py Adds regression test scenario for export-trim ordering / buffer retention.
apps/predbat/unit_test.py Registers the new trim_export test in the test runner.
coverage/cases/predbat_debug_agile1.yaml.expected.json Updated debug-case expected output after optimiser behaviour change.
coverage/cases/predbat_debug_pre_saving1.yaml.expected.json Updated debug-case expected output after optimiser behaviour change.
Comments suppressed due to low confidence (1)

apps/predbat/tests/test_trim_export.py:109

  • The test only considers a cheaper window “reduced” if it is disabled (100) or start-clipped. The optimiser can also retain buffer by raising the export SoC limit (shallower discharge) or selecting freeze (99) without clipping the start, which would incorrectly fail this test.
        window = my_predbat.export_window_best[n]
        if my_predbat.export_limits_best[n] >= 100 or window["start"] > (mn + 30 * n):
            cheaper_reduced = True
            break

Comment thread apps/predbat/plan.py Outdated
Comment thread apps/predbat/tests/test_trim_export.py Outdated
springfall2008 and others added 3 commits July 23, 2026 23:16
…ering

The debug regression cases (agile1, pre_saving1) passed standalone but failed in
the full suite. Two derived attributes are neither saved in the debug yaml nor
reset by reset_inverter, so they leaked from an earlier test and changed the plan:

- dynamic_load_baseline: feeds the load model calculate_plan rebuilds. It is
  normally (re)computed by dynamic_load(), which only runs from update_pred, not
  the debug harness, so a leaked value flowed straight into the load rebuild.
- battery_rate_max_export: the discharge power cap used in prediction. A leaked
  full-precision value (vs the 0.0333 default) flipped pre_saving1's plan at a
  decision boundary (10 -> 11 charge windows).

Reset both to their standalone-run defaults in run_single_debug so each case is
deterministic regardless of test ordering. Confirmed by state-diffing the pre-plan
snapshot between a standalone and full-suite run; the committed baselines already
match the deterministic plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The trim_export acceptance guard accepted any option with a higher SoC limit
regardless of its start. Because optimise_export is called after the window start
is reset to start_orig, a later trim_export pass on an already-trimmed window could
return a higher limit together with an earlier start (a bigger window), which
exports more - contradicting the reduce-only intent.

Require a reduction in both dimensions: a shallower discharge (higher SoC limit)
and no earlier start. Off/freeze (limit >= 99) export no battery and force the start
back to the full window, so they stay exempt from the earlier-start check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The peak assertion only flagged limit >= 100 (off), so a peak returned as 99
(export freeze) would keep the battery from exporting yet still pass. Fail when the
peak is off or frozen (>= 99). Likewise count a cheaper window as reduced when it is
frozen, not only when disabled or clipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit fdbf8b1 into main Jul 24, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/trim-export-import-split branch July 24, 2026 08:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plan discharges at a worse price while leaving better adjacent prices completely unused, right before the main high-price export window

2 participants