From 38661c6bd718eac4f044ccb40bc214d7c56d26d8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 11:08:03 +0000 Subject: [PATCH] =?UTF-8?q?Age=20band=20conditions=20within=20one=20band;?= =?UTF-8?q?=20the=20floor-area=20band=20widens=20to=20=C2=B120%=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evidence (439-pair harness, PR #1466): historic-vs-new age band agrees 52% exactly but 90% within one band (assessors re-band, skewing newer); TFA agrees 45% within ±5% but 82% within ±20%. Equality/±5% steered the cohort toward stale values where they engaged and relaxed everywhere else. Band definitions are public so the harness's ladder replay shares one source of truth. Co-Authored-By: Claude Opus 4.8 --- .../epc_prediction/comparable_properties.py | 27 ++++++++++++++++--- scripts/expired_prediction_pairs_harness.py | 10 ++++--- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/domain/epc_prediction/comparable_properties.py b/domain/epc_prediction/comparable_properties.py index 47b762346..3e111bb36 100644 --- a/domain/epc_prediction/comparable_properties.py +++ b/domain/epc_prediction/comparable_properties.py @@ -22,8 +22,25 @@ from domain.geospatial.coordinates import Coordinates _DEFAULT_MINIMUM_COHORT = 5 # Half-width of the floor-area conditioning band: an expired Historic EPC's -# observed floor area keeps comparables within ±5% of it (ADR-0054). -_FLOOR_AREA_TOLERANCE = 0.05 +# observed floor area keeps comparables within ±20% of it — a coarse +# dwelling-size filter, not a precision match. Evidence (ADR-0054 amendment, +# 439-pair harness): the historic TFA agrees with the newly lodged one within +# ±5% only 45% of the time (remeasurement + extensions) but within ±20% 82%. +FLOOR_AREA_TOLERANCE = 0.20 + +# RdSAP Table S1 band letters in chronological order, for band-distance checks. +_AGE_BAND_ORDER = "ABCDEFGHIJKLM" + + +def age_bands_within_one(candidate: object, target_band: object) -> bool: + """Whether two Table S1 band letters are at most one band apart. Assessors + re-band constantly (harness: 52% exact agreement historic-vs-new, 90% + within one band), so the age filter keeps the band NEIGHBOURHOOD.""" + if not (isinstance(candidate, str) and isinstance(target_band, str)): + return False + if candidate not in _AGE_BAND_ORDER or target_band not in _AGE_BAND_ORDER: + return False + return abs(_AGE_BAND_ORDER.index(candidate) - _AGE_BAND_ORDER.index(target_band)) <= 1 @dataclass(frozen=True) @@ -94,7 +111,9 @@ def select_comparables( ) cohort = _maybe_filter( cohort, - lambda c: _main_construction_age_band(c) == target.construction_age_band, + lambda c: age_bands_within_one( + _main_construction_age_band(c), target.construction_age_band + ), active=target.construction_age_band is not None, minimum_cohort=minimum_cohort, ) @@ -109,7 +128,7 @@ def select_comparables( cohort, lambda c: target_area is not None and abs(c.epc.total_floor_area_m2 - target_area) - <= _FLOOR_AREA_TOLERANCE * target_area, + <= FLOOR_AREA_TOLERANCE * target_area, active=target_area is not None, minimum_cohort=minimum_cohort, ) diff --git a/scripts/expired_prediction_pairs_harness.py b/scripts/expired_prediction_pairs_harness.py index bcab7a4da..267e4092a 100644 --- a/scripts/expired_prediction_pairs_harness.py +++ b/scripts/expired_prediction_pairs_harness.py @@ -43,7 +43,9 @@ 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, select_comparables, ) from domain.epc_prediction.epc_prediction import EpcPrediction # noqa: E402 @@ -226,7 +228,7 @@ def _actual_fuel(epc: EpcPropertyData) -> Optional[object]: 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) <= 0.05 * hist_tfa + return abs(actual_tfa - hist_tfa) <= FLOOR_AREA_TOLERANCE * hist_tfa @dataclass(frozen=True) @@ -267,7 +269,7 @@ def simulate_conditioning_ladder( apply( "construction_age_band", age_band is not None, - [c for c in cohort if _actual_age_band(c.epc) == age_band], + [c for c in cohort if age_bands_within_one(_actual_age_band(c.epc), age_band)], ) apply( "main_fuel", @@ -282,7 +284,7 @@ def simulate_conditioning_ladder( for c in cohort if total_floor_area_m2 is not None and abs(c.epc.total_floor_area_m2 - total_floor_area_m2) - <= 0.05 * total_floor_area_m2 + <= FLOOR_AREA_TOLERANCE * total_floor_area_m2 ], ) return steps @@ -321,7 +323,7 @@ def format_diagnosis(rows: list[dict[str, object]]) -> str: "wall_construction", "construction_age_band", "main_fuel", - "tfa_within_5pct", + "tfa_within_band", ) lines += [ "",