diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index f2a94124..7aa9718a 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -241,7 +241,7 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( # cost / CO2 / PE all route via the correct Table 32 fuel code. # Remaining residuals are likely heating-system efficiency or # control-type gaps — separate slices. - _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+1.8594, expected_cost_resid_gbp=-42.8447, expected_co2_resid_kg=+346.8694, expected_pe_resid_kwh=-605.7603), + _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=-0.0000, expected_cost_resid_gbp=-0.0000, expected_co2_resid_kg=-93.0988, expected_pe_resid_kwh=-1027.5099), _CorpusExpectation(variant='solid fuel 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='solid fuel 4', block='11a', expected_sap_resid=+0.0850, expected_cost_resid_gbp=-1.9582, expected_co2_resid_kg=-9.3050, expected_pe_resid_kwh=-5.7762), _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+0.0000, expected_cost_resid_gbp=+0.0000, expected_co2_resid_kg=+11.9451, expected_pe_resid_kwh=+48.6604), diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 837b8401..5764c17e 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -108,6 +108,7 @@ from domain.sap10_calculator.tables.table_32 import ( additional_standing_charges_gbp, is_electric_fuel_code, is_liquid_fuel_code, + standing_charge_gbp, unit_price_p_per_kwh as table_32_unit_price_p_per_kwh, ) from domain.sap10_calculator.tables.table_4b import ( @@ -4126,6 +4127,63 @@ def _primary_loss_applies( return False +# SAP 10.2 §12.4.4 (PDF p.36-37) — Table 4a back-boiler combos that the +# spec routes through summer immersion. Verbatim spec scope: "open fire +# back boilers or closed room heaters with boilers" → Table 4a codes +# 156 (Open fire with back boiler to radiators) + 158 (Closed room heater +# with boiler to radiators). Range cookers (160, 161), stoves with +# boilers (159), and independent solid-fuel boilers (151, 153, 155) are +# NOT in §12.4.4's list — they run year-round per the spec's preceding +# sentence "Independent boilers that provide domestic hot water usually +# do so throughout the year". +_TABLE_4A_BACK_BOILER_CODES: Final[frozenset[int]] = frozenset({156, 158}) + +# Summer months Jun-Sep (0-indexed Jan=0 .. Dec=11) per the §12.4.4 rule +# verbatim: "water heating is provided by the boiler for months October +# to May and by the alternative system for months June to September". +_SECTION_12_4_4_SUMMER_MONTH_INDICES: Final[frozenset[int]] = frozenset( + {5, 6, 7, 8} +) + + +def _section_12_4_4_summer_immersion_applies( + epc: EpcPropertyData, main: Optional[MainHeatingDetail] +) -> bool: + """SAP 10.2 §12.4.4 (PDF p.36-37): "With open fire back boilers or + closed room heaters with boilers, an alternative system (electric + immersion) may be provided for heating water in summer. In that case + water heating is provided by the boiler for months October to May + and by the alternative system for months June to September." + + Applies when: + - main heating is a Table 4a back-boiler combo (SAP code 156 or 158) + - water heating sources from the main heating + (WHC ∈ {901, 902, 914} = "HW from main heating") + - a hot-water cylinder is lodged (the immersion needs a tank) + + The Elmhurst P960 worksheet for heating-systems corpus property 001431 + SF2 (code 158 + WHC=901 + cylinder thermostat) lodges this + arrangement via §1 "Water Heating" block fields `Immersion Heater + Type: Dual` + `Summer Immersion: Yes` — neither field is surfaced on + the Summary PDF the cascade reads. Per the spec's "may be provided" + permissive language, the rule is applied deterministically when the + main heating SAP code identifies the back-boiler combo, matching + Elmhurst's worksheet output (SF2 (59)m winter = 64.58 [h=5, p=0], + summer Jun-Sep = 0; SF3 code 160 range-cooker boiler with the same + WHC=901 lodging has summer (59)m ≈ 41-43 because §12.4.4 does NOT + apply to range cookers). + """ + if not epc.has_hot_water_cylinder: + return False + if main is None: + return False + if main.sap_main_heating_code not in _TABLE_4A_BACK_BOILER_CODES: + return False + return ( + epc.sap_heating.water_heating_code in _WATER_INHERIT_FROM_MAIN_CODES + ) + + # RdSAP 10 §10.11 Table 29 "Heating and hot water parameters" row # "Solar panel" (p.58) — the spec defaults to use when the cert # lodges "Solar collector details known: No". Verbatim: @@ -4456,13 +4514,24 @@ def _primary_loss_override( water_heating_code=epc.sap_heating.water_heating_code, ): return None - return primary_loss_monthly_kwh( + base = primary_loss_monthly_kwh( pipework_insulation_fraction=_pipework_insulation_fraction_table_3( primary_age ), has_cylinder_thermostat=epc.sap_heating.cylinder_thermostat == "Y", separately_timed_dhw=_separately_timed_dhw(epc, main), ) + # SAP 10.2 §12.4.4 (PDF p.36-37): for back-boiler combos summer DHW + # comes from an electric immersion, not from the boiler — the boiler + # primary circuit is not running Jun-Sep so (59)m = 0 for those four + # months. Winter (Oct-May) (59)m keeps the Table 3 row applicable + # to the boiler. + if _section_12_4_4_summer_immersion_applies(epc, main): + return tuple( + 0.0 if i in _SECTION_12_4_4_SUMMER_MONTH_INDICES else v + for i, v in enumerate(base) + ) + return base def _cylinder_storage_loss_override( @@ -4550,6 +4619,149 @@ def _apply_water_efficiency( return wh_output_annual_kwh / water_efficiency_pct +# SAP 10.2 §12.4.4 summer-immersion constants. Per Table 13 (PDF p.197) +# the dual-immersion 18-hour tariff has 100% low-rate consumption (the +# 6.8 - 0.036V × N - 0.105V formula falls below zero for normal V/N +# combos, so the spec clamps to zero high-rate fraction). The Elmhurst +# P960 worksheet for SF2 (TFA 90 m², 110 L cylinder, 18-hour) bills the +# 684 kWh summer immersion entirely at the 18-hour low rate (Table 32 +# code 40 = 7.41 p/kWh) — matching `_off_peak_low_rate_gbp_per_kwh`. +_SECTION_12_4_4_IMMERSION_EFFICIENCY_PCT: Final[float] = 100.0 +# Table 12d / 12e monthly factor code for "standard electricity" — the +# dual immersion bills as a regular electric end-use in the cascade. +_SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12: Final[int] = 30 +# Table 32 standing-charge owner code for the off-peak electric tariff. +# Mirror of `_OFF_PEAK_STANDING_CODE` in table_32.py — kept local to +# avoid importing a private mapping. Restricted to EIGHTEEN_HOUR / 7h / +# 10h / 24h in scope (STANDARD tariff path returns 0). +_SECTION_12_4_4_OFF_PEAK_STANDING_CODE: Final[dict[Tariff, int]] = { + Tariff.SEVEN_HOUR: 32, + Tariff.TEN_HOUR: 34, + Tariff.EIGHTEEN_HOUR: 38, + Tariff.TWENTY_FOUR_HOUR: 35, +} + + +def _section_12_4_4_hw_blend( + *, + wh_output_monthly_kwh: tuple[float, ...], + boiler_efficiency_pct: float, + boiler_fuel_code: Optional[int], + tariff: Tariff, + prices: PriceTable, +) -> tuple[float, float, float, float, float]: + """SAP 10.2 §12.4.4 (PDF p.36-37) HW fuel-split blend for back-boiler + combos. Returns the 5-tuple: + + (annual_hw_fuel_kwh, cost_gbp_per_kwh, co2_factor_kg_per_kwh, + primary_factor, extra_standing_charge_gbp) + + where each of the rates is the kWh-weighted blend of the two fuels + feeding the cylinder: the boiler fuel (winter, Oct-May, at the + boiler efficiency) and the electric immersion (summer, Jun-Sep, at + 100% efficiency). + + Worksheet evidence for property 001431 SF2 (Table 4a code 158 + closed-room-heater + back-boiler at 65% efficiency, anthracite, + 18-hour tariff): annual (62) heat = 2890.35 kWh splits as 2205.80 + winter / 684.55 summer. Winter fuel = 3393.5 anthracite kWh / + summer fuel = 684.55 electric kWh, total (219) = 4078.06 kWh. + Blended cost (anthr 3.64 + elec-low 7.41 p/kWh) = 4.27 p/kWh × 4078 + = £174.25 (247). Off-peak electric standing charge £40 added at + (251). + """ + winter_heat = sum( + kwh for i, kwh in enumerate(wh_output_monthly_kwh) + if i not in _SECTION_12_4_4_SUMMER_MONTH_INDICES + ) + summer_heat = sum( + kwh for i, kwh in enumerate(wh_output_monthly_kwh) + if i in _SECTION_12_4_4_SUMMER_MONTH_INDICES + ) + if boiler_efficiency_pct <= 0: + return 0.0, 0.0, 0.0, 0.0, 0.0 + winter_fuel = winter_heat / (boiler_efficiency_pct / 100.0) + summer_fuel = summer_heat / ( + _SECTION_12_4_4_IMMERSION_EFFICIENCY_PCT / 100.0 + ) + total_fuel = winter_fuel + summer_fuel + if total_fuel <= 0: + return 0.0, 0.0, 0.0, 0.0, 0.0 + + # Cost: boiler fuel at its Table 32 unit price (winter) + electric + # at the tariff's low rate (summer). For SF2 18-hour: Table 32 code + # 40 = 7.41 p/kWh per Table 13's "100% low rate" clamp for normal + # V/N combos. + if boiler_fuel_code is None: + boiler_p_per_kwh = 0.0 + else: + boiler_p_per_kwh = prices.unit_price_p_per_kwh(boiler_fuel_code) + summer_gbp_per_kwh = ( + _off_peak_low_rate_gbp_per_kwh(tariff) if tariff is not Tariff.STANDARD + else prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP + ) + blended_cost_gbp_per_kwh = ( + winter_fuel * boiler_p_per_kwh * _PENCE_TO_GBP + + summer_fuel * summer_gbp_per_kwh + ) / total_fuel + + # CO2: boiler fuel at its Table 12 annual factor (winter) + electric + # at the summer-month-weighted Table 12d cascade (per Table 12d + # header — "monthly factors instead the annual average"). + boiler_co2 = ( + co2_factor_kg_per_kwh(boiler_fuel_code) + if boiler_fuel_code is not None else 0.0 + ) + elec_co2_monthly = co2_monthly_factors_kg_per_kwh( + _SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12 + ) + summer_co2_kg = ( + sum( + wh_output_monthly_kwh[i] * elec_co2_monthly[i] + for i in _SECTION_12_4_4_SUMMER_MONTH_INDICES + ) + if elec_co2_monthly is not None else 0.0 + ) + blended_co2 = (winter_fuel * boiler_co2 + summer_co2_kg) / total_fuel + + # PE: same shape (Table 12e monthly cascade for summer electric). + boiler_pe = ( + primary_energy_factor(boiler_fuel_code) + if boiler_fuel_code is not None else 0.0 + ) + elec_pe_monthly = pe_monthly_factors_kwh_per_kwh( + _SECTION_12_4_4_IMMERSION_FUEL_CODE_TABLE_12 + ) + summer_pe_kwh = ( + sum( + wh_output_monthly_kwh[i] * elec_pe_monthly[i] + for i in _SECTION_12_4_4_SUMMER_MONTH_INDICES + ) + if elec_pe_monthly is not None else 0.0 + ) + blended_pe = (winter_fuel * boiler_pe + summer_pe_kwh) / total_fuel + + # Standing charges: Table 12 note (a) adds the off-peak electric + # standing when HW uses off-peak electricity. The §12.4.4 summer + # immersion uses the off-peak low rate so the standing charge fires + # for any off-peak tariff. `additional_standing_charges_gbp` is + # called separately by the caller with the cert's lodged water- + # heating fuel code (anthracite) — it would miss this gate. Return + # the extra to add explicitly. + standing_code = _SECTION_12_4_4_OFF_PEAK_STANDING_CODE.get(tariff) + extra_standing = ( + standing_charge_gbp(standing_code) if standing_code is not None else 0.0 + ) + + return ( + total_fuel, + blended_cost_gbp_per_kwh, + blended_co2, + blended_pe, + extra_standing, + ) + + # Sentinel zero FuelCostResult — returned from `_fuel_cost` on off-peak # tariff certs so the calculator's slice-2c fallback branch fires and the # legacy scalar-field cost math runs unchanged. Carries STANDARD-style @@ -5065,7 +5277,30 @@ def cert_to_inputs( eq_d1_winter_summer_pct=eq_d1_winter_summer_pct, space_heating_monthly_useful_kwh=space_heating_monthly_useful_kwh, ) + # 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). + # When the rule applies, the cascade swaps the single-fuel hw_kwh + # for the two-fuel sum so the (219) line lands on the worksheet's + # mixed-fuel total. The blend struct also carries the cost / CO2 + # / PE / standing overrides for the CalculatorInputs construction + # below; resolved here (not in `_apply_water_efficiency`) so the + # helper's signature stays a pure scalar→scalar mapping. + section_12_4_4_blend: Optional[ + tuple[float, float, float, float, float] + ] = None + if _section_12_4_4_summer_immersion_applies(epc, main): + section_12_4_4_blend = _section_12_4_4_hw_blend( + wh_output_monthly_kwh=wh_result.output_monthly_kwh, + # `water_eff` is the fraction (0.65 not 65.0); blend + # helper expects a percentage to match its naming. + boiler_efficiency_pct=water_eff * 100.0, + boiler_fuel_code=_water_heating_fuel_code(epc), + tariff=_rdsap_tariff(epc), + prices=prices, + ) + hw_kwh = section_12_4_4_blend[0] else: + section_12_4_4_blend = None # TFA missing → legacy `predicted_hot_water_kwh` cascade. Mirrors # the pre-§4 slice-1 behaviour exactly so we don't change the # answer for the (rare) corpus carrying no TFA. @@ -5191,6 +5426,48 @@ def cert_to_inputs( battery_capacity_kwh=_pv_battery_capacity_kwh(epc), ) + # SAP 10.2 §12.4.4 overrides — when summer immersion applies (back- + # boiler combo + cylinder + WHC from main heating), the HW cost / + # CO2 / PE factors are kWh-weighted blends of the winter boiler fuel + # + summer electric immersion. The standing-charges line adds the + # off-peak electric standing because the cylinder is heated by an + # off-peak immersion Jun-Sep. When the rule does NOT apply, the + # locals fall back to the existing single-fuel HW helpers. + hw_monthly_kwh_for_factors = ( + wh_result.output_monthly_kwh if wh_result is not None + else (0.0,) * 12 + ) + if section_12_4_4_blend is not None: + ( + _hw_total_unused, + _hw_cost_rate, + _hw_co2_factor, + _hw_pe_factor, + _hw_extra_standing, + ) = section_12_4_4_blend + hw_cost_rate = _hw_cost_rate + hw_co2_factor = _hw_co2_factor + hw_pe_factor = _hw_pe_factor + else: + hw_cost_rate = _hot_water_fuel_cost_gbp_per_kwh( + _water_heating_fuel_code(epc), + _water_heating_main(epc), + _rdsap_tariff(epc), + prices, + ) + hw_co2_factor = _hot_water_co2_factor_kg_per_kwh( + epc, hw_monthly_kwh_for_factors, + ) + hw_pe_factor = _hot_water_primary_factor( + epc, hw_monthly_kwh_for_factors, + ) + _hw_extra_standing = 0.0 + standing_charges_total = additional_standing_charges_gbp( + main_fuel_code=_main_fuel_code(main), + water_heating_fuel_code=_water_heating_fuel_code(epc), + tariff=_rdsap_tariff(epc), + ) + _hw_extra_standing + return CalculatorInputs( dimensions=dim, heat_transmission=ht, @@ -5232,12 +5509,7 @@ def cert_to_inputs( space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_kwh( main, _rdsap_tariff(epc), prices ), - hot_water_fuel_cost_gbp_per_kwh=_hot_water_fuel_cost_gbp_per_kwh( - _water_heating_fuel_code(epc), - _water_heating_main(epc), - _rdsap_tariff(epc), - prices, - ), + hot_water_fuel_cost_gbp_per_kwh=hw_cost_rate, other_fuel_cost_gbp_per_kwh=_other_fuel_cost_gbp_per_kwh( _rdsap_tariff(epc), prices ), @@ -5257,11 +5529,7 @@ def cert_to_inputs( # STANDARD-tariff certs route via `fuel_cost.additional_ # standing_charges_gbp` (set inside `_fuel_cost`) and the # calculator ignores this scalar on that path. - standing_charges_gbp=additional_standing_charges_gbp( - main_fuel_code=_main_fuel_code(main), - water_heating_fuel_code=_water_heating_fuel_code(epc), - tariff=_rdsap_tariff(epc), - ), + standing_charges_gbp=standing_charges_total, co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main), # SAP10.2 Table 12d (p.194) per-end-use effective CO2 factors. For # electricity end-uses Σ(kWh_m × CO2_m) / Σ(kWh_m) replaces the @@ -5281,10 +5549,7 @@ def cert_to_inputs( secondary_heating_co2_factor_kg_per_kwh=_secondary_heating_co2_factor_kg_per_kwh( epc, energy_requirements_result.secondary_fuel_monthly_kwh, ), - hot_water_co2_factor_kg_per_kwh=_hot_water_co2_factor_kg_per_kwh( - epc, - wh_result.output_monthly_kwh if wh_result is not None else (0.0,) * 12, - ), + hot_water_co2_factor_kg_per_kwh=hw_co2_factor, # SAP 10.2 Table 12a Grid 2 (p.191) + Table 12d (p.194): pumps, # lighting, and the electric-shower end-use all bill via the # "All other uses" row → on off-peak tariffs blend the high / @@ -5359,10 +5624,7 @@ def cert_to_inputs( main, _rdsap_tariff(epc), energy_requirements_result.main_1_fuel_monthly_kwh, ), - hot_water_primary_factor=_hot_water_primary_factor( - epc, - wh_result.output_monthly_kwh if wh_result is not None else (0.0,) * 12, - ), + hot_water_primary_factor=hw_pe_factor, other_primary_factor=primary_energy_factor(30), # standard electricity # SAP 10.2 Table 12e (p.195) per-end-use effective PE factors. Same # shape as the Table 12d CO2 cascade: electricity end-uses use the 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 e76dadaa..0636cc66 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -52,6 +52,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import ( _pv_pitch_deg, # pyright: ignore[reportPrivateUsage] _responsiveness, # pyright: ignore[reportPrivateUsage] _secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage] + _section_12_4_4_summer_immersion_applies, # pyright: ignore[reportPrivateUsage] _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] @@ -1628,6 +1629,128 @@ def test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b() assert sep_immersion is False +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 + # while the boiler heats the cylinder Oct-May: + # + # "With open fire back boilers or closed room heaters with boilers, + # an alternative system (electric immersion) may be provided for + # heating water in summer. In that case water heating is provided + # by the boiler for months October to May and by the alternative + # system for months June to September." + # + # Spec scope is verbatim Table 4a codes: + # 156 = Open fire with back boiler to radiators + # 158 = Closed room heater with boiler to radiators + # + # Range cooker boilers (160, 161), pellet stoves with boilers (159), + # and independent solid-fuel boilers (151, 153, 155) are explicitly + # NOT in the §12.4.4 list — they run year-round per the preceding + # spec sentence "Independent boilers that provide domestic hot water + # usually do so throughout the year". + # + # Worksheet evidence (heating-systems corpus property 001431): + # - solid fuel 2 (SAP code 158 + WHC=901 + cylinder thermostat): + # (59)m summer Jun-Sep = 0 (boiler not firing); (217)m winter = + # 65% (boiler), summer = 100% (immersion); (251) standing +£40 + # for the 18-hour off-peak electric standing charge. + # - solid fuel 3 (SAP code 160 range cooker boiler, same WHC/ + # cylinder lodging): (59)m summer ≈ 41-43 kWh (boiler keeps + # firing); (217)m all 12 months = 65%. The §12.4.4 rule does NOT + # fire. + # + # Per the spec's "may be provided" permissive language, the cascade + # applies the rule deterministically when the main heating SAP code + # identifies the back-boiler combo — Elmhurst lodges + # `Summer Immersion: Yes` in the P960 §1 Water Heating block but + # the Summary PDF the cascade reads does not surface that field; the + # SAP code is the only discriminator available. + + def _solid_fuel_main(sap_code: int) -> MainHeatingDetail: + return MainHeatingDetail( + has_fghrs=False, + main_fuel_type=15, # Table 32 code 15 = anthracite + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2103, + sap_main_heating_code=sap_code, + ) + + def _back_boiler_epc(main: MainHeatingDetail) -> EpcPropertyData: + return make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_heating=make_sap_heating( + main_heating_details=[main], + water_heating_fuel=15, + water_heating_code=901, # HW from main heating + cylinder_size=2, + cylinder_insulation_type=1, + cylinder_insulation_thickness_mm=38, + ), + ) + + # Act / Assert — §12.4.4 applies for codes 156 + 158 + for code in (156, 158): + main = _solid_fuel_main(code) + epc = _back_boiler_epc(main) + assert _section_12_4_4_summer_immersion_applies(epc, main) is True, ( + f"SAP code {code}: should apply per §12.4.4 (open fire or " + f"closed room heater with back boiler)" + ) + + # Other Table 4a solid-fuel codes do NOT route through §12.4.4: + # - 151/153/155: independent boilers (run year-round) + # - 159: pellet stove with boiler (not in §12.4.4's named list) + # - 160/161: range cooker boilers (not back boilers) + for code in (151, 153, 155, 159, 160, 161): + main = _solid_fuel_main(code) + epc = _back_boiler_epc(main) + assert _section_12_4_4_summer_immersion_applies(epc, main) is False, ( + f"SAP code {code}: should NOT apply (not a back-boiler combo)" + ) + + # No cylinder → rule cannot apply (immersion needs a tank). + main_158 = _solid_fuel_main(158) + no_cyl_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=False, + sap_heating=make_sap_heating( + main_heating_details=[main_158], + water_heating_fuel=15, + water_heating_code=901, + ), + ) + assert _section_12_4_4_summer_immersion_applies(no_cyl_epc, main_158) is False + + # WHC outside the "HW from main heating" set (e.g. 903 = HW from a + # separate immersion) means the cylinder isn't on the boiler's + # primary loop — the §12.4.4 winter-boiler / summer-immersion + # division doesn't apply (immersion year-round instead). + sep_immersion_epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + has_hot_water_cylinder=True, + sap_heating=make_sap_heating( + main_heating_details=[main_158], + water_heating_fuel=30, # electricity + water_heating_code=903, # HW from separate immersion + cylinder_size=2, + cylinder_insulation_type=1, + cylinder_insulation_thickness_mm=38, + ), + ) + assert _section_12_4_4_summer_immersion_applies( + sep_immersion_epc, main_158 + ) is False + + def test_separately_timed_dhw_solid_fuel_boiler_codes_per_sap_10_2_table_3() -> None: # Arrange — SAP 10.2 Table 3 (PDF p.160) gives three primary-loss # rows keyed off the DHW timing arrangement: