Model/tests/domain/sap10_calculator/test_calculator.py
Khalim Conn-Kowlessar d7d5084f90 Move sap10_calculator tests to tests/domain/sap10_calculator/ for CI
The calculator tests lived under domain/sap10_calculator/{tests,worksheet/
tests,rdsap/tests,climate/tests,validation/tests}, none of which are in
pytest.ini testpaths — so CI (which collects tests/) never ran them. Relocate
all five dirs to tests/domain/sap10_calculator/{,worksheet,rdsap,climate,
validation}, mirroring the tests/domain/property_baseline/ convention, so the
cascade-pin / golden / e2e conformance suites run in CI.

Mechanics:
- git mv preserves history (110 files).
- Flattening the trailing /tests keeps each file's depth-to-repo-root
  identical, so all 16 repo-root parents[4] fixture refs stay valid. Only
  test_pcdb_etl.py's parents[1] (→ pcdb data) and one hardcoded absolute
  golden-fixture path in test_cert_to_inputs.py needed rebasing.
- Cross-imports rewritten domain.sap10_calculator.worksheet.tests →
  tests.domain.sap10_calculator.worksheet (21 files incl. the external
  importer backend/documents_parser/tests/test_summary_pdf_mapper_chain.py).
- Golden-fixture path strings in test_summary_pdf_mapper_chain.py +
  scripts/fetch_cohort2_api_jsons.py updated to the new location (the JSONs
  moved with the rdsap tests).

load_cells / gitignored worksheet xlsx: the xlsx-pinned tests (test_dimensions
/ ventilation / water_heating) read 2026-05-19-17-18 RdSap10Worksheet.xlsx,
which is gitignored (.gitignore `*.xlsx`) and so absent in CI. _xlsx_loader.
load_cells now pytest.skip()s when the file is absent, so those tests run
locally and skip cleanly in CI instead of erroring — no new CI failures from
the move, and the gitignore policy is respected.

Verified: tests/domain/sap10_calculator + backend/documents_parser +
tests/domain/property_baseline = 2248 pass, 1 skipped; pyright resolves the
new import paths with zero import-resolution errors.

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

713 lines
29 KiB
Python
Raw 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_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_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)