mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
f3e3494bf7
commit
39ae2cf0c2
3 changed files with 156 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue