From 5330bc20d957be9ee2286553cac93e3f0b9f9d80 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 1 Jul 2026 17:00:24 +0000 Subject: [PATCH] Add individual wood-logs main-fuel archetype (API 6 / RdSAP Table 32 code 20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainFuelType had no individual wood-logs member — only "biomass (community)" — so the LLM classifier funnelled "Solid Fuel: Wood Logs" into the community fuel, inventing a community heat network the dwelling isn't on (and mislabelling the connection). main_fuel had no deterministic guard at all, so nothing caught it. Verified against domain/sap10_calculator/docs/specs: RdSAP 10 Specification Table 32 lists "wood logs" as a solid fuel (code 20, 0.028 kgCO2e/kWh); the calculator's input scheme (the gov EPC API fuel enum) codes it 6 -> Table 32 20 (sap_efficiencies._API_TO_TABLE32), and water_heating_overlay already pins the same fuel to 6. So _FUEL_CODES["wood logs"] = 6 is confirmed, not guessed. Adds MainFuelType.WOOD_LOGS + the _FUEL_CODES entry, a main_fuel_guard mirroring water_heating_guard (claims the "wood log" token; dual fuel keeps its own member since it has no "wood log" substring), and wires main_fuel through a GuardedColumnClassifier so the live path is deterministic. Applied the scoped backfill to portfolio 796 (Hyde): 21 rows off "biomass (community)" -> "wood logs". property_overrides (TEXT) only; the classifier-cache pgEnum member is deferred to the FE Drizzle migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../landlord_description_overrides/handler.py | 11 +- .../property_overlays/main_fuel_overlay.py | 4 + .../epc/property_overrides/main_fuel_guard.py | 28 ++++ .../epc/property_overrides/main_fuel_type.py | 1 + .../reclassify_wood_logs_main_fuel.py | 124 ++++++++++++++++++ tests/domain/epc/test_main_fuel_guard.py | 43 ++++++ tests/domain/epc/test_main_fuel_overlay.py | 3 + 7 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 domain/epc/property_overrides/main_fuel_guard.py create mode 100644 scripts/lisasrequest/reclassify_wood_logs_main_fuel.py create mode 100644 tests/domain/epc/test_main_fuel_guard.py diff --git a/applications/landlord_description_overrides/handler.py b/applications/landlord_description_overrides/handler.py index 6d87490e9..43b6f0a98 100644 --- a/applications/landlord_description_overrides/handler.py +++ b/applications/landlord_description_overrides/handler.py @@ -12,6 +12,7 @@ from domain.epc.property_overrides.construction_age_band import ConstructionAgeB from domain.epc.property_overrides.glazing_type import GlazingType from domain.epc.property_overrides.glazing_mix_guard import glazing_mix_guard from domain.epc.property_overrides.main_fuel_type import MainFuelType +from domain.epc.property_overrides.main_fuel_guard import main_fuel_guard from domain.epc.property_overrides.main_heating_system_type import MainHeatingSystemType from domain.epc.property_overrides.main_heating_guard import main_heating_guard from domain.epc.property_overrides.property_type import PropertyType @@ -139,8 +140,14 @@ def _build_columns( "main_fuel": lambda src: ClassifiableColumn( name="main_fuel", source_column=src, - classifier=ChatGptColumnClassifier( - chat_gpt, MainFuelType, MainFuelType.UNKNOWN + # An individual wood-logs fuel had no LLM target and was funnelled into + # "biomass (community)"; the deterministic guard resolves it and the LLM + # handles the rest (#1376). + classifier=GuardedColumnClassifier( + guard=main_fuel_guard, + fallback=ChatGptColumnClassifier( + chat_gpt, MainFuelType, MainFuelType.UNKNOWN + ), ), repo=LandlordOverridesRepository[MainFuelType]( session, LandlordMainFuelOverrideRow diff --git a/domain/epc/property_overlays/main_fuel_overlay.py b/domain/epc/property_overlays/main_fuel_overlay.py index 3827b7524..a49b05e15 100644 --- a/domain/epc/property_overlays/main_fuel_overlay.py +++ b/domain/epc/property_overlays/main_fuel_overlay.py @@ -28,6 +28,10 @@ _FUEL_CODES: dict[str, int] = { "house coal": 33, "smokeless coal": 15, "dual fuel (mineral and wood)": 10, + # Individual wood-logs solid fuel — API fuel enum 6 (RdSAP Table 32 code 20, + # 0.028 kgCO2e/kWh), matching the water overlay's `wood logs` fuel. Distinct + # from biomass (community) 31; the LLM funnelled it there for want of a member. + "wood logs": 6, "biomass (community)": 31, } diff --git a/domain/epc/property_overrides/main_fuel_guard.py b/domain/epc/property_overrides/main_fuel_guard.py new file mode 100644 index 000000000..5f499b352 --- /dev/null +++ b/domain/epc/property_overrides/main_fuel_guard.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Optional + +from domain.epc.property_overrides.main_fuel_type import MainFuelType + + +def main_fuel_guard(description: str) -> Optional[MainFuelType]: + """Deterministically resolve an individual wood-logs main fuel, which the LLM + funnelled into ``"biomass (community)"`` for want of a wood-logs member (#1376). + + A landlord main-fuel description arrives as ``": "`` (e.g. + ``"Solid Fuel: Wood Logs"``). With no individual wood-logs archetype the + classifier force-picked ``BIOMASS_COMMUNITY`` — inventing a community heat + network the dwelling isn't on (0.028 kgCO2e/kWh is right by luck, but the + community flag mis-costs it and mislabels the connection). Wood logs is a real + RdSAP solid fuel (API enum 6 / Table 32 code 20), so this guard claims it. + + Mirrors ``water_heating_guard`` (which already rescues the same fuel token for + DHW, ADR-0043). Returns ``None`` for fuels the LLM already classifies correctly + (mains gas, electricity, oil, dual fuel, genuine community biomass) and for + varied phrasings, so those still reach the LLM via ``GuardedColumnClassifier``. + ``"dual fuel: mineral and wood"`` contains ``"wood"`` but not ``"wood log"``, + so it keeps its own ``DUAL_FUEL_MINERAL_WOOD`` member. + """ + if "wood log" in description.lower(): + return MainFuelType.WOOD_LOGS + return None diff --git a/domain/epc/property_overrides/main_fuel_type.py b/domain/epc/property_overrides/main_fuel_type.py index 8427b9cf0..24224210b 100644 --- a/domain/epc/property_overrides/main_fuel_type.py +++ b/domain/epc/property_overrides/main_fuel_type.py @@ -25,5 +25,6 @@ class MainFuelType(Enum): HOUSE_COAL = "house coal" SMOKELESS_COAL = "smokeless coal" DUAL_FUEL_MINERAL_WOOD = "dual fuel (mineral and wood)" + WOOD_LOGS = "wood logs" BIOMASS_COMMUNITY = "biomass (community)" UNKNOWN = "Unknown" diff --git a/scripts/lisasrequest/reclassify_wood_logs_main_fuel.py b/scripts/lisasrequest/reclassify_wood_logs_main_fuel.py new file mode 100644 index 000000000..8d95b72d9 --- /dev/null +++ b/scripts/lisasrequest/reclassify_wood_logs_main_fuel.py @@ -0,0 +1,124 @@ +"""Backfill main-fuel overrides where an individual wood-logs dwelling was funnelled +into "biomass (community)". + +The ``MainFuelType`` taxonomy had no individual wood-logs member (only +``biomass (community)``), so the LLM force-picked the community fuel for +``"Solid Fuel: Wood Logs"`` — inventing a community heat network the dwelling +isn't on. ``MainFuelType.WOOD_LOGS`` (API fuel enum 6 / RdSAP Table 32 code 20) +now exists and ``main_fuel_guard`` resolves the wood-logs token deterministically, +so this fixes the rows written before that change. + +Uses the SAME guard as the live path, so the backfill and the classifier cannot +drift. SCOPED TO ONE PORTFOLIO (``--portfolio``, default 796 = Hyde) and only +touches ``property_overrides.override_value`` (TEXT — what the modelling reads); +the global ``landlord_main_fuel_overrides`` classifier cache is left alone (its +``value`` is the Drizzle-owned pgEnum, which needs the FE migration to add the +member first — the Class-A/B deferral Khalim's scripts use). + +DRY-RUN BY DEFAULT: prints what it would change and writes nothing. Pass +``--apply`` to execute inside a transaction; it also writes an audit CSV of every +row changed (property_id, uprn, old value, new value) so the change is reversible. +Idempotent — only rows whose stored value differs from the guard's target member +are touched. + + python -m scripts.lisasrequest.reclassify_wood_logs_main_fuel # dry run + python -m scripts.lisasrequest.reclassify_wood_logs_main_fuel --apply # write +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from collections import Counter +from pathlib import Path + +from sqlalchemy import text + +_REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(_REPO_ROOT)) + +from domain.epc.property_overrides.main_fuel_guard import main_fuel_guard # noqa: E402 +from scripts.e2e_common import ENV_PATH, build_engine, load_env # noqa: E402 + +_SELECT = text( + """ + SELECT po.property_id, pr.uprn, + po.original_spreadsheet_description AS description, + po.override_value AS value + FROM property_overrides po + JOIN property pr ON pr.id = po.property_id + WHERE po.portfolio_id = :portfolio + AND po.override_component = 'main_fuel' + """ +) +_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE portfolio_id = :portfolio + AND override_component = 'main_fuel' + AND property_id = :property_id + AND override_value <> :new_value + """ +) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--portfolio", type=int, default=796) + parser.add_argument( + "--apply", + action="store_true", + help="execute the updates (default: dry-run, writes nothing)", + ) + args = parser.parse_args() + + load_env(ENV_PATH) + engine = build_engine() + + audit: list[tuple[int, object, str, str, str]] = [] # pid, uprn, descr, old, new + tally: Counter[str] = Counter() + with engine.begin() as conn: + conn.execute(text("SET statement_timeout = 120000")) + for property_id, uprn, description, value in conn.execute( + _SELECT, {"portfolio": args.portfolio} + ): + member = main_fuel_guard(description or "") + if member is None or value == member.value: + continue + tally[f"{value!r} -> {member.value!r}"] += 1 + audit.append((property_id, uprn, description, value, member.value)) + if args.apply: + conn.execute( + _UPDATE, + { + "portfolio": args.portfolio, + "property_id": property_id, + "new_value": member.value, + }, + ) + if not args.apply: + conn.rollback() + + verb = "re-classified" if args.apply else "would re-classify" + print(f"portfolio {args.portfolio}: {verb} {len(audit)} main-fuel override row(s)") + for change, n in tally.most_common(): + print(f" {n:5d} {change}") + + if args.apply and audit: + out = _REPO_ROOT / "scripts" / "lisasrequest" / ( + f"reclassify_wood_logs_main_fuel_{args.portfolio}_audit.csv" + ) + with out.open("w", newline="") as fh: + w = csv.writer(fh) + w.writerow(["property_id", "uprn", "description", "old_value", "new_value"]) + w.writerows(audit) + print(f"\naudit trail: {out}") + if not args.apply: + print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/domain/epc/test_main_fuel_guard.py b/tests/domain/epc/test_main_fuel_guard.py new file mode 100644 index 000000000..deac813ba --- /dev/null +++ b/tests/domain/epc/test_main_fuel_guard.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import pytest + +from domain.epc.property_overrides.main_fuel_guard import main_fuel_guard +from domain.epc.property_overrides.main_fuel_type import MainFuelType + + +def test_guard_resolves_individual_wood_logs() -> None: + # Arrange — the bug: with no individual wood-logs member, the LLM funnelled a + # solid-fuel wood-logs dwelling into "biomass (community)" (inventing a + # community heat network it isn't on). + description = "Solid Fuel: Wood Logs" + + # Act + result = main_fuel_guard(description) + + # Assert — an individual wood-logs fuel, never community biomass. + assert result is MainFuelType.WOOD_LOGS + + +@pytest.mark.parametrize( + "description", + [ + # Fuels the LLM already classifies correctly — the guard only rescues the + # wood-logs gap and leaves the rest to the fallback classifier. + "Gas: Mains Gas", + "Electricity: Electricity", + "Oil and Liquid Fuels: Oil", + # Dual fuel contains "wood" but not "wood log" — it has its own member (10) + # and must not be stolen by the wood-logs rule. + "Solid Fuel: Dual Fuel: Mineral and Wood", + # Genuine community biomass keeps its community member. + "Other Heat: Biomass (Community)", + "", + ], +) +def test_guard_defers_other_fuels_to_the_llm(description: str) -> None: + # Act + result = main_fuel_guard(description) + + # Assert + assert result is None diff --git a/tests/domain/epc/test_main_fuel_overlay.py b/tests/domain/epc/test_main_fuel_overlay.py index 1e477ba98..87376acc6 100644 --- a/tests/domain/epc/test_main_fuel_overlay.py +++ b/tests/domain/epc/test_main_fuel_overlay.py @@ -56,6 +56,9 @@ def test_fuels_decode_to_their_modern_not_community_codes( ("biomass (community)", 31), ("dual fuel (mineral and wood)", 10), ("smokeless coal", 15), + # Wood logs — API fuel enum 6 (RdSAP Table 32 code 20). An individual + # solid-fuel dwelling, distinct from biomass (community) 31. + ("wood logs", 6), ], ) def test_more_fuels_decode_to_their_codes(main_fuel_value: str, code: int) -> None: