diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 92158613..58a08efd 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -659,6 +659,19 @@ _INSTANTANEOUS_WATER_CODES: Final[frozenset[int]] = frozenset({907, 909}) # zero-loss list, so primary loss is zero whenever this code is lodged. _WHC_ELECTRIC_IMMERSION: Final[int] = 903 +# Water-heating codes for a dedicated "boiler/circulator for water +# heating only" — SAP 10.2 Table 4a hot-water section (PDF p.166): +# 911 gas, 912 liquid fuel, 913 solid fuel boiler/circulator; 921-931 +# range cooker with boiler for water heating only. Each is a heat +# generator feeding the cylinder through a primary loop, so SAP 10.2 +# Table 3 (PDF p.160) row 1 primary circuit loss applies — independent +# of the space-heating system (which for these certs is a separate main, +# e.g. electric storage heaters). 941 (electric HP for water only) is +# excluded: HP DHW vessels follow the Table 3 integral-vessel rules. +_WATER_HEATING_BOILER_CIRCULATOR_CODES: Final[frozenset[int]] = frozenset( + {911, 912, 913} | set(range(921, 932)) +) + # SAP 10.2 Appendix M equation (M1): EPV = 0.8 × kWp × S × ZPV, summed # per array. The module efficiency constant (0.8), orientation-dependent @@ -5110,6 +5123,18 @@ def _primary_loss_applies( # kWh/yr primary loss to a system with no primary circuit at all. if water_heating_code == _WHC_ELECTRIC_IMMERSION: return False + # SAP 10.2 Table 3 (PDF p.160) row 1 — a dedicated "boiler/circulator + # for water heating only" (WHC 911 gas / 912 liquid / 913 solid / + # 921-931 range cooker with boiler) is a heat generator feeding the + # cylinder through a primary loop, so the loss applies regardless of + # the space-heating main. Checked off `water_heating_code` (not + # `main`) because for these certs the resolved DHW `main` is the + # SPACE main (e.g. an electric storage heater, SAP code 402) — the + # gas/oil water boiler isn't a `main_heating_detail`. Simulated case + # 19 (storage main + WHS 911 + 210 L cylinder): worksheet (59) = 676.68 + # kWh/yr — zero before this branch. + if water_heating_code in _WATER_HEATING_BOILER_CIRCULATOR_CODES: + return True if main.main_heating_category == 4: if hp_record is None: # No PCDB record → assume separate-vessel (conservative; the diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 69afd8dc..fc95112c 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -60,6 +60,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _is_off_peak_meter, # pyright: ignore[reportPrivateUsage] _main_floor_u_value, # pyright: ignore[reportPrivateUsage] _other_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage] + _primary_loss_applies, # pyright: ignore[reportPrivateUsage] _rdsap_extract_fans_default, # pyright: ignore[reportPrivateUsage] _pv_overshading_factor, # pyright: ignore[reportPrivateUsage] _pv_pitch_deg, # pyright: ignore[reportPrivateUsage] @@ -1956,6 +1957,46 @@ def test_secondary_electric_off_peak_bills_at_table_12a_direct_acting_high_rate( assert abs(secondary_rate_gbp_per_kwh - 0.1529) <= 1e-6 +def test_sap_table_3_primary_loss_applies_to_dedicated_water_heating_boiler_circulator() -> None: + # Arrange — SAP 10.2 Table 3 (PDF p.160) row 1: primary circuit loss + # applies when "hot water is heated by a heat generator (e.g. boiler) + # connected to a hot water storage vessel via insulated or + # uninsulated pipes". The dedicated "boiler/circulator for water + # heating only" water-heating codes (Table 4a hot-water section, PDF + # p.166): 911 gas, 912 liquid fuel, 913 solid fuel, 921-931 range + # cooker with boiler — each is a boiler feeding the cylinder through a + # primary loop, so the loss applies regardless of what the SPACE + # heating system is. Simulated case 19 pairs electric storage heaters + # (SAP code 402) for space with a WHS 911 gas boiler/circulator for + # water: `_water_heating_main` resolves to the code-402 storage main + # (electric, no primary loop), so before this slice every dedicated- + # boiler branch missed the cylinder's primary circuit and (59)m went + # to zero — dropping the worksheet's 676.68 kWh/yr (59) and inflating + # HW fuel (219). + storage_heater_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, # electricity + heat_emitter_type="", + emitter_temperature=1, + main_heating_control=2402, + main_heating_category=None, + sap_main_heating_code=402, # electric storage heaters (space) + ) + + # Act + applies_with_cylinder = _primary_loss_applies( + storage_heater_main, True, None, water_heating_code=911, + ) + applies_without_cylinder = _primary_loss_applies( + storage_heater_main, False, None, water_heating_code=911, + ) + + # Assert — WHS 911 + cylinder → primary loss applies; no cylinder → + # no primary circuit, no loss. + assert applies_with_cylinder is True + assert applies_without_cylinder 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