Model/tests/domain/modelling/test_scoring.py
Khalim Conn-Kowlessar 13dd5fe81a feat(modelling): per-measure scoring — marginal cascade + per-Option signal (#1156)
scoring.py adds the telescoping marginal cascade that serves two of the three
ADR-0016 scoring roles:
- marginal_impacts(scorer, baseline, overlays): applies overlays cumulatively
  in order and reports each measure's marginal MeasureImpact (sap_points +
  carbon/energy savings). Role 3 (final-package attribution) — the marginals
  telescope EXACTLY to the whole-package total.
- independent_option_impacts(scorer, baseline, options): role 1 — scores each
  Option's overlay independently vs baseline, scoring each DISTINCT overlay
  once (Options sharing an overlay reuse the result). Approximate signal for
  the optimiser; never surfaced as a measure's true impact.

Role 2 (whole-package re-score) is PackageScorer.score directly. Three
behaviour tests on the real Sap10Calculator / a counting stand-in (hand-built
EPD): single-overlay marginal == improvement-over-baseline; two-overlay
marginals telescope to the package total; per-Option dedup scores each
distinct overlay once. Closes #1156. pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 08:50:49 +00:00

142 lines
4.4 KiB
Python

"""Behaviour of per-measure scoring: the telescoping marginal cascade that
serves both the per-Option optimiser signal (role 1) and the final-package
display attribution (role 3) — ADR-0016. Exercises the real calculator on a
hand-built EPD; no PDF/parser involved.
"""
from typing import Sequence
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
)
from domain.modelling.package_scorer import PackageScorer, Score
from domain.modelling.recommendation import MeasureOption
from domain.modelling.scoring import (
MeasureImpact,
independent_option_impacts,
marginal_impacts,
)
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from domain.sap10_calculator.calculator import Sap10Calculator
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc,
)
_MAIN_CAVITY = EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2)
}
)
_EXT1_CAVITY = EpcSimulation(
building_parts={
BuildingPartIdentifier.EXTENSION_1: BuildingPartOverlay(wall_insulation_type=2)
}
)
class _CountingScorer(PackageScorer):
"""A PackageScorer stand-in that counts score() calls; the score is a
deterministic function of the overlays so distinct overlays differ."""
def __init__(self) -> None:
self.calls = 0
def score(
self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation]
) -> Score:
self.calls += 1
total = 0.0
for sim in simulations:
for overlay in sim.building_parts.values():
total += overlay.wall_insulation_type or 0
return Score(
sap_continuous=total, co2_kg_per_yr=0.0, primary_energy_kwh_per_yr=0.0
)
def _option(overlay: EpcSimulation) -> MeasureOption:
return MeasureOption(
measure_type="cavity_wall_insulation", description="opt", overlay=overlay
)
def test_independent_option_impacts_score_each_distinct_overlay_once() -> None:
# Arrange
baseline: EpcPropertyData = build_epc()
scorer = _CountingScorer()
overlay_a = EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2)
}
)
overlay_a_dup = EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2)
}
)
overlay_b = EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=3)
}
)
options = [_option(overlay_a), _option(overlay_a_dup), _option(overlay_b)]
# Act
impacts: list[MeasureImpact] = independent_option_impacts(
scorer, baseline, options
)
# Assert
# baseline scored once + one score per DISTINCT overlay (a, b) = 3, not 4
assert scorer.calls == 3
assert impacts[0].sap_points == impacts[1].sap_points == 2.0
assert impacts[2].sap_points == 3.0
def test_single_overlay_marginal_is_its_improvement_over_baseline() -> None:
# Arrange
baseline: EpcPropertyData = build_epc()
scorer = PackageScorer(Sap10Calculator())
base: Score = scorer.score(baseline, [])
filled: Score = scorer.score(baseline, [_MAIN_CAVITY])
# Act
impacts: list[MeasureImpact] = marginal_impacts(scorer, baseline, [_MAIN_CAVITY])
# Assert
assert len(impacts) == 1
assert impacts[0].sap_points > 0 # cavity fill improves SAP
assert (
abs(impacts[0].sap_points - (filled.sap_continuous - base.sap_continuous))
<= 1e-9
)
def test_marginals_telescope_to_the_whole_package_total() -> None:
# Arrange
baseline: EpcPropertyData = build_epc()
scorer = PackageScorer(Sap10Calculator())
overlays = [_MAIN_CAVITY, _EXT1_CAVITY]
base: Score = scorer.score(baseline, [])
full: Score = scorer.score(baseline, overlays)
# Act
impacts: list[MeasureImpact] = marginal_impacts(scorer, baseline, overlays)
# Assert
assert len(impacts) == 2
assert (
abs(
sum(i.sap_points for i in impacts)
- (full.sap_continuous - base.sap_continuous)
)
<= 1e-9
)
assert (
abs(
sum(i.energy_savings_kwh_per_yr for i in impacts)
- (base.primary_energy_kwh_per_yr - full.primary_energy_kwh_per_yr)
)
<= 1e-6
)