"""One-time backfill: NULL the phantom Lodged Performance on predicted Properties. #1361 Class B: ``PropertyBaselineOrchestrator`` used to read Lodged Performance off a predicted Property's neighbour-synthesised EPC, persisting a phantom set of ``lodged_*`` figures on ``property_baseline_performance`` — a *different dwelling's* SAP / band / carbon / Primary Energy Intensity presented as this Property's government-register record. The orchestrator no longer does this (``lodged`` is ``None`` when ``source_path == "predicted"``, ADR-0004 amendment), but the ~12k rows written before the fix still carry the phantom. This NULLs the four ``lodged_*`` columns on every predicted-source baseline row, leaving the **Effective** half, the **bill** block, and ``rebaseline_reason`` untouched (a predicted Property's Effective Performance is correct — it is a first-class modelled output). A predicted-source Property is identified exactly as the orchestrator's ``source_path == "predicted"``: it has a **predicted** EPC and **no lodged** EPC. Site Notes keep their as-surveyed Lodged Performance and so are excluded — though no Site-Notes-sourced EPC exists in ``epc_property`` today, the predicate (a predicted EPC, no lodged EPC) holds regardless. DRY-RUN BY DEFAULT: prints the count it would change and writes nothing. Pass ``--apply`` to execute inside a transaction. **Idempotent** — only rows whose ``lodged_sap_score`` is still non-NULL are touched, so a second run is a no-op. Requires the FE-owned Drizzle ``ALTER ... DROP NOT NULL`` on the four ``lodged_*`` columns to have landed first; without it the UPDATE to NULL violates the constraint. """ from __future__ import annotations import argparse from sqlalchemy import Connection, text from scripts.e2e_common import build_engine, load_env # A predicted-source baseline row: a predicted EPC exists for the property and no # lodged one does (``source_path == "predicted"``). ``lodged_sap_score IS NOT # NULL`` makes it idempotent — a row already nulled is skipped on a re-run. _PREDICTED_PHANTOM_PREDICATE = """ pbp.lodged_sap_score IS NOT NULL AND EXISTS ( SELECT 1 FROM epc_property e WHERE e.property_id = pbp.property_id AND e.source = 'predicted' ) AND NOT EXISTS ( SELECT 1 FROM epc_property e WHERE e.property_id = pbp.property_id AND e.source = 'lodged' ) """ _COUNT = text( f""" SELECT count(*) FROM property_baseline_performance pbp WHERE {_PREDICTED_PHANTOM_PREDICATE} """ ) _NULL_LODGED = text( f""" UPDATE property_baseline_performance AS pbp SET lodged_sap_score = NULL, lodged_epc_band = NULL, lodged_co2_emissions_t_per_yr = NULL, lodged_primary_energy_intensity_kwh_per_m2_yr = NULL WHERE {_PREDICTED_PHANTOM_PREDICATE} """ ) def backfill(conn: Connection, *, apply: bool) -> int: """NULL the four ``lodged_*`` columns on predicted-source baseline rows. Returns the number of phantom rows found (those that ``--apply`` would / did null). Reads the count first so the dry-run reports it without writing. """ found = conn.execute(_COUNT).scalar() or 0 if apply: conn.execute(_NULL_LODGED) return found def main() -> None: load_env() parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--apply", action="store_true", help="execute the update (default: dry-run, writes nothing)", ) args = parser.parse_args() engine = build_engine() with engine.begin() as conn: conn.execute(text("SET statement_timeout = 120000")) found = backfill(conn, apply=args.apply) verb = "NULLed" if args.apply else "would NULL" print( f"{verb} the Lodged Performance (lodged_* → NULL) on {found} " "predicted-source baseline row(s); Effective / bill / rebaseline_reason " "left intact." ) if not args.apply: print("\nDRY-RUN — nothing written. Re-run with --apply to execute.") if __name__ == "__main__": main()