From c24e4659745e7a74847ff6c31600d2f023ed6aec Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 12:59:56 +0000 Subject: [PATCH 01/10] =?UTF-8?q?Resolve=20a=20from-main=20wood-logs=20wat?= =?UTF-8?q?er-heating=20override=20to=20the=20wood=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) --- ...unity-scored-by-main-individual-by-fuel.md | 123 ++++++++++++++++++ .../domain/epc/test_water_heating_overlay.py | 13 ++ 2 files changed, 136 insertions(+) create mode 100644 docs/adr/0043-water-heating-community-scored-by-main-individual-by-fuel.md diff --git a/docs/adr/0043-water-heating-community-scored-by-main-individual-by-fuel.md b/docs/adr/0043-water-heating-community-scored-by-main-individual-by-fuel.md new file mode 100644 index 000000000..9f579d228 --- /dev/null +++ b/docs/adr/0043-water-heating-community-scored-by-main-individual-by-fuel.md @@ -0,0 +1,123 @@ +# Water-heating override: community DHW is scored by its heat-network main, individual DHW by its own fuel + +## Status + +accepted + +## Context + +A **Landlord Override** for water heating names a canonical `", "` +**Recognised Internal Description** (`WaterHeatingType`), which the water-heating +**Simulation Overlay** (`water_heating_overlay_for`) decomposes into the SAP +`water_heating_code` (Table 4a system) + `water_heating_fuel` int codes the +calculator reads (ADR-0032). The taxonomy was **too small** — it had no biomass / +wood / dual-fuel / biodiesel water fuel — so the LLM classifier funnelled every +such description into the nearest bucket, **`"From main system, house coal"`** +(fuel 33). An audit found **131** `property_overrides` rows on that value; only +**3** are genuinely house coal. This is the same missing-archetype defect as +ADR-0041 (heating) — a dumping ground standing in for a gap in the taxonomy. + +Investigating the mis-score revealed the fix is **not** a uniform "swap coal for +biomass". The 131 rows split by whether the dwelling's **main heating is a heat +network**, because the calculator scores hot water differently in the two cases: + +- **Community main** (SAP Table 4a 301/302/304). When DHW inherits from the main + (`water_heating_code` ∈ {901, 902, 914}) and the main is a costable heat + network, `cert_to_inputs._is_community_heating_hw_from_main` computes the hot- + water CO2/PE/cost from the **main's** heat-network fuel (via + `_heat_network_community_fuel_code`, e.g. biomass-community fuel 31 → Table 12 + code 43), scaled by heat-source efficiency — and **ignores + `water_heating_fuel` entirely**. Verified: HW CO2 is `0.036` whether the water + fuel is 33 (coal) or 31 (biomass). So the **84** community rows already score + their DHW as biomass-heat-network; the `"house coal"` value is a **cosmetic + display lie**, not a scoring error. +- **Individual main** (a real boiler / room heater). DHW inherits the explicitly + lodged `water_heating_fuel`, so `33` scores as **house coal (0.395 kgCO2/kWh)** — + a genuine ~14× carbon overstatement for a wood/biomass dwelling. **47** rows are + mis-scored this way (20 wood logs, 4 dual fuel, 23 "electric immersion + assumed"); **3** genuine house-coal rows score correctly. + +The community-fuel collision (API enum 30/31/32 collide in value with the Table-32 +electricity tariff codes 30/31/32) is **already handled** at the fuel-type +boundary, gated on heat-network context (`_heat_network_community_fuel_code`) — a +deliberate design that must **not** be globally canonicalised, because the cascade +uses bare code 30 internally as standard-rate grid electricity +(`table_32.py` comment). So **no calculator change is required**: community DHW is +scored correctly *given the coherent community main the override data already +carries* (`main_heating_system = "Community heating, boilers"`, +`main_fuel = "biomass (community)"`). + +## Decision + +Complete the water-heating taxonomy and reclassify the dumping-ground rows to +**faithful** archetypes. No calculator change. + +1. **Add the missing water archetypes**, each mapping to its RdSAP + `water_heating_fuel` code in `water_heating_overlay._WATER_HEATING_CODES`: + - `"From main system, wood logs"` → (901, **6**) + - `"From main system, biomass (community)"` → (901, **31**) + - `"From main system, dual fuel (mineral and wood)"` → (901, **9**) + - `"From main system, biodiesel (community)"` → (901, **34**) + +2. **Community DHW maps to its community fuel (31 / 34) and is score-neutral.** + Because the community main drives the HW score, mapping the 75 community + biomass/biodiesel rows to their honest community fuel corrects the **displayed + value** without changing the score — we do **not** flatten them to individual + wood (6), which would be a semantic lie. Correct labelling, zero scoring risk. + +3. **Individual DHW maps to its real fuel and moves the score.** Wood-logs → 6 + (0.028), dual fuel → 9, off the coal 0.395 — the genuine fix for the 47 + mis-scored individual-main rows. + +4. **"No hot water system present – electric immersion assumed" → the existing + `"Electric immersion, electricity"` archetype (903, 29).** The description + states the assessment's own assumption; honouring it is faithful. This corrects + the 23 individual-main rows (coal → electricity) and re-labels the 9 + community-main rows off the accidental biomass score onto the assessed + immersion — a small, defensible score change, surfaced here as the one non- + cosmetic community reclassification. + +5. **Genuine `"From main system, house coal"` (3 rows) is retained** — the taxonomy + keeps house coal for the dwellings that really burn it. + +6. **Deterministic guard + reclassify, TEXT-first / pgEnum-deferred.** A pure + `water_heating_guard(description) -> Optional[WaterHeatingType]` (mirroring + `glazing_mix_guard`) recognises the structured `": "` phrasings + and is wired via `GuardedColumnClassifier(guard, fallback=ChatGpt…)`; varied + phrasings stay with the LLM. A one-time `scripts/reclassify_water_heating.py` + reuses the same guard as its decision function (so backfill and live path can't + drift), updates `property_overrides.override_value` (TEXT) unconditionally, and + defers the `landlord_water_heating_overrides.value` pgEnum-cache write for any + value the live enum doesn't yet carry — printing the deferred set for the FE + owner (the `main-heating-system-pgenum-is-fe-owned` constraint). + +## Consequences + +- **FE-owned pgEnum additions (Dan).** Four new `landlord_water_heating_overrides` + values: `"From main system, wood logs"`, `"From main system, biomass + (community)"`, `"From main system, dual fuel (mineral and wood)"`, `"From main + system, biodiesel (community)"`. The TEXT read path is fixed immediately; the + cache write for these four defers until the migration lands. +- **No `cert_to_inputs.py` change**, so no risk to the thousands of lodged certs + that carry water fuel 31/33/34 directly. (A separate latent question — whether a + lodged *individual*-main cert with `water_heating_fuel = 31` or `34` mis-scores + as electricity because those codes are un-normalised outside heat-network + context — is **out of scope** here and tracked separately; it does not affect any + override row, all of which resolve correctly under this decision.) +- Extends [ADR-0041](0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md) + (complete taxonomy, no dumping ground) and [ADR-0035](0035-coherent-heating-system-synthesis.md) + (the community main and its DHW cohere — here the coherence is what makes the + community rows score correctly). The visible baseline shift on the 47 individual + rows is correct Rebaselining ([ADR-0039](0039-override-aware-rebaselining.md)). + +### Alternatives rejected + +- **Flatten community biomass to individual wood (code 6).** Scores ~identically + (0.028 vs 0.029) but mislabels a district-heating dwelling as burning its own + wood — a semantic lie for no benefit, since the community main already scores it. +- **A calculator "collision fix" normalising 31/33/34 globally.** Rejected: 33 is + already normalised (`canonical_fuel_code` 33→11); 31/32 must stay un-normalised + outside heat-network context or genuine grid electricity mis-prices as community + waste (`table_32.py`). No override row needs it. +- **Defer the community rows to `None`.** Rejected per ADR-0041 dec-4 (community is + modelled, not deferred) — and unnecessary, since they already score correctly. diff --git a/tests/domain/epc/test_water_heating_overlay.py b/tests/domain/epc/test_water_heating_overlay.py index a1955aa73..58ba813f7 100644 --- a/tests/domain/epc/test_water_heating_overlay.py +++ b/tests/domain/epc/test_water_heating_overlay.py @@ -73,6 +73,19 @@ def test_more_water_heating_combos_decode_to_their_codes( assert simulation.heating.water_heating_fuel == fuel +def test_from_main_wood_logs_decodes_to_the_wood_fuel_not_house_coal() -> None: + # Act + simulation = water_heating_overlay_for("From main system, wood logs", 0) + + # Assert — wood logs is RdSAP water_heating_fuel 6, from main system (901). + # A wood-fired dwelling's DHW must not fall to house coal (33), which scores + # ~14x the carbon (ADR-0043). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.water_heating_code == 901 + assert simulation.heating.water_heating_fuel == 6 + + @pytest.mark.parametrize("water_heating_value", ["Unknown", ""]) def test_unresolvable_water_heating_produces_no_overlay( water_heating_value: str, From ea3696e8d58c9026ad70ad7375549c382434f899 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 13:02:13 +0000 Subject: [PATCH 02/10] =?UTF-8?q?Resolve=20a=20from-main=20wood-logs=20wat?= =?UTF-8?q?er-heating=20override=20to=20the=20wood=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) --- domain/epc/property_overlays/water_heating_overlay.py | 4 ++++ domain/epc/property_overrides/water_heating_type.py | 1 + 2 files changed, 5 insertions(+) diff --git a/domain/epc/property_overlays/water_heating_overlay.py b/domain/epc/property_overlays/water_heating_overlay.py index 3ec952de2..290182c50 100644 --- a/domain/epc/property_overlays/water_heating_overlay.py +++ b/domain/epc/property_overlays/water_heating_overlay.py @@ -26,6 +26,10 @@ _WATER_HEATING_CODES: dict[str, tuple[int, int]] = { "From main system, LPG (bulk)": (901, 27), "From main system, bottled LPG": (901, 3), "From main system, house coal": (901, 33), + # Wood logs — RdSAP water_heating_fuel 6 (biomass ~0.028 kgCO2/kWh), from + # main (901). A wood-fired individual-main dwelling's DHW must not fall to + # house coal 33 (~0.395), a ~14x carbon overstatement (ADR-0043). + "From main system, wood logs": (901, 6), "Electric immersion, electricity": (903, 29), # "boiler/circulator for water heating only" — SAP Table 4a code 911 (gas). "Gas boiler/circulator, mains gas": (911, 26), diff --git a/domain/epc/property_overrides/water_heating_type.py b/domain/epc/property_overrides/water_heating_type.py index e85a195d8..32e929acb 100644 --- a/domain/epc/property_overrides/water_heating_type.py +++ b/domain/epc/property_overrides/water_heating_type.py @@ -21,6 +21,7 @@ class WaterHeatingType(Enum): FROM_MAIN_LPG_BULK = "From main system, LPG (bulk)" FROM_MAIN_BOTTLED_LPG = "From main system, bottled LPG" FROM_MAIN_HOUSE_COAL = "From main system, house coal" + FROM_MAIN_WOOD_LOGS = "From main system, wood logs" ELECTRIC_IMMERSION = "Electric immersion, electricity" GAS_BOILER_CIRCULATOR_MAINS_GAS = "Gas boiler/circulator, mains gas" UNKNOWN = "Unknown" From da15418fd897f94cc4f4b469dfdc1987b38e2a42 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 13:02:38 +0000 Subject: [PATCH 03/10] =?UTF-8?q?Resolve=20biomass,=20dual-fuel=20and=20bi?= =?UTF-8?q?odiesel=20water-heating=20overrides=20off=20house=20coal=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) --- .../domain/epc/test_water_heating_overlay.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/domain/epc/test_water_heating_overlay.py b/tests/domain/epc/test_water_heating_overlay.py index 58ba813f7..f71ddcee4 100644 --- a/tests/domain/epc/test_water_heating_overlay.py +++ b/tests/domain/epc/test_water_heating_overlay.py @@ -86,6 +86,33 @@ def test_from_main_wood_logs_decodes_to_the_wood_fuel_not_house_coal() -> None: assert simulation.heating.water_heating_fuel == 6 +@pytest.mark.parametrize( + ("water_heating_value", "fuel"), + [ + # Biomass (community) — RdSAP fuel 31. Under a coherent community main + # the calculator scores DHW from the main, so this is a faithful, + # score-neutral relabel off house coal; the fuel is still emitted so an + # individual-main dwelling would score biomass, not coal (ADR-0043). + ("From main system, biomass (community)", 31), + # Dual fuel (mineral + wood) — RdSAP fuel 9. + ("From main system, dual fuel (mineral and wood)", 9), + # Biodiesel (community) — RdSAP fuel 34. + ("From main system, biodiesel (community)", 34), + ], +) +def test_biomass_family_water_heating_decodes_off_house_coal( + water_heating_value: str, fuel: int +) -> None: + # Act + simulation = water_heating_overlay_for(water_heating_value, 0) + + # Assert — from main system (901), each to its own RdSAP fuel, not coal 33. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.water_heating_code == 901 + assert simulation.heating.water_heating_fuel == fuel + + @pytest.mark.parametrize("water_heating_value", ["Unknown", ""]) def test_unresolvable_water_heating_produces_no_overlay( water_heating_value: str, From 84fcf9da49415270e1cee533e58900cc50e18b23 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 13:03:28 +0000 Subject: [PATCH 04/10] =?UTF-8?q?Resolve=20biomass,=20dual-fuel=20and=20bi?= =?UTF-8?q?odiesel=20water-heating=20overrides=20off=20house=20coal=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) --- .../epc/property_overlays/water_heating_overlay.py | 14 +++++++++++--- .../epc/property_overrides/water_heating_type.py | 3 +++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/domain/epc/property_overlays/water_heating_overlay.py b/domain/epc/property_overlays/water_heating_overlay.py index 290182c50..61b6a159f 100644 --- a/domain/epc/property_overlays/water_heating_overlay.py +++ b/domain/epc/property_overlays/water_heating_overlay.py @@ -26,10 +26,18 @@ _WATER_HEATING_CODES: dict[str, tuple[int, int]] = { "From main system, LPG (bulk)": (901, 27), "From main system, bottled LPG": (901, 3), "From main system, house coal": (901, 33), - # Wood logs — RdSAP water_heating_fuel 6 (biomass ~0.028 kgCO2/kWh), from - # main (901). A wood-fired individual-main dwelling's DHW must not fall to - # house coal 33 (~0.395), a ~14x carbon overstatement (ADR-0043). + # Biomass / wood water fuels routed off the house-coal dumping ground + # (ADR-0043), all from main (901) with their own RdSAP water_heating_fuel: + # 6 wood logs (biomass ~0.028 kgCO2/kWh vs house coal 33 ~0.395) + # 9 dual fuel (mineral + wood) + # 31 biomass (community) — scored via the coherent community main; the + # fuel is still emitted so an individual main would score biomass + # 34 biodiesel (community) + # A wood-fired individual-main dwelling's DHW must never fall to coal 33. "From main system, wood logs": (901, 6), + "From main system, dual fuel (mineral and wood)": (901, 9), + "From main system, biomass (community)": (901, 31), + "From main system, biodiesel (community)": (901, 34), "Electric immersion, electricity": (903, 29), # "boiler/circulator for water heating only" — SAP Table 4a code 911 (gas). "Gas boiler/circulator, mains gas": (911, 26), diff --git a/domain/epc/property_overrides/water_heating_type.py b/domain/epc/property_overrides/water_heating_type.py index 32e929acb..92cd3a75b 100644 --- a/domain/epc/property_overrides/water_heating_type.py +++ b/domain/epc/property_overrides/water_heating_type.py @@ -22,6 +22,9 @@ class WaterHeatingType(Enum): FROM_MAIN_BOTTLED_LPG = "From main system, bottled LPG" FROM_MAIN_HOUSE_COAL = "From main system, house coal" FROM_MAIN_WOOD_LOGS = "From main system, wood logs" + FROM_MAIN_DUAL_FUEL_MINERAL_WOOD = "From main system, dual fuel (mineral and wood)" + FROM_MAIN_BIOMASS_COMMUNITY = "From main system, biomass (community)" + FROM_MAIN_BIODIESEL_COMMUNITY = "From main system, biodiesel (community)" ELECTRIC_IMMERSION = "Electric immersion, electricity" GAS_BOILER_CIRCULATOR_MAINS_GAS = "Gas boiler/circulator, mains gas" UNKNOWN = "Unknown" From d7f069b7c58af9311fd913e3709ca20279bef753 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 13:05:47 +0000 Subject: [PATCH 05/10] =?UTF-8?q?Deterministically=20guard=20structured=20?= =?UTF-8?q?biomass=20and=20immersion=20water-heating=20descriptions=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) --- .../property_overrides/water_heating_guard.py | 9 +++ tests/domain/epc/test_water_heating_guard.py | 80 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 domain/epc/property_overrides/water_heating_guard.py create mode 100644 tests/domain/epc/test_water_heating_guard.py diff --git a/domain/epc/property_overrides/water_heating_guard.py b/domain/epc/property_overrides/water_heating_guard.py new file mode 100644 index 000000000..4b447ff6e --- /dev/null +++ b/domain/epc/property_overrides/water_heating_guard.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Optional + +from domain.epc.property_overrides.water_heating_type import WaterHeatingType + + +def water_heating_guard(description: str) -> Optional[WaterHeatingType]: + raise NotImplementedError diff --git a/tests/domain/epc/test_water_heating_guard.py b/tests/domain/epc/test_water_heating_guard.py new file mode 100644 index 000000000..227ed3f4e --- /dev/null +++ b/tests/domain/epc/test_water_heating_guard.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import pytest + +from domain.epc.property_overrides.water_heating_guard import water_heating_guard +from domain.epc.property_overrides.water_heating_type import WaterHeatingType + + +@pytest.mark.parametrize( + ("description", "expected"), + [ + # The biomass/wood family the LLM funnelled into "house coal" (#1376). + ( + "From main heating system: Wood Logs", + WaterHeatingType.FROM_MAIN_WOOD_LOGS, + ), + ( + "From main heating system: Dual Fuel: Mineral and Wood", + WaterHeatingType.FROM_MAIN_DUAL_FUEL_MINERAL_WOOD, + ), + # "Hot-water only community scheme" flattens to "from main", per the + # existing convention (community mains-gas already resolves that way). + ( + "From main heating system: Biomass (Community)", + WaterHeatingType.FROM_MAIN_BIOMASS_COMMUNITY, + ), + ( + "Hot-water only community scheme - boilers: Biomass (Community)", + WaterHeatingType.FROM_MAIN_BIOMASS_COMMUNITY, + ), + ( + "Hot-water only community scheme - boilers: Heat from Boilers " + "using Biodiesel from any source (Community)", + WaterHeatingType.FROM_MAIN_BIODIESEL_COMMUNITY, + ), + # "No Heating or Hot Water" under "electric immersion assumed" is the + # assessment's own immersion assumption — honour it (ADR-0043). + ( + "No hot water system present - electric immersion assumed: " + "No Heating or Hot Water", + WaterHeatingType.ELECTRIC_IMMERSION, + ), + # A genuinely coal DHW stays house coal. + ( + "From main heating system: House Coal", + WaterHeatingType.FROM_MAIN_HOUSE_COAL, + ), + ], +) +def test_guard_resolves_the_structured_biomass_and_immersion_descriptions( + description: str, expected: WaterHeatingType +) -> None: + # Act + result = water_heating_guard(description) + + # Assert + assert result is expected + + +@pytest.mark.parametrize( + "description", + [ + # Fuels the LLM already classifies correctly — the guard must not claim + # them (it only rescues the previously-misclassified structured cases). + "From main heating system: Mains Gas", + "Electric immersion: Electricity", + "From main heating system: Oil", + # A varied / unstructured phrasing is the LLM's job. + "hot water from the wood burner", + "", + ], +) +def test_guard_defers_recognised_and_unstructured_descriptions_to_the_llm( + description: str, +) -> None: + # Act + result = water_heating_guard(description) + + # Assert + assert result is None From d0aa596ae7fef13a1c023af9bf67a2d5ac6ebd73 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 13:06:52 +0000 Subject: [PATCH 06/10] =?UTF-8?q?Deterministically=20guard=20structured=20?= =?UTF-8?q?biomass=20and=20immersion=20water-heating=20descriptions=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) --- .../property_overrides/water_heating_guard.py | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/domain/epc/property_overrides/water_heating_guard.py b/domain/epc/property_overrides/water_heating_guard.py index 4b447ff6e..1970f6bfd 100644 --- a/domain/epc/property_overrides/water_heating_guard.py +++ b/domain/epc/property_overrides/water_heating_guard.py @@ -6,4 +6,36 @@ from domain.epc.property_overrides.water_heating_type import WaterHeatingType def water_heating_guard(description: str) -> Optional[WaterHeatingType]: - raise NotImplementedError + """Deterministically resolve the structured water-heating descriptions the LLM + funnelled into the ``"From main system, house coal"`` dumping ground (#1376). + + Landlord water-heating descriptions arrive as ``": "`` (e.g. + ``"From main heating system: Wood Logs"``). Where the taxonomy had no biomass / + wood / dual-fuel / biodiesel water fuel, the classifier force-picked house coal, + scoring a biomass dwelling's DHW at ~14x its carbon (ADR-0043). This guard keys + on the **fuel token** and returns the faithful archetype — the ``"Hot-water only + community scheme"`` system flattens to ``"From main system, …"`` exactly as the + community mains-gas description already does. ``"No Heating or Hot Water"`` under + an ``"electric immersion assumed"`` system is the assessment's own immersion + assumption, so it resolves to ``ELECTRIC_IMMERSION``. + + Returns ``None`` for fuels the LLM already classifies correctly (mains gas, + electricity, oil, …) and for varied / unstructured phrasings, so those still + reach the LLM classifier via ``GuardedColumnClassifier``. Order matters: + biodiesel and dual fuel are tested before the bare biomass / wood tokens they + contain. + """ + text = description.lower() + if "biodiesel" in text: + return WaterHeatingType.FROM_MAIN_BIODIESEL_COMMUNITY + if "biomass" in text: + return WaterHeatingType.FROM_MAIN_BIOMASS_COMMUNITY + if "dual fuel" in text: + return WaterHeatingType.FROM_MAIN_DUAL_FUEL_MINERAL_WOOD + if "wood log" in text: + return WaterHeatingType.FROM_MAIN_WOOD_LOGS + if "house coal" in text: + return WaterHeatingType.FROM_MAIN_HOUSE_COAL + if "no heating or hot water" in text: + return WaterHeatingType.ELECTRIC_IMMERSION + return None From 36ead3ca1db7fabf0f7a20b8439ab5dad241c575 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 13:09:12 +0000 Subject: [PATCH 07/10] =?UTF-8?q?Route=20water-heating=20overrides=20throu?= =?UTF-8?q?gh=20the=20deterministic=20guard=20before=20the=20LLM=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) --- .../landlord_description_overrides/handler.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/applications/landlord_description_overrides/handler.py b/applications/landlord_description_overrides/handler.py index dd37e461c..bc5fe8ee9 100644 --- a/applications/landlord_description_overrides/handler.py +++ b/applications/landlord_description_overrides/handler.py @@ -22,6 +22,7 @@ from domain.data_transformation.guarded_column_classifier import ( GuardedColumnClassifier, ) from domain.epc.property_overrides.water_heating_type import WaterHeatingType +from domain.epc.property_overrides.water_heating_guard import water_heating_guard from domain.epc.property_overrides.wall_type import WallType from domain.epc.property_overrides.wall_type_construction_dates import ( wall_type_construction_date_prompt_hint, @@ -174,8 +175,16 @@ def _build_columns( "water_heating": lambda src: ClassifiableColumn( name="water_heating", source_column=src, - classifier=ChatGptColumnClassifier( - chat_gpt, WaterHeatingType, WaterHeatingType.UNKNOWN + # A biomass / wood / dual-fuel / biodiesel DHW description has no + # dedicated LLM target and was funnelled into "house coal"; the + # deterministic guard resolves the structured fuels (and the "electric + # immersion assumed" no-system case) and the LLM handles the rest + # (#1376, ADR-0043). + classifier=GuardedColumnClassifier( + guard=water_heating_guard, + fallback=ChatGptColumnClassifier( + chat_gpt, WaterHeatingType, WaterHeatingType.UNKNOWN + ), ), repo=LandlordOverridesRepository[WaterHeatingType]( session, LandlordWaterHeatingOverrideRow From ffbef2d0475a2700f32e3a83015c852e69bdebc5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 13:58:39 +0000 Subject: [PATCH 08/10] =?UTF-8?q?Re-map=20house-coal=20water-heating=20ove?= =?UTF-8?q?rrides=20onto=20faithful=20biomass=20and=20immersion=20archetyp?= =?UTF-8?q?es=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) --- scripts/reclassify_water_heating.py | 12 ++++ .../scripts/test_reclassify_water_heating.py | 68 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 scripts/reclassify_water_heating.py create mode 100644 tests/scripts/test_reclassify_water_heating.py diff --git a/scripts/reclassify_water_heating.py b/scripts/reclassify_water_heating.py new file mode 100644 index 000000000..b79463ad6 --- /dev/null +++ b/scripts/reclassify_water_heating.py @@ -0,0 +1,12 @@ +"""One-time re-classification of water-heating overrides funnelled into house coal. + +Placeholder — decision function stubbed; filled in GREEN. +""" + +from __future__ import annotations + +from collections.abc import Iterable + + +def water_heating_corrections(stored: Iterable[tuple[str, str]]) -> dict[str, str]: + raise NotImplementedError diff --git a/tests/scripts/test_reclassify_water_heating.py b/tests/scripts/test_reclassify_water_heating.py new file mode 100644 index 000000000..f59e6d200 --- /dev/null +++ b/tests/scripts/test_reclassify_water_heating.py @@ -0,0 +1,68 @@ +"""The water-heating reclassify maps the house-coal dumping ground onto faithful +biomass / wood / dual-fuel / biodiesel / immersion archetypes, leaves genuine coal +and already-correct rows alone, and is idempotent (#1376 / ADR-0043).""" + +from __future__ import annotations + +from scripts.reclassify_water_heating import water_heating_corrections + + +def test_house_coal_bucket_rows_are_remapped_to_faithful_fuels() -> None: + # Arrange — the biomass/wood family + the "no system / immersion assumed" case + # all funnelled into house coal, plus a genuinely-coal row and a correctly- + # classified mains-gas row (descriptions arrive lower-cased from the query). + stored = [ + ("from main heating system: wood logs", "From main system, house coal"), + ( + "from main heating system: dual fuel: mineral and wood", + "From main system, house coal", + ), + ( + "hot-water only community scheme - boilers: biomass (community)", + "From main system, house coal", + ), + ( + "hot-water only community scheme - boilers: heat from boilers using " + "biodiesel from any source (community)", + "From main system, house coal", + ), + ( + "no hot water system present - electric immersion assumed: " + "no heating or hot water", + "From main system, house coal", + ), + # Genuinely coal — stays (guard resolves house coal, value unchanged). + ("from main heating system: house coal", "From main system, house coal"), + # Already correct — the guard does not claim it. + ("from main heating system: mains gas", "From main system, mains gas"), + ] + + # Act + corrections = water_heating_corrections(stored) + + # Assert — only the mis-scored rows move, each to its faithful archetype. + assert corrections == { + "from main heating system: wood logs": "From main system, wood logs", + "from main heating system: dual fuel: mineral and wood": ( + "From main system, dual fuel (mineral and wood)" + ), + "hot-water only community scheme - boilers: biomass (community)": ( + "From main system, biomass (community)" + ), + "hot-water only community scheme - boilers: heat from boilers using " + "biodiesel from any source (community)": ( + "From main system, biodiesel (community)" + ), + "no hot water system present - electric immersion assumed: " + "no heating or hot water": "Electric immersion, electricity", + } + + +def test_already_corrected_rows_need_no_change() -> None: + # Act / Assert — idempotent: a re-run against corrected data is a no-op. + assert ( + water_heating_corrections( + [("from main heating system: wood logs", "From main system, wood logs")] + ) + == {} + ) From 51abd152894b7430638ac919d28bfc9b7dc51a18 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 13:59:44 +0000 Subject: [PATCH 09/10] =?UTF-8?q?Re-map=20house-coal=20water-heating=20ove?= =?UTF-8?q?rrides=20onto=20faithful=20biomass=20and=20immersion=20archetyp?= =?UTF-8?q?es=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) --- scripts/reclassify_water_heating.py | 142 +++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 3 deletions(-) diff --git a/scripts/reclassify_water_heating.py b/scripts/reclassify_water_heating.py index b79463ad6..40ecb4dd0 100644 --- a/scripts/reclassify_water_heating.py +++ b/scripts/reclassify_water_heating.py @@ -1,12 +1,148 @@ """One-time re-classification of water-heating overrides funnelled into house coal. -Placeholder — decision function stubbed; filled in GREEN. +#1376 / ADR-0043: with no biomass / wood / dual-fuel / biodiesel water fuel in the +taxonomy, the LLM classified those DHW descriptions (and the "no system / electric +immersion assumed" case) to ``"From main system, house coal"`` (fuel 33). On an +individual-main dwelling that scores hot water at ~14x the carbon of biomass; on a +community-main dwelling the score is already right but the stored value lies. + +The live classifier now applies ``water_heating_guard`` deterministically (so new +intakes are correct); this fixes the rows written before it. The **same guard** +decides the correction here, so the backfill and the live path cannot drift. + +Updates the TEXT ``property_overrides.override_value`` (what the modelling reads — +the immediate fix) unconditionally. The ``landlord_water_heating_overrides.value`` +classifier cache is a ``water_heating`` **pgEnum**; the four new archetypes are +FE-owned values, so their cache writes are **deferred** until the Drizzle migration +adds them (the Class-A/B pattern). ``Electric immersion, electricity`` and +``From main system, house coal`` are existing enum members and update the cache. + +DRY-RUN BY DEFAULT: prints the counts it would change and writes nothing. Pass +``--apply`` to execute inside a transaction. Idempotent — only rows whose stored +value differs from the target member are touched. """ from __future__ import annotations +import argparse from collections.abc import Iterable +from sqlalchemy import Connection, text -def water_heating_corrections(stored: Iterable[tuple[str, str]]) -> dict[str, str]: - raise NotImplementedError +from domain.epc.property_overrides.water_heating_guard import water_heating_guard +from scripts.e2e_common import build_engine, load_env + + +def water_heating_corrections( + stored: Iterable[tuple[str, str]], +) -> dict[str, str]: + """``(description, stored override_value)`` → the faithful archetype value, for + the descriptions the water-heating guard resolves whose stored value differs. + Descriptions the guard defers (mains gas, electricity, …), genuine house-coal + rows, and rows already on the target are omitted, so re-running against + corrected data is a no-op.""" + corrections: dict[str, str] = {} + for description, value in stored: + member = water_heating_guard(description) + if member is not None and value != member.value: + corrections[description] = member.value + return corrections + + +_DISTINCT = text( + """ + SELECT DISTINCT lower(original_spreadsheet_description) AS description, + override_value AS value + FROM property_overrides + WHERE override_component = 'water_heating' + """ +) +_OVERRIDES_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE override_component = 'water_heating' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_OVERRIDES_COUNT = text( + """ + SELECT count(*) FROM property_overrides + WHERE override_component = 'water_heating' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_CACHE_UPDATE = text( + """ + UPDATE landlord_water_heating_overrides + SET value = :new_value, updated_at = now() + 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 = 'water_heating'" +) + + +def reclassify(conn: Connection, *, apply: bool) -> tuple[int, set[str]]: + """Re-map house-coal water-heating overrides onto their faithful archetypes. + Returns the number of ``property_overrides`` rows found and the set of target + values the live ``water_heating`` pgEnum does not yet carry (cache-deferred + until the FE migration).""" + stored = [(r.description, r.value) for r in conn.execute(_DISTINCT)] + enum_values = {r[0] for r in conn.execute(_ENUM_VALUES)} + total = 0 + deferred: set[str] = set() + for description, new_value in water_heating_corrections(stored).items(): + params = {"description": description, "new_value": new_value} + total += conn.execute(_OVERRIDES_COUNT, params).scalar() or 0 + in_enum = new_value in enum_values + if not in_enum: + deferred.add(new_value) + if apply: + conn.execute(_OVERRIDES_UPDATE, params) + if in_enum: + conn.execute(_CACHE_UPDATE, params) + return total, deferred + + +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")) + total, deferred = reclassify(conn, apply=args.apply) + + verb = "re-classified" if args.apply else "would re-classify" + print( + f"{verb} {total} water-heating override row(s) off the house-coal dumping " + "ground onto faithful biomass / wood / dual-fuel / biodiesel / immersion " + "archetypes (property_overrides / TEXT — what the modelling reads)." + ) + if deferred: + print( + f"\n{len(deferred)} target value(s) NOT yet in the water_heating " + "pgEnum — their classifier-cache rows are deferred until the FE-repo " + "enum migration adds these members (property_overrides was still " + "updated, which is what the modelling reads):" + ) + for value in sorted(deferred): + print(f" {value!r}") + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + + +if __name__ == "__main__": + main() From 1debf5f5a3871303419d1d2ffe041ee7b0111887 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 14:02:18 +0000 Subject: [PATCH 10/10] =?UTF-8?q?Assert=20every=20water-heating=20reclassi?= =?UTF-8?q?fy=20target=20round-trips=20to=20a=20resolvable=20archetype=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) --- .../scripts/test_reclassify_water_heating.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/scripts/test_reclassify_water_heating.py b/tests/scripts/test_reclassify_water_heating.py index f59e6d200..c6534a2f6 100644 --- a/tests/scripts/test_reclassify_water_heating.py +++ b/tests/scripts/test_reclassify_water_heating.py @@ -4,6 +4,11 @@ and already-correct rows alone, and is idempotent (#1376 / ADR-0043).""" from __future__ import annotations +from domain.epc.property_overlays.water_heating_overlay import ( + water_heating_overlay_for, +) +from domain.epc.property_overrides.water_heating_guard import water_heating_guard +from domain.epc.property_overrides.water_heating_type import WaterHeatingType from scripts.reclassify_water_heating import water_heating_corrections @@ -58,6 +63,34 @@ def test_house_coal_bucket_rows_are_remapped_to_faithful_fuels() -> None: } +def test_every_guard_target_round_trips_to_a_resolvable_archetype() -> None: + # The reclassify decides via the guard; every archetype the guard can emit for + # the house-coal bucket must be a real member that decodes to SAP water-heating + # codes — a guard/overlay drift can't silently write an unmodellable value to + # the DB (ADR-0043). + house_coal_bucket = [ + "from main heating system: wood logs", + "from main heating system: dual fuel: mineral and wood", + "hot-water only community scheme - boilers: biomass (community)", + "hot-water only community scheme - boilers: heat from boilers using " + "biodiesel from any source (community)", + "no hot water system present - electric immersion assumed: " + "no heating or hot water", + "from main heating system: house coal", + ] + + for description in house_coal_bucket: + member = water_heating_guard(description) + + assert member is not None, description + assert member in WaterHeatingType, description + simulation = water_heating_overlay_for(member.value, 0) + assert simulation is not None, description + assert simulation.heating is not None, description + assert simulation.heating.water_heating_code is not None, description + assert simulation.heating.water_heating_fuel is not None, description + + def test_already_corrected_rows_need_no_change() -> None: # Act / Assert — idempotent: a re-run against corrected data is a no-op. assert (