Model/scripts/backfill_boiler_efficiency_band.py
Khalim Conn-Kowlessar eb07b91614 review: guard-only band classifier, backfill count, docstring (PR #1713)
Addresses reviewer feedback:
- #3 (Khalim + kimjunte): the boiler_efficiency_band classifier column is now
  GUARD-ONLY — LLM fallback removed. The band format is deterministic, so an LLM
  guess could only fabricate a band the landlord never stated (moves SAP +
  eligibility). New LoggingUnknownColumnClassifier is the non-fabricating fallback:
  maps guard-misses to UNKNOWN (never stored) and logs them for review — kimjunte's
  'warn on None', placed in the classifier path not the shared guard (which sees
  legitimate None constantly on the modelling/backfill paths).
- #2 (Khalim): backfill --apply now reports rows ACTUALLY written (upsert
  rowcount), not the candidate count, so an idempotent re-run reports 0.
- #1 (Khalim): overlay module docstring scrubbed of stale 'slot/pending' wording
  to match the cert-native anchor mechanism.

kimjunte's finaliser optional-skip question (declared-vs-shared-column flow) left
for reviewer alignment, not changed. 529 tests green; pyright clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 14:57:11 +00:00

187 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Backfill the ``boiler_efficiency_band`` override for already-ingested boilers.
The SEDBUK band (ADR-0068) is losslessly present in every ``main_heating_system``
override's ``original_spreadsheet_description`` (``Boiler: C rated Combi``) but was
dropped at classification. This one-off populates the new ``boiler_efficiency_band``
override rows for the existing stock by re-parsing that text with the SAME guard the
live classifier uses (``boiler_efficiency_band_guard``), so the backfill and the
forward path cannot drift.
One ``boiler_efficiency_band`` row per ``main_heating_system`` row whose description
carries a band (``A````G``), keyed to the same ``(property_id, building_part)``.
Descriptions with no band (a plain boiler, a non-boiler heating system) get no row.
The modelling gate (gas/oil boilers only, ADR-0068) is applied at overlay time, not
here — so a band parsed off an electric ``Boiler: A rated NA`` is stored for
fidelity but ignored by the calculator, exactly as the live classifier stores it.
GATED: the ``boiler_efficiency_band`` value of the FE-owned ``override_component``
pgEnum must exist first (the Drizzle migration in the assessment-model repo). Until
then this writes nothing in prod — the Class-A/B deferred pattern.
DRY-RUN BY DEFAULT: prints the counts it would write and writes nothing. Pass
``--apply`` to execute inside a transaction. Idempotent — a row already carrying the
target band is left untouched, so re-running is a no-op.
"""
from __future__ import annotations
import argparse
from collections.abc import Iterable
from dataclasses import dataclass
from sqlalchemy import Connection, text
from domain.epc.property_overrides.boiler_efficiency_band import BoilerEfficiencyBand
from domain.epc.property_overrides.boiler_efficiency_band_guard import (
boiler_efficiency_band_guard,
)
from scripts.e2e_common import build_engine, load_env
_OVERRIDE_COMPONENT = "boiler_efficiency_band"
@dataclass(frozen=True)
class MainHeatingRow:
"""The fields of a ``main_heating_system`` ``property_overrides`` row the
backfill reads."""
property_id: int
portfolio_id: int
building_part: int
original_spreadsheet_description: str
@dataclass(frozen=True)
class BandBackfillRow:
"""A ``boiler_efficiency_band`` override row to upsert for a boiler."""
property_id: int
portfolio_id: int
building_part: int
override_value: str
original_spreadsheet_description: str
def band_backfill_rows(rows: Iterable[MainHeatingRow]) -> list[BandBackfillRow]:
"""The ``boiler_efficiency_band`` rows to write for a set of
``main_heating_system`` rows — one per row whose description carries a
parseable SEDBUK band, keyed to the same property + building part. Rows with
no band (or the ``UNKNOWN`` sentinel) produce nothing.
Pure and DB-free, so the parse/decide logic is unit-tested without a
database; the connection wrapper below just feeds it rows and upserts."""
backfill: list[BandBackfillRow] = []
for row in rows:
band = boiler_efficiency_band_guard(row.original_spreadsheet_description)
if band is None or band is BoilerEfficiencyBand.UNKNOWN:
continue
backfill.append(
BandBackfillRow(
property_id=row.property_id,
portfolio_id=row.portfolio_id,
building_part=row.building_part,
override_value=band.value,
original_spreadsheet_description=row.original_spreadsheet_description,
)
)
return backfill
_SELECT_MAIN_HEATING = text(
"""
SELECT property_id, portfolio_id, building_part, original_spreadsheet_description
FROM property_overrides
WHERE override_component = 'main_heating_system'
"""
)
# Idempotent upsert on the (property, component, part) unique constraint — a row
# already carrying the target band is left untouched (updated_at unchanged).
_UPSERT_BAND = text(
"""
INSERT INTO property_overrides
(id, property_id, portfolio_id, building_part, override_component,
override_value, original_spreadsheet_description, created_at, updated_at)
VALUES
(gen_random_uuid(), :property_id, :portfolio_id, :building_part,
'boiler_efficiency_band', :override_value, :original_spreadsheet_description,
now(), now())
ON CONFLICT (property_id, override_component, building_part)
DO UPDATE SET override_value = EXCLUDED.override_value,
original_spreadsheet_description =
EXCLUDED.original_spreadsheet_description,
updated_at = now()
WHERE property_overrides.override_value <> EXCLUDED.override_value
"""
)
def backfill(conn: Connection, *, apply: bool) -> int:
"""Backfill the ``boiler_efficiency_band`` rows off the existing
``main_heating_system`` descriptions.
In ``--apply`` returns the number of rows **actually written** — the upsert's
``WHERE override_value <> EXCLUDED.override_value`` no-ops rows already at the
target band, so an idempotent re-run reports 0, not the candidate count. In
dry-run returns the number of **candidate** rows (it cannot know how many
differ without writing)."""
rows = [
MainHeatingRow(
property_id=r.property_id,
portfolio_id=r.portfolio_id,
building_part=r.building_part,
original_spreadsheet_description=r.original_spreadsheet_description or "",
)
for r in conn.execute(_SELECT_MAIN_HEATING)
]
to_write = band_backfill_rows(rows)
if not apply:
return len(to_write)
written = 0
for band_row in to_write:
result = conn.execute(
_UPSERT_BAND,
{
"property_id": band_row.property_id,
"portfolio_id": band_row.portfolio_id,
"building_part": band_row.building_part,
"override_value": band_row.override_value,
"original_spreadsheet_description": (
band_row.original_spreadsheet_description
),
},
)
written += result.rowcount or 0
return written
def main() -> None:
load_env()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--apply",
action="store_true",
help="execute the writes (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 = backfill(conn, apply=args.apply)
noun = "row(s) written" if args.apply else "candidate row(s)"
print(
f"{total} boiler_efficiency_band {noun} from the existing "
"main_heating_system descriptions (Boiler: <A-G> rated ...). The modelling "
"gate (gas/oil boilers only) is applied at overlay time (ADR-0068)."
)
if args.apply:
print(
"(Rows already at their target band are no-ops, so a re-run reports 0.)"
)
else:
print("\nDRY-RUN — nothing written. Re-run with --apply to execute.")
if __name__ == "__main__":
main()