diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index d35be43b..9c6cc278 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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]: diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 9d7971cf..a862c238 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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. diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index e6c3be1e..378b6b99 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -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). #