"""Tests for SAP 10.2 §2 + RdSAP10 §4.1 ventilation rate worksheet. Covers every line of the §2 worksheet: openings (6a)-(7c), infiltration (8), components (10)-(16), pressure-test override (17)-(18), shelter (19)-(21), monthly wind (22)-(22b), and mechanical ventilation modes (23a)-(24d) → final monthly (25)m. Reference: SAP 10.2 (14-03-2025) §2; RdSAP10 (June 2025) §4.1 Table 5. Canonical worked example: `2026-05-19-17-18 RdSap10Worksheet.xlsx`, `NonRegionalWeather` sheet, rows 27-121. """ import pytest from tests.domain.sap10_calculator.worksheet._xlsx_loader import load_cells from domain.sap10_calculator.worksheet.ventilation import ( MechanicalVentilationKind, TABLE_U2_NON_REGIONAL_WIND_SPEED_M_S, VentilationResult, ventilation_from_inputs, ) def test_bare_masonry_detached_returns_baseline_line_16_of_0_65() -> None: # Arrange — Single-storey masonry detached bungalow with no openings, # no draught lobby, 0% draught-proofed windows. §2 baseline summed: # (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 = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, sheltered_sides=0, ) # Assert assert isinstance(result, VentilationResult) assert result.infiltration_rate_ach == pytest.approx(0.65, abs=1e-12) def test_open_chimney_adds_80_per_volume_to_line_8_openings() -> None: # Arrange — Same masonry bungalow with one open chimney. Per Table # 2.1 an open chimney contributes 80 m³/h. Volume 200 m³, so # (8) = 80 / 200 = 0.40 and (16) = 0.65 + 0.40 = 1.05. # Act result = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, open_chimneys=1, sheltered_sides=0, ) # Assert assert result.openings_ach == pytest.approx(0.40, abs=1e-12) assert result.infiltration_rate_ach == pytest.approx(1.05, abs=1e-12) def test_two_storey_dwelling_adds_0_1_via_line_10() -> None: # Arrange — Line (10): additional infiltration = (n − 1) × 0.1. # A two-storey home contributes +0.1 ach on top of the baseline. # Act result = ventilation_from_inputs( volume_m3=200.0, storey_count=2, is_timber_or_steel_frame=False, sheltered_sides=0, ) # Assert assert result.additional_ach == pytest.approx(0.1, abs=1e-12) assert result.infiltration_rate_ach == pytest.approx(0.75, abs=1e-12) def test_timber_frame_uses_line_11_structural_0_25_not_0_35() -> None: # Arrange — Line (11) per RdSAP §4.1: structural = 0.25 for steel or # timber frame, 0.35 for masonry. Baseline drops by 0.10 ach. # Act result = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=True, sheltered_sides=0, ) # Assert assert result.structural_ach == pytest.approx(0.25, abs=1e-12) assert result.infiltration_rate_ach == pytest.approx(0.55, abs=1e-12) def test_suspended_timber_floor_line_12_unsealed_vs_sealed() -> None: # Arrange — Line (12): 0.2 unsealed suspended timber / 0.1 sealed / 0. # Act unsealed = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, has_suspended_timber_floor=True, suspended_timber_floor_sealed=False, sheltered_sides=0, ) sealed = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, has_suspended_timber_floor=True, suspended_timber_floor_sealed=True, sheltered_sides=0, ) # Assert assert unsealed.floor_ach == pytest.approx(0.2, abs=1e-12) assert sealed.floor_ach == pytest.approx(0.1, abs=1e-12) def test_draught_lobby_present_zeros_line_13() -> None: # Arrange — Line (13): no lobby → 0.05 ach; lobby present → 0. # Act result = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, has_draught_lobby=True, sheltered_sides=0, ) # Assert assert result.draught_lobby_ach == pytest.approx(0.0, abs=1e-12) def test_window_draught_proofed_line_15_is_linear_in_pct() -> None: # Arrange — Line (15): 0.25 - 0.2 × (pct/100). 100% DP → 0.05; # 50% DP → 0.15; 0% → 0.25. # Act full = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, window_pct_draught_proofed=100.0, sheltered_sides=0, ) half = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, window_pct_draught_proofed=50.0, sheltered_sides=0, ) # Assert assert full.window_ach == pytest.approx(0.05, abs=1e-12) assert half.window_ach == pytest.approx(0.15, abs=1e-12) def test_openings_sum_each_table_2_1_rate_independently() -> None: # Arrange — 1 open flue (20) + 1 closed fire (10) + 1 SF boiler (20) # + 1 other heater (35) + 1 blocked (20) + 1 fan (10) + 1 PSV (10) + # 1 flueless GF (40) = 165 m³/h. Vol 200 → openings_ach = 0.825. # Act result = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, 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=1e-12) def test_zero_or_negative_volume_raises_value_error() -> None: # Arrange / Act / Assert — line (8) divides by volume, so guard. with pytest.raises(ValueError, match="volume_m3"): ventilation_from_inputs(volume_m3=0.0, storey_count=1, is_timber_or_steel_frame=False) with pytest.raises(ValueError, match="volume_m3"): ventilation_from_inputs(volume_m3=-1.0, storey_count=1, is_timber_or_steel_frame=False) def test_wrong_length_monthly_wind_array_raises_value_error() -> None: # Arrange / Act / Assert — Table U2 always has 12 entries (Jan-Dec). with pytest.raises(ValueError, match="12 entries"): ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, monthly_wind_speed_m_s=(4.0, 4.0, 4.0), ) def test_pressure_test_ap50_uses_line_18a_formula() -> None: # Arrange — line (18) = (17) / 20 + (8). With AP50=5 and 0 openings, # (18) = 0.25 (vs (16) which would be ~0.65). Pressure test overrides. # Act result = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, air_permeability_ap50=5.0, sheltered_sides=0, ) # Assert assert result.pressure_test_ach == pytest.approx(0.25, abs=1e-12) def test_pressure_test_ap4_uses_line_18b_formula() -> None: # Arrange — line (18) = 0.263 × (17a)^0.924 + (8). With AP4=4 and 0 # openings, (18) = 0.263 × 4^0.924 ≈ 0.951. # Act result = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, air_permeability_ap4=4.0, sheltered_sides=0, ) # Assert assert result.pressure_test_ach == pytest.approx(0.263 * (4.0 ** 0.924), abs=1e-12) def test_shelter_factor_line_20_clamps_sides_to_0_4() -> None: # Arrange — (20) = 1 - 0.075 × min(4, max(0, sides)). # 0 sides → 1.0 # 2 sides → 0.85 # 4 sides → 0.7 # 5+ sides → clamped to 4 → 0.7 # Act / Assert assert ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, sheltered_sides=0, ).shelter_factor == pytest.approx(1.0) assert ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, sheltered_sides=2, ).shelter_factor == pytest.approx(0.85) assert ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, sheltered_sides=4, ).shelter_factor == pytest.approx(0.7) assert ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, sheltered_sides=99, ).shelter_factor == pytest.approx(0.7) def test_monthly_wind_factor_line_22a_is_wind_over_4() -> None: # Arrange — (22a)m = (22)m / 4. Default Table U2 Jan=5.1 → 1.275. # Act result = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, ) # Assert — 12 entries, first month Jan = 5.1 m/s → 1.275. assert len(result.monthly_wind_factor) == 12 assert result.monthly_wind_factor[0] == pytest.approx(5.1 / 4.0) assert result.monthly_wind_factor[5] == pytest.approx(3.8 / 4.0) # Jun assert result.monthly_wind_factor[11] == pytest.approx(4.7 / 4.0) # Dec def test_natural_ventilation_uses_24d_piecewise_formula() -> None: # Arrange — (24d)m: if (22b)m ≥ 1 → (22b)m; else 0.5 + (22b)m² / 2. # With a high (21) value, some months will yield (22b)m ≥ 1 and pass # through; others will use the quadratic. # Act — Pick (21) such that Jan (22a=1.275) gives (22b)≈1.0: # (21) ≈ 0.785 → (22b)Jan = 0.785 × 1.275 ≈ 1.001 (>= 1, passes through) # (22b)Jun = 0.785 × 0.95 ≈ 0.746 (< 1, uses quadratic) result = ventilation_from_inputs( volume_m3=200.0, storey_count=4, is_timber_or_steel_frame=False, # storeys=4 → (10)=0.3; add components → ~1.0; ×0.85 shelter → 0.85 # Adjusting via window draught proof to dial in the value window_pct_draught_proofed=0.0, sheltered_sides=2, mv_kind=MechanicalVentilationKind.NATURAL, ) # Assert — verify the piecewise law numerically. for i, w_22b in enumerate(result.monthly_wind_adjusted_ach): if w_22b >= 1.0: assert result.effective_monthly_ach[i] == pytest.approx(w_22b) else: assert result.effective_monthly_ach[i] == pytest.approx( 0.5 + (w_22b ** 2) * 0.5 ) def test_mvhr_24a_subtracts_efficiency_from_system_air_change() -> None: # Arrange — (24a)m = (22b)m + (23b) × (1 - (23c)/100). With 90% # efficiency, only 10% of system ach contributes; with 0%, all. # Act mvhr_90 = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, mv_kind=MechanicalVentilationKind.MVHR, mv_system_ach=0.5, mvhr_efficiency_pct=90.0, sheltered_sides=0, ) mvhr_0 = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, mv_kind=MechanicalVentilationKind.MVHR, mv_system_ach=0.5, mvhr_efficiency_pct=0.0, sheltered_sides=0, ) # Assert — 90% efficiency adds 0.5×0.1=0.05 to each month; 0% adds 0.5. for i in range(12): delta_90 = mvhr_90.effective_monthly_ach[i] - mvhr_90.monthly_wind_adjusted_ach[i] delta_0 = mvhr_0.effective_monthly_ach[i] - mvhr_0.monthly_wind_adjusted_ach[i] assert delta_90 == pytest.approx(0.05, abs=1e-12) assert delta_0 == pytest.approx(0.5, abs=1e-12) def test_balanced_mv_24b_adds_full_system_ach_each_month() -> None: # Arrange — (24b)m = (22b)m + (23b). Balanced MV without recovery. # Act result = ventilation_from_inputs( volume_m3=200.0, storey_count=1, is_timber_or_steel_frame=False, mv_kind=MechanicalVentilationKind.MV, mv_system_ach=0.4, sheltered_sides=0, ) # Assert for i in range(12): assert result.effective_monthly_ach[i] == pytest.approx( result.monthly_wind_adjusted_ach[i] + 0.4 ) def test_extract_or_piv_24c_clips_at_system_ach_for_low_wind_months() -> None: # Arrange — (24c)m: if (22b)m < 0.5 × (23b) → (23b); else (22b)m + 0.5 × (23b). # Low natural wind (low (22b)m) → just (23b). High wind → (22b)m + half. # Act — pick (21) tiny so (22b)m << 0.5 × (23b) in every month: # mv_system_ach=2.0 → threshold 1.0. (21)=0.1 → (22b)m max ≈ 0.13 (well under 1). low_wind = ventilation_from_inputs( volume_m3=10000.0, # huge volume → openings near 0 storey_count=1, is_timber_or_steel_frame=True, # 0.25 structural window_pct_draught_proofed=100.0, # window→0.05 has_draught_lobby=True, # lobby→0 mv_kind=MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE, mv_system_ach=2.0, sheltered_sides=4, # shelter factor 0.7 ) # All months should be clipped to 2.0. # Assert for v in low_wind.effective_monthly_ach: assert v == pytest.approx(2.0) def test_excel_worksheet_conformance_section_2_lines_6a_to_25m() -> None: """Mirror the worked example in `2026-05-19-17-18 RdSap10Worksheet.xlsx`, sheet `NonRegionalWeather`, §2 (rows 27-121) covering every line (6a)..(25)m. Inputs from the worksheet: - Volume (5) = 511.9628 m³ - Storeys (9) = 2 - Intermittent fans (7a) = 30 m³/h → 3 fans - Masonry, no suspended timber floor, no draught lobby, 100% draught-proofed - 1 sheltered side - Whole-house extract / PIV-outside MV at (23a) = 0.5 ach Every line is then asserted against its Excel cell.""" # Arrange — load every line ref from the canonical worksheet cells = load_cells( "NonRegionalWeather", [ # Openings (6a..6f, 7a..7c) "U30", "U32", "U34", "U36", "U38", "U40", "U42", "U44", "U46", # Line (8) infiltration from openings "U48", # Volume (5) and storey count (9) "U25", "U52", # Components (10..15) "U54", "U56", "U58", "U60", "U62", "U64", # Line (16) sum "U66", # Pressure test (17, 17a, 18) "U73", # Shelter (19, 20, 21) "U77", "U79", "U81", # Monthly wind speed (22)m Jan..Dec "G86", "H86", "I86", "J86", "K86", "L86", "M86", "N86", "O86", "P86", "Q86", "R86", # Monthly (22a)m wind factor Jan..Dec "G89", "H89", "I89", "J89", "K89", "L89", "M89", "N89", "O89", "P89", "Q89", "R89", # Monthly (22b)m wind-adjusted ach Jan..Dec "G92", "H92", "I92", "J92", "K92", "L92", "M92", "N92", "O92", "P92", "Q92", "R92", # MV system (23a), (23b) "U96", "U98", # Monthly (24c)m / (25)m Jan..Dec — this example uses extract/PIV path "G109", "H109", "I109", "J109", "K109", "L109", "M109", "N109", "O109", "P109", "Q109", "R109", "G115", "H115", "I115", "J115", "K115", "L115", "M115", "N115", "O115", "P115", "Q115", "R115", ], ) # Act — mirror the worksheet inputs into ventilation_from_inputs. # Excel (7a) = 30 = 3 fans × 10; (9) = 2 storeys; volume from (5). result = ventilation_from_inputs( volume_m3=cells["U25"], storey_count=int(cells["U52"]), is_timber_or_steel_frame=False, # (11) = 0.35 masonry intermittent_fans=3, # (7a) = 30 has_suspended_timber_floor=False, # (12) = 0 has_draught_lobby=False, # (13) = 0.05 window_pct_draught_proofed=cells["U62"], # (14) = 100 sheltered_sides=int(cells["U77"]), # (19) = 1 mv_kind=MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE, mv_system_ach=cells["U96"], # (23a) = 0.5 ) # Assert — every populated line matches its Excel cell. # Openings m³/h assert result.open_chimneys_m3_h == pytest.approx(cells["U30"]) # (6a) assert result.open_flues_m3_h == pytest.approx(cells["U32"]) # (6b) assert result.closed_fire_chimneys_m3_h == pytest.approx(cells["U34"]) # (6c) assert result.solid_fuel_boiler_m3_h == pytest.approx(cells["U36"]) # (6d) assert result.other_heater_m3_h == pytest.approx(cells["U38"]) # (6e) assert result.blocked_chimneys_m3_h == pytest.approx(cells["U40"]) # (6f) assert result.intermittent_fans_m3_h == pytest.approx(cells["U42"]) # (7a) assert result.passive_vents_m3_h == pytest.approx(cells["U44"]) # (7b) assert result.flueless_gas_fires_m3_h == pytest.approx(cells["U46"]) # (7c) # Line (8) infiltration from openings assert result.openings_ach == pytest.approx(cells["U48"]) # Components (10..15) assert result.additional_ach == pytest.approx(cells["U54"]) # (10) assert result.structural_ach == pytest.approx(cells["U56"]) # (11) assert result.floor_ach == pytest.approx(cells["U58"]) # (12) assert result.draught_lobby_ach == pytest.approx(cells["U60"]) # (13) assert result.window_ach == pytest.approx(cells["U64"]) # (15) # Line (16) sum assert result.infiltration_rate_ach == pytest.approx(cells["U66"]) # Line (18) — no pressure test, so (18) = (16) assert result.pressure_test_ach == pytest.approx(cells["U73"]) # Shelter (19, 20, 21) assert result.sheltered_sides == int(cells["U77"]) assert result.shelter_factor == pytest.approx(cells["U79"]) assert result.shelter_adjusted_ach == pytest.approx(cells["U81"]) # Monthly wind speed (22)m expected_22 = tuple(cells[c] for c in ("G86","H86","I86","J86","K86","L86","M86","N86","O86","P86","Q86","R86")) expected_22a = tuple(cells[c] for c in ("G89","H89","I89","J89","K89","L89","M89","N89","O89","P89","Q89","R89")) expected_22b = tuple(cells[c] for c in ("G92","H92","I92","J92","K92","L92","M92","N92","O92","P92","Q92","R92")) expected_24c = tuple(cells[c] for c in ("G109","H109","I109","J109","K109","L109","M109","N109","O109","P109","Q109","R109")) expected_25 = tuple(cells[c] for c in ("G115","H115","I115","J115","K115","L115","M115","N115","O115","P115","Q115","R115")) for i in range(12): assert result.monthly_wind_speed_m_s[i] == pytest.approx(expected_22[i]) assert result.monthly_wind_factor[i] == pytest.approx(expected_22a[i]) assert result.monthly_wind_adjusted_ach[i] == pytest.approx(expected_22b[i]) # The extract/PIV-outside path makes (24c)m = (25)m here. assert result.effective_monthly_ach[i] == pytest.approx(expected_24c[i]) assert result.effective_monthly_ach[i] == pytest.approx(expected_25[i]) # MV system (23a, 23b) assert result.mv_system_ach == pytest.approx(cells["U96"]) assert result.mv_system_ach_after_fmv == pytest.approx(cells["U98"]) from types import ModuleType # noqa: E402 from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import ( # noqa: E402 ALL_FIXTURES as _ELMHURST_FIXTURES, fixture_id as _elmhurst_fixture_id, ) @pytest.mark.parametrize("fixture", _ELMHURST_FIXTURES, ids=_elmhurst_fixture_id) def test_section_2_matches_elmhurst_worksheet(fixture: ModuleType) -> None: """Real Elmhurst SAP10.2 worksheets — asserts every populated §2 line ref against the worksheet output for each registered fixture. `sheltered_sides` and HAS_SUSPENDED_TIMBER_FLOOR vary per cert and are carried on the fixture. `storey_count` is now derived from `dims.storey_count` (post-max-semantic fix); the LINE_9_STOREYS field on each fixture cross-checks that the derivation matches the worksheet's (9) value. """ # Arrange from domain.sap10_calculator.worksheet.dimensions import dimensions_from_cert dims = dimensions_from_cert(fixture.build_epc()) assert dims.storey_count == fixture.LINE_9_STOREYS, ( f"dims.storey_count={dims.storey_count} should equal worksheet (9) " f"= {fixture.LINE_9_STOREYS}" ) # Act result = ventilation_from_inputs( volume_m3=dims.volume_m3, storey_count=dims.storey_count, is_timber_or_steel_frame=False, intermittent_fans=fixture.INTERMITTENT_FANS, has_suspended_timber_floor=fixture.HAS_SUSPENDED_TIMBER_FLOOR, suspended_timber_floor_sealed=fixture.SUSPENDED_TIMBER_FLOOR_SEALED, has_draught_lobby=fixture.HAS_DRAUGHT_LOBBY, window_pct_draught_proofed=fixture.WINDOW_PCT_DRAUGHT_PROOFED, sheltered_sides=fixture.LINE_19_SHELTERED_SIDES, mv_kind=fixture.MV_KIND, ) # Assert — line-by-line vs Elmhurst output. All pins ride at abs=1e-4 # (PDF 4-d.p. display floor); cascade lands at ~5e-5 for monthly tuples. assert result.openings_ach == pytest.approx(fixture.LINE_8_OPENINGS_ACH, abs=1e-4) assert result.additional_ach == pytest.approx(fixture.LINE_10_ADDITIONAL_ACH, abs=1e-4) assert result.structural_ach == pytest.approx(fixture.LINE_11_STRUCTURAL_ACH, abs=1e-4) assert result.floor_ach == pytest.approx(fixture.LINE_12_FLOOR_ACH, abs=1e-4) assert result.draught_lobby_ach == pytest.approx(fixture.LINE_13_DRAUGHT_LOBBY_ACH, abs=1e-4) assert result.window_ach == pytest.approx(fixture.LINE_15_WINDOW_ACH, abs=1e-4) assert result.infiltration_rate_ach == pytest.approx(fixture.LINE_16_INFILTRATION_RATE_ACH, abs=1e-4) assert result.pressure_test_ach == pytest.approx(fixture.LINE_18_PRESSURE_TEST_ACH, abs=1e-4) assert result.shelter_factor == pytest.approx(fixture.LINE_20_SHELTER_FACTOR, abs=1e-4) assert result.shelter_adjusted_ach == pytest.approx(fixture.LINE_21_SHELTER_ADJUSTED_ACH, abs=1e-4) # Monthly arrays — every month at abs=1e-4 (PDF 4-d.p. display). for i in range(12): assert result.monthly_wind_speed_m_s[i] == pytest.approx(fixture.LINE_22_WIND_SPEED_M_S[i], abs=1e-4) assert result.monthly_wind_factor[i] == pytest.approx(fixture.LINE_22A_WIND_FACTOR[i], abs=1e-4) assert result.monthly_wind_adjusted_ach[i] == pytest.approx(fixture.LINE_22B_WIND_ADJUSTED_ACH[i], abs=1e-4) assert result.effective_monthly_ach[i] == pytest.approx(fixture.LINE_25_EFFECTIVE_ACH[i], abs=1e-4) def test_table_u2_default_matches_worksheet_g86_to_r86() -> None: """The TABLE_U2_NON_REGIONAL_WIND_SPEED_M_S constant must match the `NonRegionalWeather` sheet row 86 (G86..R86) so RdSAP runs without regional weather lookup still produce spec-correct (22)m.""" # Arrange — pull the 12 cells from the worksheet cells = load_cells( "NonRegionalWeather", ["G86", "H86", "I86", "J86", "K86", "L86", "M86", "N86", "O86", "P86", "Q86", "R86"], ) expected = (cells["G86"], cells["H86"], cells["I86"], cells["J86"], cells["K86"], cells["L86"], cells["M86"], cells["N86"], cells["O86"], cells["P86"], cells["Q86"], cells["R86"]) # Act / Assert — constant matches sheet exactly. assert TABLE_U2_NON_REGIONAL_WIND_SPEED_M_S == pytest.approx(expected)