mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(epc-prediction): slice-5d target assembly + eligibility gate
build_prediction_target assembles an EPC-less Property's PredictionTarget from its identity (postcode), resolved coordinates, and Landlord-Override attributes (property_type / built_form / wall_construction). The eligibility GATE: a Property whose property_type is unknown returns None — never sized from a mixed-type cohort (ADR-0031). property_type is the hard cohort filter. The override attributes are read through a PredictionTargetAttributesReader port (stub seam) — the real adapter (a read over property_overrides) is being built separately by the team; ingestion wiring depends on the abstraction and tests substitute a fake. 2 tests (assembly + gate); pyright strict clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
fd43cf2d23
commit
f2f954f459
3 changed files with 130 additions and 0 deletions
51
domain/epc_prediction/prediction_target.py
Normal file
51
domain/epc_prediction/prediction_target.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Assemble an EPC-less Property's PredictionTarget, with the eligibility gate
|
||||
(ADR-0031 slice-5d).
|
||||
|
||||
A `PredictionTarget` needs the target's own known inputs: its postcode (to find
|
||||
the cohort), coordinates (to distance-weight it), and the Landlord-Override
|
||||
attributes that condition selection — `property_type` (the HARD cohort filter),
|
||||
plus optional `built_form` / `wall_construction`. `property_type` is required: a
|
||||
Property whose type is unknown is gated out (never sized from a mixed-type
|
||||
cohort), so the builder returns None and the caller skips prediction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Union
|
||||
|
||||
from domain.epc_prediction.comparable_properties import PredictionTarget
|
||||
from domain.geospatial.coordinates import Coordinates
|
||||
from domain.property.property import PropertyIdentity
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PredictionTargetAttributes:
|
||||
"""The target Property's own attributes resolved from Landlord Overrides,
|
||||
needed to find and condition its cohort. `property_type` is the code-space
|
||||
value the cohort EPCs carry (e.g. "2"); None means it could not be resolved,
|
||||
which gates the Property out of prediction."""
|
||||
|
||||
property_type: Optional[str]
|
||||
built_form: Optional[str] = None
|
||||
wall_construction: Optional[Union[int, str]] = None
|
||||
|
||||
|
||||
def build_prediction_target(
|
||||
identity: PropertyIdentity,
|
||||
coordinates: Optional[Coordinates],
|
||||
attributes: PredictionTargetAttributes,
|
||||
) -> Optional[PredictionTarget]:
|
||||
"""The PredictionTarget for an EPC-less Property, or None when ineligible —
|
||||
`property_type` is the hard cohort filter, so a Property whose type is unknown
|
||||
is gated out of prediction (ADR-0031) rather than sized from a mixed-type
|
||||
cohort."""
|
||||
if attributes.property_type is None:
|
||||
return None
|
||||
return PredictionTarget(
|
||||
postcode=identity.postcode,
|
||||
property_type=attributes.property_type,
|
||||
built_form=attributes.built_form,
|
||||
wall_construction=attributes.wall_construction,
|
||||
coordinates=coordinates,
|
||||
)
|
||||
23
repositories/property/prediction_target_attributes_reader.py
Normal file
23
repositories/property/prediction_target_attributes_reader.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""Read port for an EPC-less Property's prediction attributes (ADR-0031 slice-5d).
|
||||
|
||||
Returns the `property_type` / `built_form` / `wall_construction` resolved from
|
||||
Landlord Overrides that `build_prediction_target` needs. Kept a port because the
|
||||
real adapter — a read over the `property_overrides` fact layer — is being built
|
||||
separately (see docs/HANDOVER_EPC_PREDICTION_WIRING.md); the ingestion wiring
|
||||
depends on this abstraction and tests substitute a fake.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from domain.epc_prediction.prediction_target import PredictionTargetAttributes
|
||||
|
||||
|
||||
class PredictionTargetAttributesReader(ABC):
|
||||
@abstractmethod
|
||||
def attributes_for(self, property_id: int) -> PredictionTargetAttributes:
|
||||
"""The Property's resolved prediction attributes. `property_type` is None
|
||||
when it could not be resolved — which gates the Property out of
|
||||
prediction (`build_prediction_target` returns None)."""
|
||||
...
|
||||
56
tests/domain/epc_prediction/test_prediction_target.py
Normal file
56
tests/domain/epc_prediction/test_prediction_target.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Assembling an EPC-less Property's PredictionTarget, and the eligibility gate:
|
||||
a Property whose property type is unknown is not predicted (ADR-0031 slice-5d)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from domain.epc_prediction.comparable_properties import PredictionTarget
|
||||
from domain.epc_prediction.prediction_target import (
|
||||
PredictionTargetAttributes,
|
||||
build_prediction_target,
|
||||
)
|
||||
from domain.geospatial.coordinates import Coordinates
|
||||
from domain.property.property import PropertyIdentity
|
||||
|
||||
|
||||
def _identity(postcode: str = "LS6 1AA") -> PropertyIdentity:
|
||||
return PropertyIdentity(
|
||||
portfolio_id=1, postcode=postcode, address="1 Some Street", uprn=12345
|
||||
)
|
||||
|
||||
|
||||
def test_target_is_assembled_from_identity_coords_and_overrides() -> None:
|
||||
# Arrange — a known property type + built form + wall (Landlord Overrides),
|
||||
# and resolved coordinates.
|
||||
here = Coordinates(longitude=-1.55, latitude=53.81)
|
||||
attributes = PredictionTargetAttributes(
|
||||
property_type="2", built_form="3", wall_construction=1
|
||||
)
|
||||
|
||||
# Act
|
||||
target: Optional[PredictionTarget] = build_prediction_target(
|
||||
_identity(), here, attributes
|
||||
)
|
||||
|
||||
# Assert — every known input is threaded onto the target.
|
||||
assert target is not None
|
||||
assert target.postcode == "LS6 1AA"
|
||||
assert target.property_type == "2"
|
||||
assert target.built_form == "3"
|
||||
assert target.wall_construction == 1
|
||||
assert target.coordinates is here
|
||||
|
||||
|
||||
def test_an_unknown_property_type_gates_the_property_out() -> None:
|
||||
# Arrange — property type is the hard cohort filter; without it the Property
|
||||
# must not be predicted from a mixed-type cohort (ADR-0031).
|
||||
attributes = PredictionTargetAttributes(property_type=None)
|
||||
|
||||
# Act
|
||||
target: Optional[PredictionTarget] = build_prediction_target(
|
||||
_identity(), None, attributes
|
||||
)
|
||||
|
||||
# Assert — gated out: no target to predict from.
|
||||
assert target is None
|
||||
Loading…
Add table
Reference in a new issue