mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
fa5bdcc26f
commit
3fcec7ef22
2 changed files with 307 additions and 0 deletions
|
|
@ -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)
|
||||
107
packages/domain/src/domain/sap/worksheet/ventilation.py
Normal file
107
packages/domain/src/domain/sap/worksheet/ventilation.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue