Model/domain/property_baseline/rebaseliner.py
Khalim Conn-Kowlessar 15da2d3970 feat(baseline): CalculatorRebaseliner — calculator goes load-bearing (ADR-0013 amend)
Slice 5a: the promotion. Replaces StubRebaseliner in production and collapses the
shadow runner into the rebaseliner (ADR-0013 amendment).

- CalculatorRebaseliner runs Sap10Calculator on every Property:
  * sap_version < 10.2 -> Effective Performance IS the calculator output
    (band via Epc.from_sap_score, CO2 kg->t, PEUI rounded), reason "pre_sap10".
  * sap_version >= 10.2 -> Effective = lodged (API figures on-target), and the
    calculator only logs divergence (SAP>0.5, PEUI/CO2 1%) as a validation signal.
  * a calculator raise propagates -> batch aborts (ADR-0012); fix the cert at once.
- Rebaseliner.rebaseline gains property_id (for the divergence log).
- LoggingCalculatorShadow / the calculator_shadow seam removed from the
  orchestrator; its divergence-comparison logic now lives in the rebaseliner.
- StubRebaseliner kept (signature updated) for orchestrator/repo unit tests.

The SapResult->EnergyBreakdown adapter + BillDerivation wiring (to populate the
bill block) follow once the appliances/cooking SapResult fields land.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 10:04:24 +00:00

62 lines
2.5 KiB
Python

from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Literal
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.property_baseline.performance import Performance
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).
_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).
"""
class Rebaseliner(ABC):
"""Produces a Property's Effective Performance from its Effective EPC.
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;
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.
"""
@abstractmethod
def rebaseline(
self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance
) -> tuple[Performance, RebaselineReason]: ...
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.
"""
def rebaseline(
self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance
) -> tuple[Performance, RebaselineReason]:
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"
)
return lodged, "none"