diff --git a/scripts/reclassify_hhrsh_storage.py b/scripts/reclassify_hhrsh_storage.py index da4fbce82..952b205b7 100644 --- a/scripts/reclassify_hhrsh_storage.py +++ b/scripts/reclassify_hhrsh_storage.py @@ -1,12 +1,145 @@ """One-time re-classification of HHRSH overrides funnelled into old storage. -Placeholder — decision function stubbed; filled in GREEN. +#1376 / ADR-0044: with no High Heat Retention archetype, the LLM classified +"Electric Storage Systems: High heat retention storage heaters" to +``"Electric storage heaters, old"`` (SAP 401) — scoring the *best* modern storage +as the *worst* old large-volume type. An audit found 111 such rows. + +The live classifier now applies ``main_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_main_heating_system_overrides`` +``value`` classifier cache is a ``main_heating_system`` **pgEnum**; the new HHRSH +archetype is an FE-owned value, so its cache write is **deferred** until the +Drizzle migration adds it (the Class-A/B pattern). + +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 hhrsh_storage_corrections(stored: Iterable[tuple[str, str]]) -> dict[str, str]: - raise NotImplementedError +from domain.epc.property_overrides.main_heating_guard import main_heating_guard +from scripts.e2e_common import build_engine, load_env + + +def hhrsh_storage_corrections( + stored: Iterable[tuple[str, str]], +) -> dict[str, str]: + """``(description, stored override_value)`` → the HHRSH archetype value, for the + descriptions the main-heating guard resolves to HHRSH whose stored value is not + already it. Descriptions the guard defers 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 = main_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 = 'main_heating_system' + """ +) +_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 + """ +) +_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_UPDATE = text( + """ + UPDATE landlord_main_heating_system_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 = 'main_heating_system'" +) + + +def reclassify(conn: Connection, *, apply: bool) -> tuple[int, set[str]]: + """Re-map old-storage HHRSH overrides onto the high-heat-retention archetype. + Returns the number of ``property_overrides`` rows found and the set of target + values the live ``main_heating_system`` 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 hhrsh_storage_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} high-heat-retention storage override row(s) off " + "'Electric storage heaters, old' onto the HHRSH archetype (SAP 409) — " + "property_overrides / TEXT, what the modelling reads." + ) + if deferred: + print( + f"\n{len(deferred)} target value(s) NOT yet in the main_heating_system " + "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()