mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
commit
c1a75fb45b
10 changed files with 701 additions and 105 deletions
|
|
@ -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 631–636 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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -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",
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
]
|
||||
)
|
||||
== {}
|
||||
)
|
||||
154
tests/scripts/test_reclassify_main_heating.py
Normal file
154
tests/scripts/test_reclassify_main_heating.py
Normal 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"),
|
||||
]
|
||||
)
|
||||
== {}
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue