mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
76fdab42de
commit
70aa709c1c
3 changed files with 201 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
67
domain/sap10_calculator/tests/test_pcdb_table_362_lookup.py
Normal file
67
domain/sap10_calculator/tests/test_pcdb_table_362_lookup.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue