From a7894b11856b5950a9ef642d0d4d1b8089cb71db Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 30 May 2026 13:29:50 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.92:=20AP4=20+=20MEV=20decentralis?= =?UTF-8?q?ed=20plumbing=20(SAP=2010.2=20=C2=A72=20(17a)/(18)/(23a)/(24c))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../documents_parser/elmhurst_extractor.py | 25 ++++++ .../tests/test_summary_pdf_mapper_chain.py | 82 +++++++++++++++++++ datatypes/epc/domain/epc_property_data.py | 10 +++ datatypes/epc/domain/mapper.py | 25 ++++++ datatypes/epc/surveys/elmhurst_site_notes.py | 7 ++ .../sap10_calculator/rdsap/cert_to_inputs.py | 26 +++++- .../rdsap/tests/test_cert_to_inputs.py | 69 ++++++++++++++++ 7 files changed, 243 insertions(+), 1 deletion(-) 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