mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
commit
dc868e87a9
8 changed files with 270 additions and 6 deletions
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue