mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
commit
706e0072d0
4 changed files with 165 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue