Skip to content

Sustain in-progress forced export across plan cycles (anti-flapping on flat peaks)#4118

Open
tieskuh wants to merge 1 commit into
springfall2008:mainfrom
tieskuh:fix/export-anti-flapping
Open

Sustain in-progress forced export across plan cycles (anti-flapping on flat peaks)#4118
tieskuh wants to merge 1 commit into
springfall2008:mainfrom
tieskuh:fix/export-anti-flapping

Conversation

@tieskuh

@tieskuh tieskuh commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

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_export already has a hysteresis to keep an existing export window:

if window_n < 2 and this_export_limit < 99.0 and self.export_window and self.isExporting:
    ...
    metric -= max(0.5, self.metric_min_improvement_export)

But the binding selection gate is on the raw cost, not the metric:

if (metric <= off_metric) and (metric <= best_metric) and ((cost + min_improvement_scaled) <= off_cost):

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 metric and 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:

if keep_export:
    min_improvement_scaled = min(min_improvement_scaled, -max(0.5, self.metric_min_improvement_export))

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 the metric_keep gaming 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 as export_commitment):

  • fresh plan, not exporting, large min-improvement → export correctly gated out (100%)
  • already exporting within the window → export retained under commitment (<100%)
  • stale export flag outside the prior window → no spurious export (guard)

The test fails without the patch (the in-progress export is dropped to 100%) and passes with it. optimise_levels, optimise_windows, compute_metric, execute and model groups 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):

  • Before (unpatched, ~0.69/0.67 peak): the storage-mode select flipped mid-peak — Discharge to Maximize ExportMaximize Self ConsumptionDischarge to Maximize Export within 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.
  • After (this patch, ~0.94/0.96 peak): a single transition into Discharge to Maximize Export at 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.

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.
@tieskuh tieskuh marked this pull request as ready for review June 24, 2026 20:39
@springfall2008 springfall2008 requested a review from Copilot June 26, 2026 18:39

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 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.

Comment thread apps/predbat/plan.py
Comment on lines 1719 to +1724
if window_n < 2 and this_export_limit < 99.0 and self.export_window and self.isExporting:
pwindow = export_window[window_n]
dwindow = self.export_window[0]
if self.minutes_now >= pwindow["start"] and self.minutes_now < pwindow["end"] and ((self.minutes_now >= dwindow["start"] and self.minutes_now < dwindow["end"]) or (dwindow["end"] == pwindow["start"])):
metric -= max(0.5, self.metric_min_improvement_export)
keep_export = True
Comment on lines +124 to +128
if failed:
print("**** Export commitment tests FAILED ****")
else:
print("**** Export commitment tests passed ****")
return failed
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.

2 participants