"""SAP 10.2 §2 + RdSAP10 §4.1 — ventilation rate worksheet. Ports every worksheet line of §2: openings (6a)-(7c), infiltration (8), non-pressure-test components (10)-(16), pressure-test override (17)-(18), shelter (19)-(21), monthly wind adjustment (22)-(22b), and mechanical ventilation modes (23a)-(24d) → final monthly (25)m. Per-line accessors on `VentilationResult` let callers audit the computation against the SAP10.2 worksheet by line number. The calculator consumes `effective_monthly_ach` directly so the §3-(38) monthly HLC reflects wind-adjusted, MV-mode-specific ventilation — not a single annual scalar. Reference: - SAP 10.2 specification (14-03-2025) §2 (pages 12-17) - RdSAP10 specification (June 2025) §4.1 Table 5 (pages 27-30) - Canonical worked example at `2026-05-19-17-18 RdSap10Worksheet.xlsx`, `NonRegionalWeather` sheet, rows 27-121 """ from __future__ import annotations from dataclasses import dataclass from enum import Enum from typing import Final, Optional # 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 # Table U2 (non-regional) — monthly average wind speed at 10m, m/s, Jan-Dec. # Source: worksheet `NonRegionalWeather` row 86 (cells G86..R86). TABLE_U2_NON_REGIONAL_WIND_SPEED_M_S: Final[tuple[float, ...]] = ( 5.1, 5.0, 4.9, 4.4, 4.3, 3.8, 3.8, 3.7, 4.0, 4.3, 4.5, 4.7, ) class MechanicalVentilationKind(Enum): """SAP10.2 worksheet (24a)-(24d) mechanical-ventilation categories. - NATURAL: natural ventilation OR positive input ventilation from the loft → equation (24d)m. The default for dwellings with no MV system installed. - MVHR: balanced mechanical ventilation with heat recovery → equation (24a)m. Requires `mvhr_efficiency_pct` from PCDB. - MV: balanced mechanical ventilation without heat recovery → equation (24b)m. - EXTRACT_OR_PIV_OUTSIDE: whole-house extract ventilation OR positive input ventilation from OUTSIDE → equation (24c)m. """ NATURAL = "natural" MVHR = "mvhr" MV = "mv" EXTRACT_OR_PIV_OUTSIDE = "extract_or_piv_outside" @dataclass(frozen=True) class VentilationResult: """Every SAP10.2 §2 worksheet line — `(6a)` through `(25)m`. Fields are organised in worksheet order so a reader can locate each one in the canonical xlsx without ambiguity.""" # Lines (6a)-(7c) — openings in m³/hour. open_chimneys_m3_h: float # (6a) open_flues_m3_h: float # (6b) closed_fire_chimneys_m3_h: float # (6c) solid_fuel_boiler_m3_h: float # (6d) other_heater_m3_h: float # (6e) blocked_chimneys_m3_h: float # (6f) intermittent_fans_m3_h: float # (7a) passive_vents_m3_h: float # (7b) flueless_gas_fires_m3_h: float # (7c) # Line (8) — Σ openings ÷ dwelling volume (ach). openings_ach: float # Lines (10)-(15) — infiltration components (ach). additional_ach: float # (10) = (storeys − 1) × 0.1 structural_ach: float # (11) 0.25 frame / 0.35 masonry floor_ach: float # (12) suspended timber adjustment draught_lobby_ach: float # (13) 0.05 when absent, else 0 window_pct_draught_proofed: float # (14) % windows/doors DP window_ach: float # (15) 0.25 − 0.2 × (14)/100 # Line (16) — pre-pressure-test infiltration rate (ach). infiltration_rate_ach: float # Lines (17)-(18) — pressure test override. air_permeability_ap50: Optional[float] # (17) air_permeability_ap4: Optional[float] # (17a) pressure_test_ach: float # (18) # Lines (19)-(21) — shelter factor. sheltered_sides: int # (19) shelter_factor: float # (20) = 1 − 0.075 × (19) shelter_adjusted_ach: float # (21) = (18) × (20) # Lines (22)-(22b) — monthly wind adjustment (Jan..Dec). monthly_wind_speed_m_s: tuple[float, ...] # (22)m monthly_wind_factor: tuple[float, ...] # (22a)m = (22)m ÷ 4 monthly_wind_adjusted_ach: tuple[float, ...] # (22b)m = (21) × (22a)m # Lines (23)-(25) — mechanical ventilation + final monthly rate. mv_kind: MechanicalVentilationKind mv_system_ach: float # (23a) mv_system_ach_after_fmv: float # (23b) mvhr_efficiency_pct: Optional[float] # (23c) — None when not MVHR effective_monthly_ach: tuple[float, ...] # (25)m — final answer def ventilation_from_inputs( *, 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, air_permeability_ap50: Optional[float] = None, air_permeability_ap4: Optional[float] = None, sheltered_sides: int = 2, monthly_wind_speed_m_s: tuple[float, ...] = TABLE_U2_NON_REGIONAL_WIND_SPEED_M_S, mv_kind: MechanicalVentilationKind = MechanicalVentilationKind.NATURAL, mv_system_ach: float = 0.0, mv_fmv_factor: float = 1.0, mvhr_efficiency_pct: Optional[float] = None, ) -> VentilationResult: """Build a `VentilationResult` from a single dwelling's inputs. `sheltered_sides` defaults to 2 (typical UK terraced/semi-detached); the cert doesn't lodge this value so callers should match the spec convention. `monthly_wind_speed_m_s` defaults to Table U2 (non-regional) so RdSAP runs with no regional weather lookup still produce spec-correct (22b)m / (25)m values. """ if volume_m3 <= 0: raise ValueError(f"volume_m3 must be > 0, got {volume_m3}") if len(monthly_wind_speed_m_s) != 12: raise ValueError( f"monthly_wind_speed_m_s must have 12 entries, got {len(monthly_wind_speed_m_s)}" ) # Lines (6a)-(7c): m³/h per opening type × Table 2.1 rate. open_chim = open_chimneys * _OPEN_CHIMNEY_M3_H open_flue = open_flues * _OPEN_FLUE_M3_H closed_fire = closed_fire_chimneys * _CLOSED_FIRE_CHIMNEY_M3_H solid_fuel = solid_fuel_boiler_chimneys * _SOLID_FUEL_BOILER_CHIMNEY_M3_H other_heater = other_heater_chimneys * _OTHER_HEATER_CHIMNEY_M3_H blocked = blocked_chimneys * _BLOCKED_CHIMNEY_M3_H int_fans = intermittent_fans * _INTERMITTENT_FAN_M3_H pas_vents = passive_vents * _PASSIVE_VENT_M3_H flueless = flueless_gas_fires * _FLUELESS_GAS_FIRE_M3_H # Line (8): Σ (6a..6f)+(7a..7c) ÷ volume. total_openings_m3_h = ( open_chim + open_flue + closed_fire + solid_fuel + other_heater + blocked + int_fans + pas_vents + flueless ) openings_ach = total_openings_m3_h / volume_m3 # Lines (10)-(15). 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) # Line (16) — sum (8) + (10) + (11) + (12) + (13) + (15). line_16 = openings_ach + additional + structural + floor + draught_lobby + window # Lines (17)-(18) — pressure-test override (AP50 preferred over AP4). if air_permeability_ap50 is not None: line_18 = air_permeability_ap50 / 20.0 + openings_ach elif air_permeability_ap4 is not None: line_18 = 0.263 * (air_permeability_ap4 ** 0.924) + openings_ach else: line_18 = line_16 # Lines (19)-(21) — shelter factor (clamped 0..4 sides per spec). clamped_sides = max(0, min(4, sheltered_sides)) shelter_factor = 1.0 - 0.075 * clamped_sides line_21 = line_18 * shelter_factor # Lines (22)-(22b) — monthly wind adjustment from Table U2. monthly_wind_factor = tuple(w / 4.0 for w in monthly_wind_speed_m_s) monthly_22b = tuple(line_21 * f for f in monthly_wind_factor) # Lines (23a)-(23b) — MV system air-change rate. line_23a = mv_system_ach line_23b = line_23a * mv_fmv_factor # Lines (24a)-(24d) → (25)m — pick the formula matching mv_kind. monthly_25: tuple[float, ...] if mv_kind is MechanicalVentilationKind.MVHR: # (24a)m = (22b)m + (23b) × [1 - (23c)/100] eff = (mvhr_efficiency_pct or 0.0) / 100.0 monthly_25 = tuple(w + line_23b * (1.0 - eff) for w in monthly_22b) elif mv_kind is MechanicalVentilationKind.MV: # (24b)m = (22b)m + (23b) monthly_25 = tuple(w + line_23b for w in monthly_22b) elif mv_kind is MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE: # (24c)m: if (22b)m < 0.5 × (23b) → (23b); else (22b)m + 0.5 × (23b) monthly_25 = tuple( line_23b if w < 0.5 * line_23b else w + 0.5 * line_23b for w in monthly_22b ) else: # NATURAL # (24d)m: if (22b)m ≥ 1 → (22b)m; else 0.5 + (22b)m² / 2 monthly_25 = tuple( w if w >= 1.0 else 0.5 + (w ** 2) * 0.5 for w in monthly_22b ) return VentilationResult( open_chimneys_m3_h=open_chim, open_flues_m3_h=open_flue, closed_fire_chimneys_m3_h=closed_fire, solid_fuel_boiler_m3_h=solid_fuel, other_heater_m3_h=other_heater, blocked_chimneys_m3_h=blocked, intermittent_fans_m3_h=int_fans, passive_vents_m3_h=pas_vents, flueless_gas_fires_m3_h=flueless, openings_ach=openings_ach, additional_ach=additional, structural_ach=structural, floor_ach=floor, draught_lobby_ach=draught_lobby, window_pct_draught_proofed=window_pct_draught_proofed, window_ach=window, infiltration_rate_ach=line_16, air_permeability_ap50=air_permeability_ap50, air_permeability_ap4=air_permeability_ap4, pressure_test_ach=line_18, sheltered_sides=clamped_sides, shelter_factor=shelter_factor, shelter_adjusted_ach=line_21, monthly_wind_speed_m_s=tuple(monthly_wind_speed_m_s), monthly_wind_factor=monthly_wind_factor, monthly_wind_adjusted_ach=monthly_22b, mv_kind=mv_kind, mv_system_ach=line_23a, mv_system_ach_after_fmv=line_23b, mvhr_efficiency_pct=mvhr_efficiency_pct, effective_monthly_ach=monthly_25, )