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