Skip to content

Feature: Automated Solar Clipping Mitigation (using native clear-sky models) - #4086

Open
rholligan wants to merge 209 commits into
springfall2008:mainfrom
rholligan:clipping-cloud-model
Open

rholligan wants to merge 209 commits into
springfall2008:mainfrom
rholligan:clipping-cloud-model

Conversation

@rholligan

Copy link
Copy Markdown
Contributor

Introduction:

This PR introduces a native clipping buffer to Predbat. It prevents solar energy loss on high-generation days by creating dynamic headroom in the battery SoC, specifically targeting periods where PV generation is forecast to exceed inverter AC capacity or DNO export limits.

This implementation integrates with the existing cloud model. It extends SolarAPI to compute and store new pv_clearsky arrays (supporting multiple native upstream sources like ha_solcast_clearsky, solcast_api, and openmeteo). Clear-sky arrays are used alongside a lightweight auto-tuner to safely estimate peak generation and align it with hardware limits.

Resolves issue #1206, supersedes PR #4036.

Key Features:

  • Dynamic Buffer Calculation: Calculates exactly how much spare capacity the battery needs to safely absorb any solar generation that exceeds the inverter limits.
  • Cost-Optimized Headroom Creation: Integrates directly into Predbat's financial solver by adding a cost penalty whenever the battery doesn't have enough headroom. This naturally forces Predbat to prioritize exporting or deferring charge just before a solar peak hits, guaranteeing the necessary spare capacity.
  • Clear-Sky Cloud Modelling: Introduces ha_solcast_clearsky integration alongside a robust clipping_auto_tune mechanism. This explicitly accounts for those short "break in the clouds" spikes, ensuring the buffer is safely sized even when the standard hourly forecast is smoothed out.
  • Defensive HA Polling: Hardened solcast.py with safe float(val or 0.0) casting to gracefully handle missing HA clear-sky sensor history without crashing.

Configuration Parameters:

  • clipping_buffer_enable: Master toggle for the clipping buffer feature.
  • clipping_clearsky_source: Selection of clear-sky forecast source (recommends ha_solcast_clearsky). This setting is fully exposed in the Home Assistant configuration panel.
  • clipping_use_clearsky_peaks: Toggle to use Clear-Sky generation arrays rather than standard PV forecast arrays to size the buffer.
  • clipping_auto_tune: Automatically learns the scale difference between standard forecasts and hardware limits based on past clipping behavior.
  • clipping_amplification: Manual multiplier for the standard solar forecast (used if Auto-Tune is disabled).
  • clipping_cost_weight: Multiplier to add a harsher financial penalty for clipping, forcing the optimizer to prioritize creating headroom.
  • clipping_limit_override: Manual configuration of the inverter AC ceiling in Watts.
  • clipping_buffer_max_kwh / start_time / end_time: Manual settings to force a fixed static buffer size and time window instead of using the dynamic calculation.

Developed with Gemini ✦

rholligan added 30 commits June 14, 2026 12:27
@chalfontchubby

Copy link
Copy Markdown
Collaborator

@rholligan — Claude again, on chalfontchubby's behalf. Thinking on the clipping problem has moved on since we last spoke here, and some of it bears directly on what this PR needs to do. Passing it on rather than letting you find out the hard way.

Two things came out of digging into #4865 and a related bug.

1. A lot of "Predbat won't avoid clipping" turns out to be export slots being invisible, not a missing buffer

high_export_rates is built from a price threshold in fetch_sensor_data() before the planner runs. A window below that threshold isn't considered and rejected - it's absent for the whole cycle. The threshold comes from price statistics alone, so it can hide an entire period the plan needs. An ordinary two-level export tariff does it with no VPP event or spike involved:

export 20p morning / 90p afternoon, import flat 23.8p
-> threshold 20.5p, 24 candidate windows, every one of them 12:00 or later
-> windows covering any morning minute: 0

Battery fills by 11:00, nothing to export into, clips. That's #5221.

2. Once the slots are visible, the existing optimiser already handles it

This is the part most relevant to you. Across every coverage/debug/*.yaml, coverage/cases/*.yaml and the two #4865 files, looking at clipping after real planning rather than do-nothing clipping:

file do-nothing clip clip after planning
07_00_00 1.46 0.08
11_05_00 3.70 0.00
20_25_00 0.94 0.00
30 Aug (#4865) 3.70 0.00
daviessm (hidden slots) 2.90 3.26 → 2.08 once slots admitted

Every case except the hidden-slot one is already solved by existing logic. Where a prototype slot-admission walk wanted to export, 21/21 and 7/7 of those times were already inside existing candidate windows - it was picking the same slots the optimiser already uses.

Worth being straight about what that implies: on this corpus there's no residual for a clipping buffer to fix. Clipping is already costed correctly (clipped PV simply never earns its export revenue), and with slots visible the metric acts on it.

3. But there's a hole neither of us can close from the forecast, and it's yours

Everything above keys off predicted clipping, and a 30-minute-averaged forecast can't represent a 10-minute cloud spike. Same file, same PV data, varying only metric_cloud_coverage:

cloud factor modelled peak predicted clip
0 5.51 kW 0.01 kWh
0.27 7.00 kW 1.57 kWh
0.5 8.27 kW 4.09 kWh

A 120x swing on identical data. And the 30 August day it's taken from actually came in 31% under PV50, peaking at 5.48 kW against a 5.5 kW limit - nothing clipped at all. So sizing headroom from the central forecast is a bet, and the parameter it hinges on isn't calibrated against anything I can find.

Also worth knowing: Solcast's PV90 for that day peaked at 7.64 kW against a demonstrated clear-sky ceiling of ~9.4 kW - about 18% low. Your choice of pv_clearsky over a percentile looks like the right call, and for a sharper reason than when you made it.

What I'd suggest

The strongest remaining case for this PR isn't "the optimiser undervalues clipping" - it doesn't. It's the two things forecast-driven logic can't reach:

  • spiky-cloud clipping the model never sees (the table above)
  • the battery already being full before any export slot exists, which slot admission can't help with because there's nothing left to export

That's a narrower feature than "clipping mitigation", but it's a real gap with no other candidate fix, and it's robust precisely because a clear-sky buffer ignores the forecast's fine structure.

Practically: this branch is now CONFLICTING against main, and #4871 (the cloud modulation fix) is in - which changes what the model predicts, so any clipping numbers taken before 2 Sep are stale.

Happy to run your branch against the same corpus once it's rebased, if that's useful - the harness is just a per-step PV/load walk plus a calculate_plan before/after.

Analysis by Claude, run and reviewed by chalfontchubby.

@chalfontchubby

Copy link
Copy Markdown
Collaborator

@rholligan — Claude on chalfontchubby's behalf, with the review you asked for. Ran the branch locally rather than just reading the diff.

Short version: the core design is sound and the clear-sky choice looks better-justified than when you made it. Most of what follows is about separating things that don't need to ship together, because the non-clipping changes in here are what will make this hard to review and risky to merge, not the clipping work itself.

The clear-sky approach is right, and for a stronger reason than you had

Sizing from clear-sky rather than a forecast percentile is the correct instrument, because it's a physical bound — geometry, not probability. It can't be 31% wrong the way PV50 was on the 30 August day in #4865, and it doesn't depend on metric_cloud_coverage, which we've since found swings modelled clipping from 0.01 to 4.09 kWh on identical PV data.

Concretely: Solcast's PV90 for that day peaked at 7.64 kW against a demonstrated clear-sky ceiling of ~9.4 kW. Anything sized from PV90 would have undersized the buffer by ~18%. Your pv_clearsky default avoids that by construction.

One thing that needs fixing: the committed golden baseline doesn't match the branch

./run_all --test debug_cases fails on this branch:

**** Debug case predbat_debug_agile1.yaml: PASSED ****
ERROR: Actual plan does not match expected plan
**** Debug case predbat_debug_pre_saving1.yaml: FAILED ****

coverage/cases/predbat_debug_pre_saving1.yaml.expected.json was rebaselined in 81de6ae ("after clipping penalty fix"), but 4e50e41 then removed the prediction-side penalty in favour of the plan-side ceiling. So the committed baseline looks like it's from the older design and is now stale. Worth regenerating and re-checking what the diff says.

The bigger issue: plan changes on a case with the feature switched off

That failing case has clipping_buffer_enable at its default of False, so the plan should be byte-identical to main. It isn't — the charge windows are substantially reshaped (12 windows to 14, different targets, one fewer export window).

The gating on the clipping code itself looks right (inject_clipping_export_windows aborts early, the plan.py block is behind if self.clipping_buffer_enable). But some changes in here aren't gated at all and affect everyone:

  • prediction.py: minute_absolute_aligned = int(minute_absolute / step) * step changes the charge/export window lookup for every user on every plan. That alone could explain the reshaped windows.
  • prediction.py: the battery_to_max / limit_max rework and the battery_to_discharge_min change in the force-export branch.
  • remove_intersecting_windows infinite-loop fix (13e580e) — that one sounds like a genuine bug fix worth having on its own.

None of these are necessarily wrong. The problem is that they're plan-affecting changes to shared code riding along inside a feature PR that's off by default, which makes it very hard for anyone to tell whether a plan difference is the feature working or a regression.

What I'd suggest, and I think it makes this easier not harder

Split into two or three PRs:

  1. The genuine standalone fixes — remove_intersecting_windows infinite loop, the Windows/ctypes compatibility fixes, the kernel closing-brace fix. These are small, obviously-good, and would merge quickly on their own.
  2. The ungated prediction.py behaviour changes — the aligned window lookup and the battery_to_max rework. These need their own justification and their own golden-baseline discussion, because they move everyone's plan. If the aligned lookup is fixing a real off-by-step bug, that's a valuable fix in its own right and deserves to be seen as one rather than buried.
  3. The clipping buffer itself — which then has the property that with the flag off, every plan is byte-identical to main, and the only golden-baseline changes are ones you deliberately made.

That third PR becomes much easier to review and much easier for Trefor to merge, because "flag off means nothing changes" is checkable in one command.

Also worth pulling in: #4871 (cloud modulation fix, merged 2 Sep) changes what the model predicts, so any clipping numbers measured before then are stale. And the branch is currently CONFLICTING against main.

Where this feature fits now — the scope has narrowed, but it hasn't gone away

Worth knowing what we found while looking at #4865 and #5221, because it changes the argument for this PR rather than weakening it.

A lot of "Predbat won't avoid clipping" turns out to be export slots being invisible to the planner — the price threshold that builds high_export_rates can hide an entire period, so the optimiser has nothing to export into. Across all 12 debug files in the repo plus two from #4865, looking at clipping after real planning:

file do-nothing clip after planning
07_00_00 1.46 0.08
11_05_00 3.70 0.00
20_25_00 0.94 0.00
30 Aug 3.70 0.00

Existing logic already handles these. Clipping is costed correctly today (clipped PV simply never earns its export revenue), so where slots are visible, the metric acts on it.

But that leaves exactly the gap this PR fills, and nothing else does:

  • Spiky-cloud clipping the forecast never shows. A 30-minute-averaged forecast can't represent a 10-minute cloud break. Anything keyed off predicted clipping — including the slot-admission work — is blind to it. A clear-sky-sized buffer isn't, precisely because it ignores the forecast's fine structure.
  • The battery already being full before any export slot exists. Slot visibility can't help when there's nothing left to export.

So the honest framing is that this is a narrower feature than "clipping mitigation" — but it's the only candidate for the part that forecast-driven logic structurally cannot reach. That's a better argument than the one it started with, and it's worth putting in the PR description.

Happy to run your branch against the same 12-file corpus once it's rebased and the baseline regenerated — the harness is a per-step PV/load walk plus a calculate_plan before/after, and it reports avoidable clip relieved rather than clip remaining (on a day where PV genuinely exceeds pipe-plus-battery, residual clipping is physics, not a failure).

Review by Claude, run and reviewed by chalfontchubby. Method: fetched the branch, ran ./run_all --test debug_cases, and diffed against main.

@chalfontchubby

Copy link
Copy Markdown
Collaborator

One follow-up, offered as things to weigh rather than change requests — both might simplify what this PR has to carry.

1. Your auto-tuner and metric_cloud_coverage are solving overlapping problems

Reading clipping_auto_tune: it pulls the last 24h of pv_today, derives peak PV power, compares it to the inverter limit, and nudges auto_amp (and the window offsets) when observed peak lands within ±2% of the limit. So it's learning a per-site correction for "the forecast is smoother than reality".

That's very close to what metric_cloud_coverage is nominally for, and that parameter is — as far as I can find — not calibrated against anything. It's the single biggest lever on predicted clipping:

metric_cloud_coverage modelled peak predicted clip
0 5.51 kW 0.01 kWh
0.27 7.00 kW 1.57 kWh
0.5 8.27 kW 4.09 kWh

Same file, same PV data. Since #4871 (merged 2 Sep) that modulation now applies to the PV50/PV90 curves, not just PV10 — so on current main the central forecast already carries intra-slot variance it didn't when you started this work. Two things follow that might be worth testing before finalising the tuner:

  • How much of the amplification your tuner learns is now already in the curve? If fix(fetch): make the cloud/load divergence model actually modulate #4871 supplies a chunk of it, the tuner may be double-counting, and clipping_amplification may be doing less work than it was.
  • Your tuner has better data than the cloud model does. It's measuring real peak-vs-limit per site. If that estimate were fed back as a calibrated cloud factor rather than a clipping-only amplification, it would improve every consumer of the PV curve, not just the buffer. That's a bigger contribution than the buffer itself, and it's mostly code you've already written.

Not saying you should take that on — just that the tuner may be more generally valuable than its current scope, and it's worth knowing before it's finalised as a clipping-private mechanism.

One small note on the implementation while I was reading it: the tuner persists to clipping_auto_tune.json via direct file I/O. CLAUDE.md asks for the Storage component rather than direct file access, so that may get flagged in review.

2. Slot availability may change what the buffer has to do, not just how much

I mentioned #5221 as scope-narrowing, but there's a design angle I didn't draw out.

The two mechanisms answer different halves of the same constraint:

surplus arriving by time T  <=  export capacity admitted before T  +  battery headroom

Slot admission raises the first term; your buffer raises the second. Where slots are already visible, the optimiser is currently free to use either — and on the corpus it consistently chose export over headroom, ending at 0.00 clip without reserving anything.

Which raises a question worth answering deliberately: when both are available, which should the plan prefer? Exporting early costs the arbitrage spread if rates rise later; reserving headroom costs the cycling and the risk that the forecast peak doesn't arrive. Those trade off differently per site and per day, and if the buffer applies a cost penalty for insufficient headroom while the optimiser could have solved it more cheaply by exporting, the penalty could push the plan the wrong way.

Might be worth testing the buffer specifically on a case where an export slot would have solved it, to check the two don't fight. If they compose cleanly, that's a good result worth stating; if they don't, better to know before Trefor asks.

Genuinely not trying to expand your scope here — the buffer is still the only candidate for the spiky-cloud gap, and that stands regardless of either point above.

@chalfontchubby

Copy link
Copy Markdown
Collaborator

Thinking about the auto-tuner point a bit more — there might be a cleaner split available than the one I suggested earlier, if you like the shape of it.

Calibration and buffer as separate PRs.

The earlier suggestion (standalone fixes / ungated prediction changes / feature) was really about tidiness. This one is along a capability boundary instead, and I think it's a better line:

  • Calibration: your tuner's core insight — measure per-site how much peakier reality is than the forecast, and feed that back as a calibrated cloud factor.
  • Buffer: the clear-sky sizing, the headroom ceiling, the cost penalty, the config surface.

Why that line might be worth it:

The calibration stands alone and helps everyone. A better PV curve improves the planner, clipping prediction, the PV10/PV90 blend — everything downstream. It's useful with the buffer switched off, and to users who'll never turn it on. metric_cloud_coverage currently isn't calibrated against anything as far as I can tell, and it's the single biggest lever on predicted clipping (0.01 → 4.09 kWh across its range on one file). That's a live defect for current users, independent of any new feature, and a PR fixing it lands on its own merits.

The dependency only runs one way. The buffer wants good calibration; calibration doesn't need the buffer. So calibration merges first and the buffer builds on it — and if the buffer takes a while in review, the calibration isn't held up behind it. Given what happened to #4036, that seems worth something.

They're verifiable by different means. Calibration is a backtest: does the learned amplification predict observed peaks better than the fixed factor? No planning involved. The buffer needs plan-level before/after on clipping days. Different evidence, different questions, neither blocking the other.

Two things that cut the other way, so you can weigh them:

  • Calibration changes everyone's plans, being a shared-model change — so it carries the golden-baseline regeneration and justification, and loses the "flag off, nothing changes" property that makes the buffer easy to merge. Splitting puts that burden on the calibration PR rather than on the buffer, which I think is the right place for it, but it doesn't make it disappear.
  • Your tuner currently keys off peak-vs-inverter-limit, so it only learns anything on days that approach clipping. A general cloud calibration wants variance on all days. Right signal, possibly too narrow a sample — it'd need widening to be a true calibration rather than a clipping-day correction. That's real work, and a reason you might prefer to keep it clipping-scoped for now.

So the fuller sequence would be: standalone fixes → ungated prediction.py changes → calibration → buffer. Roughly increasing order of how much discussion each needs, each merging on its own evidence.

Entirely your call — you may well have reasons the tuner needs to stay coupled to the buffer, and "keep it scoped, ship it, generalise later" is a perfectly good answer too. Just wanted to put the option in front of you before the shape is settled.

@rholligan

rholligan commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough testing and feedback, @chalfontchubby. Based on your review, I have implemented your core recommendations on branch clipping-cloud-model as detailed below:

  1. Baseline Purity on pre_saving1 ("Ran the branch locally against our 12 test files..."):
    • Implemented: Gated battery_to_discharge_min, battery_draw, and window collision splitting strictly behind if is_anti_clipping: / has_clipping in apps/predbat/prediction.py and apps/predbat/utils.py. When clipping_buffer_enable: False, simulations run upstream logic verbatim. Reverted coverage/cases/predbat_debug_pre_saving1.yaml.expected.json to 0 diff against upstream main.
  2. Storage Architecture Compliance ("One follow-up, offered as things to weigh..."):
    • Implemented: Eliminated direct file I/O (open("clipping_auto_tune.json")) in apps/predbat/plan.py. Parameter persistence now strictly adheres to Predbat standard configuration persistence (predbat_config.json via self.save_current_config()), preserving Docker and Predbat.com database compatibility.
  3. Remote Branch Sync & Conflict Resolution ("Merge conflicts against current main..."):
    • Implemented: Merged latest origin/main and pushed local commits to GitHub, resolving the stale conflict state. All 14 pre-commit hooks and unit test suites pass 100%.
  4. Clear-Sky Physical Bounding ("Thinking on the clipping problem has moved on..."):
    • Appreciate the empirical validation against the 30 August data. The 18% under-forecast on Solcast PV90 against demonstrated clear-sky peak confirms why physical clear-sky bounding is structurally safer than percentile curves for sizing battery headroom.
  5. Auto-Tuning Scope & Slot Admission (#5221) ("Thinking about the auto-tuner point a bit more..."):
    • Keeping clipping_auto_tune strictly as a local headroom margin nudger on clipping days; expanding it into whole-codebase PV curve calibration would alter plans fleet-wide and is best addressed separately.
    • Slot admission and the clipping buffer solve distinct physical problems: morning slot admission helps when generation fits within inverter AC limits, but for oversized DC solar arrays full from overnight off-peak charging, midday solar exceeds AC grid export capacity. Sizing headroom overnight is physically required to avoid clipping.
  6. Daytime Import Arbitrage, Dynamic Window Scaling & C++ Kernel Parity (2d24b3fb):
    • SoC Unit Consistency (plan.py): Fixed a percentage-vs-kWh unit mismatch in allow_this_charge_window / optimise_charge_limit where clipping_target_soc_pct (0–100%) was placed directly into try_charge_limit (kWh) instead of converted to kWh and added to try_socs, and clamped clip_charge_slots so clipping-capped charge windows are never raised back to 100% during post-pass clipping.
    • C++ Prediction Kernel Parity (prediction_kernel.cpp / prediction_kernel.py): Bumped kernel ABI/parity (ABI 8, Revision 15), added the clipping flag to PkExportLimit, updated clip_intersecting_charge_windows to split and clamp intersecting charge windows to target_kwh rather than truncating/deleting them, and mirrored the Python anti-clipping target clamps inside pk_run_one.
    • Dynamic Window Duration & Solar-Deficit Arbitrage (plan.py): Scaled anti-clipping export window duration proportionally to buffer size (15–30m for small buffers up to 60m+ for multi-kWh summer buffers) and enabled positive daytime import arbitrage up to the dynamic clipping_target_soc_pct ceiling when pre-peak net solar cannot fill the battery (headroom_deficit > 0.25 kWh).

Merge Strategy

Please review the recent commits and updates above. Once you and @springfall2008 have had a chance to look over the branch, let's align on the best way to merge - whether to proceed directly with #4086 now that it is conflict-free and passing all checks, or decompose into smaller PRs.

@chalfontchubby

Copy link
Copy Markdown
Collaborator

Cheers. I'll read more in a bit, but we will need to get @springfall2008 to look as well. I don't have power to merge.

This branch has not been deployed

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

4 participants