Slice S0380.92: AP4 + MEV decentralised plumbing (SAP 10.2 §2 (17a)/(18)/(23a)/(24c))

SAP 10.2 §2 lines (17a)/(18) "Air permeability value, AP4 (m³/h/m²)"
(PDF p.12-13):

> "The air permeability at 4 Pa (AP4) measured with the low-pressure
>  pulse technique [...] is used in the following formula to estimate
>  of the air infiltration rate at typical pressure differences.
>  In this case (9) to (16) of the worksheet are not used."
>
>   Air infiltration rate (ach) = 0.263 × AP4^0.924
>
>   If based on air permeability value at 4 Pa,
>   then (18) = [0.263 × (17a)^0.924] + (8)

SAP 10.2 §2 lines (23a)/(24c)/(25) "MEV" + "Whole-house extract
ventilation" (PDF p.13/133):

> "The SAP calculation is based on a throughput of 0.5 air changes per
>  hour through the mechanical system."  (23a) = 0.5
>
>   If whole house extract ventilation or positive input ventilation
>   from outside:
>     if (22b)m < 0.5 × (23b), then (24c) = (23b)
>     otherwise (24c) = (22b)m + 0.5 × (23b)

Cert 000565 lodges:
- Summary §12.1 "Mechanical Ventilation Type: Mechanical extract,
  decentralised (MEV dc)" (PCDF 500755)
- Summary §12.2 "Test Method: Pulse" + "Pressure Test Result (AP4): 2.00"

Pre-slice both lodgements were silently dropped by the Elmhurst
extractor / mapper / `cert_to_inputs` cascade:

- AP4 had no schema field on `VentilationAndCooling` or `SapVentilation`
  even though `ventilation.py:ventilation_from_inputs(air_permeability_
  ap4=...)` already implemented the spec formula.
- Mechanical Ventilation Type had no schema field; `cert_to_inputs.
  ventilation_from_cert` hardcoded `mv_kind=MechanicalVentilationKind.
  NATURAL` regardless of the lodgement, routing cert 000565 through
  the (24d) natural-vent formula instead of (24c).

These bugs are coupled: AP4 alone would close (18) but the cascade's
(25) NATURAL pass-through would then *under*-count the effective ach
by 0.25 (the missing MEV contribution). MEV alone would over-count
because the (18) over-count remains. Per [[feedback-bigger-slices-
for-uniform-work]] + handover precedent on coupling-aware reverts,
these land together.

Slice span (5 layers):
1. **Schema** — `VentilationAndCooling.air_permeability_ap4_m3_h_m2` +
   `VentilationAndCooling.mechanical_ventilation_type` (site-notes);
   `SapVentilation.air_permeability_ap4_m3_h_m2` +
   `SapVentilation.mechanical_ventilation_kind` (domain).
2. **Extractor** — `_extract_ventilation` parses "Pressure Test Result
   (AP4)" scoped to §12.2 and "Mechanical Ventilation Type" scoped to
   §12.1. Both default to None when the cert lodges no MV / no Pulse
   test (cohort modal case).
3. **Mapper** — `_map_elmhurst_ventilation` plumbs AP4 through; new
   `_ELMHURST_MV_TYPE_TO_KIND` dispatch with strict-raise on unmapped
   labels (per [[reference-unmapped-elmhurst-label]] mirror pattern).
4. **cert_to_inputs** — `ventilation_from_cert` reads AP4 and resolves
   `mechanical_ventilation_kind` name → `MechanicalVentilationKind`
   enum. MEV/MV/MVHR kinds set `mv_system_ach=0.5` per spec (23a).
5. **Tests** — 4 in test_summary_pdf_mapper_chain.py (extractor + mapper
   for both AP4 and MEV kind), 2 in test_cert_to_inputs.py (cascade
   AP4 formula + MEV kind dispatch). All AAA-structured.

Cert 000565 movement (HEAD `83218630` → this slice):
  - cascade (18) pressure_test_ach:  2.4037 → 2.0287 ✓ EXACT vs ws 2.0287
  - cascade (21) shelter-adj:        2.0431 → 1.7244 ✓ EXACT vs ws 1.7244
  - cascade mean (25)m:              2.2347 → 2.1360 vs ws 2.086 (+0.05)
  - **sap_score (integer):           28     → 29 ✓ EXACT vs ws 29** (Δ−1 → Δ 0)
  - sap_score_continuous:            27.99  → 28.77 (Δ−0.52 → +0.26)
  - ecf:                             5.44   → 5.36  (Δ+0.05 → −0.03)
  - total_fuel_cost_gbp:             4726.75 → 4657.37 (Δ+46 → Δ−23)
  - co2_kg_per_yr:                   6506.48 → 6415.56 (Δ+59 → Δ−32)
  - **space_heating_kwh:             +631   → −367**   (~75% closed)
  - main_heating_fuel:               +371   → −216    (~58% closed)
  - hot_water_kwh:                   ✓ 0 EXACT unchanged
  - lighting / pumps_fans:           sub-spec residuals unchanged

The residual cascade-over-by-0.05 ach on (25)m is the cascade using
the cert-agnostic Table U2 wind tuple instead of the cert's regional
wind lookup; future ventilation_from_cert wires a `postcode_climate`
arg through which `cert_to_demand_inputs` already does for the demand
cascade, but the SAP-rating cascade keeps the Table U2 default.

Cohort safety:
- All 21 other Elmhurst cohort fixtures lodge `pressure_test_method=
  "Not available"` and `mechanical_ventilation=False` → both new
  fields default to None → cascade behaviour unchanged.
- 9 golden + 38 cohort-2 API certs route through `_map_sap_ventilation`
  (the API mapper variant), which leaves both new SapVentilation
  fields at their None default → cascade behaviour unchanged.

Test baseline: 582 pass + 8 expected `000565` fails (was 575 + 9; +6
new tests + sap_score reclassified from fail to pass). 1763 pass in
broader sap10_ml + worksheet + epc.domain suites + 3 pre-existing
fails unchanged. Pyright net-zero per touched file (1/0/0/32/34→32/13/
11 → 1/0/0/32/32/13/11, cert_to_inputs.py improved −2).

Per [[project-sap10_ml-deprecation]] the new fields live on the
existing `SapVentilation` domain type; no new modules under sap10_ml.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-30 13:29:50 +00:00
parent 8321863015
commit a7894b1185
7 changed files with 243 additions and 1 deletions

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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),
)

View file

@ -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

View file

@ -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,
)

View file

@ -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