From b7455aabe5d7865a0060aec9fbfb4fb85a6e56ab Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 23 Jun 2026 20:15:32 +0000 Subject: [PATCH] fix(ventilation): MVHR takes lodged 0 intermittent fans, not the Table 5 default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the dMEV intermittent-fan fix (4db05e84) to MVHR. A balanced whole-house MVHR system IS the dwelling's ventilation, so the lodged (7a) intermittent-extract-fan count is explicit — a lodged 0 means 0, not the RdSAP 10 Table 5 age-band "unknown" default. The cascade was substituting the default (here 20 m³/h) into worksheet line (8) openings, inflating (16/18) infiltration → (21) → (22b) → (25) effective ach → (38) ventilation heat loss → the space-heating demand. Worksheet-proven on simulated case 49 (000565 + Vent Axia 500140 MVHR, lodged (7a)=0): our (8) openings 0.0723 -> 0.0000, (18) 0.7223 -> 0.6500, (25)m Jan 0.9423 -> 0.8571, all now matching Elmhurst exactly; space- heating demand 7857 -> 7528 kWh (worksheet 7546). SAP 70.90 -> 71.43 continuous. (The residual to the worksheet's 72 is its own continuous SAP 71.69 rounding up, driven by a separate gas-combi water-heating-loss gap, not ventilation.) Scoped to EXTRACT_OR_PIV_OUTSIDE + MVHR only — MV-without-HR (mechanical_ventilation=1) stays on the default-substitution path (forcing its lodged 0 regressed 47 Howsman / 18 Jutland and is not worksheet-validated). Corpus within-0.5 holds 72.7%, MAE 0.782 -> 0.781. Note: pyright strict type gate not run locally (pyright not installed). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 27 ++++++++++++------- .../epc_client/test_sap_accuracy_corpus.py | 5 ++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index be7faa47..7767c788 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -5196,16 +5196,23 @@ 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: + # For a whole-house mechanical EXTRACT (MEV / dMEV) OR balanced- + # with-heat-recovery (MVHR) system 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 such a dwelling the fan + # count is explicit (the mechanical system IS the ventilation), so + # 0 means 0, not "unknown". Worksheet-proven on three 000565 builds: + # "case 48" (dMEV) lodges (7a)=0 → SAP 57 exact; "case 49" (MVHR, + # Vent Axia 500140) lodges (7a)=0 → the worksheet (8) openings line + # is 0.0000 (our default had added 20 m³/h = 0.0723 ach, inflating + # (22b)/(25)/(38) and the demand). The original 000565 fixture + # lodges (7a)=2 → unchanged. MV-without-HR (mechanical_ventilation + # =1) is EXCLUDED: forcing its lodged 0 regressed 47 Howsman / 18 + # Jutland and is not worksheet-validated. + if mv_kind in ( + MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE, + MechanicalVentilationKind.MVHR, + ): intermittent_fans = vc.intermittent_fans # SAP 10.2 §2.6.6 equation (2): the (24a) MVHR effective-air-change # credit needs the in-use heat-recovery efficiency (23c) = raw PCDB diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index dd6e322c..2eb5b80a 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -210,6 +210,11 @@ _MIN_WITHIN_HALF_SAP = 0.72 # 81.9%, (230a) 415.9325, (231) 501.9325 — matching Elmhurst exactly). Corpus # MVHR certs: Flat 1 +6 -> 0, 12a Princes Gate +3 -> +1; Apartment 707's -4 -> # -6 is a separate baseline under-rate (it under-rated as natural too). +# Then 0.782 -> 0.781 via extending the dMEV "lodged 0 extract fans, no Table 5 +# default" rule to MVHR (the balanced system is the ventilation, so a lodged +# (7a)=0 is explicit): case 49's (8) openings line is 0.0000 — our default had +# added 20 m3/h (0.0723 ach), inflating (22b)/(25)/(38) and demand (SAP 70.90 +# -> 71.43; the worksheet's own continuous SAP is 71.69 -> rounds to 72). _MAX_SAP_MAE = 0.785 _MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 3.6 # kWh / m2 / yr vs energy_consumption_current