From 5e941b92951204678a87329fbbf850086b80100f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 1 Jun 2026 14:18:44 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.154:=20SAP=2010.2=20=C2=A712.4.4?= =?UTF-8?q?=20=E2=80=94=20back-boiler=20summer-immersion=20HW=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 §12.4.4 (PDF p.36-37): "Independent boilers that provide domestic hot water usually do so throughout the year. 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." Scope is verbatim Table 4a codes 156 (Open fire with back boiler to radiators) and 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 NOT covered. Pre-slice, the cascade treated the back-boiler cohort identically to year-round solid-fuel mains: (59)m primary loss applied Jun-Sep, HW fuel kWh was billed entirely at the boiler's solid-fuel rate, the HW CO2 / PE factors used the boiler fuel's annual factor, and the off-peak electric standing charge (£40 for 18-hour tariff) was not added because the cert's lodged water-heating fuel code was anthracite. Implementation (4 wired pieces): 1. `_section_12_4_4_summer_immersion_applies(epc, main)` — predicate gate keyed on back-boiler SAP code (156, 158) + WHC ∈ {901, 902, 914} "HW from main heating" + cylinder present. 2. `_primary_loss_override` zeroes (59)m for Jun-Sep when the predicate fires — matches the Elmhurst P960 worksheet which has (59) Jun-Sep = 0 for SF2 (vs ~42 kWh/month for SF3 range cooker). 3. `_section_12_4_4_hw_blend(...)` — returns the 5-tuple (annual_hw_fuel_kwh, blended_cost_gbp_per_kwh, blended_co2_factor, blended_pe_factor, extra_standing_charge_gbp). The blend is kWh- weighted across: - Winter Oct-May: boiler fuel at the boiler's Table 32 unit price / Table 12 annual CO2 / Table 12 annual PE factor - Summer Jun-Sep: standard electricity (Table 12d/12e monthly factors weighted by summer (62)m demand) priced at the tariff's off-peak low rate per Table 13 note 2 (the 6.8 - 0.036V × N - 0.105V dual-immersion formula clamps to zero high-rate for normal V/N combos on tariffs with ≥18 hrs low rate; SF2 has V=110, N≈2 → 100% low-rate) - The Table 32 off-peak electric standing charge that fires when hot water uses off-peak electricity per Table 12 note (a). For EIGHTEEN_HOUR tariff this is Table 32 code 38 = £40. 4. Orchestrator (`cert_to_inputs`) resolves the blend once and overrides `hot_water_kwh_per_yr`, `hot_water_fuel_cost_gbp_per_kwh`, `hot_water_co2_factor_kg_per_kwh`, `hot_water_primary_factor`, and `standing_charges_gbp` when the predicate fires. Other certs fall back to the existing single-fuel HW helpers (no behaviour change). Worksheet evidence (heating-systems corpus property 001431 SF2 — code 158 + WHC=901 + cylinder thermostat + 18-hour tariff): - (62) Oct-May = 2205.80 kWh, Jun-Sep = 684.55 kWh - (217)m = 65 winter / 100 summer, (219) = 3393.5 anthr + 684.55 elec = 4078.06 fuel kWh - (247) HW cost = 4078.06 × 4.27 p/kWh blended = £174.25 - (251) Standing = £40 (off-peak electric standing only — solid fuel has no standing charge) - (255) Total = £801.13 Closures (SF2): ΔSAP_c +1.86 → -0.0000 (EXACT) Δcost -£42.84 → -£0.00 (EXACT) ΔCO2 +346.87 → -93.10 kg/yr (residual: Elmhurst CO2 blend uses a different summer-month weighting that the SAP 10.2 Table 12d cascade does not reproduce — spec-correct per Table 12d header). ΔPE -605.76 → -1027.51 kWh/yr (same spec-vs-Elmhurst PE blend artifact via Table 12e monthly cascade). No regressions: 40/41 corpus variants unchanged (gate is narrow by SAP code 156/158). Extended handover suite 898 pass / 0 fail. Pyright net- zero (43 → 43). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_heating_systems_corpus.py | 2 +- .../sap10_calculator/rdsap/cert_to_inputs.py | 302 ++++++++++++++++-- .../rdsap/tests/test_cert_to_inputs.py | 123 +++++++ 3 files changed, 406 insertions(+), 21 deletions(-) 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: