mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
interpolate_heat_pump_efficiency_at_psr clamped to the smallest/largest PSR row when the dwelling's plant size ratio fell outside the record's range. That is the SAP 10.2 Appendix N rule for *combined heat-pump-and-boiler* packages, not for a plain air/ground/water source heat pump. Per Appendix N2 (PDF p.101, footnotes 44/45) a source heat pump whose PSR exceeds the record's largest value takes a reciprocal-linear interpolation between the largest-PSR efficiency and 100% at twice that PSR (100% beyond), and 100% when the PSR is below the record's smallest value. Both the space- and water-heating PSR-dependent efficiencies extend this way. Effect: an oversized heat pump in a small dwelling is no longer credited the full top-of-table COP. Accredited Elmhurst worksheet for cert 100110101713 (golden fixture case 56, PCDB 100061, PSR 3.107 over largest 2.0): (206) 334.4% -> 139.66% = Elmhurst exact. Corpus (RdSAP-21.0.1, n=1000) MAE 0.7397 -> 0.7258, within-0.5 0.7410 held; only two certs move (both oversized-PSR heat pumps), 100110101713 +18.32 -> -4.97. Exhaust-air and combined heat-pump-and-boiler packages have different boundary rules (straight-to-100% / clamp-to-edge) but are not distinguished by the current PCDB parse; the air/ground/water rule is applied uniformly, a documented limitation noted in the function docstring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
807 lines
35 KiB
Python
807 lines
35 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
|
||
|
||
# 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")]
|