diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 507962cb..0c02f665 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -386,6 +386,25 @@ class _CorpusExpectation: # cascade-OK variants now SAP / cost / CO2 / PE EXACT on every metric. # Documented at `SAP_CALCULATOR.md §8.2` with the explicit single-cert # caveat (heating-systems corpus has only one §12.4.4 fixture). +# +# Slice S0380.165 closed the LAST sub-tolerance gap: `pcdb 1` (Δ−0.0108 +# SAP / +£0.24 / +1.33 CO2 / +5.70 PE → all ±0.0000 within 1e-4). SAP +# 10.2 §9.4.11 (PDF p.30) "boiler interlock": "The efficiency of gas and +# liquid fuel boilers for both space and water heating is reduced by 5% +# if the boiler is not interlocked." S0380.141 had subtracted the 5pp +# from BOTH `Pwinter` and `Psummer` BEFORE running the SAP 10.2 +# Appendix D §D2.1 Equation D1 monthly cascade. The Elmhurst worksheet +# for pcdb 1 (PCDB 716 oil boiler, Pwinter 65 / Psummer 53, Cylinder +# Stat=No → no interlock) shows the −5pp is applied to the η_water, +# monthly OUTPUT of Eq D1, NOT to its inputs — Eq D1's reciprocal +# weighting (1/η_winter and 1/η_summer) is non-linear in η, so the two +# interpretations diverge subtly. Worked example for pcdb 1 Jan +# (Q_space=1409.77, Q_water=387.86): +# Old cascade: Eq D1(60, 48, …) → 56.93% (off −0.04 pp vs worksheet) +# Worksheet: Eq D1(65, 53, …) → 61.97%, −5pp → 56.97% ✓ +# Across all 12 months the post-Eq-D1 form matches worksheet (217)m at +# 1e-4. Cascade HW kWh 7068.41 → 7063.96 (= worksheet (219) total) Δ +# −4.45 kWh propagates the closure. _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000), _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), @@ -401,7 +420,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=-0.0000, expected_pe_resid_kwh=+0.0000), _CorpusExpectation(variant='oil pcdb 3', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000), - _CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=-0.0108, expected_cost_resid_gbp=+0.2420, expected_co2_resid_kg=+1.3254, expected_pe_resid_kwh=+5.6974), + _CorpusExpectation(variant='pcdb 1', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+0.0000, expected_pe_resid_kwh=-0.0000), # Slice S0380.133 unblocked 10 solid-fuel variants by routing the # Elmhurst §14.0 "Main Heating EES Code" through the new # `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE` dict. Pre-slice the diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 540cabfd..09b06e2d 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -4818,6 +4818,7 @@ def _apply_water_efficiency( water_efficiency_pct: float, eq_d1_winter_summer_pct: Optional[tuple[float, float]], space_heating_monthly_useful_kwh: tuple[float, ...], + interlock_penalty_pp: float = 0.0, ) -> float: """Divide §4 (64)m by the appropriate efficiency to land HW fuel kWh. @@ -4826,7 +4827,21 @@ def _apply_water_efficiency( fallback (`tables.table_4b.table_4b_seasonal_efficiencies_pct`) — use the SAP 10.2 Appendix D §D2.1 (2) Equation D1 monthly cascade. Otherwise stay on the legacy scalar `water_efficiency_pct` divisor - (single-value PCDB summer eff, Table 4a inherit, etc.).""" + (single-value PCDB summer eff, Table 4a inherit, etc.). + + `interlock_penalty_pp` is the SAP 10.2 §9.4.11 (PDF p.30) "Boiler + interlock" -5pp reduction (or 0 when the boiler IS interlocked). + Pre-S0380.165 the caller subtracted the penalty from the (winter, + summer) PCDB efficiencies BEFORE passing them in. The Elmhurst P960 + worksheet for pcdb 1 (PCDB 716, Pwinter 65 / Psummer 53, Cylinder + Stat=No → no interlock) shows the -5pp applied to the η_water, + monthly OUTPUT of Eq D1, NOT to its inputs — the two interpretations + diverge because Eq D1 weights `1/η_winter` and `1/η_summer` + reciprocally and the penalty does not commute with the reciprocal + interp. The helper now takes the raw seasonal efficiencies + the + penalty separately, runs Eq D1 on the raw inputs, then subtracts + `interlock_penalty_pp / 100` from each monthly eff before dividing. + Matches worksheet (217)m for pcdb 1 to 1e-4 across all 12 months.""" if water_efficiency_pct <= 0: return 0.0 if eq_d1_winter_summer_pct is not None: @@ -4837,8 +4852,9 @@ def _apply_water_efficiency( space_heating_monthly_useful_kwh=space_heating_monthly_useful_kwh, water_heating_output_monthly_kwh=wh_output_monthly_kwh, ) + penalty_frac = interlock_penalty_pp / 100.0 return sum( - output / eff if eff > 0 else 0.0 + output / max(eff - penalty_frac, 1e-9) if eff > 0 else 0.0 for output, eff in zip(wh_output_monthly_kwh, monthly_eff) ) return wh_output_annual_kwh / water_efficiency_pct @@ -5348,16 +5364,16 @@ def cert_to_inputs( ) if no_interlock and pcdb_main is not None: eff -= 0.05 - # §9.4.11 -5pp interlock applies symmetrically to both winter and - # summer columns of the Equation D1 input — matches worksheet - # (217)m for pcdb 1 (PCDB 716 winter 65 / summer 53 → 60 / 48). - # No -5pp on the Table 4b branch when interlock is present (oil 1 - # cert has cylinder thermostat → interlock OK → no adjustment). - if no_interlock and eq_d1_winter_summer_pct is not None: - eq_d1_winter_summer_pct = ( - eq_d1_winter_summer_pct[0] - 5.0, - eq_d1_winter_summer_pct[1] - 5.0, - ) + # SAP 10.2 §9.4.11 -5pp interlock is applied to the Eq D1 OUTPUT + # via `_apply_water_efficiency`'s `interlock_penalty_pp` kwarg — + # NOT pre-subtracted from the Pwinter / Psummer inputs. The two + # forms differ because Eq D1's reciprocal weighting is non-linear + # in η; the worksheet's (217)m for pcdb 1 matches the post-Eq-D1 + # form. See `_apply_water_efficiency` docstring + S0380.165 commit. + eq_d1_interlock_penalty_pp = ( + 5.0 if no_interlock and eq_d1_winter_summer_pct is not None + else 0.0 + ) # SAP 10.2 Appendix N3.6 + N3.7(a) — when an HP cert lodges a PCDB # Table 362 record, the cascade replaces the Table 4a defaults with # APM-interpolated η_space and η_water at the dwelling's PSR. @@ -5542,6 +5558,7 @@ def cert_to_inputs( water_efficiency_pct=water_eff, eq_d1_winter_summer_pct=eq_d1_winter_summer_pct, space_heating_monthly_useful_kwh=space_heating_monthly_useful_kwh, + interlock_penalty_pp=eq_d1_interlock_penalty_pp, ) # SAP 10.2 §12.4.4 (PDF p.36-37) — back-boiler HW kWh splits at # boiler efficiency (Oct-May) + 100% electric immersion (Jun-Sep). diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 856e79c1..a85e1edb 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -1845,6 +1845,106 @@ def test_section_12_4_4_summer_immersion_applies_to_back_boiler_combos() -> None ) is False +def test_apply_water_efficiency_applies_interlock_penalty_after_equation_d1() -> None: + # Arrange — SAP 10.2 §9.4.11 (PDF p.30) "Boiler interlock": "The + # efficiency of gas and liquid fuel boilers for both space and water + # heating is reduced by 5% if the boiler is not interlocked for + # space and water heating." Table 4c (PDF p.169-170) Note c) "These + # do not accumulate as no thermostatic control or presence of a + # bypass means that there is no boiler interlock." + # + # Pre-slice the cascade subtracted 5pp from BOTH Pwinter and Psummer + # BEFORE running the SAP 10.2 Appendix D §D2.1 Equation D1 monthly + # cascade. The Elmhurst P960 worksheet for pcdb 1 (PCDB 716, Pwinter + # 65 / Psummer 53, Cylinder Stat=No → no interlock) shows the -5pp + # is applied to the η_water,monthly OUTPUT of Eq D1, NOT to its + # inputs. The two interpretations diverge because Eq D1's reciprocal- + # weighted form is non-linear in η: + # + # Worksheet: Pwinter_raw=65 / Psummer_raw=53 → Eq D1 monthly → + # η_m_raw_jan = 61.9725, → −5pp → 56.9725 (217)m_jan ✓ + # + # Old cascade: 60 / 48 → Eq D1 monthly → + # η_m_jan = 56.9292 (off by −0.043 pp) + # + # Verified across all 12 months for pcdb 1: post-Eq-D1 -5pp matches + # worksheet (217)m to 1e-4 every month; total (219) = 7063.96 vs + # cascade pre-slice 7068.41 (Δ +4.45 kWh, source of the −0.011 SAP / + # +£0.24 / +1.33 CO2 / +5.7 PE pcdb 1 corpus residual pin). + # + # Worksheet (98c)m + (64)m + Pwinter / Psummer (raw): + + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _apply_water_efficiency, # pyright: ignore[reportPrivateUsage] + ) + + pcdb1_q_space = (1409.7675, 1178.9199, 1092.7588, 761.5542, 474.6862, + 0.0, 0.0, 0.0, 0.0, 642.4138, 1028.9740, 1415.7149) + pcdb1_q_water = (387.8612, 345.4882, 372.5001, 339.6744, 337.7296, + 231.1325, 230.5321, 237.6818, 238.0910, 346.4331, + 355.2277, 385.1451) + pwinter_raw = 65.0 + psummer_raw = 53.0 + worksheet_219_total = 7063.9614 + + # Act — invoke the helper with the raw seasonal efficiencies + + # explicit interlock penalty applied post-Eq D1. + hw_fuel_kwh = _apply_water_efficiency( + wh_output_monthly_kwh=pcdb1_q_water, + wh_output_annual_kwh=sum(pcdb1_q_water), + water_efficiency_pct=psummer_raw / 100.0, + eq_d1_winter_summer_pct=(pwinter_raw, psummer_raw), + space_heating_monthly_useful_kwh=pcdb1_q_space, + interlock_penalty_pp=5.0, + ) + + # Assert — fuel total reproduces the worksheet (219) sum within 1e-3. + assert abs(hw_fuel_kwh - worksheet_219_total) <= 1e-3, ( + f"cascade {hw_fuel_kwh:.4f} vs worksheet {worksheet_219_total:.4f}" + ) + + +def test_apply_water_efficiency_interlock_penalty_zero_keeps_raw_eq_d1() -> None: + # Arrange — when interlock is present (interlock_penalty_pp = 0) + # Eq D1 cascade runs unchanged on the raw seasonal efficiencies. + # Cohort-2 / U985 fixtures lodge cylinder thermostat = Yes; their + # cascade falls through this path with no penalty (S0380.141's + # gate keeps interlock-OK certs at raw η). Guards the no-penalty + # path so it matches the spec-literal Eq D1 reference. + + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _apply_water_efficiency, # pyright: ignore[reportPrivateUsage] + ) + from domain.sap10_calculator.worksheet.water_heating import ( + water_efficiency_monthly_via_equation_d1, + ) + + q_space = (1000.0, 800.0, 700.0, 500.0, 300.0, 0.0, 0.0, 0.0, 0.0, + 400.0, 700.0, 1000.0) + q_water = (300.0, 280.0, 290.0, 270.0, 260.0, 200.0, 195.0, 205.0, + 210.0, 270.0, 285.0, 305.0) + + # Act — no penalty + hw_fuel = _apply_water_efficiency( + wh_output_monthly_kwh=q_water, + wh_output_annual_kwh=sum(q_water), + water_efficiency_pct=0.53, + eq_d1_winter_summer_pct=(85.0, 80.0), + space_heating_monthly_useful_kwh=q_space, + interlock_penalty_pp=0.0, + ) + + # Assert — expected = direct Eq D1 cascade without any penalty + monthly_eff = water_efficiency_monthly_via_equation_d1( + winter_efficiency_pct=85.0, + summer_efficiency_pct=80.0, + space_heating_monthly_useful_kwh=q_space, + water_heating_output_monthly_kwh=q_water, + ) + expected = sum(qw / e for qw, e in zip(q_water, monthly_eff)) + assert abs(hw_fuel - expected) <= 1e-9 + + def test_section_12_4_4_hw_blend_mirrors_elmhurst_summer_annual_pe_co2_double_count() -> None: # Arrange — SAP 10.2 §12.4.4 back-boiler combos with the boiler heating # the cylinder Oct-May + an electric immersion Jun-Sep. The spec-literal