Model/tests/domain/sap10_calculator/test_calculator.py
Khalim Conn-Kowlessar 3f5cd550cb Thread the off-peak meter flag and high-rate fractions onto SapResult 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:45:29 +00:00

817 lines
33 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the synthetic-input Sap10 calculator orchestrator.
The orchestrator drives SAP 10.3's 12-month heat-balance loop from a
`CalculatorInputs` aggregate (geometry, envelope, ventilation, climate,
heating + the running-cost lines hot-water/pumps-fans/lighting). It
returns a typed `SapResult` carrying the SAP score, the cost/CO2 totals,
and a 12-entry `monthly` breakdown so downstream consumers can audit
month-by-month physics.
Tests use synthetic inputs (not cert-derived) so that orchestration
behaviour is verified independently of the cert→inputs mapper (S-A7b).
Reference: SAP 10.3 specification (13-01-2026) §§5-13 + Table 9c (the
worksheet step list) + Table 12 (Energy Cost Deflator 0.36).
"""
from __future__ import annotations
from dataclasses import replace
import pytest
from domain.sap10_calculator.calculator import (
CalculatorInputs,
SapResult,
calculate_sap_from_inputs,
)
from domain.sap10_calculator.climate.appendix_u import external_temperature_c
from domain.sap10_calculator.worksheet.dimensions import Dimensions
from domain.sap10_calculator.worksheet.heat_transmission import HeatTransmission
from domain.sap10_calculator.worksheet.mean_internal_temperature import (
mean_internal_temperature_monthly,
)
from domain.sap10_calculator.worksheet.space_heating import space_heating_monthly_kwh
def _baseline_inputs() -> CalculatorInputs:
"""Reference dwelling for orchestrator tests — a 100 m² semi-detached
gas-boiler home in UK-average climate. Numbers chosen to land roughly
where a real RdSAP cert would: HLC ~150 W/K, τ ~100 h, SAP ~70."""
dim = Dimensions(
total_floor_area_m2=100.0,
volume_m3=250.0,
storey_count=2,
avg_storey_height_m=2.5,
ground_floor_area_m2=50.0,
ground_floor_perimeter_m=30.0,
top_floor_area_m2=50.0,
gross_wall_area_m2=150.0,
party_wall_area_m2=50.0,
)
ht = HeatTransmission(
walls_w_per_k=60.0,
roof_w_per_k=20.0,
floor_w_per_k=20.0,
party_walls_w_per_k=0.0,
windows_w_per_k=25.0,
roof_windows_w_per_k=0.0,
doors_w_per_k=5.0,
thermal_bridging_w_per_k=20.0,
fabric_heat_loss_w_per_k=130.0, # 60+20+20+0+25+5
total_external_element_area_m2=200.0, # synthetic placeholder
total_w_per_k=150.0,
)
internal_gains_monthly_w = (450.0,) * 12
solar_gains_monthly_w = (
70.1510, 118.4419, 161.4420, 202.5589, 231.7608, 232.9177,
223.3279, 200.6543, 175.3023, 130.5274, 83.7805, 60.2212,
)
ext_temp_monthly_c = tuple(external_temperature_c(0, m) for m in range(1, 13))
total_gains_monthly_w = tuple(
internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12)
)
htc_monthly_w_per_k = tuple(
ht.total_w_per_k + 0.33 * dim.volume_m3 * 0.7 for _ in range(12)
)
mit_result = mean_internal_temperature_monthly(
monthly_external_temp_c=ext_temp_monthly_c,
monthly_total_gains_w=total_gains_monthly_w,
monthly_heat_transfer_coefficient_w_per_k=htc_monthly_w_per_k,
thermal_mass_parameter_kj_per_m2_k=250.0,
total_floor_area_m2=dim.total_floor_area_m2,
control_type=2,
responsiveness=1.0,
living_area_fraction=0.30,
)
space_heating_result = space_heating_monthly_kwh(
monthly_heat_transfer_coefficient_w_per_k=htc_monthly_w_per_k,
monthly_internal_temperature_c=mit_result.adjusted_mean_internal_temp_monthly,
monthly_external_temperature_c=ext_temp_monthly_c,
monthly_utilisation_factor=mit_result.utilisation_factor_whole_monthly,
monthly_total_gains_w=total_gains_monthly_w,
total_floor_area_m2=dim.total_floor_area_m2,
)
return CalculatorInputs(
dimensions=dim,
heat_transmission=ht,
monthly_infiltration_ach=(0.7,) * 12,
# Synthetic baseline internal gains: 450 W constant. Real
# per-month variation lives in §5 orchestrator output; tracer
# tests don't need the modulation to verify the SAP loop.
internal_gains_monthly_w=internal_gains_monthly_w,
# Hand-computed solar (S + N 4 m² panes, g⊥=0.63 FF=0.7 Z=0.77,
# UK-avg region 0, vertical) — captured from §6 leaves at HEAD.
solar_gains_monthly_w=solar_gains_monthly_w,
# §7 (93)m + (94)m precomputed from the orchestrator above so the
# baseline reflects spec-correct sequential per-zone η.
mean_internal_temp_monthly_c=mit_result.adjusted_mean_internal_temp_monthly,
utilisation_factor_monthly=mit_result.utilisation_factor_whole_monthly,
# §8 (98c)m precomputed from the orchestrator above.
space_heating_monthly_kwh=space_heating_result.total_space_heating_monthly_kwh,
region=0,
control_type=2,
responsiveness=1.0,
living_area_fraction=0.30,
control_temperature_adjustment_c=0.0,
thermal_mass_parameter_kj_per_m2_k=250.0,
main_heating_efficiency=0.85,
hot_water_kwh_per_yr=2400.0,
pumps_fans_kwh_per_yr=100.0,
lighting_kwh_per_yr=600.0,
# Non-zero on purpose: these unregulated loads must NOT leak into
# cost / CO2 / PE / sap_score. The reconciliation assertions in
# this file sum only the regulated end-uses, so a leak surfaces here.
appliances_kwh_per_yr=2000.0,
cooking_kwh_per_yr=200.0,
space_heating_fuel_cost_gbp_per_kwh=0.07,
hot_water_fuel_cost_gbp_per_kwh=0.07,
other_fuel_cost_gbp_per_kwh=0.07,
co2_factor_kg_per_kwh=0.21,
)
def test_fuel_codes_and_pv_export_thread_unchanged_onto_sap_result() -> None:
"""Per-end-use fuel codes + PV export reach SapResult untouched.
ADR-0014 BillDerivation attributes each end-use to a fuel carrier, so
the per-end-use fuel codes (RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
code column) and the annual PV export kWh must surface on SapResult.
These are output-only metadata — they must thread byte-identical from
CalculatorInputs through `calculate_sap_from_inputs` onto SapResult and
NOT be recomputed or perturbed. `pv_exported_kwh_per_yr` collapses a
None CalculatorInputs value to 0.0.
"""
# Arrange — set the four fuel codes + PV export to distinct known
# values on the baseline. Mains gas (1) main, LPG (2) main-2, standard
# electricity (30) secondary, mains gas (1) hot water.
inputs = replace(
_baseline_inputs(),
main_heating_fuel_code=1,
main_2_heating_fuel_code=2,
secondary_heating_fuel_code=30,
hot_water_fuel_code=1,
pv_exported_kwh_per_yr=850.0,
)
# Act
result = calculate_sap_from_inputs(inputs)
# Assert — threaded unchanged; PV export carried through.
assert result.main_heating_fuel_code == 1
assert result.main_2_heating_fuel_code == 2
assert result.secondary_heating_fuel_code == 30
assert result.hot_water_fuel_code == 1
assert abs(result.pv_exported_kwh_per_yr - 850.0) <= 1e-9
def test_off_peak_meter_flag_and_high_rate_fractions_thread_onto_sap_result() -> None:
"""The Off-Peak Meter flag + per-end-use High-Rate Fractions surface on
SapResult unchanged (ADR-0014). Output-only metadata for Bill Derivation's
day/night split — they must thread byte-identical from CalculatorInputs
through `calculate_sap_from_inputs` and NOT perturb the SAP cost/score."""
# Arrange — an off-peak dwelling: storage heating all-night (0.0), HW immersion
# all-night (0.0), other uses 0.90 / pumps 0.71 (7-hour Grid-2 fractions).
inputs = replace(
_baseline_inputs(),
is_off_peak_meter=True,
main_heating_high_rate_fraction=0.0,
main_2_heating_high_rate_fraction=0.5,
secondary_heating_high_rate_fraction=1.0,
hot_water_high_rate_fraction=0.0,
pumps_fans_high_rate_fraction=0.71,
other_electricity_high_rate_fraction=0.90,
)
# Act
result = calculate_sap_from_inputs(inputs)
# Assert — threaded unchanged.
assert result.is_off_peak_meter is True
assert result.main_heating_high_rate_fraction == 0.0
assert result.main_2_heating_high_rate_fraction == 0.5
assert result.secondary_heating_high_rate_fraction == 1.0
assert result.hot_water_high_rate_fraction == 0.0
assert result.pumps_fans_high_rate_fraction == 0.71
assert result.other_electricity_high_rate_fraction == 0.90
def test_pv_export_collapses_none_input_to_zero_on_sap_result() -> None:
"""`pv_exported_kwh_per_yr` is 0.0 (not None) on SapResult for no-PV.
CalculatorInputs.pv_exported_kwh_per_yr is Optional[float] (None on
certs without a PV split); SapResult.pv_exported_kwh_per_yr is a plain
float, so the assembly collapses None to 0.0 for the bill adapter.
"""
# Arrange — baseline has no PV split (pv_exported_kwh_per_yr defaults None).
inputs = replace(_baseline_inputs(), pv_exported_kwh_per_yr=None)
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
assert result.pv_exported_kwh_per_yr == 0.0
def test_calculator_consumes_solar_gains_monthly_w_field_for_per_month_solar() -> None:
# Arrange — replace the baseline inputs' solar with an explicit known
# 12-tuple. The §6 orchestrator produces this upstream; the calculator
# must just look it up, not recompute from the legacy `windows` field.
# 100 W constant solar everywhere — distinct enough that any leftover
# _solar_gains_w(windows, ...) recomputation would land elsewhere.
explicit_solar = (100.0,) * 12
inputs = replace(
_baseline_inputs(), solar_gains_monthly_w=explicit_solar,
)
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
for monthly in result.monthly:
assert monthly.solar_gains_w == 100.0
def test_calculator_consumes_space_heating_monthly_kwh_field() -> None:
# Arrange — replace baseline inputs' space heating with an explicit known
# 12-tuple. The §8 orchestrator produces this upstream; the calculator
# must just look it up, not call monthly_heat_requirement_kwh inline.
# 500 kWh constant per month — distinct enough that any leftover inline
# computation would land elsewhere.
explicit_space_heating = (500.0,) * 12
inputs = replace(
_baseline_inputs(), space_heating_monthly_kwh=explicit_space_heating,
)
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
for monthly in result.monthly:
assert monthly.space_heat_requirement_kwh == 500.0
def test_calculator_consumes_mean_internal_temp_and_utilisation_monthly_fields() -> None:
# Arrange — replace baseline inputs' MIT + η with explicit known 12-tuples.
# The §7 orchestrator produces these upstream; the calculator must just
# look them up, not iterate or recompute. 18.0 °C MIT + 0.8 η constant
# everywhere — distinct enough that any leftover iteration would drift.
explicit_mit = (18.0,) * 12
explicit_eta = (0.8,) * 12
inputs = replace(
_baseline_inputs(),
mean_internal_temp_monthly_c=explicit_mit,
utilisation_factor_monthly=explicit_eta,
)
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
for monthly in result.monthly:
assert monthly.internal_temp_c == 18.0
assert monthly.utilisation_factor == 0.8
def test_calculator_returns_twelve_month_breakdown_and_plausible_sap_score() -> None:
# Arrange — baseline 100 m² gas-boiler dwelling in UK-average climate.
inputs = _baseline_inputs()
# Act
result = calculate_sap_from_inputs(inputs)
# Assert — sanity, not exact: tracer bullet that the 12-month loop runs
# end-to-end and lands in a believable SAP band for the inputs.
assert isinstance(result, SapResult)
assert len(result.monthly) == 12
assert 1 <= result.sap_score <= 100
assert result.space_heating_kwh_per_yr > 0
assert result.total_fuel_cost_gbp > 0
assert result.ecf > 0
# The "main_heating_fuel + hot_water + pumps_fans + lighting" totals
# must reconcile with the cost line through the fuel unit cost.
expected_fuel = (
result.main_heating_fuel_kwh_per_yr
+ result.hot_water_kwh_per_yr
+ result.pumps_fans_kwh_per_yr
+ result.lighting_kwh_per_yr
)
assert result.total_fuel_cost_gbp == pytest.approx(
expected_fuel * inputs.space_heating_fuel_cost_gbp_per_kwh, rel=1e-6
)
def test_sap_score_continuous_floored_at_1_for_degenerate_high_cost() -> None:
# Arrange — SAP 10.2 §13 / RdSAP 10 §13: the SAP rating is floored at 1
# ("if the result of the calculation is less than 1, the rating is 1").
# Drive the cost so high that the raw ECF formula returns a negative SAP
# (a degenerate dwelling, e.g. a cert lodged at the floor of 1); both the
# integer AND the continuous score must clamp to 1 rather than emit a
# physically impossible negative rating.
inputs = replace(
_baseline_inputs(),
space_heating_fuel_cost_gbp_per_kwh=5.0,
hot_water_fuel_cost_gbp_per_kwh=5.0,
other_fuel_cost_gbp_per_kwh=5.0,
)
# Act
result = calculate_sap_from_inputs(inputs)
# Assert — raw SAP would be < 1 here; the floor holds on both outputs.
assert result.sap_score == 1
assert abs(result.sap_score_continuous - 1.0) <= 1e-9
def test_calculate_exposes_dimensions_intermediates() -> None:
# Arrange — P5 trace mode: `result.intermediate` must surface the
# worksheet-named dimensions variables for per-section diffing
# against BRE worked examples and hand calcs (ADR-0010 / handover §11).
inputs = _baseline_inputs()
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
assert result.intermediate["tfa_m2"] == inputs.dimensions.total_floor_area_m2
assert result.intermediate["volume_m3"] == inputs.dimensions.volume_m3
assert result.intermediate["storey_count"] == float(inputs.dimensions.storey_count)
def test_calculate_exposes_heat_transmission_intermediates() -> None:
# Arrange — P5 trace mode: the 7 fabric W/K components must surface on
# `intermediate` so section-§5 sweep slices can diff per-component
# against BRE worked examples.
inputs = _baseline_inputs()
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
ht = inputs.heat_transmission
assert result.intermediate["walls_w_per_k"] == ht.walls_w_per_k
assert result.intermediate["roof_w_per_k"] == ht.roof_w_per_k
assert result.intermediate["floor_w_per_k"] == ht.floor_w_per_k
assert result.intermediate["party_walls_w_per_k"] == ht.party_walls_w_per_k
assert result.intermediate["windows_w_per_k"] == ht.windows_w_per_k
assert result.intermediate["doors_w_per_k"] == ht.doors_w_per_k
assert result.intermediate["thermal_bridging_w_per_k"] == ht.thermal_bridging_w_per_k
def test_calculate_exposes_ventilation_intermediates() -> None:
# Arrange — P5 trace mode: infiltration ach (the cert-derived input) and
# the derived ventilation heat-loss W/K must surface so §4 / Table 4g
# sweep slices can diff per-cert against the spec formula
# HLC_V = ACH × volume × 0.33 (SAP 10.2 §4.1).
inputs = _baseline_inputs()
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
annual_mean_ach = sum(inputs.monthly_infiltration_ach) / 12.0
assert result.intermediate["infiltration_ach"] == pytest.approx(annual_mean_ach, rel=1e-12)
expected_hlc_v = annual_mean_ach * inputs.dimensions.volume_m3 * 0.33
assert result.intermediate["infiltration_w_per_k"] == pytest.approx(
expected_hlc_v, rel=1e-9
)
def test_calculate_exposes_hlc_hlp_and_annual_averages() -> None:
# Arrange — P5 trace mode: HLC (W/K), HLP (W/m²K), time constant, and
# annual-average internal gains + mean internal temperature surface on
# `intermediate`. These are the worksheet-line aggregates §7 / §13
# depend on; the annual averages let sweep slices verify monthly-loop
# outputs without re-computing the 12-month sum themselves.
inputs = _baseline_inputs()
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
annual_mean_ach = sum(inputs.monthly_infiltration_ach) / 12.0
expected_hlc = (
inputs.heat_transmission.total_w_per_k
+ annual_mean_ach * inputs.dimensions.volume_m3 * 0.33
)
expected_hlp = expected_hlc / inputs.dimensions.total_floor_area_m2
assert result.intermediate["heat_transfer_coefficient_w_per_k"] == pytest.approx(
expected_hlc, rel=1e-9
)
assert result.intermediate["heat_loss_parameter_w_per_m2k"] == pytest.approx(
expected_hlp, rel=1e-9
)
assert result.intermediate["time_constant_h"] > 0.0
avg_gains = sum(e.internal_gains_w for e in result.monthly) / 12.0
avg_mit = sum(e.internal_temp_c for e in result.monthly) / 12.0
assert result.intermediate["internal_gains_annual_avg_w"] == pytest.approx(
avg_gains, rel=1e-9
)
assert result.intermediate["mean_internal_temp_annual_avg_c"] == pytest.approx(
avg_mit, rel=1e-9
)
def test_calculate_exposes_useful_space_heating_kwh() -> None:
# Arrange — P5 trace mode: useful space heating kWh/yr (§9 / Table 9c
# step 10) surfaces on `intermediate` keyed by worksheet name. Mirrors
# `space_heating_kwh_per_yr` on the top-level result so spec sweep
# slices can refer to the worksheet name regardless of `SapResult`
# field renames.
inputs = _baseline_inputs()
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
assert result.intermediate["useful_space_heating_kwh_per_yr"] == pytest.approx(
result.space_heating_kwh_per_yr, rel=1e-9
)
def test_total_fuel_cost_includes_247a_electric_shower_in_fallback_path() -> None:
"""SAP 10.2 §10a (PDF p.145) line (247a) bills electric showers via
Energy for instantaneous electric shower(s) (64a) × 0.01 = (247a)
Total energy cost (240)...(242) + (245)…(254) = (255)
Instantaneous electric showers route to (64a) (their own kWh stream
independent of the (62)m HW cylinder demand) and accrue cost at the
"other fuel" tariff used for pumps/fans and lighting. The
`fuel_cost`-based STANDARD-tariff path already plumbs (247a) via
`instant_shower_cost_gbp`; the fallback scalar path (off-peak or
`_ZERO_FUEL_COST_RESULT`) was silently dropping the line. Cert 000565
(Dual-meter TEN_HOUR + 1 electric shower) surfaced this as a +£93
cost under-count and a SAP-integer regression once the upstream
(45)m bath-formula extractor bug closed.
"""
# Arrange — baseline with an electric shower lodged. Other-uses
# tariff and electric-shower kWh are independent so the expected
# cost delta is mechanically `kwh × other_fuel_cost`.
baseline = _baseline_inputs()
shower_kwh = 700.0
inputs_no_shower = baseline
inputs_with_shower = replace(baseline, electric_shower_kwh_per_yr=shower_kwh)
# Act
result_no_shower = calculate_sap_from_inputs(inputs_no_shower)
result_with_shower = calculate_sap_from_inputs(inputs_with_shower)
# Assert — total cost rises by exactly (64a) × other-fuel tariff,
# matching worksheet (247a).
expected_delta = shower_kwh * baseline.other_fuel_cost_gbp_per_kwh
actual_delta = (
result_with_shower.total_fuel_cost_gbp
- result_no_shower.total_fuel_cost_gbp
)
assert abs(actual_delta - expected_delta) < 1e-6, (
f"(247a) electric shower cost delta: got {actual_delta!r}, "
f"want {expected_delta!r} per SAP 10.2 §10a line (247a)"
)
def test_calculate_exposes_per_end_use_fuel_costs() -> None:
# Arrange — P5 trace mode: per-end-use fuel costs (§12 / Table 12) break
# out on `intermediate` so the §12 sweep can diff main vs hot water vs
# pumps/fans vs lighting individually rather than against the bundled
# `total_fuel_cost_gbp`. Secondary heating cost is also surfaced even
# though §11 omitted it — the field exists on the calculator and is a
# named worksheet variable.
inputs = _baseline_inputs()
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
main_cost = (
result.main_heating_fuel_kwh_per_yr * inputs.space_heating_fuel_cost_gbp_per_kwh
)
secondary_cost = (
result.secondary_heating_fuel_kwh_per_yr
* inputs.secondary_heating_fuel_cost_gbp_per_kwh
)
hot_water_cost = inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh
pumps_cost = inputs.pumps_fans_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh
lighting_cost = inputs.lighting_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh
assert result.intermediate["main_heating_cost_gbp"] == pytest.approx(main_cost, rel=1e-9)
assert result.intermediate["secondary_heating_cost_gbp"] == pytest.approx(
secondary_cost, rel=1e-9
)
assert result.intermediate["hot_water_cost_gbp"] == pytest.approx(hot_water_cost, rel=1e-9)
assert result.intermediate["pumps_fans_cost_gbp"] == pytest.approx(pumps_cost, rel=1e-9)
assert result.intermediate["lighting_cost_gbp"] == pytest.approx(lighting_cost, rel=1e-9)
def test_calculate_exposes_ecf_and_deflator() -> None:
# Arrange — P5 trace mode: ECF (the rating denominator) and the §13
# Table 12 deflator (0.42 per SAP 10.2) surface on `intermediate`.
# ECF mirrors the top-level field; deflator is the only fixed
# worksheet constant the SAP rating depends on, so naming it lets
# future rating-equation sweep slices reference it explicitly.
inputs = _baseline_inputs()
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
assert result.intermediate["ecf"] == pytest.approx(result.ecf, rel=1e-9)
assert result.intermediate["deflator"] == pytest.approx(0.42, rel=1e-12)
def test_calculate_exposes_co2_chain() -> None:
# Arrange — P5 trace mode: CO2 = delivered_fuel × co2_factor. Both
# inputs surface on `intermediate` so the top-level co2_kg_per_yr is
# auditable. Delivered fuel is the sum of every end-use kWh; the
# factor mirrors the SAP10 inputs.co2_factor_kg_per_kwh.
inputs = _baseline_inputs()
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
expected_delivered = (
result.main_heating_fuel_kwh_per_yr
+ result.secondary_heating_fuel_kwh_per_yr
+ result.hot_water_kwh_per_yr
+ result.pumps_fans_kwh_per_yr
+ result.lighting_kwh_per_yr
)
assert result.intermediate["delivered_fuel_kwh_per_yr"] == pytest.approx(
expected_delivered, rel=1e-9
)
assert result.intermediate["co2_factor_kg_per_kwh"] == pytest.approx(
inputs.co2_factor_kg_per_kwh, rel=1e-12
)
assert (
result.intermediate["delivered_fuel_kwh_per_yr"]
* result.intermediate["co2_factor_kg_per_kwh"]
) == pytest.approx(result.co2_kg_per_yr, rel=1e-9)
def test_calculate_exposes_primary_energy_breakdown() -> None:
# Arrange — P5 trace mode: primary energy splits across three PEFs
# (space-heating, hot-water, other) and a PV offset at the other-PEF
# (Appendix M). The §11 sketch in HANDOVER_SYSTEMATIC_REVIEW lists
# these as `_kwh_per_m2` because primary energy enters the rating
# equation per-floor-area; absolute values are recoverable via tfa_m2.
inputs = _baseline_inputs()
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
tfa = inputs.dimensions.total_floor_area_m2
space_heating_pe = (
(result.main_heating_fuel_kwh_per_yr + result.secondary_heating_fuel_kwh_per_yr)
* inputs.space_heating_primary_factor
/ tfa
)
hot_water_pe = inputs.hot_water_kwh_per_yr * inputs.hot_water_primary_factor / tfa
other_pe = (
(inputs.pumps_fans_kwh_per_yr + inputs.lighting_kwh_per_yr)
* inputs.other_primary_factor
/ tfa
)
pv_offset_pe = inputs.pv_generation_kwh_per_yr * inputs.other_primary_factor / tfa
assert result.intermediate["space_heating_pe_kwh_per_m2"] == pytest.approx(
space_heating_pe, rel=1e-9
)
assert result.intermediate["hot_water_pe_kwh_per_m2"] == pytest.approx(
hot_water_pe, rel=1e-9
)
assert result.intermediate["other_pe_kwh_per_m2"] == pytest.approx(other_pe, rel=1e-9)
assert result.intermediate["pv_pe_offset_kwh_per_m2"] == pytest.approx(
pv_offset_pe, rel=1e-9
)
expected_total_per_m2 = max(
0.0, space_heating_pe + hot_water_pe + other_pe - pv_offset_pe
)
assert result.primary_energy_kwh_per_m2 == pytest.approx(
expected_total_per_m2, rel=1e-9
)
def test_calculate_exposes_per_end_use_co2() -> None:
# Arrange — P5 trace mode: §11 sketch lists "primary energy AND CO2
# per end-use". The calculator applies a single co2_factor_kg_per_kwh
# to total delivered fuel (no PV deduction on CO2 in the current
# implementation), so per-end-use CO2 is fuel_kwh × factor and the
# five components sum exactly to the top-level co2_kg_per_yr.
inputs = _baseline_inputs()
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
factor = inputs.co2_factor_kg_per_kwh
assert result.intermediate["main_heating_co2_kg_per_yr"] == pytest.approx(
result.main_heating_fuel_kwh_per_yr * factor, rel=1e-9
)
assert result.intermediate["secondary_heating_co2_kg_per_yr"] == pytest.approx(
result.secondary_heating_fuel_kwh_per_yr * factor, rel=1e-9
)
assert result.intermediate["hot_water_co2_kg_per_yr"] == pytest.approx(
result.hot_water_kwh_per_yr * factor, rel=1e-9
)
assert result.intermediate["pumps_fans_co2_kg_per_yr"] == pytest.approx(
result.pumps_fans_kwh_per_yr * factor, rel=1e-9
)
assert result.intermediate["lighting_co2_kg_per_yr"] == pytest.approx(
result.lighting_kwh_per_yr * factor, rel=1e-9
)
breakdown_sum = (
result.intermediate["main_heating_co2_kg_per_yr"]
+ result.intermediate["secondary_heating_co2_kg_per_yr"]
+ result.intermediate["hot_water_co2_kg_per_yr"]
+ result.intermediate["pumps_fans_co2_kg_per_yr"]
+ result.intermediate["lighting_co2_kg_per_yr"]
)
assert breakdown_sum == pytest.approx(result.co2_kg_per_yr, rel=1e-9)
def test_calculate_exposes_pv_export_credit() -> None:
# Arrange — P5 trace mode: total_fuel_cost_gbp = sum(per-end-use
# costs) pv_export_credit, floored at 0. The PV credit is the only
# missing term linking the P5.6 per-end-use cost breakdown to the
# top-level total. Set non-zero PV values so the credit is meaningful.
inputs = replace(
_baseline_inputs(),
pv_generation_kwh_per_yr=1000.0,
pv_export_credit_gbp_per_kwh=0.05,
)
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
expected_credit = (
inputs.pv_generation_kwh_per_yr * inputs.pv_export_credit_gbp_per_kwh
)
assert result.intermediate["pv_export_credit_gbp"] == pytest.approx(
expected_credit, rel=1e-12
)
gross_cost = (
result.intermediate["main_heating_cost_gbp"]
+ result.intermediate["secondary_heating_cost_gbp"]
+ result.intermediate["hot_water_cost_gbp"]
+ result.intermediate["pumps_fans_cost_gbp"]
+ result.intermediate["lighting_cost_gbp"]
)
assert max(0.0, gross_cost - expected_credit) == pytest.approx(
result.total_fuel_cost_gbp, rel=1e-9
)
def test_calculate_exposes_rating_equation_spec_constants() -> None:
# Arrange — P5 trace mode: the §13 ECF denominator carries a 45 m²
# floor-area offset (Table 12) and the SAP rating splits between a
# linear and a log regime at ECF = 3.5. Surfacing both on
# `intermediate` documents the equation alongside the already-exposed
# ecf + deflator (P5.7), so the SAP rating curve is fully auditable.
inputs = _baseline_inputs()
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
assert result.intermediate["floor_area_offset_m2"] == pytest.approx(45.0, rel=1e-12)
assert result.intermediate["ecf_log_threshold"] == pytest.approx(3.5, rel=1e-12)
def test_higher_main_heating_efficiency_reduces_fuel_use() -> None:
# Arrange — Direction check: doubling the boiler efficiency must halve
# the main-heating fuel kWh, holding everything else constant.
base = _baseline_inputs()
high_eff = replace(base, main_heating_efficiency=base.main_heating_efficiency * 2.0)
# Act
r_base = calculate_sap_from_inputs(base)
r_high = calculate_sap_from_inputs(high_eff)
# Assert
assert r_base.space_heating_kwh_per_yr == pytest.approx(
r_high.space_heating_kwh_per_yr, rel=1e-6
)
assert r_high.main_heating_fuel_kwh_per_yr == pytest.approx(
r_base.main_heating_fuel_kwh_per_yr / 2.0, rel=1e-6
)
assert r_high.sap_score >= r_base.sap_score
def _baseline_with_region(region: int) -> CalculatorInputs:
"""Rebuild baseline with a different climate region. Recomputes the
§7 + §8 orchestrators because they depend on external temperatures,
which vary per region in Appendix U Table U1."""
base = _baseline_inputs()
ext_temp_monthly_c = tuple(external_temperature_c(region, m) for m in range(1, 13))
htc_monthly = base.heat_transmission.total_w_per_k + 0.33 * base.dimensions.volume_m3 * 0.7
htc_monthly_w_per_k = (htc_monthly,) * 12
total_gains_monthly_w = tuple(
base.internal_gains_monthly_w[m] + base.solar_gains_monthly_w[m] for m in range(12)
)
mit_result = mean_internal_temperature_monthly(
monthly_external_temp_c=ext_temp_monthly_c,
monthly_total_gains_w=total_gains_monthly_w,
monthly_heat_transfer_coefficient_w_per_k=htc_monthly_w_per_k,
thermal_mass_parameter_kj_per_m2_k=base.thermal_mass_parameter_kj_per_m2_k,
total_floor_area_m2=base.dimensions.total_floor_area_m2,
control_type=base.control_type,
responsiveness=base.responsiveness,
living_area_fraction=base.living_area_fraction,
)
space_heating_result = space_heating_monthly_kwh(
monthly_heat_transfer_coefficient_w_per_k=htc_monthly_w_per_k,
monthly_internal_temperature_c=mit_result.adjusted_mean_internal_temp_monthly,
monthly_external_temperature_c=ext_temp_monthly_c,
monthly_utilisation_factor=mit_result.utilisation_factor_whole_monthly,
monthly_total_gains_w=total_gains_monthly_w,
total_floor_area_m2=base.dimensions.total_floor_area_m2,
)
return replace(
base,
region=region,
mean_internal_temp_monthly_c=mit_result.adjusted_mean_internal_temp_monthly,
utilisation_factor_monthly=mit_result.utilisation_factor_whole_monthly,
space_heating_monthly_kwh=space_heating_result.total_space_heating_monthly_kwh,
)
def test_colder_climate_region_increases_space_heating_demand() -> None:
# Arrange — Direction check: same dwelling in Shetland (region 20) must
# require more space-heating kWh than in Thames (region 1) because the
# external-temperature column in Table U1 is consistently lower.
thames = _baseline_with_region(1)
shetland = _baseline_with_region(20)
# Act
r_thames = calculate_sap_from_inputs(thames)
r_shetland = calculate_sap_from_inputs(shetland)
# Assert
assert r_shetland.space_heating_kwh_per_yr > r_thames.space_heating_kwh_per_yr
def test_zero_heat_transmission_collapses_space_heating_to_zero() -> None:
# Arrange — When HLC = 0 (perfect envelope) and there's no ventilation
# heat loss, no month can have a positive loss rate, so space heating
# must be zero across the year. (98c)m is therefore (0,)*12 — the §8
# orchestrator value-clamps on useful_loss ≤ 0.
base = _baseline_inputs()
no_loss = replace(
base,
heat_transmission=HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
monthly_infiltration_ach=(0.0,) * 12,
space_heating_monthly_kwh=(0.0,) * 12,
)
# Act
result = calculate_sap_from_inputs(no_loss)
# Assert
assert result.space_heating_kwh_per_yr == 0.0
assert result.main_heating_fuel_kwh_per_yr == 0.0
def test_ecf_uses_table_12_energy_cost_deflator() -> None:
# Arrange — §13 Equation (7): ECF = 0.42 × cost / (TFA + 45) per
# SAP 10.2 Table 12. The orchestrator must report an ECF that
# reconciles with this formula given the cost it reported.
inputs = _baseline_inputs()
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
expected_ecf = (
0.42
* result.total_fuel_cost_gbp
/ (inputs.dimensions.total_floor_area_m2 + 45.0)
)
assert result.ecf == pytest.approx(expected_ecf, rel=1e-6)
def test_split_tariff_charges_space_heating_at_off_peak_rate() -> None:
# Arrange — Economy-7 dwelling: storage-heater space heating at the
# 7h-low rate (~5.5 p/kWh), everything else on standard (13.19 p/kWh).
# Verifies the split-tariff cost line aggregates correctly per SAP §12.
base = _baseline_inputs()
e7 = replace(
base,
space_heating_fuel_cost_gbp_per_kwh=0.055,
hot_water_fuel_cost_gbp_per_kwh=0.1319,
other_fuel_cost_gbp_per_kwh=0.1319,
)
# Act
r_e7 = calculate_sap_from_inputs(e7)
# Assert
expected_cost = (
r_e7.main_heating_fuel_kwh_per_yr * 0.055
+ r_e7.hot_water_kwh_per_yr * 0.1319
+ (r_e7.pumps_fans_kwh_per_yr + r_e7.lighting_kwh_per_yr) * 0.1319
)
assert r_e7.total_fuel_cost_gbp == pytest.approx(expected_cost, rel=1e-6)