Model/scripts/expired_prediction_pairs_harness.py
Khalim Conn-Kowlessar 817c00720e The historic roof description conditions the cohort by form family 🟩
roof_construction codes group by FORM (empirical: 1=Flat 98%, 4/5/8=
Pitched 88-99%, 3=dwelling-above 100% over 7,974 certs; 7/9=premises-
above per #1452), so the filter matches families — an exact-code filter
would wrongly drop pitched neighbours lodged as 5/8. Historic prefixes
map to the same families; roof rooms and thatch stay unconditioned.
Harness ladder replay and telemetry mirror the new filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 12:05:56 +00:00

597 lines
22 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.

"""Pre-2012 x SAP-10.2 pairs harness for Expired-Enhanced Prediction (ADR-0054).
For each postcode, find properties holding BOTH a pre-2012 cert in the historic
S3 backup AND a SAP-10.2 cert on the new gov API (RdSAP 10 went live June 2025;
only a same-spec lodged figure is a valid validation target — see Component
Accuracy, ADR-0030). Each pair is predicted twice from its leave-one-out
postcode cohort:
- PLAIN arm: property type + built form only (what a blind prediction sees);
- CONDITIONED arm: the historic cert's stable attributes conditioning cohort
selection (ADR-0054).
Both arms are scored against the lodged SAP-10.2 cert with the SAME metric
suite as the prediction corpus (ADR-0030 Component Accuracy): per-component
classification hit-rates, mean-absolute numeric residuals, plus the secondary
calculator-floored SAP residual (calc(predicted) vs the lodged score). The
per-attribute breakdown is the whitelist evidence: an attribute whose
conditioned hit-rate is WORSE than plain is stale and gets demoted.
The expensive cohort fetch (search-by-postcode + per-cert fetch) happens only
for postcodes where a pair actually exists, so the script can sweep hundreds
of postcodes cheaply.
Usage:
python scripts/expired_prediction_pairs_harness.py "B93 8SY" "LS6 1AA"
python scripts/expired_prediction_pairs_harness.py --postcodes-file pcs.txt --out report.md
Env: OPEN_EPC_API_TOKEN, DATA_BUCKET; ambient AWS credentials for S3.
"""
from __future__ import annotations
import argparse
import os
import sys
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from datatypes.epc.domain.epc_property_data import EpcPropertyData # noqa: E402
from datatypes.epc.domain.historic_epc import HistoricEpc # noqa: E402
from domain.epc_prediction.comparable_properties import ( # noqa: E402
FLOOR_AREA_TOLERANCE,
ComparableProperty,
age_bands_within_one,
roof_form_of_construction,
select_comparables,
)
from domain.epc_prediction.epc_prediction import EpcPrediction # noqa: E402
from domain.epc_prediction.historic_conditioning import ( # noqa: E402
attributes_with_historic_fallback,
conditioning_from_historic,
target_with_conditioning,
)
from domain.epc_prediction.prediction_comparison import ( # noqa: E402
PredictionComparison,
compare_prediction,
)
from domain.epc_prediction.prediction_target import ( # noqa: E402
PredictionTarget,
build_prediction_target,
)
from domain.postcode import Postcode # noqa: E402
from domain.property.property import PropertyIdentity # noqa: E402
_PRE_2012 = "2012-01-01"
_VALIDATION_SAP_VERSION = 10.2
_RESIDUAL_COMPONENTS = (
"floor_area_m2",
"building_parts",
"window_count",
"total_window_area_m2",
"door_count",
)
def latest_pre_2012_by_uprn(records: list[HistoricEpc]) -> dict[str, HistoricEpc]:
"""One historic cert per UPRN: the latest lodgement strictly before 2012
(ISO date strings compare lexicographically). UPRN-less rows are dropped —
without a UPRN there is nothing to pair."""
by_uprn: dict[str, HistoricEpc] = {}
for record in records:
if not record.uprn or not record.lodgement_date:
continue
if record.lodgement_date >= _PRE_2012:
continue
current = by_uprn.get(record.uprn)
if current is None or record.lodgement_date > current.lodgement_date:
by_uprn[record.uprn] = record
return by_uprn
@dataclass(frozen=True)
class PairScore:
"""One arm's score for one pair: the component comparison plus the
calculator-floored SAP residual (calc(predicted) lodged), None when the
calculator could not score the predicted picture."""
comparison: PredictionComparison
sap_residual: Optional[float]
@dataclass(frozen=True)
class ArmScores:
"""One arm aggregated in the ComponentAccuracy shape (ADR-0030):
classification maps component -> (hits, applicable-total); residuals maps a
numeric component -> signed values; sap_residuals are calc lodged."""
classification: dict[str, tuple[int, int]]
residuals: dict[str, list[float]]
sap_residuals: list[float]
def aggregate(scores: list[PairScore]) -> ArmScores:
counts: dict[str, list[int]] = defaultdict(lambda: [0, 0])
residuals: dict[str, list[float]] = defaultdict(list)
sap_residuals: list[float] = []
for score in scores:
comparison = score.comparison
for component, hit in comparison.categorical_hits.items():
if hit is None:
continue
counts[component][1] += 1
if hit:
counts[component][0] += 1
residuals["floor_area_m2"].append(comparison.floor_area_residual)
residuals["building_parts"].append(float(comparison.building_parts_residual))
residuals["window_count"].append(float(comparison.window_count_residual))
residuals["total_window_area_m2"].append(
comparison.total_window_area_residual
)
residuals["door_count"].append(float(comparison.door_count_residual))
if score.sap_residual is not None:
sap_residuals.append(score.sap_residual)
return ArmScores(
classification={k: (v[0], v[1]) for k, v in counts.items()},
residuals=dict(residuals),
sap_residuals=sap_residuals,
)
def _mean_abs(values: list[float]) -> str:
return f"{sum(abs(v) for v in values) / len(values):.1f}" if values else "n/a"
def _rate_cell(hits: tuple[int, int]) -> str:
hit, total = hits
return f"{hit}/{total} ({hit / total:.0%})" if total else "n/a"
def format_report(plain: ArmScores, conditioned: ArmScores, pairs: int) -> str:
"""The two arms side by side, in the corpus gate's shape: classification
hit-rates per component, then mean-abs residuals, then the secondary SAP
residual."""
components = sorted(set(plain.classification) | set(conditioned.classification))
lines = [
f"# Expired-Enhanced Prediction pairs report ({pairs} pairs)",
"",
"## Classification hit-rates (hits/applicable)",
"",
"| component | plain | conditioned |",
"|---|---|---|",
]
for component in components:
p = _rate_cell(plain.classification.get(component, (0, 0)))
c = _rate_cell(conditioned.classification.get(component, (0, 0)))
lines.append(f"| {component} | {p} | {c} |")
lines += [
"",
"## Mean absolute residuals (predicted actual)",
"",
"| component | plain | conditioned |",
"|---|---|---|",
]
for component in _RESIDUAL_COMPONENTS:
p = _mean_abs(plain.residuals.get(component, []))
c = _mean_abs(conditioned.residuals.get(component, []))
lines.append(f"| {component} | {p} | {c} |")
lines += [
"",
"## SAP residual — secondary, calculator-floored (calc(predicted) lodged)",
"",
"| metric | plain | conditioned |",
"|---|---|---|",
f"| mean abs | {_mean_abs(plain.sap_residuals)} "
f"| {_mean_abs(conditioned.sap_residuals)} |",
f"| scored | {len(plain.sap_residuals)} | {len(conditioned.sap_residuals)} |",
]
return "\n".join(lines)
def _predict_arm(
target: Optional[PredictionTarget],
cohort: list[ComparableProperty],
predictor: EpcPrediction,
) -> Optional[EpcPropertyData]:
if target is None:
return None
comparables = select_comparables(target, cohort)
if not comparables.members:
return None
return predictor.predict(target, comparables)
def _part0(epc: EpcPropertyData) -> Optional[object]:
parts = epc.sap_building_parts
return parts[0] if parts else None
def _actual_roof_form(epc: EpcPropertyData) -> Optional[str]:
part = _part0(epc)
return roof_form_of_construction(getattr(part, "roof_construction", None))
def _actual_age_band(epc: EpcPropertyData) -> Optional[object]:
part = _part0(epc)
return getattr(part, "construction_age_band", None)
def _actual_wall(epc: EpcPropertyData) -> Optional[object]:
part = _part0(epc)
return getattr(part, "wall_construction", None)
def _actual_fuel(epc: EpcPropertyData) -> Optional[object]:
details = epc.sap_heating.main_heating_details
return details[0].main_fuel_type if details else None
def _tfa_within_band(actual_tfa: float, hist_tfa: Optional[float]) -> Optional[bool]:
if hist_tfa is None:
return None
return abs(actual_tfa - hist_tfa) <= FLOOR_AREA_TOLERANCE * hist_tfa
@dataclass(frozen=True)
class LadderStep:
"""One conditioning filter's fate in the sequential relax ladder: how many
of the incoming cohort matched, and whether it engaged (matches >= k)."""
matches: int
cohort_before: int
engaged: bool
def simulate_conditioning_ladder(
base: list[ComparableProperty],
*,
age_band: Optional[str],
main_fuel: Optional[int],
total_floor_area_m2: Optional[float],
roof_form: Optional[str] = None,
minimum_cohort: int = 5,
) -> dict[str, Optional[LadderStep]]:
"""Replay select_comparables' age->fuel->TFA conditioning sequence over the
plain arm's selected cohort, recording per filter whether it ENGAGED
(>= minimum_cohort matches survive) or RELAXED. None = attribute unresolved,
filter never active."""
steps: dict[str, Optional[LadderStep]] = {}
cohort = list(base)
def apply(name: str, active: bool, matches: list[ComparableProperty]) -> None:
nonlocal cohort
if not active:
steps[name] = None
return
engaged = len(matches) >= minimum_cohort
steps[name] = LadderStep(len(matches), len(cohort), engaged)
if engaged:
cohort = matches
apply(
"roof_form",
roof_form is not None,
[c for c in cohort if _actual_roof_form(c.epc) == roof_form],
)
apply(
"construction_age_band",
age_band is not None,
[c for c in cohort if age_bands_within_one(_actual_age_band(c.epc), age_band)],
)
apply(
"main_fuel",
main_fuel is not None,
[c for c in cohort if _actual_fuel(c.epc) == main_fuel],
)
apply(
"total_floor_area",
total_floor_area_m2 is not None,
[
c
for c in cohort
if total_floor_area_m2 is not None
and abs(c.epc.total_floor_area_m2 - total_floor_area_m2)
<= FLOOR_AREA_TOLERANCE * total_floor_area_m2
],
)
return steps
def format_diagnosis(rows: list[dict[str, object]]) -> str:
"""Aggregate the per-pair telemetry into the two diagnosis tables: did each
conditioning filter ever ENGAGE, and does the historic value still AGREE
with the newly lodged one (the staleness measurement)."""
if not rows:
return ""
filters = ("roof_form", "construction_age_band", "main_fuel", "total_floor_area")
lines = [
"",
"## Diagnosis — filter engagement (conditioned arm)",
"",
"| filter | resolved | engaged | relaxed (too few matches) |",
"|---|---|---|---|",
]
for name in filters:
resolved = engaged = 0
for row in rows:
step = row.get(f"ladder_{name}")
if step is None:
continue
resolved += 1
if isinstance(step, LadderStep) and step.engaged:
engaged += 1
lines.append(
f"| {name} | {resolved}/{len(rows)} | {engaged}/{resolved or 1} "
f"| {resolved - engaged}/{resolved or 1} |"
)
attrs = (
"property_type",
"built_form",
"wall_construction",
"roof_form",
"construction_age_band",
"main_fuel",
"tfa_within_band",
)
lines += [
"",
"## Diagnosis — historic vs newly-lodged agreement (staleness)",
"",
"| attribute | historic resolved | agrees with new cert |",
"|---|---|---|",
]
for name in attrs:
resolved = agrees = 0
for row in rows:
value = row.get(f"agrees_{name}")
if value is None:
continue
resolved += 1
if value:
agrees += 1
pct = f" ({agrees / resolved:.0%})" if resolved else ""
lines.append(f"| {name} | {resolved}/{len(rows)} | {agrees}/{resolved or 1}{pct} |")
sizes: list[int] = [
size
for row in rows
if isinstance((size := row.get("plain_cohort_size")), int)
]
if sizes:
lines += [
"",
f"Mean plain-arm cohort size: {sum(sizes) / len(sizes):.1f} "
f"(min {min(sizes)}, max {max(sizes)}); relax threshold k=5.",
]
return "\n".join(lines)
def run( # pragma: no cover - live IO composition
postcodes: list[str],
telemetry_path: Optional[Path] = None,
cache_dir: Optional[Path] = None,
) -> str:
from domain.sap10_calculator.calculator import Sap10Calculator
from infrastructure.epc_client.epc_client_service import EpcClientService
from repositories.comparable_properties.epc_comparable_properties_repository import (
EpcComparablePropertiesRepository,
)
from repositories.geospatial.geospatial_s3_repository import (
GeospatialS3Repository,
)
from repositories.historic_epc.historic_epc_s3_repository import (
HistoricEpcS3Repository,
)
from scripts.e2e_common import load_env, s3_parquet_reader
load_env()
if cache_dir is not None:
from scripts.epc_disk_cache import JsonCachingEpcClient
epc_client: EpcClientService = JsonCachingEpcClient(
os.environ["OPEN_EPC_API_TOKEN"], cache_dir
)
else:
epc_client = EpcClientService(os.environ["OPEN_EPC_API_TOKEN"])
geospatial = GeospatialS3Repository(s3_parquet_reader(os.environ["DATA_BUCKET"]))
comparables_repo = EpcComparablePropertiesRepository(epc_client, geospatial)
historic_repo = HistoricEpcS3Repository.with_default_s3_client()
predictor = EpcPrediction()
calculator = Sap10Calculator()
def sap_residual(
predicted: Optional[EpcPropertyData], actual: EpcPropertyData
) -> Optional[float]:
if predicted is None or actual.energy_rating_current is None:
return None
try:
calculated: float = calculator.calculate(predicted).sap_score_continuous
except Exception as error: # the calculator strict-raises on gaps
print(f" calculator raised: {error}", file=sys.stderr)
return None
return calculated - float(actual.energy_rating_current)
plain_scores: list[PairScore] = []
conditioned_scores: list[PairScore] = []
telemetry: list[dict[str, object]] = []
pairs = 0
def emit_telemetry(row: dict[str, object]) -> None:
# Append incrementally so a late crash loses nothing.
telemetry.append(row)
if telemetry_path is None:
return
import dataclasses as _dc
import json as _json
serialisable = {
k: (_dc.asdict(v) if isinstance(v, LadderStep) else v)
for k, v in row.items()
}
with telemetry_path.open("a") as handle:
handle.write(_json.dumps(serialisable) + "\n")
if telemetry_path is not None and telemetry_path.exists():
telemetry_path.unlink()
for index, raw_postcode in enumerate(postcodes):
postcode = str(Postcode(raw_postcode))
historic = latest_pre_2012_by_uprn(
historic_repo.get_for_postcode(Postcode(raw_postcode))
)
# Pair-check first: only a postcode with a SAP-10.2 relodgement of a
# pre-2012 UPRN pays for the cohort fetch. A cert the mapper can't yet
# map (strict-raise) can't be a validation target either — skip it and
# keep sweeping; one bad cert must not kill a multi-hour run.
paired: list[tuple[str, HistoricEpc, EpcPropertyData]] = []
for uprn, record in historic.items():
try:
actual = epc_client.get_by_uprn(int(uprn))
except Exception as error:
print(f" {uprn}: unmappable cert skipped: {error}", file=sys.stderr)
continue
if actual is not None and actual.sap_version == _VALIDATION_SAP_VERSION:
paired.append((uprn, record, actual))
print(
f"[{index + 1}/{len(postcodes)}] {postcode}: "
f"{len(historic)} pre-2012 UPRNs, {len(paired)} pairs",
file=sys.stderr,
flush=True,
)
if not paired:
continue
try:
cohort = comparables_repo.candidates_for(postcode)
except Exception as error:
print(f" {postcode}: cohort fetch failed: {error}", file=sys.stderr)
continue
for uprn, record, actual in paired:
pairs += 1
loo_cohort = [c for c in cohort if c.epc.uprn != int(uprn)]
identity = PropertyIdentity(
portfolio_id=0, postcode=postcode, address=record.address, uprn=int(uprn)
)
conditioning = conditioning_from_historic(record)
attributes = attributes_with_historic_fallback(None, conditioning)
plain_target = build_prediction_target(identity, None, attributes)
conditioned_target = (
target_with_conditioning(plain_target, conditioning)
if plain_target is not None
else None
)
plain = _predict_arm(plain_target, loo_cohort, predictor)
conditioned = _predict_arm(conditioned_target, loo_cohort, predictor)
if plain_target is not None:
base = list(select_comparables(plain_target, loo_cohort).members)
ladder = simulate_conditioning_ladder(
base,
age_band=conditioning.construction_age_band,
main_fuel=conditioning.main_fuel,
total_floor_area_m2=conditioning.total_floor_area_m2,
roof_form=conditioning.roof_form,
)
emit_telemetry(
{
"postcode": postcode,
"uprn": uprn,
"plain_cohort_size": len(base),
# Raw values alongside the booleans, so band-width and
# near-miss questions are answerable post hoc.
"historic_age_band": conditioning.construction_age_band,
"actual_age_band": _actual_age_band(actual),
"historic_tfa": conditioning.total_floor_area_m2,
"actual_tfa": actual.total_floor_area_m2,
"historic_fuel": conditioning.main_fuel,
"actual_fuel": _actual_fuel(actual),
"historic_fuel_text": record.main_fuel,
**{f"ladder_{k}": v for k, v in ladder.items()},
"agrees_property_type": (
None
if conditioning.property_type is None
else conditioning.property_type == actual.property_type
),
"agrees_built_form": (
None
if conditioning.built_form is None
else conditioning.built_form == actual.built_form
),
"agrees_roof_form": (
None
if conditioning.roof_form is None
else conditioning.roof_form == _actual_roof_form(actual)
),
"agrees_wall_construction": (
None
if conditioning.wall_construction is None
else conditioning.wall_construction == _actual_wall(actual)
),
"agrees_construction_age_band": (
None
if conditioning.construction_age_band is None
else conditioning.construction_age_band
== _actual_age_band(actual)
),
"agrees_main_fuel": (
None
if conditioning.main_fuel is None
else conditioning.main_fuel == _actual_fuel(actual)
),
"agrees_tfa_within_band": _tfa_within_band(
actual.total_floor_area_m2,
conditioning.total_floor_area_m2,
),
}
)
if plain is not None:
plain_scores.append(
PairScore(compare_prediction(plain, actual), sap_residual(plain, actual))
)
if conditioned is not None:
conditioned_scores.append(
PairScore(
compare_prediction(conditioned, actual),
sap_residual(conditioned, actual),
)
)
report = format_report(
aggregate(plain_scores), aggregate(conditioned_scores), pairs
)
return report + format_diagnosis(telemetry)
def main() -> None: # pragma: no cover - CLI entry
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("postcodes", nargs="*", help="postcodes to scan")
parser.add_argument("--postcodes-file", type=Path, default=None)
parser.add_argument("--out", type=Path, default=None, help="write the report here")
parser.add_argument(
"--telemetry", type=Path, default=None, help="write per-pair JSONL here"
)
parser.add_argument(
"--cache-dir",
type=Path,
default=None,
help="raw-JSON disk cache for API responses (fast re-runs; fixture material)",
)
args = parser.parse_args()
postcodes: list[str] = list(args.postcodes)
if args.postcodes_file is not None:
postcodes += [
line.strip()
for line in args.postcodes_file.read_text().splitlines()
if line.strip()
]
if not postcodes:
parser.error("no postcodes given")
report = run(postcodes, telemetry_path=args.telemetry, cache_dir=args.cache_dir)
if args.out is not None:
args.out.write_text(report + "\n")
print(report)
if __name__ == "__main__":
main()