diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 6649ade1..d5c11b1c 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -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? diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index b47db127..1512ea44 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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, diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 8fceb878..8235569c 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -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 diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index c12bf31c..ef30581e 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 0ef51687..9a90c0fe 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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( diff --git a/domain/sap10_ml/tests/_fixtures.py b/domain/sap10_ml/tests/_fixtures.py index 040466b5..2c7c3b68 100644 --- a/domain/sap10_ml/tests/_fixtures.py +++ b/domain/sap10_ml/tests/_fixtures.py @@ -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, 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 8a055253..19288401 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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