diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index c36f3ef8c..1985db439 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6128,7 +6128,10 @@ def _map_sap_heating( secondary_heating_type=heating.secondary_heating.secondary_system, shower_outlets=shower_outlets, cylinder_size=( - heating.water_heating.cylinder_size + _pashub_cylinder_size_code( + heating.water_heating.cylinder_size, + heating.water_heating.system, + ) if cylinder_present else None ), @@ -6172,6 +6175,45 @@ def _pashub_mechanical_ventilation_kind( return _PASHUB_VENTILATION_TYPE_TO_KIND[ventilation_type] +# PasHub surveyed §Water-Heating "Cylinder Size" band → SAP10 cylinder-size +# enum (Table 28: 2=Normal/110L, 3=Medium/160L, 4=Large/210L). PasHub renders +# the band with its own litre-range suffix (unlike the Elmhurst forms in +# `_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10`); the raw string was int-or-noned by +# `_cylinder_volume_l_from_code`, silently skipping the Table 28 volume +# convention (issue #1590 bug 6). +_PASHUB_CYLINDER_SIZE_TO_SAP10: Dict[str, int] = { + "Normal (90-130 litres)": 2, + "Large (>170 litres)": 4, +} + + +def _pashub_cylinder_size_code( + size_label: Optional[str], system_label: str +) -> Optional[int]: + """Resolve a PasHub surveyed Cylinder Size band to the SAP10 enum at the + mapper boundary (ADR-0015). "No Access" follows RdSAP 10 Table 28 (p.55): + off-peak dual electric immersion → 210 L, solid-fuel boiler → 160 L, + otherwise → 110 L (code 2) — the cohort's case is a cylinder fed from the + gas main. An electric-immersion "No Access" needs the meter-type context + this path doesn't yet plumb, so it strict-raises (loud gap, mirroring the + cohort-scoped control approach) rather than silently taking the 110 L + branch; any other uncovered non-empty label strict-raises too.""" + if not size_label or size_label == "No Cylinder": + return None + if size_label == "No Access": + if system_label == "Electric immersion": + raise UnmappedPasHubLabel( + "cylinder size", + "'No Access' on an electric immersion needs meter-type " + "context (RdSAP 10 Table 28 210L branch) — not yet mapped", + ) + return 2 # Table 28 "otherwise": 110 L + code = _PASHUB_CYLINDER_SIZE_TO_SAP10.get(size_label) + if code is None: + raise UnmappedPasHubLabel("cylinder size", size_label) + return code + + def _map_sap_ventilation(ventilation: Ventilation) -> SapVentilation: return SapVentilation( ventilation_type=ventilation.ventilation_type, diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 77e99403c..844a17a03 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -1038,7 +1038,7 @@ class TestFromSiteNotesExample1: ) ], has_fixed_air_conditioning=False, - cylinder_size="Normal (90-130 litres)", + cylinder_size=2, # "Normal (90-130 litres)" → Table 28 code 2 cylinder_insulation_type="Factory fitted", cylinder_insulation_thickness_mm=12, water_heating_code=901, # cylinder + "From main heating 1" → WHC 901