Model/tests/domain/epc_prediction/test_validation.py
Khalim Conn-Kowlessar 027ee1fba3 refactor(epc-prediction): extract shared leave-one-out scorer + corpus loader (ADR-0030)
"One scorer, two harnesses" (ADR-0030): the committed gate, the local script,
and the future battle-test must run the *same* scoring. Extract it:

- domain/epc_prediction/validation.py — `iter_predictions` (the single
  leave-one-out orchestration: latest-per-address hold-out, SAP-10.2 target
  filter, all-vintage source) + `evaluate_component_accuracy` (calculator-free
  ComponentAccuracy aggregation, the primary signal). Unit-tested.
- harness/epc_prediction_corpus.py — `load_corpus(dir)` IO: corpus dir ->
  Comparable cohorts (maps payloads, carries address + registration_date).

validate_epc_prediction.py now just loads + calls the scorer for the component
section and iterates iter_predictions for the calculator-floored end-to-end.
Identical numbers (181 targets, SAP MAE 6.34) — behaviour-preserving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:12:08 +00:00

123 lines
4.2 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 Comparable
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,
) -> Comparable:
"""A Comparable 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
epc.sap_energy_source = energy
return Comparable(
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