mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
fix(hw-cost): WHC-903 immersion off-peak HW bills at Table 13 high-rate fraction
Electric immersion water heating (WHC 903) on an off-peak tariff billed 100% at the low rate, under-costing the dwelling and over-rating it (median +0.98 SAP across the off-peak WHC-903 API cohort, n=57). SAP 10.2 Table 12a "Immersion water heater" row (PDF p.191) routes the water-heating column to Table 13 (PDF p.197): the high-rate fraction is a function of cylinder volume V, assumed occupancy N (Appendix J Table 1b) and single-/dual-immersion. The remainder bills at the low rate. Table 13 Note 2 supplies exact equations equivalent to the rounded grid; `electric_dhw_high_rate_fraction` evaluates them (validated against the published 110 L grid cells). Per Note 1 the 10-hour equations cover any tariff with >=10 hours/day low-rate (so 18-/24-hour use that column). Immersion code mapping CONFIRMED 1=dual, 2=single via RdSAP 10 §10.5 (PDF p.54 — an immersion is "assumed dual" on a dual/off-peak meter) cross-checked against the API cohort (code 1 sits 3.6:1 on dual meters; code 2 on single meters). This INVERTS an earlier handover's unverified "1=single, 2=dual" note — the dual code carries Table 13's small fraction, matching the cohort over-rating direction; the single mapping overshot in a prototype. API SAP eval: 47.6% -> 48.6% within 0.5; <1.0 62.6% -> 63.8%; mean|err| 1.586 -> 1.561; 909 computed, 0 raises. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d83c431c7d
commit
43d4c67d12
4 changed files with 288 additions and 6 deletions
|
|
@ -105,6 +105,9 @@ from domain.sap10_calculator.tables.table_12a import (
|
|||
tariff_from_meter_type,
|
||||
water_heating_high_rate_fraction,
|
||||
)
|
||||
from domain.sap10_calculator.tables.table_13 import (
|
||||
electric_dhw_high_rate_fraction,
|
||||
)
|
||||
from domain.sap10_calculator.tables.table_32 import (
|
||||
additional_standing_charges_gbp,
|
||||
is_electric_fuel_code,
|
||||
|
|
@ -2265,6 +2268,9 @@ def _hot_water_fuel_cost_gbp_per_kwh(
|
|||
*,
|
||||
water_heating_code: Optional[int] = None,
|
||||
inherit_main_for_community_heating: bool = False,
|
||||
cylinder_volume_l: Optional[float] = None,
|
||||
occupancy_n: Optional[float] = None,
|
||||
immersion_single: Optional[bool] = None,
|
||||
) -> float:
|
||||
"""Hot water bills at the *water-heating* fuel's rate. When the
|
||||
water-heating fuel is electric AND tariff is off-peak, bill at the
|
||||
|
|
@ -2278,10 +2284,18 @@ def _hot_water_fuel_cost_gbp_per_kwh(
|
|||
∈ {901, 902, 914}) and that main is a PCDB Table 362 heat pump, the
|
||||
HW bills per SAP 10.2 Table 12a Grid 1 WH column (PDF p.191) — the
|
||||
ASHP/GSHP-from-database row carries a 0.70 high-rate fraction at
|
||||
7-hour and 10-hour, NOT 100% off-peak low rate. Electric IMMERSION
|
||||
(WHC 903) is a different Table 12a row (off-peak immersion 0.17 /
|
||||
Table 13) and stays on the 100%-low-rate fallback until that slice
|
||||
lands.
|
||||
7-hour and 10-hour, NOT 100% off-peak low rate.
|
||||
|
||||
Electric IMMERSION exception (WHC 903): Table 12a's "Immersion water
|
||||
heater" row (PDF p.191) routes the WH column to Table 13 (PDF p.197).
|
||||
The Table 13 high-rate fraction — a function of cylinder volume,
|
||||
assumed occupancy and single-/dual-immersion — gives the proportion
|
||||
billed at the high rate, the remainder at the low rate. Without it
|
||||
the immersion HW billed 100% at the off-peak low rate, under-costing
|
||||
the dwelling and over-rating it (median +0.98 SAP across the off-peak
|
||||
WHC-903 API cohort). Needs `cylinder_volume_l` + `occupancy_n` +
|
||||
`immersion_single`; absent any of them (no cylinder / volume not
|
||||
resolvable) it falls back to the 100%-low-rate scalar.
|
||||
|
||||
`inherit_main_for_community_heating`: per S0380.173, when WHC
|
||||
∈ {901, 902, 914} AND main is a heat network, ignore the cert-
|
||||
|
|
@ -2306,6 +2320,21 @@ def _hot_water_fuel_cost_gbp_per_kwh(
|
|||
)
|
||||
blended = high_frac * high_rate + (1.0 - high_frac) * low_rate
|
||||
return blended * _PENCE_TO_GBP
|
||||
if (
|
||||
water_heating_code == _WHC_ELECTRIC_IMMERSION
|
||||
and cylinder_volume_l is not None
|
||||
and occupancy_n is not None
|
||||
and immersion_single is not None
|
||||
):
|
||||
high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff)
|
||||
high_frac = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=cylinder_volume_l,
|
||||
occupancy_n=occupancy_n,
|
||||
single_immersion=immersion_single,
|
||||
tariff=tariff,
|
||||
)
|
||||
blended = high_frac * high_rate + (1.0 - high_frac) * low_rate
|
||||
return blended * _PENCE_TO_GBP
|
||||
return _off_peak_low_rate_gbp_per_kwh(tariff)
|
||||
if water_heating_fuel is not None:
|
||||
return prices.unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP
|
||||
|
|
@ -4851,6 +4880,17 @@ _CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = {
|
|||
2: 110.0, 3: 160.0, 4: 210.0
|
||||
}
|
||||
|
||||
# RdSAP `immersion_heating_type` lodgement codes. Code 1 = DUAL immersion,
|
||||
# code 2 = SINGLE. Confirmed against RdSAP 10 §10.5 (PDF p.54 — an
|
||||
# immersion is "assumed dual" on a dual/off-peak meter) cross-checked
|
||||
# with the API cohort: code 1 sits 3.6:1 on dual meters (40 vs 11 single)
|
||||
# while code 2 sits on single meters (22 single vs 16 dual). This INVERTS
|
||||
# the unverified "1=single, 2=dual" annotation in an earlier handover —
|
||||
# the dual code (1) carries Table 13's small high-rate fraction, matching
|
||||
# the cohort's over-rating direction; treating code 1 as single overshot.
|
||||
_IMMERSION_TYPE_DUAL: Final[int] = 1
|
||||
_IMMERSION_TYPE_SINGLE: Final[int] = 2
|
||||
|
||||
# RdSAP 10 §10.5 code 7-11: cylinder insulation type. Empirical mapping
|
||||
# from the ASHP cohort (all 7 certs lodge code 1, worksheet shows
|
||||
# "Foam" → factory-applied per SAP 10.2 Table 2 Note 2).
|
||||
|
|
@ -5673,6 +5713,21 @@ def _hot_water_cylinder_volume_l(epc: EpcPropertyData) -> Optional[float]:
|
|||
return _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
|
||||
|
||||
|
||||
def _immersion_is_single(epc: EpcPropertyData) -> Optional[bool]:
|
||||
"""True for a single immersion, False for a dual immersion, None when
|
||||
the cert lodges no recognised `immersion_heating_type`. Maps the
|
||||
RdSAP code (1 = dual, 2 = single — see `_IMMERSION_TYPE_DUAL`).
|
||||
None makes the Table 13 high-rate-fraction caller fall back to the
|
||||
100%-low-rate scalar rather than guess the immersion configuration.
|
||||
"""
|
||||
code = _int_or_none(epc.sap_heating.immersion_heating_type)
|
||||
if code == _IMMERSION_TYPE_DUAL:
|
||||
return False
|
||||
if code == _IMMERSION_TYPE_SINGLE:
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
def _table_3a_combi_loss_default_applies(main: Optional[MainHeatingDetail]) -> bool:
|
||||
"""Gate for the Table 3a keep-hot 600 kWh/yr fall-through per SAP 10.2
|
||||
§4 line 7702. Returns True only when the main heating system is in the
|
||||
|
|
@ -7058,6 +7113,9 @@ def cert_to_inputs(
|
|||
prices,
|
||||
water_heating_code=epc.sap_heating.water_heating_code,
|
||||
inherit_main_for_community_heating=_community_hw_inherit,
|
||||
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),
|
||||
|
|
|
|||
70
domain/sap10_calculator/tables/table_13.py
Normal file
70
domain/sap10_calculator/tables/table_13.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""SAP 10.2 Table 13 — high-rate fraction for electric DHW heating.
|
||||
|
||||
Sourced verbatim from `domain/sap10_calculator/docs/specs/sap-10-2-full-
|
||||
specification-2025-03-14.pdf`, page 197 (Table 13). RdSAP10 §10.5 (PDF
|
||||
p.54) routes electric immersion water heating here via Table 12a's
|
||||
"Immersion water heater" row, whose Water-heating column reads "Fraction
|
||||
from Table 13".
|
||||
|
||||
The table gives the fraction of DHW electricity consumed at the HIGH
|
||||
rate for a cylinder with a single or dual immersion heater on an
|
||||
off-peak tariff; the remainder is at the low rate. Note 2 of the table
|
||||
supplies exact equations equivalent to the tabulated (rounded) grid —
|
||||
this module evaluates those equations, so no floor-area interpolation
|
||||
is needed:
|
||||
|
||||
7-hour tariff (>= 7 hours/day at the low rate)
|
||||
Dual: [(6.8 - 0.024 V) N + 14 - 0.07 V] / 100
|
||||
Single: [(14530 - 762 N) / V - 80 + 10 N] / 100
|
||||
|
||||
10-hour tariff (>= 10 hours/day at the low rate)
|
||||
Dual: [(6.8 - 0.036 V) N + 14 - 0.105 V] / 100
|
||||
Single: [(14530 - 762 N) / (1.5 V) - 80 + 10 N] / 100
|
||||
|
||||
where V is the cylinder volume (litres) and N is the assumed occupancy
|
||||
(Appendix J Table 1b). Per Note 2 the result is clamped to [0, 1]. Per
|
||||
Note 1 the 10-hour equations apply to any tariff providing at least 10
|
||||
hours/day at the low rate (so 18-hour and 24-hour use the 10-hour
|
||||
column). Heat pumps providing water heating only are treated as dual
|
||||
immersion (Note 1) — out of scope of this helper (callers route those
|
||||
via Table 12a).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from domain.sap10_calculator.tables.table_12a import Tariff
|
||||
|
||||
|
||||
def electric_dhw_high_rate_fraction(
|
||||
*,
|
||||
cylinder_volume_l: float,
|
||||
occupancy_n: float,
|
||||
single_immersion: bool,
|
||||
tariff: Tariff,
|
||||
) -> float:
|
||||
"""SAP 10.2 Table 13 (PDF p.197) high-rate fraction for an electric
|
||||
immersion DHW cylinder on an off-peak tariff.
|
||||
|
||||
`single_immersion` selects the single- vs dual-immersion equation
|
||||
(RdSAP10 §10.5 p.54: an immersion is assumed dual on a dual meter).
|
||||
The 7-hour tariff uses the 7-hour equations; every other off-peak
|
||||
tariff (10/18/24-hour, all >= 10 hours low-rate per Note 1) uses the
|
||||
10-hour equations. STANDARD has no off-peak split and is rejected —
|
||||
callers must early-return before this fires.
|
||||
"""
|
||||
if tariff is Tariff.STANDARD:
|
||||
raise ValueError("Table 13 high-rate fraction is undefined for STANDARD")
|
||||
v = cylinder_volume_l
|
||||
n = occupancy_n
|
||||
if tariff is Tariff.SEVEN_HOUR:
|
||||
if single_immersion:
|
||||
fraction = ((14530 - 762 * n) / v - 80 + 10 * n) / 100
|
||||
else:
|
||||
fraction = ((6.8 - 0.024 * v) * n + 14 - 0.07 * v) / 100
|
||||
else:
|
||||
# >= 10 hours/day at the low rate (10/18/24-hour) — Note 1.
|
||||
if single_immersion:
|
||||
fraction = ((14530 - 762 * n) / (1.5 * v) - 80 + 10 * n) / 100
|
||||
else:
|
||||
fraction = ((6.8 - 0.036 * v) * n + 14 - 0.105 * v) / 100
|
||||
return max(0.0, min(1.0, fraction))
|
||||
|
|
@ -3271,8 +3271,9 @@ def test_hot_water_from_pcdb_heat_pump_bills_at_app_n_wh_high_rate() -> None:
|
|||
# 10-hour. `_hot_water_fuel_cost_gbp_per_kwh` previously billed any
|
||||
# electric off-peak HW at 100% low rate (its TODO), over-crediting the
|
||||
# HP-DHW cat-4 cluster. Electric IMMERSION (WHC 903) is a different
|
||||
# Table 12a row (off-peak immersion 0.17 / Table 13) and must stay on
|
||||
# the 100%-low-rate fallback here.
|
||||
# Table 12a row (Table 13) — without the cylinder volume / occupancy /
|
||||
# immersion-type inputs (not passed here) it falls back to the
|
||||
# 100%-low-rate scalar; the Table 13 blend is locked separately below.
|
||||
from domain.sap10_calculator.tables.table_12a import Tariff
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
_hot_water_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
|
||||
|
|
@ -3305,6 +3306,56 @@ def test_hot_water_from_pcdb_heat_pump_bills_at_app_n_wh_high_rate() -> None:
|
|||
assert abs(rate_immersion - 0.0750) <= 1e-6
|
||||
|
||||
|
||||
def test_hot_water_immersion_off_peak_bills_at_table_13_blend() -> None:
|
||||
# Arrange — SAP 10.2 Table 12a (PDF p.191) "Immersion water heater"
|
||||
# row routes the WH column to Table 13 (PDF p.197). For an electric
|
||||
# immersion (WHC 903) on an off-peak tariff with a known cylinder
|
||||
# volume + occupancy + immersion type, the HW bills at the Table 13
|
||||
# high-rate fraction blend, NOT 100% at the off-peak low rate. A dual
|
||||
# immersion (small fraction) bills only a little above the low rate; a
|
||||
# single immersion (large fraction) bills much closer to the high rate.
|
||||
# Pre-slice both billed 100% at the 7-hour low rate 5.50 p (£0.0550),
|
||||
# under-costing the dwelling and over-rating it (median +0.98 SAP
|
||||
# across the off-peak WHC-903 API cohort).
|
||||
from domain.sap10_calculator.tables.table_12a import Tariff
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
_hot_water_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
from domain.sap10_calculator.tables.table_13 import (
|
||||
electric_dhw_high_rate_fraction,
|
||||
)
|
||||
high_p, low_p = 15.29, 5.50 # Table 32 codes 32 / 31 (7-hour)
|
||||
n_occupants = 2.7395 # Appendix J Table 1b N at 100 m²
|
||||
|
||||
# Act — 110 L cylinder, occupancy N(100), dual (False) vs single (True).
|
||||
rate_dual = _hot_water_fuel_cost_gbp_per_kwh(
|
||||
29, None, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES,
|
||||
water_heating_code=903, cylinder_volume_l=110.0,
|
||||
occupancy_n=n_occupants, immersion_single=False,
|
||||
)
|
||||
rate_single = _hot_water_fuel_cost_gbp_per_kwh(
|
||||
29, None, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES,
|
||||
water_heating_code=903, cylinder_volume_l=110.0,
|
||||
occupancy_n=n_occupants, immersion_single=True,
|
||||
)
|
||||
|
||||
# Assert — each rate equals its Table 13 blend; single > dual; both
|
||||
# strictly above the 100%-low fallback (5.50 p) it replaces.
|
||||
frac_dual = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=110.0, occupancy_n=n_occupants,
|
||||
single_immersion=False, tariff=Tariff.SEVEN_HOUR,
|
||||
)
|
||||
frac_single = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=110.0, occupancy_n=n_occupants,
|
||||
single_immersion=True, tariff=Tariff.SEVEN_HOUR,
|
||||
)
|
||||
expected_dual = (frac_dual * high_p + (1 - frac_dual) * low_p) / 100
|
||||
expected_single = (frac_single * high_p + (1 - frac_single) * low_p) / 100
|
||||
assert abs(rate_dual - expected_dual) <= 1e-9
|
||||
assert abs(rate_single - expected_single) <= 1e-9
|
||||
assert rate_single > rate_dual > 0.0550
|
||||
|
||||
|
||||
def test_space_heating_pcdb_heat_pump_without_sap_code_bills_at_app_n_high_rate() -> None:
|
||||
# Arrange — an API-path heat pump resolves via its PCDB Table 362
|
||||
# index alone (data_source=1, no Table-4a SAP code lodged), so
|
||||
|
|
|
|||
103
tests/domain/sap10_calculator/test_table_13.py
Normal file
103
tests/domain/sap10_calculator/test_table_13.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""SAP 10.2 Table 13 — high-rate fraction for electric DHW heating.
|
||||
|
||||
Locks `electric_dhw_high_rate_fraction` against the published table grid
|
||||
and the Note-2 clamp at
|
||||
`domain/sap10_calculator/docs/specs/sap-10-2-full-specification-2025-03-14.pdf`,
|
||||
page 197. Table 12a's "Immersion water heater" row (PDF p.191) routes
|
||||
electric immersion DHW here.
|
||||
|
||||
The helper evaluates the Note-2 equations, which the spec offers as an
|
||||
exact alternative to the rounded grid — the pins below check that the
|
||||
equations reproduce the published 2-dp cells.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from domain.sap10_calculator.tables.table_12a import Tariff
|
||||
from domain.sap10_calculator.tables.table_13 import (
|
||||
electric_dhw_high_rate_fraction,
|
||||
)
|
||||
|
||||
# Appendix J Table 1b occupancy N at a few total floor areas (m²) — the
|
||||
# anchor for the V/N grid cells below. Computed from the same piecewise
|
||||
# formula the §4 worksheet uses (water_heating.assumed_occupancy).
|
||||
_N_AT_TFA_100 = 2.7395 # N(100 m²)
|
||||
_N_AT_TFA_60 = 1.9816 # N(60 m²)
|
||||
|
||||
# Table 13 high-rate-fraction grid cells (PDF p.197), keyed by (floor
|
||||
# area row, cylinder litres column, tariff, single?) → published value.
|
||||
_GRID_TOL = 0.005 # the published grid is rounded to 2 dp
|
||||
|
||||
|
||||
def test_table_13_dual_immersion_matches_published_grid() -> None:
|
||||
# Arrange — SAP 10.2 Table 13 (PDF p.197), 110 L cylinder, dual
|
||||
# immersion. Floor area 100 m² row: 7-hour = 0.18, 10-hour = 0.10.
|
||||
|
||||
# Act
|
||||
seven = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100,
|
||||
single_immersion=False, tariff=Tariff.SEVEN_HOUR,
|
||||
)
|
||||
ten = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100,
|
||||
single_immersion=False, tariff=Tariff.TEN_HOUR,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert abs(seven - 0.18) <= _GRID_TOL
|
||||
assert abs(ten - 0.10) <= _GRID_TOL
|
||||
|
||||
|
||||
def test_table_13_single_immersion_matches_published_grid() -> None:
|
||||
# Arrange — SAP 10.2 Table 13 (PDF p.197), 110 L cylinder, single
|
||||
# immersion. Floor area 100 m² row: 7-hour = 0.61, 10-hour = 0.23.
|
||||
# Single immersion carries a much larger high-rate fraction than dual.
|
||||
|
||||
# Act
|
||||
seven = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100,
|
||||
single_immersion=True, tariff=Tariff.SEVEN_HOUR,
|
||||
)
|
||||
ten = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100,
|
||||
single_immersion=True, tariff=Tariff.TEN_HOUR,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert abs(seven - 0.61) <= _GRID_TOL
|
||||
assert abs(ten - 0.23) <= _GRID_TOL
|
||||
|
||||
|
||||
def test_table_13_large_cylinder_single_immersion_clamps_to_zero() -> None:
|
||||
# Arrange — SAP 10.2 Table 13 Note 2 (PDF p.197): "If these formulae
|
||||
# give a value less than zero, set the high-rate fraction to zero." A
|
||||
# 210 L cylinder with single immersion on a 10-hour tariff falls below
|
||||
# zero (the published 210 L 10-hour column is 0), so the helper clamps.
|
||||
|
||||
# Act
|
||||
fraction = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=210.0, occupancy_n=_N_AT_TFA_60,
|
||||
single_immersion=True, tariff=Tariff.TEN_HOUR,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert fraction == 0.0
|
||||
|
||||
|
||||
def test_table_13_eighteen_hour_uses_ten_hour_column() -> None:
|
||||
# Arrange — SAP 10.2 Table 13 Note 1 (PDF p.197): the table applies
|
||||
# "for tariffs providing at least 10 hours ... at the low rate", so an
|
||||
# 18-hour tariff resolves to the 10-hour equations, not a separate
|
||||
# column.
|
||||
|
||||
# Act
|
||||
eighteen = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100,
|
||||
single_immersion=False, tariff=Tariff.EIGHTEEN_HOUR,
|
||||
)
|
||||
ten = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100,
|
||||
single_immersion=False, tariff=Tariff.TEN_HOUR,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert abs(eighteen - ten) <= 1e-9
|
||||
Loading…
Add table
Reference in a new issue