"""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.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 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_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() 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 )