diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 7aa9718a..c9c5f87f 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -228,7 +228,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+0.1017, expected_cost_resid_gbp=-2.3444, expected_co2_resid_kg=+7.6424, expected_pe_resid_kwh=+3.0976), _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=+0.0941, expected_cost_resid_gbp=-2.1679, expected_co2_resid_kg=+7.9230, expected_pe_resid_kwh=+6.5824), _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=+0.1199, expected_cost_resid_gbp=-2.7611, expected_co2_resid_kg=+6.8225, expected_pe_resid_kwh=-4.5085), - _CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+0.9373, expected_cost_resid_gbp=-21.5977, expected_co2_resid_kg=-34.9751, expected_pe_resid_kwh=-418.9168), + _CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=-0.0178, expected_cost_resid_gbp=+0.4092, expected_co2_resid_kg=+7.0616, expected_pe_resid_kwh=+33.5171), _CorpusExpectation(variant='oil 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 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), diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 5764c17e..e70a4add 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2081,6 +2081,35 @@ _WATER_INHERIT_FROM_MAIN_CODES: Final[frozenset[int]] = frozenset({901, 902, 914 _WHC_FROM_MAIN_HEATING: Final[int] = 901 +# SAP 10.2 Table 4a (PDF p.163-164) — heat-pump rows have TWO efficiency +# columns ("space" and "water"). For low-temperature ground/water-source +# HPs (codes 211, 213) and all gas-fired HPs (215, 216, 217) the water +# column is lower than the space column because the HP loses efficiency +# raising water to ~55°C DHW temperatures vs ~35°C space-heating flow. +# Mirror in Category 5 warm-air HPs (codes 521, 523, 525, 526, 527). +# +# When WHC ∈ {901, 902, 914} ("HW from main heating") the cascade +# inherits the main system's efficiency for HW. For Table 4a HP codes +# the inherit must consult this Water column, NOT the Space column. +# `seasonal_efficiency` returns the Space column verbatim; this dict +# overrides for the codes where the two columns diverge. +_TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY: Final[dict[int, float]] = { + # Electric heat pumps with flow temperature <= 35°C + 211: 1.70, # Ground source HP (space 230) + 213: 1.70, # Water source HP (space 230) + # Gas-fired heat pumps with flow temperature <= 35°C + 215: 0.84, # Ground source HP (space 120) + 216: 0.84, # Water source HP (space 120) + 217: 0.77, # Air source HP (space 110) + # Category 5 warm-air heat pumps — same shape as Category 4 + 521: 1.70, # Electric GSHP warm-air (space 230) + 523: 1.70, # Electric WSHP warm-air (space 230) + 525: 0.84, # Gas-fired GSHP warm-air (space 120) + 526: 0.84, # Gas-fired WSHP warm-air (space 120) + 527: 0.77, # Gas-fired ASHP warm-air (space 110) +} + + def _water_efficiency_with_category_inherit( *, water_heating_code: Optional[int], @@ -2094,10 +2123,22 @@ def _water_efficiency_with_category_inherit( when `sap_main_heating_code` is None. The legacy water_heating_efficiency only passes main_code through and so collapses heat pumps (cat 4) + no-code lodgements into the 0.80 gas-boiler default. + + SAP 10.2 Table 4a (PDF p.163-164) heat-pump rows split efficiency + into Space and Water columns. For Table 4a HP codes with diverging + columns (`_TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY`) we return the + Water value directly; `seasonal_efficiency` returns the Space value + so unconditionally inheriting through it gives the wrong number for + DHW (HP loses efficiency at higher DHW temperatures). """ if water_heating_code is None: return _legacy_water_heating_efficiency(None, main_code) if water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES: + if ( + main_code is not None + and main_code in _TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY + ): + return _TABLE_4A_HEAT_PUMP_WATER_EFFICIENCY[main_code] return seasonal_efficiency(main_code, main_category, main_fuel) return _legacy_water_heating_efficiency(water_heating_code, main_code) 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 0636cc66..87019c0c 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -56,6 +56,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _separately_timed_dhw, # pyright: ignore[reportPrivateUsage] _space_heating_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] _tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage] + _water_efficiency_with_category_inherit, # pyright: ignore[reportPrivateUsage] _water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage] cert_to_demand_inputs, cert_to_inputs, @@ -1629,6 +1630,99 @@ def test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b() assert sep_immersion is False +def test_water_efficiency_uses_table_4a_water_column_for_heat_pumps_per_sap_10_2() -> None: + # Arrange — SAP 10.2 Table 4a (PDF p.163-164) gives heat pumps two + # efficiency columns: "space" and "water". For low-temperature + # ground-source / water-source heat pumps and gas-fired heat pumps + # the columns differ: + # + # Code System space water + # 211 Ground source HP with flow temp <= 35°C 230 170 + # 213 Water source HP with flow temp <= 35°C 230 170 + # 215 Gas-fired GSHP with flow temp <= 35°C 120 84 + # 216 Gas-fired WSHP with flow temp <= 35°C 120 84 + # 217 Gas-fired ASHP with flow temp <= 35°C 110 77 + # 521 Warm-air electric GSHP 230 170 + # 523 Warm-air electric WSHP 230 170 + # 525 Warm-air gas-fired GSHP 120 84 + # 526 Warm-air gas-fired WSHP 120 84 + # 527 Warm-air gas-fired ASHP 110 77 + # + # Heat pumps lose efficiency when raising water to DHW temperatures + # (typically 55-60°C) vs space-heating flow temperatures (~35°C for + # the low-temp codes). The split columns reflect this real physics. + # + # Pre-slice `_water_efficiency_with_category_inherit` routed WHC=901 + # ("HW from main heating") through `seasonal_efficiency(main_code)` + # — which only consults the SPACE column. For SAP code 211 the + # cascade returned 2.30 (= space) when the spec requires 1.70 + # (= water). HW fuel kWh undercounted by 26%: cascade HW for the + # heating-systems corpus gshp variant was 841.47 kWh vs worksheet + # 1138.46 kWh. + # + # ASHP "in other cases" (codes 214, 224) and 525-526 unchanged in + # space-vs-water terms keep returning the same value via the + # legacy `seasonal_efficiency` path — no regression risk. + + # Act / Assert — codes where Table 4a space ≠ water route through + # the water column when WHC inherits from main heating. + expected_water = { + 211: 1.70, 213: 1.70, 215: 0.84, 216: 0.84, 217: 0.77, + 521: 1.70, 523: 1.70, 525: 0.84, 526: 0.84, 527: 0.77, + } + for code, expected in expected_water.items(): + result = _water_efficiency_with_category_inherit( + water_heating_code=901, + main_code=code, + main_category=4, + main_fuel=30, + ) + assert abs(result - expected) <= 1e-9, ( + f"Table 4a code {code}: WHC=901 should return water " + f"efficiency {expected} (space-vs-water split per spec); " + f"got {result}" + ) + + # Codes with space == water (214, 221, 223, 224, 524) are unchanged. + # Verifies the fix preserves the legacy path for these. + for code, expected in ((214, 1.70), (221, 1.70), (224, 1.70), + (524, 1.70)): + result = _water_efficiency_with_category_inherit( + water_heating_code=901, + main_code=code, + main_category=4, + main_fuel=30, + ) + assert abs(result - expected) <= 1e-9, ( + f"Table 4a code {code}: water efficiency stays at {expected} " + f"(space = water column); got {result}" + ) + + # Non-HP codes (e.g. solid-fuel boiler 158 / gas-combi 102) keep + # inheriting via `seasonal_efficiency` since their Table 4a/4b row + # has a single efficiency column. + sf_result = _water_efficiency_with_category_inherit( + water_heating_code=901, main_code=158, main_category=None, + main_fuel=15, + ) + assert abs(sf_result - 0.65) <= 1e-9, ( + f"Solid-fuel back-boiler (158) water eff should be Table 4a " + f"column (0.65); got {sf_result}" + ) + + # WHC outside the inherit-from-main set (e.g. 903 = HW from separate + # immersion) ignores the heat-pump fix and uses the WHC's own + # efficiency from Table 4a's HW section. + immersion_result = _water_efficiency_with_category_inherit( + water_heating_code=903, main_code=211, main_category=4, + main_fuel=30, + ) + assert abs(immersion_result - 1.00) <= 1e-9, ( + f"WHC=903 (separate electric immersion) bypasses HP main " + f"efficiency lookup → 100%; got {immersion_result}" + ) + + def test_section_12_4_4_summer_immersion_applies_to_back_boiler_combos() -> None: # Arrange — SAP 10.2 §12.4.4 (PDF p.36-37) names two Table 4a back- # boiler combos that route DHW through an electric immersion Jun-Sep