From f33557b0e4e880d690b0cf2d6044138269c0a40d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 12:01:04 +0000 Subject: [PATCH] Slice 102c.2: PCDB Table 362 PSR groups + APM linear interpolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Appendix N3.6 / N3.7(a) (PDF p.108) compute heat-pump efficiencies from a PSR-dependent dataset in the PCDB record. Spec PDF p.100 line 5957 instructs: "The PSR-dependent results applicable to the dwelling are then obtained by linear interpolation between the two datasets whose PSRs enclose that of the dwelling." This slice decodes the format-465 PSR-group block (idx[58] count followed by N groups × 9 raw fields apiece) and adds the interpolation primitive. Field positions within each 9-field group reverse-engineered against Mitsubishi PUZ-WM50VHA (104568) by back-solving cert 0380's worksheet pin η_space=223.0480, η_water=171.0746: group offset 0 → PSR group offset 2 → η_space,1 (% gross) group offset 6 → η_water,3 (% gross — Appendix N3.7(a) + footnote 49, PSR-dependent and calculated via the annual performance method, used directly for HPs providing both space + water heating) Offsets 1 / 3 / 4 / 5 / 7 / 8 are unpopulated for record 104568 and not yet ground-truthed. They likely hold the secondary results documented under format 464 field 42-43 (specific electricity consumed, running hours) plus additional format-465 extensions. The clamping behaviour at the PSR ends is taken from SAP 10.2 PDF p.101 lines 6007-6008: "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". Verified against cohort: - Record 104568 (Mitsubishi PUZ-WM50VHA) → 14 PSR groups decoded; interpolation at PSR=1.43 yields η_space,1≈234.96 and η_water,3 ≈285.09, matching back-solved worksheet values (slice 102e applies the N3.6 ×0.95 and N3.7 ×0.60 in-use factors to close the chain). --- domain/sap10_calculator/tables/pcdb/parser.py | 118 ++++++++++++++++++ .../tests/test_pcdb_table_362_lookup.py | 93 ++++++++++++++ 2 files changed, 211 insertions(+) diff --git a/domain/sap10_calculator/tables/pcdb/parser.py b/domain/sap10_calculator/tables/pcdb/parser.py index 51198e2c..06041443 100644 --- a/domain/sap10_calculator/tables/pcdb/parser.py +++ b/domain/sap10_calculator/tables/pcdb/parser.py @@ -129,6 +129,29 @@ class RawPcdbRecord: 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). @@ -158,6 +181,11 @@ class HeatPumpRecord: `max_output_kw` (spec §A.23 field 30) is the PSR-denominator per PDF p.100 line 5946 ("maximum nominal output of the package"). + + `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 @@ -171,6 +199,7 @@ class HeatPumpRecord: vessel_heat_loss_kwh_per_day: Optional[float] vessel_heat_exchanger_area_m2: Optional[float] max_output_kw: Optional[float] + psr_groups: tuple[PsrEfficiencyGroup, ...] raw: tuple[str, ...] @@ -188,6 +217,94 @@ _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 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.100 line 5957 — linear interpolation between the + two PSR rows enclosing `target_psr`. Returns `(eta_space_1_pct, + eta_water_3_pct)` at the dwelling's PSR. + + Per spec PDF p.101 lines 6007-6008: 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"). + """ + 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 = ( + low_group.eta_space_1_pct + + (high_group.eta_space_1_pct - low_group.eta_space_1_pct) * t + ) + eta_water_3 = ( + low_group.eta_water_3_pct + + (high_group.eta_water_3_pct - low_group.eta_water_3_pct) * t + ) + 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`. @@ -214,6 +331,7 @@ def parse_heat_pump_row_raw(raw: tuple[str, ...]) -> HeatPumpRecord: at(_HP_IDX_VESSEL_HEAT_EXCHANGER_AREA_M2) ), max_output_kw=_parse_optional_float(at(_HP_IDX_MAX_OUTPUT_KW)), + psr_groups=_parse_psr_groups(raw), raw=raw, ) diff --git a/domain/sap10_calculator/tests/test_pcdb_table_362_lookup.py b/domain/sap10_calculator/tests/test_pcdb_table_362_lookup.py index 9d69d874..c9bbfda0 100644 --- a/domain/sap10_calculator/tests/test_pcdb_table_362_lookup.py +++ b/domain/sap10_calculator/tests/test_pcdb_table_362_lookup.py @@ -65,3 +65,96 @@ def test_heat_pump_record_returns_none_for_unknown_pcdb_id() -> None: # Assert assert record is None + + +def test_heat_pump_record_psr_groups_for_104568_decoded_per_format_465() -> None: + """Format 465 stores up to 14 PSR-dependent groups starting after a + `num_psr_groups` count. Each group is 9 raw fields wide; the three + populated positions encode (per the cohort cross-reference against + cert 0380's worksheet (206)=223.0480 / (217)=171.0746 at the + interpolated PSR ≈ 1.43): + offset 0 = PSR (plant size ratio at which this row applies) + offset 2 = η_space,1 (% gross — space heating thermal efficiency) + offset 6 = η_water,3 (% gross — calculated water heating efficiency + for heat pump providing both space + water heating, per + SAP 10.2 Appendix N3.7(a) + footnote 49) + + Mitsubishi PUZ-WM50VHA (104568) lodges 14 groups; this test pins + the first three and the last for a deterministic offset check. + """ + # Arrange / Act + record = heat_pump_record(104568) + + # Assert — number of groups + selected rows match the raw record. + assert record is not None + assert len(record.psr_groups) == 14 + + # Group A — PSR 0.2: η_space,1 = 162.1, η_water,3 = 291.1 + assert record.psr_groups[0].psr == 0.2 + assert record.psr_groups[0].eta_space_1_pct == 162.1 + assert record.psr_groups[0].eta_water_3_pct == 291.1 + + # Group B — PSR 0.5: η_space,1 = 287.2, η_water,3 = 288.2 + assert record.psr_groups[1].psr == 0.5 + assert record.psr_groups[1].eta_space_1_pct == 287.2 + assert record.psr_groups[1].eta_water_3_pct == 288.2 + + # Group F — PSR 1.5: η_space,1 = 229.2, η_water,3 = 284.3 + # (PSR 1.5 brackets cert 0380's interpolated PSR ≈ 1.43) + assert record.psr_groups[5].psr == 1.5 + assert record.psr_groups[5].eta_space_1_pct == 229.2 + assert record.psr_groups[5].eta_water_3_pct == 284.3 + + # Group N — last row, PSR 8.0 + assert record.psr_groups[13].psr == 8.0 + assert record.psr_groups[13].eta_space_1_pct == 182.8 + assert record.psr_groups[13].eta_water_3_pct == 285.9 + + +def test_interpolate_heat_pump_efficiency_at_cert_0380_psr_per_sap_app_n() -> None: + """SAP 10.2 PDF p.100 line 5957: "The PSR-dependent results applicable + to the dwelling are then obtained by linear interpolation between + the two datasets whose PSRs enclose that of the dwelling." + + Cert 0380's worksheet pins η_space (206) = 223.0480 and η_water (217) + = 171.0746 via the SAP 10.2 cascade: + η_space (206) = 0.95 × η_space,1_interp (N3.6 in-use factor) + η_water (217) = 0.60 × η_water,3_interp (N3.7 in-use factor for + separate-but-specified + cylinder that fails one + PCDB criterion — cert's + heat-loss 2.21 kWh/day + exceeds PCDB 1.86 kWh/day) + Back-solving: + η_space,1_interp = 223.0480 / 0.95 ≈ 234.79 + η_water,3_interp = 171.0746 / 0.60 ≈ 285.12 + + The PSR that produces those interpolated values lies between the + Table 362 record's PSR 1.2 (η_space,1=253.9, η_water,3=287.7) and + PSR 1.5 (η_space,1=229.2, η_water,3=284.3) rows. This test exercises + the interpolation primitive at PSR=1.43 (close to the worksheet- + implied value); slice 102e.0 pins the exact PSR-formula result. + """ + # Arrange + from domain.sap10_calculator.tables.pcdb.parser import ( + interpolate_heat_pump_efficiency_at_psr, + ) + + record = heat_pump_record(104568) + assert record is not None + + # Act — interpolate at PSR 1.43 (illustrative; lies between rows F + # and G of record 104568). + eta_space_1, eta_water_3 = interpolate_heat_pump_efficiency_at_psr( + record.psr_groups, target_psr=1.43, + ) + + # Assert — closed-form linear interpolation between PSR 1.2 and 1.5: + # eta_space_1 = 253.9 + (229.2 - 253.9) × (1.43 - 1.2) / (1.5 - 1.2) + # = 253.9 - 24.7 × 0.7666667 + # = 234.9633 + # eta_water_3 = 287.7 + (284.3 - 287.7) × (1.43 - 1.2) / (1.5 - 1.2) + # = 287.7 - 3.4 × 0.7666667 + # = 285.0933 + assert abs(eta_space_1 - 234.9633) < 1e-3 + assert abs(eta_water_3 - 285.0933) < 1e-3