From 081bb8fd7e5245ac59bec44ecd2dd62ad8d16229 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 11:44:11 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.28:=20SAP=2010.2=20Appendix=20N?= =?UTF-8?q?=20footnote=2043=20reciprocal=20=CE=B7=20interpolation=20?= =?UTF-8?q?=E2=80=94=20closes=20the=20+0.03..+0.06=20ASHP=20precision-floo?= =?UTF-8?q?r=20cluster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../rdsap/tests/test_golden_fixtures.py | 24 +++++----- domain/sap10_calculator/tables/pcdb/parser.py | 48 ++++++++++++------- .../tests/test_pcdb_table_362_lookup.py | 30 +++++++----- 3 files changed, 60 insertions(+), 42 deletions(-) diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index 600eccbb..2a05962b 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -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." diff --git a/domain/sap10_calculator/tables/pcdb/parser.py b/domain/sap10_calculator/tables/pcdb/parser.py index 0d612461..7e8bc22f 100644 --- a/domain/sap10_calculator/tables/pcdb/parser.py +++ b/domain/sap10_calculator/tables/pcdb/parser.py @@ -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. 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 f3f8ffeb..fe3d40b6 100644 --- a/domain/sap10_calculator/tests/test_pcdb_table_362_lookup.py +++ b/domain/sap10_calculator/tests/test_pcdb_table_362_lookup.py @@ -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