From 7a478cff6e7709b418e608c40314cb9b4ed106ea Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 08:41:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(modelling):=20Package=20Scorer=20=E2=80=94?= =?UTF-8?q?=20compose=20overlays=20+=20score=20on=20the=20calculator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PackageScorer(calculator: SapCalculator).score(baseline, simulations) folds the Simulation Overlays onto the baseline via the Overlay Applicator and scores the throwaway EpcPropertyData on the injected deterministic SAP calculator, returning Score(sap_continuous, co2_kg_per_yr, primary_energy_kwh_per_yr). Depends on the SapCalculator abstraction, not a concrete engine. This is the reusable scoring primitive (ADR-0016) — the same call serves the optimiser's whole-package re-score and a future live re-score of a user-assembled plan. Two behaviour tests against the real Sap10Calculator on a hand-built EPD: filling the main cavity improves SAP (right-directional through the real physics); an empty package scores the unmodified baseline (pins the SapResult->Score mapping). The Elmhurst before/after cascade PIN (#1154's acceptance) lands once cert 001431 parses (external _extract_windows fix). Co-Authored-By: Claude Opus 4.8 --- domain/modelling/package_scorer.py | 47 ++++++++++++++++ tests/domain/modelling/test_package_scorer.py | 54 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 domain/modelling/package_scorer.py create mode 100644 tests/domain/modelling/test_package_scorer.py diff --git a/domain/modelling/package_scorer.py b/domain/modelling/package_scorer.py new file mode 100644 index 00000000..bacf9e18 --- /dev/null +++ b/domain/modelling/package_scorer.py @@ -0,0 +1,47 @@ +"""The Package Scorer — the reusable scoring primitive (ADR-0016). + +Composes an ordered set of Simulation Overlays onto a baseline EpcPropertyData +(via the Overlay Applicator) and scores the throwaway result on a deterministic +SAP calculator, returning the headline metrics. The same primitive powers the +optimiser's whole-package re-score and any future live re-score of a +user-assembled plan. +""" + +from dataclasses import dataclass +from typing import Sequence + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.overlay_applicator import apply_simulations +from domain.modelling.simulation import EpcSimulation +from domain.sap10_calculator.calculator import SapCalculator, SapResult + + +@dataclass(frozen=True) +class Score: + """The headline metrics of a scored package. `sap_continuous` is the + un-rounded SAP rating (used for deltas); carbon and primary energy are the + annual totals.""" + + sap_continuous: float + co2_kg_per_yr: float + primary_energy_kwh_per_yr: float + + +class PackageScorer: + """Scores a package of Simulation Overlays against a baseline EpcPropertyData + on an injected SAP calculator (depends on the `SapCalculator` abstraction, + not a concrete engine).""" + + def __init__(self, calculator: SapCalculator) -> None: + self._calculator = calculator + + def score( + self, baseline: EpcPropertyData, simulations: Sequence[EpcSimulation] + ) -> Score: + simulated: EpcPropertyData = apply_simulations(baseline, simulations) + result: SapResult = self._calculator.calculate(simulated) + return Score( + sap_continuous=result.sap_score_continuous, + co2_kg_per_yr=result.co2_kg_per_yr, + primary_energy_kwh_per_yr=result.primary_energy_kwh_per_yr, + ) diff --git a/tests/domain/modelling/test_package_scorer.py b/tests/domain/modelling/test_package_scorer.py new file mode 100644 index 00000000..ffe50cd5 --- /dev/null +++ b/tests/domain/modelling/test_package_scorer.py @@ -0,0 +1,54 @@ +"""Behaviour of the Package Scorer: composing Simulation Overlays onto a +baseline EpcPropertyData and scoring the result on the deterministic SAP10 +calculator. The reusable compute primitive (ADR-0016). Elmhurst before/after +cascade pins land with #1154 once the cert parses; here we exercise the real +calculator on a hand-built EPD. +""" + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, +) +from domain.modelling.package_scorer import PackageScorer, Score +from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation +from domain.sap10_calculator.calculator import Sap10Calculator, SapResult +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + +_CAVITY_FILL = EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay(wall_insulation_type=2) + } +) + + +def test_filling_the_main_cavity_improves_sap() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() # MAIN: uninsulated cavity + scorer = PackageScorer(Sap10Calculator()) + + # Act + base: Score = scorer.score(baseline, []) + filled: Score = scorer.score(baseline, [_CAVITY_FILL]) + + # Assert + assert filled.sap_continuous > base.sap_continuous + + +def test_empty_package_scores_the_unmodified_baseline() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + calculator = Sap10Calculator() + direct: SapResult = calculator.calculate(baseline) + + # Act + score: Score = PackageScorer(calculator).score(baseline, []) + + # Assert + assert abs(score.sap_continuous - direct.sap_score_continuous) <= 1e-9 + assert abs(score.co2_kg_per_yr - direct.co2_kg_per_yr) <= 1e-9 + assert ( + abs(score.primary_energy_kwh_per_yr - direct.primary_energy_kwh_per_yr) + <= 1e-9 + )