feat(modelling): wire Valuation Uplift onto the Plan

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 08:59:04 +00:00
parent e6f54df92b
commit b3f4609c2d
10 changed files with 170 additions and 3 deletions

View file

@ -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

View file

@ -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:

View file

@ -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,

View file

@ -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])

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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: