mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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>
130 lines
5.4 KiB
Python
130 lines
5.4 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,
|
|
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,
|
|
)
|