mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
597 lines
22 KiB
Python
597 lines
22 KiB
Python
"""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()
|