mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +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.scoring.package_scorer import Score
|
||||||
from domain.modelling.recommendation import Cost
|
from domain.modelling.recommendation import Cost
|
||||||
from domain.modelling.scoring.scoring import MeasureImpact
|
from domain.modelling.scoring.scoring import MeasureImpact
|
||||||
|
from domain.modelling.valuation import ValuationUplift, estimate_valuation_uplift
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -60,6 +61,10 @@ class Plan:
|
||||||
post_retrofit: Score
|
post_retrofit: Score
|
||||||
baseline_bill: Optional[Bill] = None
|
baseline_bill: Optional[Bill] = None
|
||||||
post_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
|
@property
|
||||||
def cost_of_works(self) -> float:
|
def cost_of_works(self) -> float:
|
||||||
|
|
@ -88,6 +93,24 @@ class Plan:
|
||||||
"""The post-retrofit EPC band, from the rounded SAP rating."""
|
"""The post-retrofit EPC band, from the rounded SAP rating."""
|
||||||
return Epc.from_sap_score(round(self.post_retrofit.sap_continuous))
|
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
|
@property
|
||||||
def co2_savings_kg_per_yr(self) -> float:
|
def co2_savings_kg_per_yr(self) -> float:
|
||||||
"""Whole-package CO₂ reduction (kg/yr) vs the baseline re-score. The
|
"""Whole-package CO₂ reduction (kg/yr) vs the baseline re-score. The
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ class Property:
|
||||||
identity: PropertyIdentity
|
identity: PropertyIdentity
|
||||||
epc: Optional[EpcPropertyData] = None
|
epc: Optional[EpcPropertyData] = None
|
||||||
site_notes: Optional[SiteNotes] = 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
|
@property
|
||||||
def source_path(self) -> SourcePath:
|
def source_path(self) -> SourcePath:
|
||||||
|
|
|
||||||
|
|
@ -84,12 +84,15 @@ def run_one(
|
||||||
*,
|
*,
|
||||||
goal_band: str = "C",
|
goal_band: str = "C",
|
||||||
catalogue_path: Path = DEFAULT_CATALOGUE,
|
catalogue_path: Path = DEFAULT_CATALOGUE,
|
||||||
|
current_market_value: Optional[float] = None,
|
||||||
print_table: bool = True,
|
print_table: bool = True,
|
||||||
) -> Plan:
|
) -> Plan:
|
||||||
"""Run ``epc`` through the full First Run pipeline with no database and
|
"""Run ``epc`` through the full First Run pipeline with no database and
|
||||||
return its Plan for the default Increasing-EPC Scenario targeting
|
return its Plan for the default Increasing-EPC Scenario targeting
|
||||||
``goal_band``. Prints the sense-check table unless ``print_table`` is False.
|
``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`` must carry lodged recorded-performance + the RHI block (a real lodged
|
||||||
EPC does) so the Baseline stage can run."""
|
EPC does) so the Baseline stage can run."""
|
||||||
epc_repo = FakeEpcRepo()
|
epc_repo = FakeEpcRepo()
|
||||||
|
|
@ -102,7 +105,8 @@ def run_one(
|
||||||
postcode="A0 0AA",
|
postcode="A0 0AA",
|
||||||
address="1 Some Street",
|
address="1 Some Street",
|
||||||
uprn=12345,
|
uprn=12345,
|
||||||
)
|
),
|
||||||
|
current_market_value=current_market_value,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
epc_repo=epc_repo,
|
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" cost £{plan.cost_of_works:,.0f} (+£{plan.contingency_cost:,.0f} cont.)"
|
||||||
f" bill saved {_money(plan.energy_bill_savings)}/yr"
|
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 = (
|
columns = (
|
||||||
f" {'measure':<30}{'SAP':>7}{'cost':>10}"
|
f" {'measure':<30}{'SAP':>7}{'cost':>10}"
|
||||||
f"{'kWh/yr':>10}{'£/yr':>9}"
|
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}"
|
f"{_signed_gbp(measure.energy_cost_savings):>9}"
|
||||||
for measure in plan.measures
|
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,
|
energy_bill_savings=plan.energy_bill_savings,
|
||||||
post_energy_consumption=plan.post_energy_consumption,
|
post_energy_consumption=plan.post_energy_consumption,
|
||||||
energy_consumption_savings=plan.energy_consumption_savings,
|
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
|
effective_epc: EpcPropertyData = prop.effective_epc
|
||||||
for scenario in scenarios:
|
for scenario in scenarios:
|
||||||
plan = self._plan_for(
|
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(
|
uow.plan.save(
|
||||||
plan,
|
plan,
|
||||||
|
|
@ -115,6 +120,8 @@ class ModellingOrchestrator:
|
||||||
effective_epc: EpcPropertyData,
|
effective_epc: EpcPropertyData,
|
||||||
products: ProductRepository,
|
products: ProductRepository,
|
||||||
scenario: Scenario,
|
scenario: Scenario,
|
||||||
|
*,
|
||||||
|
current_market_value: Optional[float],
|
||||||
) -> Plan:
|
) -> Plan:
|
||||||
"""Generate → score → optimise → re-score/repair → attribute → bill →
|
"""Generate → score → optimise → re-score/repair → attribute → bill →
|
||||||
assemble the Plan for one Property + Scenario."""
|
assemble the Plan for one Property + Scenario."""
|
||||||
|
|
@ -165,6 +172,7 @@ class ModellingOrchestrator:
|
||||||
post_retrofit=package.score,
|
post_retrofit=package.score,
|
||||||
baseline_bill=bills[0],
|
baseline_bill=bills[0],
|
||||||
post_bill=bills[-1],
|
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
|
printed: str = capsys.readouterr().out
|
||||||
assert "Plan SAP" in printed
|
assert "Plan SAP" in printed
|
||||||
assert "cavity_wall_insulation" 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)
|
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:
|
def test_table_shows_package_transition_and_each_measure() -> None:
|
||||||
# Arrange
|
# Arrange
|
||||||
plan: Plan = _plan()
|
plan: Plan = _plan()
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ class FakePropertyRepo(PropertyRepository):
|
||||||
identity=prop.identity,
|
identity=prop.identity,
|
||||||
epc=self._epc_repo.get_for_property(property_id),
|
epc=self._epc_repo.get_for_property(property_id),
|
||||||
site_notes=prop.site_notes,
|
site_notes=prop.site_notes,
|
||||||
|
current_market_value=prop.current_market_value,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get(self, property_id: int) -> Property:
|
def get(self, property_id: int) -> Property:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue