Model/tests/domain/epc_prediction/test_comparable_properties.py
Khalim Conn-Kowlessar bf6b6fac17 feat(epc-prediction): Comparable Properties selection ladder (ADR-0029)
Pure-domain select_comparables: property type is an always-hard filter; built
form and known Landlord Overrides (e.g. solid brick) are conditioning filters on
the filter-then-relax ladder — applied while >= minimum_cohort survive, relaxed
otherwise (the mixed-street border case degrades gracefully). PredictionTarget
(known inputs) + Comparable (epc + register metadata) + ComparableProperties
(selected cohort). Weighting (recency x similarity) follows in the synthesis slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:44:57 +00:00

126 lines
4.6 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 Comparable 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 typing import Optional, Union
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingPart
from domain.epc_prediction.comparable_properties import (
Comparable,
ComparableProperties,
PredictionTarget,
select_comparables,
)
def _comparable(
*,
property_type: str,
certificate_number: str,
built_form: str = "1",
wall_construction: Optional[Union[int, str]] = None,
) -> Comparable:
"""A Comparable 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
epc.sap_building_parts = [main]
return Comparable(epc=epc, certificate_number=certificate_number)
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_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