Merge pull request #1403 from Hestia-Homes/fix/1376-gas-cpsu-dumping-ground

Retire the Gas CPSU dumping ground: solid-fuel room heaters + electric boilers (#1376)
This commit is contained in:
Jun-te Kim 2026-07-02 09:57:15 +01:00 committed by GitHub
commit c1a75fb45b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 701 additions and 105 deletions

View file

@ -0,0 +1,96 @@
# 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 631636 set) — no overlay-logic change.
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: <band> 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
- 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"`, `"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
code).
### Out of scope
- **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).

View file

@ -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.

View file

@ -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
@ -112,6 +116,13 @@ _CATEGORY_10_ROOM_HEATER_CODES = (
_ELECTRICITY_FUEL = 29
_HOUSE_COAL_FUEL = 33
_ELECTRIC_ROOM_HEATER_CODES = frozenset(range(691, 702))
# 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})
# 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
@ -173,8 +184,29 @@ _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 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
# 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).
@ -200,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
@ -245,6 +285,8 @@ 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 in _ELECTRIC_BOILER_CODES
or code in _ELECTRIC_UNDERFLOOR_CODES
):
return _ELECTRICITY_FUEL
if code in _SOLID_FUEL_ROOM_HEATER_CODES:

View file

@ -8,17 +8,46 @@ 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.
- **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
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 "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:
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

View file

@ -27,8 +27,24 @@ class MainHeatingSystemType(Enum):
"Electric storage heaters, high heat retention"
)
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"
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"

View file

@ -1,9 +1,13 @@
"""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 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**
@ -11,9 +15,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
@ -24,20 +28,36 @@ 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 hhrsh_storage_corrections(
def electric_cpsu_for(description: str, main_fuel: Optional[str]) -> Optional[str]:
"""``"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(
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)
@ -83,18 +103,42 @@ _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]]:
"""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
@ -104,6 +148,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
@ -124,9 +183,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(

View file

@ -19,6 +19,66 @@ 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"),
[
# 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 +87,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",
"",

View file

@ -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 (
@ -78,6 +79,109 @@ 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
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
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"),
[
# 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",
[
# "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"),
[
@ -264,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

View file

@ -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",
)
]
)
== {}
)

View file

@ -0,0 +1,154 @@
"""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 (
electric_cpsu_for,
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_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, 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)",
"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_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 (
main_heating_corrections(
[
(
"electric storage systems: high heat retention storage heaters",
"Electric storage heaters, high heat retention",
),
("boiler: a rated na", "Electric boiler"),
]
)
== {}
)