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 += [ "",