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