refactor(modelling): expose cascade_scores for the role-3 + bill cascade

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 17:54:54 +00:00
parent d36e42b582
commit e79ffabfc5
2 changed files with 74 additions and 8 deletions

View file

@ -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,

View file

@ -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()