Model/domain/property_baseline/calculator_rebaseliner.py
Khalim Conn-Kowlessar 0fb5da2f79 refactor(baseline): Performance.from_sap_result replaces the loose mapper
PR feedback: the SapResult -> Performance mapping should be a method, not a
free function you must know exists in the rebaseliner. Put the factory on
the target as `Performance.from_sap_result`, beside its sibling
`lodged_performance` and mirroring `Epc.from_sap_score` (the factory this
mapping already calls).

Not a `SapResult.to_performance()`: that would make the SAP calculator
import `Performance` (a property_baseline type), re-introducing the
engine->consumer coupling removed by the SapCalculator ABC. SapResult is a
TYPE_CHECKING-only import in performance.py (the body only reads attributes),
so the calculator module is not pulled in at runtime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:59:25 +00:00

94 lines
3.7 KiB
Python

from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Optional
from domain.property_baseline.performance import Performance
from domain.property_baseline.rebaseliner import Rebaseliner, RebaselineReason
if TYPE_CHECKING:
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.sap10_calculator.calculator import SapCalculator, SapResult
logger = logging.getLogger(__name__)
# The calculator targets SAP 10.2 (14-03-2025). A cert lodged below this carries
# a superseded methodology and is rebaselined to the calculator's output; at or
# above it, the API's lodged figures are kept and the calculator only validates.
_SAP10_2_FLOOR = 10.2
_SAP_ABS_TOL = 0.5
_REL_TOL = 0.01
_KG_PER_TONNE = 1000.0
def _relative_diff(calculated: float, lodged: float) -> float:
if lodged == 0:
return 0.0 if calculated == 0 else float("inf")
return abs(calculated - lodged) / abs(lodged)
class CalculatorRebaseliner(Rebaseliner):
"""Produces Effective Performance from the deterministic `Sap10Calculator`
(ADR-0013 amendment — the calculator is load-bearing).
Runs the calculator on every Property. For a cert lodged under a superseded
methodology (``sap_version < 10.2``) the calculator's output **is** Effective
Performance. At or above 10.2 the API's lodged figures are kept and the
calculator only **logs divergence** (a validation signal). A calculator
strict-raise propagates — the batch aborts (ADR-0012) and the un-mapped cert
is fixed immediately.
"""
def __init__(self, calculator: "SapCalculator") -> None:
self._calculator = calculator
def rebaseline(
self, property_id: int, effective_epc: "EpcPropertyData", lodged: Performance
) -> tuple[Performance, RebaselineReason]:
# A raise (UnmappedSapCode, etc.) propagates: the calculator is
# load-bearing, so the batch aborts and the cert is fixed at once.
result: SapResult = self._calculator.calculate(effective_epc)
sap_version: Optional[float] = effective_epc.sap_version
if sap_version is not None and sap_version < _SAP10_2_FLOOR:
return Performance.from_sap_result(result), "pre_sap10"
self._log_divergence(
property_id=property_id, sap_version=sap_version, result=result, lodged=lodged
)
return lodged, "none"
def _log_divergence(
self,
*,
property_id: int,
sap_version: Optional[float],
result: "SapResult",
lodged: Performance,
) -> None:
if abs(result.sap_score_continuous - lodged.sap_score) > _SAP_ABS_TOL:
self._warn(property_id, sap_version, "sap_score", lodged.sap_score, result.sap_score_continuous)
if _relative_diff(result.primary_energy_kwh_per_m2, lodged.primary_energy_intensity) > _REL_TOL:
self._warn(
property_id, sap_version, "primary_energy_intensity",
lodged.primary_energy_intensity, result.primary_energy_kwh_per_m2,
)
calculated_co2_t = result.co2_kg_per_yr / _KG_PER_TONNE
if _relative_diff(calculated_co2_t, lodged.co2_emissions) > _REL_TOL:
self._warn(property_id, sap_version, "co2_emissions", lodged.co2_emissions, calculated_co2_t)
def _warn(
self,
property_id: int,
sap_version: Optional[float],
quantity: str,
lodged: float,
calculated: float,
) -> None:
logger.warning(
"SAP10 calculator divergence on %s for property_id=%s sap_version=%s: "
"lodged=%s calculated=%s",
quantity,
property_id,
sap_version,
lodged,
calculated,
)