mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
feat(modelling): Plan Measure carries per-measure kwh/cost savings
`PlanMeasure` grows optional `kwh_savings` (delivered energy) and `energy_cost_savings` (£) — its slice of the telescoping bill cascade, signed so positive is a saving and `None` until billing runs. `RecommendationRow` declares the matching live `recommendation.kwh_savings` / `energy_cost_savings` columns and maps them in `from_domain` (None → NULL). The vestigial `recommendation.energy_savings` stays undeclared (legacy = 0). No FE migration — the columns already exist on the live table (ADR-0014 / 0017). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e79ffabfc5
commit
7e79c30af1
3 changed files with 59 additions and 1 deletions
|
|
@ -29,12 +29,21 @@ def _total_consumption_kwh(bill: Bill) -> float:
|
|||
@dataclass(frozen=True)
|
||||
class PlanMeasure:
|
||||
"""One selected Measure Option as it lands in a Plan: the measure, its
|
||||
installed Cost, and its role-3 (final-package cascade) attributed impact."""
|
||||
installed Cost, and its role-3 (final-package cascade) attributed impact.
|
||||
|
||||
`kwh_savings` (delivered energy) and `energy_cost_savings` (£) are this
|
||||
measure's slice of the telescoping bill cascade — its marginal Bill delta
|
||||
over the running package state. They can be negative (e.g. ventilation
|
||||
increases energy) and telescope exactly to the Plan totals; `None` until
|
||||
billing has run (persisted as NULL — ADR-0014 amendment). They are distinct
|
||||
from `impact.energy_savings_kwh_per_yr`, which is *primary* energy."""
|
||||
|
||||
measure_type: str
|
||||
description: str
|
||||
cost: Cost
|
||||
impact: MeasureImpact
|
||||
kwh_savings: Optional[float] = None
|
||||
energy_cost_savings: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
|
|||
|
|
@ -103,6 +103,8 @@ class RecommendationRow(SQLModel, table=True):
|
|||
estimated_cost: Optional[float] = Field(default=None)
|
||||
sap_points: Optional[float] = Field(default=None)
|
||||
co2_equivalent_savings: Optional[float] = Field(default=None) # tonnes/yr
|
||||
kwh_savings: Optional[float] = Field(default=None) # delivered kWh/yr
|
||||
energy_cost_savings: Optional[float] = Field(default=None) # £/yr
|
||||
default: bool = True
|
||||
already_installed: bool = False
|
||||
|
||||
|
|
@ -121,6 +123,8 @@ class RecommendationRow(SQLModel, table=True):
|
|||
co2_equivalent_savings=(
|
||||
measure.impact.co2_savings_kg_per_yr / _KG_PER_TONNE
|
||||
),
|
||||
kwh_savings=measure.kwh_savings,
|
||||
energy_cost_savings=measure.energy_cost_savings,
|
||||
default=True,
|
||||
already_installed=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ def _plan() -> Plan:
|
|||
co2_savings_kg_per_yr=500.0,
|
||||
energy_savings_kwh_per_yr=2000.0,
|
||||
),
|
||||
kwh_savings=1500.0,
|
||||
energy_cost_savings=300.0,
|
||||
),
|
||||
)
|
||||
return Plan(
|
||||
|
|
@ -97,10 +99,53 @@ def test_save_persists_plan_and_its_measures_with_tonnes_and_band(
|
|||
assert abs(rec.estimated_cost - 1000.0) <= 1e-9
|
||||
assert abs(rec.sap_points - 8.0) <= 1e-9
|
||||
assert abs(rec.co2_equivalent_savings - 0.5) <= 1e-9 # tonnes
|
||||
assert rec.kwh_savings is not None
|
||||
assert rec.energy_cost_savings is not None
|
||||
assert abs(rec.kwh_savings - 1500.0) <= 1e-9 # delivered kWh saved/yr
|
||||
assert abs(rec.energy_cost_savings - 300.0) <= 1e-9 # £/yr saved
|
||||
assert rec.default is True
|
||||
assert rec.already_installed is False
|
||||
|
||||
|
||||
def test_save_persists_null_per_measure_savings_when_unbilled(
|
||||
db_engine: Engine,
|
||||
) -> None:
|
||||
# Arrange — a Plan Measure whose per-measure bills were never derived.
|
||||
measure = PlanMeasure(
|
||||
measure_type="loft_insulation",
|
||||
description="Loft insulation",
|
||||
cost=Cost(total=500.0, contingency_rate=0.20),
|
||||
impact=MeasureImpact(
|
||||
sap_points=3.0, co2_savings_kg_per_yr=200.0, energy_savings_kwh_per_yr=800.0
|
||||
),
|
||||
)
|
||||
plan = Plan(
|
||||
measures=(measure,),
|
||||
baseline=Score(
|
||||
sap_continuous=40.0, co2_kg_per_yr=4000.0, primary_energy_kwh_per_yr=20000.0
|
||||
),
|
||||
post_retrofit=Score(
|
||||
sap_continuous=45.0, co2_kg_per_yr=3800.0, primary_energy_kwh_per_yr=19000.0
|
||||
),
|
||||
)
|
||||
|
||||
# Act
|
||||
with Session(db_engine) as session:
|
||||
plan_id: int = PlanPostgresRepository(session).save(
|
||||
plan, property_id=11, scenario_id=7, portfolio_id=1, is_default=True
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# Assert — the savings columns persist as NULL (ADR-0014 amendment)
|
||||
with Session(db_engine) as session:
|
||||
rec_rows = session.exec(
|
||||
select(RecommendationRow).where(col(RecommendationRow.plan_id) == plan_id)
|
||||
).all()
|
||||
assert len(rec_rows) == 1
|
||||
assert rec_rows[0].kwh_savings is None
|
||||
assert rec_rows[0].energy_cost_savings is None
|
||||
|
||||
|
||||
def test_save_is_idempotent_on_rerun_for_the_same_property_and_scenario(
|
||||
db_engine: Engine,
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue