diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index e0b4ef3b..b9745fd0 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py b/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py index 86fef5d5..172ff27a 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py @@ -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}" + )