Model/tests/domain/sap10_calculator/worksheet/test_ventilation.py
Khalim Conn-Kowlessar d7d5084f90 Move sap10_calculator tests to tests/domain/sap10_calculator/ for CI
The calculator tests lived under domain/sap10_calculator/{tests,worksheet/
tests,rdsap/tests,climate/tests,validation/tests}, none of which are in
pytest.ini testpaths — so CI (which collects tests/) never ran them. Relocate
all five dirs to tests/domain/sap10_calculator/{,worksheet,rdsap,climate,
validation}, mirroring the tests/domain/property_baseline/ convention, so the
cascade-pin / golden / e2e conformance suites run in CI.

Mechanics:
- git mv preserves history (110 files).
- Flattening the trailing /tests keeps each file's depth-to-repo-root
  identical, so all 16 repo-root parents[4] fixture refs stay valid. Only
  test_pcdb_etl.py's parents[1] (→ pcdb data) and one hardcoded absolute
  golden-fixture path in test_cert_to_inputs.py needed rebasing.
- Cross-imports rewritten domain.sap10_calculator.worksheet.tests →
  tests.domain.sap10_calculator.worksheet (21 files incl. the external
  importer backend/documents_parser/tests/test_summary_pdf_mapper_chain.py).
- Golden-fixture path strings in test_summary_pdf_mapper_chain.py +
  scripts/fetch_cohort2_api_jsons.py updated to the new location (the JSONs
  moved with the rdsap tests).

load_cells / gitignored worksheet xlsx: the xlsx-pinned tests (test_dimensions
/ ventilation / water_heating) read 2026-05-19-17-18 RdSap10Worksheet.xlsx,
which is gitignored (.gitignore `*.xlsx`) and so absent in CI. _xlsx_loader.
load_cells now pytest.skip()s when the file is absent, so those tests run
locally and skip cleanly in CI instead of erroring — no new CI failures from
the move, and the gitignore policy is respected.

Verified: tests/domain/sap10_calculator + backend/documents_parser +
tests/domain/property_baseline = 2248 pass, 1 skipped; pyright resolves the
new import paths with zero import-resolution errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:58:00 +00:00

539 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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