Sustain in-progress forced export across plan cycles (anti-flapping on flat peaks) - #4118
Conversation
optimise_export already nudges the metric to "keep existing windows" when an export is running, but the binding selection gate is the raw cost check ((cost + min_improvement_scaled) <= off_cost). On a near-flat multi-slot price peak with live PV, exporting the current slot versus holding it and exporting the adjacent equal-priced slot is a cost coin-toss, so the per-recompute decision oscillated and the forced export "flapped" (drive/stop/drive) instead of draining continuously through the peak. When that same hysteresis condition fires (already exporting within this window) also relax the cost gate by the commitment amount, so an in-progress forced export is sustained across recomputes. This only sustains an export that is already running and never opens a new one, so it cannot reintroduce the metric_keep gaming guarded by issue springfall2008#2984. Adds tests/test_export_commitment.py (registered as export_commitment), which fails without the patch and passes with it.
There was a problem hiding this comment.
Pull request overview
This PR updates Predbat’s export-window optimisation to better sustain an already-running forced export across 5‑minute re-plan cycles, reducing “flapping” during near-flat high export-price peaks, and adds a targeted regression test to validate the commitment behavior.
Changes:
- Add a “keep exporting” commitment that relaxes the cost gate (not only the metric) when a forced export is already in progress.
- Add a new regression test module covering fresh-plan gating, in-progress retention, and a stale-flag guard scenario.
- Register the new test in the unit test runner.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| apps/predbat/plan.py | Relaxes the optimise_export cost gate when keeping an in-progress export to prevent oscillation across plan recomputes. |
| apps/predbat/tests/test_export_commitment.py | Adds regression tests for the forced-export commitment / anti-flapping behavior. |
| apps/predbat/unit_test.py | Registers the new export_commitment test in the test runner. |
… test state - optimise_export: only relax the cost gate when the candidate option's start is at/before minutes_now, so the commitment applies to a genuinely in-progress export rather than a future-starting option (the option start is varied during optimisation). - test_export_commitment: restore the export flags, isExporting/export_window and the metric_min_improvement_export* thresholds before returning so the shared test instance does not leave later tests order-dependent.
|
Thanks for the review — both points addressed in the follow-up commit. 1. Commitment firing for a future-starting option. Good catch. The cost-gate relaxation is now gated on 2. Test leaving mutated state. The test now restores the fields it changes ( The regression test still fails without the patch (the in-progress export drops to 100%) and passes with it; |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Problem
On an evening with a high export price that stays nearly flat across several half-hour slots while PV is still producing, forced export does not run continuously through the peak. Predbat re-plans every ~5 minutes and the per-slot forced-export decision oscillates: the battery exports for a few minutes, the slot then flips to "hold", PV charges the battery instead of exporting, then it flips back. Net effect is that energy is shifted out of the most expensive slot into later, cheaper ones.
The offline plan is clean and continuous — the oscillation only shows up across live recomputes.
Root cause
optimise_exportalready has a hysteresis to keep an existing export window:But the binding selection gate is on the raw
cost, not the metric:On a near-flat multi-slot peak, exporting the current slot versus holding it and exporting the adjacent equal-priced slot is a cost coin-toss (tipped by the intraday load/PV adjustment each cycle). Because the hysteresis only lowers
metricand never touches the cost gate, it cannot hold the export and the decision flip-flops between recomputes.Fix
When that same hysteresis condition fires (we are already exporting within this window) also relax the cost gate by the commitment amount, so an in-progress forced export is sustained across recomputes:
This only sustains an export that is already running (
self.isExporting,window_n < 2, current time inside the window). It never opens a new export window, so it cannot reintroduce themetric_keepgaming guarded by #2984. The relaxation is bounded (max(0.5, metric_min_improvement_export)), so if holding becomes clearly better the export is still released.Tests
Adds
apps/predbat/tests/test_export_commitment.py(registered asexport_commitment):The test fails without the patch (the in-progress export is dropped to 100%) and passes with it.
optimise_levels,optimise_windows,compute_metric,executeandmodelgroups still pass; black / ruff (F401) / interrogate / cspell are clean.Live validation
Validated on a real SolarEdge hybrid install across two consecutive evenings with similar near-flat two-hour export peaks — the two peak half-hours within ~2c/kWh of each other, which is the condition that triggers the oscillation (the absolute height is not what drives it):
Discharge to Maximize Export→Maximize Self Consumption→Discharge to Maximize Exportwithin the first 15 minutes — and during the "hold" the remaining PV charged the battery instead of exporting, shifting energy out of the dearest slot into cheaper later ones.Discharge to Maximize Exportat the start of the peak, held continuously for the full two hours, returning to demand only when the price dropped. Zero mode-flips; the battery held a steady ~10 kW discharge with no charging dips; SoC fell monotonically (100% → ~23%) with no saw-tooth.A single evening is not conclusive on its own, but the oscillation condition was present and the before/after contrast on the same install is clear.