From 5cf5b674201a2dcb1ce32d160d788c20a067be1f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 10:37:36 +0000 Subject: [PATCH 1/6] =?UTF-8?q?Carry=20donor's=20display=20heating=20+=20c?= =?UTF-8?q?ontrol=20into=20predicted=20EPC=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _apply_heating_donor copies the donor's calc sap_heating but leaves the display rows (main_heating, main_heating_controls) on the structural template — incoherent, and 'Heating Control: Unknown' when the template lodged no control (predicted property 721167, ADR-0029 follow-up). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc_prediction/test_epc_prediction.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/domain/epc_prediction/test_epc_prediction.py b/tests/domain/epc_prediction/test_epc_prediction.py index 6cbb3de4..4b0ff149 100644 --- a/tests/domain/epc_prediction/test_epc_prediction.py +++ b/tests/domain/epc_prediction/test_epc_prediction.py @@ -10,6 +10,7 @@ from typing import Optional, Union from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, + EnergyElement, EpcPropertyData, MainHeatingDetail, SapBuildingPart, @@ -51,6 +52,8 @@ 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, ) -> EpcPropertyData: epc: EpcPropertyData = object.__new__(EpcPropertyData) epc.property_type = "2" @@ -87,6 +90,22 @@ 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 energy: SapEnergySource = object.__new__(SapEnergySource) @@ -637,3 +656,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" + ) From 44726469d4e37bb051681879ee752b3d5b027ad9 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 10:44:05 +0000 Subject: [PATCH 2/6] =?UTF-8?q?Carry=20donor's=20display=20heating=20+=20c?= =?UTF-8?q?ontrol=20into=20predicted=20EPC=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _apply_heating_donor now also deepcopies the donor's main_heating and main_heating_controls EnergyElements alongside its calc sap_heating cluster, so the building-passport heating rows are coherent with the donated system and a control row the size-template lacked (but the donor lodges) is populated. Fixes 'Heating Control: Unknown' on predicted properties (e.g. 721167). Display-only: component-accuracy gate + corpus harness unchanged (26/6 green). Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/epc_prediction/epc_prediction.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/domain/epc_prediction/epc_prediction.py b/domain/epc_prediction/epc_prediction.py index 4261df6d..072fcfc6 100644 --- a/domain/epc_prediction/epc_prediction.py +++ b/domain/epc_prediction/epc_prediction.py @@ -93,7 +93,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 +109,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( From 80c5ad0c6caf583766db816df3629291072b43a1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 10:49:37 +0000 Subject: [PATCH 3/6] =?UTF-8?q?Predict=20ventilation=20kind=20from=20the?= =?UTF-8?q?=20cohort=20mode=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prediction never synthesises ventilation — it keeps the size-template's sap_ventilation, so a predicted dwelling in an MEV/MVHR neighbourhood is scored + displayed as natural (predicted property 721167 follow-up). Mode the mechanical_ventilation_kind across the cohort like glazing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc_prediction/test_epc_prediction.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/domain/epc_prediction/test_epc_prediction.py b/tests/domain/epc_prediction/test_epc_prediction.py index 4b0ff149..c7ffbf52 100644 --- a/tests/domain/epc_prediction/test_epc_prediction.py +++ b/tests/domain/epc_prediction/test_epc_prediction.py @@ -17,6 +17,7 @@ from datatypes.epc.domain.epc_property_data import ( SapEnergySource, SapFloorDimension, SapHeating, + SapVentilation, SapWindow, ) from domain.geospatial.coordinates import Coordinates @@ -54,6 +55,7 @@ def _epc( 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" @@ -108,6 +110,10 @@ def _epc( ) 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 @@ -557,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 From a88b5502344eaa5422d3d4a7d947ef3c77293b95 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 10:54:11 +0000 Subject: [PATCH 4/6] =?UTF-8?q?Predict=20ventilation=20kind=20from=20the?= =?UTF-8?q?=20cohort=20mode=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add _apply_ventilation_mode: set the predicted mechanical_ventilation_kind to the recency/geo-weighted cohort mode (mirrors _apply_glazing_mode — MEV/MVHR is a new-build/retrofit feature clustering by era + street). Only the kind moves; the template's sheltered_sides etc. stay. Natural cohorts mode to None and stay natural (§2 default), so this only moves genuine MEV/MVHR neighbourhoods. Display-only for the calc gate: component-accuracy (26) + corpus (6) + e2e (1) all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/epc_prediction/epc_prediction.py | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/domain/epc_prediction/epc_prediction.py b/domain/epc_prediction/epc_prediction.py index 072fcfc6..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 @@ -139,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: @@ -551,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 From 5fc8bd3b3d790685aececfb4f185a20539a20b68 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 10:54:53 +0000 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20record=20predicted-property=20displ?= =?UTF-8?q?ay=20fixes=20(item=204)=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/baseline-downgrade-followups.md | 31 ++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) 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. From ac94a543b1fcbf8f24453caab7261fc8b4876460 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 12:35:01 +0000 Subject: [PATCH 6/6] =?UTF-8?q?Set=20main=5Fheating=20on=20the=20test=5Fva?= =?UTF-8?q?lidation=20partial=20EPC=20builder=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heating-donor display synthesis reads donor.epc.main_heating, which has no dataclass default — so a partial object.__new__ EpcPropertyData must set it. test_validation's _comparable builder didn't, failing the two leave-one-out scorer tests in CI (the full epc_prediction suite wasn't run pre-push). main_heating_controls / sap_ventilation default to None via class attributes. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/domain/epc_prediction/test_validation.py | 4 ++++ 1 file changed, 4 insertions(+) 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