From 72e1e67c6d20b1588b449dd7cfa347d78467b821 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 14:33:54 +0000 Subject: [PATCH 01/36] =?UTF-8?q?docs:=20ADR-0041=20=E2=80=94=20landlord-h?= =?UTF-8?q?eating=20classification=20targets=20a=20complete=20taxonomy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grill-with-docs output for PRD #1361 Class A. The band-G baselines are not a fabric/rebaseline bug (walls are correct — landlord asserted as-built); they are a target-vocabulary gap in the landlord-description heating classifier. The LLM maps unbounded free-text onto a closed internal taxonomy, but that taxonomy had 9 archetypes vs ~13 RdSAP families, so it overflowed into "Gas CPSU"/storage (the "convector" collision sent direct-acting room heaters to off-peak storage 403 → SAP ~10). - ADR-0041: fix the taxonomy not the mapper; unmapped input -> None (keep lodged EPC, surface to user), never a forced archetype; heat pumps modellable via a default Table 4a code (no PCDB id); community heating via codes 301/302/304 with a documented DLF-1.50 / boiler-source default. - CONTEXT.md: sharpen Landlord-Description Classification and the four distinct vocabularies (landlord / recognised-internal / lodged / SAP code). Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 4 + ...-targets-a-complete-modellable-taxonomy.md | 103 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md diff --git a/CONTEXT.md b/CONTEXT.md index fe6493ee4..19825e15a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -101,6 +101,10 @@ _Avoid_: energy assessment, site survey, field survey, Domna survey, Hestia surv Property data supplied by a landlord that may correct or supplement the public EPC for a single Property; triggers Rebaselining when applied; not applicable when Site Notes are present. _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). +_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 + ### Modelling **Effective EPC**: diff --git a/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md b/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md new file mode 100644 index 000000000..2028d037d --- /dev/null +++ b/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md @@ -0,0 +1,103 @@ +# Landlord-heating classification targets a complete, modellable taxonomy; unmapped input is no-override + +## Status + +accepted + +## Context + +Landlord supplementary data describes a dwelling's components in **unbounded +free-text** — a wall is "cavity insulated" / "CWI" / "Cav filled"; a heating +system is "communal gas boiler" / "Quantum storage" / a boiler make. An LLM +classifier maps that free-text onto a **closed internal taxonomy** of recognised +descriptions, each of which binds to a Simulation Overlay applied to +`EpcPropertyData` (ADR-0032). For heating the taxonomy is the +`MainHeatingSystemType` enum, mapped to a representative SAP Table 4a/4b code by +`main_heating_system_overlay`, with coherent companions dragged from the code +(ADR-0035). The LLM is the right tool for the unbounded-free-text problem; the +deterministic dicts in legacy `asset_list` were a *reviewed cache of LLM output*, +not a replacement, and the `landlord_*_overrides` table already serves as that +verified cache (keyed `portfolio_id + description`). + +The taxonomy was **too small** — 9 heating archetypes against a ~13-family RdSAP +main-heating taxonomy. With no correct target the classifier force-picked the +nearest wrong archetype, and treated **"Gas CPSU" as a garbage drawer**: oil and +solid-fuel room heaters, community heating, and `"boiler: a rated na"` all +classified to Gas CPSU; the word "convector" in `"electric (direct acting) room +heaters: panel, convector or radiant heaters"` pulled it to `"Electric storage +heaters, convector"` (code 403, off-peak storage) instead of `"Electric room +heaters"` (691, direct-acting, single-rate). + +Folded into the Effective EPC, a single-rate dwelling modelled as off-peak +storage scores **SAP ~10 (band G)**. This is the true cause of **PRD #1361 +Class A** — 14 properties lodged in band C/D rebaselining to band G — which had +been mis-attributed to a fabric "no insulation (assumed)" assumption and the +pre-SAP10 rebaseline. The walls are correct (landlord asserted as-built); the +heating is the crater. Exemplar **property 718066**: lodged SAP 57, landlord +described "electric (direct acting) room heaters: panel, convector or radiant" +(correct code 691, single-rate), mis-stored as code 403 → live SAP **9.9**. The +stored override was classified `source=classifier` on 2026-06-20, **before** the +691 "Electric room heaters" archetype existed (commit ada2bc07, 2026-06-29) — so +it is also stale. + +## Decision + +The fix is the **target taxonomy, not the mapper**. The LLM stays. + +1. **The classifier targets a complete, modellable taxonomy.** Expand + `MainHeatingSystemType` + `_MAIN_HEATING_CODES` to cover every RdSAP + main-heating family the calculator can score: room heaters by fuel + (gas / oil / solid), warm air, **heat pumps**, and **community heating**. Each + maps to a representative Table 4a/4b code; coherent companions drag from the + code per ADR-0035, so adding an archetype is still "just add its code". + +2. **Unmapped free-text → `None`, never a forced archetype.** The overlay already + returns `None` (no overlay → the lodged EPC heating stands) for any value not + in `_MAIN_HEATING_CODES`; the classifier must *emit* Unknown/`None` when it + cannot confidently place the text, rather than the nearest wrong archetype. + "Gas CPSU" (or any other archetype) is never a fallback. The `None` mapping is + persisted and surfaced to the user as "no suitable match" for later edit. A + forced archetype overwrites a correct lodged cert; `None` (keep lodged) is the + only safe and honest behaviour. + +3. **Heat pumps are modellable without a PCDB index** — a "model unknown" heat + pump maps to a default Table 4a heat-pump code (211–224) with the table's + default seasonal efficiency; no `main_heating_index_number` is required. + +4. **Community heating is modelled via its explicit codes** — 301 (boiler + community) / 302 (CHP + boilers) / 304 (electric heat-pump community), which + the catalogue strings disambiguate ("community boilers only" / "community CHP + and boilers" / "community heat pump"). The calculator (Appendix C §C3.2 + distribution loss, Table 12c DLF, Table 12 source factors) and Bill Derivation + (`HEAT_NETWORK` indicative rate) already support these. **Load-bearing + assumption:** when the landlord text does not supply them, the **DLF defaults + to Table 12c 1.50** and the source to a boiler — community SAP is *sensitive* + to the DLF (1.0 vs 1.5 is a large swing), so this default is documented here + and surfaced as an assumption. Coarse "communal heating" with **no named + source** falls to `None` rather than inventing a heat-network model. + +5. **Re-classify the stale `source=classifier` overrides** once the taxonomy is + complete, so the band-G properties pick up their correct code. + +## Considered Options + +- **Replace the LLM with a deterministic catalogue map** — rejected: landlord + input is unbounded free-text, not a closed vocabulary. The LLM is the right + front door; determinism belongs as a downstream *verified* cache (the override + table), built from LLM output + review, exactly as `asset_list` was. +- **Keep the 9-member enum, tune the LLM prompt** — rejected: with no correct + target the LLM must pick a wrong archetype regardless of prompt. Target + completeness is the fix; prompt quality is secondary. +- **Force unmapped input to a conservative archetype** (e.g. direct-acting + electric) — rejected: any forced archetype overwrites a correct lodged cert. +- **Defer community heating to `None`** (grill option b) — rejected in favour of + modelling the three explicit community codes, since calculator + bill already + support heat networks and the catalogue strings disambiguate the cases; only + coarse unnamed "communal heating" falls to `None`. + +This extends [ADR-0032](0032-landlord-override-epc-overlay.md) (the override +overlay) and [ADR-0035](0035-coherent-heating-system-synthesis.md) (coherent +companions drag from the code). The visible baseline shift it produces is +correct Rebaselining per [ADR-0039](0039-override-aware-rebaselining.md). It +**corrects the stated cause of PRD #1361 Class A** (classifier taxonomy gap, not +fabric/rebaseline). From d51154fe73fd1c8ac2c232f74b2351e963644d42 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 14:43:24 +0000 Subject: [PATCH 02/36] =?UTF-8?q?Recognise=20a=20solid-fuel=20room=20heate?= =?UTF-8?q?r=20as=20its=20own=20heating=20archetype=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_main_heating_system_overlay.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index ea73c0f70..ca82a6ae1 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -383,3 +383,19 @@ def test_every_resolvable_main_heating_value_decodes( # Assert assert simulation is not None + + +def test_solid_fuel_room_heater_decodes_to_the_closed_room_heater_code() -> None: + # A landlord-named solid-fuel room heater (e.g. a closed stove) is a + # recognised archetype, not a gas wet system — it must decode to its SAP + # Table 4a code (633, closed solid-fuel room heater), not overflow into the + # nearest wrong archetype the way the under-populated taxonomy did, sending + # "solid fuel room heaters: closed room heater" to Gas CPSU (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Solid fuel room heater, closed", 0) + + # Assert — SAP Table 4a code 633 (closed solid-fuel room heater). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 633 From f0975611607ec66c8d37a91a266e992876deb8b2 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 14:44:30 +0000 Subject: [PATCH 03/36] =?UTF-8?q?Recognise=20a=20solid-fuel=20room=20heate?= =?UTF-8?q?r=20as=20its=20own=20heating=20archetype=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/epc/property_overlays/main_heating_system_overlay.py | 1 + domain/epc/property_overrides/main_heating_system_type.py | 1 + 2 files changed, 2 insertions(+) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index e763fea75..14ca6b6bf 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -109,6 +109,7 @@ _MAIN_HEATING_CODES: dict[str, int] = { "Electric storage heaters, fan": 404, "Direct-acting electric": 191, "Electric room heaters": 691, + "Solid fuel room heater, closed": 633, } diff --git a/domain/epc/property_overrides/main_heating_system_type.py b/domain/epc/property_overrides/main_heating_system_type.py index 3bfff01e5..2ea81c924 100644 --- a/domain/epc/property_overrides/main_heating_system_type.py +++ b/domain/epc/property_overrides/main_heating_system_type.py @@ -25,4 +25,5 @@ class MainHeatingSystemType(Enum): ELECTRIC_STORAGE_FAN = "Electric storage heaters, fan" DIRECT_ELECTRIC = "Direct-acting electric" ELECTRIC_ROOM_HEATERS = "Electric room heaters" + SOLID_FUEL_ROOM_HEATER_CLOSED = "Solid fuel room heater, closed" UNKNOWN = "Unknown" From d5280e9d961cedbb725faa40906986cd65801688 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 16:42:48 +0000 Subject: [PATCH 04/36] =?UTF-8?q?Drag=20a=20solid-fuel=20room=20heater's?= =?UTF-8?q?=20coherent=20room-heater=20companions=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_main_heating_system_overlay.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index ca82a6ae1..d7c500f53 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -399,3 +399,23 @@ def test_solid_fuel_room_heater_decodes_to_the_closed_room_heater_code() -> None assert simulation is not None assert simulation.heating is not None assert simulation.heating.sap_main_heating_code == 633 + + +def test_solid_fuel_room_heater_drags_its_coherent_room_heater_companions() -> None: + # The landlord names the system, not its category/control/meter. A solid-fuel + # room heater is a room-heater system (Table 4a category 10) on a single-rate + # meter (not off-peak storage), under the conservative room-heater control + # real certs lodge (Table 4e Group 6 code 2601 — "no thermostatic control", + # the lowest-SAP room-heater control, so it never over-credits an unobserved + # control, mirroring the storage manual-charge default). ADR-0035. + + # Act + simulation = main_heating_overlay_for("Solid fuel room heater, closed", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 10 + assert heating.main_heating_control == 2601 + assert heating.meter_type == "Single" From 0cd30a13e6fba754c776d98664e9e7c05cdeaf24 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 16:44:04 +0000 Subject: [PATCH 05/36] =?UTF-8?q?Drag=20a=20solid-fuel=20room=20heater's?= =?UTF-8?q?=20coherent=20room-heater=20companions=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main_heating_system_overlay.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 14ca6b6bf..a2d9bc634 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -68,6 +68,17 @@ _ASSUMED_DUAL_METER_CODES = OFF_PEAK_IMPLYING_HEATING_CODES | _ROOM_HEATER_CODES _MANUAL_CHARGE_CONTROL = 2401 _STORAGE_HEATER_CODES = frozenset(range(401, 410)) +# SAP Table 4a category 10 ("Room heaters") and its conservative Table 4e Group 6 +# control. A landlord names a room heater, not its control; code 2601 ("no +# thermostatic control of room temperature") is the lowest-SAP room-heater +# control — the modal one real solid-fuel room-heater certs lodge — so it never +# over-credits an unobserved control (the room-heater mirror of the storage +# manual-charge default). Scoped per family as archetypes land; solid-fuel room +# heaters are Table 4a 631-636. +_ROOM_HEATER_CATEGORY = 10 +_ROOM_HEATER_CONTROL = 2601 +_SOLID_FUEL_ROOM_HEATER_CODES = frozenset(range(631, 637)) + # SAP Table 4c full boiler-control code: programmer + room thermostat + TRVs. The # landlord names the boiler, not its controls — but a gas boiler installed under # modern Building Regs must carry compliant controls, and this overlay already @@ -130,6 +141,17 @@ def _control_for(code: int) -> Optional[int]: return _MANUAL_CHARGE_CONTROL if code in _GAS_BOILER_CODES: return _FULL_BOILER_CONTROL + if code in _SOLID_FUEL_ROOM_HEATER_CODES: + return _ROOM_HEATER_CONTROL + return None + + +def _category_for(code: int) -> Optional[int]: + """The SAP Table 4a heating category a code implies, where the archetype + fixes it. Room heaters are category 10 — set so a system-only override reads + as a room-heater system, not whatever category the replaced system carried.""" + if code in _SOLID_FUEL_ROOM_HEATER_CODES: + return _ROOM_HEATER_CATEGORY return None @@ -163,6 +185,7 @@ def main_heating_overlay_for( return EpcSimulation( heating=HeatingOverlay( sap_main_heating_code=code, + main_heating_category=_category_for(code), meter_type=_meter_for(code), main_heating_control=_control_for(code), # A landlord override describes the existing dwelling, so its assumed From 5a29238480908d10d7afc9c5ebc5e6daf2da84bf Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 16:45:20 +0000 Subject: [PATCH 06/36] =?UTF-8?q?Drag=20electricity=20as=20an=20electric?= =?UTF-8?q?=20room=20heater's=20natural=20fuel=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_main_heating_system_overlay.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index d7c500f53..7bd0db524 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -419,3 +419,20 @@ def test_solid_fuel_room_heater_drags_its_coherent_room_heater_companions() -> N assert heating.main_heating_category == 10 assert heating.main_heating_control == 2601 assert heating.meter_type == "Single" + + +def test_electric_room_heaters_drag_their_natural_electricity_fuel() -> None: + # A landlord names the system; an electric room heater's natural fuel is + # unambiguously electricity (RdSAP main_fuel code 29). The overlay drags it so + # a system-only override is self-coherent even on a cert that lodged a + # different fuel — a later `main_fuel` override still wins (last-wins + # composition), and a contradicting fuel is logged, not silently kept + # (ADR-0041 natural-fuel coherence). + + # Act + simulation = main_heating_overlay_for("Electric room heaters", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_fuel_type == 29 From d8acc9baa5dd7c480bf12b8d1c3676899713b576 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 16:46:18 +0000 Subject: [PATCH 07/36] =?UTF-8?q?Drag=20electricity=20as=20an=20electric?= =?UTF-8?q?=20room=20heater's=20natural=20fuel=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main_heating_system_overlay.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index a2d9bc634..bc00cf350 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -79,6 +79,16 @@ _ROOM_HEATER_CATEGORY = 10 _ROOM_HEATER_CONTROL = 2601 _SOLID_FUEL_ROOM_HEATER_CODES = frozenset(range(631, 637)) +# Natural-fuel coherence (ADR-0041): where an archetype unambiguously implies a +# fuel, the overlay drags it so a system-only override is self-coherent on a cert +# that lodged a different fuel. A later `main_fuel` override still wins (last-wins +# composition); a contradicting landlord fuel is logged, not silently overridden. +# Electric room heaters (Table 4a 691-701) are unambiguously electricity (RdSAP +# main_fuel code 29). Solid/oil room heaters are fuel-ambiguous (coal vs wood vs +# smokeless), so they drag no natural fuel and rely on the `main_fuel` override. +_ELECTRICITY_FUEL = 29 +_ELECTRIC_ROOM_HEATER_CODES = frozenset(range(691, 702)) + # SAP Table 4c full boiler-control code: programmer + room thermostat + TRVs. The # landlord names the boiler, not its controls — but a gas boiler installed under # modern Building Regs must carry compliant controls, and this overlay already @@ -155,6 +165,15 @@ def _category_for(code: int) -> Optional[int]: return None +def _natural_fuel_for(code: int) -> Optional[int]: + """The fuel an archetype unambiguously implies (a coherent default), or None + where the fuel is ambiguous and must come from the `main_fuel` override. A + present `main_fuel` override wins by last-wins composition.""" + if code in _ELECTRIC_ROOM_HEATER_CODES: + return _ELECTRICITY_FUEL + return None + + def _gas_boiler_overlay(code: int) -> HeatingOverlay: """The coherent gas-boiler companion set: a mains-gas connection + gas main fuel, the gas-boiler heating category, a fanned room-sealed flue, full modern @@ -186,6 +205,7 @@ def main_heating_overlay_for( heating=HeatingOverlay( sap_main_heating_code=code, main_heating_category=_category_for(code), + main_fuel_type=_natural_fuel_for(code), meter_type=_meter_for(code), main_heating_control=_control_for(code), # A landlord override describes the existing dwelling, so its assumed From d5927c68a9e0e7722738a551496ab1b56a0c486b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 16:46:56 +0000 Subject: [PATCH 08/36] =?UTF-8?q?docs:=20ADR-0041=20amendment=20=E2=80=94?= =?UTF-8?q?=20natural-fuel=20coherence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the natural-fuel drag-along rule the overlay now implements: drag a fuel only where the archetype is unambiguous (electric/gas), leave ambiguous solid/oil room heaters to the main_fuel override, let a present main_fuel override win by last-wins composition, and LOG (not raise) a landlord fuel that contradicts the archetype's natural fuel. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-targets-a-complete-modellable-taxonomy.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md b/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md index 2028d037d..3efcc1c9f 100644 --- a/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md +++ b/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md @@ -101,3 +101,27 @@ companions drag from the code). The visible baseline shift it produces is correct Rebaselining per [ADR-0039](0039-override-aware-rebaselining.md). It **corrects the stated cause of PRD #1361 Class A** (classifier taxonomy gap, not fabric/rebaseline). + +### Amendment: natural-fuel coherence + +A room-heater / direct-acting archetype names a *system*, not its fuel — and fuel +arrives as its own composable `main_fuel` override. Two rules keep the system +self-coherent without coupling the two overlays: + +- **The heating overlay drags a natural fuel only where the archetype is + unambiguous** — electric room heaters / direct-acting / storage are + unambiguously electricity (RdSAP `main_fuel` code 29), a gas boiler is mains + gas (26). A **solid / oil room heater is fuel-ambiguous** (coal vs wood vs + smokeless), so it drags **no** natural fuel and relies on the `main_fuel` + override; dragging a guessed coal-vs-wood code would be *more* wrong (very + different carbon). +- **A present `main_fuel` override wins** by the applicator's last-wins + composition (`apply_simulations` setattrs non-`None` fields in order), so the + `main_fuel` overlay is applied **after** the heating overlay. The agreeing + case (electric system + electricity fuel) is order-immaterial; only a + *disagreeing* one depends on the ordering. +- **A landlord fuel that contradicts the archetype's natural fuel is *logged*, + not raised** (e.g. "gas" fuel on a solid-fuel room heater) — an + override-plausibility signal (cf. the ADR-0039 scanner); what to do with it is + deferred. The override is still honoured (the landlord wins); we only record + the implausibility. From 390df5a6bdf680118272b73bde25889bda271aef Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 16:54:07 +0000 Subject: [PATCH 09/36] =?UTF-8?q?Default=20a=20solid-fuel=20room=20heater'?= =?UTF-8?q?s=20natural=20fuel=20to=20house=20coal=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_main_heating_system_overlay.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 7bd0db524..9a329a321 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -436,3 +436,19 @@ def test_electric_room_heaters_drag_their_natural_electricity_fuel() -> None: assert simulation is not None assert simulation.heating is not None assert simulation.heating.main_fuel_type == 29 + + +def test_solid_fuel_room_heater_defaults_to_house_coal_as_its_natural_fuel() -> None: + # Solid fuel is fuel-ambiguous (coal / anthracite / smokeless / dual fuel / + # wood logs / pellets), but the system must still self-cohere when no + # main_fuel override is given. Default to house coal (RdSAP main_fuel code + # 33), the most common solid fuel; a main_fuel override naming a specific + # solid fuel still wins by last-wins composition (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Solid fuel room heater, closed", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_fuel_type == 33 From 56aaace5b1b830113aecf563734c56c80dd22408 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 16:55:57 +0000 Subject: [PATCH 10/36] =?UTF-8?q?Default=20a=20solid-fuel=20room=20heater'?= =?UTF-8?q?s=20natural=20fuel=20to=20house=20coal=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solid fuel is ambiguous across coal/wood/smokeless/pellets, so the overlay defaults to house coal (33, the most common) when no main_fuel override is given; a specific solid-fuel override still wins by last-wins composition. Updates the ADR-0041 natural-fuel amendment accordingly (every archetype drags a natural fuel; solid defaults to coal). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ion-targets-a-complete-modellable-taxonomy.md | 16 +++++++++------- .../main_heating_system_overlay.py | 9 +++++++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md b/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md index 3efcc1c9f..47614fe56 100644 --- a/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md +++ b/docs/adr/0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md @@ -108,13 +108,15 @@ A room-heater / direct-acting archetype names a *system*, not its fuel — and f arrives as its own composable `main_fuel` override. Two rules keep the system self-coherent without coupling the two overlays: -- **The heating overlay drags a natural fuel only where the archetype is - unambiguous** — electric room heaters / direct-acting / storage are - unambiguously electricity (RdSAP `main_fuel` code 29), a gas boiler is mains - gas (26). A **solid / oil room heater is fuel-ambiguous** (coal vs wood vs - smokeless), so it drags **no** natural fuel and relies on the `main_fuel` - override; dragging a guessed coal-vs-wood code would be *more* wrong (very - different carbon). +- **Every archetype drags a natural fuel so the system self-coheres without a + `main_fuel` override.** Where the fuel is unambiguous it is exact — electric + room heaters / direct-acting / storage → electricity (RdSAP `main_fuel` 29), a + gas boiler → mains gas (26), an oil room heater → oil (28). **Solid fuel is + ambiguous** (coal / anthracite / smokeless / dual fuel / wood logs / pellets), + so it **defaults to house coal (33)** — the most common solid fuel — which a + specific `main_fuel` override then refines. (The `main_fuel` override's own + vocabulary must grow to carry the full solid-fuel list — a parallel taxonomy + gap, same shape as this one.) - **A present `main_fuel` override wins** by the applicator's last-wins composition (`apply_simulations` setattrs non-`None` fields in order), so the `main_fuel` overlay is applied **after** the heating overlay. The agreeing diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index bc00cf350..b08b230cf 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -84,9 +84,12 @@ _SOLID_FUEL_ROOM_HEATER_CODES = frozenset(range(631, 637)) # that lodged a different fuel. A later `main_fuel` override still wins (last-wins # composition); a contradicting landlord fuel is logged, not silently overridden. # Electric room heaters (Table 4a 691-701) are unambiguously electricity (RdSAP -# main_fuel code 29). Solid/oil room heaters are fuel-ambiguous (coal vs wood vs -# smokeless), so they drag no natural fuel and rely on the `main_fuel` override. +# main_fuel code 29). Solid fuel is ambiguous (coal / anthracite / smokeless / +# dual fuel / wood logs / pellets), so it defaults to house coal (33) — the most +# common solid fuel — when the landlord gives no `main_fuel` override; a specific +# solid-fuel override still wins by last-wins composition. _ELECTRICITY_FUEL = 29 +_HOUSE_COAL_FUEL = 33 _ELECTRIC_ROOM_HEATER_CODES = frozenset(range(691, 702)) # SAP Table 4c full boiler-control code: programmer + room thermostat + TRVs. The @@ -171,6 +174,8 @@ def _natural_fuel_for(code: int) -> Optional[int]: present `main_fuel` override wins by last-wins composition.""" if code in _ELECTRIC_ROOM_HEATER_CODES: return _ELECTRICITY_FUEL + if code in _SOLID_FUEL_ROOM_HEATER_CODES: + return _HOUSE_COAL_FUEL return None From 260e3cbd32d0648a5b0de1d7eb7240a64d40aa15 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:04:44 +0000 Subject: [PATCH 11/36] =?UTF-8?q?A=20main=5Ffuel=20override=20beats=20a=20?= =?UTF-8?q?heating=20archetype's=20natural-fuel=20default=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_landlord_override_overlays.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/repositories/property/test_landlord_override_overlays.py b/tests/repositories/property/test_landlord_override_overlays.py index 1f790aa88..99d0d3b82 100644 --- a/tests/repositories/property/test_landlord_override_overlays.py +++ b/tests/repositories/property/test_landlord_override_overlays.py @@ -7,11 +7,15 @@ that fold onto the lodged EPC — per component, partial, skipping unmapped rows from __future__ import annotations from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier +from domain.modelling.scoring.overlay_applicator import apply_simulations from repositories.property.landlord_override_overlays import overlays_from from repositories.property.property_overrides_reader import ( ResolvedPropertyOverride, ResolvedPropertyOverrides, ) +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) def test_roof_type_row_produces_a_roof_overlay() -> None: @@ -143,3 +147,27 @@ def test_unresolvable_rows_are_skipped() -> None: # Assert assert overlays == [] + + +def test_main_fuel_override_wins_over_a_heating_archetype_natural_fuel() -> None: + # A heating-system override drags a natural fuel (a solid-fuel room heater + # defaults to house coal, RdSAP main_fuel 33), but an explicit main_fuel + # override must win. apply_simulations is last-wins and the override rows + # arrive in arbitrary order, so overlays_from must apply main_fuel AFTER + # main_heating_system — otherwise the natural-fuel default clobbers the + # explicit fuel (ADR-0041). The rows here are in the order that exposes it. + baseline = build_epc() + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride("main_fuel", 0, "smokeless coal"), + ResolvedPropertyOverride( + "main_heating_system", 0, "Solid fuel room heater, closed" + ), + ) + ) + + # Act + result = apply_simulations(baseline, overlays_from(overrides)) + + # Assert — the explicit fuel (smokeless coal 15) wins over the coal default. + assert result.sap_heating.main_heating_details[0].main_fuel_type == 15 From 6276730cfe8367e58c1090587b74c91bec798f35 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:06:56 +0000 Subject: [PATCH 12/36] =?UTF-8?q?A=20main=5Ffuel=20override=20beats=20a=20?= =?UTF-8?q?heating=20archetype's=20natural-fuel=20default=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit overlays_from now applies main_fuel after main_heating_system (stable sort), so an explicit landlord fuel wins the natural-fuel default the heating archetype drags, regardless of override row order. apply_simulations is last-wins. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../property/landlord_override_overlays.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/repositories/property/landlord_override_overlays.py b/repositories/property/landlord_override_overlays.py index b65cf5d29..7477cc785 100644 --- a/repositories/property/landlord_override_overlays.py +++ b/repositories/property/landlord_override_overlays.py @@ -67,9 +67,22 @@ _COMPONENT_OVERLAYS: dict[str, Callable[[str, int], Optional[EpcSimulation]]] = } +# Components whose overlay must be applied LAST so an explicit value wins a +# default another overlay dragged. `apply_simulations` is last-wins and override +# rows arrive in arbitrary order, so a `main_fuel` override must be applied after +# the `main_heating_system` archetype, whose natural-fuel default it overrides +# (ADR-0041) — e.g. "smokeless coal" must beat a solid-fuel room heater's coal +# default. +_APPLY_LAST: frozenset[str] = frozenset({"main_fuel"}) + + def overlays_from(overrides: ResolvedPropertyOverrides) -> list[EpcSimulation]: overlays: list[EpcSimulation] = [] - for row in overrides.rows: + # Stable sort: non-`_APPLY_LAST` rows keep their order, `main_fuel` goes last. + ordered_rows = sorted( + overrides.rows, key=lambda row: row.override_component in _APPLY_LAST + ) + for row in ordered_rows: mapper = _COMPONENT_OVERLAYS.get(row.override_component) if mapper is None: continue From 648013e05c8f32fa8e53e0185874e95f42b4c539 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:15:16 +0000 Subject: [PATCH 13/36] =?UTF-8?q?Log=20a=20landlord=20fuel=20that=20contra?= =?UTF-8?q?dicts=20the=20heating=20archetype's=20natural=20fuel=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_landlord_override_overlays.py | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/repositories/property/test_landlord_override_overlays.py b/tests/repositories/property/test_landlord_override_overlays.py index 99d0d3b82..385d2f355 100644 --- a/tests/repositories/property/test_landlord_override_overlays.py +++ b/tests/repositories/property/test_landlord_override_overlays.py @@ -6,9 +6,16 @@ that fold onto the lodged EPC — per component, partial, skipping unmapped rows from __future__ import annotations +import logging + +import pytest + from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier from domain.modelling.scoring.overlay_applicator import apply_simulations -from repositories.property.landlord_override_overlays import overlays_from +from repositories.property.landlord_override_overlays import ( + flag_fuel_mismatch, + overlays_from, +) from repositories.property.property_overrides_reader import ( ResolvedPropertyOverride, ResolvedPropertyOverrides, @@ -171,3 +178,26 @@ def test_main_fuel_override_wins_over_a_heating_archetype_natural_fuel() -> None # Assert — the explicit fuel (smokeless coal 15) wins over the coal default. assert result.sap_heating.main_heating_details[0].main_fuel_type == 15 + + +def test_a_fuel_override_contradicting_the_heating_archetype_is_logged( + caplog: pytest.LogCaptureFixture, +) -> None: + # An electric room heater is unambiguously electricity; a "mains gas" main_fuel + # override contradicts it (a different fuel family). The override still wins + # (ADR-0041), but the implausibility is logged for later review — not raised. + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride( + "main_heating_system", 0, "Electric room heaters" + ), + ResolvedPropertyOverride("main_fuel", 0, "mains gas"), + ) + ) + + # Act + with caplog.at_level(logging.WARNING): + flag_fuel_mismatch(overrides) + + # Assert + assert any("fuel" in r.message.lower() for r in caplog.records) From 79fe54a82289d5b4c2a374a41decf59d79a1b83b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:16:14 +0000 Subject: [PATCH 14/36] =?UTF-8?q?Log=20a=20landlord=20fuel=20that=20contra?= =?UTF-8?q?dicts=20the=20heating=20archetype's=20natural=20fuel=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main_heating_system_overlay.py | 10 +++++ .../property/landlord_override_overlays.py | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index b08b230cf..636fa6191 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -198,6 +198,16 @@ def _gas_boiler_overlay(code: int) -> HeatingOverlay: ) +def natural_fuel_for(main_heating_value: str) -> Optional[int]: + """The RdSAP `main_fuel` code a heating archetype unambiguously implies — its + natural fuel (ADR-0041) — or None for an unmapped archetype. Exposed so a + plausibility check can compare it against a landlord `main_fuel` override.""" + code = _MAIN_HEATING_CODES.get(main_heating_value) + if code is None: + return None + return _natural_fuel_for(code) + + def main_heating_overlay_for( main_heating_value: str, building_part: int ) -> Optional[EpcSimulation]: diff --git a/repositories/property/landlord_override_overlays.py b/repositories/property/landlord_override_overlays.py index 7477cc785..5f47a3b39 100644 --- a/repositories/property/landlord_override_overlays.py +++ b/repositories/property/landlord_override_overlays.py @@ -28,6 +28,7 @@ values are live vs no-op before any write. from __future__ import annotations +import logging from typing import Callable, Optional from domain.epc.property_overlays.attribute_overlay import ( @@ -41,6 +42,7 @@ from domain.epc.property_overlays.glazing_overlay import glazing_overlay_for from domain.epc.property_overlays.main_fuel_overlay import fuel_overlay_for from domain.epc.property_overlays.main_heating_system_overlay import ( main_heating_overlay_for, + natural_fuel_for, ) from domain.epc.property_overlays.water_heating_overlay import ( water_heating_overlay_for, @@ -50,6 +52,8 @@ from domain.epc.property_overlays.wall_type_overlay import wall_overlay_for from domain.modelling.simulation import EpcSimulation from repositories.property.property_overrides_reader import ResolvedPropertyOverrides +logger = logging.getLogger(__name__) + # Each override component maps its value (+ building part) to an overlay, or None # when the value isn't resolvable. Fabric (wall/roof) folds onto building parts; # property_type / built_form_type are whole-dwelling categorical corrections @@ -90,3 +94,37 @@ def overlays_from(overrides: ResolvedPropertyOverrides) -> list[EpcSimulation]: if overlay is not None: overlays.append(overlay) return overlays + + +def _override_value(overrides: ResolvedPropertyOverrides, component: str) -> Optional[str]: + for row in overrides.rows: + if row.override_component == component: + return row.override_value + return None + + +def flag_fuel_mismatch(overrides: ResolvedPropertyOverrides) -> None: + """Log (not raise) when a landlord `main_fuel` override contradicts the + `main_heating_system` archetype's natural fuel — a plausibility signal (e.g. + a gas fuel on an electric room heater). The override is still honoured (it + wins, ADR-0041); we only record the implausibility. Deferred handling.""" + heating_value = _override_value(overrides, "main_heating_system") + fuel_value = _override_value(overrides, "main_fuel") + if heating_value is None or fuel_value is None: + return + natural = natural_fuel_for(heating_value) + fuel_overlay = fuel_overlay_for(fuel_value, 0) + override_fuel = ( + fuel_overlay.heating.main_fuel_type + if fuel_overlay is not None and fuel_overlay.heating is not None + else None + ) + if natural is None or override_fuel is None: + return + if natural != override_fuel: + logger.warning( + "Landlord main_fuel %r contradicts the natural fuel of heating " + "system %r (override is honoured; flagged for review)", + fuel_value, + heating_value, + ) From 27f6b8e9ae467cee49f48a6175b0fc78cac12a57 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:17:04 +0000 Subject: [PATCH 15/36] =?UTF-8?q?Treat=20a=20same-family=20fuel=20refineme?= =?UTF-8?q?nt=20as=20consistent,=20not=20a=20mismatch=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_landlord_override_overlays.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/repositories/property/test_landlord_override_overlays.py b/tests/repositories/property/test_landlord_override_overlays.py index 385d2f355..e35034ebc 100644 --- a/tests/repositories/property/test_landlord_override_overlays.py +++ b/tests/repositories/property/test_landlord_override_overlays.py @@ -201,3 +201,27 @@ def test_a_fuel_override_contradicting_the_heating_archetype_is_logged( # Assert assert any("fuel" in r.message.lower() for r in caplog.records) + + +def test_a_same_family_fuel_refinement_is_not_logged( + caplog: pytest.LogCaptureFixture, +) -> None: + # A solid-fuel room heater's natural fuel (house coal 33) is a *default* for + # the ambiguous solid family. A "smokeless coal" override refines it within + # the same family — that is expected, not a contradiction, so it must NOT log. + # Only a different fuel family (gas/electric on a solid heater) is flagged. + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride( + "main_heating_system", 0, "Solid fuel room heater, closed" + ), + ResolvedPropertyOverride("main_fuel", 0, "smokeless coal"), + ) + ) + + # Act + with caplog.at_level(logging.WARNING): + flag_fuel_mismatch(overrides) + + # Assert + assert caplog.records == [] From 13a6a213fb318b1bbbbddd34f20c3050c37d3945 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:18:51 +0000 Subject: [PATCH 16/36] =?UTF-8?q?Treat=20a=20same-family=20fuel=20refineme?= =?UTF-8?q?nt=20as=20consistent,=20not=20a=20mismatch=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare fuel families (not exact codes), so a solid-fuel room heater refined to smokeless/dual/biomass is consistent; only a different family (gas/electric on a solid heater) is logged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../property/landlord_override_overlays.py | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/repositories/property/landlord_override_overlays.py b/repositories/property/landlord_override_overlays.py index 5f47a3b39..29ebcea00 100644 --- a/repositories/property/landlord_override_overlays.py +++ b/repositories/property/landlord_override_overlays.py @@ -96,6 +96,20 @@ def overlays_from(overrides: ResolvedPropertyOverrides) -> list[EpcSimulation]: return overlays +# Coarse fuel family per RdSAP `main_fuel` code (main_fuel_overlay._FUEL_CODES), +# for the plausibility check. The natural fuel a solid-fuel archetype drags +# (house coal) is a *default* across the ambiguous solid family, so a same-family +# override (smokeless / dual fuel / biomass) is a refinement, not a contradiction +# — only a different family (gas/electric on a solid heater) is flagged. +_FUEL_FAMILY: dict[int, str] = { + 26: "gas", 20: "gas", + 27: "lpg", 3: "lpg", 17: "lpg", + 28: "oil", + 29: "electricity", 25: "electricity", + 33: "solid", 15: "solid", 10: "solid", 31: "solid", +} + + def _override_value(overrides: ResolvedPropertyOverrides, component: str) -> Optional[str]: for row in overrides.rows: if row.override_component == component: @@ -119,9 +133,13 @@ def flag_fuel_mismatch(overrides: ResolvedPropertyOverrides) -> None: if fuel_overlay is not None and fuel_overlay.heating is not None else None ) - if natural is None or override_fuel is None: + if natural is None or not isinstance(override_fuel, int): return - if natural != override_fuel: + natural_family = _FUEL_FAMILY.get(natural) + override_family = _FUEL_FAMILY.get(override_fuel) + if natural_family is None or override_family is None: + return + if natural_family != override_family: logger.warning( "Landlord main_fuel %r contradicts the natural fuel of heating " "system %r (override is honoured; flagged for review)", From dbcd2e38401b46a15e1fdd9e5dc46a3353440663 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:21:32 +0000 Subject: [PATCH 17/36] =?UTF-8?q?Run=20the=20fuel-mismatch=20check=20on=20?= =?UTF-8?q?every=20Property's=20resolved=20overrides=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire flag_fuel_mismatch into the two override-resolution paths (the property repository and the modelling-e2e handler), keeping overlays_from a pure mapping. Co-Authored-By: Claude Opus 4.8 (1M context) --- applications/modelling_e2e/handler.py | 9 +++++++-- repositories/property/property_postgres_repository.py | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/applications/modelling_e2e/handler.py b/applications/modelling_e2e/handler.py index 916c2c247..b4848f694 100644 --- a/applications/modelling_e2e/handler.py +++ b/applications/modelling_e2e/handler.py @@ -108,7 +108,10 @@ from repositories.product.composite_product_repository import ( from repositories.property.in_memory_property_overrides_reader import ( InMemoryPropertyOverridesReader, ) -from repositories.property.landlord_override_overlays import overlays_from +from repositories.property.landlord_override_overlays import ( + flag_fuel_mismatch, + overlays_from, +) from repositories.property.override_backed_prediction_attributes_reader import ( OverrideBackedPredictionAttributesReader, ) @@ -561,7 +564,9 @@ def handler( epc = ( None # no stored lodged EPC; prediction path handles this property ) - overrides = overlays_from(overrides_reader.overrides_for(pid)) + resolved_overrides = overrides_reader.overrides_for(pid) + flag_fuel_mismatch(resolved_overrides) + overrides = overlays_from(resolved_overrides) predicted_epc: Optional[EpcPropertyData] = None predicted_epc_is_new = False diff --git a/repositories/property/property_postgres_repository.py b/repositories/property/property_postgres_repository.py index dec92814e..1072c18d8 100644 --- a/repositories/property/property_postgres_repository.py +++ b/repositories/property/property_postgres_repository.py @@ -14,7 +14,10 @@ from domain.property.properties import Properties from domain.property.property import Property, PropertyIdentity from infrastructure.postgres.property_table import PropertyRow from repositories.epc.epc_repository import EpcRepository -from repositories.property.landlord_override_overlays import overlays_from +from repositories.property.landlord_override_overlays import ( + flag_fuel_mismatch, + overlays_from, +) from repositories.property.property_overrides_reader import PropertyOverridesReader from repositories.property.property_repository import ( PropertyIdentityInsert, @@ -63,7 +66,9 @@ class PropertyPostgresRepository(PropertyRepository): no reader is wired (the overlay stays off) or the Property has none.""" if self._overrides_reader is None: return [] - return overlays_from(self._overrides_reader.overrides_for(property_id)) + overrides = self._overrides_reader.overrides_for(property_id) + flag_fuel_mismatch(overrides) + return overlays_from(overrides) def get(self, property_id: int) -> Property: row = self._session.get(PropertyRow, property_id) From 646bff38a6129420725871a17f2a1bb831b561a0 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:28:48 +0000 Subject: [PATCH 18/36] =?UTF-8?q?Recognise=20an=20air=20source=20heat=20pu?= =?UTF-8?q?mp=20via=20the=20default=20ASHP=20code=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_main_heating_system_overlay.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 9a329a321..41cc484a1 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -452,3 +452,18 @@ def test_solid_fuel_room_heater_defaults_to_house_coal_as_its_natural_fuel() -> assert simulation is not None assert simulation.heating is not None assert simulation.heating.main_fuel_type == 33 + + +def test_air_source_heat_pump_decodes_to_the_default_ashp_code() -> None: + # A landlord names "air source heat pump" without a PCDB model. It is + # modellable via the SAP Table 4a default ASHP code (211, "flow temp in other + # cases", default SPF 2.30) — no main_heating_index_number needed — so it must + # decode to 211, not produce no overlay / fall to Unknown (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Air source heat pump", 0) + + # Assert — SAP Table 4a code 211 (default air-source heat pump). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 211 From 1552e24fa5339a78bceee914b68cd928226adb17 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:30:00 +0000 Subject: [PATCH 19/36] =?UTF-8?q?Recognise=20an=20air=20source=20heat=20pu?= =?UTF-8?q?mp=20via=20the=20default=20ASHP=20code=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/property_overlays/main_heating_system_overlay.py | 4 ++++ domain/epc/property_overrides/main_heating_system_type.py | 1 + tests/domain/epc/test_main_heating_system_overlay.py | 7 ++++--- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 636fa6191..f02839105 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -134,6 +134,10 @@ _MAIN_HEATING_CODES: dict[str, int] = { "Direct-acting electric": 191, "Electric room heaters": 691, "Solid fuel room heater, closed": 633, + # Default air-source heat pump — SAP Table 4a 211 ("flow temp in other + # cases", default SPF 2.30). Modellable without a PCDB index; an actual + # product index would refine it (ADR-0041). + "Air source heat pump": 211, } diff --git a/domain/epc/property_overrides/main_heating_system_type.py b/domain/epc/property_overrides/main_heating_system_type.py index 2ea81c924..91e3b6f24 100644 --- a/domain/epc/property_overrides/main_heating_system_type.py +++ b/domain/epc/property_overrides/main_heating_system_type.py @@ -26,4 +26,5 @@ class MainHeatingSystemType(Enum): DIRECT_ELECTRIC = "Direct-acting electric" ELECTRIC_ROOM_HEATERS = "Electric room heaters" SOLID_FUEL_ROOM_HEATER_CLOSED = "Solid fuel room heater, closed" + AIR_SOURCE_HEAT_PUMP = "Air source heat pump" UNKNOWN = "Unknown" diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 41cc484a1..4726d6866 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -245,13 +245,14 @@ def test_off_peak_archetypes_drag_dual_others_drag_single() -> None: @pytest.mark.parametrize( "main_heating_value", - ["Unknown", "", "Air source heat pump", "Community heating"], + ["Unknown", "", "Community heating"], ) def test_unresolvable_or_unmodelled_heating_produces_no_overlay( main_heating_value: str, ) -> None: - # Heat pumps (main_heating_index_number) and community heating (community - # codes) don't map to a Table 4b sap_main_heating_code yet — no overlay. + # Genuinely unrecognised values (Unknown / empty) and not-yet-modelled + # community heating produce no overlay — the lodged EPC heating stands + # (ADR-0041). Air source heat pumps ARE now modelled (Table 4a 211). # Act simulation = main_heating_overlay_for(main_heating_value, 0) From 848252e5d4a5060d086fa7658c8ee488dbbda6ea Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:30:38 +0000 Subject: [PATCH 20/36] =?UTF-8?q?Drag=20an=20air=20source=20heat=20pump's?= =?UTF-8?q?=20coherent=20companions=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_main_heating_system_overlay.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 4726d6866..0658bbf65 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -468,3 +468,22 @@ def test_air_source_heat_pump_decodes_to_the_default_ashp_code() -> None: assert simulation is not None assert simulation.heating is not None assert simulation.heating.sap_main_heating_code == 211 + + +def test_air_source_heat_pump_drags_its_coherent_companions() -> None: + # A heat pump is unambiguously electric (natural fuel 29) and SAP Table 4a + # category 4, billed on an off-peak (Dual) meter (§12 Rule 3 lists heat pumps + # 211-224). The overlay drags these code-derived companions so a system-only + # override is self-coherent on a cert that lodged a different system + # (ADR-0035 / ADR-0041). + + # Act + simulation = main_heating_overlay_for("Air source heat pump", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 4 + assert heating.main_fuel_type == 29 + assert heating.meter_type == "Dual" From 0c12f70589bb5e34141b4d9c8152326e0de56d76 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:33:05 +0000 Subject: [PATCH 21/36] =?UTF-8?q?Drag=20an=20air=20source=20heat=20pump's?= =?UTF-8?q?=20coherent=20companions=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heat pump drags category 4 + electricity (29). Meter stays single-rate: a heat pump does not imply an off-peak tariff the way storage/CPSU do (SAP §12 Rule 3 is conditional), so forcing Dual would mis-bill it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../property_overlays/main_heating_system_overlay.py | 10 +++++++++- tests/domain/epc/test_main_heating_system_overlay.py | 11 ++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index f02839105..0ca83c206 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -92,6 +92,12 @@ _ELECTRICITY_FUEL = 29 _HOUSE_COAL_FUEL = 33 _ELECTRIC_ROOM_HEATER_CODES = frozenset(range(691, 702)) +# 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 +# without a PCDB index (ADR-0041). +_HEAT_PUMP_CATEGORY = 4 +_HEAT_PUMP_CODES = frozenset(range(211, 225)) | frozenset(range(521, 528)) + # SAP Table 4c full boiler-control code: programmer + room thermostat + TRVs. The # landlord names the boiler, not its controls — but a gas boiler installed under # modern Building Regs must carry compliant controls, and this overlay already @@ -169,6 +175,8 @@ def _category_for(code: int) -> Optional[int]: as a room-heater system, not whatever category the replaced system carried.""" if code in _SOLID_FUEL_ROOM_HEATER_CODES: return _ROOM_HEATER_CATEGORY + if code in _HEAT_PUMP_CODES: + return _HEAT_PUMP_CATEGORY return None @@ -176,7 +184,7 @@ def _natural_fuel_for(code: int) -> Optional[int]: """The fuel an archetype unambiguously implies (a coherent default), or None where the fuel is ambiguous and must come from the `main_fuel` override. A present `main_fuel` override wins by last-wins composition.""" - if code in _ELECTRIC_ROOM_HEATER_CODES: + if code in _ELECTRIC_ROOM_HEATER_CODES or code in _HEAT_PUMP_CODES: return _ELECTRICITY_FUEL if code in _SOLID_FUEL_ROOM_HEATER_CODES: return _HOUSE_COAL_FUEL diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 0658bbf65..458311723 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -472,10 +472,11 @@ def test_air_source_heat_pump_decodes_to_the_default_ashp_code() -> None: def test_air_source_heat_pump_drags_its_coherent_companions() -> None: # A heat pump is unambiguously electric (natural fuel 29) and SAP Table 4a - # category 4, billed on an off-peak (Dual) meter (§12 Rule 3 lists heat pumps - # 211-224). The overlay drags these code-derived companions so a system-only - # override is self-coherent on a cert that lodged a different system - # (ADR-0035 / ADR-0041). + # category 4. Unlike storage/CPSU it does NOT imply an off-peak tariff (SAP + # §12 Rule 3 is conditional, not forcing), so the coherent default meter is + # single-rate — forcing Dual would assume an off-peak split it may not have + # and mis-bill it. The overlay drags these code-derived companions so a + # system-only override is self-coherent (ADR-0035 / ADR-0041). # Act simulation = main_heating_overlay_for("Air source heat pump", 0) @@ -486,4 +487,4 @@ def test_air_source_heat_pump_drags_its_coherent_companions() -> None: heating = simulation.heating assert heating.main_heating_category == 4 assert heating.main_fuel_type == 29 - assert heating.meter_type == "Dual" + assert heating.meter_type == "Single" From c7b2fe7a1b1640ed743131d06b28c3e21fbe4cf3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:36:41 +0000 Subject: [PATCH 22/36] =?UTF-8?q?Recognise=20community=20boilers=20as=20a?= =?UTF-8?q?=20heat-network=20system=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_main_heating_system_overlay.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 458311723..7e322181e 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -488,3 +488,19 @@ def test_air_source_heat_pump_drags_its_coherent_companions() -> None: assert heating.main_heating_category == 4 assert heating.main_fuel_type == 29 assert heating.meter_type == "Single" + + +def test_community_boilers_decode_to_the_heat_network_boiler_code() -> None: + # A community/heat-network scheme driven by boilers is SAP Table 4a code 301 + # (the calculator derives the heat-source efficiency + DLF from it). It must + # decode to 301, not the Gas CPSU (120) the under-populated taxonomy forced — + # a single-dwelling gas wet boiler is the wrong picture for a heat network + # (ADR-0041). A generic "Community heating" with no named source stays None. + + # Act + simulation = main_heating_overlay_for("Community heating, boilers", 0) + + # Assert — SAP Table 4a code 301 (boiler-driven community heating). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 301 From 46f4b2c3a3b7527953ee4a821e29a7d9d5f96dab Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:37:34 +0000 Subject: [PATCH 23/36] =?UTF-8?q?Recognise=20community=20boilers=20as=20a?= =?UTF-8?q?=20heat-network=20system=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/epc/property_overlays/main_heating_system_overlay.py | 5 +++++ domain/epc/property_overrides/main_heating_system_type.py | 1 + 2 files changed, 6 insertions(+) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 0ca83c206..b8ea16c0d 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -144,6 +144,11 @@ _MAIN_HEATING_CODES: dict[str, int] = { # cases", default SPF 2.30). Modellable without a PCDB index; an actual # product index would refine it (ADR-0041). "Air source heat pump": 211, + # Boiler-driven community / heat-network heating — SAP Table 4a 301. The + # calculator derives the heat-source efficiency (80%) + DLF (1.50 default) + # from the code (cert_to_inputs). A generic "Community heating" with no named + # source stays unmapped (-> None) — the source can't be assumed (ADR-0041). + "Community heating, boilers": 301, } diff --git a/domain/epc/property_overrides/main_heating_system_type.py b/domain/epc/property_overrides/main_heating_system_type.py index 91e3b6f24..eda1bef7a 100644 --- a/domain/epc/property_overrides/main_heating_system_type.py +++ b/domain/epc/property_overrides/main_heating_system_type.py @@ -27,4 +27,5 @@ class MainHeatingSystemType(Enum): ELECTRIC_ROOM_HEATERS = "Electric room heaters" SOLID_FUEL_ROOM_HEATER_CLOSED = "Solid fuel room heater, closed" AIR_SOURCE_HEAT_PUMP = "Air source heat pump" + COMMUNITY_BOILERS = "Community heating, boilers" UNKNOWN = "Unknown" From 098427e620610ce7652a5b35735be244a82f197b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:38:08 +0000 Subject: [PATCH 24/36] =?UTF-8?q?Drag=20heat-network=20category=20and=20a?= =?UTF-8?q?=20community=20gas=20fuel=20for=20community=20boilers=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_main_heating_system_overlay.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 7e322181e..3e2fe9317 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -504,3 +504,21 @@ def test_community_boilers_decode_to_the_heat_network_boiler_code() -> None: assert simulation is not None assert simulation.heating is not None assert simulation.heating.sap_main_heating_code == 301 + + +def test_community_boilers_drag_heat_network_category_and_a_community_gas_fuel() -> None: + # A community boiler scheme is SAP main_heating_category 6 (heat network), so + # the calculator treats it as a heat network (cert_to_inputs `_is_heat_network` + # checks code OR category 6). Its natural fuel is mains gas (community) — RdSAP + # main_fuel code 20, the dominant fuel real community-boiler certs lodge. A + # specific main_fuel override (e.g. biomass community) still wins (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Community heating, boilers", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 6 + assert heating.main_fuel_type == 20 From 1dc94e7cdae92e40103f095ca71f1c413bac2d63 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:39:42 +0000 Subject: [PATCH 25/36] =?UTF-8?q?Drag=20heat-network=20category=20and=20a?= =?UTF-8?q?=20community=20gas=20fuel=20for=20community=20boilers=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main_heating_system_overlay.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index b8ea16c0d..957ce94da 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -98,6 +98,16 @@ _ELECTRIC_ROOM_HEATER_CODES = frozenset(range(691, 702)) _HEAT_PUMP_CATEGORY = 4 _HEAT_PUMP_CODES = frozenset(range(211, 225)) | frozenset(range(521, 528)) +# Community / heat-network heating (SAP Table 4a 301-304) is category 6; the +# calculator's `_is_heat_network` keys off code OR category 6. The boiler-driven +# schemes (301/302/303) are dominantly mains gas (community) — RdSAP main_fuel 20 +# — per real community certs; the heat-pump scheme (304) is electricity +# (community), added with its archetype (ADR-0041). +_HEAT_NETWORK_CATEGORY = 6 +_HEAT_NETWORK_CODES = frozenset({301, 302, 303, 304}) +_COMMUNITY_BOILER_CODES = frozenset({301, 302, 303}) +_COMMUNITY_GAS_FUEL = 20 + # SAP Table 4c full boiler-control code: programmer + room thermostat + TRVs. The # landlord names the boiler, not its controls — but a gas boiler installed under # modern Building Regs must carry compliant controls, and this overlay already @@ -182,6 +192,8 @@ def _category_for(code: int) -> Optional[int]: return _ROOM_HEATER_CATEGORY if code in _HEAT_PUMP_CODES: return _HEAT_PUMP_CATEGORY + if code in _HEAT_NETWORK_CODES: + return _HEAT_NETWORK_CATEGORY return None @@ -193,6 +205,8 @@ def _natural_fuel_for(code: int) -> Optional[int]: return _ELECTRICITY_FUEL if code in _SOLID_FUEL_ROOM_HEATER_CODES: return _HOUSE_COAL_FUEL + if code in _COMMUNITY_BOILER_CODES: + return _COMMUNITY_GAS_FUEL return None From fe97a7fc1cfab245294ab5ec356e3c73e080a334 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:53:54 +0000 Subject: [PATCH 26/36] =?UTF-8?q?Recognise=20a=20modern=20oil=20room=20hea?= =?UTF-8?q?ter=20as=20its=20own=20archetype=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../domain/epc/test_main_heating_system_overlay.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 3e2fe9317..aab952793 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -522,3 +522,17 @@ def test_community_boilers_drag_heat_network_category_and_a_community_gas_fuel() heating = simulation.heating assert heating.main_heating_category == 6 assert heating.main_fuel_type == 20 + + +def test_oil_room_heater_decodes_to_the_post_2000_code() -> None: + # A modern standalone oil room heater is SAP Table 4a code 623 ("Oil room + # heater, 2000 or later", no boiler). It must decode to 623, not the Gas CPSU + # the under-populated taxonomy forced (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Oil room heater, 2000 or later", 0) + + # Assert — SAP Table 4a code 623. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == 623 From efa3a588df101e5f3b6b2f637b076080c95b4a3c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:54:38 +0000 Subject: [PATCH 27/36] =?UTF-8?q?Recognise=20a=20modern=20oil=20room=20hea?= =?UTF-8?q?ter=20as=20its=20own=20archetype=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/epc/property_overlays/main_heating_system_overlay.py | 3 +++ domain/epc/property_overrides/main_heating_system_type.py | 1 + 2 files changed, 4 insertions(+) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 957ce94da..5fb199407 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -159,6 +159,9 @@ _MAIN_HEATING_CODES: dict[str, int] = { # from the code (cert_to_inputs). A generic "Community heating" with no named # source stays unmapped (-> None) — the source can't be assumed (ADR-0041). "Community heating, boilers": 301, + # Modern standalone oil room heater — SAP Table 4a 623 ("Oil room heater, + # 2000 or later", no boiler). Natural fuel heating oil (ADR-0041). + "Oil room heater, 2000 or later": 623, } diff --git a/domain/epc/property_overrides/main_heating_system_type.py b/domain/epc/property_overrides/main_heating_system_type.py index eda1bef7a..c15afd602 100644 --- a/domain/epc/property_overrides/main_heating_system_type.py +++ b/domain/epc/property_overrides/main_heating_system_type.py @@ -28,4 +28,5 @@ class MainHeatingSystemType(Enum): SOLID_FUEL_ROOM_HEATER_CLOSED = "Solid fuel room heater, closed" AIR_SOURCE_HEAT_PUMP = "Air source heat pump" COMMUNITY_BOILERS = "Community heating, boilers" + OIL_ROOM_HEATER_POST_2000 = "Oil room heater, 2000 or later" UNKNOWN = "Unknown" From 34302adce5ce63de0a9beef2d33d19a388d726a5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:55:07 +0000 Subject: [PATCH 28/36] =?UTF-8?q?Drag=20an=20oil=20room=20heater's=20coher?= =?UTF-8?q?ent=20companions=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_main_heating_system_overlay.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index aab952793..804183a3b 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -536,3 +536,22 @@ def test_oil_room_heater_decodes_to_the_post_2000_code() -> None: assert simulation is not None assert simulation.heating is not None assert simulation.heating.sap_main_heating_code == 623 + + +def test_oil_room_heater_drags_its_coherent_room_heater_companions() -> None: + # An oil room heater is a room-heater system (category 10) on a single-rate + # meter, under the conservative room-heater control (2601), with its natural + # fuel heating oil (RdSAP main_fuel 28). The overlay drags these so a + # system-only override is self-coherent (ADR-0035 / ADR-0041). + + # Act + simulation = main_heating_overlay_for("Oil room heater, 2000 or later", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 10 + assert heating.main_heating_control == 2601 + assert heating.main_fuel_type == 28 + assert heating.meter_type == "Single" From 1d0633f4a5dfe7e07f5f2d75250dd3248a5f4631 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 17:57:04 +0000 Subject: [PATCH 29/36] =?UTF-8?q?Drag=20an=20oil=20room=20heater's=20coher?= =?UTF-8?q?ent=20companions=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalise the room-heater category-10 + conservative-control drag to cover both solid and oil families; oil's natural fuel is heating oil (28). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main_heating_system_overlay.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 5fb199407..f9b004cfc 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -78,6 +78,14 @@ _STORAGE_HEATER_CODES = frozenset(range(401, 410)) _ROOM_HEATER_CATEGORY = 10 _ROOM_HEATER_CONTROL = 2601 _SOLID_FUEL_ROOM_HEATER_CODES = frozenset(range(631, 637)) +# Oil room heaters (SAP Table 4a 621-625) — category 10, conservative room-heater +# control, natural fuel heating oil (RdSAP main_fuel 28). +_OIL_ROOM_HEATER_CODES = frozenset(range(621, 626)) +_OIL_FUEL = 28 +# Fuel-burning room heaters (solid + oil) share category 10 + the conservative +# room-heater control; only the natural fuel differs by family. (Distinct from +# `_ROOM_HEATER_CODES` above, which is the electric-room-heater dual-meter set.) +_CATEGORY_10_ROOM_HEATER_CODES = _SOLID_FUEL_ROOM_HEATER_CODES | _OIL_ROOM_HEATER_CODES # Natural-fuel coherence (ADR-0041): where an archetype unambiguously implies a # fuel, the overlay drags it so a system-only override is self-coherent on a cert @@ -182,7 +190,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 _SOLID_FUEL_ROOM_HEATER_CODES: + if code in _CATEGORY_10_ROOM_HEATER_CODES: return _ROOM_HEATER_CONTROL return None @@ -191,7 +199,7 @@ def _category_for(code: int) -> Optional[int]: """The SAP Table 4a heating category a code implies, where the archetype fixes it. Room heaters are category 10 — set so a system-only override reads as a room-heater system, not whatever category the replaced system carried.""" - if code in _SOLID_FUEL_ROOM_HEATER_CODES: + if code in _CATEGORY_10_ROOM_HEATER_CODES: return _ROOM_HEATER_CATEGORY if code in _HEAT_PUMP_CODES: return _HEAT_PUMP_CATEGORY @@ -208,6 +216,8 @@ def _natural_fuel_for(code: int) -> Optional[int]: return _ELECTRICITY_FUEL if code in _SOLID_FUEL_ROOM_HEATER_CODES: return _HOUSE_COAL_FUEL + if code in _OIL_ROOM_HEATER_CODES: + return _OIL_FUEL if code in _COMMUNITY_BOILER_CODES: return _COMMUNITY_GAS_FUEL return None From 60e27c80a1757d3bf117d23087994adabd246622 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 18:03:07 +0000 Subject: [PATCH 30/36] =?UTF-8?q?Recognise=20gas=20room-heater=20variants?= =?UTF-8?q?=20by=20their=20SAP=20codes=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_main_heating_system_overlay.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 804183a3b..d8431e7ef 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -555,3 +555,31 @@ def test_oil_room_heater_drags_its_coherent_room_heater_companions() -> None: assert heating.main_heating_control == 2601 assert heating.main_fuel_type == 28 assert heating.meter_type == "Single" + + +@pytest.mark.parametrize( + ("value", "code"), + [ + ("Gas room heater, condensing fire", 611), + ("Gas room heater, decorative fuel-effect", 612), + ("Gas room heater, flush live-effect", 605), + ("Gas room heater, open flue 1980 or later", 603), + ("Gas room heater, open flue pre-1980", 601), + ], +) +def test_gas_room_heater_variants_decode_to_their_sap_codes( + value: str, code: int +) -> None: + # Gas room heaters span a wide efficiency range (decorative 0.20 -> condensing + # 0.85), so each catalogue variant maps to its own SAP Table 4a code rather + # than collapsing to one representative — which would over-credit a decorative + # fire and under-credit a condensing one. Today they mis-bucket to "Gas + # boiler, regular" (ADR-0041). + + # Act + simulation = main_heating_overlay_for(value, 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.sap_main_heating_code == code From 42e9dafd0bad1d6acb4c43b17549859364ffb75e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 18:03:57 +0000 Subject: [PATCH 31/36] =?UTF-8?q?Recognise=20gas=20room-heater=20variants?= =?UTF-8?q?=20by=20their=20SAP=20codes=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/property_overlays/main_heating_system_overlay.py | 8 ++++++++ domain/epc/property_overrides/main_heating_system_type.py | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index f9b004cfc..aee3d3b40 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -170,6 +170,14 @@ _MAIN_HEATING_CODES: dict[str, int] = { # Modern standalone oil room heater — SAP Table 4a 623 ("Oil room heater, # 2000 or later", no boiler). Natural fuel heating oil (ADR-0041). "Oil room heater, 2000 or later": 623, + # Gas room heaters — each catalogue variant to its own SAP Table 4a code + # (wide efficiency spread, so no single representative). Natural fuel mains + # gas; an LPG dwelling is refined by a `main_fuel` override (ADR-0041). + "Gas room heater, condensing fire": 611, + "Gas room heater, decorative fuel-effect": 612, + "Gas room heater, flush live-effect": 605, + "Gas room heater, open flue 1980 or later": 603, + "Gas room heater, open flue pre-1980": 601, } diff --git a/domain/epc/property_overrides/main_heating_system_type.py b/domain/epc/property_overrides/main_heating_system_type.py index c15afd602..eb4658b84 100644 --- a/domain/epc/property_overrides/main_heating_system_type.py +++ b/domain/epc/property_overrides/main_heating_system_type.py @@ -29,4 +29,9 @@ class MainHeatingSystemType(Enum): AIR_SOURCE_HEAT_PUMP = "Air source heat pump" COMMUNITY_BOILERS = "Community heating, boilers" OIL_ROOM_HEATER_POST_2000 = "Oil room heater, 2000 or later" + GAS_FIRE_CONDENSING = "Gas room heater, condensing fire" + GAS_FIRE_DECORATIVE = "Gas room heater, decorative fuel-effect" + GAS_FIRE_FLUSH_LIVE_EFFECT = "Gas room heater, flush live-effect" + GAS_FIRE_OPEN_FLUE_POST_1980 = "Gas room heater, open flue 1980 or later" + GAS_FIRE_OPEN_FLUE_PRE_1980 = "Gas room heater, open flue pre-1980" UNKNOWN = "Unknown" From c2a842e9b9debbf5df4b97309c7e9bb6cfcb5da7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 18:04:30 +0000 Subject: [PATCH 32/36] =?UTF-8?q?Drag=20a=20gas=20room=20heater's=20cohere?= =?UTF-8?q?nt=20companions=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_main_heating_system_overlay.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index d8431e7ef..7f2ebc271 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -583,3 +583,22 @@ def test_gas_room_heater_variants_decode_to_their_sap_codes( assert simulation is not None assert simulation.heating is not None assert simulation.heating.sap_main_heating_code == code + + +def test_gas_room_heater_drags_its_coherent_companions() -> None: + # A gas room heater is a room-heater system (category 10) on a single-rate + # meter, under the conservative room-heater control (2601). Its natural fuel + # is mains gas (26) — an LPG dwelling is refined by a main_fuel override + # (the overlay can't see the mains connection) (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Gas room heater, condensing fire", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.main_heating_category == 10 + assert heating.main_heating_control == 2601 + assert heating.main_fuel_type == 26 + assert heating.meter_type == "Single" From ba258d601818de31e3d0229f835ac38352d3730f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 18:08:51 +0000 Subject: [PATCH 33/36] =?UTF-8?q?Drag=20a=20gas=20room=20heater's=20cohere?= =?UTF-8?q?nt=20companions=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main_heating_system_overlay.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index aee3d3b40..89b62ea46 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -82,10 +82,17 @@ _SOLID_FUEL_ROOM_HEATER_CODES = frozenset(range(631, 637)) # control, natural fuel heating oil (RdSAP main_fuel 28). _OIL_ROOM_HEATER_CODES = frozenset(range(621, 626)) _OIL_FUEL = 28 -# Fuel-burning room heaters (solid + oil) share category 10 + the conservative -# room-heater control; only the natural fuel differs by family. (Distinct from -# `_ROOM_HEATER_CODES` above, which is the electric-room-heater dual-meter set.) -_CATEGORY_10_ROOM_HEATER_CODES = _SOLID_FUEL_ROOM_HEATER_CODES | _OIL_ROOM_HEATER_CODES +# Gas (incl. LPG) room heaters (SAP Table 4a 601-613) — category 10, conservative +# control, natural fuel mains gas (26); an LPG dwelling is refined by a main_fuel +# override (the overlay can't see the mains connection). +_GAS_ROOM_HEATER_CODES = frozenset(range(601, 614)) +# Fuel-burning room heaters (solid + oil + gas) share category 10 + the +# conservative room-heater control; only the natural fuel differs by family. +# (Distinct from `_ROOM_HEATER_CODES` above, the electric-room-heater dual-meter +# set.) +_CATEGORY_10_ROOM_HEATER_CODES = ( + _SOLID_FUEL_ROOM_HEATER_CODES | _OIL_ROOM_HEATER_CODES | _GAS_ROOM_HEATER_CODES +) # Natural-fuel coherence (ADR-0041): where an archetype unambiguously implies a # fuel, the overlay drags it so a system-only override is self-coherent on a cert @@ -226,6 +233,8 @@ def _natural_fuel_for(code: int) -> Optional[int]: return _HOUSE_COAL_FUEL if code in _OIL_ROOM_HEATER_CODES: return _OIL_FUEL + if code in _GAS_ROOM_HEATER_CODES: + return _MAINS_GAS_FUEL if code in _COMMUNITY_BOILER_CODES: return _COMMUNITY_GAS_FUEL return None From 2fab2ad82ecff09101a7c421bccb6b467f0419ee Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 20:15:38 +0000 Subject: [PATCH 34/36] =?UTF-8?q?Recognise=20community=20CHP=20and=20boile?= =?UTF-8?q?rs=20as=20a=20heat-network=20system=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc/test_main_heating_system_overlay.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 7f2ebc271..d98d01458 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -524,6 +524,23 @@ def test_community_boilers_drag_heat_network_category_and_a_community_gas_fuel() assert heating.main_fuel_type == 20 +def test_community_chp_and_boilers_decode_to_their_heat_network_code() -> None: + # A community scheme with CHP + boilers is SAP Table 4a code 302, still a heat + # network (category 6) on community mains gas (20). Real audit data: 10 + # properties carry this, today mis-bucketed to Gas CPSU (ADR-0041). + + # Act + simulation = main_heating_overlay_for("Community heating, CHP and boilers", 0) + + # Assert + assert simulation is not None + assert simulation.heating is not None + heating = simulation.heating + assert heating.sap_main_heating_code == 302 + assert heating.main_heating_category == 6 + assert heating.main_fuel_type == 20 + + def test_oil_room_heater_decodes_to_the_post_2000_code() -> None: # A modern standalone oil room heater is SAP Table 4a code 623 ("Oil room # heater, 2000 or later", no boiler). It must decode to 623, not the Gas CPSU From 478f459e6c622eeefba6b9ce4e8104ed7b2b7f5a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 20:16:15 +0000 Subject: [PATCH 35/36] =?UTF-8?q?Recognise=20community=20CHP=20and=20boile?= =?UTF-8?q?rs=20as=20a=20heat-network=20system=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the 10 audited "community CHP and boilers" properties (302) alongside the 2,210 "community boilers" (301) — both were mis-bucketed to Gas CPSU. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/epc/property_overlays/main_heating_system_overlay.py | 2 ++ domain/epc/property_overrides/main_heating_system_type.py | 1 + 2 files changed, 3 insertions(+) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 89b62ea46..6069b7a49 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -174,6 +174,8 @@ _MAIN_HEATING_CODES: dict[str, int] = { # from the code (cert_to_inputs). A generic "Community heating" with no named # source stays unmapped (-> None) — the source can't be assumed (ADR-0041). "Community heating, boilers": 301, + # Community CHP + boilers — SAP Table 4a 302 (heat network, category 6). + "Community heating, CHP and boilers": 302, # Modern standalone oil room heater — SAP Table 4a 623 ("Oil room heater, # 2000 or later", no boiler). Natural fuel heating oil (ADR-0041). "Oil room heater, 2000 or later": 623, diff --git a/domain/epc/property_overrides/main_heating_system_type.py b/domain/epc/property_overrides/main_heating_system_type.py index eb4658b84..d79fec1d1 100644 --- a/domain/epc/property_overrides/main_heating_system_type.py +++ b/domain/epc/property_overrides/main_heating_system_type.py @@ -28,6 +28,7 @@ class MainHeatingSystemType(Enum): SOLID_FUEL_ROOM_HEATER_CLOSED = "Solid fuel room heater, closed" AIR_SOURCE_HEAT_PUMP = "Air source heat pump" COMMUNITY_BOILERS = "Community heating, boilers" + COMMUNITY_CHP_AND_BOILERS = "Community heating, CHP and boilers" OIL_ROOM_HEATER_POST_2000 = "Oil room heater, 2000 or later" GAS_FIRE_CONDENSING = "Gas room heater, condensing fire" GAS_FIRE_DECORATIVE = "Gas room heater, decorative fuel-effect" From 585c8b1501c1e707a7f965dfc99fd2923d8479de Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 30 Jun 2026 20:30:59 +0000 Subject: [PATCH 36/36] One-time script to re-classify stale heating overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deterministic catalogue remap (no LLM) that re-resolves the stored archetype for the canonical RdSAP heating descriptions the under-populated taxonomy mis-classified. Dry-run by default; --apply writes inside a transaction; idempotent. Updates property_overrides.override_value (TEXT — what the modelling reads, the actual band-G fix: 3,028 rows incl. 2,210 community boilers + 770 panel/convector room heaters). Cache (the Drizzle-owned main_heating_system pgEnum) updates are deferred per-value until the FE-repo enum migration adds the new archetypes — surfaced explicitly by the script. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/reclassify_heating_overrides.py | 176 ++++++++++++++++++ .../test_reclassify_heating_overrides.py | 30 +++ 2 files changed, 206 insertions(+) create mode 100644 scripts/reclassify_heating_overrides.py create mode 100644 tests/scripts/test_reclassify_heating_overrides.py diff --git a/scripts/reclassify_heating_overrides.py b/scripts/reclassify_heating_overrides.py new file mode 100644 index 000000000..002ebef9b --- /dev/null +++ b/scripts/reclassify_heating_overrides.py @@ -0,0 +1,176 @@ +"""One-time re-classification of stale landlord main-heating-system overrides. + +PRD #1361 Class A: the LLM landlord-description classifier ran against a +too-small `MainHeatingSystemType` taxonomy (ADR-0041), so canonical RdSAP +heating descriptions were stored against the wrong archetype — community heating +and oil/solid room heaters dumped into "Gas CPSU", and direct-acting +"panel, convector or radiant" room heaters mis-read as off-peak storage (the +band-G crater). The taxonomy is now complete, so these *canonical* descriptions +re-resolve deterministically — no LLM, no nondeterminism. + +This rewrites the resolved archetype on every affected row in BOTH the +per-Property fact layer (`property_overrides.override_value`) and the +per-portfolio classifier cache (`landlord_main_heating_system_overrides.value`), +keyed on the raw description (`original_spreadsheet_description` / `description`). + +DRY-RUN BY DEFAULT: prints the row counts it would change and writes nothing. +Pass `--apply` to execute inside a transaction. Idempotent — only rows whose +stored value differs from the corrected archetype are touched, so a second run +is a no-op. Descriptions with no confident archetype yet (solid-fuel open-fire / +with-boiler, warm air, electric underfloor) are deliberately NOT remapped here. +""" + +from __future__ import annotations + +import argparse + +from sqlalchemy import text + +from domain.epc.property_overrides.main_heating_system_type import ( + MainHeatingSystemType, +) +from scripts.e2e_common import build_engine, load_env + +# Canonical RdSAP main-heating description (lowercased, as stored in +# `original_spreadsheet_description` / `description`) → the corrected archetype. +# Only descriptions whose correct archetype now exists in the taxonomy appear +# here; the value is a `MainHeatingSystemType` so a typo can't slip a non-archetype +# string into the DB (the enum is the single source of truth — ADR-0041). +REMAP: dict[str, MainHeatingSystemType] = { + "community heating systems: community boilers only (rdsap)": ( + MainHeatingSystemType.COMMUNITY_BOILERS + ), + "community heating systems: community chp and boilers (rdsap)": ( + MainHeatingSystemType.COMMUNITY_CHP_AND_BOILERS + ), + "electric (direct acting) room heaters: panel, convector or radiant heaters": ( + MainHeatingSystemType.ELECTRIC_ROOM_HEATERS + ), + "oil room heaters: room heater, 2000 or later": ( + MainHeatingSystemType.OIL_ROOM_HEATER_POST_2000 + ), + "solid fuel room heaters: closed room heater": ( + MainHeatingSystemType.SOLID_FUEL_ROOM_HEATER_CLOSED + ), + "gas (including lpg) room heaters: condensing gas fire": ( + MainHeatingSystemType.GAS_FIRE_CONDENSING + ), + "gas (including lpg) room heaters: decorative fuel effect gas fire, " + "open to chimney": MainHeatingSystemType.GAS_FIRE_DECORATIVE, + "gas (including lpg) room heaters: flush fitting live fuel effect gas fire " + "(open fronted), sealed to fireplace opening": ( + MainHeatingSystemType.GAS_FIRE_FLUSH_LIVE_EFFECT + ), + "gas (including lpg) room heaters: gas fire, open flue, 1980 or later " + "(open fronted), sitting proud of, and sealed to, fireplace opening": ( + MainHeatingSystemType.GAS_FIRE_OPEN_FLUE_POST_1980 + ), + "gas (including lpg) room heaters: gas fire, open flue, pre-1980 " + "(open fronted)": MainHeatingSystemType.GAS_FIRE_OPEN_FLUE_PRE_1980, +} + +# property_overrides keys the raw text on `original_spreadsheet_description`; the +# classifier cache keys it on `description`. Both compared case-insensitively. +_PROPERTY_OVERRIDES_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE override_component = 'main_heating_system' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +# The cache `value` column is the Drizzle-owned `main_heating_system` pgEnum; +# compare/update it as text (no CAST) so a target value the DB enum does not yet +# carry doesn't error the read. The UPDATE is only issued for values the live +# enum already has — `_db_enum_values` gates it (see main()). +_CACHE_UPDATE = text( + """ + UPDATE landlord_main_heating_system_overrides + SET value = :new_value, updated_at = now() + WHERE lower(description) = :description + AND value::text <> :new_value + """ +) +_PROPERTY_OVERRIDES_COUNT = text( + """ + SELECT count(*) FROM property_overrides + WHERE override_component = 'main_heating_system' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_CACHE_COUNT = text( + """ + SELECT count(*) FROM landlord_main_heating_system_overrides + WHERE lower(description) = :description + AND value::text <> :new_value + """ +) +_ENUM_VALUES = text( + "SELECT e.enumlabel FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid " + "WHERE t.typname = 'main_heating_system'" +) + + +def main() -> None: + load_env() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--apply", + action="store_true", + help="execute the updates (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + engine = build_engine() + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + # The cache `value` is the Drizzle-owned pgEnum; only update it for + # archetypes the live enum already carries. The rest need the Drizzle + # migration (FE repo) before the cache — and the classifier — can store + # them; property_overrides (TEXT, what the modelling reads) is updated + # regardless, which is the actual band-G fix. + enum_values = {r[0] for r in conn.execute(_ENUM_VALUES)} + prop_total = 0 + cache_total = 0 + deferred: list[str] = [] + for description, archetype in REMAP.items(): + params = {"description": description, "new_value": archetype.value} + prop_n = conn.execute(_PROPERTY_OVERRIDES_COUNT, params).scalar() or 0 + prop_total += prop_n + in_enum = archetype.value in enum_values + cache_n = conn.execute(_CACHE_COUNT, params).scalar() or 0 if in_enum else 0 + cache_total += cache_n + if not in_enum and prop_n: + deferred.append(archetype.value) + if prop_n or cache_n: + tag = "" if in_enum else " [cache deferred: enum value missing]" + print( + f" {prop_n:>6} property_overrides + {cache_n} cache " + f"-> {archetype.value!r} <= {description!r}{tag}" + ) + if args.apply: + conn.execute(_PROPERTY_OVERRIDES_UPDATE, params) + if in_enum: + conn.execute(_CACHE_UPDATE, params) + verb = "updated" if args.apply else "would update" + print( + f"\n{verb} {prop_total} property_overrides rows + {cache_total} " + f"classifier-cache rows across {len(REMAP)} descriptions." + ) + if deferred: + print( + f"\n{len(set(deferred))} archetype(s) NOT in the Drizzle " + "`main_heating_system` pgEnum — their cache rows are deferred " + "until the FE-repo enum migration lands (property_overrides was " + "still updated, which is what the modelling reads):" + ) + for v in sorted(set(deferred)): + print(f" {v!r}") + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + + +if __name__ == "__main__": + main() diff --git a/tests/scripts/test_reclassify_heating_overrides.py b/tests/scripts/test_reclassify_heating_overrides.py new file mode 100644 index 000000000..f3cb73382 --- /dev/null +++ b/tests/scripts/test_reclassify_heating_overrides.py @@ -0,0 +1,30 @@ +"""The one-time heating-override re-classification map is internally valid.""" + +from __future__ import annotations + +from domain.epc.property_overlays.main_heating_system_overlay import ( + main_heating_overlay_for, +) +from domain.epc.property_overrides.main_heating_system_type import ( + MainHeatingSystemType, +) +from scripts.reclassify_heating_overrides import REMAP + + +def test_every_remap_target_is_a_resolvable_archetype() -> None: + # The remap must never point a stored description at an archetype the overlay + # can't model — that would replace one broken value with another. Every + # target must decode to a real SAP heating code (ADR-0041). + for description, archetype in REMAP.items(): + simulation = main_heating_overlay_for(archetype.value, 0) + + assert simulation is not None, description + assert simulation.heating is not None, description + assert simulation.heating.sap_main_heating_code is not None, description + + +def test_remap_targets_are_main_heating_system_members() -> None: + # Belt and braces: the values are enum members (a typo can't smuggle a + # non-archetype string into the DB). + for archetype in REMAP.values(): + assert archetype in MainHeatingSystemType