mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
539 lines
23 KiB
Python
539 lines
23 KiB
Python
"""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)
|