fix(plan): convert phantom export windows to freeze in clip_export_slots (#4453) - #4487
Merged
Conversation
…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>
Contributor
There was a problem hiding this comment.
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_slotsto 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.
… 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>
Merged
4 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
metric_min_improvement_export(raised to 1.0p by the reporter to fight churn — scaled ~0.83p for the window; 0.67p < 0.83p).drop=Truebranch ofoptimise_swap_exporthas no live caller (only the commented-out early invocation passes it). Its own gate (best_metric_drop <= selected_metric) would have deleted this window.clip_export_slotsbranch 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.metric_min_improvement_plan(2.0p), soshould_replace_plankeeps 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:set_export_freezeis unsupported; manual exports are preserved.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
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--quicksuite passes; pre-commit passesNote
cases/predbat_debug_pre_saving1.yaml.expected.jsonfails 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 skipsdebug_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