Model/tests/domain/epc_prediction/test_epc_prediction.py
Khalim Conn-Kowlessar 80c5ad0c6c Predict ventilation kind from the cohort mode 🟥
Prediction never synthesises ventilation — it keeps the size-template's
sap_ventilation, so a predicted dwelling in an MEV/MVHR neighbourhood is scored
+ displayed as natural (predicted property 721167 follow-up). Mode the
mechanical_ventilation_kind across the cohort like glazing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:49:37 +00:00

718 lines
29 KiB
Python

"""Behaviour of EPC Prediction synthesis (ADR-0029): turn the selected
ComparableProperty Properties into a predicted EpcPropertyData. Hybrid — copy a coherent
representative template's structure (building parts, windows, geometry), set the
homogeneous categoricals to the recency-weighted cohort mode, apply Landlord
Overrides on top. Pure domain logic.
"""
from datetime import date
from typing import Optional, Union
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EnergyElement,
EpcPropertyData,
MainHeatingDetail,
SapBuildingPart,
SapEnergySource,
SapFloorDimension,
SapHeating,
SapVentilation,
SapWindow,
)
from domain.geospatial.coordinates import Coordinates
from domain.epc_prediction.comparable_properties import (
ComparableProperty,
ComparableProperties,
)
from domain.epc_prediction.epc_prediction import (
EpcPrediction,
PredictionConfidence,
)
from domain.epc_prediction.prediction_target import PredictionTarget
def _epc(
*,
building_parts: int = 1,
identifier: BuildingPartIdentifier = BuildingPartIdentifier.MAIN,
floor_area: float = 80.0,
wall_construction: Union[int, str] = 1,
wall_insulation_type: Union[int, str] = 1,
construction_age_band: str = "K",
roof_construction: Optional[int] = 1,
roof_insulation_thickness: Optional[Union[str, int]] = 100,
floor_construction: Optional[int] = 1,
floor_insulation: Optional[int] = 1,
glazing_type: Union[int, str] = 3,
main_fuel_type: Union[int, str] = 1,
main_heating_category: Optional[int] = 1,
main_heating_control: Union[int, str] = 1,
water_heating_fuel: Optional[int] = 1,
water_heating_code: Optional[int] = 1,
has_hot_water_cylinder: bool = True,
solar_water_heating: bool = False,
meter_type: str = "2",
main_heating_label: str = "Boiler and radiators, mains gas",
main_heating_controls_label: Optional[str] = None,
mechanical_ventilation_kind: Optional[str] = None,
) -> EpcPropertyData:
epc: EpcPropertyData = object.__new__(EpcPropertyData)
epc.property_type = "2"
epc.built_form = "4"
epc.total_floor_area_m2 = floor_area
parts: list[SapBuildingPart] = []
for _ in range(building_parts):
part: SapBuildingPart = object.__new__(SapBuildingPart)
part.identifier = identifier
part.wall_construction = wall_construction
part.wall_insulation_type = wall_insulation_type
part.construction_age_band = construction_age_band
part.roof_construction = roof_construction
part.roof_insulation_thickness = roof_insulation_thickness
floor_dim: SapFloorDimension = object.__new__(SapFloorDimension)
floor_dim.floor_construction = floor_construction
floor_dim.floor_insulation = floor_insulation
part.sap_floor_dimensions = [floor_dim]
parts.append(part)
epc.sap_building_parts = parts
window: SapWindow = object.__new__(SapWindow)
window.window_width = 1.0
window.window_height = 1.0
window.glazing_type = glazing_type
epc.sap_windows = [window]
heating: SapHeating = object.__new__(SapHeating)
detail: MainHeatingDetail = object.__new__(MainHeatingDetail)
detail.main_fuel_type = main_fuel_type
detail.main_heating_category = main_heating_category
detail.main_heating_control = main_heating_control
heating.main_heating_details = [detail]
heating.water_heating_fuel = water_heating_fuel
heating.water_heating_code = water_heating_code
heating.cylinder_insulation_type = 1
heating.secondary_heating_type = None
epc.sap_heating = heating
epc.main_heating = [
EnergyElement(
description=main_heating_label,
energy_efficiency_rating=4,
environmental_efficiency_rating=4,
)
]
epc.main_heating_controls = (
EnergyElement(
description=main_heating_controls_label,
energy_efficiency_rating=4,
environmental_efficiency_rating=4,
)
if main_heating_controls_label is not None
else None
)
epc.has_hot_water_cylinder = has_hot_water_cylinder
epc.solar_water_heating = solar_water_heating
epc.sap_ventilation = SapVentilation(
mechanical_ventilation_kind=mechanical_ventilation_kind,
sheltered_sides=1,
)
energy: SapEnergySource = object.__new__(SapEnergySource)
energy.meter_type = meter_type
epc.sap_energy_source = energy
return epc
def _cohort(*epcs: EpcPropertyData) -> ComparableProperties:
return ComparableProperties(
members=tuple(
ComparableProperty(epc=e, certificate_number=str(i)) for i, e in enumerate(epcs)
)
)
def _dated_cohort(
*dated: tuple[EpcPropertyData, date],
) -> ComparableProperties:
return ComparableProperties(
members=tuple(
ComparableProperty(epc=e, certificate_number=str(i), registration_date=d)
for i, (e, d) in enumerate(dated)
)
)
def test_predicts_a_picture_by_copying_a_representative_template() -> None:
# Arrange — a single comparable with a distinctive structure (2 building
# parts, 92 m²); with nothing else to go on it is the template.
template = _epc(building_parts=2, floor_area=92.0)
target = PredictionTarget(postcode="LS6 1AA", property_type="2")
# Act
predicted: EpcPropertyData = EpcPrediction().predict(target, _cohort(template))
# Assert — the structure is copied wholesale (and it is a copy, not the same
# object — the baseline must never be mutated).
assert len(predicted.sap_building_parts) == 2
assert predicted.total_floor_area_m2 == 92.0
assert predicted is not template
def test_template_is_the_member_closest_to_the_cohort_median_size() -> None:
# Arrange — the cohort spans a wide range of sizes; members[0] is an atypical
# tiny 20 m² outlier. A single neighbour's geometry is copied wholesale, so
# the template must be the size-representative member (closest to the median),
# not whoever happens to come first (ADR-0029 decision 4: closest on size).
cohort = _cohort(
_epc(floor_area=20.0),
_epc(floor_area=80.0),
_epc(floor_area=200.0),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — the 80 m² member (the median) seeds the structure, not the 20 m²
# outlier sitting at members[0].
assert predicted.total_floor_area_m2 == 80.0
def test_template_skips_a_main_less_member_so_the_prediction_has_a_main_part() -> None:
# Arrange — the size-median member is OTHER-only (the gov API lodged its part
# with a null identifier), but the cohort holds MAIN-bearing neighbours at
# other sizes. The structural template must be a MAIN-bearing member so the
# predicted dwelling presents a main dwelling — else the modelling handler's
# MAIN-part guard rejects an otherwise-rich cohort as "not predictable".
cohort = _cohort(
_epc(floor_area=80.0, identifier=BuildingPartIdentifier.OTHER),
_epc(floor_area=30.0, identifier=BuildingPartIdentifier.MAIN),
_epc(floor_area=200.0, identifier=BuildingPartIdentifier.MAIN),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — the predicted dwelling has a MAIN part (seeded from a MAIN-bearing
# neighbour), not the OTHER-only median member.
assert any(
part.identifier is BuildingPartIdentifier.MAIN
for part in predicted.sap_building_parts
)
def test_an_all_other_cohort_predicts_without_a_main_part() -> None:
# Arrange — every member is OTHER-only (the whole same-type cohort was lodged
# with null identifiers). There is no MAIN-bearing template to seed from, so
# the prediction must NOT silently relabel real data; it falls back to the
# size-closest member and yields a MAIN-less picture, which the modelling
# handler then rejects as not-predictable (the honest "fail" decision).
cohort = _cohort(
_epc(floor_area=80.0, identifier=BuildingPartIdentifier.OTHER),
_epc(floor_area=82.0, identifier=BuildingPartIdentifier.OTHER),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — no MAIN part is conjured; the picture stays as lodged.
assert not any(
part.identifier is BuildingPartIdentifier.MAIN
for part in predicted.sap_building_parts
)
def test_template_is_the_size_closest_member_among_the_main_bearing_ones() -> None:
# Arrange — the size-median member is OTHER-only (80 m²). Of the MAIN-bearing
# members, the 78 m² one (distinctively a 2-part structure) is nearest the
# cohort median; the 30 m² one is far. The template must be the size-closest
# *MAIN-bearing* member, while the cohort median stays a property of the whole
# cohort (78 m², not the 54 m² median of just the MAIN-bearing subset).
cohort = _cohort(
_epc(floor_area=80.0, identifier=BuildingPartIdentifier.OTHER),
_epc(
floor_area=78.0,
identifier=BuildingPartIdentifier.MAIN,
building_parts=2,
),
_epc(floor_area=30.0, identifier=BuildingPartIdentifier.MAIN),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — structure copied from the 78 m² MAIN-bearing member (its 2 parts),
# and the size estimate is the full-cohort median (78 m²), not the subset's.
assert len(predicted.sap_building_parts) == 2
assert predicted.total_floor_area_m2 == 78.0
def test_sets_main_wall_construction_to_the_cohort_mode() -> None:
# Arrange — the template (members[0]) is solid brick (2), but the cohort
# majority is cavity (1). The homogeneous categorical should follow the mode,
# not the one template, so the prediction is robust to an atypical template.
cohort = _cohort(
_epc(wall_construction=2),
_epc(wall_construction=1),
_epc(wall_construction=1),
_epc(wall_construction=1),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — cavity (the mode) wins over the solid-brick template.
assert predicted.sap_building_parts[0].wall_construction == 1
def test_sets_the_other_homogeneous_categoricals_to_the_cohort_mode() -> None:
# Arrange — the median-size template (members[0], 80 m²) is an atypical
# outlier on every categorical; the cohort majority disagrees. Age band,
# wall insulation, roof construction and floor construction are all
# homogeneous categoricals, so each should follow its mode, not the one
# template (ADR-0029 decision 4).
cohort = _cohort(
_epc(
floor_area=80.0,
construction_age_band="A",
wall_insulation_type=9,
roof_construction=7,
floor_construction=7,
),
_epc(
construction_age_band="K",
wall_insulation_type=1,
roof_construction=2,
floor_construction=3,
),
_epc(
construction_age_band="K",
wall_insulation_type=1,
roof_construction=2,
floor_construction=3,
),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — every categorical follows the cohort mode over the outlier
# template.
main = predicted.sap_building_parts[0]
assert main.construction_age_band == "K"
assert main.wall_insulation_type == 1
assert main.roof_construction == 2
assert main.sap_floor_dimensions[0].floor_construction == 3
def test_modes_roof_and_floor_insulation() -> None:
# Arrange — the median-size template (members[0]) is an outlier on roof
# insulation thickness and floor insulation; the cohort majority disagrees.
# These are independent fabric categoricals, so each should follow its
# cohort mode like the construction categoricals do.
cohort = _cohort(
_epc(floor_area=80.0, roof_insulation_thickness=25, floor_insulation=9),
_epc(roof_insulation_thickness=300, floor_insulation=2),
_epc(roof_insulation_thickness=300, floor_insulation=2),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — each follows the cohort mode over the outlier template.
main = predicted.sap_building_parts[0]
assert main.roof_insulation_thickness == 300
assert main.sap_floor_dimensions[0].floor_insulation == 2
def test_recency_weights_roof_insulation_mode() -> None:
# Arrange — an old majority (three 2015 certs at 100 mm) and a recent
# minority (two 2025 certs at 300 mm). Roof insulation is topped up over
# time, so the recent neighbours reflect the current state: the recency-
# weighted mode must pick 300 over the plain-majority 100.
cohort = _dated_cohort(
(_epc(roof_insulation_thickness=100), date(2015, 1, 1)),
(_epc(roof_insulation_thickness=100), date(2015, 1, 1)),
(_epc(roof_insulation_thickness=100), date(2015, 1, 1)),
(_epc(roof_insulation_thickness=300), date(2025, 1, 1)),
(_epc(roof_insulation_thickness=300), date(2025, 1, 1)),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — recency overrides the stale majority.
assert predicted.sap_building_parts[0].roof_insulation_thickness == 300
def test_floor_area_is_the_cohort_median_not_the_templates_own_area() -> None:
# Arrange — an even-sized cohort whose median (70) falls between members, so
# the size-representative template (the first member closest to the median,
# 60 m²) does not itself sit on the median. The predicted floor area is a
# point estimate of the target's size, best served by the cohort median (the
# MAD-minimising estimator), decoupled from whichever template seeds the
# structure.
cohort = _cohort(
_epc(floor_area=40.0),
_epc(floor_area=60.0),
_epc(floor_area=80.0),
_epc(floor_area=100.0),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — the floor area is the cohort median (70), not the template's 60.
assert predicted.total_floor_area_m2 == 70.0
def test_floor_area_leans_toward_the_nearest_neighbours_size() -> None:
# Arrange — three FAR neighbours are 60 m²; one neighbour AT the target is
# 120 m². The plain median would be 60, but homes built together share a
# footprint, so the geo-proximity-weighted median leans toward the near
# neighbour's size.
here = Coordinates(longitude=0.0, latitude=0.0)
far = Coordinates(longitude=1.0, latitude=1.0) # ~150 km away
cohort = ComparableProperties(
members=(
ComparableProperty(_epc(floor_area=60.0), "1", coordinates=far),
ComparableProperty(_epc(floor_area=60.0), "2", coordinates=far),
ComparableProperty(_epc(floor_area=60.0), "3", coordinates=far),
ComparableProperty(_epc(floor_area=120.0), "4", coordinates=here),
)
)
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", coordinates=here
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(target, cohort)
# Assert — the near neighbour's size dominates the far majority.
assert predicted.total_floor_area_m2 == 120.0
def test_floor_area_median_is_unweighted_without_target_coordinates() -> None:
# Arrange — identical cohort, but the target has no coordinates, so geo
# weighting is off and the floor area reduces to the plain cohort median (60).
here = Coordinates(longitude=0.0, latitude=0.0)
far = Coordinates(longitude=1.0, latitude=1.0)
cohort = ComparableProperties(
members=(
ComparableProperty(_epc(floor_area=60.0), "1", coordinates=far),
ComparableProperty(_epc(floor_area=60.0), "2", coordinates=far),
ComparableProperty(_epc(floor_area=60.0), "3", coordinates=far),
ComparableProperty(_epc(floor_area=120.0), "4", coordinates=here),
)
)
target = PredictionTarget(postcode="LS6 1AA", property_type="2")
# Act
predicted: EpcPropertyData = EpcPrediction().predict(target, cohort)
# Assert — without target coordinates, the plain median (60) wins.
assert predicted.total_floor_area_m2 == 60.0
def test_categorical_mode_leans_on_size_similar_neighbours() -> None:
# Arrange — a count majority (three) carries wall-insulation 9, but two of
# them are 400 m² size outliers; the cohort centre (median 100 m²) holds
# wall-insulation 1. Physical-similarity weighting down-weights the outliers,
# so the size-representative value 1 wins over the plain-count majority 9.
cohort = _cohort(
_epc(floor_area=100.0, wall_insulation_type=1),
_epc(floor_area=100.0, wall_insulation_type=1),
_epc(floor_area=100.0, wall_insulation_type=9),
_epc(floor_area=400.0, wall_insulation_type=9),
_epc(floor_area=400.0, wall_insulation_type=9),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — the size-similar value wins over the outlier-driven majority.
assert predicted.sap_building_parts[0].wall_insulation_type == 1
def test_categorical_mode_leans_on_age_similar_neighbours() -> None:
# Arrange — same size throughout (so size weighting is neutral). A count
# majority (three) carries wall-insulation 9, but two of them are age-band A
# outliers while the cohort's modal band is K. Age-similarity weighting
# down-weights the outliers, so the band-representative value 1 wins.
cohort = _cohort(
_epc(construction_age_band="K", wall_insulation_type=1),
_epc(construction_age_band="K", wall_insulation_type=1),
_epc(construction_age_band="K", wall_insulation_type=9),
_epc(construction_age_band="A", wall_insulation_type=9),
_epc(construction_age_band="A", wall_insulation_type=9),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — the age-similar value wins over the outlier-driven majority.
assert predicted.sap_building_parts[0].wall_insulation_type == 1
def test_confidence_reports_cohort_size_and_unanimous_agreement() -> None:
# Arrange — a unanimous cohort: three neighbours, all cavity-walled (1).
cohort = _cohort(
_epc(wall_construction=1),
_epc(wall_construction=1),
_epc(wall_construction=1),
)
# Act
confidence: PredictionConfidence = EpcPrediction().confidence(cohort)
# Assert — three neighbours, total agreement on the wall construction.
assert confidence.cohort_size == 3
assert confidence.agreement("wall_construction") == 1.0
def test_confidence_agreement_is_the_modal_share_of_the_cohort() -> None:
# Arrange — three of four neighbours are cavity (1), one is solid brick (2),
# so the cohort is split on the wall construction.
cohort = _cohort(
_epc(wall_construction=1),
_epc(wall_construction=1),
_epc(wall_construction=1),
_epc(wall_construction=2),
)
# Act
confidence: PredictionConfidence = EpcPrediction().confidence(cohort)
# Assert — agreement is the modal value's share of the cohort: 3 of 4.
share: Optional[float] = confidence.agreement("wall_construction")
assert share is not None
assert abs(share - 0.75) <= 1e-9
def test_confidence_excludes_absent_component_values_from_the_denominator() -> None:
# Arrange — two neighbours lodge a roof construction (both code 2); one lodges
# none. The missing value must not dilute the agreement to 2/3.
cohort = _cohort(
_epc(roof_construction=2),
_epc(roof_construction=2),
_epc(roof_construction=None),
)
# Act
confidence: PredictionConfidence = EpcPrediction().confidence(cohort)
# Assert — agreement counts only the two present, unanimous values (1.0),
# while the cohort size still reflects all three neighbours.
share: Optional[float] = confidence.agreement("roof_construction")
assert share is not None
assert abs(share - 1.0) <= 1e-9
assert confidence.cohort_size == 3
def test_heating_is_a_coherent_donor_not_the_structural_template() -> None:
# Arrange — the size-representative template (median 80 m²) runs an atypical
# system (fuel 99, no cylinder), but the cohort's modal heating signature is a
# gas system (fuel 1) with a cylinder, including a recent 2024 cert. Heating
# sub-fields can't be field-moded, so the whole SapHeating cluster must be
# copied from the coherent modal donor — the most recent among the matches —
# not inherited from the structural template.
cohort = _dated_cohort(
(
_epc(
floor_area=80.0,
main_fuel_type=99,
main_heating_control=99,
has_hot_water_cylinder=False,
),
date(2016, 1, 1),
),
(_epc(main_fuel_type=1, main_heating_control=5), date(2018, 1, 1)),
(_epc(main_fuel_type=1, main_heating_control=5), date(2019, 1, 1)),
(_epc(main_fuel_type=1, main_heating_control=7), date(2024, 1, 1)),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — heating comes coherently from the modal-signature donor (gas +
# cylinder), the most recent match (control 7 from 2024), not the template's
# fuel 99.
detail = predicted.sap_heating.main_heating_details[0]
assert detail.main_fuel_type == 1
assert detail.main_heating_control == 7
assert predicted.has_hot_water_cylinder is True
def test_ventilation_kind_follows_the_cohort_mode() -> None:
# Mechanical ventilation (MEV/MVHR) is a new-build / retrofit feature that
# clusters by era and street — like glazing — so the predicted ventilation
# kind takes the recency/geo-weighted cohort mode, not the size-template's.
# The size-closest template here is natural (None); the cohort is
# predominantly MVHR, so the prediction must reflect the MVHR neighbourhood
# rather than leave the template's empty ventilation (predicted property
# 721167 follow-up). Natural-vent cohorts mode to None and stay natural.
cohort = _cohort(
_epc(mechanical_ventilation_kind=None), # template (size tie → first)
_epc(mechanical_ventilation_kind="MVHR"),
_epc(mechanical_ventilation_kind="MVHR"),
_epc(mechanical_ventilation_kind="MVHR"),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — the predicted kind is the cohort's MVHR mode, not the template's None.
assert predicted.sap_ventilation is not None
assert predicted.sap_ventilation.mechanical_ventilation_kind == "MVHR"
def test_glazing_follows_the_recency_weighted_cohort_mode() -> None:
# Arrange — an old majority single-glazed (type 1, 2015) and a recent
# minority double-glazed (type 3, 2025). Glazing is retrofitted over time
# (single → double), so the recent neighbours reflect the current state: the
# recency-weighted mode must pick double over the stale single-glazed
# majority, like roof insulation thickness.
cohort = _dated_cohort(
(_epc(glazing_type=1), date(2015, 1, 1)),
(_epc(glazing_type=1), date(2015, 1, 1)),
(_epc(glazing_type=1), date(2015, 1, 1)),
(_epc(glazing_type=3), date(2025, 1, 1)),
(_epc(glazing_type=3), date(2025, 1, 1)),
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(
PredictionTarget(postcode="LS6 1AA", property_type="2"), cohort
)
# Assert — every predicted window takes the recent glazing over the majority.
assert all(window.glazing_type == 3 for window in predicted.sap_windows)
def test_geo_proximity_weights_the_nearest_neighbour() -> None:
# Arrange — same size + age (so similarity weighting is uniform). Three FAR
# neighbours are cavity (1); one neighbour AT the target is solid brick (2).
# wall construction is a geo-weighted component, so the near neighbour
# outweighs the far majority.
here = Coordinates(longitude=0.0, latitude=0.0)
far = Coordinates(longitude=1.0, latitude=1.0) # ~150 km away
cohort = ComparableProperties(
members=(
ComparableProperty(_epc(wall_construction=1), "1", coordinates=far),
ComparableProperty(_epc(wall_construction=1), "2", coordinates=far),
ComparableProperty(_epc(wall_construction=1), "3", coordinates=far),
ComparableProperty(_epc(wall_construction=2), "4", coordinates=here),
)
)
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", coordinates=here
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(target, cohort)
# Assert — the near neighbour's wall wins over the far majority.
assert predicted.sap_building_parts[0].wall_construction == 2
def test_geo_proximity_is_off_without_target_coordinates() -> None:
# Arrange — identical cohort, but the target has no coordinates, so geo
# weighting is disabled and the plain cohort majority (cavity, 1) wins.
here = Coordinates(longitude=0.0, latitude=0.0)
far = Coordinates(longitude=1.0, latitude=1.0)
cohort = ComparableProperties(
members=(
ComparableProperty(_epc(wall_construction=1), "1", coordinates=far),
ComparableProperty(_epc(wall_construction=1), "2", coordinates=far),
ComparableProperty(_epc(wall_construction=1), "3", coordinates=far),
ComparableProperty(_epc(wall_construction=2), "4", coordinates=here),
)
)
target = PredictionTarget(postcode="LS6 1AA", property_type="2")
# Act
predicted: EpcPropertyData = EpcPrediction().predict(target, cohort)
# Assert — without target coordinates, the majority wins (geo off).
assert predicted.sap_building_parts[0].wall_construction == 1
def test_applies_a_known_wall_override_over_the_mode() -> None:
# Arrange — the cohort mode is cavity (1), but we KNOW the target is solid
# brick (2), a Landlord Override. The known value must win over the estimate.
cohort = _cohort(
_epc(wall_construction=1),
_epc(wall_construction=1),
_epc(wall_construction=1),
)
target = PredictionTarget(
postcode="LS6 1AA", property_type="2", wall_construction=2
)
# Act
predicted: EpcPropertyData = EpcPrediction().predict(target, cohort)
# Assert — the known override overrides the cohort mode.
assert predicted.sap_building_parts[0].wall_construction == 2
def test_heating_donor_carries_the_donors_off_peak_meter() -> None:
# The coherent heating system spans the meter (ADR-0035): the donor's
# off-peak meter must travel with its heating cluster, replacing the
# template's single-rate meter — otherwise a donated storage system bills at
# the peak rate and the score collapses.
predicted = _epc(meter_type="2") # the structural template's single meter
donor = _epc(meter_type="Dual", main_fuel_type=29) # the cohort's heating
EpcPrediction._apply_heating_donor(predicted, _cohort(donor))
assert predicted.sap_energy_source.meter_type == "Dual"
def test_heating_donor_carries_the_donors_display_heating_and_control() -> None:
# The displayed heating panel (Main Heating + Heating Control rows) describes
# the same system as the calc cluster, so it must travel with the donor — not
# be left on the size-representative structural template. Two failures
# otherwise: (1) the displayed heating is incoherent with the donated calc
# system, and (2) "Heating Control: Unknown" whenever the template lodged no
# control row (the donor's is dropped). Predicted property 721167 (ADR-0029
# follow-up): the template carried no main_heating_controls, so its passport
# showed Heating Control = Unknown despite a coherent gas-boiler donor.
predicted = _epc(
main_heating_label="Room heaters, electric",
main_heating_controls_label=None, # template lodged no control
)
donor = _epc(
main_fuel_type=29,
main_heating_label="Boiler and radiators, mains gas",
main_heating_controls_label="Programmer, room thermostat and TRVs",
)
EpcPrediction._apply_heating_donor(predicted, _cohort(donor))
assert predicted.main_heating[0].description == "Boiler and radiators, mains gas"
assert predicted.main_heating_controls is not None
assert (
predicted.main_heating_controls.description
== "Programmer, room thermostat and TRVs"
)