mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
A focused sibling to audit-ara-portfolio: that skill audits baselines/plans/SAP; this one audits the *recommendations themselves* — why a measure was or wasn't offered. Motivated by the portfolio-814 review (Khalim's HHRSH-on-community- heating, missing-HHRSH, missing-secondary-heating-removal, and a neighbour split). Adds: - .claude/skills/find-weird-recommendations/SKILL.md — scan -> neighbour scan -> live re-model deep-dive -> root-cause -> codify, with a seeded known-bug catalogue and the query-safety rules inherited from audit-ara-portfolio. - scripts/audit/anomalies.py: new `plan-stops-short-of-goal` HIGH check — the default plan ends below the goal band on an unlimited-budget scenario (the deterministic worklist for "why didn't this get recommended X"). Adds scenario_budget to the bundle/query so budget-capped scenarios are excluded. - scripts/audit/neighbour_divergence.py: groups a portfolio by (postcode, property_type, built_form) and flags effective-SAP outliers vs the cohort median. Never touches the 26m-row recommendation table, so it is safe portfolio-wide. - Tests for both (12 passing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
226 lines
8.4 KiB
Python
226 lines
8.4 KiB
Python
"""Flag neighbouring dwellings that were modelled *differently despite looking
|
|
the same* — the SAP half of the "neighbours should agree" heuristic.
|
|
|
|
Motivation (portfolio 814): Khalim found a dwelling that gets an HHRSH bundle
|
|
while its next-door neighbour of the same type does not. Neighbours in one
|
|
postcode, of the same property type and built form, are usually near-identical
|
|
stock; a big split in their *effective baseline SAP* is a strong tell that one of
|
|
the pair was mis-mapped, mis-overridden, or mis-rebaselined — and a SAP split is
|
|
what then drives a recommendation split. This script surfaces those pairs
|
|
cheaply so the deep-dive (``run_modelling_e2e`` on both neighbours) can compare
|
|
their actual measure sets and root-cause the divergence.
|
|
|
|
It reaches only ``property`` + ``epc_property`` + ``property_baseline_performance``,
|
|
all via the indexed ``property_id`` — it NEVER touches the 26m-row
|
|
``recommendation`` table, so it is safe to run portfolio-wide (unlike the
|
|
measure-set comparison, which the skill does per flagged cohort in the deep-dive).
|
|
|
|
Run:
|
|
python -m scripts.audit.neighbour_divergence --portfolio 814
|
|
python -m scripts.audit.neighbour_divergence --portfolio 814 --min-gap 15
|
|
|
|
Writes ``neighbour_divergence.md`` + ``neighbour_divergence.csv`` and prints a
|
|
summary. Read-only: never writes to the DB.
|
|
|
|
A cohort is the set of a portfolio's properties sharing (postcode, property_type,
|
|
built_form). Within a cohort of >= 2, any member whose effective SAP is >=
|
|
``--min-gap`` points from the cohort median is flagged. Floor area is reported,
|
|
not keyed on, so the reviewer can discount a genuinely bigger/smaller neighbour;
|
|
promoting an area guard into the cohort key is a clean future change once the
|
|
false-positive rate is measured on a real portfolio (skill Phase 6).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
from dataclasses import dataclass
|
|
from statistics import median
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import text
|
|
|
|
from scripts.e2e_common import build_engine, load_env
|
|
|
|
# Hard ceiling so a bad plan aborts instead of saturating the shared DB, mirroring
|
|
# scripts/audit/anomalies.py. Every table here is reached via the indexed
|
|
# property_id and scoped to one portfolio, so this is a backstop, not a crutch.
|
|
_STATEMENT_TIMEOUT_MS = 120_000
|
|
|
|
# Default SAP gap (points from the cohort median) at which a neighbour is flagged.
|
|
# 12 is a starting threshold, not a tuned one — a full band is ~10-11 SAP points,
|
|
# so >= 12 means the neighbour sits a clear band apart from otherwise-identical
|
|
# stock. Re-justify against a real portfolio's distribution before trusting it
|
|
# (skill Phase 6); expose it as --min-gap so a run can sweep the threshold.
|
|
_DEFAULT_MIN_GAP = 12.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Neighbour:
|
|
"""One modelled property placed in its postcode/type/form cohort."""
|
|
|
|
property_id: int
|
|
uprn: Optional[int]
|
|
postcode: Optional[str]
|
|
property_type: Optional[str]
|
|
built_form: Optional[str]
|
|
floor_area_m2: Optional[float]
|
|
effective_sap: Optional[float]
|
|
effective_band: Optional[str]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Divergence:
|
|
property_id: int
|
|
uprn: Optional[int]
|
|
cohort_key: str
|
|
cohort_size: int
|
|
detail: str
|
|
|
|
|
|
# DISTINCT ON picks the latest ingested epc_property row per property (its
|
|
# property_id is NOT unique — ingestion keeps history), ordered by id DESC.
|
|
_QUERY = text(
|
|
"""
|
|
SELECT p.id, p.uprn, p.postcode,
|
|
ep.property_type, ep.built_form, ep.total_floor_area_m2,
|
|
pbp.effective_sap_score, pbp.effective_epc_band
|
|
FROM property p
|
|
JOIN property_baseline_performance pbp ON pbp.property_id = p.id
|
|
LEFT JOIN LATERAL (
|
|
SELECT property_type, built_form, total_floor_area_m2
|
|
FROM epc_property e
|
|
WHERE e.property_id = p.id
|
|
ORDER BY e.id DESC
|
|
LIMIT 1
|
|
) ep ON TRUE
|
|
WHERE p.portfolio_id = :portfolio_id
|
|
ORDER BY p.id
|
|
"""
|
|
)
|
|
|
|
|
|
def _load(portfolio_id: int) -> list[Neighbour]:
|
|
engine = build_engine()
|
|
out: list[Neighbour] = []
|
|
with engine.connect() as conn:
|
|
conn.execute(text(f"SET statement_timeout = {_STATEMENT_TIMEOUT_MS}"))
|
|
for row in conn.execute(_QUERY, {"portfolio_id": portfolio_id}):
|
|
m = row._mapping # noqa: SLF001 — SQLAlchemy row mapping, mirrors anomalies.py
|
|
out.append(
|
|
Neighbour(
|
|
property_id=m["id"],
|
|
uprn=m["uprn"],
|
|
postcode=m["postcode"],
|
|
property_type=m["property_type"],
|
|
built_form=m["built_form"],
|
|
floor_area_m2=m["total_floor_area_m2"],
|
|
effective_sap=m["effective_sap_score"],
|
|
effective_band=m["effective_epc_band"],
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
def _cohort_key(n: Neighbour) -> Optional[str]:
|
|
"""A stable cohort label, or None when the neighbour can't be placed (no
|
|
postcode or no property type — it has no comparable peers to diverge from)."""
|
|
if not n.postcode or not n.property_type:
|
|
return None
|
|
form = n.built_form or "?"
|
|
return f"{n.postcode.strip().upper()} · {n.property_type} · {form}"
|
|
|
|
|
|
def find_divergences(
|
|
neighbours: list[Neighbour], min_gap: float
|
|
) -> list[Divergence]:
|
|
cohorts: dict[str, list[Neighbour]] = {}
|
|
for n in neighbours:
|
|
key = _cohort_key(n)
|
|
if key is None or n.effective_sap is None:
|
|
continue
|
|
cohorts.setdefault(key, []).append(n)
|
|
|
|
found: list[Divergence] = []
|
|
for key, members in cohorts.items():
|
|
if len(members) < 2:
|
|
continue
|
|
saps = [m.effective_sap for m in members if m.effective_sap is not None]
|
|
cohort_median = median(saps)
|
|
for m in members:
|
|
if m.effective_sap is None:
|
|
continue
|
|
gap = m.effective_sap - cohort_median
|
|
if abs(gap) < min_gap:
|
|
continue
|
|
area = f"{m.floor_area_m2:.0f}m²" if m.floor_area_m2 is not None else "?m²"
|
|
found.append(
|
|
Divergence(
|
|
property_id=m.property_id,
|
|
uprn=m.uprn,
|
|
cohort_key=key,
|
|
cohort_size=len(members),
|
|
detail=(
|
|
f"effective SAP {m.effective_sap:.0f} ({m.effective_band}) "
|
|
f"vs cohort median {cohort_median:.0f} (Δ{gap:+.0f}, "
|
|
f"{area}, {len(members)} neighbours)"
|
|
),
|
|
)
|
|
)
|
|
found.sort(key=lambda d: (d.cohort_key, d.property_id))
|
|
return found
|
|
|
|
|
|
def _write_reports(divergences: list[Divergence], scanned: int) -> None:
|
|
with open("neighbour_divergence.csv", "w", newline="") as f:
|
|
w = csv.writer(f)
|
|
w.writerow(["property_id", "uprn", "cohort", "cohort_size", "detail"])
|
|
for d in divergences:
|
|
w.writerow([d.property_id, d.uprn, d.cohort_key, d.cohort_size, d.detail])
|
|
|
|
by_cohort: dict[str, list[Divergence]] = {}
|
|
for d in divergences:
|
|
by_cohort.setdefault(d.cohort_key, []).append(d)
|
|
lines = [
|
|
"# Neighbour SAP-divergence audit",
|
|
"",
|
|
f"Scanned **{scanned}** properties · flagged **{len(divergences)}** "
|
|
f"divergent neighbours across **{len(by_cohort)}** cohorts.",
|
|
"",
|
|
]
|
|
for key in sorted(by_cohort):
|
|
rows = by_cohort[key]
|
|
lines.append(f"## {key} — {len(rows)} divergent")
|
|
lines.append("")
|
|
for d in rows:
|
|
lines.append(f"- property **{d.property_id}** (uprn {d.uprn}): {d.detail}")
|
|
lines.append("")
|
|
with open("neighbour_divergence.md", "w") as f:
|
|
f.write("\n".join(lines))
|
|
|
|
|
|
def main() -> None:
|
|
load_env()
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--portfolio", type=int, required=True, help="portfolio_id")
|
|
parser.add_argument(
|
|
"--min-gap",
|
|
type=float,
|
|
default=_DEFAULT_MIN_GAP,
|
|
help=f"SAP points from cohort median to flag (default {_DEFAULT_MIN_GAP})",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
neighbours = _load(args.portfolio)
|
|
divergences = find_divergences(neighbours, args.min_gap)
|
|
_write_reports(divergences, len(neighbours))
|
|
|
|
print(
|
|
f"scanned {len(neighbours)} properties · {len(divergences)} divergent "
|
|
f"neighbours (>= {args.min_gap:.0f} SAP from cohort median)"
|
|
)
|
|
print("wrote neighbour_divergence.md / neighbour_divergence.csv")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|