From 7b30b464e59170dc7aa43414a5abab5b38bd7b54 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 23 Jun 2026 19:45:37 +0000 Subject: [PATCH] feat(ventilation): credit MVHR (24a) heat recovery via PCDB Table 323 + 329 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVHR (24a) heat-recovery support, part 2: the mapper + cascade wiring. Both source paths now resolve balanced whole-house MV with heat recovery to the MVHR kind: - gov-API: `_API_MECHANICAL_VENTILATION_TO_KIND` 4 → "MVHR" (was None / treated as natural — under-stated ventilation heat loss, over-rating). - Elmhurst Summary: `_ELMHURST_MV_TYPE_TO_KIND` "Mechanical ventilation with heat recovery (MVHR)" → "MVHR" (was UnmappedElmhurstLabel, which blocked the whole Summary for MVHR dwellings). cert_to_inputs resolves the in-use heat-recovery efficiency + SFP for an MVHR cert (`_mvhr_system_values`): pick the PCDB Table 323 data point by the lodged wet-room count (SAP 10.2 §2.6.4), multiply the raw efficiency by the Table 329 ducts-inside-envelope in-use factor (0.90) and the raw SFP by the per-duct-type factor (rigid 1.4), and feed: - the §2.6.6 eq (2) effective-air-change credit (23c) → (24a)/(25)m; - the (230a) fan electricity (in-use SFP × 1.22 × V), costed but NOT added to the Table 5a gains (its effect is in the efficiency). An MVHR lodged with no PCDF index falls back to the SAP 10.2 Table 4g default (raw efficiency 66% × 0.70, raw SFP 2.0 × 2.5). Worksheet-proven on simulated case 49 (000565 semi + Vent Axia Sentinel Kinetic B 500140 + gas combi → Elmhurst Current SAP 72): every MVHR line matches Elmhurst exactly — (33) fabric heat loss 100.5923, (23c) in-use efficiency 81.9% = 91 × 0.90, (25)m Jan 0.8571, (230a) fan electricity 415.9325, (231) total pumps/fans 501.9325. The residual SAP 71 vs 72 is the known 000565-family space-heating-demand artifact (same -1/-2 seen on cases 47/48), not the MVHR logic. Corpus: within-0.5 72.6% -> 72.7%, MAE 0.788 -> 0.782, PE 3.6 -> 3.5. The 3 gov-API MVHR certs: Flat 1 +6 -> 0 (Table 4g default path) and 12a Princes Gate +3 -> +1 (heat-recovery credit); Apartment 707 -4 -> -6 is a separate baseline under-rate (it under-rated as natural too — the MVHR credit correctly adds ventilation loss per Elmhurst's method). Ratcheted _MAX_SAP_MAE 0.79 -> 0.785, _MAX_PE_PER_M2_MAE 3.7 -> 3.6. Note: pyright strict type gate not run locally (pyright not installed). Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 18 +-- .../sap10_calculator/rdsap/cert_to_inputs.py | 117 ++++++++++++++++++ .../rdsap/test_cert_to_inputs.py | 87 ++++++++++++- .../epc_client/test_sap_accuracy_corpus.py | 13 +- 4 files changed, 220 insertions(+), 15 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 40c32b0c..7ec2cd5d 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3928,20 +3928,19 @@ def _api_sheltered_sides(built_form: object) -> Optional[int]: # loft-sourced PIV as natural # (no added system air change) # 6 positive input from outside → EXTRACT_OR_PIV_OUTSIDE (24c) -# Code 4 (MVHR, 24a) is DEFERRED: its (24a)m formula needs the lodged -# heat-recovery efficiency (`mvhr_efficiency_pct`, PCDB Table 326) which the -# API→cascade path does not yet plumb; mapping it to MVHR with a null -# efficiency would mis-model it as MV (no recovery), so it stays NATURAL -# until the efficiency is wired. The extract systems (2/3/6) carry no -# efficiency, so this slice closes the clean +1.90 SAP over-rate on the -# MEV/PIV-outside cohort (n=20, 5% within 0.5) — they were silently -# defaulting to NATURAL, under-stating ventilation heat loss. +# Code 4 (MVHR, 24a) resolves to MVHR: `cert_to_inputs` reads the lodged +# heat-recovery efficiency from PCDB Table 323 (per wet-room count) × +# Table 329 in-use factor and feeds the §2.6.6 equation (2) credit. The +# 3 corpus MVHR certs carry a PCDF index (`mechanical_ventilation_index_ +# number`) + wet-room + duct lodgements; an MVHR lodged with no PCDF index +# falls back to the SAP 10.2 Table 4g default (efficiency 66% × 0.70). +# The extract systems (2/3/6) carry no efficiency. _API_MECHANICAL_VENTILATION_TO_KIND: Final[dict[int, Optional[str]]] = { 0: None, 1: "MV", 2: "EXTRACT_OR_PIV_OUTSIDE", 3: "EXTRACT_OR_PIV_OUTSIDE", - 4: None, # MVHR — efficiency plumbing deferred; treat as natural + 4: "MVHR", # balanced whole-house MV with heat recovery (24a) 5: None, 6: "EXTRACT_OR_PIV_OUTSIDE", } @@ -7564,6 +7563,7 @@ _ELMHURST_MV_TYPE_TO_KIND: Dict[str, str] = { # 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", + "Mechanical ventilation with heat recovery (MVHR)": "MVHR", } diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 72c9d4d8..be7faa47 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -75,10 +75,13 @@ from domain.sap10_calculator.tables.pcdb import ( gas_oil_boiler_record, heat_pump_record, mv_in_use_factors_record, + mvhr_record, ) from domain.sap10_calculator.tables.pcdb.parser import ( GasOilBoilerRecord, HeatPumpRecord, + MvhrDataPoint, + MvhrRecord, ) from domain.sap10_calculator.tables.pcdb.postcode_weather import ( PostcodeClimate, @@ -525,6 +528,9 @@ def _table_4f_additive_components(epc: EpcPropertyData) -> float: """ total = 0.0 total += _mev_decentralised_kwh_per_yr_from_cert(epc) + # (230a) balanced MVHR fan electricity — SFP × 1.22 × V (SAP 10.2 + # §2.6.6). Costed here but kept out of the Table 5a gains. + total += _mvhr_fan_kwh_per_yr_from_cert(epc) details = epc.sap_heating.main_heating_details if epc.sap_heating else [] if details: main_1 = details[0] @@ -709,6 +715,106 @@ def _mev_decentralised_kwh_per_yr_from_cert(epc: EpcPropertyData) -> float: sfp_av_w_per_l_per_s=sfp_av, dwelling_volume_m3=dimensions.volume_m3, ) + + +# PCDB Table 329 / 323 system_type 3 = balanced whole-house MV (with or +# without heat recovery). +_MV_BALANCED_SYSTEM_TYPE: Final[int] = 3 +# SAP 10.2 Table 4g (PDF p.176) defaults for an MVHR system NOT in the +# PCDB: raw heat-recovery efficiency 66% + raw SFP 2.0 W/(l/s). Each is +# then multiplied by the default-data in-use factor (Table 329 +# system_type 10 — efficiency 0.70 inside the envelope, SFP 2.5). +_TABLE_4G_DEFAULT_MVHR_EFFICIENCY_PCT: Final[float] = 66.0 +_TABLE_4G_DEFAULT_MVHR_SFP_W_PER_L_PER_S: Final[float] = 2.0 + + +@dataclass(frozen=True) +class _MvhrSystemValues: + """In-use SFP + heat-recovery efficiency for an MVHR dwelling, with + the PCDB Table 329 in-use factors already folded in.""" + + in_use_sfp_w_per_l_per_s: float + in_use_efficiency_pct: float + + +def _select_mvhr_data_point(record: MvhrRecord, wet_rooms_count: int) -> MvhrDataPoint: + """SAP 10.2 §2.6.4: select the MVHR data point by the dwelling's + wet-room count (each Table 323 group is keyed by wet rooms). Exact + match where lodged; otherwise clamp to the tested range. Falls back + to the smallest-wet-room group when the cert lodges 0 (unlodged).""" + points = sorted(record.data_points, key=lambda p: p.num_wet_rooms) + target = wet_rooms_count if wet_rooms_count > 0 else points[0].num_wet_rooms + for point in points: + if point.num_wet_rooms == target: + return point + if target < points[0].num_wet_rooms: + return points[0] + return points[-1] + + +def _mvhr_system_values(epc: EpcPropertyData) -> Optional[_MvhrSystemValues]: + """Resolve the in-use SFP + heat-recovery efficiency for an MVHR + dwelling, or None when the cert is not MVHR. + + PCDB path (index lodged + Table 323 hit): select the data point whose + wet-room count matches the lodgement, then apply the Table 329 + system_type-3 in-use factors — SFP per the lodged duct type (rigid + 1.4 / flexible 1.7); heat-recovery efficiency for ducts inside the + heated envelope (0.90). Worksheet-proven on simulated case 49 (000565, + 2 wet rooms, Vent Axia 500140 → raw SFP 0.88 × 1.4 = 1.232; raw + efficiency 91 × 0.90 = 81.9% = worksheet (23c)). + + Default path (no PCDB record, e.g. an MVHR lodged with no PCDF index): + SAP 10.2 Table 4g raw SFP 2.0 / efficiency 66%, × the default-data + in-use factors (Table 329 system_type 10 — SFP 2.5, efficiency 0.70). + + Duct type defaults to rigid when unlodged (the Elmhurst Summary path + captures it but not all sources do); semi-rigid maps to rigid per + SAP 10.2 §2.6.8. Only the ducts-inside-envelope efficiency factor is + worksheet-validated (all corpus + worksheet MVHR certs lodge it). + """ + sv = epc.sap_ventilation + if sv is None or sv.mechanical_ventilation_kind != MechanicalVentilationKind.MVHR.name: + return None + pcdf_id = epc.mechanical_ventilation_index_number + record = mvhr_record(pcdf_id) if pcdf_id is not None else None + if record is not None and record.data_points: + point = _select_mvhr_data_point(record, epc.wet_rooms_count) + raw_sfp = point.sfp_w_per_l_per_s + raw_efficiency_pct = point.efficiency_pct + iuf_record = mv_in_use_factors_record(_MV_BALANCED_SYSTEM_TYPE) + else: + raw_sfp = _TABLE_4G_DEFAULT_MVHR_SFP_W_PER_L_PER_S + raw_efficiency_pct = _TABLE_4G_DEFAULT_MVHR_EFFICIENCY_PCT + iuf_record = mv_in_use_factors_record(_MEV_DEFAULT_DATA_SYSTEM_TYPE) + if iuf_record is None or raw_sfp is None or raw_efficiency_pct is None: + return None + if epc.mechanical_vent_duct_type == _MV_DUCT_TYPE_FLEXIBLE: + sfp_iuf = iuf_record.sfp_iuf_flexible_no_scheme + else: + sfp_iuf = iuf_record.sfp_iuf_rigid_no_scheme + efficiency_iuf = iuf_record.mvhr_efficiency_iuf_inside_no_scheme + if sfp_iuf is None or efficiency_iuf is None: + return None + return _MvhrSystemValues( + in_use_sfp_w_per_l_per_s=raw_sfp * sfp_iuf, + in_use_efficiency_pct=raw_efficiency_pct * efficiency_iuf, + ) + + +def _mvhr_fan_kwh_per_yr_from_cert(epc: EpcPropertyData) -> float: + """SAP 10.2 §5 Table 4f line (230a) annual fan electricity for an + MVHR dwelling: in-use SFP × 1.22 × V (the same (230a) shape as + decentralised MEV). Returns 0.0 when the cert is not MVHR. Per SAP + 10.2 §2.6.6 this electricity is costed but NOT added to the Table 5a + gains (its effect is already in the heat-recovery efficiency).""" + values = _mvhr_system_values(epc) + if values is None: + return 0.0 + return mev_decentralised_kwh_per_yr( + sfp_av_w_per_l_per_s=values.in_use_sfp_w_per_l_per_s, + dwelling_volume_m3=dimensions_from_cert(epc).volume_m3, + ) # SAP10.2 Table 6d note 1: "average or unknown" overshading is the # default for existing dwellings. RdSAP doesn't lodge a per-dwelling # overshading code so §5 always uses AVERAGE → Z_L = 0.83. @@ -5101,6 +5207,16 @@ def ventilation_from_cert( # MVHR/MV kinds are left untouched pending their own worksheet. if mv_kind is MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE: 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 + # efficiency × Table 329 in-use factor (or the Table 4g default when + # no PCDB record). None for non-MVHR kinds → ventilation_from_inputs + # leaves the heat-recovery term at zero. + mvhr_efficiency_pct: Optional[float] = None + if mv_kind is MechanicalVentilationKind.MVHR: + mvhr_values = _mvhr_system_values(epc) + if mvhr_values is not None: + mvhr_efficiency_pct = mvhr_values.in_use_efficiency_pct return ventilation_from_inputs( volume_m3=vol, storey_count=storeys, @@ -5123,6 +5239,7 @@ def ventilation_from_cert( air_permeability_ap4=ap4, mv_kind=mv_kind, mv_system_ach=mv_system_ach, + mvhr_efficiency_pct=mvhr_efficiency_pct, **wind_kwargs, ) 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 175ec7c0..fa6dc2ba 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -3773,19 +3773,98 @@ def test_api_mechanical_ventilation_maps_extract_systems_to_cascade_kind() -> No ) # Act / Assert — extract / positive-input-from-outside systems carry - # the (24c) kind; MV-no-HR carries (24b); natural and PIV-from-loft - # stay NATURAL; MVHR (4) is deferred (efficiency not yet plumbed) and - # stays NATURAL rather than mis-modelling as MV. + # the (24c) kind; MV-no-HR carries (24b); MVHR (4) carries (24a) (its + # heat-recovery efficiency is read from PCDB Table 323 in cert_to_ + # inputs); natural and PIV-from-loft stay NATURAL. assert _api_mechanical_ventilation_kind(0) is None # natural assert _api_mechanical_ventilation_kind(1) == "MV" # MV, no HR assert _api_mechanical_ventilation_kind(2) == "EXTRACT_OR_PIV_OUTSIDE" # MEV dc assert _api_mechanical_ventilation_kind(3) == "EXTRACT_OR_PIV_OUTSIDE" # MEV c - assert _api_mechanical_ventilation_kind(4) is None # MVHR (deferred) + assert _api_mechanical_ventilation_kind(4) == "MVHR" # balanced + HR (24a) assert _api_mechanical_ventilation_kind(5) is None # PIV from loft assert _api_mechanical_ventilation_kind(6) == "EXTRACT_OR_PIV_OUTSIDE" # PIV outside assert _api_mechanical_ventilation_kind(None) is None +def test_mvhr_system_values_apply_pcdb_wet_room_point_and_table_329_iufs() -> None: + # Arrange — simulated case 49 (000565 semi + MVHR + gas combi → + # Elmhurst Current SAP 72). The dwelling lodges Vent Axia Sentinel + # Kinetic B (PCDB Table 323 index 500140), 2 wet rooms, rigid ducting. + # SAP 10.2 §2.6.4 selects the 2-wet-room data point (raw SFP 0.88, + # efficiency 91%); the Table 329 system_type-3 in-use factors are SFP + # rigid 1.4 + heat-recovery-efficiency-inside-envelope 0.90. + import dataclasses + + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _mvhr_fan_kwh_per_yr_from_cert, # pyright: ignore[reportPrivateUsage] + _mvhr_system_values, # pyright: ignore[reportPrivateUsage] + dimensions_from_cert, + ) + from tests.domain.sap10_calculator.worksheet import ( + _elmhurst_worksheet_000565 as _w000565, + ) + + base = _w000565.build_epc() + assert base.sap_ventilation is not None + epc = dataclasses.replace( + base, + mechanical_ventilation_index_number=500140, + wet_rooms_count=2, + mechanical_vent_duct_type=2, # rigid + sap_ventilation=dataclasses.replace( + base.sap_ventilation, mechanical_ventilation_kind="MVHR" + ), + ) + + # Act + values = _mvhr_system_values(epc) + fan_kwh = _mvhr_fan_kwh_per_yr_from_cert(epc) + + # Assert — worksheet (23c) = 91 × 0.90 = 81.9%; in-use SFP = 0.88 × + # 1.4 = 1.232 W/(l/s). The (230a) fan electricity is in-use SFP × + # 1.22 × V; on the case-49 Summary (V = 276.7275 m³) this is exactly + # 415.9325 kWh (verified via run_elmhurst_summary). Here we pin the + # formula against this fixture's own volume (its geometry differs). + assert values is not None + assert abs(values.in_use_efficiency_pct - 81.9) <= 1e-6 + assert abs(values.in_use_sfp_w_per_l_per_s - 1.232) <= 1e-9 + expected_fan_kwh = 1.232 * 1.22 * dimensions_from_cert(epc).volume_m3 + assert abs(fan_kwh - expected_fan_kwh) <= 1e-6 + + +def test_mvhr_system_values_fall_back_to_table_4g_defaults_without_pcdb_index() -> None: + # Arrange — an MVHR cert lodged with NO PCDF index (corpus cert + # "Flat 1"). SAP 10.2 Table 4g (PDF p.176): default raw efficiency + # 66% + raw SFP 2.0, × the default-data in-use factors (Table 329 + # system_type 10 — efficiency-inside 0.70, SFP 2.5). + import dataclasses + + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _mvhr_system_values, # pyright: ignore[reportPrivateUsage] + ) + from tests.domain.sap10_calculator.worksheet import ( + _elmhurst_worksheet_000565 as _w000565, + ) + + base = _w000565.build_epc() + assert base.sap_ventilation is not None + epc = dataclasses.replace( + base, + mechanical_ventilation_index_number=None, + sap_ventilation=dataclasses.replace( + base.sap_ventilation, mechanical_ventilation_kind="MVHR" + ), + ) + + # Act + values = _mvhr_system_values(epc) + + # Assert — efficiency 66 × 0.70 = 46.2%; SFP 2.0 × 2.5 = 5.0. + assert values is not None + assert abs(values.in_use_efficiency_pct - 46.2) <= 1e-6 + assert abs(values.in_use_sfp_w_per_l_per_s - 5.0) <= 1e-9 + + def test_api_mechanical_ventilation_unmapped_code_raises() -> None: # Arrange — an out-of-range `mechanical_ventilation` integer is a # spec-coverage gap: raise rather than silently default to NATURAL diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index e8b47d6a..dd6e322c 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -201,9 +201,18 @@ _MIN_WITHIN_HALF_SAP = 0.72 # 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 +# 0.788 -> 0.782 (within-0.5 72.6% -> 72.7%, PE 3.6 -> 3.5) via MVHR (24a) heat- +# recovery support: the gov-API `mechanical_ventilation=4` cohort (balanced +# whole-house MV with heat recovery) was treated as natural; now credited via +# PCDB Table 323 efficiency × Table 329 in-use factor and the §2.6.6 eq (2) +# effective-air-change formula + (230a) fan electricity. Worksheet-proven on +# simulated case 49 (Vent Axia 500140, every MVHR line — (33) 100.5923, (23c) +# 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). +_MAX_SAP_MAE = 0.785 _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 +_MAX_PE_PER_M2_MAE = 3.6 # kWh / m2 / yr vs energy_consumption_current def _load_corpus() -> list[dict[str, Any]]: