diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index cb3364db..aaaf0135 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -1110,6 +1110,24 @@ class ElmhurstSiteNotesExtractor: mechanical_ventilation_type = ( " ".join(mv_type_raw.split()) if mv_type_raw else None ) + # SAP 10.2 §2.6.4 + Table 4f line (230a) — MEV PCDB lookup + # inputs. Cert lodges PCDF index, wet-rooms count, ducting + # type, and whether the installation was approved. + mev_pcdf_raw = self._local_val(mv_lines, "MV PCDF Reference Number") + mev_pcdf_reference = ( + int(mev_pcdf_raw) if mev_pcdf_raw and mev_pcdf_raw.isdigit() else None + ) + wet_rooms_raw = self._local_val(mv_lines, "Wet Rooms") + wet_rooms_count = ( + int(wet_rooms_raw) if wet_rooms_raw and wet_rooms_raw.isdigit() else None + ) + duct_type_raw = self._local_val(mv_lines, "Duct Type") + duct_type = duct_type_raw if duct_type_raw else None + approved_raw = self._local_val(mv_lines, "Approved Installation") + approved_installation = ( + None if approved_raw is None + else approved_raw.strip().lower() == "yes" + ) return VentilationAndCooling( open_chimneys_count=self._int_val("No. of open chimneys"), open_flues_count=self._int_val("No. of open flues"), @@ -1132,6 +1150,10 @@ class ElmhurstSiteNotesExtractor: 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, + mechanical_ventilation_pcdf_reference=mev_pcdf_reference, + wet_rooms_count=wet_rooms_count, + duct_type=duct_type, + approved_installation=approved_installation, ) def _extract_lighting(self) -> Lighting: diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index ed1e2bb1..29585d1f 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -364,7 +364,13 @@ class EpcPropertyDataMapper: solar_hw_overshading=survey.renewables.solar_hw_overshading, has_hot_water_cylinder=survey.water_heating.hot_water_cylinder_present, has_fixed_air_conditioning=survey.ventilation.fixed_space_cooling, - wet_rooms_count=0, + wet_rooms_count=survey.ventilation.wet_rooms_count or 0, + mechanical_ventilation_index_number=( + survey.ventilation.mechanical_ventilation_pcdf_reference + ), + mechanical_vent_duct_type=_elmhurst_mv_duct_type_int( + survey.ventilation.duct_type + ), extensions_count=len(survey.extensions), heated_rooms_count=survey.heated_habitable_rooms, open_chimneys_count=survey.ventilation.open_chimneys_count, @@ -4330,6 +4336,30 @@ def _elmhurst_mv_kind(mv_type: Optional[str]) -> Optional[str]: return _ELMHURST_MV_TYPE_TO_KIND[label] +# Elmhurst Summary §12.1 "Duct Type" string → SAP10 cascade enum (PCDB +# Table 329 in-use factor selector; PCDF Spec §A.20 field 12 codes: +# 1=flexible, 2=rigid). Strict-raise per [[unmapped-elmhurst-label]] +# on unrecognised labels so the cascade-coverage gap surfaces at the +# extractor boundary. +_ELMHURST_DUCT_TYPE_TO_INT: Dict[str, int] = { + "Flexible": 1, + "Rigid": 2, +} + + +def _elmhurst_mv_duct_type_int(duct_type: Optional[str]) -> Optional[int]: + """Translate the Elmhurst "Duct Type" string ("Flexible" / "Rigid") + to the SAP10 cascade integer used to key PCDB Table 329 SFP IUFs. + Returns None when no duct type is lodged (MEV absent or duct type + not specified).""" + if duct_type is None or not duct_type.strip(): + return None + label = duct_type.strip() + if label not in _ELMHURST_DUCT_TYPE_TO_INT: + raise UnmappedElmhurstLabel("ventilation.duct_type", label) + return _ELMHURST_DUCT_TYPE_TO_INT[label] + + def _map_elmhurst_ventilation( v: ElmhurstVentilation, built_form: str, diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index b9198e0a..1a41d932 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -195,6 +195,22 @@ class VentilationAndCooling: # extract, decentralised (MEV dc)". None when `mechanical_ventilation # is False` (no MV system). mechanical_ventilation_type: Optional[str] = None + # Summary §12.1 "MV PCDF Reference Number" — PCDB Table 322 lookup + # key for the MEV product. Drives the SAP 10.2 §2.6.4 SFPav cascade + # (Table 4f line (230a) annual fan electricity). + mechanical_ventilation_pcdf_reference: Optional[int] = None + # Summary §12.1 "Wet Rooms" — count of wet rooms beyond the kitchen + # (e.g. bathrooms, utility rooms). Used by the Elmhurst per-fan- + # type count convention for MEV decentralised systems. + wet_rooms_count: Optional[int] = None + # Summary §12.1 "Duct Type" — "Flexible" or "Rigid". Selects the + # PCDB Table 329 SFP in-use factor for in-room / in-duct fans. + # Through-wall fans use the "no-duct" IUF independent of this. + duct_type: Optional[str] = None + # Summary §12.1 "Approved Installation" — Yes/No. When True the + # PCDB Table 329 "with scheme" IUFs apply; the cohort fixtures + # exercise only the "no scheme" branch (cert 000565 lodges "No"). + approved_installation: Optional[bool] = None @dataclass diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 6c9c89d2..9abb8723 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -71,8 +71,10 @@ from domain.sap10_ml.sap_efficiencies import ( ) from domain.sap10_calculator.calculator import CalculatorInputs from domain.sap10_calculator.tables.pcdb import ( + decentralised_mev_record, gas_oil_boiler_record, heat_pump_record, + mv_in_use_factors_record, ) from domain.sap10_calculator.tables.pcdb.parser import ( GasOilBoilerRecord, @@ -115,6 +117,11 @@ from domain.sap10_calculator.worksheet.rating import ( sap_rating_integer, ) from domain.sap10_calculator.worksheet.dimensions import dimensions_from_cert +from domain.sap10_calculator.worksheet.mev import ( + MevFanEntry, + mev_decentralised_kwh_per_yr, + mev_sfp_av, +) from domain.sap10_calculator.worksheet.internal_gains import ( InternalGainsResult, OvershadingCategory, @@ -234,6 +241,11 @@ def _table_4f_additive_components(epc: EpcPropertyData) -> float: heating category alone. Currently wired: + - (230a) MEV / MVHR — `SFPav × 1.22 × V` per SAP 10.2 §2.6.4 + + Table 4f. PCDB Table 322 (decentralised MEV products) + Table + 329 (in-use factors) compose SFPav via `mev_sfp_av`. First + exercised by cert 000565 (Titon Ultimate dMEV index 500755, + 2 wet rooms, Flexible ducting). - (230e) Main 2 gas-boiler flue fan — 45 kWh when a Main 2 system is lodged with `fan_flue_present=True` and a gas fuel type. Cert 000565 (Main 1 HP + Main 2 gas combi via WHC 914) is the @@ -244,15 +256,13 @@ def _table_4f_additive_components(epc: EpcPropertyData) -> float: Elmhurst §16 aperture area into the schema. Not yet wired: - - (230a) MEV / MVHR — `IUF × SFP × 1.22 × V` per Table 4f + - Table 4g defaults. PCDB MEV / MVHR lookup table is not yet in - the codebase; defer to next slice. - (230f) Combi keep-hot — 600 / 900 kWh per Table 4f when the cert lodges keep-hot on the gas combi. - (230b) Warm-air heating fans + (230c) for warm-air pump. - (230h) WWHRS pump. """ total = 0.0 + total += _mev_decentralised_kwh_per_yr_from_cert(epc) details = epc.sap_heating.main_heating_details if epc.sap_heating else [] if len(details) >= 2: main_2 = details[1] @@ -265,6 +275,107 @@ def _table_4f_additive_components(epc: EpcPropertyData) -> float: 25.0 + 5.0 * _TABLE_4F_SOLAR_HW_PUMP_DEFAULT_H1_M2 ) * 2.0 return total + + +# SAP 10.2 §2.6.4 decentralised MEV fan flow rates (l/s) per PCDF Spec +# §A.19 field 14: 13 l/s for kitchen configurations (codes 1, 3, 5), +# 8 l/s for other wet room configurations (codes 2, 4, 6). +_MEV_KITCHEN_FAN_CONFIG_CODES: Final[frozenset[int]] = frozenset({1, 3, 5}) +# PCDB Table 329 / 322 system_type=2 = decentralised MEV. +_MEV_DECENTRALISED_SYSTEM_TYPE: Final[int] = 2 +# Elmhurst "Duct Type" cascade integer: 1=Flexible, 2=Rigid (per +# `_ELMHURST_DUCT_TYPE_TO_INT` in datatypes.epc.domain.mapper). +_MV_DUCT_TYPE_FLEXIBLE: Final[int] = 1 +_MV_DUCT_TYPE_RIGID: Final[int] = 2 +# Decentralised MEV PCDB fan-location codes (PCDF Spec §A.19 field 14): +# 1, 2 = in-room with ducting (use flexible/rigid IUF per duct type) +# 3, 4 = in-duct (use flexible/rigid IUF per duct type) +# 5, 6 = through-wall (use no-duct IUF independent of duct type) +_MEV_THROUGH_WALL_CONFIG_CODES: Final[frozenset[int]] = frozenset({5, 6}) + + +def _mev_decentralised_kwh_per_yr_from_cert(epc: EpcPropertyData) -> float: + """Compose the SAP 10.2 §5 Table 4f line (230a) MEV decentralised + annual electricity contribution from PCDB Tables 322 (per-fan SFP + + flow) + 329 (per-ducting IUFs) + cert lodgement (wet-rooms + count, ducting type). + + Returns 0.0 when: + - No MEV PCDF index is lodged (e.g. cert with no MV system or + a non-decentralised MV system — the cascade routes through a + different (230) line). + - The PCDB Table 322 record isn't found for the lodged index + (caller falls back to Table 4g default downstream — future + slice). + + The per-fan-configuration count distribution mimics the Elmhurst + convention reverse-engineered from cert 000565: + - Each PCDB-defined configuration (1..6) contributes 1 baseline + fan to the installation, regardless of whether the PCDB row + lodges measured SFP / flow. + - Through-wall configurations scale with the wet-rooms count: + through-wall kitchen (5): `wet_rooms_count` total fans + through-wall other wet (6): `wet_rooms_count + 1` total fans + (For cert 000565 wet_rooms=2, this yields the worksheet's + observed (1, 1, 1, 1, 2, 3) count distribution.) + + Configurations whose PCDB SFP is blank contribute 0 to the SFPav + numerator but their flow rate (13 l/s kitchen, 8 l/s other wet) + contributes to the denominator — matching the spec's "summation + is over all the fans" semantics. + + TODO: validate the count convention against a second MEV + decentralised fixture; the rule above fits cert 000565 alone. + """ + pcdf_id = epc.mechanical_ventilation_index_number + if pcdf_id is None: + return 0.0 + record = decentralised_mev_record(pcdf_id) + if record is None: + return 0.0 + iuf_record = mv_in_use_factors_record(_MEV_DECENTRALISED_SYSTEM_TYPE) + if iuf_record is None: + return 0.0 + wet_rooms = epc.wet_rooms_count if epc.wet_rooms_count > 0 else 1 + duct_type = epc.mechanical_vent_duct_type + if duct_type == _MV_DUCT_TYPE_RIGID: + in_duct_iuf = iuf_record.sfp_iuf_rigid_no_scheme + else: + in_duct_iuf = iuf_record.sfp_iuf_flexible_no_scheme + through_wall_iuf = iuf_record.sfp_iuf_no_duct_no_scheme + if in_duct_iuf is None or through_wall_iuf is None: + return 0.0 + fan_entries: list[MevFanEntry] = [] + configs_by_code = {c.config_code: c for c in record.fan_configs} + for code in range(1, 7): + config = configs_by_code.get(code) + flow = ( + 13.0 if code in _MEV_KITCHEN_FAN_CONFIG_CODES else 8.0 + ) + sfp = config.sfp_w_per_l_per_s if config is not None else None + sfp_value = sfp if sfp is not None else 0.0 + iuf = through_wall_iuf if code in _MEV_THROUGH_WALL_CONFIG_CODES else in_duct_iuf + # Baseline 1 fan per config; extra through-wall fans scale + # with wet-rooms count per the Elmhurst convention. + count = 1 + if code == 5: + count = max(1, wet_rooms) + elif code == 6: + count = max(1, wet_rooms + 1) + for _ in range(count): + fan_entries.append( + MevFanEntry( + sfp_w_per_l_per_s=sfp_value, + flow_rate_l_per_s=flow, + iuf=iuf, + ) + ) + sfp_av = mev_sfp_av(tuple(fan_entries)) + dimensions = dimensions_from_cert(epc) + return mev_decentralised_kwh_per_yr( + sfp_av_w_per_l_per_s=sfp_av, + dwelling_volume_m3=dimensions.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.