mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
c147233072
commit
5b7dbe2c21
2 changed files with 143 additions and 47 deletions
|
|
@ -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 cert→MV 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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue