Resolve electric-underfloor overrides to their own SAP codes dragging electricity 🟥

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-01 16:34:41 +00:00
parent e5c112a2d3
commit ef34f76116
2 changed files with 115 additions and 0 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

@ -127,6 +127,29 @@ def test_electric_cpsu_decodes_to_192_electric_on_an_off_peak_meter() -> None:
assert simulation.heating.meter_type == "Dual"
@pytest.mark.parametrize(
("main_heating_value", "code"),
[
# Electric underfloor funnelled into Direct-acting (191) / old storage (401)
# for want of the dedicated SAP Table 4a underfloor codes (ADR-0046).
("Electric underfloor, in concrete slab (off-peak)", 421),
("Electric underfloor, integrated storage and direct-acting", 422),
("Electric underfloor, in screed above insulation", 424),
],
)
def test_electric_underfloor_decodes_to_its_own_code_dragging_electricity(
main_heating_value: str, code: int
) -> None:
# Act
simulation = main_heating_overlay_for(main_heating_value, 0)
# Assert — its own underfloor code, electric (29).
assert simulation is not None
assert simulation.heating is not None
assert simulation.heating.sap_main_heating_code == code
assert simulation.heating.main_fuel_type == 29
@pytest.mark.parametrize(
("main_heating_value", "code"),
[