From fe3bf4eaed2d1d6bc2ab1ce56e5597b2b57255d4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Jun 2026 23:18:17 +0000 Subject: [PATCH] fix(ventilation): read Blower Door AP50 pressure test (Summary) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 §2 (17)-(18): a measured/design air permeability at 50 Pa from a Blower Door test routes infiltration via `(18) = AP50/20 + (8)`, in preference to the components-based (16) estimate. The Elmhurst extractor read only the AP4 ("Pulse") column of §12.2, so a Blower Door result (§12.2 "Pressure Test Result (AP50)") fell through to the structural- infiltration default — over-counting ventilation heat loss. Surfaced by simulated case 44 (AP50 4.50): effective air change rate was 0.81 vs the worksheet's 0.58 (+38% ventilation loss). The cascade already supports `air_permeability_ap50` (preferred over AP4); this wires the read end to end (extractor → ElmhurstSiteNotes → SapVentilation → cert_to_inputs). Pinned against the case-44 P960 §2 at abs=1e-4: (18) infiltration 0.3417 (= 4.5/20 + 0.1167) and (25) Jan effective ach 0.5812. Worksheet harness stays 47/47 0-raised. Co-Authored-By: Claude Opus 4.8 --- .../documents_parser/elmhurst_extractor.py | 5 +++++ datatypes/epc/domain/epc_property_data.py | 4 ++++ datatypes/epc/domain/mapper.py | 1 + datatypes/epc/surveys/elmhurst_site_notes.py | 3 +++ .../sap10_calculator/rdsap/cert_to_inputs.py | 4 ++++ .../worksheet/test_section_cascade_pins.py | 20 +++++++++++++++++++ 6 files changed, 37 insertions(+) diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 6f7e4936..196291b4 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -1352,6 +1352,10 @@ class ElmhurstSiteNotesExtractor: air_permeability_ap4_m3_h_m2 = float(ap4_raw.split()[0]) except (ValueError, IndexError): air_permeability_ap4_m3_h_m2 = None + # SAP 10.2 §2 (17) "Measured/design AP50" from a Blower Door test. + # Routes the cascade's (18) via `AP50 / 20 + (8)` (preferred over + # AP4). Absent when the test method is "Not available". + ap50_raw = self._local_float(pressure_lines, "Pressure Test Result (AP50)") # 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( @@ -1400,6 +1404,7 @@ class ElmhurstSiteNotesExtractor: 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, + air_permeability_ap50_m3_h_m2=ap50_raw, mechanical_ventilation_type=mechanical_ventilation_type, mechanical_ventilation_pcdf_reference=mev_pcdf_reference, wet_rooms_count=wet_rooms_count, diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index c12d87f0..f69ede90 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -202,6 +202,10 @@ class SapVentilation: # 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 (17) — air permeability at 50 Pa from a Blower Door test, + # m³/h per m² of envelope area. When present the cascade routes (18) + # via `AP50 / 20 + (8)` (preferred over AP4). + air_permeability_ap50_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 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index acd24efd..514803ec 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6918,5 +6918,6 @@ def _map_elmhurst_ventilation( else (False if has_suspended_timber_floor else None) ), air_permeability_ap4_m3_h_m2=v.air_permeability_ap4_m3_h_m2, + air_permeability_ap50_m3_h_m2=v.air_permeability_ap50_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 02f27849..3b179253 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -202,6 +202,9 @@ class VentilationAndCooling: # 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 + # SAP 10.2 §2 (17) AP50 reading from §12.2 "Pressure Test Result + # (AP50)" — present for a Blower Door test. Routes (18) via AP50/20. + air_permeability_ap50_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). diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index f4a86295..c8a59fcf 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -4986,6 +4986,9 @@ def ventilation_from_cert( # 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 (17) — AP50 Blower Door reading routes (18) via + # `AP50 / 20 + (8)`, preferred over AP4 when both are lodged. + ap50 = sv.air_permeability_ap50_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 @@ -5023,6 +5026,7 @@ def ventilation_from_cert( 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, air_permeability_ap4=ap4, + air_permeability_ap50=ap50, mv_kind=mv_kind, mv_system_ach=mv_system_ach, **wind_kwargs, diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index d4725df9..19b9d7dc 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -427,6 +427,26 @@ def test_case44_non_separated_conservatory_fabric_matches_pdf() -> None: ) +def test_case44_blower_door_pressure_test_matches_pdf() -> None: + """Simulated case 44 lodges a Blower Door air-pressure test + (§12.2 "Pressure Test Result (AP50) 4.50"). SAP 10.2 §2 (17)-(18): + the AP50 reading routes infiltration via `(18) = AP50/20 + (8)` = + 4.5/20 + 0.1167 = 0.3417, in preference to the components-based (16) + estimate. The extractor previously read only the AP4 (Pulse) column, + so a Blower Door result fell through to the structural-infiltration + default (effective ach 0.81 vs the worksheet's 0.58 → ventilation + heat loss over-counted by ~38%).""" + # Arrange + epc = _w001431_case44.build_epc() + + # Act + vent = ventilation_from_cert(epc) + + # Assert — (18) infiltration + (25) Jan effective ach, at abs=1e-4. + _pin(vent.pressure_test_ach, 0.3417, "§2 (18) case44") + _pin(vent.effective_monthly_ach[0], 0.5812, "§2 (25) Jan case44") + + def test_case6_main_2_emitter_and_control_extracted() -> None: """Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter ("Underfloor Heating") and control ("SAP code 2110, ...") — the two