mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
A SAP>=10.2 cert whose physical state was changed by Landlord Overrides or Prediction must rebaseline off the calculator (the accredited lodged figure no longer describes the dwelling) and tag physical_state_changed / both, and must NOT log divergence (the calc IS adopted, nothing to validate against). This is the D1 fix: the stored Effective baseline stops echoing the lodged headline for overridden properties. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
253 lines
9.1 KiB
Python
253 lines
9.1 KiB
Python
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
|