From b3f4609c2db7d98f67c751da8a213dcc1e50a482 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 08:59:04 +0000 Subject: [PATCH] feat(modelling): wire Valuation Uplift onto the Plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plan derives its Valuation Uplift (ADR-0018) from its baseline -> post band jump and works+contingency cost, given one external input — the Property's current market value (a Property Valuation, mostly absent). `Plan.valuation` / `Plan.baseline_epc_rating` are derived like the other headline figures; `PlanModel.from_domain` maps the £ forms to the live plan.valuation_* columns (NULL when no value — the percentage is not persisted on those columns). `Property.current_market_value` is the new optional source; the orchestrator threads it onto the Plan. `run_one` takes a `current_market_value` so the harness can value the uplift, and the sense-check table shows the average % (always) plus the £ forms when known. Sourcing the current market value (upload / default) remains deferred (ADR-0018); it is None throughout until that lands, so the columns stay NULL at scale. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/plan.py | 23 ++++++ domain/property/property.py | 3 + harness/console.py | 6 +- harness/plan_table.py | 9 ++- .../postgres/modelling/plan_table.py | 7 ++ orchestration/modelling_orchestrator.py | 10 ++- tests/domain/modelling/test_plan_valuation.py | 74 +++++++++++++++++++ tests/harness/test_console.py | 15 ++++ tests/harness/test_plan_table.py | 25 +++++++ tests/orchestration/fakes.py | 1 + 10 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 tests/domain/modelling/test_plan_valuation.py diff --git a/domain/modelling/plan.py b/domain/modelling/plan.py index 7016ecda..8483359f 100644 --- a/domain/modelling/plan.py +++ b/domain/modelling/plan.py @@ -18,6 +18,7 @@ from domain.billing.bill import Bill from domain.modelling.scoring.package_scorer import Score from domain.modelling.recommendation import Cost from domain.modelling.scoring.scoring import MeasureImpact +from domain.modelling.valuation import ValuationUplift, estimate_valuation_uplift @dataclass(frozen=True) @@ -60,6 +61,10 @@ class Plan: post_retrofit: Score baseline_bill: Optional[Bill] = None post_bill: Optional[Bill] = None + # The Property's current market value (a Property Valuation), when known. + # Mostly absent — then the Valuation Uplift is percentage-only and its £ + # forms are None (ADR-0018). + current_market_value: Optional[float] = None @property def cost_of_works(self) -> float: @@ -88,6 +93,24 @@ class Plan: """The post-retrofit EPC band, from the rounded SAP rating.""" return Epc.from_sap_score(round(self.post_retrofit.sap_continuous)) + @property + def baseline_epc_rating(self) -> Epc: + """The baseline EPC band, from the rounded baseline SAP rating.""" + return Epc.from_sap_score(round(self.baseline.sap_continuous)) + + @property + def valuation(self) -> ValuationUplift: + """The Valuation Uplift this Plan produces — the estimated market-value + increase from the baseline -> post band jump (ADR-0018). Always a + percentage; the £ forms are populated only when `current_market_value` + is known, capped at 2x the works + contingency cost.""" + return estimate_valuation_uplift( + current_band=self.baseline_epc_rating.value, + target_band=self.post_epc_rating.value, + current_value=self.current_market_value, + total_cost=self.cost_of_works + self.contingency_cost, + ) + @property def co2_savings_kg_per_yr(self) -> float: """Whole-package CO₂ reduction (kg/yr) vs the baseline re-score. The diff --git a/domain/property/property.py b/domain/property/property.py index 856eb3e3..825a79a7 100644 --- a/domain/property/property.py +++ b/domain/property/property.py @@ -37,6 +37,9 @@ class Property: identity: PropertyIdentity epc: Optional[EpcPropertyData] = None site_notes: Optional[SiteNotes] = None + # The current open-market value (a Property Valuation) — externally sourced + # and mostly absent; feeds the Plan's Valuation Uplift £ forms (ADR-0018). + current_market_value: Optional[float] = None @property def source_path(self) -> SourcePath: diff --git a/harness/console.py b/harness/console.py index 26498591..648ff739 100644 --- a/harness/console.py +++ b/harness/console.py @@ -84,12 +84,15 @@ def run_one( *, goal_band: str = "C", catalogue_path: Path = DEFAULT_CATALOGUE, + current_market_value: Optional[float] = None, print_table: bool = True, ) -> Plan: """Run ``epc`` through the full First Run pipeline with no database and return its Plan for the default Increasing-EPC Scenario targeting ``goal_band``. Prints the sense-check table unless ``print_table`` is False. + Pass ``current_market_value`` (a Property Valuation) to value the Plan's + Valuation Uplift in £ — otherwise the uplift is percentage-only (ADR-0018). ``epc`` must carry lodged recorded-performance + the RHI block (a real lodged EPC does) so the Baseline stage can run.""" epc_repo = FakeEpcRepo() @@ -102,7 +105,8 @@ def run_one( postcode="A0 0AA", address="1 Some Street", uprn=12345, - ) + ), + current_market_value=current_market_value, ) }, epc_repo=epc_repo, diff --git a/harness/plan_table.py b/harness/plan_table.py index b654a7ee..7c6e96f1 100644 --- a/harness/plan_table.py +++ b/harness/plan_table.py @@ -46,6 +46,13 @@ def format_plan_table(plan: Plan) -> str: f" cost £{plan.cost_of_works:,.0f} (+£{plan.contingency_cost:,.0f} cont.)" f" bill saved {_money(plan.energy_bill_savings)}/yr" ) + valuation = plan.valuation + valuation_line = f" valuation uplift {valuation.average_pct:+.1%}" + if valuation.average_value is not None and valuation.post_retrofit_value is not None: + valuation_line += ( + f" ({_money(valuation.average_value)}" + f" -> {_money(valuation.post_retrofit_value)})" + ) columns = ( f" {'measure':<30}{'SAP':>7}{'cost':>10}" f"{'kWh/yr':>10}{'£/yr':>9}" @@ -58,4 +65,4 @@ def format_plan_table(plan: Plan) -> str: f"{_signed_gbp(measure.energy_cost_savings):>9}" for measure in plan.measures ] - return "\n".join([header, columns, *rows]) + return "\n".join([header, valuation_line, columns, *rows]) diff --git a/infrastructure/postgres/modelling/plan_table.py b/infrastructure/postgres/modelling/plan_table.py index 75485c9d..e1281f49 100644 --- a/infrastructure/postgres/modelling/plan_table.py +++ b/infrastructure/postgres/modelling/plan_table.py @@ -103,4 +103,11 @@ class PlanModel(SQLModel, table=True): energy_bill_savings=plan.energy_bill_savings, post_energy_consumption=plan.post_energy_consumption, energy_consumption_savings=plan.energy_consumption_savings, + # Valuation Uplift £ forms (NULL when no Property Valuation is known; + # the percentage is not persisted on the live plan columns — ADR-0018). + valuation_increase_lower_bound=plan.valuation.lower_value, + valuation_increase_upper_bound=plan.valuation.upper_value, + valuation_increase_average=plan.valuation.average_value, + valuation_post_retrofit=plan.valuation.post_retrofit_value, + valuation_increase=plan.valuation.average_value, ) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 1a6ee852..e7a336e1 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -97,7 +97,12 @@ class ModellingOrchestrator: effective_epc: EpcPropertyData = prop.effective_epc for scenario in scenarios: plan = self._plan_for( - scorer, bill_derivation, effective_epc, uow.product, scenario + scorer, + bill_derivation, + effective_epc, + uow.product, + scenario, + current_market_value=prop.current_market_value, ) uow.plan.save( plan, @@ -115,6 +120,8 @@ class ModellingOrchestrator: effective_epc: EpcPropertyData, products: ProductRepository, scenario: Scenario, + *, + current_market_value: Optional[float], ) -> Plan: """Generate → score → optimise → re-score/repair → attribute → bill → assemble the Plan for one Property + Scenario.""" @@ -165,6 +172,7 @@ class ModellingOrchestrator: post_retrofit=package.score, baseline_bill=bills[0], post_bill=bills[-1], + current_market_value=current_market_value, ) diff --git a/tests/domain/modelling/test_plan_valuation.py b/tests/domain/modelling/test_plan_valuation.py new file mode 100644 index 00000000..61d78a78 --- /dev/null +++ b/tests/domain/modelling/test_plan_valuation.py @@ -0,0 +1,74 @@ +"""A Plan derives its Valuation Uplift from its band jump (ADR-0018). + +The uplift is plan-conditional — it needs the Plan's baseline -> post band jump +and its cost — so the Plan derives it, given one external input: the Property's +current market value (mostly absent, so the £ forms are usually None).""" + +from __future__ import annotations + +from domain.modelling.plan import Plan +from domain.modelling.scoring.package_scorer import Score +from infrastructure.postgres.modelling import PlanModel + + +def _plan(*, current_market_value: float | None) -> Plan: + # Baseline SAP 57.4 rounds to band D; post 70.0 rounds to band C. + baseline = Score( + sap_continuous=57.4, co2_kg_per_yr=3000.0, primary_energy_kwh_per_yr=300.0 + ) + post = Score( + sap_continuous=70.0, co2_kg_per_yr=2100.0, primary_energy_kwh_per_yr=240.0 + ) + return Plan( + measures=(), + baseline=baseline, + post_retrofit=post, + current_market_value=current_market_value, + ) + + +def test_plan_derives_pound_uplift_from_a_current_market_value() -> None: + # Arrange — a £200k property modelled D -> C. + plan: Plan = _plan(current_market_value=200_000.0) + + # Act + uplift = plan.valuation + + # Assert — D->C average 2.5% of £200k = £5,000 uplift, £205,000 post-retrofit. + assert uplift.average_value is not None + assert abs(uplift.average_value - 5_000.0) <= 1e-6 + assert uplift.post_retrofit_value is not None + assert abs(uplift.post_retrofit_value - 205_000.0) <= 1e-6 + + +def test_plan_model_persists_the_valuation_pound_forms() -> None: + # Arrange + plan: Plan = _plan(current_market_value=200_000.0) + + # Act + model: PlanModel = PlanModel.from_domain( + plan, property_id=1, scenario_id=7, portfolio_id=1, is_default=True + ) + + # Assert + assert model.valuation_increase_lower_bound is not None + assert abs(model.valuation_increase_lower_bound - 4_000.0) <= 1e-6 + assert model.valuation_increase_average is not None + assert abs(model.valuation_increase_average - 5_000.0) <= 1e-6 + assert model.valuation_post_retrofit is not None + assert abs(model.valuation_post_retrofit - 205_000.0) <= 1e-6 + + +def test_plan_model_leaves_valuation_null_without_a_market_value() -> None: + # Arrange — no current market value (the common case at scale). + plan: Plan = _plan(current_market_value=None) + + # Act + model: PlanModel = PlanModel.from_domain( + plan, property_id=1, scenario_id=7, portfolio_id=1, is_default=True + ) + + # Assert — the percentage is still derivable, but the £ columns stay NULL. + assert model.valuation_increase_average is None + assert model.valuation_post_retrofit is None + assert abs(plan.valuation.average_pct - 0.025) <= 1e-9 diff --git a/tests/harness/test_console.py b/tests/harness/test_console.py index f5ddc5ed..d7b9675d 100644 --- a/tests/harness/test_console.py +++ b/tests/harness/test_console.py @@ -40,3 +40,18 @@ def test_run_one_returns_a_plan_and_prints_the_table( printed: str = capsys.readouterr().out assert "Plan SAP" in printed assert "cavity_wall_insulation" in printed + + +def test_run_one_threads_a_current_market_value_onto_the_plan() -> None: + # Arrange + epc: EpcPropertyData = _uninsulated_lodged_epc() + + # Act — supply a Property Valuation so the Plan can value the uplift. + plan = run_one( + epc, goal_band="C", current_market_value=250_000.0, print_table=False + ) + + # Assert — the value reached the Plan, which derives its Valuation Uplift + # from it (the £ amount is 0 here as 000490 stays within band D). + assert plan.current_market_value == 250_000.0 + assert plan.valuation.average_value is not None diff --git a/tests/harness/test_plan_table.py b/tests/harness/test_plan_table.py index 42dc1b1a..fa7ac6e6 100644 --- a/tests/harness/test_plan_table.py +++ b/tests/harness/test_plan_table.py @@ -45,6 +45,31 @@ def _plan() -> Plan: return Plan(measures=measures, baseline=baseline, post_retrofit=post) +def test_table_shows_valuation_uplift_with_pounds() -> None: + # Arrange — a £200k property modelled D (57.4) -> C (72.0). + baseline = Score( + sap_continuous=57.4, co2_kg_per_yr=3000.0, primary_energy_kwh_per_yr=300.0 + ) + post = Score( + sap_continuous=72.0, co2_kg_per_yr=2100.0, primary_energy_kwh_per_yr=240.0 + ) + plan = Plan( + measures=(), + baseline=baseline, + post_retrofit=post, + current_market_value=200_000.0, + ) + + # Act + table: str = format_plan_table(plan) + + # Assert — the valuation line shows the average % uplift and its £ forms. + assert "valuation uplift" in table + assert "+2.5%" in table + assert "£5,000" in table + assert "£205,000" in table + + def test_table_shows_package_transition_and_each_measure() -> None: # Arrange plan: Plan = _plan() diff --git a/tests/orchestration/fakes.py b/tests/orchestration/fakes.py index 06c22247..f8ec1734 100644 --- a/tests/orchestration/fakes.py +++ b/tests/orchestration/fakes.py @@ -48,6 +48,7 @@ class FakePropertyRepo(PropertyRepository): identity=prop.identity, epc=self._epc_repo.get_for_property(property_id), site_notes=prop.site_notes, + current_market_value=prop.current_market_value, ) def get(self, property_id: int) -> Property: