slice S-A3: infiltration worksheet lines (6a)-(16) (SAP 10.3 §2)

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.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-17 22:00:10 +00:00
parent fa5bdcc26f
commit 3fcec7ef22
2 changed files with 307 additions and 0 deletions

View file

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

View file

@ -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,
)