From 2bbc401f0d0ef5545e02084c3be30eda68085443 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 17:20:45 +0000 Subject: [PATCH] feat(modelling): Score carries the scored SapResult for billing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Score gains sap_result: Optional[SapResult], populated by PackageScorer with the calculator output its headline figures came from. This lets the Modelling stage price the post-package (and baseline) end-state via Bill Derivation reusing a SapResult already computed by the optimiser's re-score / the orchestrator's baseline score — no second calculate (ADR-0014 amendment). The optimiser reads only sap_continuous, so it stays domain-agnostic and the stub scorers (which omit sap_result) are unaffected — all optimiser tests pass unchanged. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/scoring/package_scorer.py | 12 ++++++++++-- tests/domain/modelling/test_package_scorer.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/domain/modelling/scoring/package_scorer.py b/domain/modelling/scoring/package_scorer.py index d9c88cf6..23010572 100644 --- a/domain/modelling/scoring/package_scorer.py +++ b/domain/modelling/scoring/package_scorer.py @@ -8,7 +8,7 @@ user-assembled plan. """ from dataclasses import dataclass -from typing import Sequence +from typing import Optional, Sequence from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.modelling.scoring.overlay_applicator import apply_simulations @@ -20,11 +20,18 @@ from domain.sap10_calculator.calculator import SapCalculator, SapResult 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.""" + annual totals. + + `sap_result` is the calculator output the headline figures were taken from, + carried so Bill Derivation can price the scored end-state without a second + `calculate` (ADR-0014 amendment). The optimiser never reads it — it works + off `sap_continuous` only — so it stays domain-agnostic and a stub scorer + may leave it `None`.""" sap_continuous: float co2_kg_per_yr: float primary_energy_kwh_per_yr: float + sap_result: Optional[SapResult] = None class PackageScorer: @@ -44,4 +51,5 @@ class PackageScorer: 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, + sap_result=result, ) diff --git a/tests/domain/modelling/test_package_scorer.py b/tests/domain/modelling/test_package_scorer.py index 9310e0e6..e0575ea6 100644 --- a/tests/domain/modelling/test_package_scorer.py +++ b/tests/domain/modelling/test_package_scorer.py @@ -52,3 +52,20 @@ def test_empty_package_scores_the_unmodified_baseline() -> None: abs(score.primary_energy_kwh_per_yr - direct.primary_energy_kwh_per_yr) <= 1e-9 ) + + +def test_score_carries_the_scored_sap_result_for_billing() -> None: + # Arrange — the post-package SapResult must ride on the Score so Bill + # Derivation can price the simulated end-state without a second calculate + # (ADR-0014 amendment). + baseline: EpcPropertyData = build_epc() + scorer = PackageScorer(Sap10Calculator()) + + # Act + filled: Score = scorer.score(baseline, [_CAVITY_FILL]) + + # Assert — the SapResult is the one the Score's headline figures came from. + assert filled.sap_result is not None + assert ( + abs(filled.sap_result.sap_score_continuous - filled.sap_continuous) <= 1e-9 + )