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
|
"low-rate cost" line items (SH main 1 + HW). It's an Elmhurst
|
||||||
implementation choice, not a documented spec exception.
|
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 |
|
| Tariff | HW system | HW PE / CO2 factor source |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| STANDARD | Table 12e / 12d monthly, weighted by HW demand seasonality (per spec literal) |
|
| STANDARD | any | 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) |
|
| 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
|
The SH main factor (`_main_heating_primary_factor`) already
|
||||||
matches Elmhurst by accident: for dual-rate tariffs the
|
matches Elmhurst by accident: for dual-rate tariffs the
|
||||||
|
|
|
||||||
|
|
@ -3980,6 +3980,8 @@ def _hot_water_co2_factor_kg_per_kwh(
|
||||||
epc: EpcPropertyData,
|
epc: EpcPropertyData,
|
||||||
hw_monthly_kwh: tuple[float, ...],
|
hw_monthly_kwh: tuple[float, ...],
|
||||||
tariff: Tariff,
|
tariff: Tariff,
|
||||||
|
*,
|
||||||
|
immersion_high_rate_fraction: Optional[float] = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""SAP 10.2 Table 12 / 12d (p.195) per-end-use CO2 factor for the
|
"""SAP 10.2 Table 12 / 12d (p.195) per-end-use CO2 factor for the
|
||||||
cert's lodged water-heating fuel.
|
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)
|
else _table_12_factor_fuel_code(fuel)
|
||||||
)
|
)
|
||||||
if tariff is not Tariff.STANDARD:
|
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)
|
return co2_factor_kg_per_kwh(table_12_code)
|
||||||
monthly = _effective_monthly_co2_factor(hw_monthly_kwh, table_12_code)
|
monthly = _effective_monthly_co2_factor(hw_monthly_kwh, table_12_code)
|
||||||
if monthly is not None:
|
if monthly is not None:
|
||||||
|
|
@ -4054,6 +4075,8 @@ def _hot_water_primary_factor(
|
||||||
epc: EpcPropertyData,
|
epc: EpcPropertyData,
|
||||||
hw_monthly_kwh: tuple[float, ...],
|
hw_monthly_kwh: tuple[float, ...],
|
||||||
tariff: Tariff,
|
tariff: Tariff,
|
||||||
|
*,
|
||||||
|
immersion_high_rate_fraction: Optional[float] = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""SAP 10.2 Table 12 / 12e (p.196) per-end-use PE factor for the
|
"""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
|
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)
|
else _table_12_factor_fuel_code(fuel)
|
||||||
)
|
)
|
||||||
if tariff is not Tariff.STANDARD:
|
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)
|
return primary_energy_factor(table_12_code)
|
||||||
monthly = _effective_monthly_pe_factor(hw_monthly_kwh, table_12_code)
|
monthly = _effective_monthly_pe_factor(hw_monthly_kwh, table_12_code)
|
||||||
if monthly is not None:
|
if monthly is not None:
|
||||||
|
|
@ -4114,6 +4152,43 @@ def _hot_water_primary_factor(
|
||||||
return primary_energy_factor(fuel)
|
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:
|
def _secondary_fuel_code(epc: EpcPropertyData) -> int:
|
||||||
"""SAP 10.2 secondary fuel code, resolved through the API mapper's
|
"""SAP 10.2 secondary fuel code, resolved through the API mapper's
|
||||||
Appendix M Table 4a spec-fuel routing. When no `secondary_fuel_type`
|
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,
|
occupancy_n=wh_result.occupancy if wh_result is not None else None,
|
||||||
immersion_single=_immersion_is_single(epc),
|
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(
|
hw_co2_factor = _hot_water_co2_factor_kg_per_kwh(
|
||||||
epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc),
|
epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc),
|
||||||
|
immersion_high_rate_fraction=_hw_immersion_high_frac,
|
||||||
)
|
)
|
||||||
hw_pe_factor = _hot_water_primary_factor(
|
hw_pe_factor = _hot_water_primary_factor(
|
||||||
epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc),
|
epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc),
|
||||||
|
immersion_high_rate_fraction=_hw_immersion_high_frac,
|
||||||
)
|
)
|
||||||
_hw_extra_standing = 0.0
|
_hw_extra_standing = 0.0
|
||||||
_heat_network_standing = _heat_network_standing_charge_gbp(epc, main)
|
_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:
|
def test_gas_water_heating_co2_and_pe_factors_pass_through_annual_table_12() -> None:
|
||||||
# Arrange — RdSAP cert with mains-gas water heating
|
# Arrange — RdSAP cert with mains-gas water heating
|
||||||
# (`water_heating_fuel=26` API mains gas → Table 12 code 1). Per
|
# (`water_heating_fuel=26` API mains gas → Table 12 code 1). Per
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue