From 4db05e843cf1df3d68017af59152f9cb0a46d1f3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 23 Jun 2026 11:33:07 +0000 Subject: [PATCH] =?UTF-8?q?fix(ventilation):=20dMEV=20takes=20lodged=200?= =?UTF-8?q?=20intermittent=20fans,=20not=20the=20Table=205=20default=20(SA?= =?UTF-8?q?P=2010.2=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chasing the space-heating demand gap on "simulated case 48" (main 691 + Unknown meter + 903 dual immersion): our SAP 55 vs Elmhurst 57. Every §10a cost line already matched to the penny; the residual was demand — our space-heating energy 3849.8 kWh vs Elmhurst 3513.8 (+9.6%). Traced through the worksheet: our ventilation heat loss (38) ran ~35.5 W/K vs Elmhurst 27.76 — we were adding 20 m3/h of intermittent extract fans (the Table 5 age-band default) on a dwelling with a decentralised mechanical extract (dMEV) system that lodges 0 fans. SAP 10.2 §2 (PDF p.13): a whole-house mechanical EXTRACT system provides extraction via the (23a) 0.5 system air-change rate; the lodged intermittent extract-fan count (7a) is then explicit — a lodged 0 means 0 (the dMEV is the ventilation), NOT "unknown". The Table 5 default is an unknown-fallback for NATURALLY ventilated dwellings only, so it must not be substituted here. Fix: for EXTRACT_OR_PIV_OUTSIDE, take vc.intermittent_fans as-is (no age-band default). Worksheet-proven on two dMEV builds of cert 000565: "case 48" lodges (7a)=0 -> our SAP 55 -> 57 EXACT; the original 000565 fixture lodges (7a)=2 and keeps 2 (its e2e pins are unchanged). An earlier draft that forced fans=0 broke 000565 (which legitimately has 2) — corrected to "lodged as-is". within-0.5 72.5% -> 72.6%, MAE 0.789 -> 0.788; CO2/PE unchanged. The fix also reduces a systematic under-rating bias in the 21-cert dMEV cohort (median dSAP -0.22 -> -0.08). Scoped to EXTRACT_OR_PIV_OUTSIDE; balanced MVHR/MV kinds left untouched pending their own worksheet. SAP-schema regression test_18_0_0 pin 80 -> 81 (closer to its lodged 84, same cause). Spec-pinned in test_cert_to_inputs (dMEV-lodged-0 vs natural-default). pyright not installed in this container -- strict type gate not run locally. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/domain/tests/test_from_sap_schema.py | 5 +- .../sap10_calculator/rdsap/cert_to_inputs.py | 11 ++++ .../rdsap/test_cert_to_inputs.py | 50 +++++++++++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 5 +- 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index 64b6ee5c..6991b28d 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -506,7 +506,10 @@ class TestFromSapSchema16_2: epc = EpcPropertyDataMapper.from_api_response(load("sap_18_0_0.json")) assert isinstance(epc, EpcPropertyData) assert epc.uprn == 10094601287 - assert Sap10Calculator().calculate(epc).sap_score == 80 # lodged 84 + # 80 -> 81 (closer to lodged 84) after the dMEV intermittent-fan fix: + # this cert is EXTRACT_OR_PIV_OUTSIDE with a lodged 0 fans, so the + # Table 5 age-band default is no longer substituted (SAP 10.2 §2 (7a)). + assert Sap10Calculator().calculate(epc).sap_score == 81 # lodged 84 def test_16_0_dispatches_via_16_x_path_with_tenure_default(self) -> None: # SAP-Schema-16.0 is the same reduced-field 16.x shape; it omits the diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 3f1294e5..72c9d4d8 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -5090,6 +5090,17 @@ def ventilation_from_cert( # 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 + # For a whole-house mechanical EXTRACT system (MEV / dMEV) the + # lodged intermittent extract-fan count (7a) is taken AS-IS — the + # Table 5 age-band default must NOT be substituted for a lodged 0. + # On a mechanically-ventilated dwelling the fan count is explicit + # (the dMEV is the ventilation), so 0 means 0, not "unknown". + # Worksheet-proven on two dMEV builds of 000565: "case 48" lodges + # (7a)=0 → SAP 57 exact, while the original 000565 fixture lodges + # (7a)=2 → unchanged. Scoped to EXTRACT_OR_PIV_OUTSIDE; balanced + # MVHR/MV kinds are left untouched pending their own worksheet. + if mv_kind is MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE: + intermittent_fans = vc.intermittent_fans return ventilation_from_inputs( volume_m3=vol, storey_count=storeys, diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index add25b03..175ec7c0 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1762,6 +1762,56 @@ def test_ventilation_from_cert_applies_table_5_default_when_lodged_zero() -> Non ) +def test_ventilation_from_cert_dmev_takes_lodged_zero_fans_not_table_5_default() -> None: + # Arrange — on a dwelling with a whole-house mechanical EXTRACT system + # (dMEV → EXTRACT_OR_PIV_OUTSIDE) the lodged intermittent extract-fan + # count (7a) is explicit: a lodged 0 means 0 (the dMEV is the + # ventilation), NOT "unknown". The SAP 10.2 §2 Table 5 age-band default + # must NOT be substituted as it would on a NATURALLY ventilated dwelling. + # Worksheet-proven: two dMEV builds of cert 000565 — "simulated case 48" + # lodges (7a)=0 and Elmhurst's worksheet uses 0 (→ SAP 57 exact), while + # the original 000565 fixture lodges (7a)=2 and Elmhurst uses 2. The same + # Table 5 default that this age-G NATURAL case applies (1 fan above) must + # be suppressed here. + from domain.sap10_calculator.worksheet.ventilation import ( + MechanicalVentilationKind, + ) + age_g_part = make_building_part( + floor_dimensions=[ + make_floor_dimension(total_floor_area_m2=45.0, floor=0), + make_floor_dimension(total_floor_area_m2=45.0, floor=1), + ], + construction_age_band='G', + ) + base = _typical_semi_detached_epc() + epc_dmev = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + region_code="1", + sap_building_parts=[age_g_part], + sap_windows=base.sap_windows, + sap_heating=base.sap_heating, + sap_ventilation=SapVentilation( + extract_fans_count=0, + mechanical_ventilation_kind=( + MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE.name + ), + ), + ) + + # Act + v = ventilation_from_cert(epc_dmev) + + # Assert — 0 intermittent fans contribute 0 to (8), so openings ACH = 0 + # (no Table 5 age-G default fan); the mechanical system is applied via + # the separate (23a) 0.5 system air-change rate instead. + assert abs(v.openings_ach) <= 1e-9, ( + f"openings ACH {v.openings_ach:.6f} should be 0 for a dMEV cert " + f"lodging 0 fans (no Table 5 default substituted)" + ) + assert v.mv_system_ach == 0.5 + + def test_ventilation_from_cert_uses_lodged_fans_below_age_default_not_floored() -> None: # Arrange — RdSAP 10 §4.1 Table 5 (PDF p.28): "Number of extract fans if # known; if unknown: [age-band default]." The default is an UNKNOWN- diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 16d7300f..e8b47d6a 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -197,7 +197,10 @@ _MIN_WITHIN_HALF_SAP = 0.72 # 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak # trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion) # -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57, -# 10-Hour Off Peak). within-0.5 holds 72.5%. +# 10-Hour Off Peak). Then 0.789 -> 0.788 (within-0.5 72.5% -> 72.6%) via the +# dMEV intermittent-fan fix: an EXTRACT_OR_PIV_OUTSIDE cert lodging 0 fans now +# takes 0 (not the Table 5 age-band default) — same "case 48" worksheet closes +# its space-heating demand to land SAP 57 exact. _MAX_SAP_MAE = 0.79 _MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 3.7 # kWh / m2 / yr vs energy_consumption_current