mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-19 08:53:17 +00:00
A dwelling's heating is one conceptual system, but its fields are scattered across EpcPropertyData (a gov-API schema mirror): the cluster on sap_heating, the electricity tariff on sap_energy_source.meter_type, hot-water flags loose at top level. Three places synthesise a heating system — Measure Options, Landlord Overrides, EPC Prediction's donor — and each hand-copied a different ad-hoc subset. The override and donor both dropped meter_type, so an electric-storage system landed on the template's single-rate meter and billed overnight heat at the peak rate: property 713406 scored SAP 13 (G) vs ~50 (E), inflating the HHRSH measure to +45.8 and overshooting the plan to band A. Establish a single Coherent Heating System boundary (CONTEXT.md) that every synthesiser must cover, with a source-appropriate fill policy (ADR-0035): - Override overlay *completes* the partial system the landlord named. Companion fields are now DERIVED from the SAP code, not hand-attached per archetype: the off-peak meter from the calculator's single off-peak classification (new OFF_PEAK_IMPLYING_HEATING_CODES = SAP §12 Rules 1-2), and an unobserved storage charge control defaults to the conservative manual control (Table 4e 2401). So adding a heating archetype is just adding its code — companions can't be forgotten. A contract test guards it (every off-peak code drags a Dual meter). - Prediction's heating donor now *carries* the donor's meter_type alongside its sap_heating cluster — the donor is already coherent. Coherence is a synthesis-time obligation only; the calculator still scores a real lodged cert exactly as lodged. Verified on 713406: baseline 13 -> 47.8 (E), matching its recorded rating; the phantom HHRSH recommendation is gone and the plan no longer overshoots to A. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
124 lines
4.3 KiB
Python
124 lines
4.3 KiB
Python
"""Behaviour of the Component Accuracy leave-one-out scorer (ADR-0030): given
|
|
loaded postcode cohorts, hold out each SAP 10.2 target, predict it from its
|
|
all-vintage neighbours, and aggregate the per-component hits + residuals. Pure
|
|
(no IO, no calculator) — corpus loading is the caller's job.
|
|
"""
|
|
|
|
from datetime import date
|
|
from typing import Optional, Union
|
|
|
|
from datatypes.epc.domain.epc_property_data import (
|
|
EpcPropertyData,
|
|
MainHeatingDetail,
|
|
SapBuildingPart,
|
|
SapEnergySource,
|
|
SapFloorDimension,
|
|
SapHeating,
|
|
)
|
|
from domain.epc_prediction.comparable_properties import ComparableProperty
|
|
from domain.epc_prediction.validation import evaluate_component_accuracy
|
|
|
|
|
|
def _comparable(
|
|
*,
|
|
certificate_number: str,
|
|
address: str,
|
|
sap_version: float,
|
|
wall_construction: Union[int, str] = 1,
|
|
registration_date: Optional[date] = None,
|
|
) -> ComparableProperty:
|
|
"""A ComparableProperty carrying a fully-populated opaque EpcPropertyData — every
|
|
field the predictor + comparison read (the partial-instance idiom)."""
|
|
epc: EpcPropertyData = object.__new__(EpcPropertyData)
|
|
epc.sap_version = sap_version
|
|
epc.postcode = "LS6 1AA"
|
|
epc.property_type = "2"
|
|
epc.built_form = "4"
|
|
epc.total_floor_area_m2 = 80.0
|
|
epc.door_count = 2
|
|
epc.solar_water_heating = False
|
|
epc.has_hot_water_cylinder = True
|
|
part: SapBuildingPart = object.__new__(SapBuildingPart)
|
|
part.wall_construction = wall_construction
|
|
part.wall_insulation_type = 1
|
|
part.construction_age_band = "K"
|
|
part.roof_construction = 1
|
|
part.roof_insulation_thickness = 100
|
|
part.sap_room_in_roof = None
|
|
floor_dim: SapFloorDimension = object.__new__(SapFloorDimension)
|
|
floor_dim.floor_construction = 1
|
|
floor_dim.floor_insulation = 1
|
|
part.sap_floor_dimensions = [floor_dim]
|
|
epc.sap_building_parts = [part]
|
|
epc.sap_windows = []
|
|
detail: MainHeatingDetail = object.__new__(MainHeatingDetail)
|
|
detail.main_fuel_type = 20
|
|
detail.main_heating_category = 2
|
|
detail.main_heating_control = 2100
|
|
heating: SapHeating = object.__new__(SapHeating)
|
|
heating.main_heating_details = [detail]
|
|
heating.water_heating_fuel = 20
|
|
heating.water_heating_code = 901
|
|
heating.cylinder_insulation_type = 1
|
|
heating.secondary_heating_type = None
|
|
epc.sap_heating = heating
|
|
energy: SapEnergySource = object.__new__(SapEnergySource)
|
|
energy.photovoltaic_supply = None
|
|
energy.photovoltaic_arrays = None
|
|
energy.meter_type = "2"
|
|
epc.sap_energy_source = energy
|
|
return ComparableProperty(
|
|
epc=epc,
|
|
certificate_number=certificate_number,
|
|
address=address,
|
|
registration_date=registration_date,
|
|
)
|
|
|
|
|
|
def test_scores_only_sap_10_2_targets() -> None:
|
|
# Arrange — a cohort of two distinct addresses: one SAP 10.2, one older
|
|
# (SAP 9.94). Only the 10.2 cert is a valid held-out target; the older one
|
|
# is kept as source evidence (its components are still valid).
|
|
cohort = [
|
|
_comparable(
|
|
certificate_number="A", address="1 THE ROW", sap_version=10.2
|
|
),
|
|
_comparable(
|
|
certificate_number="B", address="2 THE ROW", sap_version=9.94
|
|
),
|
|
]
|
|
|
|
# Act
|
|
accuracy = evaluate_component_accuracy([cohort])
|
|
|
|
# Assert — exactly one target scored (the 10.2 cert), predicted from the
|
|
# older neighbour; the older cert was never held out.
|
|
assert accuracy.targets == 1
|
|
assert accuracy.rate("wall_construction") == 1.0
|
|
|
|
|
|
def test_aggregates_a_wall_classification_miss() -> None:
|
|
# Arrange — the 10.2 target is solid brick (2); its only neighbour (the
|
|
# source) is cavity (1), so the predicted mode misses the wall.
|
|
cohort = [
|
|
_comparable(
|
|
certificate_number="A",
|
|
address="1 THE ROW",
|
|
sap_version=10.2,
|
|
wall_construction=2,
|
|
),
|
|
_comparable(
|
|
certificate_number="B",
|
|
address="2 THE ROW",
|
|
sap_version=10.2,
|
|
wall_construction=1,
|
|
),
|
|
]
|
|
|
|
# Act
|
|
accuracy = evaluate_component_accuracy([cohort])
|
|
|
|
# Assert — both are 10.2 targets, and each is predicted from the other (the
|
|
# opposite wall), so wall_construction is missed both times.
|
|
assert accuracy.targets == 2
|
|
assert accuracy.rate("wall_construction") == 0.0
|