mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
713 lines
29 KiB
Python
713 lines
29 KiB
Python
"""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)
|