feat(modelling): Score carries the scored SapResult for billing

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 17:20:45 +00:00
parent ced6287baa
commit 2bbc401f0d
2 changed files with 27 additions and 2 deletions

View file

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

View file

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