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