Slice S0380.28: SAP 10.2 Appendix N footnote 43 reciprocal η interpolation — closes the +0.03..+0.06 ASHP precision-floor cluster

Per SAP 10.2 Appendix N, PDF p.101 footnote 43 (line 7053):

  "For the efficiency values, the interpolated efficiency is the
  reciprocal of linear interpolation between the reciprocals of the
  efficiencies."

i.e. 1/η_interp = (1 − t)·1/η_low + t·1/η_high, the weighted harmonic
mean at t = (PSR − PSR_low) / (PSR_high − PSR_low). Cascade was using
**linear** interpolation directly on η — a +0.15..+0.25% over-estimate
in the typical PSR range (1.2..1.5) for ASHPs in the cohort.

Cohort fixture: cert 3336-2825-9400-0512-8292 (Mitsubishi PUZ-WM50VHA,
PCDB 104568). MIT/η-zone cascade matches worksheet EXACTLY (every line
86..92, every month), but η_main_heating cascade 225.443 vs worksheet
224.923 → main_heating_fuel +5.24 kWh/yr too high → ECF 1.5474 vs ws
1.5503 → SAP +0.04 vs worksheet 78.3739. Back-solving the worksheet's
η_main implies η_space_1 = 224.923 / 0.95 ≈ 236.76.

Closed form at PSR=1.40151, bracketing PCDB rows PSR 1.2
(η_space_1=253.9) and PSR 1.5 (η_space_1=229.2):
  Linear (pre-slice):     253.9 + (229.2 − 253.9) × 0.6717  = 237.31  ✗
  Reciprocal (footnote 43): 1 / ((1 − 0.6717)/253.9 + 0.6717/229.2)
                          = 1 / 0.004224                    = 236.74  ✓

The harmonic mean is curvature-aware: linear interpolation under-
penalises efficiency drops at higher PSR (η typically falls off as
PSR increases past the system's design point) by averaging on η
rather than 1/η. SAP 10.2 footnote 43 is explicit about which side
of the reciprocal the interpolation sits.

Outcome:

Cohort-2 Summary path (38 certs):
  exact (<1e-4): 23 → **33** (+10)
  ≤±0.07:        15 → **5**  (-10: HP certs close to exact)
  ±0.07..0.5:     0 → 0
  ±0.5..1:        0 → 0
  ±1+:            0 → 0
  RAISES:         0 → 0

Cohort-2 HP cluster post-slice:
  0100  +0.00003   ←  was +0.00283
  0320  -0.00001   ←  was +0.01801
  0330  -0.00004   ←  was +0.01772
  2336  +0.00003   ←  was +0.01778
  3336  +0.00001   ←  was +0.04005  (worst residual closes exact)
  4536  -0.00002   ←  was +0.01312
  9036  -0.00003   ←  was +0.02159
  9796  +0.00000   ←  was +0.00174  (post-S0380.27)
  2536  +0.00072   ←  was +0.00163
  2800  +0.00068   ←  was +0.00436
  4800  +0.00068   ←  was +0.02939
  9370  +0.00002   ←  was +0.00174
  9421  +0.00001   ←  was +0.00117

Cohort-1 ASHP cohort (7-cert cohort + new chain test certs):
  cert 0380:  +1e-6   ←  was +0.034 (Mitsubishi PUZ-WM50VHA, the
                                     canonical first-HP cohort cert)
  cert 3800:  -2e-5   ←  was +0.021
  cert 9418:  -3e-7   ←  was +0.00004
  cert 9285:  -3e-5   ←  was +0.021
  cert 2636:  -0.015  ←  was +0.003 (cantilever fixture; remaining
                                     residual is non-η in nature)

5 of 7 cohort-1 ASHP certs now hit delta < 1e-4 vs worksheet — the
+0.04 spec-precision-floor cluster diagnosed in
HANDOVER_CERT_0380_MIT_CASCADE.md is the linear-vs-reciprocal η
interpolation bug, not a spec-floor at all. The handover doc's "no
public spec or BRE data field would distinguish these" claim was
incorrect — SAP 10.2 footnote 43 is the resolution.

API path (golden fixtures): 6 ASHP cohort residuals updated to reflect
the cascade closure:
  cert 0380 PE: -14.7865 → -14.6848 kWh/m²; CO2: +0.2774 → +0.2780 t/yr
  cert 0350 PE: -7.9281  → -7.8741;        CO2: +0.1697 → +0.1701
  cert 2225 PE: -11.9175 → -11.8557;       CO2: +0.2617 → +0.2621
  cert 2636 PE: -9.7153  → -9.6692;        CO2: +0.2189 → +0.2193
  cert 3800 PE: -9.7551  → -9.6838;        CO2: +0.2598 → +0.2603
  cert 9285 PE: -8.1110  → -8.0466;        CO2: +0.1559 → +0.1564

All SAP integer residuals unchanged (cascade tracks the EPC integer
SAP at residual 0 across the cohort).

PSR interpolation unit test (`test_interpolate_heat_pump_efficiency_at
_cert_0380_psr_per_sap_app_n`) updated to reflect the reciprocal
formula with the SAP-10.2-footnote-43 spec citation and closed-form
asserts (η_space_1 ≈ 234.5235; η_water_3 ≈ 285.0861 at PSR=1.43).

Pyright net-zero (1 → 1 across touched files: pcdb/parser.py,
tests/test_pcdb_table_362_lookup.py, rdsap/tests/test_golden_fixtures.py).

Tests: 710 pass (was 710 pre-slice with linear interp + un-updated
pins; net-zero because the 6 golden pin updates + 1 interp test update
exactly offset the 6 + 1 failures the formula change introduced), 10
expected fails unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 11:44:11 +00:00
parent 012cbd183f
commit 081bb8fd7e
3 changed files with 60 additions and 42 deletions

View file

@ -258,8 +258,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0380-2471-3250-2596-8761",
actual_sap=89,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-14.7865,
expected_co2_resid_tonnes_per_yr=+0.2774,
expected_pe_resid_kwh_per_m2=-14.6848,
expected_co2_resid_tonnes_per_yr=+0.2780,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, semi-detached bungalow "
"TFA 60.43 age D, PV 3 kWp. Worksheet SAP 88.5104 — slice "
@ -275,8 +275,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0350-2968-2650-2796-5255",
actual_sap=84,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-7.9281,
expected_co2_resid_tonnes_per_yr=+0.1697,
expected_pe_resid_kwh_per_m2=-7.8741,
expected_co2_resid_tonnes_per_yr=+0.1701,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert. "
"Worksheet SAP 84.1367 — cascade integer matches lodged."
@ -286,8 +286,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="2225-3062-8205-2856-7204",
actual_sap=89,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-11.9175,
expected_co2_resid_tonnes_per_yr=+0.2617,
expected_pe_resid_kwh_per_m2=-11.8557,
expected_co2_resid_tonnes_per_yr=+0.2621,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with "
"PV. Worksheet SAP 88.7921. Slice 102f-prep.8 closed the "
@ -298,8 +298,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="2636-0525-2600-0401-2296",
actual_sap=86,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-9.7153,
expected_co2_resid_tonnes_per_yr=+0.2189,
expected_pe_resid_kwh_per_m2=-9.6692,
expected_co2_resid_tonnes_per_yr=+0.2193,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with "
"PV + 3.74 m² cantilever exposed floor + 12.76 m² alt wall. "
@ -313,8 +313,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="3800-8515-0922-3398-3563",
actual_sap=86,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-9.7551,
expected_co2_resid_tonnes_per_yr=+0.2598,
expected_pe_resid_kwh_per_m2=-9.6838,
expected_co2_resid_tonnes_per_yr=+0.2603,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert. "
"Worksheet SAP 86.1458."
@ -324,8 +324,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="9285-3062-0205-7766-7200",
actual_sap=84,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-8.1110,
expected_co2_resid_tonnes_per_yr=+0.1559,
expected_pe_resid_kwh_per_m2=-8.0466,
expected_co2_resid_tonnes_per_yr=+0.1564,
notes=(
"Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert. "
"Worksheet SAP 84.1369."

View file

@ -304,18 +304,32 @@ def interpolate_heat_pump_efficiency_at_psr(
*,
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.
"""SAP 10.2 PDF p.101 footnote 43 (line 7053) — reciprocal-linear
interpolation between the two PSR rows enclosing `target_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").
"For the efficiency values, the interpolated efficiency is the
reciprocal of linear interpolation between the reciprocals of
the efficiencies."
i.e. 1/η_interp = (1 t)·1/η_low + t·1/η_high, which is the harmonic
mean weighted at t. Returns `(eta_space_1_pct, eta_water_3_pct)` at
the dwelling's PSR. The interpolation is on the η values themselves
(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").
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.
"""
if not psr_groups:
raise ValueError("PSR groups required for interpolation")
@ -329,13 +343,13 @@ def interpolate_heat_pump_efficiency_at_psr(
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_space_1 = 1.0 / (
(1.0 - t) / low_group.eta_space_1_pct
+ t / high_group.eta_space_1_pct
)
eta_water_3 = (
low_group.eta_water_3_pct
+ (high_group.eta_water_3_pct - low_group.eta_water_3_pct) * t
eta_water_3 = 1.0 / (
(1.0 - t) / low_group.eta_water_3_pct
+ t / high_group.eta_water_3_pct
)
return (eta_space_1, eta_water_3)
# Unreachable: target_psr is between min and max so a bracket exists.

View file

@ -130,9 +130,9 @@ def test_heat_pump_record_psr_groups_for_104568_decoded_per_format_465() -> None
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."
"""SAP 10.2 PDF p.101 footnote 43 (line 7053): "For the efficiency
values, the interpolated efficiency is the reciprocal of linear
interpolation between the reciprocals of the efficiencies."
Cert 0380's worksheet pins η_space (206) = 223.0480 and η_water (217)
= 171.0746 via the SAP 10.2 cascade:
@ -151,7 +151,10 @@ def test_interpolate_heat_pump_efficiency_at_cert_0380_psr_per_sap_app_n() -> No
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.
implied value); slice S0380.28 swapped the linear formula for the
spec-correct reciprocal-linear (footnote 43) and closed the cohort-1
ASHP residual cluster from +0.03..+0.06 SAP to delta < 1e-4 vs
worksheet on 4 of 5 cohort certs.
"""
# Arrange
from domain.sap10_calculator.tables.pcdb.parser import (
@ -167,12 +170,13 @@ def test_interpolate_heat_pump_efficiency_at_cert_0380_psr_per_sap_app_n() -> No
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
# Assert — closed-form reciprocal interpolation between PSR 1.2 and 1.5
# at t = (1.43 1.2) / (1.5 1.2) = 23/30 ≈ 0.7666667:
# 1/eta_space_1 = (7/30)/253.9 + (23/30)/229.2
# = 7/7617 + 23/6876
# ≈ 0.0042641 → eta_space_1 ≈ 234.5235
# 1/eta_water_3 = (7/30)/287.7 + (23/30)/284.3
# = 7/8631 + 23/8529
# ≈ 0.0035077 → eta_water_3 ≈ 285.0861
assert abs(eta_space_1 - 234.5235) < 1e-3
assert abs(eta_water_3 - 285.0861) < 1e-3