From 39ae2cf0c2592994cd178b4708eba4d5075b084e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 24 Jun 2026 09:05:21 +0000 Subject: [PATCH] fix(water-heating): split whc-903 immersion HW CO2/PE on off-peak tariffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAP 10.2 Table 12d/12e: electric water heating on a 7-/10-hour tariff bills CO2/PE at the high-rate code (32/34) and low-rate code (31/33), kWh-weighted by the Table 13 high-rate fraction. The cost path already applied this split; the CO2/PE factors did not — they used the flat annual Table 12 figure (0.136 CO2 / 1.501 PE) for ALL dual-rate electric HW. That flat-annual behaviour (slice S0380.163) was validated only against HW-from-main "low-rate cost" certs (100% low, no high-rate split). It is NOT how Elmhurst bills a whc-903 ELECTRIC IMMERSION: the hand-built case-50 worksheet (000565 + dual immersion, 7-hour) splits HW CO2/PE into "high rate cost" (CO2 0.1475 / PE 1.5514) + "low rate cost" (CO2 0.1238 / PE 1.4429) weighted by the Table 13 fraction 0.1009. So flat-0.136 for immersion HW was a spec gap on our side, not an Elmhurst divergence. Fix: `_electric_immersion_hw_high_rate_fraction` threads the Table 13 fraction (scoped to whc-903, 7-/10-hour, cylinder data present) into the HW CO2 + PE factor helpers, which then blend the Table 12d/12e high/low codes. The flat rule is unchanged for HW-from-main and 18-/24-hour (no Table 12d split), so the S0380.163 41-variant cases and the existing pin are untouched. Case 50: rating CO2 2413.48 -> 2397.1237 = Elmhurst EXACT; demand CO2 2007.1384 EXACT; demand PE +111 -> +32.5 residual (within corpus PE noise). Corpus unchanged 73.3% / MAE 0.774 / CO2 0.08 / PE 3.4 (62 whc-903 off-peak certs; aggregate gauges hold). SAP unaffected (cost-based). Pin: test_whc903_immersion_hw_co2_pe_factors_split_high_low_on_off_peak; doc updated in SAP_CALCULATOR.md §8.1. pyright strict gate not run locally (pyright not installed in this container). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/docs/SAP_CALCULATOR.md | 23 +++-- .../sap10_calculator/rdsap/cert_to_inputs.py | 86 +++++++++++++++++++ .../rdsap/test_cert_to_inputs.py | 52 +++++++++++ 3 files changed, 156 insertions(+), 5 deletions(-) diff --git a/domain/sap10_calculator/docs/SAP_CALCULATOR.md b/domain/sap10_calculator/docs/SAP_CALCULATOR.md index 7e78d99c..68555fda 100644 --- a/domain/sap10_calculator/docs/SAP_CALCULATOR.md +++ b/domain/sap10_calculator/docs/SAP_CALCULATOR.md @@ -427,12 +427,25 @@ secondary heating (1.5715) on the same certs, but flat Table 12 for the "low-rate cost" line items (SH main 1 + HW). It's an Elmhurst implementation choice, not a documented spec exception. -**Cascade rule (post-S0380.163):** +**Cascade rule (post-S0380.163, refined for whc-903 immersion):** -| Tariff | HW PE / CO2 factor source | -|---|---| -| STANDARD | Table 12e / 12d monthly, weighted by HW demand seasonality (per spec literal) | -| 7-hour / 10-hour / 18-hour / 24-hour | Table 12 annual flat (1.501 PE / 0.136 CO2) | +| Tariff | HW system | HW PE / CO2 factor source | +|---|---|---| +| STANDARD | any | Table 12e / 12d monthly, weighted by HW demand seasonality (per spec literal) | +| 7-hour / 10-hour | whc-903 electric immersion (Table 13 split) | Table 12d/12e high- + low-rate codes (32/31 or 34/33), kWh-weighted, blended by the Table 13 high-rate fraction — the SAME split the cost path applies | +| 7-hour / 10-hour / 18-hour / 24-hour | all other (HW-from-main "low-rate cost", or 18h/24h immersion) | Table 12 annual flat (1.501 PE / 0.136 CO2) | + +**whc-903 immersion carve-out (simulated case 50).** The original S0380.163 +rule applied flat annual 0.136/1.501 to *every* dual-rate electric HW. That +was validated only against HW-from-main "low-rate cost" certs (100% low, no +high-rate split). The hand-built **case-50** worksheet (whc-903 dual electric +immersion, 7-hour) bills HW CO2/PE as a split — "Water heating - high rate +cost" (CO2 0.1475 / PE 1.5514, code 32) + "low rate cost" (CO2 0.1238 / PE +1.4429, code 31), weighted by the Table 13 fraction 0.1009 — NOT flat 0.136. +So flat-0.136 for immersion HW was a *spec gap on our side*, not an Elmhurst +divergence. `_electric_immersion_hw_high_rate_fraction` threads the Table 13 +fraction into the CO2/PE helpers; the flat rule still holds for everything +else (the 41-variant cases below are unaffected — they are HW-from-main). The SH main factor (`_main_heating_primary_factor`) already matches Elmhurst by accident: for dual-rate tariffs the diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 779790a9..2fb2151d 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -3980,6 +3980,8 @@ def _hot_water_co2_factor_kg_per_kwh( epc: EpcPropertyData, hw_monthly_kwh: tuple[float, ...], tariff: Tariff, + *, + immersion_high_rate_fraction: Optional[float] = None, ) -> float: """SAP 10.2 Table 12 / 12d (p.195) per-end-use CO2 factor for the cert's lodged water-heating fuel. @@ -4043,6 +4045,25 @@ def _hot_water_co2_factor_kg_per_kwh( else _table_12_factor_fuel_code(fuel) ) if tariff is not Tariff.STANDARD: + # whc-903 electric IMMERSION with a Table 13 high-rate split: the + # Elmhurst worksheet bills HW CO2 as two lines — "Water heating - + # high rate cost" at the Table 12d high-rate code + "low rate cost" + # at the low-rate code, weighted by the SAME Table 13 fraction the + # COST path uses (simulated case 50: high 0.1475 + low 0.1238, + # frac 0.1009). The flat-annual S0380.163 rule below was validated + # only on HW-from-main "low-rate cost" certs (100% low) where no + # high-rate split exists; it does NOT hold for the immersion split. + if immersion_high_rate_fraction is not None: + codes = _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff) + if codes is not None: + high_code, low_code = codes + f_high = _effective_monthly_co2_factor(hw_monthly_kwh, high_code) + f_low = _effective_monthly_co2_factor(hw_monthly_kwh, low_code) + if f_high is not None and f_low is not None: + return ( + immersion_high_rate_fraction * f_high + + (1.0 - immersion_high_rate_fraction) * f_low + ) return co2_factor_kg_per_kwh(table_12_code) monthly = _effective_monthly_co2_factor(hw_monthly_kwh, table_12_code) if monthly is not None: @@ -4054,6 +4075,8 @@ def _hot_water_primary_factor( epc: EpcPropertyData, hw_monthly_kwh: tuple[float, ...], tariff: Tariff, + *, + immersion_high_rate_fraction: Optional[float] = None, ) -> float: """SAP 10.2 Table 12 / 12e (p.196) per-end-use PE factor for the cert's lodged water-heating fuel. PE-side mirror of @@ -4107,6 +4130,21 @@ def _hot_water_primary_factor( else _table_12_factor_fuel_code(fuel) ) if tariff is not Tariff.STANDARD: + # whc-903 immersion Table 13 split — PE mirror of the CO2 helper. + # Elmhurst splits HW PE into Table 12e high-/low-rate electricity + # weighted by the Table 13 fraction; the flat-annual S0380.163 rule + # only holds for HW-from-main "low-rate cost" certs (no split). + if immersion_high_rate_fraction is not None: + codes = _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff) + if codes is not None: + high_code, low_code = codes + f_high = _effective_monthly_pe_factor(hw_monthly_kwh, high_code) + f_low = _effective_monthly_pe_factor(hw_monthly_kwh, low_code) + if f_high is not None and f_low is not None: + return ( + immersion_high_rate_fraction * f_high + + (1.0 - immersion_high_rate_fraction) * f_low + ) return primary_energy_factor(table_12_code) monthly = _effective_monthly_pe_factor(hw_monthly_kwh, table_12_code) if monthly is not None: @@ -4114,6 +4152,43 @@ def _hot_water_primary_factor( return primary_energy_factor(fuel) +def _electric_immersion_hw_high_rate_fraction( + epc: EpcPropertyData, + tariff: Tariff, + *, + cylinder_volume_l: Optional[float], + occupancy_n: Optional[float], + immersion_single: Optional[bool], +) -> Optional[float]: + """SAP 10.2 Table 13 HW high-rate fraction for a whc-903 electric + immersion on a 7-/10-hour off-peak tariff — the SAME split the cost + path (`_hot_water_fuel_cost_gbp_per_kwh`) applies. The CO2/PE factor + helpers blend the Table 12d/12e high- and low-rate electricity factors + by this fraction, mirroring the worksheet's split "Water heating - + high/low rate cost" lines (simulated case 50). Returns None when not a + dual-rate immersion, when the cylinder/occupancy data Table 13 needs is + missing, or on 18-/24-hour tariffs (no Table 12d/12e high/low split) — + callers then keep the flat-annual S0380.163 factor.""" + if tariff is Tariff.STANDARD: + return None + if epc.sap_heating.water_heating_code != _WHC_ELECTRIC_IMMERSION: + return None + if ( + cylinder_volume_l is None + or occupancy_n is None + or immersion_single is None + ): + return None + if _TARIFF_HIGH_LOW_FUEL_CODES_TABLE_12.get(tariff) is None: + return None + return electric_dhw_high_rate_fraction( + cylinder_volume_l=cylinder_volume_l, + occupancy_n=occupancy_n, + single_immersion=immersion_single, + tariff=tariff, + ) + + def _secondary_fuel_code(epc: EpcPropertyData) -> int: """SAP 10.2 secondary fuel code, resolved through the API mapper's Appendix M Table 4a spec-fuel routing. When no `secondary_fuel_type` @@ -8027,11 +8102,22 @@ def cert_to_inputs( occupancy_n=wh_result.occupancy if wh_result is not None else None, immersion_single=_immersion_is_single(epc), ) + # whc-903 immersion Table 13 high-rate fraction — same split the + # cost path applies above; threaded into the CO2/PE factor helpers + # so the worksheet's high/low HW lines reconcile (simulated case 50). + _hw_immersion_high_frac = _electric_immersion_hw_high_rate_fraction( + epc, _rdsap_tariff(epc), + cylinder_volume_l=_hot_water_cylinder_volume_l(epc), + occupancy_n=wh_result.occupancy if wh_result is not None else None, + immersion_single=_immersion_is_single(epc), + ) hw_co2_factor = _hot_water_co2_factor_kg_per_kwh( epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc), + immersion_high_rate_fraction=_hw_immersion_high_frac, ) hw_pe_factor = _hot_water_primary_factor( epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc), + immersion_high_rate_fraction=_hw_immersion_high_frac, ) _hw_extra_standing = 0.0 _heat_network_standing = _heat_network_standing_charge_gbp(epc, main) 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 4af6d3d0..99a2348a 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -5352,6 +5352,58 @@ def test_electric_water_heating_factors_use_annual_table_12_on_dual_rate_tariff( ) +def test_whc903_immersion_hw_co2_pe_factors_split_high_low_on_off_peak() -> None: + # Arrange — the flat-annual S0380.163 rule (preceding test) was validated + # only on HW-from-main "low-rate cost" certs (100% low, no high-rate + # split). For whc-903 ELECTRIC IMMERSION the Elmhurst worksheet bills HW + # CO2/PE as TWO lines — "Water heating - high rate cost" at the Table + # 12d/12e high-rate electricity factor + "low rate cost" at the low-rate + # factor — weighted by the SAME Table 13 fraction the COST path applies + # (simulated case 50: CO2 high 0.1475 + low 0.1238; PE high 1.5514 + low + # 1.4429; frac 0.1009). Our cascade previously applied the flat annual + # 0.136 / 1.501 to dual-rate immersion HW too — a spec gap, not a genuine + # Elmhurst divergence (case 50 worksheet proves the split). The split + # makes both factors fall BELOW the flat annual (the low-rate factor + # dominates at ~0.90 weight). Reconciled exactly by the case-50 e2e pin. + storage_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, + heat_emitter_type="", + emitter_temperature="", + main_heating_control=2401, + sap_main_heating_code=402, # storage heater → off-peak + central_heating_pump_age_str="Unknown", + ) + 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_building_parts=[make_building_part(construction_age_band="D")], + sap_heating=make_sap_heating( + water_heating_code=903, # electric immersion + water_heating_fuel=29, + cylinder_size=5, + immersion_heating_type=1, # dual immersion + main_heating_details=[storage_main], + ), + ) + # Unknown meter + dual electric immersion → 7-hour off-peak (§12 trigger). + epc.sap_energy_source.meter_type = "Unknown" + + # Act + inputs = cert_to_inputs(epc) + co2 = inputs.hot_water_co2_factor_kg_per_kwh + pe = inputs.hot_water_primary_factor + + # Assert — the Table 13 split is applied, so both factors are a genuine + # high/low blend strictly BELOW the flat-annual S0380.163 figures (and + # above the all-low-rate bound — not degenerate). + assert co2 is not None and pe is not None + assert 0.1238 < co2 < 0.136, f"expected split CO2 in (0.1238, 0.136); got {co2}" + assert 1.4429 < pe < 1.501, f"expected split PE in (1.4429, 1.501); got {pe}" + + def test_gas_water_heating_co2_and_pe_factors_pass_through_annual_table_12() -> None: # Arrange — RdSAP cert with mains-gas water heating # (`water_heating_fuel=26` API mains gas → Table 12 code 1). Per