fix(ventilation): read Blower Door AP50 pressure test (Summary)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-16 23:18:17 +00:00
parent d4d2b222fc
commit fe3bf4eaed
6 changed files with 37 additions and 0 deletions

View file

@ -1352,6 +1352,10 @@ class ElmhurstSiteNotesExtractor:
air_permeability_ap4_m3_h_m2 = float(ap4_raw.split()[0]) air_permeability_ap4_m3_h_m2 = float(ap4_raw.split()[0])
except (ValueError, IndexError): except (ValueError, IndexError):
air_permeability_ap4_m3_h_m2 = None 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 # Summary §12.1 "Mechanical Ventilation Type" — scoped to §12.1
# body so the global "Type" labels in §14 / §15 can't shadow it. # body so the global "Type" labels in §14 / §15 can't shadow it.
mv_lines = self._section_lines( mv_lines = self._section_lines(
@ -1400,6 +1404,7 @@ class ElmhurstSiteNotesExtractor:
mechanical_ventilation=self._bool_val("Mechanical Ventilation"), mechanical_ventilation=self._bool_val("Mechanical Ventilation"),
pressure_test_method=self._str_val("Test Method"), pressure_test_method=self._str_val("Test Method"),
air_permeability_ap4_m3_h_m2=air_permeability_ap4_m3_h_m2, 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_type=mechanical_ventilation_type,
mechanical_ventilation_pcdf_reference=mev_pcdf_reference, mechanical_ventilation_pcdf_reference=mev_pcdf_reference,
wet_rooms_count=wet_rooms_count, wet_rooms_count=wet_rooms_count,

View file

@ -202,6 +202,10 @@ class SapVentilation:
# Pulse pressure test, m³/h per m² of envelope area. When present the # 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)`. # cascade routes (18) via the AP4 formula `0.263 × AP4^0.924 + (8)`.
air_permeability_ap4_m3_h_m2: Optional[float] = None 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" # SAP 10.2 §2 (23a)/(24a..d) — Elmhurst "Mechanical Ventilation Type"
# string mapped to the `MechanicalVentilationKind` enum name (e.g. # string mapped to the `MechanicalVentilationKind` enum name (e.g.
# "EXTRACT_OR_PIV_OUTSIDE" for MEV decentralised). The cascade uses # "EXTRACT_OR_PIV_OUTSIDE" for MEV decentralised). The cascade uses

View file

@ -6918,5 +6918,6 @@ def _map_elmhurst_ventilation(
else (False if has_suspended_timber_floor else None) else (False if has_suspended_timber_floor else None)
), ),
air_permeability_ap4_m3_h_m2=v.air_permeability_ap4_m3_h_m2, 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), mechanical_ventilation_kind=_elmhurst_mv_kind(v.mechanical_ventilation_type),
) )

View file

@ -202,6 +202,9 @@ class VentilationAndCooling:
# SAP 10.2 §2 (17a) AP4 reading from §12.2 "Pressure Test Result # SAP 10.2 §2 (17a) AP4 reading from §12.2 "Pressure Test Result
# (AP4)" — only present when `pressure_test_method == "Pulse"`. # (AP4)" — only present when `pressure_test_method == "Pulse"`.
air_permeability_ap4_m3_h_m2: Optional[float] = None 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 # Summary §12.1 "Mechanical Ventilation Type" — e.g. "Mechanical
# extract, decentralised (MEV dc)". None when `mechanical_ventilation # extract, decentralised (MEV dc)". None when `mechanical_ventilation
# is False` (no MV system). # is False` (no MV system).

View file

@ -4986,6 +4986,9 @@ def ventilation_from_cert(
# cascade's `(18) = 0.263 × AP4^0.924 + (8)` formula; absent value # cascade's `(18) = 0.263 × AP4^0.924 + (8)` formula; absent value
# falls through to the components-based (16) ach. # falls through to the components-based (16) ach.
ap4 = sv.air_permeability_ap4_m3_h_m2 if sv is not None else None 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 # SAP 10.2 §2 (23a)/(24a..d) — MV kind dispatch chooses the (25)m
# effective-ach formula. The Elmhurst mapper translates the lodged # effective-ach formula. The Elmhurst mapper translates the lodged
# "Mechanical Ventilation Type" string to an enum *name*; resolve # "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), 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, 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_ap4=ap4,
air_permeability_ap50=ap50,
mv_kind=mv_kind, mv_kind=mv_kind,
mv_system_ach=mv_system_ach, mv_system_ach=mv_system_ach,
**wind_kwargs, **wind_kwargs,

View file

@ -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: def test_case6_main_2_emitter_and_control_extracted() -> None:
"""Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter """Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter
("Underfloor Heating") and control ("SAP code 2110, ...") the two ("Underfloor Heating") and control ("SAP code 2110, ...") the two