Skip to content

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

Merged
springfall2008 merged 4 commits into
springfall2008:mainfrom
tieskuh:fix/export-anti-flapping
Jul 2, 2026
Merged

Sustain in-progress forced export across plan cycles (anti-flapping on flat peaks)#4118
springfall2008 merged 4 commits 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 Outdated
Comment thread apps/predbat/tests/test_export_commitment.py
… 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.
@tieskuh

tieskuh commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

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 start <= self.minutes_now, so it only applies to an option that actually covers the current minute (a genuinely in-progress export). The pre-existing metric nudge just above it is left unchanged; for a future-starting option that nudge is harmless in practice — the option starting at window["start"] exports the most and dominates selection — but the new cost-gate relaxation is the stronger lever, so it's now scoped tightly to in-progress options.

2. Test leaving mutated state. The test now restores the fields it changes (isExporting, export_window, the export-freeze flags and the metric_min_improvement_export* thresholds) before returning, so it can't make later tests in the shared-instance suite order-dependent.

The regression test still fails without the patch (the in-progress export drops to 100%) and passes with it; optimise_levels / optimise_windows / compute_metric / execute / model groups remain green.

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

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

Comment thread apps/predbat/tests/test_export_commitment.py Outdated
Comment thread apps/predbat/tests/test_export_commitment.py Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

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

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

Comment thread apps/predbat/tests/test_export_commitment.py Outdated
Comment thread apps/predbat/tests/test_export_commitment.py
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@springfall2008
springfall2008 merged commit 71f727b into springfall2008:main Jul 2, 2026
1 check passed
@tieskuh
tieskuh deleted the fix/export-anti-flapping branch July 2, 2026 21:46
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.

3 participants