From ef34f76116dbbc71d406cf767029e961322021e3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 16:34:41 +0000 Subject: [PATCH 1/7] =?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 2/7] =?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 3/7] =?UTF-8?q?Defer=20the=20screed-underfloor=20meter=20t?= =?UTF-8?q?o=20the=20cert=20while=20off-peak=20underfloor=20asserts=20Dual?= =?UTF-8?q?=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 4/7] =?UTF-8?q?Defer=20the=20screed-underfloor=20meter=20t?= =?UTF-8?q?o=20the=20cert=20while=20off-peak=20underfloor=20asserts=20Dual?= =?UTF-8?q?=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 5/7] =?UTF-8?q?Deterministically=20guard=20the=20electric-?= =?UTF-8?q?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 6/7] =?UTF-8?q?Deterministically=20guard=20the=20electric-?= =?UTF-8?q?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 7/7] =?UTF-8?q?Reclassify=20electric-underfloor=20override?= =?UTF-8?q?s=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)",