mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
184 lines
5.8 KiB
Python
184 lines
5.8 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.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
|
|
)
|