Merge pull request #1405 from Hestia-Homes/fix/1376-offpeak-underfloor

Score off-peak electric underfloor on its own codes, not direct-acting single-meter (#1376)
This commit is contained in:
Jun-te Kim 2026-07-02 09:56:57 +01:00 committed by GitHub
commit dc868e87a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 270 additions and 6 deletions

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
@ -116,6 +120,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 +191,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
@ -218,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
@ -264,6 +286,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:

View file

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

View file

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

View file

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

View file

@ -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"),
[

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 (
@ -127,6 +128,60 @@ 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",
[
# "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"),
[
@ -313,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

@ -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)",