Model/scripts/reclassify_main_heating.py
Khalim Conn-Kowlessar de7bfc5dfe Reclassify Gas CPSU dumping-ground overrides via the generalized main-heating reclassify 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:58:29 +00:00

149 lines
5.7 KiB
Python

"""One-time re-classification of main-heating overrides funnelled into a garbage
drawer for want of a correct archetype (#1376).
Covers both dumping grounds the ``main_heating_guard`` now rescues:
- **HHRSH** (ADR-0044): "High heat retention storage heaters""Electric storage
heaters, old" (SAP 401), the worst old type — 111 rows.
- **Gas CPSU** (ADR-0045): solid-fuel room heaters (open fire / + back boiler /
closed + boiler) and electric "NA"-type boilers → "Gas CPSU" (120, mains gas).
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
archetypes are FE-owned values, so their cache writes are **deferred** until the
Drizzle migration adds them (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
from domain.epc.property_overrides.main_heating_guard import main_heating_guard
from scripts.e2e_common import build_engine, load_env
def main_heating_corrections(
stored: Iterable[tuple[str, str]],
) -> dict[str, str]:
"""``(description, stored override_value)`` → the faithful archetype value, for
the descriptions the main-heating guard resolves (HHRSH, solid-fuel room
heaters, electric boiler) whose stored value differs. 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 main-heating overrides off their garbage-drawer archetypes onto the
faithful ones the guard resolves. 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 main_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} main-heating override row(s) off their garbage-drawer "
"archetypes (old storage / Gas CPSU) onto faithful HHRSH / solid-fuel / "
"electric-boiler archetypes — 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()