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