diff --git a/docs/baseline-downgrade-followups.md b/docs/baseline-downgrade-followups.md index 0b7464c1..cc3931d3 100644 --- a/docs/baseline-downgrade-followups.md +++ b/docs/baseline-downgrade-followups.md @@ -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. diff --git a/domain/epc_prediction/epc_prediction.py b/domain/epc_prediction/epc_prediction.py index 4261df6d..c582548c 100644 --- a/domain/epc_prediction/epc_prediction.py +++ b/domain/epc_prediction/epc_prediction.py @@ -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 diff --git a/tests/domain/epc_prediction/test_epc_prediction.py b/tests/domain/epc_prediction/test_epc_prediction.py index 6cbb3de4..c7ffbf52 100644 --- a/tests/domain/epc_prediction/test_epc_prediction.py +++ b/tests/domain/epc_prediction/test_epc_prediction.py @@ -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" + ) diff --git a/tests/domain/epc_prediction/test_validation.py b/tests/domain/epc_prediction/test_validation.py index 2cdad977..24fa0198 100644 --- a/tests/domain/epc_prediction/test_validation.py +++ b/tests/domain/epc_prediction/test_validation.py @@ -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