From f7dc9dbccbd602b871964ceb62d1353846679064 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 2 Jun 2026 18:37:13 +0000 Subject: [PATCH] feat(baseline): Rebaseliner returns RebaselineResult carrying the SapResult The Rebaseliner is the assemble-and-score step (ADR-0013 amendment); its SapResult is the scored picture that Bill Derivation also prices (ADR-0014), so rebaseline() now returns a RebaselineResult{effective, reason, sap_result} instead of (Performance, reason). CalculatorRebaseliner sets sap_result on both branches (the bill prices it whether lodged or calculated figures win); StubRebaseliner returns sap_result=None (runs no calculator). Orchestrator unpacks the result; the bill wiring lands in the next slice. Also refreshes the stale ML-era docstrings in rebaseliner.py to the assemble-and-score model (the calculator, not ML, is the rebaseliner mechanism per ADR-0013). Co-Authored-By: Claude Opus 4.8 --- .../calculator_rebaseliner.py | 16 +++-- domain/property_baseline/rebaseliner.py | 59 +++++++++++++------ .../property_baseline_orchestrator.py | 6 +- .../test_calculator_rebaseliner.py | 28 +++++---- .../property_baseline/test_rebaseliner.py | 14 +++-- 5 files changed, 81 insertions(+), 42 deletions(-) diff --git a/domain/property_baseline/calculator_rebaseliner.py b/domain/property_baseline/calculator_rebaseliner.py index 9c412c4e..6ed95c4e 100644 --- a/domain/property_baseline/calculator_rebaseliner.py +++ b/domain/property_baseline/calculator_rebaseliner.py @@ -4,7 +4,7 @@ import logging from typing import TYPE_CHECKING, Optional from domain.property_baseline.performance import Performance -from domain.property_baseline.rebaseliner import Rebaseliner, RebaselineReason +from domain.property_baseline.rebaseliner import Rebaseliner, RebaselineResult if TYPE_CHECKING: from datatypes.epc.domain.epc_property_data import EpcPropertyData @@ -51,17 +51,23 @@ class CalculatorRebaseliner(Rebaseliner): def rebaseline( self, property_id: int, effective_epc: "EpcPropertyData", lodged: Performance - ) -> tuple[Performance, RebaselineReason]: + ) -> RebaselineResult: # A raise (UnmappedSapCode, etc.) propagates: the calculator is - # load-bearing, so the batch aborts and the cert is fixed at once. + # load-bearing, so the batch aborts and the cert is fixed at once. The + # SapResult rides on the result either way — Bill Derivation prices it + # regardless of whether lodged or calculated figures win (ADR-0013/0014). result: SapResult = self._calculator.calculate(effective_epc) sap_version: Optional[float] = effective_epc.sap_version if sap_version is not None and sap_version < _MIN_TRUSTED_SAP_VERSION: - return Performance.from_sap_result(result), "pre_sap10" + return RebaselineResult( + effective=Performance.from_sap_result(result), + reason="pre_sap10", + sap_result=result, + ) self._log_divergence( property_id=property_id, sap_version=sap_version, result=result, lodged=lodged ) - return lodged, "none" + return RebaselineResult(effective=lodged, reason="none", sap_result=result) def _log_divergence( self, diff --git a/domain/property_baseline/rebaseliner.py b/domain/property_baseline/rebaseliner.py index 2fd60df9..e5d94d3f 100644 --- a/domain/property_baseline/rebaseliner.py +++ b/domain/property_baseline/rebaseliner.py @@ -1,15 +1,20 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Literal +from dataclasses import dataclass +from typing import Literal, Optional, TYPE_CHECKING from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.property_baseline.performance import Performance +if TYPE_CHECKING: + from domain.sap10_calculator.calculator import SapResult + RebaselineReason = Literal["none", "pre_sap10", "physical_state_changed", "both"] # The SAP spec version below which a cert's recorded scores reflect a superseded -# methodology and must be ML-rebaselined (CONTEXT.md: Rebaselining). +# methodology and must be rebaselined to the calculator's output (CONTEXT.md: +# Rebaselining). _SAP10_FLOOR = 10.0 @@ -23,40 +28,60 @@ class RebaselineNotImplemented(Exception): """ -class Rebaseliner(ABC): - """Produces a Property's Effective Performance from its Effective EPC. +@dataclass(frozen=True) +class RebaselineResult: + """The outcome of Rebaselining a Property: its Effective Performance, why it + differs from Lodged, and the calculator `SapResult` it was scored from. - Rebaselining (CONTEXT.md) re-predicts the rated quantities via ML when the - EPC was lodged pre-SAP10 or its physical state diverged from the lodged EPC; + ``sap_result`` is the scored picture (ADR-0013 amendment) — a first-class + part of the result because Bill Derivation prices the *same* scoring + (ADR-0014). It is ``None`` only for a Rebaseliner that ran no calculator (the + test ``StubRebaseliner``); the load-bearing ``CalculatorRebaseliner`` always + sets it. + """ + + effective: Performance + reason: RebaselineReason + sap_result: Optional["SapResult"] + + +class Rebaseliner(ABC): + """Produces a Property's Effective Performance by Rebaselining its Effective EPC. + + Rebaselining (CONTEXT.md) assembles the Effective EPC picture and scores it + through SAP10 Calculation, replacing the recorded scores when the EPC was + lodged pre-SAP10 or its physical state diverged from the lodged EPC; otherwise Effective Performance equals Lodged. Injected into the - PropertyBaselineOrchestrator (ADR-0011) so the ML adapter can swap in without - touching the orchestrator, and so the single-property re-score-on-override - flow reuses the same port. + PropertyBaselineOrchestrator (ADR-0011) so the implementation can swap + without touching the orchestrator, and so the single-property + re-score-on-override flow reuses the same port. """ @abstractmethod def rebaseline( self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance - ) -> tuple[Performance, RebaselineReason]: ... + ) -> RebaselineResult: ... class StubRebaseliner(Rebaseliner): """A no-calculator stub for tests that don't want the real calculator. SAP10 certs pass through untouched — Effective Performance equals Lodged, - reason ``"none"``. A pre-SAP10 cert genuinely needs rebaselining, which this - stub does not do, so it raises rather than fabricating a "none". Production - uses ``CalculatorRebaseliner`` (the calculator is load-bearing — ADR-0013 - amendment); this stub stays for orchestrator/repo unit tests. + reason ``"none"``, and ``sap_result`` is ``None`` (no calculator ran). A + pre-SAP10 cert genuinely needs rebaselining, which this stub does not do, so + it raises rather than fabricating a "none". Production uses + ``CalculatorRebaseliner`` (the calculator is load-bearing — ADR-0013 + amendment); this stub stays for orchestrator/repo unit tests that don't + exercise the bill. """ def rebaseline( self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance - ) -> tuple[Performance, RebaselineReason]: + ) -> RebaselineResult: sap_version = effective_epc.sap_version if sap_version is not None and sap_version < _SAP10_FLOOR: raise RebaselineNotImplemented( f"Property needs rebaselining (pre-SAP10 cert, sap_version=" - f"{sap_version}); ML rebaselining is not implemented yet" + f"{sap_version}); this stub does not run the calculator" ) - return lodged, "none" + return RebaselineResult(effective=lodged, reason="none", sap_result=None) diff --git a/orchestration/property_baseline_orchestrator.py b/orchestration/property_baseline_orchestrator.py index bf82a514..3eb55e54 100644 --- a/orchestration/property_baseline_orchestrator.py +++ b/orchestration/property_baseline_orchestrator.py @@ -42,14 +42,14 @@ class PropertyBaselineOrchestrator: for property_id, prop in zip(property_ids, properties, strict=True): effective_epc = prop.effective_epc lodged = lodged_performance(effective_epc) - effective, reason = self._rebaseliner.rebaseline( + rebaselined = self._rebaseliner.rebaseline( property_id, effective_epc, lodged ) rhi = _require_rhi(effective_epc) baseline = PropertyBaselinePerformance( lodged=lodged, - effective=effective, - rebaseline_reason=reason, + effective=rebaselined.effective, + rebaseline_reason=rebaselined.reason, space_heating_kwh=rhi.space_heating_kwh, water_heating_kwh=rhi.water_heating_kwh, ) diff --git a/tests/domain/property_baseline/test_calculator_rebaseliner.py b/tests/domain/property_baseline/test_calculator_rebaseliner.py index e77ee6da..000d28ef 100644 --- a/tests/domain/property_baseline/test_calculator_rebaseliner.py +++ b/tests/domain/property_baseline/test_calculator_rebaseliner.py @@ -72,39 +72,45 @@ class _StubCalculator(SapCalculator): def test_pre_10_2_cert_is_rebaselined_to_the_calculator_output() -> None: # Arrange — a SAP 10.0 cert: lodged figures are a superseded methodology, so # the calculator's output becomes Effective Performance (ADR-0013 amendment). - calculator = _StubCalculator( - _sap_result(sap_score=70, co2_kg_per_yr=1900.0, primary_energy_kwh_per_m2=185.4) + sap_result = _sap_result( + sap_score=70, co2_kg_per_yr=1900.0, primary_energy_kwh_per_m2=185.4 ) + calculator = _StubCalculator(sap_result) rebaseliner = CalculatorRebaseliner(calculator) epc = _epc(sap_version=10.0) # Act - effective, reason = rebaseliner.rebaseline( + result = rebaseliner.rebaseline( property_id=10, effective_epc=epc, lodged=_lodged() ) - # Assert — calculated Performance: band from the score, CO2 kg->t, PEUI rounded. - assert effective == Performance( + # Assert — calculated Performance: band from the score, CO2 kg->t, PEUI rounded; + # the SapResult rides on the result for Bill Derivation. + assert result.effective == Performance( sap_score=70, epc_band=Epc.C, co2_emissions=1.9, primary_energy_intensity=185 ) - assert reason == "pre_sap10" + assert result.reason == "pre_sap10" + assert result.sap_result is sap_result def test_a_10_2_cert_keeps_the_lodged_figures() -> None: # Arrange — a SAP 10.2 cert: the API's lodged figures are on-target, so they # stand; the calculator runs only to validate. - calculator = _StubCalculator(_sap_result(sap_score=72)) + sap_result = _sap_result(sap_score=72) + calculator = _StubCalculator(sap_result) rebaseliner = CalculatorRebaseliner(calculator) epc = _epc(sap_version=10.2) # Act - effective, reason = rebaseliner.rebaseline( + result = rebaseliner.rebaseline( property_id=10, effective_epc=epc, lodged=_lodged() ) - # Assert - assert effective == _lodged() - assert reason == "none" + # Assert — lodged kept as effective, but the SapResult still rides along for + # Bill Derivation (the bill prices it regardless of which figures win). + assert result.effective == _lodged() + assert result.reason == "none" + assert result.sap_result is sap_result def test_a_10_2_cert_logs_divergence_when_the_calculator_disagrees( diff --git a/tests/domain/property_baseline/test_rebaseliner.py b/tests/domain/property_baseline/test_rebaseliner.py index f760dbf0..b4d671a7 100644 --- a/tests/domain/property_baseline/test_rebaseliner.py +++ b/tests/domain/property_baseline/test_rebaseliner.py @@ -29,16 +29,18 @@ def test_sap10_epc_is_not_rebaselined_so_effective_equals_lodged() -> None: rebaseliner = StubRebaseliner() # Act - effective, reason = rebaseliner.rebaseline(10, epc, lodged) + result = rebaseliner.rebaseline(10, epc, lodged) - # Assert — Effective Performance equals Lodged, reason "none". - assert effective == lodged - assert reason == "none" + # Assert — Effective Performance equals Lodged, reason "none", no SapResult + # (the stub runs no calculator). + assert result.effective == lodged + assert result.reason == "none" + assert result.sap_result is None def test_pre_sap10_epc_raises_because_rebaselining_is_not_implemented() -> None: - # Arrange — a cert lodged under a pre-SAP10 schema genuinely needs ML - # rebaselining, which does not exist yet; the stub must not fabricate a + # Arrange — a cert lodged under a pre-SAP10 schema genuinely needs + # rebaselining, which this stub does not do; it must not fabricate a # "none" answer for it. epc = _epc(sap_version=9.94) rebaseliner = StubRebaseliner()