mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
e6f54df92b
commit
b3f4609c2d
10 changed files with 170 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
74
tests/domain/modelling/test_plan_valuation.py
Normal file
74
tests/domain/modelling/test_plan_valuation.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue