fix(hot-water): default present-but-unsized cylinder to Table 28 Normal 110 L

RdSAP 10 §10.5 (PDF p.55): "If the actual size is not determined, the size of
a hot-water cylinder is taken as according to Table 28." When a cylinder is
present (has_hot_water_cylinder) but no size descriptor resolves — the gov API
lodges cylinder_size=0, or Exact with no measured volume — `_hot_water_
cylinder_volume_l` returned None, silently dropping BOTH the cylinder's
storage loss and the Table 13 electric-DHW high-rate fraction, under-costing
and over-rating the dwelling. Default such cylinders to the Table 28 baseline
"Normal" 110 L (the value §10.7 also instantiates as the first-row default).

The context-dependent Inaccessible 210/160 values are deliberately NOT applied
here — they are tied to the explicit "Inaccessible" descriptor (code 5) the
assessor lodges, not to an unpopulated size field.

Scope: 7 of 301 cylinder certs in the corpus (2%). Correctness fix — closes a
real spec gap; marginal on the headline (within-0.5 66.1% unchanged, MAE
1.128 -> 1.124) because these certs' residual is dominated by a separate HW-
demand gap, not the cylinder. Worksheet harness 47/47 0 diverge (Summary certs
lodge a real size, so the fallback never fires). 1 AAA test, pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-14 08:20:34 +00:00
parent bec62b9167
commit 94275d07cc
3 changed files with 46 additions and 7 deletions

View file

@ -5223,6 +5223,15 @@ _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
# RdSAP 10 §10.5 (PDF p.55): "If the actual size is not determined, the size
# of a hot-water cylinder is taken as according to Table 28." For a cylinder
# present but with no size descriptor lodged (size code 0 / absent), the
# baseline Table 28 default is the "Normal" row (110 L) — the same value
# §10.7 instantiates as the first-row default. The context-dependent
# Inaccessible 210/160 values are NOT applied here: they are tied to the
# explicit "Inaccessible" descriptor (code 5) the assessor lodges
# deliberately, not to a merely-unpopulated size field.
_CYLINDER_SIZE_NOT_DETERMINED_L: Final[float] = 110.0
def _cylinder_inaccessible_volume_l(epc: EpcPropertyData) -> float:
@ -6163,11 +6172,21 @@ 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 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."""
measured volume). Returns None only when no cylinder is lodged.
RdSAP 10 §10.5 (PDF p.55): "If the actual size is not determined, the
size of a hot-water cylinder is taken as according to Table 28." When a
cylinder IS present but no size descriptor resolves (size code 0 /
absent, or Exact with no measured volume), fall back to the Table 28
baseline "Normal" default (110 L). Without this the cylinder resolved
to None, silently dropping BOTH its storage loss and the Table 13
high-rate fraction, over-rating unsized-cylinder electric dwellings."""
if not epc.has_hot_water_cylinder:
return None
return _cylinder_volume_l_from_code(epc)
volume_l = _cylinder_volume_l_from_code(epc)
if volume_l is not None:
return volume_l
return _CYLINDER_SIZE_NOT_DETERMINED_L
def _immersion_is_single(epc: EpcPropertyData) -> Optional[bool]:

View file

@ -561,6 +561,25 @@ def test_cylinder_size_exact_code_6_uses_lodged_measured_volume() -> None:
assert volume_l is not None and abs(volume_l - 150.0) <= 1e-9
def test_cylinder_present_but_size_not_determined_defaults_to_normal_110l() -> None:
# Arrange — RdSAP 10 §10.5 (PDF p.55): "If the actual size is not
# determined, the size of a hot-water cylinder is taken as according to
# Table 28." A cylinder IS present but no size descriptor resolves (gov
# API lodges `cylinder_size=0`) → the Table 28 baseline "Normal" default
# of 110 L, NOT None (which silently dropped the cylinder's storage loss
# AND the Table 13 high-rate fraction, over-rating the dwelling).
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_hot_water_cylinder_volume_l, # pyright: ignore[reportPrivateUsage]
)
epc = _cylinder_epc(cylinder_size=0)
# 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_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.

View file

@ -41,10 +41,11 @@ _CORPUS = Path(
)
# Measured floors/ceilings over the fixed corpus at HEAD (1000 certs, 0 skips).
# Current: SAP within-0.5 = 66.1%, SAP MAE = 1.128 (Table 12a Grid 1
# integrated-storage code-408 0.20 high-rate fraction, this slice: sap408
# over-rate +14.6/+12.9/+12.7 -> +7.1/+5.1/+3.4; prior slice was the
# heat-network Table 4c(3) flat-rate charging factor).
# Current: SAP within-0.5 = 66.1%, SAP MAE = 1.124 (RdSAP 10 §10.5 present-
# but-unsized cylinder -> Table 28 Normal 110 L default, this slice — a
# correctness fix: 7 certs that silently dropped storage loss + Table 13;
# marginal on the headline. Prior slices: Table 12a code-408 0.20 storage
# high-rate fraction; heat-network Table 4c(3) flat-rate charging factor).
# CO2 MAE = 0.28 t/yr (signed +0.17 — a systematic over-estimate, see below).
# PE MAE = 14.7 kWh/m2/yr (signed +9.1).
#