From f253a7b9baeae977d28f7ce3e15d3e4607193d44 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 15:39:31 +0000 Subject: [PATCH 01/19] =?UTF-8?q?Resolve=20solid-fuel=20room-heater=20over?= =?UTF-8?q?rides=20off=20Gas=20CPSU=20to=20their=20SAP=20codes=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...lid-fuel-and-electric-boiler-archetypes.md | 89 +++++++++++++++++++ .../epc/test_main_heating_system_overlay.py | 23 +++++ 2 files changed, 112 insertions(+) create mode 100644 docs/adr/0045-gas-cpsu-dumping-ground-splits-into-solid-fuel-and-electric-boiler-archetypes.md diff --git a/docs/adr/0045-gas-cpsu-dumping-ground-splits-into-solid-fuel-and-electric-boiler-archetypes.md b/docs/adr/0045-gas-cpsu-dumping-ground-splits-into-solid-fuel-and-electric-boiler-archetypes.md new file mode 100644 index 000000000..49117b477 --- /dev/null +++ b/docs/adr/0045-gas-cpsu-dumping-ground-splits-into-solid-fuel-and-electric-boiler-archetypes.md @@ -0,0 +1,89 @@ +# The "Gas CPSU" dumping ground splits into solid-fuel room-heater and electric-boiler archetypes + +## Status + +accepted + +## Context + +ADR-0041 named **"Gas CPSU" (SAP Table 4a 120, mains gas)** as the original +garbage-drawer archetype the LLM classifier over-used when the heating taxonomy +lacked a correct target. An audit of every `main_heating_system` override still +resolving to `"Gas CPSU"` found it standing in for three *different*, unrelated +systems — a mains-gas CPSU is neither solid fuel nor electric, so every one of +these is mis-scored (wrong system **and**, for the electric ones, wrong fuel): + +| description | rows | true system | +| --- | --- | --- | +| Solid fuel room heaters: Open fire in grate | 16 | open fire (solid) | +| Solid fuel room heaters: Open fire with back boiler (no radiators) | 19 | open fire + back boiler | +| Solid fuel room heaters: Closed room heater with boiler (no radiators) | 2 | closed room heater + boiler | +| Boiler: A rated NA | 58 | **electric** boiler | +| Boiler: A rated CPSU | 1 | electric CPSU | +| Boiler: C rated CPSU | 1 | *genuine* gas CPSU — correct | + +**The electric boilers are the sharpest mis-score.** All 58 `"Boiler: A rated NA"` +rows carry a `main_fuel = electricity` override — "NA" is the Hyde boiler-type +value meaning "not a gas combi / regular / CPSU", i.e. an **electric boiler**. +Classified as Gas CPSU they are scored as a mains-gas stored-water unit. A +lodged-cert check showed **defer-to-lodged is not viable**: 34 of the 58 lodge +**no** main-heating code at all (fuel 0), 11 lodge electric *room heaters* (691), +a few lodge community heating — the very gap the landlord override exists to fill. +So the faithful move is to model the electric boiler the landlord names, informed +by the electricity fuel we hold, not to defer to an empty cert. + +The correct SAP Table 4a codes are exact (confirmed against +`domain/sap10_ml/sap_efficiencies.py`): **631** open fire (32%), **632** open fire ++ back boiler (50%), **634** closed room heater + boiler (65%); **191** +direct-acting electric boiler (100%). All are already scored by the calculator, +and 631/632/634 already sit in the overlay's `_SOLID_FUEL_ROOM_HEATER_CODES` set, +so their coherent companions (house-coal fuel 33, room-heater control 2601, +category 10) drag automatically (ADR-0035). + +## Decision + +Add the missing archetypes and reclassify the bucket. No calculator change. + +1. **Three solid-fuel room-heater archetypes** in `_MAIN_HEATING_CODES`: + `"Solid fuel room heater, open fire"` → 631, + `"Solid fuel room heater, open fire with back boiler"` → 632, + `"Solid fuel room heater, closed with boiler"` → 634. Companions drag from the + code (already-covered 631–636 set) — no overlay-logic change. +2. **`"Electric boiler"` → SAP 191**, dragging **electricity (29)** via + `_natural_fuel_for` — an electric boiler is unambiguously electric (ADR-0041 + "direct-acting/storage → electricity"), and this also closes the pre-existing + gap where 191 dragged no fuel. Meter stays Single, control None (191 is neither + a storage nor a wet-boiler-control system). +3. **Deterministic `main_heating_guard` + reclassify**, TEXT-first / + pgEnum-deferred. The guard resolves the structured phrasings by description + alone: `"…open fire … back boiler"` → 632 (tested before the bare `"open fire"` + → 631), `"…closed … with boiler"` → 634, and `"Boiler: NA"` → + `"Electric boiler"` (the NA type is the electric signal). `scripts/ + reclassify_gas_cpsu.py` reuses the guard as its decision function and defers the + four new `main_heating_system` pgEnum-cache values. + +## Consequences + +- ~95 rows leave the Gas CPSU garbage drawer for faithful archetypes: 37 solid + fuel scored as solid fuel (not mains gas), 58 electric boilers scored as electric + (not a mains-gas CPSU). Correct Rebaselining (ADR-0039). +- **FE-owned pgEnum additions (Dan):** four new `main_heating_system` values — + `"Solid fuel room heater, open fire"`, `"Solid fuel room heater, open fire with + back boiler"`, `"Solid fuel room heater, closed with boiler"`, `"Electric + boiler"`. TEXT read path fixed on merge; cache writes defer to the migration. +- Extends [ADR-0041](0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md) + (complete taxonomy — retiring the Gas CPSU garbage drawer it named) and + [ADR-0035](0035-coherent-heating-system-synthesis.md) (companions drag from the + code). + +### Out of scope + +- **Electric CPSU (SAP 192), the 1 `"Boiler: A rated CPSU"` row.** Its description + is identical to the genuine gas `"Boiler: C rated CPSU"` bar the efficiency + band — the electric/gas split is **fuel-determined**, which a pure-string guard + cannot see. Rather than break the guard = reclassify-decision-function symmetry + for one row, this is deferred to a small fuel-aware follow-up; the gas CPSU row + stays correctly on 120. +- **Whether "NA" ever denotes a non-electric boiler.** It is 100% electric in the + corpus and the guard treats it as electric; a future non-electric "NA" would be + a re-open signal, not a silent mis-map (the fuel override would disagree). diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index b100f75a6..4421fefe1 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -78,6 +78,29 @@ def test_heating_archetypes_decode_to_their_sap_codes( assert simulation.heating.sap_main_heating_code == code +@pytest.mark.parametrize( + ("main_heating_value", "code"), + [ + # Solid-fuel room heaters the LLM funnelled into "Gas CPSU" (120, mains + # gas). SAP Table 4a: 631 open fire, 632 open fire + back boiler, 634 + # closed room heater + boiler (ADR-0045). + ("Solid fuel room heater, open fire", 631), + ("Solid fuel room heater, open fire with back boiler", 632), + ("Solid fuel room heater, closed with boiler", 634), + ], +) +def test_solid_fuel_room_heaters_decode_off_gas_cpsu( + main_heating_value: str, code: int +) -> None: + # Act + simulation = main_heating_overlay_for(main_heating_value, 0) + + # Assert — its own solid-fuel code, not mains-gas CPSU (120). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == code + + @pytest.mark.parametrize( ("main_heating_value", "code"), [ From de4e44aacc90808a8629d882763c6ace6e6afba3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 15:41:52 +0000 Subject: [PATCH 02/19] =?UTF-8?q?Resolve=20solid-fuel=20room-heater=20over?= =?UTF-8?q?rides=20off=20Gas=20CPSU=20to=20their=20SAP=20codes=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/property_overlays/main_heating_system_overlay.py | 7 +++++++ domain/epc/property_overrides/main_heating_system_type.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 7d34546ce..62be5488a 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -175,6 +175,13 @@ _MAIN_HEATING_CODES: dict[str, int] = { "Direct-acting electric": 191, "Electric room heaters": 691, "Solid fuel room heater, closed": 633, + # Solid-fuel room heaters off the Gas CPSU dumping ground (ADR-0045). SAP + # Table 4a: 631 open fire, 632 open fire + back boiler, 634 closed + boiler. + # Companions (house-coal fuel 33, room-heater control 2601, category 10) drag + # from the code — 631/632/634 are already in the 631-636 solid-fuel set. + "Solid fuel room heater, open fire": 631, + "Solid fuel room heater, open fire with back boiler": 632, + "Solid fuel room heater, closed with boiler": 634, # Default air-source heat pump — SAP Table 4a 211 ("flow temp in other # cases", default SPF 2.30). Modellable without a PCDB index; an actual # product index would refine it (ADR-0041). diff --git a/domain/epc/property_overrides/main_heating_system_type.py b/domain/epc/property_overrides/main_heating_system_type.py index 3d74b93c0..2ce796bc8 100644 --- a/domain/epc/property_overrides/main_heating_system_type.py +++ b/domain/epc/property_overrides/main_heating_system_type.py @@ -29,6 +29,13 @@ class MainHeatingSystemType(Enum): DIRECT_ELECTRIC = "Direct-acting electric" ELECTRIC_ROOM_HEATERS = "Electric room heaters" SOLID_FUEL_ROOM_HEATER_CLOSED = "Solid fuel room heater, closed" + SOLID_FUEL_ROOM_HEATER_OPEN_FIRE = "Solid fuel room heater, open fire" + SOLID_FUEL_ROOM_HEATER_OPEN_FIRE_BACK_BOILER = ( + "Solid fuel room heater, open fire with back boiler" + ) + SOLID_FUEL_ROOM_HEATER_CLOSED_WITH_BOILER = ( + "Solid fuel room heater, closed with boiler" + ) AIR_SOURCE_HEAT_PUMP = "Air source heat pump" COMMUNITY_BOILERS = "Community heating, boilers" COMMUNITY_CHP_AND_BOILERS = "Community heating, CHP and boilers" From e83c420f3bb6835de983fe9c659bad6308ea9b56 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 15:42:25 +0000 Subject: [PATCH 03/19] =?UTF-8?q?Resolve=20an=20electric-boiler=20override?= =?UTF-8?q?=20to=20SAP=20191=20dragging=20electricity=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../domain/epc/test_main_heating_system_overlay.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 4421fefe1..af8af3bce 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -101,6 +101,19 @@ def test_solid_fuel_room_heaters_decode_off_gas_cpsu( assert simulation.heating.sap_main_heating_code == code +def test_electric_boiler_decodes_to_191_and_drags_electricity() -> None: + # An "A rated NA" boiler (all-electric per its main_fuel override) was funnelled + # into Gas CPSU (120, mains gas). It is a direct-acting electric boiler (SAP 191) + # and unambiguously electric, so the archetype drags electricity (29) — never + # leaving a mains-gas fuel to bleed through (ADR-0045). + simulation = main_heating_overlay_for("Electric boiler", 0) + + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 191 + assert simulation.heating.main_fuel_type == 29 + + @pytest.mark.parametrize( ("main_heating_value", "code"), [ From 098edf65920cd32ae697ef712038bd7b6188f2e2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 15:44:07 +0000 Subject: [PATCH 04/19] =?UTF-8?q?Resolve=20an=20electric-boiler=20override?= =?UTF-8?q?=20to=20SAP=20191=20dragging=20electricity=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/property_overlays/main_heating_system_overlay.py | 9 +++++++++ .../epc/property_overrides/main_heating_system_type.py | 1 + 2 files changed, 10 insertions(+) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 62be5488a..e15aeeb3c 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -112,6 +112,10 @@ _CATEGORY_10_ROOM_HEATER_CODES = ( _ELECTRICITY_FUEL = 29 _HOUSE_COAL_FUEL = 33 _ELECTRIC_ROOM_HEATER_CODES = frozenset(range(691, 702)) +# Direct-acting electric boiler (SAP Table 4a 191) — unambiguously electric, so it +# drags electricity like the electric room heaters (ADR-0045, closing the prior +# no-fuel gap on 191). +_ELECTRIC_BOILER_CODE = 191 # Heat pumps (SAP Table 4a 211-224 wet, 521-527 warm-air) are category 4 and # unambiguously electric (natural fuel 29). Modellable on the default code's SPF @@ -173,6 +177,10 @@ _MAIN_HEATING_CODES: dict[str, int] = { # generic manual-storage default (ADR-0044). "Electric storage heaters, high heat retention": 409, "Direct-acting electric": 191, + # A-rated electric boiler off the Gas CPSU dumping ground — SAP Table 4a 191 + # (direct-acting electric boiler). Unambiguously electric, so it drags + # electricity below (ADR-0045). + "Electric boiler": 191, "Electric room heaters": 691, "Solid fuel room heater, closed": 633, # Solid-fuel room heaters off the Gas CPSU dumping ground (ADR-0045). SAP @@ -252,6 +260,7 @@ def _natural_fuel_for(code: int) -> Optional[int]: code in _ELECTRIC_ROOM_HEATER_CODES or code in _HEAT_PUMP_CODES or code == _HHR_STORAGE_CODE + or code == _ELECTRIC_BOILER_CODE ): return _ELECTRICITY_FUEL if code in _SOLID_FUEL_ROOM_HEATER_CODES: diff --git a/domain/epc/property_overrides/main_heating_system_type.py b/domain/epc/property_overrides/main_heating_system_type.py index 2ce796bc8..aaa917df1 100644 --- a/domain/epc/property_overrides/main_heating_system_type.py +++ b/domain/epc/property_overrides/main_heating_system_type.py @@ -27,6 +27,7 @@ class MainHeatingSystemType(Enum): "Electric storage heaters, high heat retention" ) DIRECT_ELECTRIC = "Direct-acting electric" + ELECTRIC_BOILER = "Electric boiler" ELECTRIC_ROOM_HEATERS = "Electric room heaters" SOLID_FUEL_ROOM_HEATER_CLOSED = "Solid fuel room heater, closed" SOLID_FUEL_ROOM_HEATER_OPEN_FIRE = "Solid fuel room heater, open fire" From 2b5d38a7f923bf1c72c62e2eba04e07fe75309af Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 15:45:12 +0000 Subject: [PATCH 05/19] =?UTF-8?q?Deterministically=20guard=20the=20Gas=20C?= =?UTF-8?q?PSU=20solid-fuel=20and=20electric-boiler=20descriptions=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/domain/epc/test_main_heating_guard.py | 38 +++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/domain/epc/test_main_heating_guard.py b/tests/domain/epc/test_main_heating_guard.py index 452474d61..a32f3710f 100644 --- a/tests/domain/epc/test_main_heating_guard.py +++ b/tests/domain/epc/test_main_heating_guard.py @@ -19,6 +19,38 @@ def test_guard_resolves_high_heat_retention_storage() -> None: assert result is MainHeatingSystemType.ELECTRIC_STORAGE_HIGH_HEAT_RETENTION +@pytest.mark.parametrize( + ("description", "expected"), + [ + # The Gas CPSU dumping ground (ADR-0045): solid-fuel room heaters and + # electric ("NA" type) boilers the LLM sent to Gas CPSU (120, mains gas). + ( + "Solid fuel room heaters: Open fire in grate", + MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_OPEN_FIRE, + ), + ( + "Solid fuel room heaters: Open fire with back boiler (no radiators)", + MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_OPEN_FIRE_BACK_BOILER, + ), + ( + "Solid fuel room heaters: Closed room heater with boiler (no radiators)", + MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_CLOSED_WITH_BOILER, + ), + # "NA" is the boiler-type value meaning "not a gas combi/regular/CPSU" — + # i.e. an electric boiler. + ("Boiler: A rated NA", MainHeatingSystemType.ELECTRIC_BOILER), + ], +) +def test_guard_resolves_the_gas_cpsu_dumping_ground( + description: str, expected: MainHeatingSystemType +) -> None: + # Act + result = main_heating_guard(description) + + # Assert + assert result is expected + + @pytest.mark.parametrize( "description", [ @@ -27,6 +59,12 @@ def test_guard_resolves_high_heat_retention_storage() -> None: "Electric Storage Systems: Old (large volume) storage heaters", "Electric Storage Systems: Modern (slimline) storage heaters", "Electric Storage Systems: Fan storage heaters", + # Genuinely-gas boilers the LLM already classifies correctly (not "NA"). + "Boiler: A rated Combi", + "Boiler: C rated Regular Boiler", + # A plain closed room heater (no boiler) is the existing 633 archetype — + # the LLM keeps it; only "…with boiler" (634) is guarded. + "Solid fuel room heaters: Closed room heater (no boiler)", # Unrelated / varied phrasings are the LLM's job. "Gas boiler", "", From f6f4c3717dff43560463631dcff4e874ad41ea95 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 15:46:34 +0000 Subject: [PATCH 06/19] =?UTF-8?q?Deterministically=20guard=20the=20Gas=20C?= =?UTF-8?q?PSU=20solid-fuel=20and=20electric-boiler=20descriptions=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../property_overrides/main_heating_guard.py | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/domain/epc/property_overrides/main_heating_guard.py b/domain/epc/property_overrides/main_heating_guard.py index 9eb50548b..8347651bf 100644 --- a/domain/epc/property_overrides/main_heating_guard.py +++ b/domain/epc/property_overrides/main_heating_guard.py @@ -8,17 +8,34 @@ from domain.epc.property_overrides.main_heating_system_type import ( def main_heating_guard(description: str) -> Optional[MainHeatingSystemType]: - """Deterministically resolve a High Heat Retention Storage Heater (HHRSH) - description, which the LLM funnelled into ``"Electric storage heaters, old"`` - (SAP 401) for want of an HHRSH archetype (#1376 / ADR-0044). + """Deterministically resolve the structured main-heating descriptions the LLM + funnelled into a garbage-drawer archetype for want of a correct target (#1376). - HHRSH is a distinct RdSAP system (SAP Table 4a 409) with its own intrinsic - charge control (2404) — the *best* modern storage, not the *worst* old large- - volume type. This guard claims only the structured HHRSH phrasing and returns - ``None`` for every other description (the remaining storage subtypes the LLM - already classifies correctly, and varied phrasings), so they still reach the - LLM classifier via ``GuardedColumnClassifier``. + Two dumping grounds are rescued: + + - **HHRSH** (ADR-0044): ``"…High heat retention storage heaters"`` went to + ``"Electric storage heaters, old"`` (SAP 401) — the *worst* old type. HHRSH is + SAP Table 4a 409 with its own intrinsic charge control (2404). + - **Gas CPSU** (ADR-0045): the mains-gas CPSU garbage drawer also held + solid-fuel room heaters (open fire 631 / + back boiler 632 / closed + boiler + 634) and electric ``"NA"``-type boilers (SAP 191). "NA" is the boiler-type + value meaning "not a gas combi/regular/CPSU", i.e. an electric boiler. + + Returns ``None`` for descriptions the LLM already classifies correctly (genuine + gas boilers, other storage subtypes, a plain closed room heater) and for varied + phrasings, so they still reach the LLM via ``GuardedColumnClassifier``. Order + matters: the back-boiler open fire is tested before the bare open fire it + contains. """ - if "high heat retention" in description.lower(): + text = description.lower() + if "high heat retention" in text: return MainHeatingSystemType.ELECTRIC_STORAGE_HIGH_HEAT_RETENTION + if "rated na" in text: + return MainHeatingSystemType.ELECTRIC_BOILER + if "open fire" in text and "back boiler" in text: + return MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_OPEN_FIRE_BACK_BOILER + if "open fire" in text: + return MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_OPEN_FIRE + if "closed room heater" in text and "with boiler" in text: + return MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_CLOSED_WITH_BOILER return None From de7bfc5dfef858199ce58a5a61bbedcbdf750f73 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 15:56:33 +0000 Subject: [PATCH 07/19] =?UTF-8?q?Reclassify=20Gas=20CPSU=20dumping-ground?= =?UTF-8?q?=20overrides=20via=20the=20generalized=20main-heating=20reclass?= =?UTF-8?q?ify=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ..._storage.py => reclassify_main_heating.py} | 46 ++++---- .../scripts/test_reclassify_hhrsh_storage.py | 70 ------------ tests/scripts/test_reclassify_main_heating.py | 101 ++++++++++++++++++ 3 files changed, 126 insertions(+), 91 deletions(-) rename scripts/{reclassify_hhrsh_storage.py => reclassify_main_heating.py} (71%) delete mode 100644 tests/scripts/test_reclassify_hhrsh_storage.py create mode 100644 tests/scripts/test_reclassify_main_heating.py diff --git a/scripts/reclassify_hhrsh_storage.py b/scripts/reclassify_main_heating.py similarity index 71% rename from scripts/reclassify_hhrsh_storage.py rename to scripts/reclassify_main_heating.py index 952b205b7..a51743aa4 100644 --- a/scripts/reclassify_hhrsh_storage.py +++ b/scripts/reclassify_main_heating.py @@ -1,9 +1,11 @@ -"""One-time re-classification of HHRSH overrides funnelled into old storage. +"""One-time re-classification of main-heating overrides funnelled into a garbage +drawer for want of a correct archetype (#1376). -#1376 / ADR-0044: with no High Heat Retention archetype, the LLM classified -"Electric Storage Systems: High heat retention storage heaters" to -``"Electric storage heaters, old"`` (SAP 401) — scoring the *best* modern storage -as the *worst* old large-volume type. An audit found 111 such rows. +Covers both dumping grounds the ``main_heating_guard`` now rescues: +- **HHRSH** (ADR-0044): "High heat retention storage heaters" → "Electric storage + heaters, old" (SAP 401), the worst old type — 111 rows. +- **Gas CPSU** (ADR-0045): solid-fuel room heaters (open fire / + back boiler / + closed + boiler) and electric "NA"-type boilers → "Gas CPSU" (120, mains gas). The live classifier now applies ``main_heating_guard`` deterministically (so new intakes are correct); this fixes the rows written before it. The **same guard** @@ -11,9 +13,9 @@ decides the correction here, so the backfill and the live path cannot drift. Updates the TEXT ``property_overrides.override_value`` (what the modelling reads — the immediate fix) unconditionally. The ``landlord_main_heating_system_overrides`` -``value`` classifier cache is a ``main_heating_system`` **pgEnum**; the new HHRSH -archetype is an FE-owned value, so its cache write is **deferred** until the -Drizzle migration adds it (the Class-A/B pattern). +``value`` classifier cache is a ``main_heating_system`` **pgEnum**; the new +archetypes are FE-owned values, so their cache writes are **deferred** until the +Drizzle migration adds them (the Class-A/B pattern). DRY-RUN BY DEFAULT: prints the counts it would change and writes nothing. Pass ``--apply`` to execute inside a transaction. Idempotent — only rows whose stored @@ -31,13 +33,14 @@ from domain.epc.property_overrides.main_heating_guard import main_heating_guard from scripts.e2e_common import build_engine, load_env -def hhrsh_storage_corrections( +def main_heating_corrections( stored: Iterable[tuple[str, str]], ) -> dict[str, str]: - """``(description, stored override_value)`` → the HHRSH archetype value, for the - descriptions the main-heating guard resolves to HHRSH whose stored value is not - already it. Descriptions the guard defers and rows already on the target are - omitted, so re-running against corrected data is a no-op.""" + """``(description, stored override_value)`` → the faithful archetype value, for + the descriptions the main-heating guard resolves (HHRSH, solid-fuel room + heaters, electric boiler) whose stored value differs. Descriptions the guard + defers and rows already on the target are omitted, so re-running against + corrected data is a no-op.""" corrections: dict[str, str] = {} for description, value in stored: member = main_heating_guard(description) @@ -86,15 +89,15 @@ _ENUM_VALUES = text( def reclassify(conn: Connection, *, apply: bool) -> tuple[int, set[str]]: - """Re-map old-storage HHRSH overrides onto the high-heat-retention archetype. - Returns the number of ``property_overrides`` rows found and the set of target - values the live ``main_heating_system`` pgEnum does not yet carry (cache- - deferred until the FE migration).""" + """Re-map main-heating overrides off their garbage-drawer archetypes onto the + faithful ones the guard resolves. Returns the number of ``property_overrides`` + rows found and the set of target values the live ``main_heating_system`` pgEnum + does not yet carry (cache-deferred until the FE migration).""" stored = [(r.description, r.value) for r in conn.execute(_DISTINCT)] enum_values = {r[0] for r in conn.execute(_ENUM_VALUES)} total = 0 deferred: set[str] = set() - for description, new_value in hhrsh_storage_corrections(stored).items(): + for description, new_value in main_heating_corrections(stored).items(): params = {"description": description, "new_value": new_value} total += conn.execute(_OVERRIDES_COUNT, params).scalar() or 0 in_enum = new_value in enum_values @@ -124,9 +127,10 @@ def main() -> None: verb = "re-classified" if args.apply else "would re-classify" print( - f"{verb} {total} high-heat-retention storage override row(s) off " - "'Electric storage heaters, old' onto the HHRSH archetype (SAP 409) — " - "property_overrides / TEXT, what the modelling reads." + f"{verb} {total} main-heating override row(s) off their garbage-drawer " + "archetypes (old storage / Gas CPSU) onto faithful HHRSH / solid-fuel / " + "electric-boiler archetypes — property_overrides / TEXT, what the " + "modelling reads." ) if deferred: print( diff --git a/tests/scripts/test_reclassify_hhrsh_storage.py b/tests/scripts/test_reclassify_hhrsh_storage.py deleted file mode 100644 index c3b025c53..000000000 --- a/tests/scripts/test_reclassify_hhrsh_storage.py +++ /dev/null @@ -1,70 +0,0 @@ -"""The HHRSH reclassify maps the "old storage" dumping ground onto the high-heat- -retention archetype, leaves other storage subtypes alone, and is idempotent -(#1376 / ADR-0044).""" - -from __future__ import annotations - -from domain.epc.property_overlays.main_heating_system_overlay import ( - main_heating_overlay_for, -) -from domain.epc.property_overrides.main_heating_guard import main_heating_guard -from domain.epc.property_overrides.main_heating_system_type import ( - MainHeatingSystemType, -) -from scripts.reclassify_hhrsh_storage import hhrsh_storage_corrections - - -def test_hhrsh_rows_are_remapped_off_old_storage() -> None: - # Arrange — HHRSH funnelled into old storage (the bug), plus a genuinely-old - # storage row and a gas row that must be left untouched. - stored = [ - ( - "electric storage systems: high heat retention storage heaters", - "Electric storage heaters, old", - ), - ( - "electric storage systems: old (large volume) storage heaters", - "Electric storage heaters, old", - ), - ("gas boiler", "Gas boiler, regular"), - ] - - # Act - corrections = hhrsh_storage_corrections(stored) - - # Assert — only the HHRSH row moves, to its own archetype. - assert corrections == { - "electric storage systems: high heat retention storage heaters": ( - "Electric storage heaters, high heat retention" - ) - } - - -def test_every_guard_target_round_trips_to_a_resolvable_archetype() -> None: - # The HHRSH archetype the guard emits must decode to a real SAP heating code — - # a guard/overlay drift can't silently write an unmodellable value (ADR-0044). - member = main_heating_guard( - "Electric Storage Systems: High heat retention storage heaters" - ) - - assert member is not None - assert member in MainHeatingSystemType - simulation = main_heating_overlay_for(member.value, 0) - assert simulation is not None - assert simulation.heating is not None - assert simulation.heating.sap_main_heating_code is not None - - -def test_already_corrected_rows_need_no_change() -> None: - # Act / Assert — idempotent. - assert ( - hhrsh_storage_corrections( - [ - ( - "electric storage systems: high heat retention storage heaters", - "Electric storage heaters, high heat retention", - ) - ] - ) - == {} - ) diff --git a/tests/scripts/test_reclassify_main_heating.py b/tests/scripts/test_reclassify_main_heating.py new file mode 100644 index 000000000..12ce4254d --- /dev/null +++ b/tests/scripts/test_reclassify_main_heating.py @@ -0,0 +1,101 @@ +"""The main-heating reclassify maps the garbage-drawer archetypes (old storage, +Gas CPSU) onto the faithful ones the guard resolves, leaves correctly-classified +rows alone, and is idempotent (#1376 / ADR-0044, ADR-0045).""" + +from __future__ import annotations + +from domain.epc.property_overlays.main_heating_system_overlay import ( + main_heating_overlay_for, +) +from domain.epc.property_overrides.main_heating_guard import main_heating_guard +from domain.epc.property_overrides.main_heating_system_type import ( + MainHeatingSystemType, +) +from scripts.reclassify_main_heating import main_heating_corrections + + +def test_garbage_drawer_rows_are_remapped_to_faithful_archetypes() -> None: + # Arrange — HHRSH funnelled into old storage, and the Gas CPSU dumping ground + # (solid-fuel room heaters + electric "NA" boiler); plus correctly-classified + # rows (genuine old storage, a gas combi) that must be left untouched. + stored = [ + ( + "electric storage systems: high heat retention storage heaters", + "Electric storage heaters, old", + ), + ("solid fuel room heaters: open fire in grate", "Gas CPSU"), + ( + "solid fuel room heaters: open fire with back boiler (no radiators)", + "Gas CPSU", + ), + ( + "solid fuel room heaters: closed room heater with boiler (no radiators)", + "Gas CPSU", + ), + ("boiler: a rated na", "Gas CPSU"), + # Correctly classified — the guard does not claim these. + ( + "electric storage systems: old (large volume) storage heaters", + "Electric storage heaters, old", + ), + ("boiler: a rated combi", "Gas boiler, combi"), + ] + + # Act + corrections = main_heating_corrections(stored) + + # Assert — every mis-classified row moves to its faithful archetype. + assert corrections == { + "electric storage systems: high heat retention storage heaters": ( + "Electric storage heaters, high heat retention" + ), + "solid fuel room heaters: open fire in grate": ( + "Solid fuel room heater, open fire" + ), + "solid fuel room heaters: open fire with back boiler (no radiators)": ( + "Solid fuel room heater, open fire with back boiler" + ), + "solid fuel room heaters: closed room heater with boiler (no radiators)": ( + "Solid fuel room heater, closed with boiler" + ), + "boiler: a rated na": "Electric boiler", + } + + +def test_every_guard_target_round_trips_to_a_resolvable_archetype() -> None: + # Every archetype the guard emits for the rescued descriptions must decode to a + # real SAP heating code — a guard/overlay drift can't silently write an + # unmodellable value (ADR-0044, ADR-0045). + descriptions = [ + "Electric Storage Systems: High heat retention storage heaters", + "Solid fuel room heaters: Open fire in grate", + "Solid fuel room heaters: Open fire with back boiler (no radiators)", + "Solid fuel room heaters: Closed room heater with boiler (no radiators)", + "Boiler: A rated NA", + ] + + for description in descriptions: + member = main_heating_guard(description) + + assert member is not None, description + assert member in MainHeatingSystemType, description + simulation = main_heating_overlay_for(member.value, 0) + assert simulation is not None, description + assert simulation.heating is not None, description + assert simulation.heating.sap_main_heating_code is not None, description + + +def test_already_corrected_rows_need_no_change() -> None: + # Act / Assert — idempotent. + assert ( + main_heating_corrections( + [ + ( + "electric storage systems: high heat retention storage heaters", + "Electric storage heaters, high heat retention", + ), + ("boiler: a rated na", "Electric boiler"), + ] + ) + == {} + ) From 94a5e79067b8603b5c799d0df964198ef04c6a57 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 16:08:42 +0000 Subject: [PATCH 08/19] =?UTF-8?q?Resolve=20an=20electric-CPSU=20override?= =?UTF-8?q?=20to=20SAP=20192=20electric=20on=20an=20off-peak=20meter=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../domain/epc/test_main_heating_system_overlay.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index af8af3bce..4b4558718 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -114,6 +114,19 @@ def test_electric_boiler_decodes_to_191_and_drags_electricity() -> None: assert simulation.heating.main_fuel_type == 29 +def test_electric_cpsu_decodes_to_192_electric_on_an_off_peak_meter() -> None: + # An electric "Boiler: A rated CPSU" was funnelled into mains-gas Gas CPSU + # (120). Electric CPSU is SAP Table 4a 192, electric (29), charging overnight on + # a Dual off-peak meter (§12 Rule 1, 10-hour) — ADR-0045. + simulation = main_heating_overlay_for("Electric CPSU", 0) + + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 192 + assert simulation.heating.main_fuel_type == 29 + assert simulation.heating.meter_type == "Dual" + + @pytest.mark.parametrize( ("main_heating_value", "code"), [ From fedae2ea003c686163b5fc839047a1d6b3f96e2d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 16:09:56 +0000 Subject: [PATCH 09/19] =?UTF-8?q?Resolve=20an=20electric-CPSU=20override?= =?UTF-8?q?=20to=20SAP=20192=20electric=20on=20an=20off-peak=20meter=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main_heating_system_overlay.py | 13 ++++++++----- .../property_overrides/main_heating_system_type.py | 1 + 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index e15aeeb3c..5d7c3b836 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -112,10 +112,10 @@ _CATEGORY_10_ROOM_HEATER_CODES = ( _ELECTRICITY_FUEL = 29 _HOUSE_COAL_FUEL = 33 _ELECTRIC_ROOM_HEATER_CODES = frozenset(range(691, 702)) -# Direct-acting electric boiler (SAP Table 4a 191) — unambiguously electric, so it -# drags electricity like the electric room heaters (ADR-0045, closing the prior -# no-fuel gap on 191). -_ELECTRIC_BOILER_CODE = 191 +# Electric boilers — SAP Table 4a 191 (direct-acting) and 192 (electric CPSU) — +# unambiguously electric, so they drag electricity like the electric room heaters +# (ADR-0045, closing the prior no-fuel gap on 191/192). +_ELECTRIC_BOILER_CODES = frozenset({191, 192}) # Heat pumps (SAP Table 4a 211-224 wet, 521-527 warm-air) are category 4 and # unambiguously electric (natural fuel 29). Modellable on the default code's SPF @@ -181,6 +181,9 @@ _MAIN_HEATING_CODES: dict[str, int] = { # (direct-acting electric boiler). Unambiguously electric, so it drags # electricity below (ADR-0045). "Electric boiler": 191, + # Electric CPSU — SAP Table 4a 192. Electric (drags 29), on an off-peak Dual + # meter (already in _ASSUMED_DUAL_METER_CODES; §12 Rule 1 10-hour) — ADR-0045. + "Electric CPSU": 192, "Electric room heaters": 691, "Solid fuel room heater, closed": 633, # Solid-fuel room heaters off the Gas CPSU dumping ground (ADR-0045). SAP @@ -260,7 +263,7 @@ def _natural_fuel_for(code: int) -> Optional[int]: code in _ELECTRIC_ROOM_HEATER_CODES or code in _HEAT_PUMP_CODES or code == _HHR_STORAGE_CODE - or code == _ELECTRIC_BOILER_CODE + or code in _ELECTRIC_BOILER_CODES ): return _ELECTRICITY_FUEL if code in _SOLID_FUEL_ROOM_HEATER_CODES: diff --git a/domain/epc/property_overrides/main_heating_system_type.py b/domain/epc/property_overrides/main_heating_system_type.py index aaa917df1..866767f4f 100644 --- a/domain/epc/property_overrides/main_heating_system_type.py +++ b/domain/epc/property_overrides/main_heating_system_type.py @@ -28,6 +28,7 @@ class MainHeatingSystemType(Enum): ) DIRECT_ELECTRIC = "Direct-acting electric" ELECTRIC_BOILER = "Electric boiler" + ELECTRIC_CPSU = "Electric CPSU" ELECTRIC_ROOM_HEATERS = "Electric room heaters" SOLID_FUEL_ROOM_HEATER_CLOSED = "Solid fuel room heater, closed" SOLID_FUEL_ROOM_HEATER_OPEN_FIRE = "Solid fuel room heater, open fire" From 8b0176976de9970c797073693e6c0035a04188a2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 16:12:00 +0000 Subject: [PATCH 10/19] =?UTF-8?q?Resolve=20electric=20CPSU=20from=20the=20?= =?UTF-8?q?property's=20fuel,=20keeping=20gas=20CPSU=20on=20120=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/reclassify_main_heating.py | 8 ++++++++ tests/scripts/test_reclassify_main_heating.py | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/scripts/reclassify_main_heating.py b/scripts/reclassify_main_heating.py index a51743aa4..c958458b4 100644 --- a/scripts/reclassify_main_heating.py +++ b/scripts/reclassify_main_heating.py @@ -26,13 +26,21 @@ from __future__ import annotations import argparse from collections.abc import Iterable +from typing import Optional from sqlalchemy import Connection, text from domain.epc.property_overrides.main_heating_guard import main_heating_guard +from domain.epc.property_overrides.main_heating_system_type import ( + MainHeatingSystemType, +) from scripts.e2e_common import build_engine, load_env +def electric_cpsu_for(description: str, main_fuel: Optional[str]) -> Optional[str]: + raise NotImplementedError + + def main_heating_corrections( stored: Iterable[tuple[str, str]], ) -> dict[str, str]: diff --git a/tests/scripts/test_reclassify_main_heating.py b/tests/scripts/test_reclassify_main_heating.py index 12ce4254d..8c172d0c5 100644 --- a/tests/scripts/test_reclassify_main_heating.py +++ b/tests/scripts/test_reclassify_main_heating.py @@ -11,7 +11,10 @@ from domain.epc.property_overrides.main_heating_guard import main_heating_guard from domain.epc.property_overrides.main_heating_system_type import ( MainHeatingSystemType, ) -from scripts.reclassify_main_heating import main_heating_corrections +from scripts.reclassify_main_heating import ( + electric_cpsu_for, + main_heating_corrections, +) def test_garbage_drawer_rows_are_remapped_to_faithful_archetypes() -> None: @@ -85,6 +88,18 @@ def test_every_guard_target_round_trips_to_a_resolvable_archetype() -> None: assert simulation.heating.sap_main_heating_code is not None, description +def test_electric_cpsu_is_resolved_from_the_fuel_not_the_description() -> None: + # "Boiler: A/C rated CPSU" is identical bar the band — the electric/gas split is + # fuel-determined (the pure-string guard can't see it), so the reclassify keys + # on the property's main_fuel override: electricity → Electric CPSU (192); + # mains gas / unknown → keep Gas CPSU (120). A non-CPSU boiler is ignored here + # (the guard handles "NA" electric boilers) — ADR-0045. + assert electric_cpsu_for("boiler: a rated cpsu", "electricity") == "Electric CPSU" + assert electric_cpsu_for("boiler: c rated cpsu", "mains gas") is None + assert electric_cpsu_for("boiler: c rated cpsu", None) is None + assert electric_cpsu_for("boiler: a rated na", "electricity") is None + + def test_already_corrected_rows_need_no_change() -> None: # Act / Assert — idempotent. assert ( From 927384e5a9070addf13f0cbd4d7b804bf1fb297c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 16:16:10 +0000 Subject: [PATCH 11/19] =?UTF-8?q?Resolve=20electric=20CPSU=20from=20the=20?= =?UTF-8?q?property's=20fuel,=20keeping=20gas=20CPSU=20on=20120=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/reclassify_main_heating.py | 48 +++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/scripts/reclassify_main_heating.py b/scripts/reclassify_main_heating.py index c958458b4..0d070445e 100644 --- a/scripts/reclassify_main_heating.py +++ b/scripts/reclassify_main_heating.py @@ -38,7 +38,14 @@ from scripts.e2e_common import build_engine, load_env def electric_cpsu_for(description: str, main_fuel: Optional[str]) -> Optional[str]: - raise NotImplementedError + """``"Electric CPSU"`` for a CPSU-boiler override whose property's ``main_fuel`` + override is electricity, else ``None`` (ADR-0045). ``"Boiler: A/C rated CPSU"`` + is identical bar the band — the electric/gas split is *fuel*-determined, so this + keys on the fuel the guard cannot see; a gas / unknown fuel keeps Gas CPSU + (120), and a non-CPSU description is left to the guard.""" + if "cpsu" in description.lower() and main_fuel == "electricity": + return MainHeatingSystemType.ELECTRIC_CPSU.value + return None def main_heating_corrections( @@ -94,6 +101,30 @@ _ENUM_VALUES = text( "SELECT e.enumlabel FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid " "WHERE t.typname = 'main_heating_system'" ) +# CPSU boilers still on Gas CPSU, each with its property's main_fuel override — +# the electric/gas split the description can't carry (ADR-0045). +_CPSU_ROWS = text( + """ + SELECT mh.property_id AS property_id, + lower(mh.original_spreadsheet_description) AS description, + mf.override_value AS main_fuel + FROM property_overrides mh + LEFT JOIN property_overrides mf + ON mf.property_id = mh.property_id AND mf.override_component = 'main_fuel' + WHERE mh.override_component = 'main_heating_system' + AND mh.override_value = 'Gas CPSU' + AND lower(mh.original_spreadsheet_description) LIKE '%cpsu%' + """ +) +_CPSU_PROPERTY_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE property_id = :property_id + AND override_component = 'main_heating_system' + AND override_value = 'Gas CPSU' + """ +) def reclassify(conn: Connection, *, apply: bool) -> tuple[int, set[str]]: @@ -115,6 +146,21 @@ def reclassify(conn: Connection, *, apply: bool) -> tuple[int, set[str]]: conn.execute(_OVERRIDES_UPDATE, params) if in_enum: conn.execute(_CACHE_UPDATE, params) + + # Fuel-aware phase: an electric CPSU (fuel-determined, per-property) — update + # only the TEXT layer. The description-keyed classifier cache cannot represent + # a fuel-dependent value, and "Electric CPSU" is a deferred pgEnum value anyway. + for row in conn.execute(_CPSU_ROWS): + new_value = electric_cpsu_for(row.description, row.main_fuel) + if new_value is None: + continue + total += 1 + deferred.add(new_value) + if apply: + conn.execute( + _CPSU_PROPERTY_UPDATE, + {"property_id": row.property_id, "new_value": new_value}, + ) return total, deferred From e5c112a2d3ef2610476c7bfd0d5ec72862f78912 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 16:17:22 +0000 Subject: [PATCH 12/19] =?UTF-8?q?Document=20the=20fuel-aware=20electric-CP?= =?UTF-8?q?SU=20resolution=20in=20ADR-0045=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...lid-fuel-and-electric-boiler-archetypes.md | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/docs/adr/0045-gas-cpsu-dumping-ground-splits-into-solid-fuel-and-electric-boiler-archetypes.md b/docs/adr/0045-gas-cpsu-dumping-ground-splits-into-solid-fuel-and-electric-boiler-archetypes.md index 49117b477..da1bdad85 100644 --- a/docs/adr/0045-gas-cpsu-dumping-ground-splits-into-solid-fuel-and-electric-boiler-archetypes.md +++ b/docs/adr/0045-gas-cpsu-dumping-ground-splits-into-solid-fuel-and-electric-boiler-archetypes.md @@ -49,28 +49,41 @@ Add the missing archetypes and reclassify the bucket. No calculator change. `"Solid fuel room heater, open fire with back boiler"` → 632, `"Solid fuel room heater, closed with boiler"` → 634. Companions drag from the code (already-covered 631–636 set) — no overlay-logic change. -2. **`"Electric boiler"` → SAP 191**, dragging **electricity (29)** via - `_natural_fuel_for` — an electric boiler is unambiguously electric (ADR-0041 - "direct-acting/storage → electricity"), and this also closes the pre-existing - gap where 191 dragged no fuel. Meter stays Single, control None (191 is neither - a storage nor a wet-boiler-control system). -3. **Deterministic `main_heating_guard` + reclassify**, TEXT-first / - pgEnum-deferred. The guard resolves the structured phrasings by description - alone: `"…open fire … back boiler"` → 632 (tested before the bare `"open fire"` - → 631), `"…closed … with boiler"` → 634, and `"Boiler: NA"` → - `"Electric boiler"` (the NA type is the electric signal). `scripts/ - reclassify_gas_cpsu.py` reuses the guard as its decision function and defers the - four new `main_heating_system` pgEnum-cache values. +2. **`"Electric boiler"` → SAP 191** and **`"Electric CPSU"` → SAP 192**, both + dragging **electricity (29)** via `_natural_fuel_for` — electric boilers are + unambiguously electric (ADR-0041 "direct-acting/storage → electricity"), also + closing the pre-existing gap where 191/192 dragged no fuel. 191 stays on a + Single meter; 192 (electric CPSU) is already in `_ASSUMED_DUAL_METER_CODES`, so + it drags an off-peak Dual meter (§12 Rule 1, 10-hour). +3. **Deterministic `main_heating_guard` for the description-determined cases + + reclassify**, TEXT-first / pgEnum-deferred. The guard resolves the structured + phrasings by description alone: `"…open fire … back boiler"` → 632 (tested + before the bare `"open fire"` → 631), `"…closed … with boiler"` → 634, and + `"Boiler: NA"` → `"Electric boiler"` (the NA type is the electric + signal). `scripts/reclassify_main_heating.py` reuses the guard as its decision + function. +4. **Electric CPSU is fuel-determined, not description-determined.** `"Boiler: A/C + rated CPSU"` is identical bar the efficiency band, so the electric/gas split + cannot be read from the description — the guard cannot see it. The reclassify + therefore resolves it with a **fuel-aware, per-property** step (`electric_cpsu_ + for(description, main_fuel)`): a CPSU boiler whose property carries a + `main_fuel = electricity` override → `"Electric CPSU"` (192); a gas / unknown + fuel stays Gas CPSU (120). Only the **TEXT** layer is updated for it — the + description-keyed classifier cache cannot represent a fuel-dependent value. The + **live** guard stays fuel-blind, so a new electric-CPSU intake (rare — 1 row in + the corpus) falls to the LLM rather than being silently mis-guarded. ## Consequences -- ~95 rows leave the Gas CPSU garbage drawer for faithful archetypes: 37 solid - fuel scored as solid fuel (not mains gas), 58 electric boilers scored as electric - (not a mains-gas CPSU). Correct Rebaselining (ADR-0039). -- **FE-owned pgEnum additions (Dan):** four new `main_heating_system` values — +- 96 rows leave the Gas CPSU garbage drawer for faithful archetypes: 37 solid + fuel scored as solid fuel (not mains gas), 58 electric boilers + 1 electric CPSU + scored as electric (not a mains-gas CPSU); the 1 genuine gas CPSU stays on 120. + Correct Rebaselining (ADR-0039). +- **FE-owned pgEnum additions (Dan):** five new `main_heating_system` values — `"Solid fuel room heater, open fire"`, `"Solid fuel room heater, open fire with back boiler"`, `"Solid fuel room heater, closed with boiler"`, `"Electric - boiler"`. TEXT read path fixed on merge; cache writes defer to the migration. + boiler"`, `"Electric CPSU"`. TEXT read path fixed on merge; cache writes defer to + the migration. - Extends [ADR-0041](0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md) (complete taxonomy — retiring the Gas CPSU garbage drawer it named) and [ADR-0035](0035-coherent-heating-system-synthesis.md) (companions drag from the @@ -78,12 +91,6 @@ Add the missing archetypes and reclassify the bucket. No calculator change. ### Out of scope -- **Electric CPSU (SAP 192), the 1 `"Boiler: A rated CPSU"` row.** Its description - is identical to the genuine gas `"Boiler: C rated CPSU"` bar the efficiency - band — the electric/gas split is **fuel-determined**, which a pure-string guard - cannot see. Rather than break the guard = reclassify-decision-function symmetry - for one row, this is deferred to a small fuel-aware follow-up; the gas CPSU row - stays correctly on 120. - **Whether "NA" ever denotes a non-electric boiler.** It is 100% electric in the corpus and the guard treats it as electric; a future non-electric "NA" would be a re-open signal, not a silent mis-map (the fuel override would disagree). From ef34f76116dbbc71d406cf767029e961322021e3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 16:34:41 +0000 Subject: [PATCH 13/19] =?UTF-8?q?Resolve=20electric-underfloor=20overrides?= =?UTF-8?q?=20to=20their=20own=20SAP=20codes=20dragging=20electricity=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...es-meter-deferred-when-tariff-ambiguous.md | 92 +++++++++++++++++++ .../epc/test_main_heating_system_overlay.py | 23 +++++ 2 files changed, 115 insertions(+) create mode 100644 docs/adr/0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md diff --git a/docs/adr/0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md b/docs/adr/0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md new file mode 100644 index 000000000..f6550c123 --- /dev/null +++ b/docs/adr/0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md @@ -0,0 +1,92 @@ +# Electric underfloor is scored by its own SAP codes; the meter defers to the cert when the tariff is ambiguous + +## Status + +accepted + +## Context + +Electric-underfloor `main_heating_system` overrides had no archetype, so the LLM +classifier funnelled them into the nearest wrong bucket (#1376): + +| description | rows | mis-mapped to | +| --- | --- | --- | +| Electric Underfloor Heating: In concrete slab (off-peak only) | 15 | Direct-acting electric (191), **Single** meter | +| Electric Underfloor Heating: In screed above insulation (standard or off peak) | 63 | Direct-acting electric (191), **Single** meter | +| Electric Underfloor Heating: Integrated (storage+direct-acting) (off peak) | 12 | Electric storage heaters, old (401) | + +The **meter is the crux**. A Landlord heating override drags an assumed meter from +its SAP code (ADR-0035), and the `keep_existing_off_peak_meter` mitigation +([overlay_applicator.py](../../domain/modelling/scoring/overlay_applicator.py)) +protects a cert's lodged off-peak meter — but *only when the overlay's own desired +meter is off-peak*. Direct-acting (191) is a **Single**-meter system, so the +mitigation never fires and the overlay **forces Single**, overwriting the cert's +meter. For off-peak underfloor that bills overnight heat at the peak rate and +craters SAP — the exact Class-A meter defect. + +SAP Table 4a has **dedicated electric-underfloor codes** (labels from +`cert_to_inputs.py`): **421** in concrete slab (off-peak only), **422** integrated +(storage+direct-acting), **424** in screed above insulation (423 integrated-low- +off-peak and 425 timber-floor are not in the corpus). Per SAP 10.2 §12, **421/422 +are off-peak (Rule 2, 7-hour → Dual)**; **424 is not off-peak-implying (Single)**. + +A lodged-meter audit is decisive for the screed case: **51 of 63 (81%)** of the +`"…in screed (standard or off peak)"` dwellings lodge an **off-peak** meter (24-hour +dual or 7/10-hour), the rest single. The description itself says "standard **or** +off peak" — so the archetype genuinely cannot know the tariff; the cert does. +Forcing 424/Single would downgrade 81%; forcing Dual would over-credit the ~19% on +single. + +## Decision + +Add three electric-underfloor archetypes mapping to their own SAP codes, and treat +the meter per what the description actually asserts. + +1. **New archetypes** in `_MAIN_HEATING_CODES`: + `"Electric underfloor, in concrete slab (off-peak)"` → **421**, + `"Electric underfloor, integrated storage and direct-acting"` → **422**, + `"Electric underfloor, in screed above insulation"` → **424**. +2. **Electricity fuel (29)** drags for 421/422/424 via `_natural_fuel_for` — electric + underfloor is unambiguously electric (ADR-0041), closing the prior no-fuel gap. +3. **421 and 422 assert an off-peak Dual meter.** Their descriptions positively + claim off-peak ("off-peak only" / "off peak"), and both are already in + `_ASSUMED_DUAL_METER_CODES`; the `keep_existing_off_peak_meter` mitigation keeps + a more-specific cert off-peak meter and applies Dual to a single-rate cert. +4. **424 (screed) defers the meter to the cert.** Because "standard or off peak" is + tariff-agnostic and 81% lodge off-peak, `_meter_for` returns **`None`** for 424 + (a new meter-agnostic set), so the overlay leaves `meter_type` unset and the + lodged meter stands — each dwelling scores on its actual tariff. This is a + **targeted exception to the "always set the meter, never let it bleed" + invariant**: that invariant guards against a switched-*off* storage system + leaving a stale Dual meter, but an underfloor override *confirms* underfloor — + there is no switch-off, so deferring is faithful, not a bleed. +5. **Deterministic `main_heating_guard` + reclassify** (TEXT-first, pgEnum-deferred), + reusing the generalized `reclassify_main_heating.py`. All three underfloor + descriptions are description-determined, so the guard resolves them directly (no + fuel-awareness needed) and the reclassify follows. + +## Consequences + +- The 15 concrete-slab-off-peak rows stop being billed at the peak rate (191/Single + → 421/Dual); the 12 integrated rows get the correct underfloor code (401 → 422, + meter already Dual); the 63 screed rows keep their lodged (81% off-peak) meter and + score on code 424 with correct screed responsiveness (Table 4d R=0.75). Correct + Rebaselining (ADR-0039). +- **FE-owned pgEnum additions (Dan):** three new `main_heating_system` values — + `"Electric underfloor, in concrete slab (off-peak)"`, `"Electric underfloor, + integrated storage and direct-acting"`, `"Electric underfloor, in screed above + insulation"`. TEXT read path fixed on merge; cache writes defer to the migration. +- `_meter_for` becomes `Optional[str]` (a code may defer the meter). Every existing + code keeps its current Single/Dual answer; only 424 returns `None`. +- Extends [ADR-0041](0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md) + and [ADR-0035](0035-coherent-heating-system-synthesis.md); refines the + `keep_existing_off_peak_meter` behaviour ADR-0035 introduced. + +### Alternatives rejected + +- **Force Single (424 per spec).** Rejected: downgrades the 81% lodged off-peak, + reproducing the meter bug. +- **Force Dual for screed.** Rejected: over-credits the ~19% genuinely on single and + asserts a tariff the "standard or off peak" description does not claim. +- **Map underfloor to Direct-acting (191) / old storage (401) as today.** Rejected: + wrong code (responsiveness/meter) and the source of the mis-score. diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 4b4558718..ad6944255 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -127,6 +127,29 @@ def test_electric_cpsu_decodes_to_192_electric_on_an_off_peak_meter() -> None: assert simulation.heating.meter_type == "Dual" +@pytest.mark.parametrize( + ("main_heating_value", "code"), + [ + # Electric underfloor funnelled into Direct-acting (191) / old storage (401) + # for want of the dedicated SAP Table 4a underfloor codes (ADR-0046). + ("Electric underfloor, in concrete slab (off-peak)", 421), + ("Electric underfloor, integrated storage and direct-acting", 422), + ("Electric underfloor, in screed above insulation", 424), + ], +) +def test_electric_underfloor_decodes_to_its_own_code_dragging_electricity( + main_heating_value: str, code: int +) -> None: + # Act + simulation = main_heating_overlay_for(main_heating_value, 0) + + # Assert — its own underfloor code, electric (29). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == code + assert simulation.heating.main_fuel_type == 29 + + @pytest.mark.parametrize( ("main_heating_value", "code"), [ From 749ac3a5ff2aa0b9209df57534abad6706484413 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 16:36:21 +0000 Subject: [PATCH 14/19] =?UTF-8?q?Resolve=20electric-underfloor=20overrides?= =?UTF-8?q?=20to=20their=20own=20SAP=20codes=20dragging=20electricity=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../property_overlays/main_heating_system_overlay.py | 11 +++++++++++ .../property_overrides/main_heating_system_type.py | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 5d7c3b836..12d695b18 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -116,6 +116,9 @@ _ELECTRIC_ROOM_HEATER_CODES = frozenset(range(691, 702)) # unambiguously electric, so they drag electricity like the electric room heaters # (ADR-0045, closing the prior no-fuel gap on 191/192). _ELECTRIC_BOILER_CODES = frozenset({191, 192}) +# Electric underfloor — SAP Table 4a 421/422 (off-peak) and 424 (screed) — also +# unambiguously electric (ADR-0046). +_ELECTRIC_UNDERFLOOR_CODES = frozenset({421, 422, 424}) # Heat pumps (SAP Table 4a 211-224 wet, 521-527 warm-air) are category 4 and # unambiguously electric (natural fuel 29). Modellable on the default code's SPF @@ -184,6 +187,13 @@ _MAIN_HEATING_CODES: dict[str, int] = { # Electric CPSU — SAP Table 4a 192. Electric (drags 29), on an off-peak Dual # meter (already in _ASSUMED_DUAL_METER_CODES; §12 Rule 1 10-hour) — ADR-0045. "Electric CPSU": 192, + # Electric underfloor — dedicated SAP Table 4a codes, all electric (drag 29). + # 421 concrete slab / 422 integrated are off-peak (Dual, §12 Rule 2); 424 + # screed is tariff-ambiguous ("standard or off peak") so it DEFERS the meter to + # the cert (ADR-0046). + "Electric underfloor, in concrete slab (off-peak)": 421, + "Electric underfloor, integrated storage and direct-acting": 422, + "Electric underfloor, in screed above insulation": 424, "Electric room heaters": 691, "Solid fuel room heater, closed": 633, # Solid-fuel room heaters off the Gas CPSU dumping ground (ADR-0045). SAP @@ -264,6 +274,7 @@ def _natural_fuel_for(code: int) -> Optional[int]: or code in _HEAT_PUMP_CODES or code == _HHR_STORAGE_CODE or code in _ELECTRIC_BOILER_CODES + or code in _ELECTRIC_UNDERFLOOR_CODES ): return _ELECTRICITY_FUEL if code in _SOLID_FUEL_ROOM_HEATER_CODES: diff --git a/domain/epc/property_overrides/main_heating_system_type.py b/domain/epc/property_overrides/main_heating_system_type.py index 866767f4f..f8426fdcf 100644 --- a/domain/epc/property_overrides/main_heating_system_type.py +++ b/domain/epc/property_overrides/main_heating_system_type.py @@ -29,6 +29,13 @@ class MainHeatingSystemType(Enum): DIRECT_ELECTRIC = "Direct-acting electric" ELECTRIC_BOILER = "Electric boiler" ELECTRIC_CPSU = "Electric CPSU" + ELECTRIC_UNDERFLOOR_SLAB_OFF_PEAK = ( + "Electric underfloor, in concrete slab (off-peak)" + ) + ELECTRIC_UNDERFLOOR_INTEGRATED = ( + "Electric underfloor, integrated storage and direct-acting" + ) + ELECTRIC_UNDERFLOOR_SCREED = "Electric underfloor, in screed above insulation" ELECTRIC_ROOM_HEATERS = "Electric room heaters" SOLID_FUEL_ROOM_HEATER_CLOSED = "Solid fuel room heater, closed" SOLID_FUEL_ROOM_HEATER_OPEN_FIRE = "Solid fuel room heater, open fire" From 1c79b69648c0500cc04173ec6fc7e854f6fb362e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 16:36:59 +0000 Subject: [PATCH 15/19] =?UTF-8?q?Defer=20the=20screed-underfloor=20meter?= =?UTF-8?q?=20to=20the=20cert=20while=20off-peak=20underfloor=20asserts=20?= =?UTF-8?q?Dual=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_main_heating_system_overlay.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index ad6944255..c477bcee2 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -150,6 +150,37 @@ def test_electric_underfloor_decodes_to_its_own_code_dragging_electricity( assert simulation.heating.main_fuel_type == 29 +@pytest.mark.parametrize( + "main_heating_value", + [ + # "off-peak only" / "off peak" positively claim off-peak. + "Electric underfloor, in concrete slab (off-peak)", + "Electric underfloor, integrated storage and direct-acting", + ], +) +def test_off_peak_underfloor_asserts_a_dual_meter(main_heating_value: str) -> None: + # Act + simulation = main_heating_overlay_for(main_heating_value, 0) + + # Assert — an off-peak underfloor must not bill overnight heat at the peak rate. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.meter_type == "Dual" + + +def test_screed_underfloor_defers_the_meter_to_the_cert() -> None: + # "In screed above insulation (standard or off peak)" is tariff-ambiguous (81% + # of the corpus lodge off-peak), so the archetype cannot know the meter — it + # leaves meter_type unset so the cert's lodged meter stands (ADR-0046). + simulation = main_heating_overlay_for( + "Electric underfloor, in screed above insulation", 0 + ) + + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.meter_type is None + + @pytest.mark.parametrize( ("main_heating_value", "code"), [ From 6883864c328242d6a7d1723900eb27e693ad2b68 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 16:46:33 +0000 Subject: [PATCH 16/19] =?UTF-8?q?Defer=20the=20screed-underfloor=20meter?= =?UTF-8?q?=20to=20the=20cert=20while=20off-peak=20underfloor=20asserts=20?= =?UTF-8?q?Dual=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main_heating_system_overlay.py | 16 ++++++++++++++-- .../epc/test_main_heating_system_overlay.py | 12 ++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 12d695b18..904a774bf 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -45,6 +45,10 @@ _OFF_PEAK_METER = "Dual" # meter bleed through and bill the new gas/direct-acting system on an Economy-7 # split (the mirror of the storage→Dual drag, ADR-0035). _SINGLE_RATE_METER = "Single" +# Meter-agnostic codes: the archetype cannot determine the tariff, so the overlay +# defers `meter_type` to the cert (`_meter_for` → None). Screed underfloor (424) is +# "standard or off peak" — 81% of the corpus lodge off-peak (ADR-0046). +_METER_AGNOSTIC_CODES = frozenset({424}) # Electric room heaters (SAP Table 4a 691). They don't *require* off-peak the way # storage/CPSU do, so they're absent from the calculator's §12 @@ -228,11 +232,19 @@ _MAIN_HEATING_CODES: dict[str, int] = { } -def _meter_for(code: int) -> str: +def _meter_for(code: int) -> Optional[str]: """The coherent meter a heating code implies: an off-peak ("Dual") meter for the §12 off-peak systems and all-electric room-heater dwellings (`_ASSUMED_DUAL_METER_CODES`), an explicit single-rate ("Single") meter for - every other system. Always set — never left to bleed.""" + every other system — set explicitly so it never bleeds. + + Exception: a **meter-agnostic** code (`_METER_AGNOSTIC_CODES` — screed + underfloor 424, "standard or off peak") returns ``None`` so the overlay leaves + `meter_type` unset and the cert's lodged tariff stands. The archetype genuinely + cannot know the tariff, and unlike a switched-off storage system it is not a + re-metering, so deferring is faithful, not a bleed (ADR-0046).""" + if code in _METER_AGNOSTIC_CODES: + return None return _OFF_PEAK_METER if code in _ASSUMED_DUAL_METER_CODES else _SINGLE_RATE_METER diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index c477bcee2..9f83c9b3f 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -13,6 +13,7 @@ from domain.epc.property_overlays.main_fuel_overlay import fuel_overlay_for from domain.epc.property_overlays.main_heating_system_overlay import ( _ASSUMED_DUAL_METER_CODES, _MAIN_HEATING_CODES, + _METER_AGNOSTIC_CODES, main_heating_overlay_for, ) from domain.epc.property_overlays.water_heating_overlay import ( @@ -367,11 +368,18 @@ def test_off_peak_archetypes_drag_dual_others_drag_single() -> None: # the calculator's single off-peak classification, so any archetype whose # code implies off-peak MUST synthesise a Dual meter and every other code # MUST synthesise a Single meter — a system switch can never silently leave - # the previous system's meter in place. + # the previous system's meter in place. The one exception is a meter-agnostic + # code (screed underfloor, tariff-ambiguous), which defers to the cert's + # lodged meter (None) rather than assert one (ADR-0046). for value, code in _MAIN_HEATING_CODES.items(): simulation = main_heating_overlay_for(value, 0) assert simulation is not None and simulation.heating is not None - expected = "Dual" if code in _ASSUMED_DUAL_METER_CODES else "Single" + if code in _METER_AGNOSTIC_CODES: + expected = None + elif code in _ASSUMED_DUAL_METER_CODES: + expected = "Dual" + else: + expected = "Single" assert simulation.heating.meter_type == expected, value From 6dcb8aee2119df648506ab06552501c4e7ba23e4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 16:47:59 +0000 Subject: [PATCH 17/19] =?UTF-8?q?Deterministically=20guard=20the=20electri?= =?UTF-8?q?c-underfloor=20descriptions=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/domain/epc/test_main_heating_guard.py | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/domain/epc/test_main_heating_guard.py b/tests/domain/epc/test_main_heating_guard.py index a32f3710f..cec0ca2df 100644 --- a/tests/domain/epc/test_main_heating_guard.py +++ b/tests/domain/epc/test_main_heating_guard.py @@ -19,6 +19,34 @@ def test_guard_resolves_high_heat_retention_storage() -> None: assert result is MainHeatingSystemType.ELECTRIC_STORAGE_HIGH_HEAT_RETENTION +@pytest.mark.parametrize( + ("description", "expected"), + [ + # Electric underfloor dumped into Direct-acting / old storage (ADR-0046). + ( + "Electric Underfloor Heating: In concrete slab (off-peak only)", + MainHeatingSystemType.ELECTRIC_UNDERFLOOR_SLAB_OFF_PEAK, + ), + ( + "Electric Underfloor Heating: Integrated (storage+direct-acting) (off peak)", + MainHeatingSystemType.ELECTRIC_UNDERFLOOR_INTEGRATED, + ), + ( + "Electric Underfloor Heating: In screed above insulation (standard or off peak)", + MainHeatingSystemType.ELECTRIC_UNDERFLOOR_SCREED, + ), + ], +) +def test_guard_resolves_electric_underfloor( + description: str, expected: MainHeatingSystemType +) -> None: + # Act + result = main_heating_guard(description) + + # Assert + assert result is expected + + @pytest.mark.parametrize( ("description", "expected"), [ From 9d106d9d6d1453e8a827abf4c62cff7fa88c8890 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 16:48:45 +0000 Subject: [PATCH 18/19] =?UTF-8?q?Deterministically=20guard=20the=20electri?= =?UTF-8?q?c-underfloor=20descriptions=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/epc/property_overrides/main_heating_guard.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/domain/epc/property_overrides/main_heating_guard.py b/domain/epc/property_overrides/main_heating_guard.py index 8347651bf..bf69d42c0 100644 --- a/domain/epc/property_overrides/main_heating_guard.py +++ b/domain/epc/property_overrides/main_heating_guard.py @@ -20,6 +20,10 @@ def main_heating_guard(description: str) -> Optional[MainHeatingSystemType]: solid-fuel room heaters (open fire 631 / + back boiler 632 / closed + boiler 634) and electric ``"NA"``-type boilers (SAP 191). "NA" is the boiler-type value meaning "not a gas combi/regular/CPSU", i.e. an electric boiler. + - **Electric underfloor** (ADR-0046): dumped into Direct-acting (191) / old + storage (401) for want of the dedicated SAP underfloor codes — in concrete + slab (421, off-peak), integrated storage+direct-acting (422), in screed (424, + meter deferred to the cert). Returns ``None`` for descriptions the LLM already classifies correctly (genuine gas boilers, other storage subtypes, a plain closed room heater) and for varied @@ -30,6 +34,14 @@ def main_heating_guard(description: str) -> Optional[MainHeatingSystemType]: text = description.lower() if "high heat retention" in text: return MainHeatingSystemType.ELECTRIC_STORAGE_HIGH_HEAT_RETENTION + if "underfloor" in text: + if "concrete slab" in text: + return MainHeatingSystemType.ELECTRIC_UNDERFLOOR_SLAB_OFF_PEAK + if "integrated" in text: + return MainHeatingSystemType.ELECTRIC_UNDERFLOOR_INTEGRATED + if "screed" in text: + return MainHeatingSystemType.ELECTRIC_UNDERFLOOR_SCREED + return None if "rated na" in text: return MainHeatingSystemType.ELECTRIC_BOILER if "open fire" in text and "back boiler" in text: From f50ee5f957d2294e3489dbdbbff4d0119a3521c4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 16:51:22 +0000 Subject: [PATCH 19/19] =?UTF-8?q?Reclassify=20electric-underfloor=20overri?= =?UTF-8?q?des=20via=20the=20generalized=20main-heating=20reclassify=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/reclassify_main_heating.py | 4 +- tests/scripts/test_reclassify_main_heating.py | 40 ++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/scripts/reclassify_main_heating.py b/scripts/reclassify_main_heating.py index 0d070445e..b21f9b78a 100644 --- a/scripts/reclassify_main_heating.py +++ b/scripts/reclassify_main_heating.py @@ -1,11 +1,13 @@ """One-time re-classification of main-heating overrides funnelled into a garbage drawer for want of a correct archetype (#1376). -Covers both dumping grounds the ``main_heating_guard`` now rescues: +Covers the dumping grounds the ``main_heating_guard`` now rescues: - **HHRSH** (ADR-0044): "High heat retention storage heaters" → "Electric storage heaters, old" (SAP 401), the worst old type — 111 rows. - **Gas CPSU** (ADR-0045): solid-fuel room heaters (open fire / + back boiler / closed + boiler) and electric "NA"-type boilers → "Gas CPSU" (120, mains gas). +- **Electric underfloor** (ADR-0046): concrete slab (421) / integrated (422) / + screed (424) dumped into Direct-acting (191) / old storage (401). The live classifier now applies ``main_heating_guard`` deterministically (so new intakes are correct); this fixes the rows written before it. The **same guard** diff --git a/tests/scripts/test_reclassify_main_heating.py b/tests/scripts/test_reclassify_main_heating.py index 8c172d0c5..7b2450357 100644 --- a/tests/scripts/test_reclassify_main_heating.py +++ b/tests/scripts/test_reclassify_main_heating.py @@ -65,12 +65,50 @@ def test_garbage_drawer_rows_are_remapped_to_faithful_archetypes() -> None: } +def test_electric_underfloor_rows_are_remapped_off_direct_acting_and_old_storage() -> None: + # Underfloor dumped into Direct-acting (191) / old storage (401) → its own codes + # (ADR-0046). Description-determined, so the guard-based reclassify handles it. + stored = [ + ( + "electric underfloor heating: in concrete slab (off-peak only)", + "Direct-acting electric", + ), + ( + "electric underfloor heating: integrated (storage+direct-acting) (off peak)", + "Electric storage heaters, old", + ), + ( + "electric underfloor heating: in screed above insulation (standard or off peak)", + "Direct-acting electric", + ), + ] + + # Act + corrections = main_heating_corrections(stored) + + # Assert + assert corrections == { + "electric underfloor heating: in concrete slab (off-peak only)": ( + "Electric underfloor, in concrete slab (off-peak)" + ), + "electric underfloor heating: integrated (storage+direct-acting) (off peak)": ( + "Electric underfloor, integrated storage and direct-acting" + ), + "electric underfloor heating: in screed above insulation (standard or off peak)": ( + "Electric underfloor, in screed above insulation" + ), + } + + def test_every_guard_target_round_trips_to_a_resolvable_archetype() -> None: # Every archetype the guard emits for the rescued descriptions must decode to a # real SAP heating code — a guard/overlay drift can't silently write an - # unmodellable value (ADR-0044, ADR-0045). + # unmodellable value (ADR-0044, ADR-0045, ADR-0046). descriptions = [ "Electric Storage Systems: High heat retention storage heaters", + "Electric Underfloor Heating: In concrete slab (off-peak only)", + "Electric Underfloor Heating: Integrated (storage+direct-acting) (off peak)", + "Electric Underfloor Heating: In screed above insulation (standard or off peak)", "Solid fuel room heaters: Open fire in grate", "Solid fuel room heaters: Open fire with back boiler (no radiators)", "Solid fuel room heaters: Closed room heater with boiler (no radiators)",