"""Valuation Uplift — the estimated market-value increase a retrofit produces. Percentage-primary (ADR-0018): the uplift is computed purely from the EPC Band jump (current -> target) and is always returned as a percentage; the absolute £ forms appear only when a Property Valuation (current market value) is supplied, and are capped so the £ uplift never exceeds twice the retrofit cost. The band-transition percentages are ported verbatim from the legacy `backend/ml_models/Valuation.py` — four published broker sources, a provenance snapshot rather than a live feed. MoneySupermarket / Lloyds give per-band-step figures we compound across the jump; Knight Frank / Rightmove give whole-jump spot figures. The uplift takes the min / max / mean across the sources that cover the jump. See CONTEXT.md (Property Valuation, Valuation Uplift). """ from __future__ import annotations from dataclasses import dataclass from math import prod from typing import Optional # Ascending energy efficiency, worst -> best (RdSAP band letters). _EPC_BANDS: tuple[str, ...] = ("G", "F", "E", "D", "C", "B", "A") # Per-band-step uplift %, compounded across the jump. _MSM_STEP: dict[tuple[str, str], float] = { ("G", "F"): 0.06, ("F", "E"): 0.01, ("E", "D"): 0.01, ("D", "C"): 0.02, ("C", "B"): 0.04, ("B", "A"): 0.0, } _LLOYDS_STEP: dict[tuple[str, str], float] = { ("G", "F"): 0.038, ("F", "E"): 0.029, ("E", "D"): 0.024, ("D", "C"): 0.02, ("C", "B"): 0.02, ("B", "A"): 0.018, } # Whole-jump spot uplift %, looked up by (current, target); absent jumps don't # contribute a source. _KNIGHT_FRANK_JUMP: dict[tuple[str, str], float] = { ("D", "C"): 0.03, ("D", "B"): 0.088, ("D", "A"): 0.088, } _RIGHTMOVE_JUMP: dict[tuple[str, str], float] = { ("G", "C"): 0.15, ("G", "B"): 0.15, ("G", "A"): 0.15, ("F", "C"): 0.15, ("F", "B"): 0.15, ("F", "A"): 0.15, ("E", "C"): 0.07, ("E", "B"): 0.07, ("E", "A"): 0.07, ("D", "C"): 0.03, ("D", "B"): 0.03, ("D", "A"): 0.03, } _ROI_CAP = 2.0 # the £ uplift is capped at this multiple of the retrofit cost @dataclass(frozen=True) class ValuationUplift: """A retrofit's estimated market-value uplift. The percentages are always present (from the Band jump); the £ forms are populated only when a current market value was supplied. `lower_value` / `upper_value` / `average_value` are the £ *increase* at the min / max / mean source; `post_retrofit_value` is the resulting market value (current + average increase).""" lower_pct: float upper_pct: float average_pct: float lower_value: Optional[float] = None upper_value: Optional[float] = None average_value: Optional[float] = None post_retrofit_value: Optional[float] = None def _require_band(band: str) -> int: if band not in _EPC_BANDS: raise ValueError(f"unknown EPC band {band!r}") return _EPC_BANDS.index(band) def _band_uplift_percentages(current_band: str, target_band: str) -> tuple[float, float, float]: """The (min, max, mean) uplift percentages across the sources covering the jump. A non-improving jump (target no better than current) compounds over no steps and matches no spot source, so MoneySupermarket / Lloyds both yield 0 and the result is a no-op 0%.""" current_index = _require_band(current_band) target_index = _require_band(target_band) steps = [ (_EPC_BANDS[i], _EPC_BANDS[i + 1]) for i in range(current_index, target_index) ] msm: float = prod(1 + _MSM_STEP[step] for step in steps) - 1 lloyds: float = prod(1 + _LLOYDS_STEP[step] for step in steps) - 1 increases: list[float] = [msm, lloyds] knight_frank: Optional[float] = _KNIGHT_FRANK_JUMP.get((current_band, target_band)) rightmove: Optional[float] = _RIGHTMOVE_JUMP.get((current_band, target_band)) if knight_frank is not None: increases.append(knight_frank) if rightmove is not None: increases.append(rightmove) return min(increases), max(increases), sum(increases) / len(increases) def estimate_valuation_uplift( current_band: str, target_band: str, current_value: Optional[float] = None, total_cost: Optional[float] = None, ) -> ValuationUplift: """Estimate the Valuation Uplift of moving a Property from `current_band` to `target_band`. Returns percentages always; absolute £ forms only when `current_value` is given. When both `current_value` and `total_cost` are given, the percentages are rescaled so the average £ uplift does not exceed `_ROI_CAP` times the cost (the cap can only bite once a value is known).""" lower_pct, upper_pct, average_pct = _band_uplift_percentages( current_band, target_band ) if current_value is not None and total_cost is not None and total_cost > 0: average_value = current_value * average_pct if average_value > _ROI_CAP * total_cost: capped_average_pct = _ROI_CAP * total_cost / current_value scalar = capped_average_pct / average_pct lower_pct *= scalar upper_pct *= scalar average_pct = capped_average_pct if current_value is None: return ValuationUplift( lower_pct=lower_pct, upper_pct=upper_pct, average_pct=average_pct ) average_increase: float = current_value * average_pct return ValuationUplift( lower_pct=lower_pct, upper_pct=upper_pct, average_pct=average_pct, lower_value=current_value * lower_pct, upper_value=current_value * upper_pct, average_value=average_increase, post_retrofit_value=current_value + average_increase, )