"""Per-table row parsers for BRE PCDB pcdb10.dat records. Each PCDB table has its own CSV-shaped record format documented by BRE (format codes in `$,,...` headers of pcdb10.dat). Field positions are reverse-engineered from sample records and cross-checked against ground-truth records published at https://www.ncm-pcdb.org.uk. The parsers expose two layers per record: - Typed high-confidence fields (pcdb_id, manufacturer, model, winter/ summer efficiency, etc.) named per BRE's web entry vocabulary. - The full raw row as a tuple of strings, for forensics on undecoded fields and audit trails when BRE bumps the format version. Reference: BRE PCDB pcdb10.dat April 2026; user-verified web records. """ from __future__ import annotations from dataclasses import dataclass from typing import Final, Optional def _parse_optional_float(value: str) -> Optional[float]: """Empty PCDB fields are blank strings, not 'null'. Treat blank or non-numeric (e.g. '>70kW' range indicator on output-power fields) as None — the raw value is preserved on the record's `raw` tuple.""" value = value.strip() if not value: return None try: return float(value) except ValueError: return None def _parse_optional_int(value: str) -> Optional[int]: """Some PCDB fields carry status strings ('obsolete', 'discontinued') where a year would otherwise live. Treat any non-numeric value as missing rather than erroring — the status is preserved on `raw`.""" value = value.strip() if not value: return None try: return int(value) except ValueError: return None @dataclass(frozen=True) class GasOilBoilerRecord: """SAP 10.2 Appendix D2.1 PCDB record — Table 105 (Gas and Oil Boilers). Field positions verified against the ncm-pcdb.org.uk web entry for pcdb_id 000098 (Baxi Heating Wm 20/3rs): winter eff = 66.0%, summer eff = 56.0%, comparative HW = 40.8%, output 5.86 kW, final-year 1990. """ pcdb_id: int brand_name: str model_name: str model_qualifier: str winter_efficiency_pct: Optional[float] summer_efficiency_pct: Optional[float] comparative_hot_water_efficiency_pct: Optional[float] output_kw_max: Optional[float] final_year_of_manufacture: Optional[int] # SAP10.2 Appendix J Table 3b/3c — combi-loss fields per BRE PCDF Spec # Rev 6b (12 May 2021), Gas and Oil Boiler Table, fields 48 / 51 / 52 # / 56 / 57 (see `domain/sap10_calculator/docs/specs/PCDF_Spec_Rev-06b_12_May_2021.pdf` # pp. 14-15). Populated only for boilers EN 13203-2 / OPS 26 tested; # SAP-default boilers leave them all blank → `separate_dhw_tests=0` # and (61)m falls back to Table 3a. Field 48 encodes the test # schedules: 0=none, 1=schedule 2 only (profile M → Table 3b row 1), # 2=schedules 2 and 3 (profiles M+L → Table 3c), 3=schedules 2 and 1 # (profiles M+S → Table 3c). Field 55 (r2) is lodged but explicitly # excluded from SAP assessments ("only r1") so it is not surfaced. # PCDF Spec Rev 6b field 16 (0-idx 15): 0=normal, 1=integral FGHRS, # 2=combined HP+boiler, 3=combined HP+boiler+FGHRS. Gates the Table # 3b/3c row selection — only `subsidiary_type=0` exercises the # "Instantaneous with non-storage FGHRS or without FGHRS" row 1. subsidiary_type: Optional[int] # PCDF Spec Rev 6b field 39 (0-idx 38): 0=not storage combi, 1=primary # water store, 2=secondary store, 3=CPSU. Gates storage-combi rows in # Table 3b/3c (deferred until a fixture exercises). store_type: Optional[int] separate_dhw_tests: Optional[int] rejected_energy_proportion_r1: Optional[float] loss_factor_f1_kwh_per_day: Optional[float] loss_factor_f2_kwh_per_day: Optional[float] rejected_factor_f3_per_litre: Optional[float] # PCDF Spec Rev 6b (SAP10 boiler PCDB feed): "keep-hot facility" # metadata used by SAP Appendix J Table 3a sub-row dispatch. # Source: BRE STP09-B04 + the SAP 10 PCDB spec (private feed for # SAP software vendors — not surfaced on the public PCDB website # or the Open EPC API). Confirmed by cohort-2 cert 7800-1501-0922- # 7127-3563's PCDF 15709 lodging field 58 = "" (no keep-hot) # vs the cohort-1 fixture 000490's PCDF 10328 (Vaillant Ecotec # Pro 28) lodging "1" (fuel keep-hot) + field 59 = "1" (timer) # — exactly matches the hand-built comment "Combi keep hot type = # Gas/Oil, time clock" at `_elmhurst_worksheet_000490.py:277-280`. # # Field 58 enum (1-indexed): 0 = no keep-hot, 1 = fuel keep-hot, # 2 = electric keep-hot, 3 = gas + electric keep-hot. # Field 59 enum: 0 = no timer, 1 = overnight time-switch. # # Empty-string lodging is treated as None (i.e. unknown). Empirically # the cohort lodges empty for "no keep-hot" too — but some boilers # genuinely have keep-hot data missing because they predate SAP10's # PCDB spec, so None can't be unambiguously equated with 0. The # cascade dispatch in `cert_to_inputs.pcdb_combi_loss_override` # treats None and 0 identically for the Table 3a row choice # (Slice S0380.20 strict-raise context). keep_hot_facility: Optional[int] keep_hot_timer: Optional[int] raw: tuple[str, ...] _TABLE_HEADER_PREFIX: str = "$" _COMMENT_PREFIX: str = "#" _TABLE_105_HEADER_ID: str = "105" def _walk_table_records(dat_text: str, table_id: str) -> list[str]: """Yield record rows inside the named PCDB table section. The .dat file demarcates each table with a `$,,...` header on its own line. Records run from that header until the next `$` header or end-of-input. `#`-prefixed lines are comments; blank lines are skipped too. """ inside_target_table = False rows: list[str] = [] for raw_line in dat_text.splitlines(): line = raw_line.rstrip("\r") stripped = line.strip() if not stripped or stripped.startswith(_COMMENT_PREFIX): continue if stripped.startswith(_TABLE_HEADER_PREFIX): inside_target_table = stripped[1:].split(",", 1)[0] == table_id continue if inside_target_table: rows.append(line) return rows @dataclass(frozen=True) class RawPcdbRecord: """Untyped PCDB record — pcdb_id keyed lookup + raw row for future per-table typed refinement. Used for tables (122/143/362/391/313/353/ 506) where field positions have not yet been ground-truth verified.""" pcdb_id: int raw: tuple[str, ...] @dataclass(frozen=True) class PsrEfficiencyGroup: """One PSR-dependent group from a Table 362 heat-pump record. Format 465 stores each group as 9 raw fields; the three populated positions are tabulated here for SAP 10.2 Appendix N interpolation: psr plant size ratio (decimal, e.g. 0.2, 0.5, 1.0) eta_space_1_pct space heating thermal efficiency (% gross) — used by N3.6: (206) = 0.95 × eta_space_1 eta_water_3_pct calculated water heating thermal efficiency (% gross) for HPs providing both space + water — used by N3.7(a) + footnote 49: (217) = in_use_factor × eta_water_3 (in_use_factor per N3.7 table — 0.95 or 0.60 depending on whether the cert's cylinder meets the PCDB-lodged criteria of volume / HX area / heat loss). """ psr: float eta_space_1_pct: float eta_water_3_pct: float @dataclass(frozen=True) class HeatPumpRecord: """SAP 10.2 Appendix N PCDB record — Table 362 (Heat Pumps). Format 465 of pcdb10.dat (April 2026 revision) extends the published PCDF Spec Rev 6b §A.23 format 464 with additional header fields and a larger PSR-group set (up to 14 groups). Field positions are reverse-engineered against the BRE web entry at https://www.ncm-pcdb.org.uk/sap/pcdbdetails.jsp?type=362&id=; Mitsubishi PUZ-WM50VHA (104568) and Daikin EDLQ05CAV3 (102421) provide the cohort ground-truth. Encoded fields per format 464 §A.23 docs (vocabulary preserved): fuel 39 = electricity (Note: SAP 10.2 spec line 5901 allows non-electric heat pumps too) service_provision 1 = space + water heating all year 2 = space + water during heating season only 3 = space heating only 4 = water heating only hw_vessel_mode 1 = integral vessel 2 = separate and specified vessel (fields 19-21) 3 = separate but unspecified vessel 4 = none (service provision code 3) vessel_volume_l, vessel_heat_loss_kwh_per_day, vessel_heat_exchanger_area_m2: per spec §A.23 field 19/20/21 — only populated when `hw_vessel_mode in {1, 2}`. `max_output_kw` (spec §A.23 field 30) is the PSR-denominator per PDF p.100 line 5946 ("maximum nominal output of the package"). `heating_duration_code` (format-465 position 48) encodes the package's daily heating duration per SAP 10.2 Appendix N3.5 (PDF p.105 line 6099): "24", "16", "9", or "V" (Variable). Drives the extended-heating-schedule day allocation via Table N4/N5. Per footnote 48, modern records always lodge "V"; the fixed durations are retained for legacy purposes. `psr_groups` carries the PSR-dependent efficiency table (up to 14 rows) used by SAP 10.2 Appendix N3.6 (space heating) and N3.7(a) (water heating), interpolated at the dwelling's PSR per spec PDF p.100 line 5957. """ pcdb_id: int brand_name: str model_name: str model_qualifier: str fuel: Optional[int] service_provision: Optional[int] hw_vessel_mode: Optional[int] vessel_volume_l: Optional[float] vessel_heat_loss_kwh_per_day: Optional[float] vessel_heat_exchanger_area_m2: Optional[float] max_output_kw: Optional[float] heating_duration_code: Optional[str] psr_groups: tuple[PsrEfficiencyGroup, ...] raw: tuple[str, ...] # Format 465 field offsets in the raw row (0-indexed). Derived by # cross-referencing pcdb10.dat record 104568 (Mitsubishi Ecodan 5.0 kW) # with the BRE web entry's labelled values. _HP_IDX_BRAND_NAME: Final[int] = 6 _HP_IDX_MODEL_NAME: Final[int] = 7 _HP_IDX_MODEL_QUALIFIER: Final[int] = 8 _HP_IDX_FUEL: Final[int] = 16 _HP_IDX_SERVICE_PROVISION: Final[int] = 22 _HP_IDX_HW_VESSEL_MODE: Final[int] = 23 _HP_IDX_VESSEL_VOLUME_L: Final[int] = 24 _HP_IDX_VESSEL_HEAT_LOSS_KWH_PER_DAY: Final[int] = 25 _HP_IDX_VESSEL_HEAT_EXCHANGER_AREA_M2: Final[int] = 26 _HP_IDX_MAX_OUTPUT_KW: Final[int] = 47 # Format 465 position 48 — daily heating duration code per SAP 10.2 # Appendix N3.5 (PDF p.105 line 6099). Cohort ground-truth: "V" lodged # on Mitsubishi PUZ-WM50VHA (104568) and Daikin EDLQ05CAV3 (102421). _HP_IDX_HEATING_DURATION_CODE: Final[int] = 48 # Format 465 PSR-group block: idx[58] is the group count; groups start # at idx[59], 9 fields wide, with PSR / η_space,1 / η_water,3 at the # offsets below within each group. _HP_IDX_NUM_PSR_GROUPS: Final[int] = 58 _HP_PSR_GROUP_START: Final[int] = 59 _HP_PSR_GROUP_STRIDE: Final[int] = 9 _HP_PSR_GROUP_OFFSET_PSR: Final[int] = 0 _HP_PSR_GROUP_OFFSET_ETA_SPACE_1: Final[int] = 2 _HP_PSR_GROUP_OFFSET_ETA_WATER_3: Final[int] = 6 # SAP 10.2 Appendix N2 (PDF p.101, footnotes 44/45): out-of-range PSR # extension for air/ground/water source heat pumps. Above the record's # largest PSR the efficiency is reciprocal-interpolated toward 100% at # `_EXTENSION_PSR_MULTIPLE` × the largest PSR; below the smallest PSR, and # beyond that multiple, the efficiency is the terminal 100%. _EXTENSION_TERMINAL_EFFICIENCY_PCT: Final[float] = 100.0 _EXTENSION_PSR_MULTIPLE: Final[float] = 2.0 def _parse_psr_groups(raw: tuple[str, ...]) -> tuple[PsrEfficiencyGroup, ...]: """Decode the variable-length PSR-dependent block of a format-465 heat-pump record. The count comes from `idx[58]`; each subsequent group spans 9 raw fields with PSR / η_space,1 / η_water,3 at offsets 0 / 2 / 6 within the group. """ if _HP_IDX_NUM_PSR_GROUPS >= len(raw): return () count = _parse_optional_int(raw[_HP_IDX_NUM_PSR_GROUPS]) if count is None or count <= 0: return () groups: list[PsrEfficiencyGroup] = [] for group_idx in range(count): base = _HP_PSR_GROUP_START + group_idx * _HP_PSR_GROUP_STRIDE if base + _HP_PSR_GROUP_OFFSET_ETA_WATER_3 >= len(raw): break psr = _parse_optional_float(raw[base + _HP_PSR_GROUP_OFFSET_PSR]) eta_space_1 = _parse_optional_float( raw[base + _HP_PSR_GROUP_OFFSET_ETA_SPACE_1] ) eta_water_3 = _parse_optional_float( raw[base + _HP_PSR_GROUP_OFFSET_ETA_WATER_3] ) if psr is None or eta_space_1 is None or eta_water_3 is None: continue groups.append( PsrEfficiencyGroup( psr=psr, eta_space_1_pct=eta_space_1, eta_water_3_pct=eta_water_3, ) ) return tuple(groups) def interpolate_heat_pump_efficiency_at_psr( psr_groups: tuple[PsrEfficiencyGroup, ...], *, target_psr: float, ) -> tuple[float, float]: """SAP 10.2 PDF p.101 footnote 43 (line 7053) — reciprocal-linear interpolation between the two PSR rows enclosing `target_psr`: "For the efficiency values, the interpolated efficiency is the reciprocal of linear interpolation between the reciprocals of the efficiencies." i.e. 1/η_interp = (1 − t)·1/η_low + t·1/η_high, which is the harmonic mean weighted at t. Returns `(eta_space_1_pct, eta_water_3_pct)` at the dwelling's PSR. The interpolation is on the η values themselves (not their reciprocals taken from PCDB), so the η_*_pct values must be strictly positive — every PCDB row in the cohort satisfies this. Out-of-range PSR (spec PDF p.101, footnotes 44/45 — air/ground/water source heat pumps): - Below the smallest PSR in the record: "an efficiency of 100% should be used if the PSR is less than the smallest value in the database record." - Above the largest PSR in the record: "an efficiency may be obtained from linear interpolation between that at the largest PSR in the data record and efficiency 100% at PSR two times the largest PSR in the data record. If the PSR is greater than two times the largest PSR in the data record an efficiency of 100% should be used." The interpolation is reciprocal-linear too (footnote 43), with 100% as the upper anchor. Both space- and water-heating PSR-dependent efficiencies extend the same way. (Exhaust-air heat pumps and combined heat-pump-and-boiler packages instead use 100% directly above the largest PSR, and combined packages clamp to the edge rows; neither is distinguished by the current PCDB parse, so the air/ground/water rule is applied uniformly — a documented limitation. The dominant RdSAP cohort is air source.) Cohort fixture: cert 3336-2825-9400-0512-8292 (Mitsubishi PUZ-WM50VHA, PCDB 104568) — PSR 1.40151 brackets PCDB rows PSR 1.2 (η_space_1 = 253.9) and PSR 1.5 (η_space_1 = 229.2). Linear (pre-slice): 237.31; reciprocal (spec-faithful): 236.74 — matches worksheet (206)/(210) at 1e-4 once the 0.95 in-use factor is applied. Out-of-range anchor: PCDB 100061 (golden fixture case 56), largest PSR 2.0 (η_space_1=352.0). At dwelling PSR 3.10665 the extension to 100% at PSR 4.0 gives η_space_1 = 147.011 → (206) = 139.660, matching the accredited Elmhurst worksheet (vs the old clamp's 352.0 → 334.4%). """ if not psr_groups: raise ValueError("PSR groups required for interpolation") if target_psr < psr_groups[0].psr: return (_EXTENSION_TERMINAL_EFFICIENCY_PCT, _EXTENSION_TERMINAL_EFFICIENCY_PCT) if target_psr > psr_groups[-1].psr: last = psr_groups[-1] upper_psr = _EXTENSION_PSR_MULTIPLE * last.psr if target_psr >= upper_psr: return ( _EXTENSION_TERMINAL_EFFICIENCY_PCT, _EXTENSION_TERMINAL_EFFICIENCY_PCT, ) t = (target_psr - last.psr) / (upper_psr - last.psr) eta_space_1 = 1.0 / ( (1.0 - t) / last.eta_space_1_pct + t / _EXTENSION_TERMINAL_EFFICIENCY_PCT ) eta_water_3 = 1.0 / ( (1.0 - t) / last.eta_water_3_pct + t / _EXTENSION_TERMINAL_EFFICIENCY_PCT ) return (eta_space_1, eta_water_3) for low_group, high_group in zip(psr_groups, psr_groups[1:]): if low_group.psr <= target_psr <= high_group.psr: span = high_group.psr - low_group.psr t = (target_psr - low_group.psr) / span if span > 0 else 0.0 eta_space_1 = 1.0 / ( (1.0 - t) / low_group.eta_space_1_pct + t / high_group.eta_space_1_pct ) eta_water_3 = 1.0 / ( (1.0 - t) / low_group.eta_water_3_pct + t / high_group.eta_water_3_pct ) return (eta_space_1, eta_water_3) # Unreachable: target_psr is between min and max so a bracket exists. raise AssertionError("PSR bracket not found despite range check") def parse_heat_pump_row_raw(raw: tuple[str, ...]) -> HeatPumpRecord: """Decode a Table 362 format-465 raw row into a typed `HeatPumpRecord`. Tolerates missing trailing fields (older partially-populated records) by reading via index helpers that return None for short rows. """ def at(idx: int) -> str: return raw[idx] if idx < len(raw) else "" duration_raw = at(_HP_IDX_HEATING_DURATION_CODE).strip() return HeatPumpRecord( pcdb_id=int(raw[0]), brand_name=at(_HP_IDX_BRAND_NAME), model_name=at(_HP_IDX_MODEL_NAME), model_qualifier=at(_HP_IDX_MODEL_QUALIFIER), fuel=_parse_optional_int(at(_HP_IDX_FUEL)), service_provision=_parse_optional_int(at(_HP_IDX_SERVICE_PROVISION)), hw_vessel_mode=_parse_optional_int(at(_HP_IDX_HW_VESSEL_MODE)), vessel_volume_l=_parse_optional_float(at(_HP_IDX_VESSEL_VOLUME_L)), vessel_heat_loss_kwh_per_day=_parse_optional_float( at(_HP_IDX_VESSEL_HEAT_LOSS_KWH_PER_DAY) ), vessel_heat_exchanger_area_m2=_parse_optional_float( at(_HP_IDX_VESSEL_HEAT_EXCHANGER_AREA_M2) ), max_output_kw=_parse_optional_float(at(_HP_IDX_MAX_OUTPUT_KW)), heating_duration_code=duration_raw if duration_raw else None, psr_groups=_parse_psr_groups(raw), raw=raw, ) def parse_table_raw(dat_text: str, table_id: str) -> list[RawPcdbRecord]: """Generic positional walker: extract pcdb_id + raw row for any PCDB table, no per-field decoding. Future typed parsers (e.g. Table 362 heat pumps) refine specific fields without changing this contract. """ rows = _walk_table_records(dat_text, table_id) return [ RawPcdbRecord(pcdb_id=int(fields[0]), raw=fields) for row in rows for fields in (tuple(row.split(",")),) ] def parse_table_105(dat_text: str) -> list[GasOilBoilerRecord]: """Walk a PCDB dat string, yielding parsed Table 105 (Gas and Oil Boilers) records via `parse_table_105_row`.""" return [parse_table_105_row(row) for row in _walk_table_records(dat_text, _TABLE_105_HEADER_ID)] def parse_table_105_row(row: str) -> GasOilBoilerRecord: """Decode one Table 105 (Gas and Oil Boilers) record row into a typed record. Field positions (1-indexed): 1 pcdb_id, 6 brand_name, 7 model_name, 8 model_qualifier, 11 final_year, 23 output_kw_max, 26 winter_efficiency_pct, 27 summer_efficiency_pct, 29 comparative hot water efficiency. Trailing fields preserved verbatim in `raw`.""" fields = tuple(row.rstrip("\r\n").split(",")) return GasOilBoilerRecord( pcdb_id=int(fields[0]), brand_name=fields[5], model_name=fields[6], model_qualifier=fields[7], final_year_of_manufacture=_parse_optional_int(fields[10]), output_kw_max=_parse_optional_float(fields[22]), winter_efficiency_pct=_parse_optional_float(fields[25]), summer_efficiency_pct=_parse_optional_float(fields[26]), comparative_hot_water_efficiency_pct=_parse_optional_float(fields[28]), subsidiary_type=_parse_optional_int(fields[15]), store_type=_parse_optional_int(fields[38]), separate_dhw_tests=_parse_optional_int(fields[47]), rejected_energy_proportion_r1=_parse_optional_float(fields[50]), loss_factor_f1_kwh_per_day=_parse_optional_float(fields[51]), loss_factor_f2_kwh_per_day=_parse_optional_float(fields[55]), rejected_factor_f3_per_litre=_parse_optional_float(fields[56]), keep_hot_facility=_parse_optional_int(fields[57]) if len(fields) > 57 else None, keep_hot_timer=_parse_optional_int(fields[58]) if len(fields) > 58 else None, raw=fields, ) # Table 322 (Decentralised MEV) — PCDF Spec Rev 6b §A.19. Format 428 # stored in pcdb10.dat (header `$322,428,72,...`) extends spec format # 427 by dropping the per-group "Fan speed setting" string field, so # each group is a 3-field triplet (config_code, flow_l_per_s, sfp_w_per_l_per_s). # # SAP 10.2 fan configuration codes (PCDF Spec §A.19 field 14): # 1 = In-room fan, kitchen # 2 = In-room fan, other wet room # 3 = In-duct fan, kitchen # 4 = In-duct fan, other wet room # 5 = Through-wall fan, kitchen # 6 = Through-wall fan, other wet room # # Each PCDB record carries the 6-tuple of (flow_l_per_s, sfp_w_per_l_per_s) # per configuration; some configurations may be blank (PCDF Spec Note 1: # "For some products data may not be provided for certain configurations. # Such configurations are not a valid selection for SAP calculations."). _TABLE_322_NUM_FAN_CONFIGS: Final[int] = 6 _TABLE_322_GROUP_STRIDE: Final[int] = 3 # (config_idx, flow, sfp) # Format 428 header offsets (0-indexed); cross-checked against record 500755 # (Titon Ultimate dMEV) whose worksheet line (230a) lookup pins flow 13.0 # / SFP 0.15 on config 1 and flow 8.0 / SFP 0.14 on config 6. _MEV_IDX_BRAND_NAME: Final[int] = 5 _MEV_IDX_MODEL_NAME: Final[int] = 6 _MEV_IDX_MODEL_QUALIFIER: Final[int] = 7 # Per spec field 11 is "Main type" (=2 for decentralised MEV); record # layout in pcdb10.dat slots an extra "replacement_id" field between # `final_year` and `main_type`, so main_type sits at position 11 and the # fan-config block begins at position 13 (1+1 for main_type + 1 for the # config count). _MEV_IDX_MAIN_TYPE: Final[int] = 11 _MEV_IDX_NUM_CONFIGS: Final[int] = 12 _MEV_FAN_GROUP_START: Final[int] = 13 @dataclass(frozen=True) class MevFanConfig: """One fan-configuration row from a Table 322 PCDB record. `config_code` keys the SAP 10.2 §2.6.4 fan-type matrix (1-6 per PCDF Spec §A.19 field 14). `flow_rate_l_per_s` is the test flow rate for the configuration; `sfp_w_per_l_per_s` is the measured Specific Fan Power in watts per litre-per-second (used in the SFPav numerator with FR=13 for kitchens, FR=8 for other wet rooms per SAP 10.2 §2.6.4 equation 1). """ config_code: int flow_rate_l_per_s: Optional[float] sfp_w_per_l_per_s: Optional[float] @dataclass(frozen=True) class DecentralisedMevRecord: """PCDB Table 322 (Decentralised MEV) typed record. SAP 10.2 §2.6.4 — decentralised MEV systems lodge a per-fan-type SFP in the PCDB; the average SFP for SAP calculation is computed as SFPav = Σ(SFP_j × FR_j × IUF_j) / Σ(FR_j), where FR = 13 l/s for kitchens and 8 l/s for other wet rooms, and IUF is the in-use factor from PCDB Table 329 (per ducting type — flexible / rigid). Reference: PCDF Spec Rev 6b §A.19 (Format 427 in spec, Format 428 in pcdb10.dat — the spec's "Fan speed setting" string was removed). """ pcdb_id: int brand_name: str model_name: str model_qualifier: str main_type: Optional[int] # =2 for decentralised MEV fan_configs: tuple[MevFanConfig, ...] raw: tuple[str, ...] def parse_table_322(dat_text: str) -> list[DecentralisedMevRecord]: """Walk a PCDB dat string, yielding parsed Table 322 (Decentralised MEV) records via `parse_decentralised_mev_row`. Mirror of `parse_table_105` for Table 105 (Gas and Oil Boilers).""" return [parse_decentralised_mev_row(row) for row in _walk_table_records(dat_text, "322")] # Table 329 (MV In-Use Factors) — PCDF Spec Rev 6b §A.20 Format 430. # pcdb10.dat carries Format 432 (header `$329,432,4,2021,11,25,2`), an # extended-field version of spec Format 430. The spec's first 4 fields # (system_type + 3 SFP factors for "no approved scheme") align with the # Format 432 layout positions 0-3. Format 432 expands each "adjustment" # block to 7 columns (3 SFP + 4 MVHR-efficiency) and carries THREE # blocks (positions 1-7 "no scheme", 8-14 "with scheme", 15-21 a third # variant), then the timestamp. The MVHR efficiency-IUF columns within a # block are 4 (vs Format 430's documented 2: uninsulated/insulated); the # 3rd (index +6 within the block) is the "ducts inside the heated # envelope" factor — worksheet-proven against simulated case 49 (Vent # Axia 500140, system_type 3 → 0.90) and cross-checked against the # default-data row (system_type 10 → 0.70, = SAP 10.2 Table 4g default # heat-recovery in-use factor 0.70). The outside-envelope efficiency # columns (indices +4/+5/+7) and the with-scheme blocks are preserved # verbatim in `raw` (no corpus/worksheet fixture exercises them yet). # # System types per PCDF Spec §A.20 field 1: # 1 = centralised MEV # 2 = decentralised MEV # 3 = balanced whole-house MV (with or without heat recovery) # 5 = positive input ventilation (PIV) # 10 = default data (used when SFP / efficiency are taken from SAP # Table 4g rather than the PCDB) _MV_IUF_IDX_SYSTEM_TYPE: Final[int] = 0 _MV_IUF_IDX_SFP_FLEX_NO_SCHEME: Final[int] = 1 _MV_IUF_IDX_SFP_RIGID_NO_SCHEME: Final[int] = 2 _MV_IUF_IDX_SFP_NO_DUCT_NO_SCHEME: Final[int] = 3 # MVHR heat-recovery efficiency in-use factor, "no approved scheme" block, # ducts inside the heated envelope (Format 432 position 6 = block-1 eff # column index +2). See the block-layout note above. _MV_IUF_IDX_MVHR_EFF_INSIDE_NO_SCHEME: Final[int] = 6 @dataclass(frozen=True) class MvInUseFactorsRecord: """PCDB Table 329 (MV In-Use Factors) typed record. SAP 10.2 §2.6 + §2.6.4 — in-use factors (IUF) are multiplied into the PCDB SFP per equation (1) to allow for additional ductwork losses encountered in practice. Per PCDF Spec §A.20 Note 1: "If there is no applicable approved installation scheme the values for with and without scheme are the same" — so this slice exposes the "no scheme" SFP IUFs only; with-scheme variants are deferred until a fixture lodges an approved installation. Fields are Optional because each system_type populates a subset (e.g. centralised MEV lodges the flex / rigid IUFs but no through-wall — the no-duct field is blank). """ system_type: int sfp_iuf_flexible_no_scheme: Optional[float] sfp_iuf_rigid_no_scheme: Optional[float] sfp_iuf_no_duct_no_scheme: Optional[float] # MVHR heat-recovery efficiency in-use factor for ducts inside the # heated envelope, "no approved scheme" block (Format 432). None for # system types that carry no heat-recovery efficiency (1/2/5). mvhr_efficiency_iuf_inside_no_scheme: Optional[float] raw: tuple[str, ...] def parse_table_329(dat_text: str) -> list[MvInUseFactorsRecord]: """Walk a PCDB dat string, yielding parsed Table 329 (MV In-Use Factors) records. One record per `system_type`; SFP IUFs decoded for the "no scheme" variant per PCDF Spec §A.20 Note 1.""" return [parse_mv_in_use_factors_row(row) for row in _walk_table_records(dat_text, "329")] def parse_mv_in_use_factors_row(row: str) -> MvInUseFactorsRecord: """Decode one Table 329 (MV In-Use Factors) Format-432 row into a typed `MvInUseFactorsRecord`. Positions 0..3 align with PCDF Spec Format 430 fields 1..4 — the "no approved scheme" SFP IUFs.""" fields = tuple(row.rstrip("\r\n").split(",")) return MvInUseFactorsRecord( system_type=int(fields[_MV_IUF_IDX_SYSTEM_TYPE]), sfp_iuf_flexible_no_scheme=_parse_optional_float( fields[_MV_IUF_IDX_SFP_FLEX_NO_SCHEME] ), sfp_iuf_rigid_no_scheme=_parse_optional_float( fields[_MV_IUF_IDX_SFP_RIGID_NO_SCHEME] ), sfp_iuf_no_duct_no_scheme=_parse_optional_float( fields[_MV_IUF_IDX_SFP_NO_DUCT_NO_SCHEME] ), mvhr_efficiency_iuf_inside_no_scheme=( _parse_optional_float(fields[_MV_IUF_IDX_MVHR_EFF_INSIDE_NO_SCHEME]) if len(fields) > _MV_IUF_IDX_MVHR_EFF_INSIDE_NO_SCHEME else None ), raw=fields, ) def parse_decentralised_mev_row(row: str) -> DecentralisedMevRecord: """Decode one Table 322 (Decentralised MEV) Format-428 row into a typed `DecentralisedMevRecord`. The header block holds the pcdb_id + manufacturer / brand / model identifiers; the variable-length fan-configuration block carries one 3-field triplet per fan-type-and-location permutation. Blank flow / SFP values mean the configuration was not tested (spec Note 1) — they're stored as None and excluded from the SFPav summation downstream. """ fields = tuple(row.rstrip("\r\n").split(",")) num_configs = _parse_optional_int(fields[_MEV_IDX_NUM_CONFIGS]) or 0 configs: list[MevFanConfig] = [] for j in range(num_configs): start = _MEV_FAN_GROUP_START + j * _TABLE_322_GROUP_STRIDE if start + _TABLE_322_GROUP_STRIDE > len(fields): break code_str = fields[start].strip() if not code_str: continue configs.append( MevFanConfig( config_code=int(code_str), flow_rate_l_per_s=_parse_optional_float(fields[start + 1]), sfp_w_per_l_per_s=_parse_optional_float(fields[start + 2]), ) ) return DecentralisedMevRecord( pcdb_id=int(fields[0]), brand_name=fields[_MEV_IDX_BRAND_NAME], model_name=fields[_MEV_IDX_MODEL_NAME], model_qualifier=fields[_MEV_IDX_MODEL_QUALIFIER], main_type=_parse_optional_int(fields[_MEV_IDX_MAIN_TYPE]), fan_configs=tuple(configs), raw=fields, ) # Table 323 (Centralised MEV and MVHR) — PCDF Spec Rev 6b §A.18 Format 426. # pcdb10.dat carries Format 431 (header `$323,431,...`), which extends spec # Format 426 by (a) slotting an extra blank "replacement_id" field between # `final_year` (idx 9) and `main_type` (idx 11, mirroring Table 322's # layout) and (b) compressing each per-configuration test group to a # 4-field tuple `(num_wet_rooms, applicable_flow_l_per_s, sfp, efficiency)` # — the spec's "Fan speed setting" + duplicate flow fields are dropped. # # Field 11 "Main type" (PCDF §A.18): 1=centralised MEV, 3=balanced # whole-house MV WITH heat recovery, 4=...without heat recovery, # 5=positive input ventilation. Only type 3 with a non-blank efficiency # is MVHR (24a); the rest are extract/PIV and carry no heat-recovery # efficiency. # # Each test group's leading field is the number of wet rooms; SAP 10.2 # §2.6.4 ("MVHR ... SFP is a single value depending on the number of wet # rooms") selects the group whose wet-room count matches the dwelling # lodgement. Worksheet-proven on simulated case 49 (000565, 2 wet rooms, # Vent Axia Sentinel Kinetic B 500140 → group 2 = flow 21.0, SFP 0.88, # efficiency 91%). _CMV_IDX_BRAND_NAME: Final[int] = 5 _CMV_IDX_MODEL_NAME: Final[int] = 6 _CMV_IDX_MODEL_QUALIFIER: Final[int] = 7 _CMV_IDX_MAIN_TYPE: Final[int] = 11 _CMV_IDX_DUCT_TESTED: Final[int] = 13 # 1=flexible, 2=rigid (PCDF §A.18 f.13) _CMV_IDX_NUM_CONFIGS: Final[int] = 16 _CMV_GROUP_START: Final[int] = 17 _CMV_GROUP_STRIDE: Final[int] = 4 # (wet_rooms, flow, sfp, efficiency) _CMV_MAIN_TYPE_MVHR: Final[int] = 3 @dataclass(frozen=True) class MvhrDataPoint: """One per-configuration test result from a Table 323 PCDB record. `num_wet_rooms` is the configuration's wet-room count (the SAP 10.2 §2.6.4 selector); `flow_rate_l_per_s` is the applicable test flow rate; `sfp_w_per_l_per_s` is the raw Specific Fan Power; `efficiency_pct` is the raw MVHR heat-exchanger efficiency in % (None for MEV/PIV records that carry no heat recovery). All are pre-in-use-factor. """ num_wet_rooms: int flow_rate_l_per_s: Optional[float] sfp_w_per_l_per_s: Optional[float] efficiency_pct: Optional[float] @dataclass(frozen=True) class MvhrRecord: """PCDB Table 323 (Centralised MEV and MVHR) typed record. SAP 10.2 §2.6.4/§2.6.6 — a balanced whole-house MVHR system lodges a single SFP + heat-exchanger efficiency per wet-room configuration in the PCDB. SAP selects the data point matching the dwelling's wet-room count; the raw values are multiplied by the PCDB Table 329 in-use factors before use (SFP → fan electricity (230a); efficiency → the (23c) effective-air-change credit in equation (2)). Reference: PCDF Spec Rev 6b §A.18 (Format 426 in spec, Format 431 in pcdb10.dat). """ pcdb_id: int brand_name: str model_name: str model_qualifier: str main_type: Optional[int] # =3 for balanced whole-house MV (with/without HR) duct_type_tested: Optional[int] # 1=flexible, 2=rigid data_points: tuple[MvhrDataPoint, ...] raw: tuple[str, ...] def parse_centralised_mv_row(row: str) -> MvhrRecord: """Decode one Table 323 (Centralised MEV / MVHR) Format-431 row into a typed `MvhrRecord`. The header block holds the pcdb_id + manufacturer identifiers + main type + tested duct type; the variable-length test block carries one 4-field group per wet-room configuration.""" fields = tuple(row.rstrip("\r\n").split(",")) num_configs = _parse_optional_int(fields[_CMV_IDX_NUM_CONFIGS]) or 0 points: list[MvhrDataPoint] = [] for j in range(num_configs): start = _CMV_GROUP_START + j * _CMV_GROUP_STRIDE if start + _CMV_GROUP_STRIDE > len(fields): break wet_rooms_str = fields[start].strip() if not wet_rooms_str: continue points.append( MvhrDataPoint( num_wet_rooms=int(wet_rooms_str), flow_rate_l_per_s=_parse_optional_float(fields[start + 1]), sfp_w_per_l_per_s=_parse_optional_float(fields[start + 2]), efficiency_pct=_parse_optional_float(fields[start + 3]), ) ) return MvhrRecord( pcdb_id=int(fields[0]), brand_name=fields[_CMV_IDX_BRAND_NAME], model_name=fields[_CMV_IDX_MODEL_NAME], model_qualifier=fields[_CMV_IDX_MODEL_QUALIFIER], main_type=_parse_optional_int(fields[_CMV_IDX_MAIN_TYPE]), duct_type_tested=_parse_optional_int(fields[_CMV_IDX_DUCT_TESTED]), data_points=tuple(points), raw=fields, ) def parse_table_323(dat_text: str) -> list[MvhrRecord]: """Walk a PCDB dat string, yielding parsed Table 323 (Centralised MEV and MVHR) records via `parse_centralised_mv_row`. Mirror of `parse_table_322` for the decentralised-MEV table.""" return [parse_centralised_mv_row(row) for row in _walk_table_records(dat_text, "323")]