diff --git a/CONTEXT.md b/CONTEXT.md index e0b4a8493..182959e66 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -103,6 +103,10 @@ _Avoid_: patches (deprecated), corrections, manual EPC, edits **Landlord-Description Classification**: Resolving a **Landlord Description** (unbounded free-text a landlord supplies for one component — "CWI" / "Cav filled" / "cavity insulated" all name one thing) onto a **Recognised Internal Description** via an LLM classifier, persisted in the `landlord_*_overrides` table (`source=classifier`) as a reviewed cache. Four vocabularies are kept **distinct** and must not be conflated: a **Landlord Description** (unbounded input); a **Recognised Internal Description** (the closed target taxonomy — e.g. a `MainHeatingSystemType` archetype — each binding to a Simulation Overlay); a **Lodged Description** (the gov-EPC `main_heating[].description` rendering, e.g. "Room heaters, electric" — only an example of which system *types* occur, never a map key); and the **SAP main heating code** (Table 4a/4b, what the calculator consumes). The classifier maps Landlord → Recognised Internal → SAP code. When it cannot confidently place the text it emits **`None`** (no overlay → the lodged EPC stands, surfaced to the user as "no suitable match"), **never the nearest wrong archetype** — the target taxonomy must be complete enough that a real system always has a correct home, so the classifier never overflows into a garbage-drawer archetype (ADR-0041). + +**Heating Companion Set**: +The coherent SAP inputs a **main heating system** override drags alongside the **SAP main heating code** so the **Effective EPC** reads as one internally-consistent system, not a hybrid of the new code and the *replaced* system's leftovers — heating **category** (SAP Table 4a group: gas→2, heat pump→4, network→6, storage→7, electric underfloor→8, warm air→9, room heater→10), **charge/heating control** (Table 4e group), **natural fuel**, and **meter** (see **Off-Peak Meter**). Companions divide by how firmly the archetype fixes them (ADR-0048): **category and control are archetype-forced** — always written, never inherited, because a wrong value silently mis-scores or mis-bills (a leftover storage **category 7** makes the Table 12a resolver bill peaky room heaters at the all-night rate; a leftover storage **control 2401** adds a +0.7 °C Table-4e penalty a room heater should not carry). **Fuel and meter defer** only where multiple values are genuinely coherent — fuel for an ambiguous carrier (community heating: gas CHP / biomass / waste heat), meter for a meter-flexible system (room heaters run on either; screed underfloor per ADR-0046) — while a meter-*locked* system still forces (storage/CPSU/HHRSH→Dual, non-electric→Single, ADR-0035). Because the overlay composes last-wins, an **unset (`None`)** forced companion is *inherited from the replaced system*, not cleared — so an archetype the overlay cannot yet fully companion (heat pump / community / underfloor / electric boiler control defaults are unmapped) is **logged as an error and continues** (log-not-raise, matching `flag_fuel_mismatch`), making the gap visible for review rather than shipping a silently-incoherent cert; the intended end-state is to fill each and flip the log to a raise. +_Avoid_: coherent companions (informal), dragged fields, heating defaults _Avoid_: "the LLM mapper is unreliable" (the failure mode is a too-small target taxonomy, not LLM language ability); conflating the landlord input vocabulary with the gov-EPC lodged rendering or the RdSAP entry-tool catalogue; treating a deterministic dict as a *replacement* for the LLM rather than a reviewed cache of its output **Lodgement Sentinels (ND / NI / AB)**: diff --git a/docs/adr/0048-heating-override-forces-category-and-control-defers-fuel-and-meter.md b/docs/adr/0048-heating-override-forces-category-and-control-defers-fuel-and-meter.md new file mode 100644 index 000000000..5b2a1870f --- /dev/null +++ b/docs/adr/0048-heating-override-forces-category-and-control-defers-fuel-and-meter.md @@ -0,0 +1,132 @@ +# A system-replacing heating override forces category and control for coherence, and defers fuel and meter where multiple values are valid + +## Status + +accepted + +## Context + +A **Landlord Override** naming a main heating system resolves to a SAP Table 4a +code, and `main_heating_system_overlay` synthesises the **coherent companions** +that code implies (ADR-0035): heating category, charge control, natural fuel, +meter. The overlay is applied over the lodged cert by last-wins composition, so a +companion the overlay leaves **unset (`None`)** is *inherited from the replaced +system* — not cleared. + +That inheritance is a silent mis-rating when the archetype genuinely determines +the companion. A portfolio-796 audit found the case end-to-end on **86+ +properties**: a lodged **slimline storage heater** (SAP 402, category 7, control +2401, off-peak) overridden to **"Electric room heaters"** (SAP 691). The overlay +stamped code 691, fuel, and a Dual meter — but `_category_for(691)` and +`_control_for(691)` both returned `None`, because their room-heater branch keyed +on `_CATEGORY_10_ROOM_HEATER_CODES`, a set that held only the *fuel-burning* +(gas/oil/solid) room heaters and **excluded electric 691–701** despite electric +room heaters also being Table 4a **Category 10**. + +Two independent mis-ratings followed from the surviving storage companions: + +1. **Category 7 survived.** The SAP 10.2 Table 12a resolver keys on category + first, read `OTHER_STORAGE_HEATERS`, and — with the dwelling on the §12 Rule 3 + 10-hour tariff — hit `(OTHER_STORAGE_HEATERS, TEN_HOUR)`, a combination the + Grid-1 table does not carry, so it fell to the all-low-rate fallback. The + peaky room heaters were billed **100% at the night rate** — the precise + "all-low over-credit" the overlay's own Dual-meter comment claims to avoid. + Effect: baseline SAP over-rated **64.1 vs the spec-correct 56.4**; the HHRSH + upgrade measure showed a **negative** bill saving (−£13/yr) because the + baseline it was compared against was itself under-billed. +2. **Control 2401 survived.** `main_heating_control` fed Table 4e as the storage + **manual charge control → +0.7 °C** mean-internal-temperature adjustment — the + largest in the table — riding on a direct-acting room heater, which should + carry the Group 6 room-heater control **2601 → +0.3 °C**. + +The companions are **not uniform** in how strongly the archetype determines +them, which is the crux of the decision: + +- **Category is deterministic** — SAP Table 4a assigns exactly one category per + code (gas→2, heat pump→4, network→6, storage→7, **electric underfloor→8**, + warm-air→9, **room heater→10**). A `None` category is never valid. +- **Control is an assumption but always archetype-supplied** — the Table 4e + *Group* is fixed by category; the specific conservative default within the + group is a modelling choice (storage→manual 2401, HHRSH→2404 per + [ADR-0044](0044-hhrsh-baseline-archetype-drags-its-intrinsic-controls.md), + room heater→2601, gas boiler→full controls). It is never "inherit the replaced + system". +- **Fuel is sometimes genuinely ambiguous** — electric/gas/storage/heat-pump + archetypes pin their fuel, but **community heating** could be gas CHP, biomass, + or waste heat, so its fuel legitimately **defers** to a `main_fuel` override, + else the lodged value (ADR-0041 last-wins). +- **Meter is physics-locked for some systems, flexible for others** — storage / + CPSU / HHRSH **require** Dual (they charge overnight); non-electric systems are + stamped Single to stop an Economy-7 split bleeding onto a gas boiler + ([ADR-0035](0035-coherent-heating-system-synthesis.md)); screed underfloor + defers to the cert ([ADR-0046](0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md)). + +## Decision + +A system-replacing heating override must emit a **complete, coherent companion +set**. Companions split by whether the archetype *determines* the value: + +1. **Category and control are archetype-forced** — always written, never + inherited, because a wrong value mis-scores or mis-bills the cert. Electric + room heaters (Table 4a 691–701) join the Category-10 room-heater branch of + both `_category_for` (→ category **10**) and `_control_for` (→ control + **2601**, the Group 6 conservative no-thermostat default, matching the + gas/oil/solid siblings). Their fuel (electricity 29) and Dual meter were + already correct and are unchanged. + +2. **Fuel and meter defer where multiple values are coherent** — this is *not* + a gap. Fuel defers only for genuinely ambiguous carriers (community heating); + meter defers only for meter-flexible systems (room heaters run coherently on + either meter; screed underfloor per ADR-0046). Meter-locked systems still + force (storage/CPSU/HHRSH → Dual; non-electric → Single). + +3. **An incomplete companion set is logged, not raised (for now).** Where the + overlay cannot yet supply a confident category or control — heat pumps + (Group 2), community (Group 3), electric underfloor (Group 7), electric + boiler / CPSU (Group 1) — `main_heating_overlay_for` **logs an error** naming + the archetype and code and continues, matching the `flag_fuel_mismatch` + log-not-raise convention. Log-and-continue keeps a mid-portfolio run alive + and surfaces the gap (CloudWatch) for review, rather than turning a latent + inheritance bug into an outage. The intended end-state is to fill each + archetype's category/control with spec sight and then **flip the log to a + raise**, so the coherence invariant is enforced. + +Category expansion for the still-`None` archetypes (electric boiler/CPSU→2, +underfloor→8) is deterministic from Table 4a but **deferred** to its own change: +it flows into the category-keyed Table 12a resolver and needs a corpus check, so +it does not ride on the room-heater fix. + +## Consequences + +- The 86+ room-heater dwellings score against a correctly-billed baseline: the + space-heating high-rate fraction is the spec **0.50** (10-hour direct-acting), + not 0.0; baseline SAP corrects **64.1 → 56.4**; the HHRSH measure's bill saving + flips **−£13/yr → +£235/yr** (property 710788, uprn 22003310, scenario 1268). +- The `refetch_solar`-corrected portfolio's `negative-bill-savings` audit group + loses its ~86-property HHRSH-on-room-heater cluster after re-model. +- The overlay now emits a visible error for any archetype it cannot fully + companion — a standing signal (wire a CloudWatch metric filter on the log + string) for which control default to fill next. +- Extends [ADR-0035](0035-coherent-heating-system-synthesis.md) (companions drag + from the code) and [ADR-0041](0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md) + (complete taxonomy); sits beside + [ADR-0044](0044-hhrsh-baseline-archetype-drags-its-intrinsic-controls.md) + (a control named by the archetype) and + [ADR-0046](0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md) + (meter deferred where the tariff is ambiguous). + +### Alternatives rejected + +- **Make every companion total (force fuel and meter too).** Rejected: it breaks + the deliberate deferrals — community-heating fuel (ambiguous carrier) and the + ADR-0046 screed-underfloor meter — whose `None` is a load-bearing "defer to the + cert/override" signal, not a gap. +- **Fix the Table 12a resolver to prefer the SAP code over a stale category.** + A sound defence-in-depth follow-up, but it treats the symptom; the overlay + emitting an incoherent cert is the root cause, and category/control coherence + must hold for every consumer (predicted EPCs, raw lodged certs), not only the + billing resolver. +- **Raise on an incomplete companion set now.** Rejected for this change: heat + pump / community / underfloor overrides model today (by luck of inheritance), + so an immediate raise is a blast-radius change mid-campaign. Log now, raise once + the common archetypes are filled. diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 466466594..d0c498320 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -28,6 +28,7 @@ are left UNKNOWN until modelled. Unresolvable values produce no overlay. from __future__ import annotations +import logging from typing import Optional from domain.modelling.simulation import EpcSimulation, HeatingOverlay @@ -35,6 +36,8 @@ from domain.sap10_calculator.tables.table_12a import ( OFF_PEAK_IMPLYING_HEATING_CODES, ) +logger = logging.getLogger(__name__) + # Off-peak (Economy 7) meter. Electric storage / CPSU systems charge overnight at # the low rate and cannot run economically on a single-rate meter; "Dual" lets # the §12 dispatch resolve the specific tariff (storage 7-hour, CPSU 10-hour). @@ -266,7 +269,7 @@ def _control_for(code: int) -> Optional[int]: return _MANUAL_CHARGE_CONTROL if code in _GAS_BOILER_CODES: return _FULL_BOILER_CONTROL - if code in _CATEGORY_10_ROOM_HEATER_CODES: + if code in _CATEGORY_10_ROOM_HEATER_CODES or code in _ELECTRIC_ROOM_HEATER_CODES: return _ROOM_HEATER_CONTROL return None @@ -280,7 +283,7 @@ def _category_for(code: int) -> Optional[int]: category 7); without it a storage override left a stale category behind, which the SAP 10.2 Table 12a resolver (keyed on category) could misread as a non-storage system and price the off-peak storage heating at the peak rate.""" - if code in _CATEGORY_10_ROOM_HEATER_CODES: + if code in _CATEGORY_10_ROOM_HEATER_CODES or code in _ELECTRIC_ROOM_HEATER_CODES: return _ROOM_HEATER_CATEGORY if code in _STORAGE_HEATER_CODES: return _STORAGE_HEATER_CATEGORY @@ -351,13 +354,32 @@ def main_heating_overlay_for( return None if code in _GAS_BOILER_CODES: return EpcSimulation(heating=_gas_boiler_overlay(code)) + category = _category_for(code) + control = _control_for(code) + if category is None or control is None: + # A system-replacing override should stamp a complete, coherent companion + # set; an unmapped category or control leaves the field unset, so the + # effective cert inherits the REPLACED system's value (a silent mis-rating + # — e.g. a room heater keeping a storage heater's category/control). We do + # not yet have confident defaults for every archetype, so log-and-continue + # (matching `flag_fuel_mismatch`) to make the gap visible for review + # rather than raise mid-run. + logger.error( + "Landlord main_heating_system %r (SAP code %d) overlaid with an " + "incomplete companion set (category=%s, control=%s); the effective " + "cert may inherit the replaced system's value — known gap, review", + main_heating_value, + code, + category, + control, + ) return EpcSimulation( heating=HeatingOverlay( sap_main_heating_code=code, - main_heating_category=_category_for(code), + main_heating_category=category, main_fuel_type=_natural_fuel_for(code), meter_type=_meter_for(code), - main_heating_control=_control_for(code), + main_heating_control=control, # A landlord override describes the existing dwelling, so its assumed # off-peak meter must not downgrade a more-off-peak cert meter # (e.g. a 24-hour all-low tariff). Measures, which re-meter for real, diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index dae992cba..40998c7d7 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -6,6 +6,8 @@ calculator reads from the primary system; the overlay is whole-dwelling. from __future__ import annotations +import logging + import pytest from domain.epc.property_overrides.main_heating_system_type import MainHeatingSystemType @@ -58,6 +60,68 @@ def test_electric_room_heaters_assume_a_dual_economy7_meter() -> None: assert simulation.heating.meter_type == "Dual" +def test_electric_room_heaters_overlay_the_room_heater_category() -> None: + # A landlord "Electric room heaters" override replaces the lodged system, so + # the overlay must stamp the room-heater heating category (SAP Table 4a + # Category 10). Leaving it unset lets a replaced storage heater's category 7 + # survive onto the effective cert, which the SAP 10.2 Table 12a resolver + # (keyed on category) mis-reads as off-peak storage and bills the peaky room + # heaters at the all-night low rate. + # Act + simulation = main_heating_overlay_for("Electric room heaters", 0) + + # Assert — SAP Table 4a Category 10 (Room heaters). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_heating_category == 10 + + +def test_electric_room_heaters_overlay_the_room_heater_charge_control() -> None: + # A system-replacing override must stamp its own control, not inherit the + # replaced system's. Electric room heaters take the Table 4e Group 6 + # room-heater control 2601 (+0.3 C, the conservative no-thermostat default, + # matching the gas/oil/solid room heaters). Leaving it unset lets a replaced + # storage heater's manual charge control 2401 (+0.7 C) survive and over-warm + # the modelled dwelling. + # Act + simulation = main_heating_overlay_for("Electric room heaters", 0) + + # Assert — SAP Table 4e Group 6 room-heater control. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_heating_control == 2601 + + +def test_overlay_logs_when_it_cannot_complete_a_companion_set( + caplog: pytest.LogCaptureFixture, +) -> None: + # An archetype the overlay cannot yet fully companion — here an air-source + # heat pump, whose Table 4e Group 2 conservative control is not yet mapped — + # is still overlaid (log-and-continue), but the incomplete companion set is + # logged as an error so the known gap is visible (CloudWatch) rather than + # silently inheriting the replaced system's stale value. + # Act + with caplog.at_level(logging.ERROR): + main_heating_overlay_for("Air source heat pump", 0) + + # Assert — the gap is reported and names the archetype. + assert "Air source heat pump" in caplog.text + + +def test_complete_room_heater_overlay_does_not_log_a_companion_gap( + caplog: pytest.LogCaptureFixture, +) -> None: + # Electric room heaters now resolve a full companion set (category 10 + + # control 2601), so the overlay must NOT log an incomplete-companion gap — + # guarding that the fix keeps the set complete. + # Act + with caplog.at_level(logging.ERROR): + main_heating_overlay_for("Electric room heaters", 0) + + # Assert + assert "incomplete companion set" not in caplog.text + + @pytest.mark.parametrize( ("main_heating_value", "code"), [