From e79ffabfc50fca68c7cce5c181c64fcee4242c4d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 17:54:54 +0000 Subject: [PATCH] refactor(modelling): expose cascade_scores for the role-3 + bill cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull the cumulative-prefix scoring out of `marginal_impacts` into a reusable `cascade_scores(scorer, baseline, overlays) -> list[Score]` (index 0 the baseline, one calculator run per prefix) plus a pure `marginals_from_scores`. Each Score carries its SapResult, so the next slice's telescoping per-measure bill cascade can re-bill the same prefixes the role-3 attribution already scores — no extra `calculate` calls (ADR-0014 / ADR-0016). `marginal_impacts` now delegates; behaviour unchanged. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/scoring/scoring.py | 40 +++++++++++++++++++----- tests/domain/modelling/test_scoring.py | 42 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/domain/modelling/scoring/scoring.py b/domain/modelling/scoring/scoring.py index 19fc2016..ea995380 100644 --- a/domain/modelling/scoring/scoring.py +++ b/domain/modelling/scoring/scoring.py @@ -34,17 +34,32 @@ class MeasureImpact: energy_savings_kwh_per_yr: float -def marginal_impacts( +def cascade_scores( scorer: PackageScorer, baseline: EpcPropertyData, overlays: Sequence[EpcSimulation], -) -> list[MeasureImpact]: - """Apply overlays cumulatively in order; return each one's marginal impact - over the running state. The marginals telescope to the whole-package total.""" +) -> list[Score]: + """Score the cumulative prefixes of `overlays` in order: index 0 is the + baseline (empty prefix), index i the state after the first i overlays. The + list has `len(overlays) + 1` entries — one calculator run each. + + Each Score carries its `SapResult`, so the same cascade powers both the + role-3 marginal attribution (`marginals_from_scores`) and the telescoping + per-measure bill cascade — neither needs to re-score (ADR-0014 / ADR-0016).""" + return [ + scorer.score(baseline, list(overlays[:prefix_length])) + for prefix_length in range(len(overlays) + 1) + ] + + +def marginals_from_scores(scores: Sequence[Score]) -> list[MeasureImpact]: + """Each measure's marginal impact from a precomputed cumulative-prefix + cascade (`scores[0]` is the baseline). Signed so positive is an improvement; + the marginals telescope to `scores[-1]` vs `scores[0]`.""" impacts: list[MeasureImpact] = [] - previous: Score = scorer.score(baseline, []) - for index in range(len(overlays)): - current: Score = scorer.score(baseline, list(overlays[: index + 1])) + for index in range(1, len(scores)): + previous: Score = scores[index - 1] + current: Score = scores[index] impacts.append( MeasureImpact( sap_points=current.sap_continuous - previous.sap_continuous, @@ -55,10 +70,19 @@ def marginal_impacts( ), ) ) - previous = current return impacts +def marginal_impacts( + scorer: PackageScorer, + baseline: EpcPropertyData, + overlays: Sequence[EpcSimulation], +) -> list[MeasureImpact]: + """Apply overlays cumulatively in order; return each one's marginal impact + over the running state. The marginals telescope to the whole-package total.""" + return marginals_from_scores(cascade_scores(scorer, baseline, overlays)) + + def independent_option_impacts( scorer: PackageScorer, baseline: EpcPropertyData, diff --git a/tests/domain/modelling/test_scoring.py b/tests/domain/modelling/test_scoring.py index 97169667..ab55706a 100644 --- a/tests/domain/modelling/test_scoring.py +++ b/tests/domain/modelling/test_scoring.py @@ -14,8 +14,10 @@ from domain.modelling.scoring.package_scorer import PackageScorer, Score from domain.modelling.recommendation import MeasureOption from domain.modelling.scoring.scoring import ( MeasureImpact, + cascade_scores, independent_option_impacts, marginal_impacts, + marginals_from_scores, ) from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation from domain.sap10_calculator.calculator import Sap10Calculator @@ -113,6 +115,46 @@ def test_single_overlay_marginal_is_its_improvement_over_baseline() -> None: ) +def test_cascade_scores_returns_the_baseline_plus_one_score_per_prefix() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + scorer = _CountingScorer() + overlays = [_MAIN_CAVITY, _EXT1_CAVITY] + + # Act + scores: list[Score] = cascade_scores(scorer, baseline, overlays) + + # Assert + # baseline (empty prefix) + one score per cumulative prefix + assert len(scores) == 3 + assert scorer.calls == 3 + assert scores[0].sap_continuous == 0.0 # empty prefix + assert scores[1].sap_continuous == 2.0 # MAIN cavity (type 2) + assert scores[2].sap_continuous == 4.0 # + EXTENSION_1 cavity (type 2) + + +def test_marginals_from_scores_are_the_consecutive_prefix_deltas() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + scorer = PackageScorer(Sap10Calculator()) + overlays = [_MAIN_CAVITY, _EXT1_CAVITY] + scores: list[Score] = cascade_scores(scorer, baseline, overlays) + + # Act + impacts: list[MeasureImpact] = marginals_from_scores(scores) + + # Assert — each marginal is the delta over the previous prefix score + assert len(impacts) == 2 + assert ( + abs(impacts[0].sap_points - (scores[1].sap_continuous - scores[0].sap_continuous)) + <= 1e-9 + ) + assert ( + abs(impacts[1].sap_points - (scores[2].sap_continuous - scores[1].sap_continuous)) + <= 1e-9 + ) + + def test_marginals_telescope_to_the_whole_package_total() -> None: # Arrange baseline: EpcPropertyData = build_epc()