Slice 102c.1: typed PCDB Table 362 (heat pumps) header parser

SAP 10.2 Appendix N (N3.6 / N3.7(a)) requires PSR-interpolated values
from PCDB Table 362 for any heat-pump cert. The published PCDF Spec
Rev 6b §A.23 documents format 464 for that table; the live
pcdb10.dat (April 2026) ships format 465, which extends 464 with
additional header fields between fields 11 and 12 and a larger PSR
group set. The parser-layer test pins the format-465 offsets against
the BRE web entry for Mitsubishi Ecodan 5.0 kW PUZ-WM50VHA
(pcdb_id=104568, the cohort's dominant heat-pump model — 6 of 7 ASHP
certs use it).

This slice lands only the header fields the downstream APM cascade
needs (PSR-group decoding + linear interpolation follow in slice 102c.2):

  field                            spec ref          format-465 idx
  brand_name                       §A.23 field 7     6
  model_name                       §A.23 field 8     7
  model_qualifier                  §A.23 field 9     8
  fuel                             §A.23 field 13    16
  service_provision                §A.23 field 17    22
  hw_vessel_mode                   §A.23 field 18    23
  vessel_volume_l                  §A.23 field 19    24
  vessel_heat_loss_kwh_per_day     §A.23 field 20    25
  vessel_heat_exchanger_area_m2    §A.23 field 21    26
  max_output_kw                    §A.23 field 30    47

`max_output_kw` is the PSR-denominator per SAP 10.2 PDF p.100 line 5946
("maximum nominal output of the package … divided by the design heat
loss of the dwelling"); BRE labels it "Output power @ -4.7°C" on the
web entry.

Cohort header parse verified end-to-end against BRE web ground truth
for record 104568. Identical field positions apply to the Daikin
EDLQ05CAV3 (102421, cert 9418), confirmed by spot-checking the
populated raw indices.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 11:56:06 +00:00
parent 76fdab42de
commit 70aa709c1c
3 changed files with 201 additions and 3 deletions

View file

@ -27,15 +27,27 @@ import json
from pathlib import Path
from typing import Final, Optional
from domain.sap10_calculator.tables.pcdb.parser import GasOilBoilerRecord
from domain.sap10_calculator.tables.pcdb.parser import (
GasOilBoilerRecord,
HeatPumpRecord,
parse_heat_pump_row_raw,
)
__all__ = ["GasOilBoilerRecord", "gas_oil_boiler_record"]
__all__ = [
"GasOilBoilerRecord",
"HeatPumpRecord",
"gas_oil_boiler_record",
"heat_pump_record",
]
_PCDB_DATA_DIR: Final[Path] = Path(__file__).resolve().parent / "data"
_TABLE_105_JSONL: Final[Path] = (
_PCDB_DATA_DIR / "pcdb_table_105_gas_oil_boilers.jsonl"
)
_TABLE_362_JSONL: Final[Path] = (
_PCDB_DATA_DIR / "pcdb_table_362_heat_pumps.jsonl"
)
def _load_table_105() -> dict[int, GasOilBoilerRecord]:
@ -80,3 +92,33 @@ def gas_oil_boiler_record(pcdb_id: int) -> Optional[GasOilBoilerRecord]:
the cert's index number is not in Table 105 — caller falls back to
Table 4a/4b category defaults via `seasonal_efficiency(...)`."""
return _TABLE_105_BY_ID.get(pcdb_id)
def _load_table_362() -> dict[int, HeatPumpRecord]:
"""Read the Table 362 NDJSON at import time and build a by-pcdb-id
dict of typed `HeatPumpRecord`s. Each NDJSON row carries the raw
field tuple parsed once at PCDB ETL time; we decode the format-465
positions here via the same `parse_heat_pump_row_raw` helper that
the parser-layer tests pin."""
records_by_id: dict[int, HeatPumpRecord] = {}
with _TABLE_362_JSONL.open(encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
data = json.loads(line)
raw = tuple(data["raw"])
record = parse_heat_pump_row_raw(raw)
records_by_id[record.pcdb_id] = record
return records_by_id
_TABLE_362_BY_ID: Final[dict[int, HeatPumpRecord]] = _load_table_362()
def heat_pump_record(pcdb_id: int) -> Optional[HeatPumpRecord]:
"""Table 362 lookup by `main_heating_index_number`. Returns None when
the cert's index number is not in Table 362 — caller falls back to a
Table 4a heat-pump category default (which in turn requires gateway
work elsewhere in the cascade)."""
return _TABLE_362_BY_ID.get(pcdb_id)

View file

@ -17,7 +17,7 @@ Reference: BRE PCDB pcdb10.dat April 2026; user-verified web records.
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from typing import Final, Optional
def _parse_optional_float(value: str) -> Optional[float]:
@ -129,6 +129,95 @@ class RawPcdbRecord:
raw: tuple[str, ...]
@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").
"""
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]
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
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 ""
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)),
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

View file

@ -0,0 +1,67 @@
"""Tests for the runtime PCDB Table 362 (heat pumps) lookup.
The lookup loads pcdb_table_362_heat_pumps.jsonl at import time and
caches a typed `HeatPumpRecord` per pcdb_id. Callers (`cert_to_inputs`)
will invoke `heat_pump_record(pcdb_id)` to obtain the record's typed
header fields and PSR-dependent efficiency groups for SAP 10.2
Appendix N (N3.6 / N3.7(a)).
Field positions are reverse-engineered from format 465 of pcdb10.dat
(2026 revision) by cross-referencing the BRE web entry at
https://www.ncm-pcdb.org.uk/sap/pcdbdetails.jsp?type=362&id=<pcdb_id>
against the raw row. Format 465 extends format 464 (documented in
PCDF Spec Rev 6b §A.23) with additional header fields between fields
11 and 12 and an extended PSR-group cardinality.
Reference: BRE PCDB pcdb10.dat (April 2026); ncm-pcdb.org.uk web records;
SAP 10.2 specification (14-03-2025) Appendix N3.6 / N3.7(a).
"""
from __future__ import annotations
from domain.sap10_calculator.tables.pcdb import heat_pump_record
def test_heat_pump_record_returns_verified_mitsubishi_ecodan_104568_header() -> None:
"""Mitsubishi Ecodan 5.0 kW (PUZ-WM50VHA), PCDB index 104568.
Header fields cross-referenced against the BRE web entry at
https://www.ncm-pcdb.org.uk/sap/pcdbdetails.jsp?type=362&id=104568:
brand_name: "Mitsubishi Electric"
model_name: "Ecodan 5.0 kW"
model_qualifier: "PUZ-WM50VHA"
fuel: 39 (electricity)
service_provision: 1 (space and water heating all year)
hw_vessel_mode: 2 (separate and specified vessel)
vessel_volume_l: 150
vessel_heat_loss_kwh_per_day: 1.86
vessel_heat_exchanger_area_m2: 3.0
max_output_kw: 4.39 (output power @ -4.7°C, the spec's "maximum
nominal output" used in PSR per PDF p.100
line 5946)
"""
# Arrange / Act
record = heat_pump_record(104568)
# Assert — header fields match BRE web ground truth.
assert record is not None
assert record.pcdb_id == 104568
assert record.brand_name == "Mitsubishi Electric"
assert record.model_name == "Ecodan 5.0 kW"
assert record.model_qualifier == "PUZ-WM50VHA"
assert record.fuel == 39
assert record.service_provision == 1
assert record.hw_vessel_mode == 2
assert record.vessel_volume_l == 150.0
assert record.vessel_heat_loss_kwh_per_day == 1.86
assert record.vessel_heat_exchanger_area_m2 == 3.0
assert record.max_output_kw == 4.39
def test_heat_pump_record_returns_none_for_unknown_pcdb_id() -> None:
"""An index number not in Table 362 returns None so callers can fall
back to a Table 4a heat-pump category default."""
# Arrange / Act
record = heat_pump_record(99999999)
# Assert
assert record is None