Model/domain/sap10_calculator/tables/pcdb/parser.py
Khalim Conn-Kowlessar 433f4a49ce Slice S0380.99: PCDB Table 329 (MV In-Use Factors) ETL + parser + lookup (PCDF Spec §A.20)
PCDF Spec Rev 6b §A.20 (May 2021) Format 430 — Mechanical Ventilation
In-Use Factors Table. Pcdb10.dat carries Format 432 (header
`$329,432,4,2021,11,25,2`), an extended-field version where Format
430 fields 1-4 (system_type + 3 SFP factors for the "no approved
scheme" variant) align at positions 0..3. The remainder of Format
432 carries MVHR adjustments + "with approved scheme" variants +
additional Format 432 columns, preserved verbatim in `raw` for
follow-up slices.

Per PCDF Spec §A.20 field 1 — system types:
  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 with SAP Table 4g defaults)

Decentralised MEV (system_type=2) IUFs:
  SFP × ducting type:
    flexible:   1.45 (field 2)
    rigid:      1.30 (field 3)
    no-duct:    1.15 (field 4 — through-wall fans)

Per spec Note: "If there is no applicable approved installation
scheme the values for with and without scheme are the same." Cert
000565 lodges "Approved Installation: No" → use the "no scheme"
IUFs.

Validation for cert 000565 against worksheet line (230a):
  Σ(SFP_j × FR_j × IUF_j) for the 4 lodged fans:
    in-room kitchen:        1×0.15×13×1.45 = 2.8275
    in-room other wet:      1×0.15× 8×1.45 = 1.7400
    through-wall kitchen:   2×0.11×13×1.15 = 3.2890
    through-wall other wet: 3×0.14× 8×1.15 = 3.8640
  Σ = 11.7205 W (matches worksheet "total watage = 11.7205")
  Σ(FR_j) = 92.0 l/s (matches worksheet "total flow = 92.0000")
  SFPav = 11.7205 / 92.0 = 0.1274 W/(l/s) ✓ matches worksheet

Foundation only this slice — typed parser + ETL + runtime lookup
`mv_in_use_factors_record(system_type)`. No cascade integration; no
behavioural change on any cert. Next slice S0380.100 wires the
SFPav formula.

5 Table 329 records ingested. Pyright net-zero per touched file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:20:02 +00:00

631 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 `$<table>,<format>,...` 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 `$<id>,<format>,...` header
on its own line. Records run from that header until the next `$<id>`
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=<pcdb_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
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.
Per spec PDF p.100 lines 7039-7072: clamp to the smallest PSR in
the record when `target_psr` is below it, and to the largest when
above ("if the PSR is greater than the largest PSR in the database
record then the heat pump space and water heating fractions for the
largest PSR should be used, and if the PSR is less than the
smallest PSR in the database record then the heat pump space and
water heating fractions for the smallest PSR should be used").
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.
"""
if not psr_groups:
raise ValueError("PSR groups required for interpolation")
if target_psr <= psr_groups[0].psr:
first = psr_groups[0]
return (first.eta_space_1_pct, first.eta_water_3_pct)
if target_psr >= psr_groups[-1].psr:
last = psr_groups[-1]
return (last.eta_space_1_pct, last.eta_water_3_pct)
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 — the only positions this slice
# decodes. Trailing fields (MVHR adjustments + "with-scheme" variants +
# additional Format 432 columns) are preserved verbatim in `raw` for
# follow-up slices.
#
# 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
@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]
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]
),
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,
)