From 3fcec7ef2274fec878b6f655d5b27ef8f6e5f9d7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 17 May 2026 22:00:10 +0000 Subject: [PATCH] =?UTF-8?q?slice=20S-A3:=20infiltration=20worksheet=20line?= =?UTF-8?q?s=20(6a)-(16)=20(SAP=2010.3=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third slice of the SAP10 Calculator Session A (ADR-0009). Ports the SAP 10.2 / RdSAP10 §4.1 air-change-rate worksheet for the no-pressure-test path. Returns an InfiltrationBreakdown carrying each named worksheet line so callers can audit per SAP convention: (8) openings_ach — Table 2.1 rate × count / volume (10) additional_ach — (storey_count − 1) × 0.1 (11) structural_ach — 0.25 steel/timber-frame, 0.35 masonry (12) floor_ach — 0.2 unsealed timber / 0.1 sealed / 0 (13) draught_lobby_ach — 0.05 absent, 0.0 present (15) window_ach — 0.25 − 0.2 × (pct_dp / 100) (16) total_ach — sum of all of the above Table 2.1 rates: open chimney 80, open flue 20, closed-fire chimney 10, solid-fuel-boiler chimney 20, other-heater chimney 35, blocked chimney 20, intermittent fan 10, passive vent 10, flueless gas fire 40 (all m³/hour per opening). 9 AAA cycles cover the baseline calculation, each Table 2.1 opening contribution, frame-vs-masonry structural baseline, suspended-timber floor sealed/unsealed split, draught-lobby presence, window draught- proofing scale, multi-opening aggregation, and volume_m3 ≤ 0 validation. Pressure-test override (worksheet lines 17-21) and mechanical-ventilation adjustments (Table 4g, n_eff formula §2.6.6) are out of scope for this slice — separate later slices per ADR-0009. --- .../sap/worksheet/tests/test_ventilation.py | 200 ++++++++++++++++++ .../src/domain/sap/worksheet/ventilation.py | 107 ++++++++++ 2 files changed, 307 insertions(+) create mode 100644 packages/domain/src/domain/sap/worksheet/tests/test_ventilation.py create mode 100644 packages/domain/src/domain/sap/worksheet/ventilation.py diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_ventilation.py b/packages/domain/src/domain/sap/worksheet/tests/test_ventilation.py new file mode 100644 index 00000000..81066ab8 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/tests/test_ventilation.py @@ -0,0 +1,200 @@ +"""Tests for SAP 10.3 §2 + RdSAP10 §4.1 infiltration worksheet. + +Covers worksheet lines (6a)-(16): openings + structural baseline + storey +additional + floor + draught lobby + window draught proofing. Pressure-test +override (17-21) and mechanical ventilation are separate later slices. + +Reference: SAP 10.3 (13-01-2026) §2; RdSAP10 (June 2025) §4.1 Table 5. +""" + +import pytest + +from domain.sap.worksheet.ventilation import ( + InfiltrationBreakdown, + infiltration_ach, +) + + +def test_bare_masonry_detached_returns_baseline_total_of_0_65_ach() -> None: + # Arrange — Single-storey masonry detached bungalow with no openings, no + # draught lobby, 0% draught-proofed windows. Worksheet baseline summed + # per SAP 10.3 §2 / RdSAP10 §4.1 Table 5: + # (8) openings = 0 + # (10) additional = (1-1) × 0.1 = 0 + # (11) structural = 0.35 masonry + # (12) floor = 0 (not suspended timber) + # (13) lobby = 0.05 (lobby absent) + # (15) window = 0.25 - 0.2 × 0/100 = 0.25 + # (16) total = 0.65 ach + + # Act + result = infiltration_ach( + volume_m3=200.0, + storey_count=1, + is_timber_or_steel_frame=False, + ) + + # Assert + assert isinstance(result, InfiltrationBreakdown) + assert result.total_ach == pytest.approx(0.65, abs=0.01) + + +def test_open_chimney_adds_80_per_volume_to_openings_ach() -> None: + # Arrange — Same masonry detached bungalow with one open chimney. Per + # Table 2.1 an open chimney contributes 80 m³/hour. Volume is 200 m³, so + # openings_ach = 80 / 200 = 0.40 and total = 0.65 + 0.40 = 1.05. + + # Act + result = infiltration_ach( + volume_m3=200.0, + storey_count=1, + is_timber_or_steel_frame=False, + open_chimneys=1, + ) + + # Assert + assert result.openings_ach == pytest.approx(0.40, abs=0.005) + assert result.total_ach == pytest.approx(1.05, abs=0.01) + + +def test_two_storey_dwelling_adds_0_1_ach_via_additional_line_10() -> None: + # Arrange — Worksheet line (10): additional infiltration = (n − 1) × 0.1. + # A two-storey home contributes +0.1 ach on top of the baseline. Bare + # masonry baseline 0.65 → 0.75. + + # Act + result = infiltration_ach( + volume_m3=200.0, + storey_count=2, + is_timber_or_steel_frame=False, + ) + + # Assert + assert result.additional_ach == pytest.approx(0.1, abs=0.001) + assert result.total_ach == pytest.approx(0.75, abs=0.01) + + +def test_timber_frame_uses_structural_baseline_0_25_not_0_35() -> None: + # Arrange — Worksheet line (11) per RdSAP10 §4.1: structural infiltration + # = 0.25 for steel or timber frame, 0.35 for masonry. Baseline drops by + # 0.10 ach for a frame dwelling. + + # Act + result = infiltration_ach( + volume_m3=200.0, + storey_count=1, + is_timber_or_steel_frame=True, + ) + + # Assert + assert result.structural_ach == pytest.approx(0.25, abs=0.001) + assert result.total_ach == pytest.approx(0.55, abs=0.01) # 0.65 - 0.10 + + +def test_suspended_timber_floor_unsealed_adds_0_2_ach_line_12() -> None: + # Arrange — Worksheet line (12) per RdSAP10 §4.1: floor infiltration + # = 0.2 unsealed suspended timber / 0.1 sealed / 0 otherwise. Older + # solid-floor age bands or post-1970 dwellings don't carry this loss. + + # Act + unsealed = infiltration_ach( + volume_m3=200.0, + storey_count=1, + is_timber_or_steel_frame=False, + suspended_timber_floor_sealed=False, + has_suspended_timber_floor=True, + ) + sealed = infiltration_ach( + volume_m3=200.0, + storey_count=1, + is_timber_or_steel_frame=False, + suspended_timber_floor_sealed=True, + has_suspended_timber_floor=True, + ) + + # Assert + assert unsealed.floor_ach == pytest.approx(0.2, abs=0.001) + assert sealed.floor_ach == pytest.approx(0.1, abs=0.001) + assert unsealed.total_ach == pytest.approx(0.85, abs=0.01) # 0.65 + 0.20 + assert sealed.total_ach == pytest.approx(0.75, abs=0.01) # 0.65 + 0.10 + + +def test_draught_lobby_present_zeros_line_13_infiltration() -> None: + # Arrange — Worksheet line (13): no draught lobby contributes 0.05 ach; + # a present lobby contributes 0. So baseline 0.65 drops to 0.60 when the + # lobby is present. + + # Act + result = infiltration_ach( + volume_m3=200.0, + storey_count=1, + is_timber_or_steel_frame=False, + has_draught_lobby=True, + ) + + # Assert + assert result.draught_lobby_ach == pytest.approx(0.0, abs=0.001) + assert result.total_ach == pytest.approx(0.60, abs=0.01) + + +def test_fully_draught_proofed_windows_drops_line_15_to_0_05() -> None: + # Arrange — Worksheet line (15): window infiltration = 0.25 - 0.2 × (pct/100). + # 100% DP -> 0.25 - 0.20 = 0.05; 50% DP -> 0.15. + + # Act + full_dp = infiltration_ach( + volume_m3=200.0, + storey_count=1, + is_timber_or_steel_frame=False, + window_pct_draught_proofed=100.0, + ) + half_dp = infiltration_ach( + volume_m3=200.0, + storey_count=1, + is_timber_or_steel_frame=False, + window_pct_draught_proofed=50.0, + ) + + # Assert + assert full_dp.window_ach == pytest.approx(0.05, abs=0.005) + assert half_dp.window_ach == pytest.approx(0.15, abs=0.005) + assert full_dp.total_ach == pytest.approx(0.45, abs=0.01) # 0.65 - 0.20 + assert half_dp.total_ach == pytest.approx(0.55, abs=0.01) # 0.65 - 0.10 + + +def test_openings_sum_each_table_2_1_rate_independently() -> None: + # Arrange — Each opening type in Table 2.1 must contribute its own rate. + # 1 open flue (20) + 1 closed-fire chimney (10) + 1 solid-fuel-boiler + # flue (20) + 1 other-heater flue (35) + 1 blocked chimney (20) + 1 + # intermittent fan (10) + 1 passive vent (10) + 1 flueless gas fire (40) + # = 165 m³/h. Volume 200 m³ -> openings_ach = 0.825. + + # Act + result = infiltration_ach( + volume_m3=200.0, + storey_count=1, + is_timber_or_steel_frame=False, + open_chimneys=0, + open_flues=1, + closed_fire_chimneys=1, + solid_fuel_boiler_chimneys=1, + other_heater_chimneys=1, + blocked_chimneys=1, + intermittent_fans=1, + passive_vents=1, + flueless_gas_fires=1, + ) + + # Assert + assert result.openings_ach == pytest.approx(0.825, abs=0.01) + + +def test_zero_or_negative_volume_raises_value_error() -> None: + # Arrange — A zero-volume dwelling would divide-by-zero in line (8). + # Fail fast so the caller knows the upstream Dimensions are bad. + + # Act / Assert + with pytest.raises(ValueError, match="volume_m3"): + infiltration_ach(volume_m3=0.0, storey_count=1, is_timber_or_steel_frame=False) + with pytest.raises(ValueError, match="volume_m3"): + infiltration_ach(volume_m3=-1.0, storey_count=1, is_timber_or_steel_frame=False) diff --git a/packages/domain/src/domain/sap/worksheet/ventilation.py b/packages/domain/src/domain/sap/worksheet/ventilation.py new file mode 100644 index 00000000..10dc26e2 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/ventilation.py @@ -0,0 +1,107 @@ +"""SAP 10.3 §2 + RdSAP10 §4.1 — infiltration (air-change rate) worksheet. + +Ports worksheet lines (6a) through (16) of the SAP 10.2 / RdSAP10 air-change +algorithm. When no pressure test is available, infiltration in air-changes- +per-hour is the sum of: + + (8) openings — Σ(Table 2.1 rate × count) / volume + (10) additional — (storey_count − 1) × 0.1 + (11) structural — 0.25 steel/timber-frame, 0.35 masonry (default) + (12) floor — 0.2 unsealed suspended-timber / 0.1 sealed / else 0 + (13) draught lobby — 0.05 if absent, 0.0 if present + (15) window — 0.25 − 0.2 × (pct_draught_proofed / 100) + (16) total — (8) + (10) + (11) + (12) + (13) + (15) + +Returned breakdown preserves each worksheet line so callers can audit per +SAP convention. Sheltered-sides shelter factor (19-21), pressure-test +override (17-18), and mechanical ventilation adjustments are out of scope +for this slice — see ADR-0009 Session A plan. + +Reference: SAP 10.3 specification §2 (pages 12-16); +RdSAP10 specification §4.1 Table 5 (pages 27-30). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + + +# Table 2.1 — ventilation rates in m³/hour per opening type. +_OPEN_CHIMNEY_M3_H: Final[float] = 80.0 +_OPEN_FLUE_M3_H: Final[float] = 20.0 +_CLOSED_FIRE_CHIMNEY_M3_H: Final[float] = 10.0 +_SOLID_FUEL_BOILER_CHIMNEY_M3_H: Final[float] = 20.0 +_OTHER_HEATER_CHIMNEY_M3_H: Final[float] = 35.0 +_BLOCKED_CHIMNEY_M3_H: Final[float] = 20.0 +_INTERMITTENT_FAN_M3_H: Final[float] = 10.0 +_PASSIVE_VENT_M3_H: Final[float] = 10.0 +_FLUELESS_GAS_FIRE_M3_H: Final[float] = 40.0 + + +@dataclass(frozen=True) +class InfiltrationBreakdown: + """SAP worksheet lines (8), (10), (11), (12), (13), (15), (16).""" + + openings_ach: float + additional_ach: float + structural_ach: float + floor_ach: float + draught_lobby_ach: float + window_ach: float + total_ach: float + + +def infiltration_ach( + *, + volume_m3: float, + storey_count: int, + is_timber_or_steel_frame: bool, + open_chimneys: int = 0, + open_flues: int = 0, + closed_fire_chimneys: int = 0, + solid_fuel_boiler_chimneys: int = 0, + other_heater_chimneys: int = 0, + blocked_chimneys: int = 0, + intermittent_fans: int = 0, + passive_vents: int = 0, + flueless_gas_fires: int = 0, + has_suspended_timber_floor: bool = False, + suspended_timber_floor_sealed: bool = False, + has_draught_lobby: bool = False, + window_pct_draught_proofed: float = 0.0, +) -> InfiltrationBreakdown: + """Air-change rate (ach) per SAP 10.3 §2 / RdSAP10 §4.1, no pressure + test path.""" + if volume_m3 <= 0: + raise ValueError(f"volume_m3 must be > 0, got {volume_m3}") + openings_m3_h = ( + open_chimneys * _OPEN_CHIMNEY_M3_H + + open_flues * _OPEN_FLUE_M3_H + + closed_fire_chimneys * _CLOSED_FIRE_CHIMNEY_M3_H + + solid_fuel_boiler_chimneys * _SOLID_FUEL_BOILER_CHIMNEY_M3_H + + other_heater_chimneys * _OTHER_HEATER_CHIMNEY_M3_H + + blocked_chimneys * _BLOCKED_CHIMNEY_M3_H + + intermittent_fans * _INTERMITTENT_FAN_M3_H + + passive_vents * _PASSIVE_VENT_M3_H + + flueless_gas_fires * _FLUELESS_GAS_FIRE_M3_H + ) + openings = openings_m3_h / volume_m3 + additional = max(0, storey_count - 1) * 0.1 + structural = 0.25 if is_timber_or_steel_frame else 0.35 + if has_suspended_timber_floor: + floor = 0.1 if suspended_timber_floor_sealed else 0.2 + else: + floor = 0.0 + draught_lobby = 0.0 if has_draught_lobby else 0.05 + window = 0.25 - 0.2 * (window_pct_draught_proofed / 100.0) + total = openings + additional + structural + floor + draught_lobby + window + return InfiltrationBreakdown( + openings_ach=openings, + additional_ach=additional, + structural_ach=structural, + floor_ach=floor, + draught_lobby_ach=draught_lobby, + window_ach=window, + total_ach=total, + )