Model/tests/domain/epc_prediction/test_comparable_properties.py
Khalim Conn-Kowlessar 7ca1f815f6 refactor(epc-prediction): PR review — rename ComparableProperty, relocate PredictionTarget
Two review points from @dancafc:

1) Rename the `Comparable` dataclass → `ComparableProperty` (it models one
   comparable *property*; the collection stays `ComparableProperties`). Applied
   across domain, repositories, orchestration, harness, scripts, and tests with a
   word-boundary rename so `ComparableProperties` is untouched.

2) Move `PredictionTarget` out of comparable_properties.py into prediction_target.py
   (where `PredictionTargetAttributes` + `build_prediction_target` already live).
   comparable_properties.py now imports it; no import cycle (prediction_target no
   longer depends on comparable_properties). Importers updated.

92 tests pass across the touched suites; pyright strict clean.

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

173 lines
6.2 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, SapBuildingPart
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,
) -> 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
epc.sap_building_parts = [main]
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