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