from __future__ import annotations import logging from typing import Optional import pytest from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.property_baseline.calculator_rebaseliner import CalculatorRebaseliner from domain.property_baseline.performance import Performance from domain.sap10_calculator.calculator import SapCalculator, SapResult from domain.sap10_calculator.exceptions import UnmappedSapCode def _epc(*, sap_version: Optional[float]) -> EpcPropertyData: epc = object.__new__(EpcPropertyData) epc.sap_version = sap_version return epc def _lodged() -> Performance: return Performance( sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180 ) def _sap_result( *, sap_score: int = 72, co2_kg_per_yr: float = 1800.0, primary_energy_kwh_per_m2: float = 180.0, ) -> SapResult: return SapResult( sap_score=sap_score, sap_score_continuous=float(sap_score), ecf=0.0, total_fuel_cost_gbp=0.0, co2_kg_per_yr=co2_kg_per_yr, space_heating_kwh_per_yr=0.0, space_cooling_kwh_per_yr=0.0, fabric_energy_efficiency_kwh_per_m2_yr=0.0, main_heating_fuel_kwh_per_yr=0.0, main_2_heating_fuel_kwh_per_yr=0.0, secondary_heating_fuel_kwh_per_yr=0.0, space_cooling_fuel_kwh_per_yr=0.0, hot_water_kwh_per_yr=0.0, pumps_fans_kwh_per_yr=0.0, lighting_kwh_per_yr=0.0, appliances_kwh_per_yr=0.0, cooking_kwh_per_yr=0.0, main_heating_fuel_code=None, main_2_heating_fuel_code=None, secondary_heating_fuel_code=None, hot_water_fuel_code=None, pv_exported_kwh_per_yr=0.0, primary_energy_kwh_per_yr=0.0, primary_energy_kwh_per_m2=primary_energy_kwh_per_m2, monthly=(), intermediate={}, ) class _StubCalculator(SapCalculator): def __init__(self, result: SapResult) -> None: self._result = result def calculate(self, epc: EpcPropertyData) -> SapResult: return self._result def test_pre_10_2_cert_is_rebaselined_to_the_calculator_output() -> None: # Arrange — a SAP 10.0 cert: lodged figures are a superseded methodology, so # the calculator's output becomes Effective Performance (ADR-0013 amendment). sap_result = _sap_result( sap_score=70, co2_kg_per_yr=1900.0, primary_energy_kwh_per_m2=185.4 ) calculator = _StubCalculator(sap_result) rebaseliner = CalculatorRebaseliner(calculator) epc = _epc(sap_version=10.0) # Act result = rebaseliner.rebaseline( property_id=10, effective_epc=epc, lodged=_lodged() ) # Assert — calculated Performance: band from the score, CO2 kg->t, PEUI rounded; # the SapResult rides on the result for Bill Derivation. assert result.effective == Performance( sap_score=70, epc_band=Epc.C, co2_emissions=1.9, primary_energy_intensity=185 ) assert result.reason == "pre_sap10" assert result.sap_result is sap_result def test_a_10_2_cert_keeps_the_lodged_figures() -> None: # Arrange — a SAP 10.2 cert: the API's lodged figures are on-target, so they # stand; the calculator runs only to validate. sap_result = _sap_result(sap_score=72) calculator = _StubCalculator(sap_result) rebaseliner = CalculatorRebaseliner(calculator) epc = _epc(sap_version=10.2) # Act result = rebaseliner.rebaseline( property_id=10, effective_epc=epc, lodged=_lodged() ) # Assert — lodged kept as effective, but the SapResult still rides along for # Bill Derivation (the bill prices it regardless of which figures win). assert result.effective == _lodged() assert result.reason == "none" assert result.sap_result is sap_result def test_a_10_2_cert_with_changed_physical_state_uses_the_calculator() -> None: # Arrange — a 10.2 cert whose physical state was changed by Landlord # Overrides (or assembled by Prediction): the accredited lodged figure no # longer describes the dwelling, so the calculator's output becomes Effective # Performance (Rebaselining trigger (b)/(c), CONTEXT.md), not the lodged value. sap_result = _sap_result( sap_score=67, co2_kg_per_yr=2100.0, primary_energy_kwh_per_m2=210.0 ) rebaseliner = CalculatorRebaseliner(_StubCalculator(sap_result)) epc = _epc(sap_version=10.2) # Act result = rebaseliner.rebaseline( property_id=10, effective_epc=epc, lodged=_lodged(), physical_state_changed=True, ) # Assert — calculated Performance wins, tagged physical_state_changed. assert result.effective == Performance( sap_score=67, epc_band=Epc.D, co2_emissions=2.1, primary_energy_intensity=210 ) assert result.reason == "physical_state_changed" assert result.sap_result is sap_result def test_a_pre_10_2_cert_with_changed_physical_state_is_reason_both() -> None: # Arrange — both triggers fire: a superseded methodology (sap_version < 10.2) # AND a physical-state change. The calculator wins (as for either alone); the # reason records both. rebaseliner = CalculatorRebaseliner(_StubCalculator(_sap_result(sap_score=65))) epc = _epc(sap_version=10.0) # Act result = rebaseliner.rebaseline( property_id=10, effective_epc=epc, lodged=_lodged(), physical_state_changed=True, ) # Assert assert result.effective.sap_score == 65 assert result.reason == "both" def test_a_10_2_cert_with_changed_state_does_not_log_divergence( caplog: pytest.LogCaptureFixture, ) -> None: # Arrange — when we ADOPT the calculator output (physical state changed), # there is no accredited lodged figure to validate against, so no divergence # warning is emitted (calc 90 vs lodged 72 would otherwise warn). rebaseliner = CalculatorRebaseliner(_StubCalculator(_sap_result(sap_score=90))) epc = _epc(sap_version=10.2) # Act with caplog.at_level(logging.WARNING): rebaseliner.rebaseline( property_id=42, effective_epc=epc, lodged=_lodged(), physical_state_changed=True, ) # Assert — no divergence log when the calculator output is adopted. assert caplog.records == [] def test_a_10_2_cert_logs_divergence_when_the_calculator_disagrees( caplog: pytest.LogCaptureFixture, ) -> None: # Arrange — calculated SAP 76 vs lodged 72 (> 0.5 out) on a 10.2 cert. calculator = _StubCalculator(_sap_result(sap_score=76)) rebaseliner = CalculatorRebaseliner(calculator) epc = _epc(sap_version=10.2) # Act with caplog.at_level(logging.WARNING): rebaseliner.rebaseline(property_id=42, effective_epc=epc, lodged=_lodged()) # Assert — a divergence warning, tagged with property_id + sap_version. assert len(caplog.records) == 1 message = caplog.records[0].getMessage() assert "sap_score" in message assert "property_id=42" in message assert "sap_version=10.2" in message def test_a_calculator_raise_propagates_and_aborts() -> None: # Arrange — the calculator is load-bearing, so a raise is not swallowed. class _Raising(SapCalculator): def calculate(self, epc: EpcPropertyData) -> SapResult: raise UnmappedSapCode("heat_emitter_type", 99) rebaseliner = CalculatorRebaseliner(_Raising()) epc = _epc(sap_version=10.0) # Act / Assert with pytest.raises(UnmappedSapCode): rebaseliner.rebaseline(property_id=10, effective_epc=epc, lodged=_lodged()) def test_full_sap_mapped_cert_rebaselines_off_the_lodged_sap_2012_value() -> None: # Regression for the portfolio-796 "impossible downgrade" (ADR-0037). A # full-SAP cert lodges SAP 2012 (sap_version 9.92). The WIP mapper dropped # sap_version, so the rebaseliner couldn't fire trigger (a) and Effective # stayed stuck on the lodged SAP-2012 value while the plan modelled SAP-10.2. # End-to-end: a real full-SAP fixture, once mapped, now carries sap_version # so Effective becomes the calc output (not lodged). import json import os from datatypes.epc.domain.mapper import EpcPropertyDataMapper from datatypes.epc.schema.sap_schema_17_1 import SapSchema17_1 from datatypes.epc.schema.tests.helpers import from_dict fixtures = os.path.join( os.path.dirname(__file__), "../../../datatypes/epc/schema/tests/fixtures", ) with open(os.path.join(fixtures, "sap_17_1.json")) as f: raw = json.load(f) effective_epc = EpcPropertyDataMapper.from_sap_schema_17_1( from_dict(SapSchema17_1, raw) ) # The mapped cert carries the lodged SAP 2012 version, gating the flip. assert effective_epc.sap_version == 9.92 rebaseliner = CalculatorRebaseliner(_StubCalculator(_sap_result(sap_score=70))) # Act result = rebaseliner.rebaseline( property_id=1, effective_epc=effective_epc, lodged=_lodged() ) # Assert — Effective is the SAP-10.2 calc (70), NOT the lodged SAP-2012 (72). assert result.reason == "pre_sap10" assert result.effective.sap_score == 70