diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 41066c19d..947d5e6ca 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6080,7 +6080,9 @@ def _map_sap_heating( heat_emitter_type=_pashub_heat_emitter_code(main.emitter), emitter_temperature=main.emitter_temperature, fan_flue_present=main.fan_assist, - main_heating_control=_pashub_main_heating_control_code(main.controls), + main_heating_control=_pashub_main_heating_control_code( + main.controls, main.system_type + ), condensing=main.condensing, weather_compensator=main.weather_compensator, central_heating_pump_age_str=main.central_heating_pump_age, @@ -7130,15 +7132,30 @@ def _pashub_main_fuel_code(fuel_label: str) -> Union[int, str]: return code +# PasHub `system_type` labels that are SAP 10.2 Table 4e Group 1 boiler systems +# — the only group whose control codes `_PASHUB_MAIN_HEATING_CONTROL_TO_SAP10` +# covers. This gate matters because Table 4e is organised by heating-system +# family and the *same* control label recurs in every group under a different +# 2xxx code (e.g. "Programmer, room thermostat and TRVs" is 2106 on a boiler but +# 2210 on a heat pump, 23xx on a heat network). Resolving the label alone would +# silently hand a non-boiler dwelling a boiler code, so any system_type not +# recognised here strict-raises instead — the gap surfaces rather than +# mis-rating. Lower-cased to match the electric-inference comparison above. +_PASHUB_BOILER_SYSTEM_TYPES: frozenset[str] = frozenset({ + "boiler with radiators or underfloor heating", +}) + + # PasHub Site-Notes surveyed main-heating "Controls" labels mapped to their -# SAP 10.2 Table 4e (PDF p.171) code, which the calculator's `_control_type` -# keys off. Copying the raw label onto `main_heating_control` strict-raises -# `UnmappedSapCode` deep in the calculator; resolving it here at the boundary -# (ADR-0015) hands the cascade the int it expects. Seeded with the boiler -# (Group 1) control labels confirmed in the live cohort. Codes are taken -# verbatim from Table 4e Group 1; the control *type* is then the spec's — note -# 2104 ("Programmer and room thermostat") is control type 1, not 2 (type 2 -# needs 2+ room thermostats or TRVs), so the map is not uniformly type 2. +# SAP 10.2 Table 4e Group 1 (PDF p.171, boiler) code, which the calculator's +# `_control_type` keys off. Copying the raw label onto `main_heating_control` +# strict-raises `UnmappedSapCode` deep in the calculator; resolving it here at +# the boundary (ADR-0015) hands the cascade the int it expects. Codes are taken +# verbatim from Table 4e Group 1 and every one is in the calculator's +# `_CONTROL_TYPE_BY_CODE` inventory; the control *type* is then the spec's — +# note 2104 ("Programmer and room thermostat") is control type 1, not 2 (type 2 +# needs 2+ room thermostats or TRVs), so the map is not uniformly type 2. Only +# valid once the system is a Group 1 boiler (see `_PASHUB_BOILER_SYSTEM_TYPES`). _PASHUB_MAIN_HEATING_CONTROL_TO_SAP10: Dict[str, int] = { "Programmer and room thermostat": 2104, "Programmer, room thermostat and TRVs": 2106, @@ -7148,15 +7165,28 @@ _PASHUB_MAIN_HEATING_CONTROL_TO_SAP10: Dict[str, int] = { } -def _pashub_main_heating_control_code(control_label: str) -> Union[int, str]: +def _pashub_main_heating_control_code( + control_label: str, system_type: str +) -> Union[int, str]: """Resolve a PasHub surveyed main-heating Controls label to its SAP 10.2 Table 4e code at the mapper boundary (ADR-0015). A genuinely blank label is - the "no controls lodged" shape and passes through unchanged; a non-empty - label the lookup does not cover strict-raises `UnmappedPasHubLabel` so the - gap is fixed here, never strict-raised deep in the calculator as - `UnmappedSapCode`.""" + the "no controls lodged" shape and passes through unchanged. + + The resolution is system-aware: the lookup holds only Group 1 (boiler) + codes, and the same control label recurs across Table 4e's system groups + under different 2xxx codes, so a non-boiler `system_type` strict-raises + (naming the system) rather than silently taking a boiler code. A boiler + system whose label the lookup does not cover likewise strict-raises — either + way the gap is fixed here, never mis-rated or strict-raised deep in the + calculator as `UnmappedSapCode`.""" if not control_label: return control_label + if system_type.lower() not in _PASHUB_BOILER_SYSTEM_TYPES: + raise UnmappedPasHubLabel( + "main heating control", + f"{control_label!r} on non-boiler system {system_type!r} " + f"(only Table 4e Group 1 boiler codes are mapped)", + ) code = _PASHUB_MAIN_HEATING_CONTROL_TO_SAP10.get(control_label) if code is None: raise UnmappedPasHubLabel("main heating control", control_label) diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index 07786b566..657c50033 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -1012,6 +1012,11 @@ class TestPasHubUnmappedMainFuel: raw["heating_and_hot_water"]["main_heating"][ "system_type" ] = "Electric storage heaters" + # This is a fuel-inference test; blank the controls so the (boiler-only) + # control lookup passes the empty label through rather than strict-raising + # on the electric system_type — an electric-storage dwelling wouldn't + # lodge a boiler control anyway. + raw["heating_and_hot_water"]["main_heating"]["controls"] = "" survey = from_dict(PasHubRdSapSiteNotes, raw) # Act @@ -1041,6 +1046,26 @@ class TestPasHubMainHeatingControlCoding: ("TRVs and bypass", 2111, 2), ] + def test_every_mapped_code_is_in_the_calculator_table_4e_inventory(self) -> None: + # Every code the mapper emits must exist in the calculator's Table 4e + # inventory `_CONTROL_TYPE_BY_CODE`; a code that isn't there would + # strict-raise `UnmappedSapCode` in `_control_type`. This guards the + # mapper against drifting out of sync with the calculator's coverage. + from datatypes.epc.domain.mapper import ( # pyright: ignore[reportPrivateUsage] + _PASHUB_MAIN_HEATING_CONTROL_TO_SAP10, + ) + from domain.sap10_calculator.rdsap.cert_to_inputs import ( # pyright: ignore[reportPrivateUsage] + _CONTROL_TYPE_BY_CODE, + ) + + unknown = { + code + for code in _PASHUB_MAIN_HEATING_CONTROL_TO_SAP10.values() + if code not in _CONTROL_TYPE_BY_CODE + } + + assert not unknown + @pytest.mark.parametrize( "label, code", [(lbl, code) for lbl, code, _ in _LABEL_CODE_TYPE] )