diff --git a/docs/adr/0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md b/docs/adr/0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md new file mode 100644 index 000000000..f6550c123 --- /dev/null +++ b/docs/adr/0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md @@ -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. diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 4b4558718..ad6944255 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -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"), [