fix(water-heating): split whc-903 immersion HW CO2/PE on off-peak tariffs

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-24 09:05:21 +00:00
parent f3e3494bf7
commit 39ae2cf0c2
3 changed files with 156 additions and 5 deletions

View file

@ -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

View file

@ -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)

View file

@ -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