diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 6fa7a745..c23c19a4 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -1061,6 +1061,29 @@ class ElmhurstSiteNotesExtractor: return glazing_type, building_part, orientation def _extract_ventilation(self) -> VentilationAndCooling: + # SAP 10.2 §2 (17a) "Air permeability value, AP4". Scoped to + # §12.2..§13.0 so the per-window U-values + door U-values can't + # shadow the float read. Absent when `pressure_test_method != + # "Pulse"` (the modal cohort lodgement). + pressure_lines = self._section_lines( + "12.2 Air Pressure Test", "13.0 Lighting" + ) + ap4_raw = self._local_val(pressure_lines, "Pressure Test Result (AP4)") + air_permeability_ap4_m3_h_m2: Optional[float] = None + if ap4_raw: + try: + air_permeability_ap4_m3_h_m2 = float(ap4_raw.split()[0]) + except (ValueError, IndexError): + air_permeability_ap4_m3_h_m2 = None + # Summary §12.1 "Mechanical Ventilation Type" — scoped to §12.1 + # body so the global "Type" labels in §14 / §15 can't shadow it. + mv_lines = self._section_lines( + "12.1 Mechanical Ventilation", "12.2 Air Pressure Test" + ) + mv_type_raw = self._local_val(mv_lines, "Mechanical Ventilation Type") + mechanical_ventilation_type = ( + " ".join(mv_type_raw.split()) if mv_type_raw else None + ) return VentilationAndCooling( open_chimneys_count=self._int_val("No. of open chimneys"), open_flues_count=self._int_val("No. of open flues"), @@ -1081,6 +1104,8 @@ class ElmhurstSiteNotesExtractor: draught_lobby=self._str_val("Draught Lobby"), mechanical_ventilation=self._bool_val("Mechanical Ventilation"), pressure_test_method=self._str_val("Test Method"), + air_permeability_ap4_m3_h_m2=air_permeability_ap4_m3_h_m2, + mechanical_ventilation_type=mechanical_ventilation_type, ) def _extract_lighting(self) -> Lighting: diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index e2cea9a9..36816402 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1584,6 +1584,88 @@ def test_summary_000565_ext1_party_wall_cf_routes_to_u_value_0p2() -> None: assert abs(u - 0.2) <= 1e-4 +def test_summary_000565_section_12_2_pulse_pressure_test_ap4_extracted() -> None: + # Arrange — cert 000565 §12.2 Air Pressure Test lodges: + # Test Method: Pulse + # Pressure Test Result (AP4): 2.00 + # SAP 10.2 §2 line (17a) "Air permeability value, AP4, (m³/h/m²)" is + # the measured air permeability at 4 Pa from the low-pressure pulse + # technique. The cascade's `ventilation_from_inputs(air_permeability + # _ap4=...)` consumes it via line (18) = 0.263 × AP4^0.924 + (8). + # Pre-slice the extractor read only the Test Method string and + # silently dropped the AP4 value, so the cascade fell back to the + # components-based (16) infiltration rate (+0.375 ach over worksheet). + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + + # Act + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Assert + assert site_notes.ventilation.pressure_test_method == "Pulse" + ap4 = site_notes.ventilation.air_permeability_ap4_m3_h_m2 + assert ap4 is not None + assert abs(ap4 - 2.0) <= 1e-4 + + +def test_summary_000565_air_permeability_ap4_routes_to_sap_ventilation_field() -> None: + # Arrange — mapper plumbing for SAP 10.2 §2 (17a). The Elmhurst + # `VentilationAndCooling.air_permeability_ap4_m3_h_m2` field carries + # through to `SapVentilation.air_permeability_ap4_m3_h_m2` so the + # `cert_to_inputs` ventilation cascade can read it and pass into + # `ventilation_from_inputs(air_permeability_ap4=...)`. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert + assert epc.sap_ventilation is not None + ap4 = epc.sap_ventilation.air_permeability_ap4_m3_h_m2 + assert ap4 is not None + assert abs(ap4 - 2.0) <= 1e-4 + + +def test_summary_000565_section_12_1_extracts_mechanical_extract_decentralised_mev_dc_kind() -> None: + # Arrange — cert 000565 §12.1 Mechanical Ventilation lodges: + # Mechanical Ventilation: Yes + # Mechanical Ventilation Type: Mechanical extract, decentralised + # (MEV dc) + # SAP 10.2 §2 line (23a) for MEV: "system throughput = 0.5 ach"; the + # effective ach formula (25) routes through (24c) "whole-house + # extract ventilation or PIV from outside" — `(22b)m + 0.5 × (23b)` + # when (22b) ≥ 0.5×(23b). Pre-slice the extractor read only the + # "Mechanical Ventilation" yes/no bool and dropped the Type string, + # so the cascade defaulted to mv_kind=NATURAL → (24d) formula. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + + # Act + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Assert + assert site_notes.ventilation.mechanical_ventilation is True + assert ( + site_notes.ventilation.mechanical_ventilation_type + == "Mechanical extract, decentralised (MEV dc)" + ) + + +def test_summary_000565_mev_decentralised_routes_to_extract_or_piv_outside_mv_kind() -> None: + # Arrange — mapper plumbing for SAP 10.2 §2 (23a)/(24c) MEV: the + # Elmhurst "Mechanical extract, decentralised (MEV dc)" string maps + # to `MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE` so the + # cascade picks the (24c) effective-ach formula. + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000565_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + + # Act + epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) + + # Assert + assert epc.sap_ventilation is not None + assert epc.sap_ventilation.mechanical_ventilation_kind == "EXTRACT_OR_PIV_OUTSIDE" + + def test_summary_mapper_raises_on_unmapped_wall_type_code() -> None: # Arrange — strict-coverage gate per [[reference-unmapped-api- # code]] mirror: an Elmhurst wall_type lodgement that isn't in diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index a126ec9e..08935fad 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -173,6 +173,16 @@ class SapVentilation: has_suspended_timber_floor: Optional[bool] = None # (12) gate suspended_timber_floor_sealed: Optional[bool] = None has_draught_lobby: Optional[bool] = None # (13) gate (overrides .draught_lobby for §2 cascade) + # SAP 10.2 §2 (17a) — air permeability at 4 Pa from the low-pressure + # Pulse pressure test, m³/h per m² of envelope area. When present the + # cascade routes (18) via the AP4 formula `0.263 × AP4^0.924 + (8)`. + air_permeability_ap4_m3_h_m2: Optional[float] = None + # SAP 10.2 §2 (23a)/(24a..d) — Elmhurst "Mechanical Ventilation Type" + # string mapped to the `MechanicalVentilationKind` enum name (e.g. + # "EXTRACT_OR_PIV_OUTSIDE" for MEV decentralised). The cascade uses + # this to pick the (25)m effective-ach formula; None defaults to the + # natural-ventilation (24d) branch. + mechanical_ventilation_kind: Optional[str] = None @dataclass diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 7a0d004a..1641362d 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4246,6 +4246,29 @@ def _elmhurst_sheltered_sides(built_form: str) -> Optional[int]: return _ELMHURST_SHELTERED_SIDES_BY_BUILT_FORM.get(built_form) +_ELMHURST_MV_TYPE_TO_KIND: Dict[str, str] = { + # Summary §12.1 "Mechanical Ventilation Type" → MechanicalVentilation + # Kind enum name. Mapped per SAP 10.2 §2 (24a..d) effective-ach + # formula choice; the cascade resolves enum name → enum value when + # picking which (25)m formula to apply. + "Mechanical extract, decentralised (MEV dc)": "EXTRACT_OR_PIV_OUTSIDE", +} + + +def _elmhurst_mv_kind(mv_type: Optional[str]) -> Optional[str]: + """Map the Elmhurst "Mechanical Ventilation Type" string to a + `MechanicalVentilationKind` enum name. Returns None when no MV is + lodged. Raises `UnmappedElmhurstLabel` on an unrecognised non-empty + string so the strict-coverage gate surfaces new MV variants at the + extractor boundary.""" + if mv_type is None or not mv_type.strip(): + return None + label = mv_type.strip() + if label not in _ELMHURST_MV_TYPE_TO_KIND: + raise UnmappedElmhurstLabel("ventilation.mechanical_ventilation_type", label) + return _ELMHURST_MV_TYPE_TO_KIND[label] + + def _map_elmhurst_ventilation( v: ElmhurstVentilation, built_form: str, @@ -4276,4 +4299,6 @@ def _map_elmhurst_ventilation( None if has_suspended_timber_floor is None else (False if has_suspended_timber_floor else None) ), + air_permeability_ap4_m3_h_m2=v.air_permeability_ap4_m3_h_m2, + mechanical_ventilation_kind=_elmhurst_mv_kind(v.mechanical_ventilation_type), ) diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 863846b7..127aee0d 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -183,6 +183,13 @@ class VentilationAndCooling: draught_lobby: str # e.g. "Not present" mechanical_ventilation: bool pressure_test_method: str # e.g. "Not available" + # SAP 10.2 §2 (17a) AP4 reading from §12.2 "Pressure Test Result + # (AP4)" — only present when `pressure_test_method == "Pulse"`. + air_permeability_ap4_m3_h_m2: Optional[float] = None + # Summary §12.1 "Mechanical Ventilation Type" — e.g. "Mechanical + # extract, decentralised (MEV dc)". None when `mechanical_ventilation + # is False` (no MV system). + mechanical_ventilation_type: Optional[str] = None @dataclass diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 4fefa7ce..6c9c89d2 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2739,6 +2739,28 @@ def ventilation_from_cert( if sv is not None and sv.suspended_timber_floor_sealed is not None else spec_sealed ) + # SAP 10.2 §2 (17a) — AP4 pressure-test reading routes to the + # cascade's `(18) = 0.263 × AP4^0.924 + (8)` formula; absent value + # falls through to the components-based (16) ach. + ap4 = sv.air_permeability_ap4_m3_h_m2 if sv is not None else None + # SAP 10.2 §2 (23a)/(24a..d) — MV kind dispatch chooses the (25)m + # effective-ach formula. The Elmhurst mapper translates the lodged + # "Mechanical Ventilation Type" string to an enum *name*; resolve + # back to the enum here. Unmapped names default to NATURAL (24d). + mv_kind = MechanicalVentilationKind.NATURAL + mv_system_ach = 0.0 + mv_kind_name = sv.mechanical_ventilation_kind if sv is not None else None + if mv_kind_name is not None: + try: + mv_kind = MechanicalVentilationKind[mv_kind_name] + except KeyError: + mv_kind = MechanicalVentilationKind.NATURAL + if mv_kind is not MechanicalVentilationKind.NATURAL: + # SAP 10.2 §2 (23a) "If mechanical ventilation: air change + # rate through system = 0.5" (PDF p.13). PCDB-lodged systems + # can override via a future plumbing slice; the spec default + # is what every MEV / MV / MVHR cohort cert lodges today. + mv_system_ach = 0.5 return ventilation_from_inputs( volume_m3=vol, storey_count=storeys, @@ -2757,7 +2779,9 @@ def ventilation_from_cert( 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, + air_permeability_ap4=ap4, + mv_kind=mv_kind, + mv_system_ach=mv_system_ach, **wind_kwargs, ) diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index fed55a29..91f97813 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -22,6 +22,7 @@ from datatypes.epc.domain.epc_property_data import ( MainHeatingDetail, PhotovoltaicArray, SapFloorDimension, + SapVentilation, ) from domain.sap10_ml.tests._fixtures import ( @@ -46,6 +47,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( cert_to_demand_inputs, cert_to_inputs, pcdb_combi_loss_override, + ventilation_from_cert, ) from domain.sap10_calculator.tables.pcdb import GasOilBoilerRecord, gas_oil_boiler_record from domain.sap10_calculator.worksheet.tests import _elmhurst_worksheet_000477 as _w000477 @@ -599,6 +601,73 @@ def test_main_floor_u_value_routes_suspended_timber_via_floor_construction_type( assert sealed is False +def test_ventilation_from_cert_passes_lodged_ap4_to_pressure_test_ach_per_sap_10_2_section_2_line_18() -> None: + # Arrange — SAP 10.2 §2 line (17a)/(18) "Air permeability value, AP4 + # (m³/h/m²)": when a Pulse pressure test is lodged the cascade must + # use `(18) = 0.263 × AP4^0.924 + (8)` instead of the components- + # based (16) fallback. Pre-slice S0380.92 the `ventilation_from_cert` + # cascade dropped the lodged AP4 value so cert 000565 fell through + # to (16) — over-counting infiltration by +0.375 ach. + base = _typical_semi_detached_epc() + with_ap4 = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + region_code="1", + sap_building_parts=base.sap_building_parts, + sap_windows=base.sap_windows, + sap_heating=base.sap_heating, + sap_ventilation=SapVentilation(air_permeability_ap4_m3_h_m2=2.0), + ) + + # Act + v_default = ventilation_from_cert(base) + v_ap4 = ventilation_from_cert(with_ap4) + + # Assert — `(18) = 0.263 × 2.0^0.924 + (8)` where (8) is the openings + # ach. The default (no pressure test) routes through (16) which sums + # in (10)..(15) — strictly larger than the AP4 path because (10)..(15) + # are all non-negative. + expected_line_18 = 0.263 * (2.0 ** 0.924) + v_ap4.openings_ach + assert abs(v_ap4.pressure_test_ach - expected_line_18) <= 1e-4 + assert v_ap4.pressure_test_ach < v_default.pressure_test_ach + + +def test_ventilation_from_cert_routes_mev_decentralised_to_extract_or_piv_outside_mv_kind() -> None: + # Arrange — SAP 10.2 §2 line (23a)/(24c) MEV: the (25)m effective + # ach formula for whole-house extract ventilation is `(22b)m + 0.5 × + # (23b)` when (22b) ≥ 0.5×(23b). Pre-slice the cascade hardcoded + # `mv_kind=NATURAL` so MEV-decentralised certs routed through (24d) + # natural-ventilation instead. Cert 000565's Mechanical Ventilation + # Type "Mechanical extract, decentralised (MEV dc)" maps to the + # `MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE` enum. + base = _typical_semi_detached_epc() + with_mev = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + region_code="1", + sap_building_parts=base.sap_building_parts, + sap_windows=base.sap_windows, + sap_heating=base.sap_heating, + sap_ventilation=SapVentilation(mechanical_ventilation_kind="EXTRACT_OR_PIV_OUTSIDE"), + ) + + # Act + v_mev = ventilation_from_cert(with_mev) + + # Assert — MEV throughput is (23a)=0.5; (23b)=(23a)×Fmv=0.5×1.0; the + # cascade's per-month (25)m = (22b)m + 0.5×(23b) when (22b) ≥ + # 0.5×(23b), or = (23b) otherwise. Verify the cascade picks the + # `EXTRACT_OR_PIV_OUTSIDE` formula directly rather than rely on + # (22b) magnitude to disambiguate from the NATURAL fallback. + from domain.sap10_calculator.worksheet.ventilation import MechanicalVentilationKind + assert v_mev.mv_kind is MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE + assert abs(v_mev.mv_system_ach - 0.5) <= 1e-4 + assert abs(v_mev.mv_system_ach_after_fmv - 0.5) <= 1e-4 + for w22b, w25 in zip(v_mev.monthly_wind_adjusted_ach, v_mev.effective_monthly_ach): + expected = 0.5 if w22b < 0.5 * 0.5 else w22b + 0.5 * 0.5 + assert abs(w25 - expected) <= 1e-4 + + def test_open_chimneys_raise_infiltration_ach() -> None: # Arrange — Direction check: chimneys add Table 2.1 volume to the # infiltration calc, so an otherwise identical dwelling with 2 open