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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 18:37:13 +00:00
parent 5e75fb474c
commit f7dc9dbccb
5 changed files with 81 additions and 42 deletions

View file

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

View file

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

View file

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

View file

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

View file

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