Skip to content

feat(plan): add a pv90 upside forecast scenario to balance the one-sided pv10 hedge - #4462

Open
springfall2008 wants to merge 24 commits into
mainfrom
feat/pv90-upside-scenario
Open

feat(plan): add a pv90 upside forecast scenario to balance the one-sided pv10 hedge#4462
springfall2008 wants to merge 24 commits into
mainfrom
feat/pv90-upside-scenario

Conversation

@springfall2008

@springfall2008 springfall2008 commented Aug 8, 2026

Copy link
Copy Markdown
Owner

What this adds

A third simulated forecast scenario, pv90 (high PV, low load), alongside the existing nominal (p50) and pessimistic pv10 cases.

Predbat previously simulated only two futures. Risk was therefore one-sided: the optimiser was rewarded for hedging against a bad solar day and never charged for a hedge that turned out to be unnecessary. pv90 is the missing counterweight.

It ships off. A calculate_pv90_plan switch (default off) gates the whole feature. The switch is deliberately available without expert mode, so the scenario can be tested across as many real systems as possible; the two settings that tune it stay expert-only, so everyone who turns it on runs the same values and their feedback is comparable. pv_metric90_weight shows its real default of 0.15 in Home Assistant, but while the switch is off the runtime value is forced to 0.0, at which no pv90 simulation runs and the pv90 term collapses out of the metric. Everything routes through that single weight — there is no second gating path. Plans with the switch off are byte-identical to before, verified against predbat_debug_agile1.yaml (md5 d525d171…).

Honest summary of what it achieves

It does not fix the case it was built for, and the measurement explains why.

The motivating case (coverage/predbat_no_export_100.txt, 9.52 kWh battery, 0p export, 6.9p/28.85p import) charges to 100% overnight while carrying ~40% at end_record. The metric is dead flat from 66% to 100%, because the residual battery credit at end_record is 6.9 / 0.96 / 0.97 = 7.4098 p/kWh — exactly the marginal cost of storing a kWh at 6.9p. With the mid case flat, the decision fell to the pv10 hedge.

Measured metric slope across that plateau:

scenario slope
nominal +0.000p the plateau
pv10 −63.035p pulls to 100%
pv90 (best achievable) +17.2p pushes down

So the pivot is w90 > 3.66 × w10, confirmed at three weights. pv90's slope saturates around 17p against a 24p plateau width, so even a theoretically perfect pv90 tops out below pv10's 63p — at equal weights it can never win on this tariff.

That ratio is not a modelling artefact. In the pv10 world every carried kWh avoids importing at 28.85p; in the pv90 world a wasted kWh cost 6.9p. A 4.2:1 asymmetry in consequence. Charging to 100% is defensible insurance here.

What the branch does deliver: the p90 pipeline end to end, forecast90 now published by Solcast, a symmetric risk model ready for real p90 data, and precise measurement of the flat plateau — which points at the residual-battery valuation as the more promising next target.

Changes

  • Scenario selector — the boolean pv10 flag becomes a three-valued pv_scenario integer through plan.py, prediction.py and the kernel. PV_SCENARIO_PV10 == 1 keeps it numerically compatible with the old boolean.
  • p90 data pipelinesolcast.py builds and publishes a real p90 minute series as a forecast90 sensor attribute; fetch_pv_forecast() reads it, falling back to a copy of p50 when absent. No upside is synthesised.
  • Kernelpv90/load90 arrays added to PkContext, PkScenario.pv10 becomes pv_scenario, ABI 2→3 and both parity revisions bumped. All six platform binaries rebuilt.
  • Metric — the downside-only clamp is replaced by a signed weighted average across all three scenarios. This is required: pv90 is nearly always cheaper than nominal, so a one-sided clamp would contribute exactly zero at every candidate.
  • Configcalculate_pv90_plan (switch, default off, not expert-gated), plus pv_metric90_weight (default 0.15) and load_scaling90 (default 0.7), both expert-gated. All three documented in docs/customisation.md and docs/apps-yaml.md.

load_scaling90 (default 0.7) is a plain absolute multiplier, like load_scaling10. To stop any configuration inverting a scenario, the three are clamped at config-read time so the ordering always holds:

load_scaling90 = min(load_scaling90, load_scaling10, load_scaling)
load_scaling10 = max(load_scaling90, load_scaling, load_scaling10)

That guarantees load_scaling90 <= load_scaling <= load_scaling10 whatever the user sets, so pv90 is always the low-load case and pv10 always the high-load case. It also closes a latent inversion on the pv10 side that predates this branch: a user with load_scaling above load_scaling10 previously got a pv10 with less load than nominal. Clamping is logged when it changes a value, and is a no-op at the defaults.

Behaviour change for existing users

Removing the pv10 clamp affects every user, not just those who enable pv90: where metric10 < metric the metric is now a true weighted average rather than being pinned at metric. Measured exposure is small (1 of ~3538 candidates on agile1, 9 of ~6082 on pre_saving1) and plan-neutral across the debug suite, but it is not gated on pv_metric90_weight.

Testing

  • New tests/test_pv90.py, registered in TEST_REGISTRY.
  • tests/test_kernel_parity.py covers pv90; the random sweep now runs all three scenarios per seed (150/150/150, previously 57/58/35 after an accidental swap).
  • Kernel struct layout verified by compiled offsetof/sizeof against the ctypes definitions — all 112 field offsets byte-identical.
  • Full suite: 188 pass. debug_cases/predbat_debug_pre_saving1.yaml fails, but fails identically at the merge base with byte-identical output — pre-existing, not introduced here.

Known follow-ups

  • test_pv90_fallback_tracks_a_reassigned_p50 is not hermetic; it leans on a one-minute margin against state leaked by a sibling test. Can only cause a missed discard, never a spurious one.
  • A fifth weight-0 gate at plan.py:863 inside run_prediction_metric is not covered by the skip-gate test. Planning-cost only; plan output is unaffected.
  • Whether calculate_pv90_plan should default on, and whether 0.15 is the right weight, should be decided against a debug dump carrying real Solcast p90 data rather than the p50 fallback.
  • The debug_cases replay harness has fetch_config_options() commented out, so the load-scaling clamp does not fire on that path. Replays therefore use the raw configured values. Worth knowing before running any further measurement through run_single_debug.

🤖 Generated with Claude Code

springfall2008 and others added 20 commits August 8, 2026 09:04
Adds a third simulated scenario (high PV, low load) to counterbalance the
existing one-sided pv10 hedge, and replaces the downside-only metric clamp
with a signed weighted average across all three scenarios.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No upside is synthesised when a source provides no p90. Solcast generates
one in practice; the fallback only covers solar_model/Open-Meteo and older
debug dumps. Under the fallback pv90 differs from nominal only by
load_scaling90, which makes it strictly conservative and turns the Stage B
experiment into a lower bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine tasks covering scenario constants and config, the p90 data pipeline,
step arrays, the Python scenario selector, kernel ABI 2->3, the weighted
metric blend, optimiser wiring, and the staged validation that decides
whether the feature ships enabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… reads

Set both attributes to a sentinel, call fetch_config_options() for real, and
assert they come back to their documented defaults. The previous version
only checked the value already set by predbat.py's __init__ defaults and
would have passed even if the fetch.py get_arg reads were deleted.
Adds pv_forecast_minute90 wherever pv_forecast_minute10 exists: real p90
from Solcast when the source publishes one, otherwise a plain copy of the
p50 series (never a mirrored p10 spread - the upside is bounded by
clear-sky while the downside is not, so mirroring would overstate it).

- Fetch.fetch_pv_forecast() now returns a 3-tuple (p50, p10, p90) and
  reads a new forecast90 sensor attribute, falling back to a copy of p50
  when absent.
- SolarAPI.pv_calibration()/pack_and_store_forecast() thread the p90
  series through calibration and publish it as forecast90.
- SolarAPI.fetch_pv_forecast() builds pv_forecast_minute90 from Solcast's
  real pv_estimate90 when present, else falls back to p50.
- Fixed the two other production/test callers broken by the 2->3 tuple
  arity change (load_ml_component.py, test_load_ml.py's MockBase) and
  updated test_fetch_pv_forecast.py and test_solcast.py for the new
  signatures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dashboard_item() writes to three stores (ha_interface.dummy_items,
dashboard_index, dashboard_values), not just one. The pv90 forecast tests
were only restoring dummy_items, leaking the synthetic two-point fixture
into dashboard_values for the rest of the shared-instance TEST_REGISTRY
run (web.py reads dashboard_values for this entity directly). Add shared
snapshot/restore helpers covering all three stores and correct the
docstrings that overclaimed the hazard was already closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds pv_forecast_minute90_step and load_minutes_step90 to calculate_plan,
mirroring the nominal/pv10 step arrays. Both use the nominal cloud_factor
by design, since cloud_factor only reshuffles energy between adjacent
5-minute slots and preserves the total - the pv90 scenario's level comes
solely from the p90 PV series and load_scaling90. A guard copies the p50
PV series into pv_forecast_minute90 when empty, so replayed debug dumps
captured before forecast90 existed still plan without a KeyError.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ck guard test

test_pv90_missing_series_does_not_crash only checked that calculate_plan
raised no KeyError with an empty pv_forecast_minute90. step_data_history's
forward-mode read uses dict.get() with a default, so it can never raise
KeyError - without the guard it silently produces an all-zero step array
instead, which inverts pv90's meaning (high-PV scenario predicting zero
PV). Deleting the guard left both pv90 tests green.

Renamed to test_pv90_missing_series_falls_back_to_p50 and rewrote it to
assert pv_forecast_minute90_step equals pv_forecast_minute_step after the
fallback runs. Since the shared fixture's pv_forecast_minute is empty by
default, the test now seeds its own synthetic non-zero PV series so the
comparison is never vacuous, and restores both attributes afterwards.

Verified discrimination: temporarily removing the two guard lines from
plan.py made this test fail; restoring them (git checkout) made it pass
again, confirmed byte-identical via git diff. No production code changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Threads PV_SCENARIO_NOMINAL/PV10/PV90 through the Python prediction engine
(Prediction.run_prediction and its thread/wrapped helpers) and plan.py's
launch_run_prediction_* wrappers, replacing every pv10 boolean parameter and
truthiness check with an explicit pv_scenario comparison. The C++ kernel is
untouched (Task 5) and still treats the value as bool-compatible.

Prediction.__init__ gains pv_forecast_minute90_step/load_minutes_step90,
inserted before soc_kw/soc_max; both fall back to the nominal step arrays
when None so the ~15 existing call sites (all keyword-based) are unaffected.
Review round 1 findings:
- run_prediction dispatched to the C++ kernel before scenario selection;
  since prediction_kernel.py still treats the scenario as a bare bool,
  PV_SCENARIO_PV90 (2, truthy) was silently simulated as pv10 on any
  kernel-enabled install. Add an interim `pv_scenario != PV_SCENARIO_PV90`
  guard so pv90 falls through to the Python engine until Task 5 teaches
  the kernel about it (guard is commented for removal there).
- test_pv90_no_charge_derate_and_no_io_penalty used soc_max=0, which
  clamps every charge path to zero and made its de-rate assertion
  vacuous - it only ever exercised the io_adjusted rate substitution.
  Split it into test_pv90_no_io_penalty_on_identical_series (renamed,
  narrowed to what it actually checks) and a new test_pv90_no_charge_derate
  that uses a real soc_max and an explicit charge window to compare
  final_soc across scenarios, directly exercising battery_rate_max_scaling.
Teach the C++ prediction kernel about the pv90 upside scenario.

PkContext gains pv90/load90 pointers immediately after load10, and
PkScenario.pv10 becomes a three-valued pv_scenario (0 nominal, 1 pv10,
2 pv90) decoded by explicit equality rather than truthiness - the bare
"1 if pv10 else 0" ternary would have mapped pv90 onto the pv10 arrays.
PK_ABI_VERSION/PK_PARITY_REVISION and KERNEL_ABI_VERSION/
KERNEL_PARITY_REVISION all move 2->3 together so a stale binary is
rejected at load time.

pv90 deliberately does not inherit either pv10 pessimism: it uses the
plain battery_rate_max_scaling (not the charge_scaling10 de-rate) and
skips the io_adjusted worst-case import rate substitution, mirroring
prediction.py:587-592 and prediction.py:628.

This lets the Task 4 interim guard in run_prediction() go, so pv90 now
dispatches to the kernel like the other two scenarios instead of being
held on the Python engine.

Parity coverage: dual_run now threads pv_scenario and the p90 step
arrays; two deterministic pv90 cases (a PV-rich one pinning the array
selection and the charge de-rate, and a load-dominated one pinning the
io_adjusted substitution, which is invisible unless the scenario
actually imports); and the 150-seed random sweep draws from all three
scenarios. Mutation testing confirms each pv90 behaviour is caught if
broken, including a pv90/load90 swap in the ctypes fields alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replace compute_metric's downside-only clamp (metric += max(0, metric10 -
metric) * weight) with a true signed weighted average across nominal, pv10
and pv90. The clamp only ever added risk from a worse-than-nominal outcome;
since pv90 (more PV, less load) is almost always cheaper than nominal, that
clamp would have zeroed out the pv90 term at every candidate, making the
weight a permanent no-op. compute_metric gains soc90/cost90/final_iboost90
keyword-only parameters (default None/None/0.0) so existing callers are
unaffected until Task 7 wires pv90 simulation into the optimiser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s optimisers

Wire optimise_charge_limit, optimise_export and
optimise_charge_limit_price_threads to launch a third pv90 prediction per
candidate whenever pv_metric90_weight > 0, and feed the result through to
compute_metric via its soc90/cost90/final_iboost90 keyword arguments. The
launch condition is evaluated once per optimiser call into a local (run_pv90)
and reused unchanged at both the launch site and the matching pop site, so the
parallel results/results10/results90 lists cannot desync. The
charge_min_max SoC-envelope pre-pass stays nominal/pv10 only, as it feeds
candidate pruning rather than the metric.

At the default weight of 0.0 no pv90 simulation is launched and the resulting
plan is verified byte-identical to the pre-change plan (debug replay of
predbat_debug_agile1.yaml). Test setup for the new launch-counting tests
needed real charge/export windows (reset_inverter/reset_rates alone leave
calculate_plan() with nothing to optimise) and threads=0, the same idiom
already used by test_execute.py/test_random_scenarios.py/test_single_debug.py
to route calculate_plan() through the synchronous fallback instead of a real
multiprocessing Pool.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 5 bumped PK_ABI_VERSION and PK_PARITY_REVISION to 3 for the pv90
context arrays, leaving the six checked-in binaries stale and rejected by
the loader. Cross-built with zig from prediction_kernel.cpp at 6c9b6f0.

Verified: prediction_kernel_lib_darwin_arm64.so loads as the pinned binary
and passes the parity and random-sweep suites. The other five are
cross-compiled and cannot be dlopen'd on darwin/arm64 - CI verifies the
x86_64 one on ubuntu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he same pv90 scale as the optimiser

Fix round 1 review findings for the pv90 optimiser wiring:

- run_prediction_metric never carried a pv90 term, but it seeds/re-seeds the
  best_metric that optimise_charge_limit_price_threads, optimise_charge_limit
  and optimise_export's pv90-inclusive candidate metrics are compared against.
  At a non-zero weight this put the two sides of every improvement comparison
  on different scales, so genuine improvements stopped registering and the
  optimiser's refinement loop broke early - which is why plan time previously
  measured lower at weight 0.1 than at weight 0. Wired a third pv90 prediction
  into run_prediction_metric, gated the same way as everywhere else.

- optimise_charge_limit's charge_min_max pre-fill block seeds two candidates
  (full-charge loop_soc and the min-improvement best_soc_min) directly into
  resultmid/result10, bypassing the results/results10/results90 launch/pop
  pair entirely - and never seeded result90 for them, so they were scored
  with cost90=None while every other candidate in the same ranking loop got
  the three-scenario blend. Added matching pv90 launches for both candidates.

- Strengthened the desync test: counting launches or distinct try_soc values
  cannot catch a pop-side mispairing. The new test encodes both the nominal
  and pv90 cost as the try_soc that produced them and asserts they decode to
  the same candidate inside compute_metric - verified to actually fail when a
  pop-site off-by-one is introduced, and pass once reverted.

- Added launch-coverage tests for optimise_export and optimise_levels, the
  two pv90 launch sites the original round's tests never touched - verified
  each fails when its wiring is deleted and passes once restored.

Re-measured plan duration on the same debug case: weight 0.1 is now ~1.73x
weight 0's duration (was 0.87x before this fix), and weight-0 plan output
remains byte-identical to the pre-Task-7 baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng90 relative, harden ordering

Fix round 2 review findings for the pv90 optimiser wiring:

- Added a test that fails if run_prediction_metric stops carrying pv90:
  round 1's tests all scoped to other call sites (optimise_charge_limit,
  launch coverage), so replacing the weight check inside run_prediction_metric
  with `if False:` left the whole quick suite green despite being the
  single highest-severity defect in this task. Verified it discriminates.

- load_scaling90 now composes relatively with load_scaling
  (self.load_scaling * self.load_scaling90), directed by the human partner:
  as an absolute multiplier it did not cancel a user's own load_scaling, so
  on the agile1 debug case (load_scaling=0.5) the pv90 load came out 1.8x
  nominal instead of 0.9x - a same-PV, higher-load scenario, defeating the
  point of the feature. load_scaling10 is untouched and keeps its existing
  absolute convention; its own latent inversion is pre-existing and out of
  scope. Documented the new convention at the call site and in
  docs/customisation.md (which had no load_scaling90 entry at all before
  this), and added a test pinning the relative composition.

- run_prediction_metric now runs its pv90 prediction first and the nominal
  scenario last, so Plan.run_prediction's side effects (self.predict_soc,
  car_charging_soc_next, iboost_next/iboost_running*) are left holding
  nominal state rather than pv90's - a latent trap even though every traced
  consumer today only reads them after the final nominal run. Verified the
  reordering does not change plan output, at both weight 0 and weight 0.1.

- Documented why the charge_min_max pre-fill's two extra pv90 launches
  widening the SoC-pruning envelope is safe (min/max over a superset can
  only relax pruning, never drop a candidate).

Re-confirmed after all changes: weight-0 plan output remains byte-identical
to the pre-Task-7 baseline, pv_metric90_weight's default stays 0.0, and
compute_metric's body is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…esults

Stage A: 188 pass, 1 fail (debug_cases -> pre_saving1). Verified pre-existing
by running the same test in a merge-base worktree - both sides produce
byte-identical actual.json. The pv10 clamp removal is exercised (agile1 and
pre_saving1 both contain candidates where metric10 < metric) but changes no
plan.

Stage B: the no-export case does not move off [100%, 100%] at any weight from
0 to 1.0. Root cause measured, not assumed: with the p50 fallback the pv90
metric is flat across the same 66-100% plateau the mid metric is flat across
(spread 0.000p, export_kwh 0 in both scenarios), so pv90 contributes exactly
zero slope to the blend at any weight. A synthetic 1.5x p90 does produce the
intended gradient and an interior landing, but only from w90 ~ 0.4 against
this dump's 63p pv10 pull.

Recommends Task 9 does NOT flip the default to 0.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sised p90 to the array ceiling, document the weight

Fixes the five findings from the final whole-branch review. pv_metric90_weight
stays at its default of 0.0, so the feature still ships inert; every fix here
protects the expert who opts in.

1. pv_forecast_minute90 could go permanently stale. The old guard only fired on
   an EMPTY p90, so a caller that reassigns pv_forecast_minute directly - most
   importantly annual.py, which reuses ONE PredBat instance across every sampled
   day of a year - pinned every later day's "upside" to the first day's solar,
   turning pv90 into a severe downside case. annual.py now assigns the p90 on
   every sampled day, and plan.py's guard (now refresh_pv_forecast_minute90())
   re-derives the p90 whenever it fails to cover the plan horizon or has been
   left behind by a p50 that moved without it. A real p90 that moves with its
   p50 is never touched. Production is unaffected: fetch.py always builds all
   three series together over one shared minute range.

2. The calibration-synthesised p90 escaped the array-ceiling clamp its published
   sibling keeps. best_day_scaling has no floor at 1.0 (1.3 by default with
   calibration off, up to 2.0 with it on), so every Open-Meteo and Forecast.solar
   user's planner p90 could exceed what the panels can physically produce - and
   disagreed with the clamped pv_estimate90 for the same slot. It is now scaled
   per slot by min(best_day_scaling, capped_data / capped_p50), mirroring the
   published series exactly.

3. pv_metric90_weight was undocumented, leaving load_scaling90's text pointing at
   a setting no doc mentioned. Documented in customisation.md alongside its
   siblings, and the two apps-yaml.md passages updated.

4. Three of the four weight-0 skip gates were untested - the charge_min_max,
   export and levels gates could all be deleted with the suite green, imposing
   ~50% extra simulation cost on every user at the default weight. The weight-0
   test now patches all four launch functions; each gate was sabotaged in turn
   and confirmed to fail.

5. The random kernel parity sweep had swapped scenario coverage rather than
   adding it (~75/~75 nominal/pv10 became 57/58/35). It now loops all three
   scenarios per seed - 150/150/150 - leaving every previously generated
   configuration unchanged, for 0.62s -> 1.25s.

Weight-0 plan identity re-confirmed: both debug cases produce byte-identical
output to before these changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gainst 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>
Copilot AI lite review requested due to automatic review settings August 8, 2026 15:15

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

Adds a third forecast simulation scenario (pv90, high PV / low load) to complement the existing nominal (p50) and pessimistic (pv10) runs, and updates the planning metric to blend scenarios symmetrically. This extends Predbat’s planning model and the C++ prediction kernel ABI to support a 3-valued PV scenario selector end-to-end, while keeping the feature inert by default (pv_metric90_weight = 0.0).

Changes:

  • Introduces PV scenario constants and threads a pv_scenario selector through planner, prediction engine, multiprocessing wrappers, and the kernel (ABI 3).
  • Extends the Solcast → HA sensor → fetch pipeline to publish and consume forecast90, with a conservative fallback to p50 when absent.
  • Replaces the pv10 downside-only clamp in compute_metric with a signed weighted blend across nominal/pv10/pv90, with new config knobs and comprehensive test coverage.

Reviewed changes

Copilot reviewed 24 out of 30 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
docs/superpowers/specs/2026-08-08-pv90-upside-scenario-design.md New design spec documenting pv90 rationale, architecture, kernel ABI changes, and validation plan.
docs/superpowers/plans/2026-08-08-pv90-results.md New measurement report capturing regression and weight-sweep results.
docs/customisation.md Documents load_scaling90 relative composition and pv_metric90_weight behavior/defaults.
docs/apps-yaml.md Updates Solcast forecast documentation to mention optional PV90 weighting and link to customisation.
apps/predbat/unit_test.py Registers the new pv90 test module in TEST_REGISTRY.
apps/predbat/tests/test_solcast.py Updates Solcast tests for forecast90 publishing and new pv_calibration signature/behavior.
apps/predbat/tests/test_pv90.py New comprehensive pv90 pipeline + planner wiring + metric blending tests.
apps/predbat/tests/test_load_ml.py Updates mocked fetch_pv_forecast() to return p50/p10/p90 tuple.
apps/predbat/tests/test_kernel_parity.py Extends kernel parity coverage to pv90 and runs random sweep across all three scenarios.
apps/predbat/tests/test_fetch_pv_forecast.py Updates fetch PV forecast tests for p90 fallback and explicit published forecast90.
apps/predbat/tests/test_compute_metric.py Updates metric tests for signed blending and adds pv90 blend coverage.
apps/predbat/solcast.py Builds and publishes p90 minute series, clamps synthesised p90 to array ceiling, and publishes forecast90.
apps/predbat/prediction.py Switches pv10 boolean to pv_scenario selector; adds p90 step arrays and scenario-specific behavior.
apps/predbat/prediction_kernel.py Extends ctypes structs/context to pv90/load90, bumps ABI/parity to 3, and passes pv_scenario.
apps/predbat/prediction_kernel.cpp Extends kernel structs/storage to pv90/load90 and implements 3-way scenario branching.
apps/predbat/predbat.py Adds defaults/state for pv_metric90_weight, load_scaling90, and pv90 forecast storage/signatures.
apps/predbat/plan.py Builds pv90 step arrays, keeps pv90 series aligned with p50, wires pv90 sim launches behind weight gate, and blends metrics across three scenarios.
apps/predbat/load_ml_component.py Updates PV forecast fetch unpacking for the new 3-tuple return.
apps/predbat/fetch.py Extends fetch_pv_forecast() to read forecast90 and fall back to p50; reads new config options.
apps/predbat/const.py Adds PV scenario constants with PV10 fixed at 1 for boolean compatibility.
apps/predbat/config.py Adds new expert-gated config items pv_metric90_weight and load_scaling90.
apps/predbat/annual.py Ensures pv90 is explicitly re-derived per day when annual runner swaps p50 directly.
.cspell/custom-dictionary-workspace.txt Adds new technical terms used by the pv90 tests/docs.

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

pv_data[m] = dp4(energy)

pv_forecast_minute, pv_forecast_minute10 = self.base.fetch_pv_forecast()
pv_forecast_minute, pv_forecast_minute10, pv_forecast_minute90 = self.base.fetch_pv_forecast()
Comment thread apps/predbat/solcast.py
Comment on lines +1460 to +1477
# Solcast publishes a real p90; only build the series when at least one entry carries it,
# otherwise leave it empty so the p50 fallback below applies.
has_p90 = any("pv_estimate90" in entry for entry in pv_forecast_data)
if has_p90:
pv_forecast_minute90, _ = minute_data(
pv_forecast_data,
self.forecast_days,
self.midnight_utc,
"pv_estimate90",
"period_start",
backwards=False,
divide_by=divide_by,
scale=self.pv_scaling,
spreading=period,
)
else:
pv_forecast_minute90 = dict(pv_forecast_minute)

Comment thread docs/apps-yaml.md
Comment on lines 1533 to +1534
By default, Predbat will use the central (PV50) estimate and apply to it the **input_number.predbat_pv_metric10_weight** weighting of the 10% (worst case) estimate.
You can thus adjust the metric10_weight to be more pessimistic about the solar forecast.
You can thus adjust the metric10_weight to be more pessimistic about the solar forecast.<BR>
github-actions Bot and others added 4 commits August 8, 2026 15:19
…aling90 to absolute

Adds calculate_pv90_plan (default Off, expert mode) so the feature ships fully
inert: pv_metric90_weight's own default rises to 0.15 for visibility in Home
Assistant, but fetch_config_options forces the runtime weight to 0.0 whenever
the switch is off, routing through the existing weight-0 gates rather than
adding new ones.

Also reverses load_scaling90 back to an absolute multiplier of historical load
(default 0.7), rather than composing relatively with load_scaling - update
docs, comments and tests that pinned the relative convention accordingly, and
add a once-per-plan warning for the case where load_scaling90 >= load_scaling,
which silently inverts pv90 into a second downside scenario.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…0 always holds

CHANGE 4: fetch_config_options() now clamps load_scaling90 <= load_scaling <=
load_scaling10 immediately after reading all three, structurally closing both
the pv90-side inversion CHANGE 3 only warned about and the symmetric, older
pv10-side inversion. Replaces the CHANGE 3 warning (now unreachable) with a
clamp-changed-a-value log, silent on the common unclamped path.

Also fixes a latent gap in test_infra.py's MockConfigProvider - it never
defined load_scaling/load_scaling10/load_scaling90/pv_metric90_weight/
calculate_pv90_plan defaults, so any fetch_config_options() call routed
through it (as test_fetch_config_options.py does) crashed comparing None
once the clamp was added; test_pv90 already read load_scaling/load_scaling10
so this had been a dormant landmine even before CHANGE 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PV90 upside scenario is new and we want it tested across as many real
systems as possible, so the switch that enables it is no longer gated behind
expert mode. The two settings that tune it, pv_metric90_weight and
load_scaling90, stay expert-only - so everyone who turns the switch on runs
the same 0.15 and 0.7, and their feedback is comparable.

Also corrects the load_scaling90 documentation, which still described the
warning that the earlier change replaced. Predbat no longer warns and asks the
user to lower the value; it clamps the three load scalings on read so that
load_scaling90 <= load_scaling <= load_scaling10 always holds, and logs only
when a clamp actually changes something.

Co-Authored-By: Claude Opus 5 (1M context) <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