Slice 102c.2: PCDB Table 362 PSR groups + APM linear interpolation

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).
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 12:01:04 +00:00
parent 70aa709c1c
commit 5b78a1e2c8
2 changed files with 211 additions and 0 deletions

View file

@ -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,
)

View file

@ -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