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