fix(water-heating): complete RdSAP Table 28 cylinder-size map (codes 5 + 6)

`_CYLINDER_SIZE_CODE_TO_LITRES` held only codes 2/3/4 (Normal/Medium/Large →
110/160/210 L); codes 5 (Inaccessible) and 6 (Exact) fell through to None,
so the Table-13 high-rate fraction AND the cylinder storage loss were skipped
for those certs (20 code-6 certs in the API sample).

Per RdSAP 10 Specification (10-06-2025) §10.5 Table 28 (PDF p.55):
- Code 6 "Exact": use the lodged measured volume. The gov API carries it in
  `cylinder_size_measured` (e.g. 150 L) — now plumbed through the 21.0.0/21.0.1
  schema → mapper → `SapHeating.cylinder_volume_measured_l`.
- Code 5 "Inaccessible": 210 L if off-peak electric dual immersion, 160 L from
  a solid-fuel boiler, otherwise 110 L (n=0 in the current sample, but
  spec-complete).

New `_cylinder_volume_l_from_code` centralises Table 28 resolution and replaces
the three raw-dict call sites (`_hot_water_cylinder_volume_l`, the cylinder
storage-loss path, and the PCDB performance check) so all three honour codes
5/6 identically. `_cylinder_inaccessible_volume_l` applies the code-5 context
rule via the existing immersion/off-peak-meter/solid-fuel-boiler detectors.

Worksheet harness UNAFFECTED (47/47, 0 divergers): the Summary path lodges
neither code 5/6 nor a measured volume. API gauge: within-0.5 64.4% -> 65.1%
(mean|err| 1.085 -> 1.075) — the 20 code-6 certs now size their cylinder from
the measured volume. 4 AAA tests (code 6 measured; code 5 solid-fuel/default/
off-peak-dual-immersion). pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-11 10:02:39 +00:00
parent 781efd75c0
commit a97ff60b01
7 changed files with 167 additions and 16 deletions

View file

@ -140,6 +140,10 @@ class SapHeating:
cylinder_size: Optional[Union[int, str]] = (
None # int code from API; str (e.g. "Normal (90-130 litres)") from site notes
)
# RdSAP 10 §10.5 Table 28 — the lodged measured cylinder volume in
# litres, present only when `cylinder_size` is the "Exact" descriptor
# (gov-API code 6, field `cylinder_size_measured`). None otherwise.
cylinder_volume_measured_l: Optional[int] = None
water_heating_code: Optional[int] = None # TODO: make enum?
water_heating_fuel: Optional[int] = None # TODO: make enum?
immersion_heating_type: Optional[Union[int, str]] = None # TODO: make enum?

View file

@ -1331,6 +1331,7 @@ class EpcPropertyDataMapper:
has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning
== "true",
cylinder_size=schema.sap_heating.cylinder_size,
cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured,
water_heating_code=schema.sap_heating.water_heating_code,
water_heating_fuel=schema.sap_heating.water_heating_fuel,
immersion_heating_type=schema.sap_heating.immersion_heating_type,
@ -1622,6 +1623,7 @@ class EpcPropertyDataMapper:
has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning
== "true",
cylinder_size=schema.sap_heating.cylinder_size,
cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured,
water_heating_code=schema.sap_heating.water_heating_code,
water_heating_fuel=schema.sap_heating.water_heating_fuel,
immersion_heating_type=schema.sap_heating.immersion_heating_type,

View file

@ -76,6 +76,9 @@ class SapHeating:
secondary_fuel_type: Optional[int] = None
secondary_heating_type: Optional[int] = None
cylinder_insulation_thickness: Optional[int] = None
# RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged
# only when `cylinder_size` is the "Exact" descriptor (code 6).
cylinder_size_measured: Optional[int] = None
@dataclass

View file

@ -81,6 +81,9 @@ class SapHeating:
secondary_fuel_type: Optional[int] = None
secondary_heating_type: Optional[int] = None
cylinder_insulation_thickness: Optional[int] = None
# RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged
# only when `cylinder_size` is the "Exact" descriptor (code 6).
cylinder_size_measured: Optional[int] = None
@dataclass

View file

@ -5082,11 +5082,55 @@ _TABLE_3A_COMBI_LOSS_MAIN_HEATING_CATEGORIES: Final[frozenset[int]] = frozenset(
# code 3 → Medium (160 litres) (certs 0350, 0380, 2225, 2636,
# 3800, 9285)
# code 4 → Large (210 litres) (cert 9418)
# Codes 5 / 6 (Inaccessible / Exact) not yet observed.
# code 5 → Inaccessible (context-dependent — see Table 28 below,
# resolved by `_cylinder_inaccessible_volume_l`)
# code 6 → Exact (the lodged measured volume in litres,
# `cylinder_volume_measured_l`; 20 API certs)
_CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = {
2: 110.0, 3: 160.0, 4: 210.0
}
# RdSAP 10 §10.5 Table 28 (PDF p.55) — the "Inaccessible" descriptor's
# size-to-use depends on the heating context, and the "Exact" descriptor
# lodges its measured volume separately.
_CYLINDER_SIZE_INACCESSIBLE: Final[int] = 5
_CYLINDER_SIZE_EXACT: Final[int] = 6
_CYLINDER_INACCESSIBLE_DUAL_IMMERSION_L: Final[float] = 210.0
_CYLINDER_INACCESSIBLE_SOLID_FUEL_L: Final[float] = 160.0
_CYLINDER_INACCESSIBLE_DEFAULT_L: Final[float] = 110.0
def _cylinder_inaccessible_volume_l(epc: EpcPropertyData) -> float:
"""RdSAP 10 §10.5 Table 28 (PDF p.55) — size to use for an
"Inaccessible" cylinder (code 5): 210 L for off-peak electric DUAL
immersion, 160 L from a solid-fuel boiler, otherwise 110 L."""
if _immersion_is_single(epc) is False and _is_off_peak_meter(
epc.sap_energy_source.meter_type, fuel_is_electric=True
):
return _CYLINDER_INACCESSIBLE_DUAL_IMMERSION_L
main = _first_main_heating(epc)
if main is not None and main.sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES:
return _CYLINDER_INACCESSIBLE_SOLID_FUEL_L
return _CYLINDER_INACCESSIBLE_DEFAULT_L
def _cylinder_volume_l_from_code(epc: EpcPropertyData) -> Optional[float]:
"""RdSAP 10 §10.5 Table 28 — resolve the HW cylinder volume (litres)
from the lodged `cylinder_size` descriptor code. Codes 2/3/4
110/160/210; code 5 (Inaccessible) context-dependent; code 6 (Exact)
the lodged measured volume. Returns None when no size code is lodged
(or code 6 lodges no measured volume). Does NOT gate on
`has_hot_water_cylinder` callers apply that guard."""
size_code = _int_or_none(epc.sap_heating.cylinder_size)
if size_code is None:
return None
if size_code == _CYLINDER_SIZE_EXACT:
measured = _int_or_none(epc.sap_heating.cylinder_volume_measured_l)
return float(measured) if measured is not None else None
if size_code == _CYLINDER_SIZE_INACCESSIBLE:
return _cylinder_inaccessible_volume_l(epc)
return _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
# 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
@ -5484,10 +5528,7 @@ def _heat_pump_cylinder_meets_pcdb_criteria(
for the cohort this criterion is always "unknown" returns False.
"""
sh = epc.sap_heating
size_code = _int_or_none(sh.cylinder_size)
if size_code is None:
return False
cert_volume_l = _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
cert_volume_l = _cylinder_volume_l_from_code(epc)
if cert_volume_l is None:
return False
# Volume criterion.
@ -5995,15 +6036,13 @@ def _orientation_from_summary_string(raw: Optional[str]) -> Optional[Orientation
def _hot_water_cylinder_volume_l(epc: EpcPropertyData) -> Optional[float]:
"""Resolve the HW cylinder volume (litres) from the cert's
`cylinder_size` code via RdSAP 10 §10.5 Table 28. Returns None
when no cylinder is lodged or the size code falls outside the
cohort-observed range (codes 2-4 Normal / Medium / Large)."""
`cylinder_size` code via RdSAP 10 §10.5 Table 28 Normal/Medium/Large
(codes 2/3/4), Inaccessible (5, context-dependent) and Exact (6, lodged
measured volume). Returns None when no cylinder is lodged or no size
code resolves."""
if not epc.has_hot_water_cylinder:
return None
size_code = _int_or_none(epc.sap_heating.cylinder_size)
if size_code is None:
return None
return _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
return _cylinder_volume_l_from_code(epc)
def _immersion_is_single(epc: EpcPropertyData) -> Optional[bool]:
@ -6286,10 +6325,7 @@ def _cylinder_storage_loss_override(
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)
volume_l = _cylinder_volume_l_from_code(epc)
if volume_l is None:
return None
insulation_label = _cylinder_storage_loss_insulation_label(

View file

@ -96,6 +96,8 @@ def make_sap_heating(
water_heating_code: Optional[int] = 901,
water_heating_fuel: Optional[int] = 26,
cylinder_size: Optional[Union[int, str]] = None,
cylinder_volume_measured_l: Optional[int] = None,
immersion_heating_type: Optional[Union[int, str]] = None,
cylinder_insulation_type: Optional[int] = None,
cylinder_insulation_thickness_mm: Optional[int] = None,
cylinder_thermostat: Optional[str] = None,
@ -115,6 +117,8 @@ def make_sap_heating(
water_heating_code=water_heating_code,
water_heating_fuel=water_heating_fuel,
cylinder_size=cylinder_size,
cylinder_volume_measured_l=cylinder_volume_measured_l,
immersion_heating_type=immersion_heating_type,
cylinder_insulation_type=cylinder_insulation_type,
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm,
cylinder_thermostat=cylinder_thermostat,

View file

@ -421,6 +421,105 @@ def test_cylinder_thermostat_assumed_present_per_sap_9_4_9() -> None:
assert _cylinder_thermostat_present(epc_gas, gas_boiler) is False
def _cylinder_epc(
*, cylinder_size: int, cylinder_volume_measured_l: Optional[int] = None,
immersion_heating_type: Optional[int] = None, main: Optional[MainHeatingDetail] = None,
) -> EpcPropertyData:
"""A minimal cylinder-bearing epc for Table 28 size-code resolution."""
return 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(
main_heating_details=[main] if main is not None else None,
cylinder_size=cylinder_size,
cylinder_volume_measured_l=cylinder_volume_measured_l,
immersion_heating_type=immersion_heating_type,
),
)
def test_cylinder_size_exact_code_6_uses_lodged_measured_volume() -> None:
# Arrange — RdSAP 10 §10.5 Table 28 (PDF p.55): the "Exact" cylinder-size
# descriptor (gov-API code 6) lodges the measured volume in litres via
# `cylinder_size_measured`; SAP uses the actual size when present. The
# map previously held only codes 2/3/4 → code 6 fell through to None,
# skipping the Table-13 high-rate fraction (20 certs in the API sample).
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_hot_water_cylinder_volume_l, # pyright: ignore[reportPrivateUsage]
)
epc = _cylinder_epc(cylinder_size=6, cylinder_volume_measured_l=150)
# Act
volume_l = _hot_water_cylinder_volume_l(epc)
# Assert — the lodged 150 L is used verbatim, not a descriptor default.
assert volume_l is not None and abs(volume_l - 150.0) <= 1e-9
def test_cylinder_size_inaccessible_code_5_solid_fuel_boiler_uses_160l() -> None:
# Arrange — RdSAP 10 §10.5 Table 28: an "Inaccessible" cylinder (code 5)
# heated "from a solid fuel boiler" uses 160 litres.
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_hot_water_cylinder_volume_l, # pyright: ignore[reportPrivateUsage]
)
solid_fuel = MainHeatingDetail(
has_fghrs=False, main_fuel_type=5, heat_emitter_type=1,
emitter_temperature=1, main_heating_control=2106,
main_heating_category=1, sap_main_heating_code=153, # solid-fuel boiler
)
epc = _cylinder_epc(cylinder_size=5, main=solid_fuel)
# Act
volume_l = _hot_water_cylinder_volume_l(epc)
# Assert
assert volume_l is not None and abs(volume_l - 160.0) <= 1e-9
def test_cylinder_size_inaccessible_code_5_otherwise_uses_110l() -> None:
# Arrange — RdSAP 10 §10.5 Table 28: an "Inaccessible" cylinder (code 5)
# that is neither off-peak dual immersion nor solid-fuel uses 110 litres.
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_hot_water_cylinder_volume_l, # pyright: ignore[reportPrivateUsage]
)
gas_boiler = MainHeatingDetail(
has_fghrs=False, main_fuel_type=26, heat_emitter_type=1,
emitter_temperature=1, main_heating_control=2106,
main_heating_category=2, sap_main_heating_code=102,
)
epc = _cylinder_epc(cylinder_size=5, main=gas_boiler)
# Act
volume_l = _hot_water_cylinder_volume_l(epc)
# Assert
assert volume_l is not None and abs(volume_l - 110.0) <= 1e-9
def test_cylinder_size_inaccessible_code_5_off_peak_dual_immersion_uses_210l() -> None:
# Arrange — RdSAP 10 §10.5 Table 28: an "Inaccessible" cylinder (code 5)
# heated by an off-peak electric DUAL immersion (immersion_heating_type=1)
# uses 210 litres.
from dataclasses import replace
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_hot_water_cylinder_volume_l, # pyright: ignore[reportPrivateUsage]
)
base = _cylinder_epc(cylinder_size=5, immersion_heating_type=1) # 1 = dual
# Off-peak (dual / Economy-7) meter, not the fixture's "Single" default.
epc = replace(
base,
sap_energy_source=replace(base.sap_energy_source, meter_type="1"),
)
# Act
volume_l = _hot_water_cylinder_volume_l(epc)
# Assert
assert volume_l is not None and abs(volume_l - 210.0) <= 1e-9
def test_heat_network_distribution_electricity_per_sap_10_2_appendix_c_3_2() -> None:
# Arrange — heat-network main (Table 4a code 301 = community heating,
# category 6). SAP 10.2 Appendix C §C3.2 (PDF p.51): distribution