mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
ADR-0014 BillDerivation attributes each end-use (HEATING / HOT_WATER /
SECONDARY / APPLIANCES / COOKING) to a fuel carrier and credits PV
export. SapResult already carried the per-end-use kWh but not WHICH
fuel each end-use burns, nor the annual exported kWh — so a downstream
SapResult->EnergyBreakdown adapter could not pick the right tariff.
Surfaces five output-only fields, threaded exactly like the recently
merged appliances/cooking change (2f039aeb):
main_heating_fuel_code RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
main_2_heating_fuel_code code column (the lodged fuel code, e.g.
secondary_heating_fuel_code mains gas 26). None when the corresponding
hot_water_fuel_code system is absent / fuel not resolvable.
pv_exported_kwh_per_yr SAP 10.2 Appendix M1 §3-4 annual export kWh
(0.0 when no PV).
cert_to_inputs.py populates the four fuel codes from the existing
resolvers the cost/CO2 cascade already uses — `_main_fuel_code`,
`_secondary_fuel_code`, `_water_heating_fuel_code` (not reinvented);
Main 2 is the second `main_heating_details` entry, guarded for length.
There is a single CalculatorInputs construction site (cert_to_demand_
inputs delegates to cert_to_inputs). `pv_exported_kwh_per_yr` already
existed on CalculatorInputs; SapResult collapses its Optional to 0.0.
HARD CONSTRAINT honoured — output-only, zero rating drift. These fields
do NOT feed ECF / total_fuel_cost_gbp / co2_kg_per_yr / primary_energy_*
/ sap_score / any monthly value. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical: calculator suite 1658 -> 1661 passed (+3 new tests),
4 skipped, 0 failed before and after. pyright net-zero (51 -> 51 in
domain/; no new errors in the touched test files).
New tests: a synthetic threading test (four fuel codes + PV export pass
unchanged through calculate_sap_from_inputs; None PV collapses to 0.0)
and a cert-level pin (mains-gas combi cert 000516 -> main fuel code 26,
no Main 2, secondary 30, HW 26). Synthetic CalculatorInputs / SapResult
fixtures updated for the new SapResult fields (defaults cover Inputs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
141 lines
4.7 KiB
Python
141 lines
4.7 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).
|
|
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())
|