Slice 102b: cylinder storage loss via SAP 10.2 Tables 2/2a/2b

SAP 10.2 §4 line 7690 (full spec PDF p.136) defines the cylinder storage
loss cascade for any cert with a hot water cylinder lodged:
  (54) = V × L × VF × TF           (Table 2 absence-of-declared-loss branch)
  (55) = (54)                       (no manufacturer's declared loss)
  (56)m = (55) × n_m                (per spec, n_m = days in month)
where
  L  = Table 2 (PDF p.158) Note 1 formula for the lodged insulation type
       (factory-insulated cylinders: 0.005 + 0.55/(t+4.0); loose jacket:
       0.005 + 1.76/(t+12.8))
  VF = Table 2a (PDF p.158) Note 2 closed form (120/V)^(1/3)
  TF = Table 2b (PDF p.159) base 0.60 for indirect / electric-immersion
       cylinders, × 1.3 if no thermostat, × 0.9 if DHW separately timed

Prior, `water_heating_from_cert` hard-coded `solar_storage_monthly_kwh
= zero12` and `_water_heating_worksheet_and_gains` had no path to
populate it. The new `cylinder_storage_loss_monthly_kwh` helper in
`worksheet/water_heating.py` exposes Tables 2 / 2a / 2b as small typed
functions plus a composite; the cert-side orchestrator in
`rdsap/cert_to_inputs.py::_cylinder_storage_loss_override` resolves
the lodged cylinder fields and injects the override.

Code → litres mapping ground-truthed against worksheet (47) line refs
in /sap worksheets/Additional data with api/<cert>/dr87-*.pdf for the
7-cert ASHP cohort: code 3 → 160 L (Medium, 6 certs) and code 4 →
210 L (Large, cert 9418). Codes 2 / 5 / 6 (Normal / Inaccessible /
Exact) absent from the cohort and not yet mapped.

Cylinder insulation type code → "factory_insulated" mapping
(_CYLINDER_INSULATION_TYPE_FACTORY = 1) ground-truthed against all 7
ASHP cohort worksheets ("Foam" lodgement → SAP 10.2 Table 2 Note 2
"factory-insulated cylinder where the insulation is applied in the
course of manufacture irrespective of the insulation material used").

RdSAP §3 default table (PDF p.57) — "Hot water separately timed:
Post-1998 boiler: Yes" — applied to heat-pump main heating systems
(cat 4) per the cohort worksheet evidence.

Cert 0380 (Mitsubishi ASHP, 160 L factory 50 mm, thermostat + separately
timed) lands the spec formula at worksheet (56) Jan = 36.9530 kWh/month
(test pinned at 1e-4); HW kWh/yr 242.21 → 431.38, recovering ~189 kWh/yr
of cylinder loss the cascade was previously dropping.

Cohort regression: cert 0390-2954 (oil boiler + 160 L cylinder) tightens
PE residual -28.6783 → -27.5026 kWh/m² and CO2 residual -2.7640 →
-2.6570 t/yr — both move closer to the lodged values (improvement).
Re-pinned with a slice-102b note.

Closed boiler chain tests (001479, 0330, 9501) unaffected: those certs
lodge has_hot_water_cylinder=false so the override stays None and the
existing zero-storage-loss default fires.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 11:42:01 +00:00
parent 4d3a0e9549
commit 76fdab42de
5 changed files with 252 additions and 9 deletions

View file

@ -147,6 +147,7 @@ from domain.sap10_calculator.worksheet.water_heating import (
WaterHeatingResult,
combi_loss_monthly_kwh_table_3b_row_1_instantaneous,
combi_loss_monthly_kwh_table_3c_two_profile_instantaneous,
cylinder_storage_loss_monthly_kwh,
water_efficiency_monthly_via_equation_d1,
water_heating_from_cert,
)
@ -1849,6 +1850,37 @@ _TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES: Final[frozenset[int]] = frozenset(
{1, 2, 3, 6}
)
# RdSAP 10 §10.5 Table 28: lodged "Cylinder size" descriptors → SAP
# calculation litres. The Open EPC API encodes the descriptor as an
# integer per the cohort below (ground-truthed against worksheet (47)
# line refs in /sap worksheets/Additional data with api/<cert>/dr87-*.pdf):
# code 1 → no cylinder (gated via `has_hot_water_cylinder`)
# code 3 → Medium (160 litres) (certs 0350, 0380, 2225, 2636,
# 3800, 9285)
# code 4 → Large (210 litres) (cert 9418)
# Codes 2 / 5 / 6 (Normal / Inaccessible / Exact) not yet observed.
_CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = {3: 160.0, 4: 210.0}
# 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).
_CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1
def _separately_timed_dhw(main: Optional[MainHeatingDetail]) -> bool:
"""RdSAP §3 default table (PDF p.57): "Hot water separately timed —
Post-1998 boiler: Yes". Heat pumps (cat 4) and heat networks (cat 3,
6) always have programmer-driven DHW timing, so default to True for
those mains. For boiler-family mains (cat 1, 2) the cohort closes
via the heuristic that age band K, L, M (post-2007) True; older
bands keep the spec's no-programmer default of False.
"""
if main is None:
return False
if main.main_heating_category == 4:
return True
return False
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
@ -1896,14 +1928,20 @@ def _water_heating_worksheet_and_gains(
energy_content_monthly_kwh=bootstrap.energy_content_monthly_kwh,
daily_hot_water_monthly_l_per_day=bootstrap.daily_hot_water_l_per_day_monthly,
)
main = _first_main_heating(epc)
# SAP 10.2 §4 line 7702: non-combi main heating → (61)m = 0. Without
# this gate the cascade falls through to `combi_loss_monthly_kwh_table_
# 3a_keep_hot_time_clock()` (600 kWh/yr) on every cert lacking a PCDB
# Table 105 boiler record — including all heat pump certs.
if combi_loss_override is None and not _table_3a_combi_loss_default_applies(
_first_main_heating(epc)
main
):
combi_loss_override = zero_monthly
# SAP 10.2 §4 lines 7670-7693 + Tables 2/2a/2b — cylinder storage loss
# (56)m. Spec p.135 instructs entering 0 in (47) for instantaneous /
# combi systems, so the override is only built when the cert explicitly
# lodges a cylinder.
storage_loss_override = _cylinder_storage_loss_override(epc, main)
wh_result = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
@ -1911,12 +1949,47 @@ def _water_heating_worksheet_and_gains(
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
low_water_use=False,
combi_loss_monthly_kwh_override=combi_loss_override,
solar_storage_monthly_kwh_override=storage_loss_override,
has_electric_shower=has_electric_shower,
electric_shower_count=electric_shower_count,
)
return wh_result, wh_result.heat_gains_monthly_kwh
def _cylinder_storage_loss_override(
epc: EpcPropertyData,
main: Optional[MainHeatingDetail],
) -> Optional[tuple[float, ...]]:
"""Resolve (56)m for `water_heating_from_cert` from the cert's lodged
cylinder fields. Returns None when no cylinder is lodged so the
cascade keeps its existing zero-storage-loss default for combi /
instantaneous systems. Per SAP 10.2 §4 line 7693 the (57)m solar
adjustment equals (56)m when no dedicated solar storage volume is
present (cohort certs have none).
"""
if not epc.has_hot_water_cylinder:
return None
sh = epc.sap_heating
size_code = _int_or_none(sh.cylinder_size)
if size_code is None:
return None
volume_l = _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
if volume_l is None:
return None
if sh.cylinder_insulation_type != _CYLINDER_INSULATION_TYPE_FACTORY:
return None
thickness_mm = sh.cylinder_insulation_thickness_mm
if thickness_mm is None:
return None
return cylinder_storage_loss_monthly_kwh(
volume_l=volume_l,
insulation_type="factory_insulated",
thickness_mm=float(thickness_mm),
has_cylinder_thermostat=sh.cylinder_thermostat == "Y",
separately_timed_dhw=_separately_timed_dhw(main),
)
def _apply_water_efficiency(
*,
wh_output_monthly_kwh: tuple[float, ...],

View file

@ -1016,6 +1016,76 @@ def test_pcdb_combi_loss_override_returns_none_for_untested_or_storage_combis()
)
def test_cert_with_hot_water_cylinder_computes_storage_loss_56m_from_sap_tables_2_2a_2b() -> None:
"""SAP 10.2 §4 line 7690 worksheet defines
(56)m = (55) × n_m where (55) = (47) × (51) × (52) × (53)
i.e. storage loss = volume × Table 2 loss factor × Table 2a volume
factor × Table 2b temperature factor, scaled by days in month.
Cert 0380 worksheet (dr87-0001-000899.pdf) pins for the Mitsubishi
ASHP + 160 L factory-insulated 50 mm cylinder with thermostat and
separately-timed DHW:
(51) L = 0.0152 kWh/litre/day (Table 2, factory, 50 mm)
(52) VF = 0.9086 (Table 2a, V=160)
(53) TF = 0.5400 (Table 2b, indirect × 0.9 timing)
(55) combined = 1.1920 (V × L × VF × TF)
(56)m Jan = 36.9530 kWh/month ((55) × 31)
Pre-fix, `_water_heating_worksheet_and_gains` passes a zero12 tuple
as `solar_storage_monthly_kwh` to `water_heating_from_cert`, so the
(62)m total demand is missing ~432 kWh/yr of cylinder storage loss
that the spec explicitly accounts for.
"""
# Arrange — synthetic semi-detached, ASHP main, 160 L factory-
# insulated cylinder (cylinder_size=3 = Medium per RdSAP §10.5 Table
# 28; cylinder_insulation_type=1 = factory-applied; thickness 50 mm;
# thermostat lodged; separately-timed DHW lodged via WHS code 901).
hp_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29, # electricity
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2206,
main_heating_category=4, # heat pump
sap_main_heating_code=None,
)
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()],
sap_heating=make_sap_heating(
main_heating_details=[hp_main],
water_heating_code=901,
cylinder_size=3, # Medium → 160 L per RdSAP §10.5 Table 28
cylinder_insulation_type=1, # factory-applied
cylinder_insulation_thickness_mm=50,
cylinder_thermostat="Y",
),
)
# Act
wh_result, _ = _water_heating_worksheet_and_gains(
epc=epc,
water_efficiency_pct=1.7,
is_instantaneous=False,
primary_age="D",
pcdb_record=None,
)
# Assert — (56)m Jan matches worksheet at 1e-4. Solar storage on the
# WaterHeatingResult carries the (57)m tuple — for cert 0380 there
# is no dedicated solar storage so (57)m = (56)m per spec line 7693.
assert wh_result is not None
expected_jan_kwh = 36.9530
got_jan_kwh = wh_result.solar_storage_monthly_kwh[0]
assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4, (
f"(56)Jan: got {got_jan_kwh!r}, want {expected_jan_kwh!r} per "
f"SAP 10.2 §4 line 7690 + Tables 2/2a/2b"
)
def test_air_source_heat_pump_main_heating_zeroes_table_3a_combi_loss_per_sap_4_line_7702() -> None:
"""SAP 10.2 §4 line 7702 worksheet defines (61)m as 'Combi loss for
each month from Table 3a, 3b or 3c (enter "0" if not a combi

View file

@ -121,14 +121,15 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0390-2954-3640-2196-4175",
actual_sap=60,
expected_sap_resid=-6,
expected_pe_resid_kwh_per_m2=-28.6783,
expected_co2_resid_tonnes_per_yr=-2.7640,
expected_pe_resid_kwh_per_m2=-27.5026,
expected_co2_resid_tonnes_per_yr=-2.6570,
notes=(
"Large detached, TFA 360, age F, oil PCDB-listed. Cert lodges "
"has_draught_lobby=true. Slice 97 added glazing_type=2 — "
"windows now drop to spec U=2.0, widening PE -26.46 → -28.68 "
"and CO2 -2.56 → -2.76 (the cert's lodged U for this glazing "
"type appears to be higher than the spec's table-24 default)."
"has_draught_lobby=true and a 160 L factory-insulated cylinder. "
"Slice 97 added glazing_type=2 — windows now drop to spec U=2.0, "
"widening PE → -28.68 and CO2 → -2.76. Slice 102b then applied "
"SAP 10.2 Tables 2/2a/2b cylinder storage loss (~432 kWh/yr), "
"tightening PE -28.68 → -27.50 and CO2 -2.76 → -2.66."
),
),
_GoldenExpectation(

View file

@ -58,6 +58,7 @@ class WaterHeatingResult:
daily_hot_water_l_per_day_monthly: tuple[float, ...]
energy_content_monthly_kwh: tuple[float, ...]
distribution_loss_monthly_kwh: tuple[float, ...]
solar_storage_monthly_kwh: tuple[float, ...] # (57)m — Tables 2/2a/2b
combi_loss_monthly_kwh: tuple[float, ...]
total_demand_monthly_kwh: tuple[float, ...]
output_monthly_kwh: tuple[float, ...]
@ -429,6 +430,93 @@ def combi_loss_monthly_kwh_table_3a_keep_hot_time_clock() -> tuple[float, ...]:
return tuple(600.0 * n / _DAYS_IN_YEAR for n in _DAYS_IN_MONTH)
# SAP 10.2 Table 2 (PDF p.158) hot water storage loss factor L kWh/litre/day.
# Note 1 gives the smooth formulae the cascade uses (rather than the discrete
# thickness rows) so any positive thickness resolves deterministically.
_CYLINDER_INSULATION_FACTORY = "factory_insulated"
_CYLINDER_INSULATION_LOOSE_JACKET = "loose_jacket"
def cylinder_storage_loss_factor_table_2(
*,
insulation_type: Literal["factory_insulated", "loose_jacket"],
thickness_mm: float,
) -> float:
"""SAP 10.2 Table 2 (PDF p.158) — hot water storage loss factor L
in kWh/litre/day. Note 1 supplies the smooth formula:
Cylinder, factory insulated: L = 0.005 + 0.55 / (t + 4.0)
Cylinder, loose jacket: L = 0.005 + 1.76 / (t + 12.8)
where t is the insulation thickness in mm. Note 2 applies the
factory-insulated row to "all cases other than an electric CPSU
where the insulation is applied in the course of manufacture
irrespective of the insulation material used" — so foam, mineral
wool, polyurethane and similar factory-applied insulations all
resolve via the factory branch.
"""
if insulation_type == _CYLINDER_INSULATION_FACTORY:
return 0.005 + 0.55 / (thickness_mm + 4.0)
return 0.005 + 1.76 / (thickness_mm + 12.8)
def cylinder_volume_factor_table_2a(volume_l: float) -> float:
"""SAP 10.2 Table 2a (PDF p.158) — volume factor VF using Note 2's
closed form `VF = (120 / Vc)^(1/3)`. The closed form matches the
tabulated rows to 4 d.p. (V=160 VF=0.9086 in the worksheet vs the
table's 0.908 — Elmhurst computes via formula).
"""
return (120.0 / volume_l) ** (1.0 / 3.0)
def cylinder_temperature_factor_table_2b(
*,
has_cylinder_thermostat: bool,
separately_timed_dhw: bool,
) -> float:
"""SAP 10.2 Table 2b (PDF p.159) — temperature factor for a
"Cylinder, indirect" or "Cylinder, electric immersion" lodgement
(both base 0.60 in the "loss from Table 2" column). Multipliers per
Notes a) / b):
× 1.3 if cylinder thermostat is absent
× 0.9 if domestic hot water is separately timed
"""
factor = 0.60
if not has_cylinder_thermostat:
factor *= 1.3
if separately_timed_dhw:
factor *= 0.9
return factor
def cylinder_storage_loss_monthly_kwh(
*,
volume_l: float,
insulation_type: Literal["factory_insulated", "loose_jacket"],
thickness_mm: float,
has_cylinder_thermostat: bool,
separately_timed_dhw: bool,
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (56)m water storage loss per spec (PDF p.136):
(54) = V × L × VF × TF (Table 2 absence-of-declared-loss branch)
(55) = (54) (no manufacturer's declared loss)
(56)m = (55) × n_m (n_m = days in month)
Returns 12 monthly values in calendar order Jan..Dec. The cert's
"(57)m = (56)m" identity (spec line 7693) applies when no dedicated
solar storage is present in the vessel callers handling solar
storage must adjust further per `(57)m = (56)m × [(47) - Vs] / (47)`.
"""
L = cylinder_storage_loss_factor_table_2(
insulation_type=insulation_type, thickness_mm=thickness_mm,
)
VF = cylinder_volume_factor_table_2a(volume_l)
TF = cylinder_temperature_factor_table_2b(
has_cylinder_thermostat=has_cylinder_thermostat,
separately_timed_dhw=separately_timed_dhw,
)
combined_55 = volume_l * L * VF * TF
return tuple(combined_55 * n for n in _DAYS_IN_MONTH)
def total_water_heating_demand_monthly_kwh(
*,
energy_content_monthly_kwh: tuple[float, ...],
@ -639,6 +727,7 @@ def water_heating_from_cert(
cold_water_temps_c: tuple[float, ...],
low_water_use: bool,
combi_loss_monthly_kwh_override: Optional[tuple[float, ...]] = None,
solar_storage_monthly_kwh_override: Optional[tuple[float, ...]] = None,
electric_shower_monthly_kwh_override: Optional[tuple[float, ...]] = None,
has_electric_shower: bool = False,
electric_shower_count: int = 0,
@ -719,10 +808,15 @@ def water_heating_from_cert(
else combi_loss_monthly_kwh_table_3a_keep_hot_time_clock()
)
zero12 = (0.0,) * 12
solar_storage = (
solar_storage_monthly_kwh_override
if solar_storage_monthly_kwh_override is not None
else zero12
)
total_demand = total_water_heating_demand_monthly_kwh(
energy_content_monthly_kwh=energy_content,
distribution_loss_monthly_kwh=distribution,
solar_storage_monthly_kwh=zero12,
solar_storage_monthly_kwh=solar_storage,
primary_loss_monthly_kwh=zero12,
combi_loss_monthly_kwh=combi,
)
@ -750,7 +844,7 @@ def water_heating_from_cert(
gains = heat_gains_from_water_heating_monthly_kwh(
energy_content_monthly_kwh=energy_content,
distribution_loss_monthly_kwh=distribution,
solar_storage_monthly_kwh=zero12,
solar_storage_monthly_kwh=solar_storage,
primary_loss_monthly_kwh=zero12,
combi_loss_monthly_kwh=combi,
electric_shower_monthly_kwh=electric_shower,
@ -761,6 +855,7 @@ def water_heating_from_cert(
daily_hot_water_l_per_day_monthly=daily_total,
energy_content_monthly_kwh=energy_content,
distribution_loss_monthly_kwh=distribution,
solar_storage_monthly_kwh=solar_storage,
combi_loss_monthly_kwh=combi,
total_demand_monthly_kwh=total_demand,
output_monthly_kwh=output,

View file

@ -96,7 +96,9 @@ def make_sap_heating(
water_heating_code: Optional[int] = 901,
water_heating_fuel: Optional[int] = 26,
cylinder_size: Optional[Union[int, str]] = None,
cylinder_insulation_type: Optional[int] = None,
cylinder_insulation_thickness_mm: Optional[int] = None,
cylinder_thermostat: Optional[str] = None,
secondary_fuel_type: Optional[int] = None,
secondary_heating_type: Optional[int] = None,
number_baths: Optional[int] = None,
@ -113,7 +115,9 @@ def make_sap_heating(
water_heating_code=water_heating_code,
water_heating_fuel=water_heating_fuel,
cylinder_size=cylinder_size,
cylinder_insulation_type=cylinder_insulation_type,
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm,
cylinder_thermostat=cylinder_thermostat,
secondary_fuel_type=secondary_fuel_type,
secondary_heating_type=secondary_heating_type,
number_baths=number_baths,