Slice 21c: §2 cascade pins + ventilation_from_cert helper — 96/96 PASS

Refactors the inline `ventilation_from_inputs(...)` block in
`cert_to_inputs` into a public `ventilation_from_cert(epc)` helper that
returns the full `VentilationResult`. Same cascade path, now reachable
from tests without duplicating the cert→inputs argument plumbing.

Adds §2 cascade pins to `test_section_cascade_pins.py` at abs=1e-4:

  scalar (11 line refs × 6 fixtures = 66 pins):
    (8)  openings_ach, (10) additional, (11) structural, (12) floor,
    (13) draught_lobby, (14) % draught proofed, (15) window,
    (16) infiltration_rate, (18) pressure_test, (20) shelter_factor,
    (21) shelter_adjusted_ach
  monthly (4 line refs × 6 × 12 months = 288 per-month assertions
   across 24 parametrized cases):
    (22) wind_speed, (22a) wind_factor, (22b) wind_adjusted_ach,
    (25) effective_monthly_ach
  integer (1 line ref × 6):
    (19) sheltered_sides

96 §2 cases all PASS (108 total when including §1). The cert→inputs
ventilation cascade reproduces the U985 PDF exactly across every line
ref for every fixture — a strong floor for the downstream §3-§12
cascade.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 23:10:42 +00:00
parent c147233072
commit 5b7dbe2c21
2 changed files with 143 additions and 47 deletions

View file

@ -94,6 +94,7 @@ from domain.sap.worksheet.space_cooling import space_cooling_monthly_kwh
from domain.sap.worksheet.space_heating import space_heating_monthly_kwh
from domain.sap.worksheet.ventilation import (
MechanicalVentilationKind,
VentilationResult,
ventilation_from_inputs,
)
from domain.sap.worksheet.water_heating import (
@ -689,6 +690,45 @@ def _ventilation_counts(vent: Optional[SapVentilation]) -> _VentilationCounts:
)
def ventilation_from_cert(epc: EpcPropertyData) -> VentilationResult:
"""SAP 10.2 §2 cert→inputs cascade for `ventilation_from_inputs`.
Reads dimensions + sap_ventilation lodgement from `epc` and produces
the full `VentilationResult` (every (6a)..(25)m line ref) the
exact same call cert_to_inputs makes internally. Exposed so cascade
pin tests can assert every §2 line ref against the U985 PDF.
Defaults track the same conventions as cert_to_inputs (sheltered
sides 2 when missing, MV kind NATURAL until certMV mapping is
documented).
"""
dim = dimensions_from_cert(epc)
vol = dim.volume_m3 if dim.volume_m3 > 0 else 1.0
storeys = max(1, dim.storey_count)
vc = _ventilation_counts(epc.sap_ventilation)
sv = epc.sap_ventilation
return ventilation_from_inputs(
volume_m3=vol,
storey_count=storeys,
is_timber_or_steel_frame=_is_timber_or_steel_frame(epc.sap_building_parts),
open_chimneys=epc.open_chimneys_count or 0,
blocked_chimneys=epc.blocked_chimneys_count or 0,
open_flues=vc.open_flues,
closed_fire_chimneys=vc.closed_fire_chimneys,
solid_fuel_boiler_chimneys=vc.solid_fuel_boiler_chimneys,
other_heater_chimneys=vc.other_heater_chimneys,
intermittent_fans=vc.intermittent_fans,
passive_vents=vc.passive_vents,
flueless_gas_fires=vc.flueless_gas_fires,
has_suspended_timber_floor=bool(sv.has_suspended_timber_floor) if sv is not None and sv.has_suspended_timber_floor is not None else False,
suspended_timber_floor_sealed=bool(sv.suspended_timber_floor_sealed) if sv is not None and sv.suspended_timber_floor_sealed is not None else False,
has_draught_lobby=bool(sv.has_draught_lobby) if sv is not None and sv.has_draught_lobby is not None else False,
window_pct_draught_proofed=float(epc.percent_draughtproofed or 0),
sheltered_sides=int(sv.sheltered_sides) if sv is not None and sv.sheltered_sides is not None else 2,
mv_kind=MechanicalVentilationKind.NATURAL,
)
# SAP 10.2 Table J4 — default mixer-shower flow rate for an existing
# dwelling with a vented hot water system (the existing-dwelling minimum).
# Both validation worksheets (000474 + 000490) lodge this value. Combi-
@ -1022,52 +1062,9 @@ def cert_to_inputs(
exposure=exposure,
)
vol = dim.volume_m3 if dim.volume_m3 > 0 else 1.0
storeys = max(1, dim.storey_count)
vc = _ventilation_counts(epc.sap_ventilation)
# SAP §2 ventilation: full worksheet (6a)..(25)m via VentilationResult.
# The following cert→input ambiguities are intentionally papered over
# with spec-default values for now; each TODO is a tracked follow-up
# for when the mapping rule becomes available:
#
# TODO(cert→ventilation 1): `epc.mechanical_ventilation: int` carries
# a code (0..N) selecting Natural / MVHR / MV / Extract / PIV-outside
# / PIV-loft. The int→enum mapping isn't in the SAP10.2 or RdSAP10
# PDFs we have; Elmhurst likely uses the same code list. We pin
# NATURAL until we have a documented mapping or a golden cert that
# exercises an MV path.
# `has_suspended_timber_floor` / `_sealed`, `has_draught_lobby`, and
# `sheltered_sides` are now sourced from `epc.sap_ventilation` cert
# lodgements (added to the SapVentilation schema). Falls back to the
# SAP10.2 §2 "worst-case" defaults when the cert hasn't lodged.
# TODO(cert→ventilation 4): `air_permeability_ap50` / `ap4` from a
# pressure test — cert has `pressure_test: int` (code, not a value)
# and `air_tightness: {description,...}`. Likely only present on
# SAP (new-build) certs, not RdSAP. Defaulted to None (no test).
# TODO(cert→ventilation 6): `monthly_wind_speed_m_s` defaults to
# Table U2 non-regional. Should select the regional row keyed by
# `epc.region_code` once regional weather is wired in.
sv = epc.sap_ventilation
ventilation = ventilation_from_inputs(
volume_m3=vol,
storey_count=storeys,
is_timber_or_steel_frame=_is_timber_or_steel_frame(epc.sap_building_parts),
open_chimneys=epc.open_chimneys_count or 0,
blocked_chimneys=epc.blocked_chimneys_count or 0,
open_flues=vc.open_flues,
closed_fire_chimneys=vc.closed_fire_chimneys,
solid_fuel_boiler_chimneys=vc.solid_fuel_boiler_chimneys,
other_heater_chimneys=vc.other_heater_chimneys,
intermittent_fans=vc.intermittent_fans,
passive_vents=vc.passive_vents,
flueless_gas_fires=vc.flueless_gas_fires,
has_suspended_timber_floor=bool(sv.has_suspended_timber_floor) if sv is not None and sv.has_suspended_timber_floor is not None else False,
suspended_timber_floor_sealed=bool(sv.suspended_timber_floor_sealed) if sv is not None and sv.suspended_timber_floor_sealed is not None else False,
has_draught_lobby=bool(sv.has_draught_lobby) if sv is not None and sv.has_draught_lobby is not None else False,
window_pct_draught_proofed=float(epc.percent_draughtproofed or 0),
sheltered_sides=int(sv.sheltered_sides) if sv is not None and sv.sheltered_sides is not None else 2,
mv_kind=MechanicalVentilationKind.NATURAL,
)
# SAP §2 ventilation cascade — see `ventilation_from_cert` for the
# cert→inputs mapping rules + spec-default conventions.
ventilation = ventilation_from_cert(epc)
main = _first_main_heating(epc)
main_code = main.sap_main_heating_code if main is not None else None

View file

@ -16,7 +16,7 @@ from typing import Final
import pytest
from domain.sap.rdsap.cert_to_inputs import cert_to_inputs
from domain.sap.rdsap.cert_to_inputs import cert_to_inputs, ventilation_from_cert
from domain.sap.worksheet.dimensions import dimensions_from_cert
from domain.sap.worksheet.tests import (
_elmhurst_worksheet_000474 as _w000474,
@ -88,3 +88,102 @@ def test_section_1_line_5_volume_matches_pdf(fixture_name: str) -> None:
mod.LINE_5_VOLUME_M3, # type: ignore[attr-defined]
f"§1 (5) {fixture_name}",
)
# ============================================================================
# §2 Ventilation rate — LINE_8..LINE_25 scalar + monthly tuple line refs
# ============================================================================
_SECTION_2_SCALAR_PINS: Final[tuple[tuple[str, str], ...]] = (
# (fixture_attr, VentilationResult attr)
("LINE_8_OPENINGS_ACH", "openings_ach"),
("LINE_10_ADDITIONAL_ACH", "additional_ach"),
("LINE_11_STRUCTURAL_ACH", "structural_ach"),
("LINE_12_FLOOR_ACH", "floor_ach"),
("LINE_13_DRAUGHT_LOBBY_ACH", "draught_lobby_ach"),
("LINE_14_PCT_DRAUGHT_PROOFED", "window_pct_draught_proofed"),
("LINE_15_WINDOW_ACH", "window_ach"),
("LINE_16_INFILTRATION_RATE_ACH", "infiltration_rate_ach"),
("LINE_18_PRESSURE_TEST_ACH", "pressure_test_ach"),
("LINE_20_SHELTER_FACTOR", "shelter_factor"),
("LINE_21_SHELTER_ADJUSTED_ACH", "shelter_adjusted_ach"),
)
_SECTION_2_MONTHLY_PINS: Final[tuple[tuple[str, str], ...]] = (
("LINE_22_WIND_SPEED_M_S", "monthly_wind_speed_m_s"),
("LINE_22A_WIND_FACTOR", "monthly_wind_factor"),
("LINE_22B_WIND_ADJUSTED_ACH", "monthly_wind_adjusted_ach"),
("LINE_25_EFFECTIVE_ACH", "effective_monthly_ach"),
)
@pytest.mark.parametrize(
"fixture_name,fixture_attr,result_attr",
[
(fix, line, attr)
for fix in _FIXTURES
for line, attr in _SECTION_2_SCALAR_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_section_2_scalar_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""§2 scalar pins — every infiltration / shelter line ref matches
the U985 PDF to abs=1e-4."""
# Arrange
mod = _FIXTURES[fixture_name]
epc = mod.build_epc() # type: ignore[attr-defined]
expected = getattr(mod, fixture_attr)
# Act
vent = ventilation_from_cert(epc)
actual = getattr(vent, result_attr)
# Assert
_pin(actual, expected, f"§2 {fixture_attr} {fixture_name}")
@pytest.mark.parametrize(
"fixture_name,fixture_attr,result_attr",
[
(fix, line, attr)
for fix in _FIXTURES
for line, attr in _SECTION_2_MONTHLY_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_section_2_monthly_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""§2 monthly pins — every Jan..Dec value of (22)/(22a)/(22b)/(25)
matches the U985 PDF to abs=1e-4."""
# Arrange
mod = _FIXTURES[fixture_name]
epc = mod.build_epc() # type: ignore[attr-defined]
expected = getattr(mod, fixture_attr)
# Act
vent = ventilation_from_cert(epc)
actual = getattr(vent, result_attr)
# Assert — per-month with explicit indices so failures show the bad month
for m in range(12):
_pin(actual[m], expected[m], f"§2 {fixture_attr}[{m+1}] {fixture_name}")
@pytest.mark.parametrize("fixture_name", list(_FIXTURES), ids=lambda x: x)
def test_section_2_line_19_sheltered_sides_matches_pdf(fixture_name: str) -> None:
"""§2 (19) — sheltered sides is integer, exact equality."""
# Arrange
mod = _FIXTURES[fixture_name]
epc = mod.build_epc() # type: ignore[attr-defined]
expected = mod.LINE_19_SHELTERED_SIDES # type: ignore[attr-defined]
# Act
vent = ventilation_from_cert(epc)
# Assert
assert vent.sheltered_sides == expected, (
f"§2 (19) {fixture_name}: actual={vent.sheltered_sides}, expected={expected}"
)