mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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) <noreply@anthropic.com>
176 lines
7.5 KiB
Python
176 lines
7.5 KiB
Python
"""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()
|