From 51d8f65aac70c51de2de9b086dd71f9ad18bf528 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 29 Jun 2026 15:13:16 +0000 Subject: [PATCH] =?UTF-8?q?fix(pcdb):=20extend=20heat-pump=20efficiency=20?= =?UTF-8?q?toward=20100%=20beyond=20table=20PSR=20range=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interpolate_heat_pump_efficiency_at_psr clamped to the smallest/largest PSR row when the dwelling's plant size ratio fell outside the record's range. That is the SAP 10.2 Appendix N rule for *combined heat-pump-and-boiler* packages, not for a plain air/ground/water source heat pump. Per Appendix N2 (PDF p.101, footnotes 44/45) a source heat pump whose PSR exceeds the record's largest value takes a reciprocal-linear interpolation between the largest-PSR efficiency and 100% at twice that PSR (100% beyond), and 100% when the PSR is below the record's smallest value. Both the space- and water-heating PSR-dependent efficiencies extend this way. Effect: an oversized heat pump in a small dwelling is no longer credited the full top-of-table COP. Accredited Elmhurst worksheet for cert 100110101713 (golden fixture case 56, PCDB 100061, PSR 3.107 over largest 2.0): (206) 334.4% -> 139.66% = Elmhurst exact. Corpus (RdSAP-21.0.1, n=1000) MAE 0.7397 -> 0.7258, within-0.5 0.7410 held; only two certs move (both oversized-PSR heat pumps), 100110101713 +18.32 -> -4.97. Exhaust-air and combined heat-pump-and-boiler packages have different boundary rules (straight-to-100% / clamp-to-edge) but are not distinguished by the current PCDB parse; the air/ground/water rule is applied uniformly, a documented limitation noted in the function docstring. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/sap10_calculator/tables/pcdb/parser.py | 64 +++++++++++++++---- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/domain/sap10_calculator/tables/pcdb/parser.py b/domain/sap10_calculator/tables/pcdb/parser.py index 4e34195f..27e1b3d1 100644 --- a/domain/sap10_calculator/tables/pcdb/parser.py +++ b/domain/sap10_calculator/tables/pcdb/parser.py @@ -263,6 +263,14 @@ _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 +# SAP 10.2 Appendix N2 (PDF p.101, footnotes 44/45): out-of-range PSR +# extension for air/ground/water source heat pumps. Above the record's +# largest PSR the efficiency is reciprocal-interpolated toward 100% at +# `_EXTENSION_PSR_MULTIPLE` × the largest PSR; below the smallest PSR, and +# beyond that multiple, the efficiency is the terminal 100%. +_EXTENSION_TERMINAL_EFFICIENCY_PCT: Final[float] = 100.0 +_EXTENSION_PSR_MULTIPLE: Final[float] = 2.0 + def _parse_psr_groups(raw: tuple[str, ...]) -> tuple[PsrEfficiencyGroup, ...]: """Decode the variable-length PSR-dependent block of a format-465 @@ -317,28 +325,60 @@ def interpolate_heat_pump_efficiency_at_psr( (not their reciprocals taken from PCDB), so the η_*_pct values must be strictly positive — every PCDB row in the cohort satisfies this. - Per spec PDF p.100 lines 7039-7072: 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"). + Out-of-range PSR (spec PDF p.101, footnotes 44/45 — air/ground/water + source heat pumps): + + - Below the smallest PSR in the record: "an efficiency of 100% + should be used if the PSR is less than the smallest value in the + database record." + - Above the largest PSR in the record: "an efficiency may be + obtained from linear interpolation between that at the largest + PSR in the data record and efficiency 100% at PSR two times the + largest PSR in the data record. If the PSR is greater than two + times the largest PSR in the data record an efficiency of 100% + should be used." The interpolation is reciprocal-linear too + (footnote 43), with 100% as the upper anchor. + + Both space- and water-heating PSR-dependent efficiencies extend the + same way. (Exhaust-air heat pumps and combined heat-pump-and-boiler + packages instead use 100% directly above the largest PSR, and combined + packages clamp to the edge rows; neither is distinguished by the + current PCDB parse, so the air/ground/water rule is applied uniformly + — a documented limitation. The dominant RdSAP cohort is air source.) Cohort fixture: cert 3336-2825-9400-0512-8292 (Mitsubishi PUZ-WM50VHA, PCDB 104568) — PSR 1.40151 brackets PCDB rows PSR 1.2 (η_space_1 = 253.9) and PSR 1.5 (η_space_1 = 229.2). Linear (pre-slice): 237.31; reciprocal (spec-faithful): 236.74 — matches worksheet (206)/(210) at 1e-4 once the 0.95 in-use factor is applied. + + Out-of-range anchor: PCDB 100061 (golden fixture case 56), largest PSR + 2.0 (η_space_1=352.0). At dwelling PSR 3.10665 the extension to 100% + at PSR 4.0 gives η_space_1 = 147.011 → (206) = 139.660, matching the + accredited Elmhurst worksheet (vs the old clamp's 352.0 → 334.4%). """ 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: + if target_psr < psr_groups[0].psr: + return (_EXTENSION_TERMINAL_EFFICIENCY_PCT, _EXTENSION_TERMINAL_EFFICIENCY_PCT) + if target_psr > psr_groups[-1].psr: last = psr_groups[-1] - return (last.eta_space_1_pct, last.eta_water_3_pct) + upper_psr = _EXTENSION_PSR_MULTIPLE * last.psr + if target_psr >= upper_psr: + return ( + _EXTENSION_TERMINAL_EFFICIENCY_PCT, + _EXTENSION_TERMINAL_EFFICIENCY_PCT, + ) + t = (target_psr - last.psr) / (upper_psr - last.psr) + eta_space_1 = 1.0 / ( + (1.0 - t) / last.eta_space_1_pct + + t / _EXTENSION_TERMINAL_EFFICIENCY_PCT + ) + eta_water_3 = 1.0 / ( + (1.0 - t) / last.eta_water_3_pct + + t / _EXTENSION_TERMINAL_EFFICIENCY_PCT + ) + return (eta_space_1, eta_water_3) 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