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