mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Re-classify aggregate-mix glazing overrides flattened to Double onto MIXED 🟩
One-time script (dry-run default, --apply in a transaction, idempotent) reusing the live glazing_mix_guard so the backfill and classifier cannot drift. Maps every genuine percentage-mix override (neither type >= 90%) off a flattened single type onto MIXED (no overlay -> the cert's per-window glazing is kept). property_overrides TEXT updated now; the glazing pgEnum cache write for 'Mixed glazing' is deferred until the FE adds the member. Dry-run against the audited DB reports 647 rows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1500725d1d
commit
0ebff1bcc2
2 changed files with 182 additions and 0 deletions
147
scripts/reclassify_mixed_glazing.py
Normal file
147
scripts/reclassify_mixed_glazing.py
Normal file
|
|
@ -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()
|
||||
35
tests/scripts/test_reclassify_mixed_glazing.py
Normal file
35
tests/scripts/test_reclassify_mixed_glazing.py
Normal file
|
|
@ -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")]
|
||||
)
|
||||
== {}
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue