feat(plan): add a pv90 upside forecast scenario to balance the one-sided pv10 hedge - #4462
Open
springfall2008 wants to merge 24 commits into
Open
feat(plan): add a pv90 upside forecast scenario to balance the one-sided pv10 hedge#4462springfall2008 wants to merge 24 commits into
springfall2008 wants to merge 24 commits into
Conversation
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>
Contributor
There was a problem hiding this comment.
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_scenarioselector 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_metricwith 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 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 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> |
…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>
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.
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_planswitch (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_weightshows its real default of0.15in Home Assistant, but while the switch is off the runtime value is forced to0.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 againstpredbat_debug_agile1.yaml(md5d525d171…).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% atend_record. The metric is dead flat from 66% to 100%, because the residual battery credit atend_recordis6.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:
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,
forecast90now 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
pv10flag becomes a three-valuedpv_scenariointeger throughplan.py,prediction.pyand the kernel.PV_SCENARIO_PV10 == 1keeps it numerically compatible with the old boolean.solcast.pybuilds and publishes a real p90 minute series as aforecast90sensor attribute;fetch_pv_forecast()reads it, falling back to a copy of p50 when absent. No upside is synthesised.pv90/load90arrays added toPkContext,PkScenario.pv10becomespv_scenario, ABI 2→3 and both parity revisions bumped. All six platform binaries rebuilt.calculate_pv90_plan(switch, default off, not expert-gated), pluspv_metric90_weight(default 0.15) andload_scaling90(default 0.7), both expert-gated. All three documented indocs/customisation.mdanddocs/apps-yaml.md.load_scaling90(default0.7) is a plain absolute multiplier, likeload_scaling10. To stop any configuration inverting a scenario, the three are clamped at config-read time so the ordering always holds:That guarantees
load_scaling90 <= load_scaling <= load_scaling10whatever 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 withload_scalingaboveload_scaling10previously 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 < metricthe metric is now a true weighted average rather than being pinned atmetric. Measured exposure is small (1 of ~3538 candidates onagile1, 9 of ~6082 onpre_saving1) and plan-neutral across the debug suite, but it is not gated onpv_metric90_weight.Testing
tests/test_pv90.py, registered inTEST_REGISTRY.tests/test_kernel_parity.pycovers pv90; the random sweep now runs all three scenarios per seed (150/150/150, previously 57/58/35 after an accidental swap).offsetof/sizeofagainst the ctypes definitions — all 112 field offsets byte-identical.debug_cases/predbat_debug_pre_saving1.yamlfails, 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_p50is 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.plan.py:863insiderun_prediction_metricis not covered by the skip-gate test. Planning-cost only; plan output is unaffected.calculate_pv90_planshould 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.debug_casesreplay harness hasfetch_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 throughrun_single_debug.🤖 Generated with Claude Code