feat(modelling): Package Scorer — compose overlays + score on the calculator

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 08:41:30 +00:00
parent bb2c0068ff
commit 7a478cff6e
2 changed files with 101 additions and 0 deletions

View file

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

View file

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