mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
631 lines
27 KiB
Python
631 lines
27 KiB
Python
"""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,
|
||
)
|