Add individual wood-logs main-fuel archetype (API 6 / RdSAP Table 32 code 20)

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) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-01 17:00:24 +00:00
parent de9159f9b8
commit 5330bc20d9
7 changed files with 212 additions and 2 deletions

View file

@ -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

View file

@ -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,
}

View file

@ -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 ``"<family>: <fuel>"`` (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

View file

@ -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"

View file

@ -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())

View file

@ -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

View file

@ -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: