From 51abd152894b7430638ac919d28bfc9b7dc51a18 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 1 Jul 2026 13:59:44 +0000 Subject: [PATCH] =?UTF-8?q?Re-map=20house-coal=20water-heating=20overrides?= =?UTF-8?q?=20onto=20faithful=20biomass=20and=20immersion=20archetypes=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/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()