mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
parent
de9159f9b8
commit
5330bc20d9
7 changed files with 212 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
28
domain/epc/property_overrides/main_fuel_guard.py
Normal file
28
domain/epc/property_overrides/main_fuel_guard.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
124
scripts/lisasrequest/reclassify_wood_logs_main_fuel.py
Normal file
124
scripts/lisasrequest/reclassify_wood_logs_main_fuel.py
Normal 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())
|
||||
43
tests/domain/epc/test_main_fuel_guard.py
Normal file
43
tests/domain/epc/test_main_fuel_guard.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue