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, RebaselineResult, ) if TYPE_CHECKING: from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.sap10_calculator.calculator import SapCalculator, SapResult logger = logging.getLogger(__name__) # Lodged figures are trusted from SAP 10.2 (14-03-2025) onward — the version the # calculator targets. A cert lodged below this carries a superseded methodology, # so the calculator's output replaces it; at or above it the lodged figures are # kept and the calculator only validates against them. _MIN_TRUSTED_SAP_VERSION = 10.2 # Divergence thresholds for that validation log. The calculator emits a # *continuous* SAP score whereas the lodged score is rounded to an integer, so a # gap up to half a point is just rounding — beyond it the calculator and the # register genuinely disagree and we record it. CO2 and Primary Energy Intensity # are not rounded that way, so they get a 1% relative band instead. _MAX_SAP_SCORE_DIVERGENCE = 0.5 _MAX_RELATIVE_DIVERGENCE = 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, *, physical_state_changed: bool = False, ) -> RebaselineResult: # A raise (UnmappedSapCode, etc.) propagates: the calculator is # 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 pre_sap10 = sap_version is not None and sap_version < _MIN_TRUSTED_SAP_VERSION # The calculator output IS Effective Performance whenever a Rebaselining # trigger fired: (a) a superseded methodology (pre-SAP10), or (b)/(c) the # physical state was changed by Landlord Overrides / Prediction. Only a # pristine lodged SAP >= 10.2 cert keeps its accredited figure — that is # the *only* case where the calculator runs purely to validate (and where # its known divergence from the accredited register would mislead). if pre_sap10 or physical_state_changed: reason: RebaselineReason = ( "both" if pre_sap10 and physical_state_changed else "pre_sap10" if pre_sap10 else "physical_state_changed" ) return RebaselineResult( effective=Performance.from_sap_result(result), reason=reason, sap_result=result, ) self._log_divergence( property_id=property_id, sap_version=sap_version, result=result, lodged=lodged ) return RebaselineResult(effective=lodged, reason="none", sap_result=result) 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) > _MAX_SAP_SCORE_DIVERGENCE: 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) > _MAX_RELATIVE_DIVERGENCE: 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) > _MAX_RELATIVE_DIVERGENCE: 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, )