Merge pull request #1337 from Hestia-Homes/fix/predicted-property-display-unknowns

fix(epc-prediction): populate Heating-Control + Ventilation display for predicted properties
This commit is contained in:
Jun-te Kim 2026-06-26 13:45:22 +01:00 committed by GitHub
commit 706e0072d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 165 additions and 3 deletions

View file

@ -68,5 +68,32 @@ issue before trusting the cohort.
## 4. Predicted-property display path (e.g. property 721167)
721167 has **no lodged EPC** (predicted). Its Heating-Control / Main-Fuel /
Ventilation Unknowns come from the prediction + landlord-override **overlay** not
populating the display fields — a separate path from the lodged-cert mappers.
Ventilation Unknowns come from the prediction synthesis path
(`domain/epc_prediction/epc_prediction.py`), **not** the lodged-cert mappers and
**not** the landlord-override overlay (`overlays_from` is simulation-time only,
ADR-0032 — it never writes the persisted/displayed EPC). Investigated and fixed
on `fix/predicted-property-display-unknowns`:
- **Heating Control — FIXED (Model).** `_apply_heating_donor` copied the donor's
*calc* `sap_heating` cluster but left the *display* rows (`main_heating`,
`main_heating_controls`) on the size-representative structural template —
incoherent with the donated system, and "Unknown" whenever the template lodged
no control row (exactly 721167: no `main_heating_controls` energy element
persisted). Now carries the donor's display heating + control alongside the
calc cluster.
- **Ventilation — FIXED (Model).** The prediction never synthesised ventilation
— it kept the template's `sap_ventilation`, so a predicted dwelling in an
MEV/MVHR neighbourhood was scored + displayed as natural. New
`_apply_ventilation_mode` modes `mechanical_ventilation_kind` across the cohort
(recency/geo-weighted, mirroring glazing). Natural-vent cohorts mode to None
and stay natural (§2 default) — so a genuinely natural dwelling like 721167
correctly stays natural; the fix moves only real MEV/MVHR neighbourhoods.
- **Main Fuel — NOT a prediction gap.** `epc_main_heating_detail.main_fuel_type`
*is* persisted for predicted properties (721167 → `26` = mains gas, carried
from the heating donor). The "Unknown" is the **same FE code→name gap as item
2** (the Drizzle/Next repo not mapping the fuel code), not a Model issue.
Both fixes are display-only for the calculator (component-accuracy gate, corpus
harness, and prediction e2e all green); persistence already carries
`main_heating_controls` (energy element) and `ventilation_mechanical_ventilation_kind`,
so the synthesised values reach the passport on the next prediction run.

View file

@ -70,6 +70,7 @@ class EpcPrediction:
)
self._apply_categorical_modes(predicted, comparables, target.coordinates)
self._apply_glazing_mode(predicted, comparables, target.coordinates)
self._apply_ventilation_mode(predicted, comparables, target.coordinates)
self._apply_heating_donor(predicted, comparables)
self._apply_overrides(predicted, target)
return predicted
@ -93,7 +94,15 @@ class EpcPrediction:
electricity tariff (`sap_energy_source.meter_type`) and hot-water flags
live on loose top-level fields. Carry the donor's whole set, not a subset
otherwise a donated storage system lands on the template's single-rate
meter and the SAP score collapses (off-peak heat billed at the peak rate)."""
meter and the SAP score collapses (off-peak heat billed at the peak rate).
The system also has a DISPLAY face the building-passport "Main Heating"
and "Heating Control" rows (`main_heating` / `main_heating_controls`
EnergyElements). These describe the same system as the calc cluster, so
they travel with the donor too; left on the structural template they are
incoherent with the donated calc heating, and `main_heating_controls`
shows "Unknown" whenever the size-template lodged no control row but the
donor does (predicted property 721167, ADR-0029 follow-up)."""
donor = _heating_donor(comparables.members)
if donor is None:
return
@ -101,6 +110,10 @@ class EpcPrediction:
predicted.has_hot_water_cylinder = donor.epc.has_hot_water_cylinder
predicted.solar_water_heating = donor.epc.solar_water_heating
predicted.sap_energy_source.meter_type = donor.epc.sap_energy_source.meter_type
predicted.main_heating = copy.deepcopy(donor.epc.main_heating)
predicted.main_heating_controls = copy.deepcopy(
donor.epc.main_heating_controls
)
@staticmethod
def _apply_glazing_mode(
@ -127,6 +140,36 @@ class EpcPrediction:
for window in predicted.sap_windows:
window.glazing_type = glazing
@staticmethod
def _apply_ventilation_mode(
predicted: EpcPropertyData,
comparables: ComparableProperties,
target_coordinates: Optional[Coordinates],
) -> None:
"""Set the predicted mechanical-ventilation kind to the recency- and
geo-weighted cohort mode. A mechanical system (MEV/MVHR) is a new-build /
retrofit feature that clusters by era and street (like glazing), so a
recent, near neighbour is the best signal for the target's system; the
size-representative structural template is not (it just happens to carry
whatever its own cert lodged). Only the kind moves the rest of the
template's `sap_ventilation` (e.g. `sheltered_sides`, derived from
built_form) stays. A natural-vent cohort modes to None and is left
natural the §2 cascade default so this only moves genuine MEV/MVHR
neighbourhoods (ADR-0029 follow-up; predicted property 721167)."""
ventilation = predicted.sap_ventilation
if ventilation is None:
return
members = comparables.members
weights = _combine(
_recency_weights(members), _geo_weights(target_coordinates, members)
)
kind = _weighted_mode(
(_comparable_ventilation_kind(c) for c in members), weights
)
if kind is None:
return
ventilation.mechanical_ventilation_kind = kind
def confidence(
self, comparables: ComparableProperties
) -> PredictionConfidence:
@ -539,6 +582,15 @@ def _comparable_modal_glazing(
return Counter(types).most_common(1)[0][0] if types else None
def _comparable_ventilation_kind(
comparable: ComparableProperty,
) -> Optional[str]:
"""A comparable's mechanical-ventilation kind (the `MechanicalVentilationKind`
enum name, e.g. "MVHR"), or None when it lodges no system (natural)."""
ventilation = comparable.epc.sap_ventilation
return ventilation.mechanical_ventilation_kind if ventilation is not None else None
def _main_heating_detail(comparable: ComparableProperty) -> Optional[MainHeatingDetail]:
"""The primary heating system's detail row, or None when none is lodged."""
details = comparable.epc.sap_heating.main_heating_details

View file

@ -10,12 +10,14 @@ 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
@ -51,6 +53,9 @@ def _epc(
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"
@ -87,8 +92,28 @@ def _epc(
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
@ -538,6 +563,31 @@ def test_heating_is_a_coherent_donor_not_the_structural_template() -> None:
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
@ -637,3 +687,32 @@ def test_heating_donor_carries_the_donors_off_peak_meter() -> None:
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"
)

View file

@ -64,6 +64,10 @@ def _comparable(
heating.cylinder_insulation_type = 1
heating.secondary_heating_type = None
epc.sap_heating = heating
# Display heating rows the heating-donor synthesis carries (ADR-0029
# follow-up); `main_heating` has no dataclass default, so a partial instance
# must set it. `main_heating_controls` / `sap_ventilation` default to None.
epc.main_heating = []
energy: SapEnergySource = object.__new__(SapEnergySource)
energy.photovoltaic_supply = None
energy.photovoltaic_arrays = None