diff --git a/scripts/reclassify_mixed_glazing.py b/scripts/reclassify_mixed_glazing.py new file mode 100644 index 000000000..3cfc507ce --- /dev/null +++ b/scripts/reclassify_mixed_glazing.py @@ -0,0 +1,147 @@ +"""One-time re-classification of aggregate-mix glazing overrides flattened to Double. + +#1376 / ADR-0042: a landlord glazing description that is an aggregate mix +("40% double glazing 2002 or later, 60% single glazing") was classified to a single +``"Double glazing…"`` value whenever any double was present, and the overlay then +overwrote **every** window to double — over-crediting a predominantly-single dwelling +and clobbering the cert's per-window glazing. A whole-dwelling proportion cannot say +*which* windows are which, so a mix must resolve to ``GlazingType.MIXED`` (no overlay +→ the cert's per-window ``sap_windows`` are kept). + +The live classifier now applies ``glazing_mix_guard`` deterministically (so new +intakes of the structured split 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). The ``landlord_glazing_overrides.value`` classifier cache is a +``glazing`` **pgEnum**; ``MIXED`` is a new FE-owned value, so cache writes are +**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 + +from domain.epc.property_overrides.glazing_mix_guard import glazing_mix_guard +from scripts.e2e_common import build_engine, load_env + + +def mixed_glazing_corrections( + stored: Iterable[tuple[str, str]], +) -> dict[str, str]: + """``(description, stored override_value)`` → the ``MIXED`` value, for the + descriptions the glazing mix guard resolves to a mix whose stored value is not + already ``MIXED``. Uniform / unparseable descriptions and rows already on + ``MIXED`` are omitted, so re-running against corrected data is a no-op.""" + corrections: dict[str, str] = {} + for description, value in stored: + member = glazing_mix_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 = 'glazing' + """ +) +_OVERRIDES_UPDATE = text( + """ + UPDATE property_overrides + SET override_value = :new_value + WHERE override_component = 'glazing' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_OVERRIDES_COUNT = text( + """ + SELECT count(*) FROM property_overrides + WHERE override_component = 'glazing' + AND lower(original_spreadsheet_description) = :description + AND override_value <> :new_value + """ +) +_CACHE_UPDATE = text( + """ + UPDATE landlord_glazing_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 = 'glazing'" +) + + +def reclassify(conn: Connection, *, apply: bool) -> tuple[int, set[str]]: + """Re-map aggregate-mix glazing overrides onto MIXED. Returns the number of + ``property_overrides`` rows found and the set of target values the live + ``glazing`` 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 mixed_glazing_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} aggregate-mix glazing override row(s) from a flattened " + "Double onto MIXED (property_overrides / TEXT); the cert's per-window " + "glazing is kept." + ) + if deferred: + print( + f"\n{len(deferred)} target value(s) NOT yet in the glazing 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() diff --git a/tests/scripts/test_reclassify_mixed_glazing.py b/tests/scripts/test_reclassify_mixed_glazing.py new file mode 100644 index 000000000..0a2f49447 --- /dev/null +++ b/tests/scripts/test_reclassify_mixed_glazing.py @@ -0,0 +1,35 @@ +"""The mixed-glazing reclassify maps a flattened aggregate mix onto MIXED, leaves +uniform / already-correct rows alone, and is idempotent (#1376 / ADR-0042).""" + +from __future__ import annotations + +from scripts.reclassify_mixed_glazing import mixed_glazing_corrections + + +def test_flattened_mix_rows_are_corrected_to_mixed() -> None: + # Arrange — an aggregate mix flattened to Double (the bug), a near-uniform mix + # and a clean uniform row (both correctly Double), and a pure single row. + stored = [ + ("40% double glazing 2002 or later, 60% single glazing", "Double glazing, 2002 or later"), + ("96% double glazing 2002 or later, 4% single glazing", "Double glazing, 2002 or later"), + ("100% double glazing 2002 or later", "Double glazing, 2002 or later"), + ("100% single glazing", "Single glazing"), + ] + + # Act + corrections = mixed_glazing_corrections(stored) + + # Assert — only the genuine mix is re-mapped; near-uniform and uniform stay. + assert corrections == { + "40% double glazing 2002 or later, 60% single glazing": "Mixed glazing" + } + + +def test_already_mixed_rows_need_no_change() -> None: + # Act / Assert — idempotent. + assert ( + mixed_glazing_corrections( + [("40% double glazing 2002 or later, 60% single glazing", "Mixed glazing")] + ) + == {} + )