Skip to content

fix(plan): convert phantom export windows to freeze in clip_export_slots (#4453) - #4487

Merged
springfall2008 merged 4 commits into
mainfrom
fix-phantom-export-clip
Aug 11, 2026
Merged

fix(plan): convert phantom export windows to freeze in clip_export_slots (#4453)#4487
springfall2008 merged 4 commits into
mainfrom
fix-phantom-export-clip

Conversation

@springfall2008

Copy link
Copy Markdown
Owner

Summary

Investigating #4453 (lone/random forced exports on flat export tariffs, e.g. the reporter's 09:55 lone export at 58%): replaying the attached debug capture reproduces the artifact deterministically — a short forced export window (12:35–13:00 at 92%) sandwiched between freeze windows, whose predicted SoC stays completely flat at 96.7% through the window. The commanded drain never happens because PV already saturates the export limit. It moves zero energy in the central forecast, yet it survives the whole pipeline.

Why it exists and why nothing removes it

  • Why it wins over freeze (systematic, not a coin flip): a forced export whose target is at/above achievable SoC behaves as demand in the PV10 branch — charging from surplus is allowed — while freeze pins SoC and spills the PV10 surplus to grid. That gives the phantom a ~0.25 × pv10-value edge over freeze (measured +0.455p here) on any surplus-solar day.
  • Why "off" (which scores best, +0.67p) is unreachable: removal is gated by metric_min_improvement_export (raised to 1.0p by the reporter to fight churn — scaled ~0.83p for the window; 0.67p < 0.83p).
  • The purpose-built removal path is dead code: the drop=True branch of optimise_swap_export has no live caller (only the commented-out early invocation passes it). Its own gate (best_metric_drop <= selected_metric) would have deleted this window.
  • Every clip_export_slots branch targets a different failure mode: battery pinned empty (fix(plan): clip export windows with no SoC above reserve #4434), or target unreachable from below. This window has SoC above target, so the clip-up branch fires and actively legitimises it.
  • Once installed it is defended: removing it is worth well under metric_min_improvement_plan (2.0p), so should_replace_plan keeps it cycle after cycle.

The fix

In clip_export_slots, a forced export window whose predicted SoC is flat across the window while above the requested limit is converted to freeze export (99) rather than clipped up:

  • In the simulation the phantom pinned SoC — exactly what freeze does — so the executed plan stays faithful to the plan that was scored (converting to off would substitute charging where the scored plan simulated a pin).
  • The force-export command is dropped: in reality it would genuinely dump the battery to grid whenever live PV dips below the export limit, which is the visible misbehaviour reported in Predbat randomly exports instead of freeze export, often in the peak rate #4453.
  • Falls back to off (100) when set_export_freeze is unsupported; manual exports are preserved.
  • If conditions worsen toward the PV10 case, a genuine export re-appears on a later recompute from real SoC.

Confirmed on the reporter's capture: the lone export becomes freeze and merges with its neighbours — the recomputed day is clean freeze throughout, which is the plan the reporter expected on a flat rate.

Test plan

  • TDD: new tests written first and watched fail — test_normal_export_clipped_to_freeze_when_soc_flat_above_limit, test_normal_export_clipped_off_when_soc_flat_and_freeze_unsupported, test_manual_export_preserved_when_soc_flat_above_limit
  • Clip-up fixtures switched from flat to falling SoC traces (flat SoC during a commanded export is precisely the phantom signature; the clip-up tests mean "genuine export above limit") — all 13 clip tests pass
  • --quick suite passes; pre-commit passes
  • Random benchmark, 20 scenarios vs main: final plan metric and cost identical 20/20 (plan selection scores pre-clip per fix(plan): score plan selection on the plan as optimised, not as clipped #4403, so the prune provably cannot change which plan wins); the conversion fired in 1 scenario with a +0.40p post-clip shift, against a pre-existing post-clip spread of up to +4.25p from the PV10 clip tax; runtime unchanged

Note

cases/predbat_debug_pre_saving1.yaml.expected.json fails on clean main before this change (numeric drift now lands its 22:00 window exactly at reserve, so the existing #4434 branch clips it; CI runs --quick, which skips debug_cases). The actual output under this branch is identical to main's. Expected file to be regenerated on this branch separately.

🤖 Generated with Claude Code

…ots (#4453)

On a flat export tariff the optimiser can emit a lone forced export window
whose predicted SoC stays completely flat: the commanded drain never happens
because PV already saturates the export limit. The window moves no energy in
the central forecast, yet it outranks freeze in the metric because a forced
export whose target is at or above SoC behaves as demand in the PV10 branch
(charging allowed), while freeze pins SoC and spills the PV10 surplus - a
systematic ~0.25 * pv10-value bias toward phantom exports over freeze on any
surplus-solar day.

Nothing downstream removes it: turning it off is worth less than
metric_min_improvement_export so optimise_export keeps it, the drop branch of
optimise_swap_export never runs (drop=True has no live caller), every
clip_export_slots branch targets a different failure mode (battery empty, or
target unreachable from below) and the clip-up branch actively legitimises
it, and once installed should_replace_plan defends it against the 2p
replacement threshold. Confirmed against the reporter's debug capture in
#4453: the 09:55 lone export replays as a 12:35-13:00 window at 92% with SoC
flat at 96.7%, off/planned/freeze all within 1.1p.

Clip such windows to freeze export (99) rather than off: in the simulation
the phantom pinned SoC exactly as a freeze does, so the executed plan stays
faithful to what was scored, while the force-export command - which would
genuinely dump to grid if PV dips below the export limit - is dropped. Falls
back to off when set_export_freeze is unsupported; manual exports are
preserved. If conditions worsen towards the PV10 case, a genuine export
re-appears on a later recompute.

The clip-up tests previously used flat SoC fixtures as a stand-in for "SoC
above limit"; flat SoC during a commanded export is precisely the phantom
signature, so those fixtures now use a falling trace and the flat case has
its own tests (freeze conversion, off fallback without freeze support,
manual export preserved).

Random benchmark over 20 scenarios against main: final plan metric and cost
identical 20/20 (plan selection scores pre-clip per #4403); the conversion
fired in one scenario with a +0.40p post-clip shift, against a pre-existing
post-clip spread of up to +4.25p from the PV10 clip tax. Runtime unchanged.

Note: cases/predbat_debug_pre_saving1.yaml.expected.json fails on clean main
before this change (its 22:00 window now lands exactly at reserve so the
existing #4434 branch clips it; CI runs --quick which skips debug_cases).
To be regenerated separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 11, 2026 19:38

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 addresses “phantom” forced-export windows that move zero energy (flat predicted SoC above the requested export limit) by converting them to freeze export (99) (or off (100) when freeze is unsupported), preventing unintended real-world battery dumps when PV later dips.

Changes:

  • Update clip_export_slots to detect flat-SoC, above-limit export windows (non-manual) and convert them to freeze/off.
  • Add targeted unit tests for phantom-export conversion, freeze-unsupported fallback, and preservation of manual exports.
  • Adjust existing clip-up test fixtures to use falling SoC traces to distinguish “real export” from the phantom signature.

Reviewed changes

Copilot reviewed 2 out of 4 changed files in this pull request and generated 1 comment.

File Description
apps/predbat/plan.py Adds phantom-export detection in clip_export_slots and converts such windows to freeze/off.
apps/predbat/tests/test_clip_export_slots.py Adds new regression tests and updates fixtures to use falling SoC for genuine-export cases.
.gitignore Ignores coverage/*.py files.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/predbat/plan.py Outdated
pre-commit-ci-lite Bot and others added 2 commits August 11, 2026 19:41
… at full

Review finding on #4487: the phantom-export conversion produced a 99.0
freeze window that the limit==99 branch above would have clipped off had it
been a native freeze - a forced export with SoC flat at soc_max became an
unnecessary freeze command. Apply the same at-100% rule in the conversion:
when the flat SoC equals soc_max (dp1, matching the freeze branch), clip
off instead of converting to freeze.

Verified the committed and fixed code produce byte-identical plans on the
#4453 debug capture (its phantom sits at 96.7%, not full), so this only
changes the flat-at-full subset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit abf09a2 into main Aug 11, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix-phantom-export-clip branch August 11, 2026 19:50
springfall2008 added a commit that referenced this pull request Aug 12, 2026
prune_dead_plan_slots decides whether a slot achieves anything by asking the
model, which makes the trajectory-based removal heuristics in clip_charge_slots
and clip_export_slots redundant. Instrumenting every branch across the 20
scenario benchmark shows the removal branches fire 31 times with the prune
disabled and 0 times with it enabled, while both clip-up branches keep firing
unchanged (111 fires). The same holds on the real captures from #4453/#4478.

Removed: freeze-export-at-100%, no-SoC-above-reserve (#4171/#4434), phantom
export (#4453/#4487), export target-unreachable, and charge
never-reaches-limit. What remains in both functions is limit adjustment only -
narrowing a requested limit to what the window can actually achieve, so the
target sent to the inverter matches the simulated plan and adjacent windows
merge. The charge freeze-to-charge-at-100% conversion is kept: it rewrites a
limit rather than removing a window, and covers windows the prune skips.

The prune deliberately does not trial a window covering the current minute
(#4402), so an in-progress phantom is no longer converted in place; it is
re-planned on the next cycle from real inverter state instead.

Random benchmark over 20 scenarios before vs after this removal: plan metric
and cost identical 20/20, runtime unchanged. Debug cases pass unchanged - the
expected files regenerated for the prune itself needed no further update, which
is independent evidence the branches were dead.

Tests that asserted the removed behaviour are rewritten to assert the new
contract (clipping adjusts limits and never removes); the removal behaviour
they covered is now exercised by the prune tests and the debug case regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
springfall2008 added a commit that referenced this pull request Aug 13, 2026
…the central forecast (#4491)

* feat(plan): model-based clipping - prune plan slots dead in the central forecast

Follow-up to #4487. The heuristic clip branches each target one specific
failure shape (battery pinned empty, target unreachable, SoC flat above
limit), and #4453/#4478 showed new shapes keep appearing: every gate in the
pipeline is tuned for a different pathology, and dead slots thread between
them. Replace the guessing with a direct question to the model: remove the
slot, re-simulate, did the central forecast change?

prune_dead_plan_slots trials each active charge/export slot inside the
record window in turn - the slot is removed and the whole plan re-simulated
in the nominal (50%) scenario only, one simulation per trial via a new
run_prediction_metric(nominal_only=True) option (skips pv10 and pv90). The
removal is kept when the nominal metric does not get worse, so slots whose
value exists only in the pessimistic branches - or nowhere at all - are
dropped. If the pv10/pv90 conditions materialise in reality, the next plan
recompute re-creates a genuine slot from actual state.

The pass runs after the pre-clip scoring snapshot, so plan selection still
compares plans as optimised (#4403). In-progress windows are never trialled
(the #4402 commitment - the clip_export_slots phantom branch from #4487
remains as the complementary catch for that case), manual windows are
preserved, and each accepted removal updates the running baseline so one
removal cannot make the next look free.

Random benchmark over 20 scenarios against main: 114 slots pruned, nominal
cost of the executed plan never worse than +0.012p (the per-trial epsilon)
and dramatically better on two scenarios (-35.18p, -71.61p - plans were
carrying slots that cost real money in the central forecast for pessimistic
-branch insurance); the pv-weighted metric of the executed plan gives back
+0.62p on average (max +5.05p) where pure insurance was stripped, which is
the designed trade. Plan runtime unchanged (1.33s avg both sides).

Debug case expected files regenerated: agile1's pruned plan is 1.31p
cheaper nominally (metric-neutral), pre_saving1's is cost-identical with a
+0.21p pv10 residual.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(plan): drop the clip removal branches now subsumed by the prune

prune_dead_plan_slots decides whether a slot achieves anything by asking the
model, which makes the trajectory-based removal heuristics in clip_charge_slots
and clip_export_slots redundant. Instrumenting every branch across the 20
scenario benchmark shows the removal branches fire 31 times with the prune
disabled and 0 times with it enabled, while both clip-up branches keep firing
unchanged (111 fires). The same holds on the real captures from #4453/#4478.

Removed: freeze-export-at-100%, no-SoC-above-reserve (#4171/#4434), phantom
export (#4453/#4487), export target-unreachable, and charge
never-reaches-limit. What remains in both functions is limit adjustment only -
narrowing a requested limit to what the window can actually achieve, so the
target sent to the inverter matches the simulated plan and adjacent windows
merge. The charge freeze-to-charge-at-100% conversion is kept: it rewrites a
limit rather than removing a window, and covers windows the prune skips.

The prune deliberately does not trial a window covering the current minute
(#4402), so an in-progress phantom is no longer converted in place; it is
re-planned on the next cycle from real inverter state instead.

Random benchmark over 20 scenarios before vs after this removal: plan metric
and cost identical 20/20, runtime unchanged. Debug cases pass unchanged - the
expected files regenerated for the prune itself needed no further update, which
is independent evidence the branches were dead.

Tests that asserted the removed behaviour are rewritten to assert the new
contract (clipping adjusts limits and never removes); the removal behaviour
they covered is now exercised by the prune tests and the debug case regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plan): let the prune trial in-progress slots too

The prune skipped any window covering the current minute, on the grounds that
#4402 forbids cancelling an in-progress export. That over-applied the guard:
#4402's regression came from writing back a change scored on optimise_export's
adjusted metric (commitment bonus plus tie-break weightings) with no check that
the whole plan improved, and its fix was to gate on the unadjusted whole-plan
metric - exactly what the prune trial already uses. An in-progress export worth
anything fails that gate and is kept.

Skipping it also left the worst gap: the in-progress window is the slot being
executed right now, so a dead one there is precisely the spurious command that
reaches the inverter - and since the clip removal branches were dropped in
favour of the prune, nothing covered it at all.

Probing the 20 scenario benchmark, all 15 in-progress slots are worth real
money to keep (+0.41p to +25.91p if removed), so none are pruned and the plans
are identical 20/20 on metric and cost. Runtime +0.03s average for the extra
trials.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(random): start scenarios part way through a plan slot

Every scenario ran from the template's minutes_now of 08:00, exactly on a
30 minute boundary, so a window covering the current minute always had its
full length remaining. Partially-elapsed slots - the shape that matters for
in-progress pruning and for anything that reasons about the remainder of the
window being executed - were never generated.

Scenarios now carry a clock offset sampled from 0/5/10/15/20/25 minutes (5
minutes being predbat's run cadence) and start that far into the slot. The
offset is drawn last in the generator's random sequence, so re-generating an
existing seed leaves every other parameter unchanged, and it is applied from
the slot boundary rather than the current clock so that applying scenarios in
a loop cannot accumulate offsets.

Scenario files written before this carry no "clock" entry and keep the
template's own minutes_now: re-running the committed cases/random_scenarios.yaml
gives metric and cost identical to before on all 20, so previously recorded
benchmark results stay comparable.

A freshly generated set has 16 of 20 scenarios starting mid-slot and produces
in-progress windows with 5, 15 and 25 minutes remaining (previously always 30).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(random): randomise scenario start time, and cover the whole PV horizon

Replaces the within-slot clock offset with a random minute-of-day (still on
predbat's 5 minute cadence), so scenarios start at every point of the tariff
and solar day - overnight cheap windows, the evening peak, mid-generation -
rather than all at the template's 08:00. Start times in the regenerated set
span 00:10 to 22:10 with 15 of 20 landing off a 30 minute boundary, so
partially-elapsed slots are now generated as a matter of course.

Fixes a harness bug the randomisation would otherwise have made much worse:
step_data_history reads forward series at (minute + minutes_now), but
expand_pv_forecast only generated 0..forecast_minutes, so the tail of every
horizon silently had no PV at all - 8 hours' worth even at the fixed 08:00
start, and nearly the whole horizon for a scenario starting late in the day.
It now runs to minutes_now + forecast_minutes.

cases/random_scenarios.yaml is regenerated, which resets the benchmark
baseline: the scenarios are substantially richer (mean optimise time 1.3s ->
12.9s, because the horizon now actually contains solar), so results are not
comparable with runs recorded before this commit.

Re-measured on the new set, prune vs the pre-prune base: nominal cost better
by 3.36p on average (best -43.21p, worst +0.06p, which is within the
accumulated per-trial epsilon), pv-weighted metric +0.25p on average as pure
pessimistic-branch insurance is stripped, optimise time +0.8s (6%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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