Model/domain/property_baseline/rebaseliner.py
Khalim Conn-Kowlessar 2f7cfbf446 feat: rebaseliner adopts calc output on physical-state change 🟩
CalculatorRebaseliner uses the calculator output as Effective Performance
whenever a Rebaselining trigger fired — pre-SAP10 (a) OR overrides/prediction
moved the physical state (b)/(c) — tagging pre_sap10 / physical_state_changed
/ both. Only a pristine lodged >=10.2 cert keeps its accredited figure (the
sole case the calculator runs purely to validate). Divergence is logged only
in that pristine case. ABC + StubRebaseliner take the new keyword-only flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:09:55 +00:00

111 lines
4.5 KiB
Python

from __future__ import annotations
from abc import ABC, abstractmethod
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 rebaselined to the calculator's output (CONTEXT.md:
# Rebaselining).
_SAP10_FLOOR = 10.0
class RebaselineNotImplemented(Exception):
"""A Property needs Rebaselining, but the ML adapter is not wired yet.
Raised rather than silently recording ``reason="none"`` for a property that
genuinely needs rebaselining — a plausible-but-wrong baseline is expensive to
discover downstream. Surfaces how much of a First Run cohort the pipeline can
handle today (#1135).
"""
@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.
``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 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,
*,
physical_state_changed: bool = False,
) -> RebaselineResult:
"""Produce Effective Performance. ``physical_state_changed`` is True when
the Effective EPC was assembled from something other than a pristine
lodged cert — Landlord Overrides, Site Notes, or EPC Prediction moved the
physical picture (Rebaselining trigger (b)/(c)) — so the accredited lodged
figure no longer describes the dwelling and the calculator output wins
even at SAP >= 10.2."""
...
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"``, 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,
*,
physical_state_changed: bool = False,
) -> 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}); this stub does not run the calculator"
)
# A physical-state change needs the calculator this stub does not run;
# raise rather than fabricate a "none" that ignores the overrides.
if physical_state_changed:
raise RebaselineNotImplemented(
"Property needs rebaselining (physical state changed by overrides "
"/ prediction); this stub does not run the calculator"
)
return RebaselineResult(effective=lodged, reason="none", sap_result=None)