Model/tests/domain/property_baseline/test_calculator_rebaseliner.py
Khalim Conn-Kowlessar 2f039aeb39 Thread appliances + cooking annual kWh onto SapResult for ADR-0014 bills
ADR-0014 BillDerivation prices a per-end-use EnergyBreakdown
(HEATING / HOT_WATER / LIGHTING / PUMPS_FANS / APPLIANCES / COOKING).
SapResult already carried the first four but not appliances or cooking,
so a downstream SapResult→EnergyBreakdown adapter had to stub those two
at 0 kWh — understating the bill by the whole unregulated electricity
load. Surface them so the property_baseline side can wire the sections.

Adds two output-only fields to CalculatorInputs + SapResult, threaded
exactly like lighting_kwh_per_yr:
  appliances_kwh_per_yr  — SAP 10.2 Appendix L L13/L14/L16a annual E_A
                           (sum of the §5 (68) monthly appliances kWh)
  cooking_kwh_per_yr     — SAP 10.2 Appendix L L20 (p.91) ELECTRICITY
                           estimate E_cook = 138 + 28×N

Both values already existed in cert_to_inputs.py (appliances_monthly_kwh,
cooking_monthly_kwh) — reused, not recomputed.

Fuel attribution: cooking_kwh_per_yr is the L20 ELECTRICITY figure (the
field docstring says so), distinct from the L18 cooking heat GAIN
(35 + 7N W) the §5 internal-gains cascade uses. The bill adapter should
treat cooking as an electricity carrier; a gas-cooker split, if ever
needed, is a separate follow-up.

HARD CONSTRAINT honoured — output-only, zero rating drift. Appliances +
cooking are unregulated and are NOT fed into ECF / total_fuel_cost /
CO2 / primary energy / sap_score. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical (1165 rated pins green). The synthetic CalculatorInputs
fixtures set the new fields non-zero on purpose so the existing cost/PE
reconciliation assertions act as leak detectors.

New focused test asserts both fields are populated (non-zero) and
threaded unchanged onto SapResult, with cooking equal to the L20
electricity figure (138 + 28×occupancy) to 1e-9. pyright net-zero
111 → 111.

Note: 11 pre-existing failures in test_appendix_u.py / test_table_32.py
arrived with the recently absorbed PR and are unrelated to this change
(they fail identically on the clean branch); flagged separately.

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

136 lines
4.5 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,
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).
calculator = _StubCalculator(
_sap_result(sap_score=70, co2_kg_per_yr=1900.0, primary_energy_kwh_per_m2=185.4)
)
rebaseliner = CalculatorRebaseliner(calculator)
epc = _epc(sap_version=10.0)
# Act
effective, reason = rebaseliner.rebaseline(
property_id=10, effective_epc=epc, lodged=_lodged()
)
# Assert — calculated Performance: band from the score, CO2 kg->t, PEUI rounded.
assert effective == Performance(
sap_score=70, epc_band=Epc.C, co2_emissions=1.9, primary_energy_intensity=185
)
assert reason == "pre_sap10"
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.
calculator = _StubCalculator(_sap_result(sap_score=72))
rebaseliner = CalculatorRebaseliner(calculator)
epc = _epc(sap_version=10.2)
# Act
effective, reason = rebaseliner.rebaseline(
property_id=10, effective_epc=epc, lodged=_lodged()
)
# Assert
assert effective == _lodged()
assert reason == "none"
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())