Model/tests/domain/epc_prediction/test_comparable_properties.py
Khalim Conn-Kowlessar 49c9f8181c The historic roof description conditions the cohort by form family 🟥
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:50:45 +00:00

360 lines
13 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.

"""Behaviour of ComparableProperty Properties selection (ADR-0029): given a prediction
target's known inputs and the raw postcode cohort, choose + weight the
comparables EPC Prediction will synthesise from. Filter-then-relax ladder:
hard filters on identity (property type, built form) + known overrides while
enough remain, weighted by recency × similarity. Pure domain logic.
"""
from datetime import date
from typing import Optional, Union
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
MainHeatingDetail,
SapBuildingPart,
SapHeating,
)
from domain.epc_prediction.comparable_properties import (
ComparableProperty,
ComparableProperties,
select_comparables,
)
from domain.epc_prediction.prediction_target import PredictionTarget
def _comparable(
*,
property_type: str,
certificate_number: str,
built_form: str = "1",
wall_construction: Optional[Union[int, str]] = None,
address: Optional[str] = None,
registration_date: Optional[date] = None,
construction_age_band: Optional[str] = None,
main_fuel: Optional[int] = None,
total_floor_area_m2: Optional[float] = None,
roof_construction: Optional[int] = None,
) -> ComparableProperty:
"""A ComparableProperty carrying only the fields under test (opaque EpcPropertyData
with property_type / built_form / main wall set — the partial-instance idiom)."""
epc: EpcPropertyData = object.__new__(EpcPropertyData)
epc.property_type = property_type
epc.built_form = built_form
main: SapBuildingPart = object.__new__(SapBuildingPart)
if wall_construction is not None:
main.wall_construction = wall_construction
if construction_age_band is not None:
main.construction_age_band = construction_age_band
if roof_construction is not None:
main.roof_construction = roof_construction
epc.sap_building_parts = [main]
if main_fuel is not None:
detail: MainHeatingDetail = object.__new__(MainHeatingDetail)
detail.main_fuel_type = main_fuel
heating: SapHeating = object.__new__(SapHeating)
heating.main_heating_details = [detail]
epc.sap_heating = heating
if total_floor_area_m2 is not None:
epc.total_floor_area_m2 = total_floor_area_m2
return ComparableProperty(
epc=epc,
certificate_number=certificate_number,
address=address,
registration_date=registration_date,
)
def test_selects_only_candidates_of_the_same_property_type() -> None:
# Arrange — a target house (property_type "2"); cohort of 2 houses + 1 flat.
target = PredictionTarget(postcode="LS6 1AA", property_type="2")
candidates = [
_comparable(property_type="2", certificate_number="A"),
_comparable(property_type="2", certificate_number="B"),
_comparable(property_type="1", certificate_number="C"),
]
# Act
result: ComparableProperties = select_comparables(target, candidates)
# Assert — the flat is excluded; the two houses remain.
assert {c.certificate_number for c in result.members} == {"A", "B"}
def test_dedupes_re_lodgements_to_the_latest_cert_per_address() -> None:
# Arrange — a register cohort with one address (FLAT 3) lodged three times.
# Comparables are one-per-real-neighbour, so a re-lodged address must not
# count three times towards the mode; the latest cert is its current state.
target = PredictionTarget(postcode="LS6 1AA", property_type="2")
candidates = [
_comparable(
property_type="2",
certificate_number="OLD",
address="FLAT 3",
registration_date=date(2020, 4, 6),
),
_comparable(
property_type="2",
certificate_number="MID",
address="FLAT 3",
registration_date=date(2021, 2, 1),
),
_comparable(
property_type="2",
certificate_number="NEW",
address="FLAT 3",
registration_date=date(2025, 1, 20),
),
_comparable(
property_type="2",
certificate_number="OTHER",
address="FLAT 5",
registration_date=date(2024, 9, 27),
),
]
# Act
result: ComparableProperties = select_comparables(target, candidates)
# Assert — FLAT 3 collapses to its latest cert; FLAT 5 is untouched.
assert {c.certificate_number for c in result.members} == {"NEW", "OTHER"}
def test_filters_to_the_known_built_form_when_enough_remain() -> None:
# Arrange — a mid-terrace target (built_form "4"); cohort of 5 mid-terraces
# + 2 detached, all houses. The built form is known and leaves ≥ k, so it is
# applied as a hard filter.
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", built_form="4"
)
candidates = [
_comparable(property_type="2", built_form="4", certificate_number=f"T{i}")
for i in range(5)
] + [
_comparable(property_type="2", built_form="1", certificate_number=f"D{i}")
for i in range(2)
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=5
)
# Assert — only the five mid-terraces survive.
assert {c.certificate_number for c in result.members} == {
"T0", "T1", "T2", "T3", "T4"
}
def test_known_wall_override_emphasises_matching_comparables() -> None:
# Arrange — a mixed street: 5 solid-brick (code 2) + 3 cavity (code 1) houses.
# We KNOW the target is solid brick (a Landlord Override), and the filter
# leaves ≥ k, so cavity neighbours are dropped (the border-property case).
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", wall_construction=2
)
candidates = [
_comparable(property_type="2", wall_construction=2, certificate_number=f"S{i}")
for i in range(5)
] + [
_comparable(property_type="2", wall_construction=1, certificate_number=f"C{i}")
for i in range(3)
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=5
)
# Assert — only the solid-brick comparables remain.
assert {c.certificate_number for c in result.members} == {
"S0", "S1", "S2", "S3", "S4"
}
def test_known_wall_override_relaxes_when_too_few_match() -> None:
# Arrange — only 2 solid-brick but 6 cavity houses; the override would leave
# 2 (< k=5), so it relaxes to keep the full type cohort (graceful degradation).
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", wall_construction=2
)
candidates = [
_comparable(property_type="2", wall_construction=2, certificate_number=f"S{i}")
for i in range(2)
] + [
_comparable(property_type="2", wall_construction=1, certificate_number=f"C{i}")
for i in range(6)
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=5
)
# Assert — relaxed: all eight houses retained.
assert len(result.members) == 8
def test_historic_age_band_conditions_the_cohort() -> None:
# Arrange — the expired cert observed band C (1930-1949); 5 band-C houses +
# 2 band-G houses in the cohort (ADR-0054).
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", construction_age_band="C"
)
candidates = [
_comparable(
property_type="2", construction_age_band="C", certificate_number=f"C{i}"
)
for i in range(5)
] + [
_comparable(
property_type="2", construction_age_band="G", certificate_number=f"G{i}"
)
for i in range(2)
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=5
)
# Assert — only the same-age-band comparables remain.
assert {c.certificate_number for c in result.members} == {
"C0", "C1", "C2", "C3", "C4"
}
def test_historic_main_fuel_conditions_the_cohort() -> None:
# Arrange — the expired cert observed mains gas (26); 5 gas + 2 electric
# (29) houses in the cohort (ADR-0054).
target = PredictionTarget(postcode="LS6 1AA", property_type="2", main_fuel=26)
candidates = [
_comparable(property_type="2", main_fuel=26, certificate_number=f"G{i}")
for i in range(5)
] + [
_comparable(property_type="2", main_fuel=29, certificate_number=f"E{i}")
for i in range(2)
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=5
)
# Assert
assert {c.certificate_number for c in result.members} == {
"G0", "G1", "G2", "G3", "G4"
}
def test_floor_area_band_keeps_comparables_within_20_percent() -> None:
# Arrange — the expired cert observed 100 m²; the band is ±20% (ADR-0054 as
# amended: the 439-pair harness put historic-vs-new TFA agreement at only
# 45% within ±5% but 82% within ±20% — a coarse dwelling-size filter, not a
# precision match): 96, 118 and 84 are in, 125 and 79 are out.
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", total_floor_area_m2=100.0
)
candidates = [
_comparable(property_type="2", total_floor_area_m2=96.0, certificate_number="A"),
_comparable(
property_type="2", total_floor_area_m2=118.0, certificate_number="B"
),
_comparable(property_type="2", total_floor_area_m2=84.0, certificate_number="C"),
_comparable(
property_type="2", total_floor_area_m2=125.0, certificate_number="X"
),
_comparable(property_type="2", total_floor_area_m2=79.0, certificate_number="Y"),
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=2
)
# Assert
assert {c.certificate_number for c in result.members} == {"A", "B", "C"}
def test_historic_age_band_conditions_within_one_band() -> None:
# Arrange — the expired cert observed band C, but assessors re-band ±1
# constantly (harness: 52% exact agreement, 90% within one band), so the
# filter keeps the band NEIGHBOURHOOD: B/C/D match, G does not (ADR-0054 as
# amended).
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", construction_age_band="C"
)
near = ["B", "C", "D", "D", "B"]
candidates = [
_comparable(
property_type="2",
construction_age_band=band,
certificate_number=f"N{i}",
)
for i, band in enumerate(near)
] + [
_comparable(
property_type="2", construction_age_band="G", certificate_number=f"G{i}"
)
for i in range(2)
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=5
)
# Assert — the five band-neighbourhood comparables survive; band G drops.
assert {c.certificate_number for c in result.members} == {
"N0", "N1", "N2", "N3", "N4"
}
def test_historic_roof_form_conditions_the_cohort_by_family() -> None:
# Arrange — the expired cert observed a pitched roof. The API's
# roof_construction codes group into FORM families (empirical sweep:
# 4/5/8 = pitched, 1 = flat), so all pitched-family comparables match and
# the flat one drops (ADR-0054 as amended).
target = PredictionTarget(postcode="LS6 1AA", property_type="2", roof_form="pitched")
candidates = [
_comparable(property_type="2", roof_construction=4, certificate_number="P4a"),
_comparable(property_type="2", roof_construction=4, certificate_number="P4b"),
_comparable(property_type="2", roof_construction=5, certificate_number="P5"),
_comparable(property_type="2", roof_construction=8, certificate_number="P8"),
_comparable(property_type="2", roof_construction=4, certificate_number="P4c"),
_comparable(property_type="2", roof_construction=1, certificate_number="F1"),
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=5
)
# Assert — the whole pitched family survives; the flat roof drops.
assert {c.certificate_number for c in result.members} == {
"P4a", "P4b", "P4c", "P5", "P8"
}
def test_floor_area_band_relaxes_when_too_few_match() -> None:
# Arrange — only one comparable inside the ±5% band (< k=2): the band must
# relax rather than starve the cohort (graceful degradation, ADR-0029).
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", total_floor_area_m2=100.0
)
candidates = [
_comparable(property_type="2", total_floor_area_m2=99.0, certificate_number="A"),
_comparable(
property_type="2", total_floor_area_m2=140.0, certificate_number="X"
),
_comparable(
property_type="2", total_floor_area_m2=150.0, certificate_number="Y"
),
]
# Act
result: ComparableProperties = select_comparables(
target, candidates, minimum_cohort=2
)
# Assert — relaxed: all three retained.
assert len(result.members) == 3