Model/domain/sap10_calculator/worksheet/ventilation.py
Khalim Conn-Kowlessar 29ac35ccbe refactor: lift-and-shift packages/domain/src/domain/sap → domain/sap10_calculator
Migration of the SAP 10.2 calculator package from the uv-workspace
src-layout (`packages/domain/src/domain/sap`) to the root-level layout
(`domain/sap10_calculator`), matching the pattern already used by
`domain.addresses` / `domain.tasks` / `domain.postcode`.

Changes:

- `git mv packages/domain/src/domain/sap → domain/sap10_calculator`
  (92 files; git auto-detected all as renames so blame/history is
  preserved).
- Subpackage rename: `domain.sap` → `domain.sap10_calculator`. 48
  Python files rewritten (`from domain.sap.X` → `from domain.sap10_
  calculator.X`); zero remaining `domain.sap` refs after the sed pass.
- Path-string updates: 3 .py files (test fixtures + xlsx loader) +
  6 markdown docs (CONTEXT.md, 2 ADRs, 3 sap-spec docs, sap10_
  calculator/README.md) had hard-coded `packages/domain/src/domain/
  sap/...` paths rewritten to `domain/sap10_calculator/...`.
- `Path(__file__).parents[N]` rebasing: the old tree was 3 levels
  deeper than the new one (`packages/domain/src/`), so 4× `parents[7]`
  became `parents[4]` and 1× `parents[6]` became `parents[3]` across
  `tables/pcdb/{__init__.py, postcode_weather.py, etl.py}`,
  `worksheet/tests/_xlsx_loader.py`, and `tests/test_pcdb_etl.py`.
- PEP 420 namespace package: deleted both `domain/__init__.py`
  (root + workspace, both load-bearing only as empty/docstring) so
  Python combines `domain.sap10_calculator` (root) and `domain.ml`
  (workspace) into one namespace package. Confirmed via
  `domain.__path__ == ['/workspaces/model/domain',
  '/workspaces/model/packages/domain/src/domain']`. Without this,
  the root `domain/__init__.py` shadowed the workspace one and
  `domain.ml` was unreachable.

Verified:

- Full sweep (`backend/documents_parser/tests/test_summary_pdf_
  mapper_chain.py + domain/sap10_calculator/worksheet/tests/test_
  e2e_elmhurst_sap_score.py + domain/sap10_calculator/rdsap/tests/
  test_golden_fixtures.py`): 99 passed / 19 failed — exact same
  counts as pre-refactor. All 19 failures pre-existing (9 hand-built
  001479 + 6 cohort diff + 4 cohort chain non-spec).
- Wider sweep (all sap10_calculator + domain.ml): 1654 passed /
  20 failed (the +1 vs the focused sweep is the pre-existing
  `test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_
  section_5_11_4` which was already failing on the previous baseline).
- Pyright net-zero on the three load-bearing baselines:
  `heat_transmission.py` 13, `cert_to_inputs.py` 35, `mapper.py` 33.

Lift-and-shift only — no semantic renames (`Sap10Calculator` stays
`Sap10Calculator`), no testpaths edits in pytest.ini (sap tests
continue to be invoked by explicit pytest paths).

Note: `domain.ml` still lives at `packages/domain/src/domain/ml/`.
Migrating it would close out the dual-`domain/` layout but is
out of scope for this commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:22:37 +00:00

270 lines
11 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.

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