diff --git a/domain/sap10_calculator/tables/pcdb/__init__.py b/domain/sap10_calculator/tables/pcdb/__init__.py index 4382fffd..4e7773e1 100644 --- a/domain/sap10_calculator/tables/pcdb/__init__.py +++ b/domain/sap10_calculator/tables/pcdb/__init__.py @@ -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) diff --git a/domain/sap10_calculator/tables/pcdb/parser.py b/domain/sap10_calculator/tables/pcdb/parser.py index 29bf20d2..51198e2c 100644 --- a/domain/sap10_calculator/tables/pcdb/parser.py +++ b/domain/sap10_calculator/tables/pcdb/parser.py @@ -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=; + 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 diff --git a/domain/sap10_calculator/tests/test_pcdb_table_362_lookup.py b/domain/sap10_calculator/tests/test_pcdb_table_362_lookup.py new file mode 100644 index 00000000..9d69d874 --- /dev/null +++ b/domain/sap10_calculator/tests/test_pcdb_table_362_lookup.py @@ -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= +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