Skip to content

Commit d58e387

Browse files
fix(pv90): clamp the synthesised p90 against the array ceiling, not against capped_data
The previous commit's clamp used capped_data, which is min(ceiling_slot, max(observed_slot, raw_value)) - not the array's physical ceiling. It collapses to the raw forecast itself whenever the forecast sits below the ceiling, and capped_p50 then equals capped_data, so the ratio came out at exactly 1.0 and erased best_day_scaling altogether. That is worse than the bug it was meant to fix. The create_pv10 path is reached only by Forecast.solar and Open-Meteo - exactly the users with no real Solcast p90, and the ones the docs now tell that calibration provides the 90% data. With calibration not enabled (fewer than 3 valid history days, so a fresh install or a system that was down) every adjustment is forced to 1.0 and the p90 would have equalled the p50 exactly, every slot, every day: the scenario still simulated, still costing planning time, measuring nothing on the axis it exists for, silently. Both the planner series and the published pv_estimate90 now clamp against ceiling_slot, the physical ceiling. The two expressions are the same clamp algebraically, so they still agree slot for slot as the comment above them requires. The division is guarded against a zero capped_p50. test_pv_calibration_capped_data_clamp gains a second scenario where the ceiling does NOT bind (max_kwh 20.0 against the same 3 kW forecast) and asserts the p90 carries its full 1.3x upside while staying under the array limit. Scenario A alone could not pin this: with the ceiling binding, a p90 collapsed onto the p50 is indistinguishable from a correctly clamped one. Verified in three states - no clamp fails the over-ceiling assertion, the capped_data clamp fails the new non-binding assertion, and the ceiling_slot clamp passes both. Weight-0 plan identity gate re-confirmed byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b6dd811 commit d58e387

2 files changed

Lines changed: 96 additions & 46 deletions

File tree

apps/predbat/solcast.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,22 +1137,33 @@ def pv_calibration(self, pv_forecast_minute, pv_forecast_minute10, pv_forecast_m
11371137
capped_data = min(ceiling_slot, max(observed_slot, raw_value))
11381138
# Derive all three published series from the capped P50 so they agree with the
11391139
# planner series built below from the (also capped) pv_forecast_minute_adjusted.
1140-
# pv_estimate10 needs no min(..., capped_data): worst_day_scaling is clamped to at
1141-
# most 1.0 above, so capped_p50 * worst_day_scaling <= capped_p50 <= capped_data
1142-
# always holds. pv_estimate90 keeps the clamp because best_day_scaling can exceed
1143-
# 1.0, so the optimistic case can genuinely exceed the physical ceiling.
1140+
# pv_estimate10 needs no clamp at all: worst_day_scaling is clamped to at most 1.0
1141+
# above, so capped_p50 * worst_day_scaling <= capped_p50 <= capped_data always holds.
1142+
# pv_estimate90 keeps a clamp because best_day_scaling can exceed 1.0 (up to 2.0, and
1143+
# 1.3 by default when calibration is off), so the optimistic case can genuinely exceed
1144+
# what the array can physically produce.
1145+
#
1146+
# That clamp is against ceiling_slot, the physical ceiling, NOT against capped_data.
1147+
# capped_data is min(ceiling_slot, max(observed_slot, raw_value)), so it collapses to the
1148+
# raw forecast itself whenever the raw forecast sits below the ceiling - and capped_p50
1149+
# then equals capped_data, making the clamp bite at exactly 1.0 and erasing the upside
1150+
# entirely. On a system whose forecast is comfortably inside its array limit (the normal
1151+
# case) that would leave pv90 identical to nominal, silently: the scenario would still be
1152+
# simulated and still cost planning time while measuring nothing on the axis it exists
1153+
# for. Only the array limit may cap the upside case.
11441154
capped_p50 = min(pv_value, capped_data)
11451155
pv_estimateCL[minute] = dp4(capped_p50)
11461156
pv_estimate10[minute] = dp4(capped_p50 * worst_day_scaling)
1147-
pv_estimate90[minute] = dp4(min(capped_p50 * best_day_scaling, capped_data))
1157+
pv_estimate90[minute] = dp4(min(capped_p50 * best_day_scaling, ceiling_slot))
11481158

11491159
# The planner's p90 series (built in the create_pv10 block below from the capped
11501160
# per-minute data) must land on the same ceiling as pv_estimate90 above, or the two
1151-
# disagree exactly where the comment above says they must agree. capped_data is kWh per
1161+
# disagree exactly where the comment above says they must agree. ceiling_slot is kWh per
11521162
# plan interval, so the clamp cannot be applied per minute; record the scaling that
1153-
# holds this slot's p90 total at min(capped_p50 * best_day_scaling, capped_data) instead
1154-
# and let the block below scale every minute of the slot by it.
1155-
slot_best_scaling[minute] = min(best_day_scaling, capped_data / capped_p50) if capped_p50 > 0 else best_day_scaling
1163+
# holds this slot's p90 total at min(capped_p50 * best_day_scaling, ceiling_slot) instead
1164+
# and let the block below scale every minute of the slot by it. An empty slot has no
1165+
# ratio to take, and needs no clamp either - scaling zero by anything stays zero.
1166+
slot_best_scaling[minute] = min(best_day_scaling, ceiling_slot / capped_p50) if capped_p50 > 0 else best_day_scaling
11561167

11571168
# Apply the same cap to the per-minute data the planner consumes. Scale rather than
11581169
# clamp per minute: capped_data is kWh per plan interval, not per minute.

apps/predbat/tests/test_solcast.py

Lines changed: 76 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3314,19 +3314,27 @@ def mock_get_history(entity_id, days, required=False, _h0=h0_ha_history):
33143314

33153315
def test_pv_calibration_capped_data_clamp(my_predbat):
33163316
"""
3317-
Test the per-slot cap in pv_calibration.
3317+
Test the per-slot cap in pv_calibration, and the array-ceiling clamp on the synthesised p90.
33183318
3319-
Setup: observed peak is 1 kW, the raw forecast is 3 kW, and max_kwh (declared array
3320-
capacity) is 2 kW. The h0 forecast history is empty so calibration is disabled and the
3321-
adjusted values equal the raw forecast.
3319+
Setup: observed peak is 1 kW and the raw forecast is 3 kW. The h0 forecast history is empty so
3320+
calibration is disabled, the adjusted values equal the raw forecast, and best_day_scaling takes
3321+
its fixed 1.3 default.
33223322
3323-
cap = min(ceiling, max(observed_slot, raw_slot))
3324-
= min(max(1.2 * 2.0, 1.0), max(1.0, 3.0))
3325-
= min(2.4, 3.0)
3326-
= 2.4 kW -> 2.4 * plan_interval / 60 per slot
3323+
Scenario A - max_kwh (declared array capacity) is 2 kW, so the ceiling BINDS:
3324+
3325+
cap = min(ceiling, max(observed_slot, raw_slot))
3326+
= min(max(1.2 * 2.0, 1.0), max(1.0, 3.0))
3327+
= min(2.4, 3.0)
3328+
= 2.4 kW -> 2.4 * plan_interval / 60 per slot
33273329
33283330
The 1.2 * max_kwh ceiling is what binds here. Note the cap is ABOVE the observed peak
3329-
of 1 kW: a dull week must not suppress a sunny day's forecast.
3331+
of 1 kW: a dull week must not suppress a sunny day's forecast. The p90 must be held AT that
3332+
ceiling rather than 1.3x above it.
3333+
3334+
Scenario B - max_kwh is 20 kW, so the ceiling does NOT bind and the p90 must carry its full
3335+
1.3x upside. This is the scenario that distinguishes a clamp against the physical ceiling from
3336+
a clamp against capped_data, which silently erases the upside on any system whose forecast sits
3337+
inside its array limit - i.e. very nearly all of them.
33303338
"""
33313339
print(" - test_pv_calibration_capped_data_clamp")
33323340
failed = False
@@ -3360,27 +3368,45 @@ def mock_minute_data_import_export(max_days_previous, now_utc, key, scale=1.0, r
33603368
base.minute_data_import_export = mock_minute_data_import_export
33613369
solar.get_history_wrapper = lambda entity_id, days, required=False: []
33623370

3363-
# Forecast: 3 kW constant (above historical and above max_kwh)
3364-
total_minutes = 4 * 24 * 60
3365-
pv_forecast_minute = {m: 3.0 / 60 for m in range(total_minutes)} # kWh per minute
3366-
pv_forecast_minute10 = {m: 2.0 / 60 for m in range(total_minutes)}
3367-
33683371
# Build forecast data entries — one per plan_interval over 1 day
33693372
from datetime import timedelta
33703373
import pytz
33713374

33723375
midnight = base.midnight_utc.replace(tzinfo=pytz.utc)
3373-
pv_forecast_data = []
3374-
for slot in range(0, 24 * 60, plan_interval):
3375-
ts = midnight + timedelta(minutes=slot)
3376-
pv_forecast_data.append({"period_start": ts.strftime("%Y-%m-%dT%H:%M:%S+0000"), "pv_estimate": 3.0 * plan_interval / 60})
33773376

3377+
def _calibrate(max_kwh):
3378+
"""Run pv_calibration against a 3 kW constant forecast for the given declared array capacity.
3379+
3380+
Every input is rebuilt per call: pv_calibration writes into the p10/p90 dicts it is handed
3381+
and annotates the forecast data entries in place, so reusing them across scenarios would
3382+
leak the first scenario's results into the second.
3383+
"""
3384+
total_minutes = 4 * 24 * 60
3385+
pv_forecast_minute = {m: 3.0 / 60 for m in range(total_minutes)} # kWh per minute
3386+
pv_forecast_minute10 = {m: 2.0 / 60 for m in range(total_minutes)}
3387+
pv_forecast_data = []
3388+
for slot in range(0, 24 * 60, plan_interval):
3389+
ts = midnight + timedelta(minutes=slot)
3390+
pv_forecast_data.append({"period_start": ts.strftime("%Y-%m-%dT%H:%M:%S+0000"), "pv_estimate": 3.0 * plan_interval / 60})
3391+
# create_pv10=True so the synthesised p10/p90 planner series are built too - they are the series
3392+
# every Open-Meteo and Forecast.solar user's planner actually consumes, and the p90 is scaled by
3393+
# best_day_scaling (1.3 here with calibration disabled, up to 2.0 with it on), so it is the one
3394+
# series that can be pushed above the array limit after the p50 has been capped.
3395+
return solar.pv_calibration(pv_forecast_minute, pv_forecast_minute10, {}, pv_forecast_data, create_pv10=True, divide_by=1.0, max_kwh=max_kwh, forecast_days=solar.forecast_days)
3396+
3397+
def _max_slot_total(series):
3398+
"""Return the largest per-plan-interval total in a per-minute kWh series."""
3399+
if not series:
3400+
return 0.0
3401+
slot_totals = {}
3402+
for minute, value in series.items():
3403+
slot_start = int(minute / plan_interval) * plan_interval
3404+
slot_totals[slot_start] = slot_totals.get(slot_start, 0.0) + value
3405+
return max(slot_totals.values())
3406+
3407+
# ---- Scenario A: the array ceiling BINDS (max_kwh 2.0 against a 3 kW forecast) ----
33783408
max_kwh = 2.0 # panel peak output cap in kW
3379-
# create_pv10=True so the synthesised p10/p90 planner series are built too - they are the series
3380-
# every Open-Meteo and Forecast.solar user's planner actually consumes, and the p90 is scaled by
3381-
# best_day_scaling (1.3 here with calibration disabled, up to 2.0 with it on), so it is the one
3382-
# series that can be pushed back above the ceiling after the p50 has been capped.
3383-
adj_minute, adj_minute10, adj_minute90, adj_data = solar.pv_calibration(pv_forecast_minute, pv_forecast_minute10, {}, pv_forecast_data, create_pv10=True, divide_by=1.0, max_kwh=max_kwh, forecast_days=solar.forecast_days)
3409+
adj_minute, adj_minute10, adj_minute90, adj_data = _calibrate(max_kwh)
33843410

33853411
expected_cap = 1.2 * max_kwh / 60 * plan_interval # the 1.2 * max_kwh ceiling binds here
33863412

@@ -3400,21 +3426,10 @@ def mock_minute_data_import_export(max_days_previous, now_utc, key, scale=1.0, r
34003426
print("ERROR: pv_estimateCL {} is below the expected cap {} - the raw forecast floor is not being applied".format(got_max, expected_cap))
34013427
failed = True
34023428

3403-
# The synthesised p90 planner series must respect the SAME ceiling the p50 was capped to.
3404-
# best_day_scaling has no floor at 1.0 (it defaults to 1.3 with calibration disabled and can
3405-
# reach 2.0 with it on), so an unclamped p90 lands at 1.3x the array's physical ceiling and
3406-
# the planner prices a spill that cannot happen - and it disagrees with the published
3429+
# The synthesised p90 planner series must respect the array ceiling. best_day_scaling has no
3430+
# floor at 1.0, so an unclamped p90 lands at 1.3x the array's physical ceiling here and the
3431+
# planner prices a spill that cannot happen - and it disagrees with the published
34073432
# pv_estimate90 for the very same slot, which is clamped.
3408-
def _max_slot_total(series):
3409-
"""Return the largest per-plan-interval total in a per-minute kWh series."""
3410-
if not series:
3411-
return 0.0
3412-
slot_totals = {}
3413-
for minute, value in series.items():
3414-
slot_start = int(minute / plan_interval) * plan_interval
3415-
slot_totals[slot_start] = slot_totals.get(slot_start, 0.0) + value
3416-
return max(slot_totals.values())
3417-
34183433
max_slot_p50 = _max_slot_total(adj_minute)
34193434
max_slot_p90 = _max_slot_total(adj_minute90)
34203435
if max_slot_p90 > expected_cap * 1.01:
@@ -3424,6 +3439,30 @@ def _max_slot_total(series):
34243439
print("ERROR: pv_forecast_minute90 peaks at {} kWh/slot, below the p50 peak of {} kWh/slot - the clamp must hold the upside case AT the ceiling, not below it".format(round(max_slot_p90, 4), round(max_slot_p50, 4)))
34253440
failed = True
34263441

3442+
# ---- Scenario B: the array ceiling does NOT bind (max_kwh 20.0 against the same 3 kW forecast) ----
3443+
# Scenario A alone cannot pin the clamp: there every slot sits AT the ceiling, so a p90 that has
3444+
# been collapsed onto the p50 is indistinguishable from a correctly clamped one. Here the ceiling
3445+
# is 8x above the forecast and must not bite at all - the p90 has to carry the full
3446+
# best_day_scaling upside. Clamping against capped_data instead of the physical ceiling fails
3447+
# this: capped_data collapses to the raw forecast whenever the forecast is below the ceiling, so
3448+
# the ratio comes out at exactly 1.0 and the upside is erased on every normally-sized system.
3449+
max_kwh_open = 20.0
3450+
_, _, adj_minute90_open, _ = _calibrate(max_kwh_open)
3451+
ceiling_open = 1.2 * max_kwh_open / 60 * plan_interval
3452+
best_day_scaling = 1.3 # calibration is disabled (no h0 history), so this is the fixed default
3453+
max_slot_p90_open = _max_slot_total(adj_minute90_open)
3454+
expected_p90_open = 3.0 / 60 * plan_interval * best_day_scaling
3455+
if max_slot_p90_open < expected_p90_open * 0.99:
3456+
print(
3457+
"ERROR: with the array ceiling {} kWh/slot well above the forecast, pv_forecast_minute90 peaks at only {} kWh/slot - expected {} ({}x the {} kWh/slot forecast). The upside has been clamped away by something other than the array limit".format(
3458+
round(ceiling_open, 4), round(max_slot_p90_open, 4), round(expected_p90_open, 4), best_day_scaling, round(3.0 / 60 * plan_interval, 4)
3459+
)
3460+
)
3461+
failed = True
3462+
if max_slot_p90_open > ceiling_open * 1.01:
3463+
print("ERROR: pv_forecast_minute90 peaks at {} kWh/slot, above the array ceiling of {} kWh/slot".format(round(max_slot_p90_open, 4), round(ceiling_open, 4)))
3464+
failed = True
3465+
34273466
finally:
34283467
test_api.cleanup()
34293468

0 commit comments

Comments
 (0)