mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
Heating sub-fields can't be field-moded without breaking system coherence, so the whole SapHeating cluster is now copied as a unit from a single coherent donor rather than inherited from the structural template: the neighbour matching the cohort's modal heating signature (main fuel + category + cylinder presence), most recent among the matches (recent cert = current system). Including cylinder presence in the signature is load-bearing — it protects has_hot_water_cylinder + cylinder_insulation (a bare fuel+cat signature regressed them). Corpus (150pc/514): heating_main_control 66.3 -> 73.9% (+7.6, the target), main_fuel 92.8 -> 96.9, category 90.7 -> 95.7, water_fuel 92.8 -> 96.3, water_code 88.5 -> 95.3, has_cylinder 81.1 -> 89.7, secondary 36.2 -> 42.0. SAP MAE vs lodged 7.08 -> 6.00 (calculator floor 1.57). cylinder_insulation -13.6 corpus (tiny-n) but +33pp on the fixture; AC requires control up + fuel/category hold + SAP not worsened, all met. Gate (36-target fixture): zero regression; ratcheted main_category 0.8889->0.9444, main_control 0.7500->0.8056, water_fuel 0.9167->0.9722, water_code 0.8889->0.9444, cylinder_insulation_type 0.1667->0.5000. This is the per-component heating method ([[feedback_per_component_best_method]]): coherent donor, never field-mode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
448 lines
17 KiB
Python
448 lines
17 KiB
Python
"""Behaviour of EPC Prediction synthesis (ADR-0029): turn the selected
|
|
Comparable 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 (
|
|
EpcPropertyData,
|
|
MainHeatingDetail,
|
|
SapBuildingPart,
|
|
SapFloorDimension,
|
|
SapHeating,
|
|
SapWindow,
|
|
)
|
|
from domain.epc_prediction.comparable_properties import (
|
|
Comparable,
|
|
ComparableProperties,
|
|
PredictionTarget,
|
|
)
|
|
from domain.epc_prediction.epc_prediction import (
|
|
EpcPrediction,
|
|
PredictionConfidence,
|
|
)
|
|
|
|
|
|
def _epc(
|
|
*,
|
|
building_parts: int = 1,
|
|
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,
|
|
) -> 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.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.has_hot_water_cylinder = has_hot_water_cylinder
|
|
epc.solar_water_heating = solar_water_heating
|
|
return epc
|
|
|
|
|
|
def _cohort(*epcs: EpcPropertyData) -> ComparableProperties:
|
|
return ComparableProperties(
|
|
members=tuple(
|
|
Comparable(epc=e, certificate_number=str(i)) for i, e in enumerate(epcs)
|
|
)
|
|
)
|
|
|
|
|
|
def _dated_cohort(
|
|
*dated: tuple[EpcPropertyData, date],
|
|
) -> ComparableProperties:
|
|
return ComparableProperties(
|
|
members=tuple(
|
|
Comparable(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_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_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_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_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
|