mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +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)
|
@dataclass(frozen=True)
|
||||||
class PlanMeasure:
|
class PlanMeasure:
|
||||||
"""One selected Measure Option as it lands in a Plan: the measure, its
|
"""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
|
measure_type: str
|
||||||
description: str
|
description: str
|
||||||
cost: Cost
|
cost: Cost
|
||||||
impact: MeasureImpact
|
impact: MeasureImpact
|
||||||
|
kwh_savings: Optional[float] = None
|
||||||
|
energy_cost_savings: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,8 @@ class RecommendationRow(SQLModel, table=True):
|
||||||
estimated_cost: Optional[float] = Field(default=None)
|
estimated_cost: Optional[float] = Field(default=None)
|
||||||
sap_points: Optional[float] = Field(default=None)
|
sap_points: Optional[float] = Field(default=None)
|
||||||
co2_equivalent_savings: Optional[float] = Field(default=None) # tonnes/yr
|
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
|
default: bool = True
|
||||||
already_installed: bool = False
|
already_installed: bool = False
|
||||||
|
|
||||||
|
|
@ -121,6 +123,8 @@ class RecommendationRow(SQLModel, table=True):
|
||||||
co2_equivalent_savings=(
|
co2_equivalent_savings=(
|
||||||
measure.impact.co2_savings_kg_per_yr / _KG_PER_TONNE
|
measure.impact.co2_savings_kg_per_yr / _KG_PER_TONNE
|
||||||
),
|
),
|
||||||
|
kwh_savings=measure.kwh_savings,
|
||||||
|
energy_cost_savings=measure.energy_cost_savings,
|
||||||
default=True,
|
default=True,
|
||||||
already_installed=False,
|
already_installed=False,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ def _plan() -> Plan:
|
||||||
co2_savings_kg_per_yr=500.0,
|
co2_savings_kg_per_yr=500.0,
|
||||||
energy_savings_kwh_per_yr=2000.0,
|
energy_savings_kwh_per_yr=2000.0,
|
||||||
),
|
),
|
||||||
|
kwh_savings=1500.0,
|
||||||
|
energy_cost_savings=300.0,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return Plan(
|
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.estimated_cost - 1000.0) <= 1e-9
|
||||||
assert abs(rec.sap_points - 8.0) <= 1e-9
|
assert abs(rec.sap_points - 8.0) <= 1e-9
|
||||||
assert abs(rec.co2_equivalent_savings - 0.5) <= 1e-9 # tonnes
|
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.default is True
|
||||||
assert rec.already_installed is False
|
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(
|
def test_save_is_idempotent_on_rerun_for_the_same_property_and_scenario(
|
||||||
db_engine: Engine,
|
db_engine: Engine,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue