Fix the PV calibration cap so it bounds the planner's forecast - #4390
Conversation
…ecast Two tasks: replace the global cap with a per-slot min(max(1.2*max_kwh, observed), max(observed, raw_slot)), dropping max_pv_power_forecast which made the cap depend on its own previous output; then apply the cap to pv_forecast_minute_adjusted so it reaches the planner rather than only the published sensor attributes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y ceiling The old cap was a single global min(max_kwh_cap, observed_cap) that could clip below a slot's own raw forecast (a dull week suppressing a sunny day) and was circular (observed_cap folded in max_pv_power_forecast, which is read back from the pv_forecast_h0 sensor whose state is this same capped output). Replace it with a per-slot cap min(ceiling, max(observed_slot, raw_slot)) where ceiling = max(1.2 * max_kwh, max_pv_power_hist), computed inside the existing per-slot loop so the raw forecast floor and observed-generation floor are both respected and the cap depends only on trustworthy inputs.
test_pv_calibration_capped_data_clamp and test_pv_calibration_cap_applies_ without_declared_capacity only checked an upper bound, so both passed under the old, buggy cap formula (which under-capped rather than over-capped in these scenarios) as well as the new one - they didn't actually pin the fix. Add a lower-bound check to each, verified to fail under the reverted old cap block and pass under the current one.
Final-review fix wave for the PV calibration cap: worst_day_scaling only had a lower clamp (0.3), so when every day's actual/forecast ratio exceeded the hard-clamped average_day_scaling ceiling (2.0), the division left worst_day_scaling > 1.0. pv_forecast_minute10 - which the planner consumes - is built by multiplying the already-capped P50 series by this factor, so the "pessimistic" P10 scenario could exceed both P50 and the array's physical ceiling, and invert cloud modelling. Also: derive the cap summary log's ceiling from the already-computed ceiling_slot instead of recomputing it, strengthen the planner-data test's lower bound to a proper two-sided check, add a test pinning the deliberate no-history 1.2x ceiling clipping, and correct stale "inverter rating" wording left over from before max_kwh was distinguished from it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Distinguishes a config problem (kwp under-declared, or pv_scaling raised to compensate) from ordinary calibration scaling in pv_calibration()'s existing capped-slots log, which cannot tell the two apart. The new warning names both settings and states that the forecast is being clipped, so users have a way to diagnose a low-looking plan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every other calibration log in solcast.py (including the capped_slots line directly above) uses "Warn: SolarAPI: PV Calibration: ...". Fix the new raw-forecast-exceeds-ceiling warning to match, and loosen the two new tests to assert on a distinctive message fragment rather than the prefix, so they don't re-break if the prefix convention changes again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes PV forecast capping in SolarAPI.pv_calibration() so the planner is bounded by a per-slot physical ceiling (instead of only capping published sensor attributes), removes the circular dependency on the pv_forecast_h0 sensor-derived peak, and ensures the pessimistic P10 series cannot exceed P50/cap.
Changes:
- Reworked PV calibration capping to be computed per plan slot from
min(ceiling_slot, max(observed_slot, raw_slot))and applied topv_forecast_minute_adjusted(planner-consumed data) via proportional scaling. - Clamped
worst_day_scalingto<= 1.0to prevent P10 from exceeding P50/cap. - Added/updated Solcast calibration tests to pin the new cap invariants and new warning behavior; added an implementation-plan doc.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| docs/superpowers/plans/2026-07-30-pv-calibration-cap.md | Adds an implementation plan and test/runbook instructions for the PV calibration cap changes. |
| apps/predbat/tests/test_solcast.py | Expands unit coverage for the new per-slot cap behavior, planner-data capping, P10 constraints, and warning emission. |
| apps/predbat/solcast.py | Applies a per-slot physical ceiling cap to planner forecast data and bounds worst-day scaling to keep P10 coherent. |
Comments suppressed due to low confidence (8)
docs/superpowers/plans/2026-07-30-pv-calibration-cap.md:325
- This command uses a user-specific absolute path, which makes the instructions non-reproducible for other contributors. Prefer a repo-relative path (or a generic placeholder) in docs.
cd /Users/treforsouthwell/source/batpred/coverage
docs/superpowers/plans/2026-07-30-pv-calibration-cap.md:377
- This command uses a user-specific absolute path, which makes the instructions non-reproducible for other contributors. Prefer a repo-relative path (or a generic placeholder) in docs.
cd /Users/treforsouthwell/source/batpred/coverage
docs/superpowers/plans/2026-07-30-pv-calibration-cap.md:388
- This command uses a user-specific absolute path, which makes the instructions non-reproducible for other contributors. Prefer a repo-relative path (or a generic placeholder) in docs.
cd /Users/treforsouthwell/source/batpred
docs/superpowers/plans/2026-07-30-pv-calibration-cap.md:453
- This command uses a user-specific absolute path, which makes the instructions non-reproducible for other contributors. Prefer a repo-relative path (or a generic placeholder) in docs.
cd /Users/treforsouthwell/source/batpred/coverage
docs/superpowers/plans/2026-07-30-pv-calibration-cap.md:496
- This command uses a user-specific absolute path, which makes the instructions non-reproducible for other contributors. Prefer a repo-relative path (or a generic placeholder) in docs.
cd /Users/treforsouthwell/source/batpred/coverage
docs/superpowers/plans/2026-07-30-pv-calibration-cap.md:509
- This command uses a user-specific absolute path, which makes the instructions non-reproducible for other contributors. Prefer a repo-relative path (or a generic placeholder) in docs.
cd /Users/treforsouthwell/source/batpred/coverage
docs/superpowers/plans/2026-07-30-pv-calibration-cap.md:521
- This command uses a user-specific absolute path, which makes the instructions non-reproducible for other contributors. Prefer a repo-relative path (or a generic placeholder) in docs.
cd /Users/treforsouthwell/source/batpred
docs/superpowers/plans/2026-07-30-pv-calibration-cap.md:533
- This command uses a user-specific absolute path, which makes the instructions non-reproducible for other contributors. Prefer a repo-relative path (or a generic placeholder) in docs.
cd /Users/treforsouthwell/source/batpred/coverage
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
apps/predbat/solcast.py:1137
- pv_estimate10/pv_estimate90 are computed from the pre-scale-down pv_value, then pv_forecast_minute_adjusted is potentially scaled down to capped_data. In slots where pv_value > capped_data and worst_day_scaling < 1.0, this makes the published pv_estimate10 inconsistent with the planner’s P10 series (built later from the scaled-down P50) and can overstate the pessimistic forecast (e.g. pv_value=10, cap=3, worst=0.7 -> pv_estimate10 becomes 3.0 but the planner’s P10 would be 2.1). Derive pv_estimate10/90 from the capped P50 slot value instead.
capped_data = min(ceiling_slot, max(observed_slot, raw_value))
pv_estimateCL[minute] = dp4(min(pv_value, capped_data))
pv_estimate10[minute] = dp4(min(pv_value * worst_day_scaling, capped_data))
pv_estimate90[minute] = dp4(min(pv_value * best_day_scaling, capped_data))
…series pv_estimateCL/10/90 were computed from the pre-cap P50 slot value and re-clamped independently, while the planner's pv_forecast_minute10 is built later from the already-capped P50. In any slot where the cap binds, the two disagreed - published pv_estimate10 could equal pv_estimateCL (spread collapsed to zero) while the planner P10 was genuinely lower. Derive all three published series from the same capped P50 so they agree with what the plan used. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
The cap in
pv_calibration()was meant to stop calibration scaling the solar forecast above what the array can physically produce. It wasn't doing that. Three defects:pv_estimateCL/pv_estimate10/pv_estimate90, which only annotate published sensor attributes.pv_calibration()returned the uncappedpv_forecast_minute_adjusted— the series the optimiser actually plans against. Sinceslot_adjustmentis clamped to[0.2, 4.0]andaverage_day_scalingto[0.1, 2.0], and they compound, the planner could receive a forecast several times the array's ceiling.min(max_kwh_cap, observed_cap)ignored what the forecast itself predicted for a slot, so a dull week's low observed peak suppressed the next sunny day's plan.observed_capincludedmax_pv_power_forecast, which is read back from thepv_forecast_h0sensor — whose state is that same capped output.The new cap
Per slot, in kWh per plan interval:
1.2 * max_kwh— declared array capacity plus headroom.max_kwhiskwp * efficiencysummed across planes; it is not the inverter rating, contrary to the old comment. Inverter limits are the wrong bound for PV generation: a hybrid can harvest through the MPPT and DC-DC stage without crossing the AC rating, and for AC-coupled systems the battery inverter is a different device entirely. Cloud-edge enhancement and cool cells briefly push an array above nameplate, hence the 20%.max(..., max_pv_power_hist)in the ceiling — measured generation beats a declared figure. Under-declaredkwpis common (users enter the inverter size, or one string of two), and the cap must never clip below what the meter recorded.max(observed_slot, raw_slot[s])— the cap only limits scaling upwards; it never clips the raw forecast in the normal case.Because
ceiling_slot >= observed_slotand the innermax >= observed_slot, the invariantcap[s] >= observed_slotholds for all inputs.max_pv_power_forecastis still computed and still logged — only its use in the cap is removed, which is what breaks the feedback loop.Also fixed: the planner's P10 series
Capping P50 alone was not enough.
pv_forecast_minute10is rebuilt after the cap loop asP50 * worst_day_scaling, andworst_day_scalinghad only a lower clamp whileaverage_day_scalingis hard-clamped to 2.0. Whenever every day's actual/forecast ratio exceeds 2.0, the clamped denominator leavesworst_day_scaling > 1.0— measured at up to 4x the cap, and inverted above P50, which silently disables cloud modelling (fetch.py:50returnsNonewhenpv_total10 >= pv_total).worst_day_scalingis now clamped to at most 1.0. A "worst day relative to average" multiplier above 1.0 is incoherent regardless, and since P10 derives from the already-capped P50 this makesP10 <= P50 <= caphold by construction.New diagnostic
The existing
capped_slotslog couldn't distinguish calibration scaling too hard (intended) from a config problem. A separate warning now fires when the raw forecast alone exceeds the ceiling:pv_scalingis deliberately not applied to the ceiling. It is a user dial applied upstream atsolcast.py:1366; letting it move a physical bound would defeat the cap in the direction that matters —pv_scaling: 3.0would yield a 3.6x nameplate ceiling. The warning points atkwp, which is the correct knob.Behaviour changes
max_kwh = 9999, so the ceiling is inert and the cap reduces tomax(observed_slot, raw_slot[s]). Both terms are floors, so no plan can be cut below the greater of the demonstrated peak and today's own raw forecast. What changes is that a plan which could previously reach 8x raw is now bounded. The new warning cannot fire for them.max_pv_power_hist = 0, so the ceiling is strictly1.2 * max_kwhand a raw forecast above that is clipped. Intended, and pinned by a test.Testing
Nine new tests in
apps/predbat/tests/test_solcast.pycovering: the raw-forecast floor, the headroom ceiling binding, an under-declaredkwpnot clipping observed generation,max_kwh = 9999, the cap reaching the planner's data, P10 never exceeding the cap or P50, the no-history ceiling behaviour, and the new warning firing and not firing. One existing test was updated — its fixture was precisely the case defect 2 describes.Full suite passes:
All tests passed (total time: 277.29s).🤖 Generated with Claude Code