"""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)