mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Tighten the recommendation/plan vocabulary off generic str: MeasureOption.measure_type and PlanMeasure.measure_type are now MeasureType (also _GlazingTarget.measure_type, MeasureDependency.triggers -> frozenset[MeasureType], and the optimiser's chosen/required-type locals). Because MeasureType is a StrEnum the change is transparent to persistence (the `recommendation` varchar column), the optimiser group-by key, and every `== "solar_pv"`-style comparison — so pyright now enforces the enum at every construction site with no runtime behaviour change. The catalogue boundary stays str: ProductRepository.get(measure_type: str) and Product.measure_type are unchanged (they map arbitrary DB/JSON rows), so the fake product repos in tests need no edit. Test construction helpers coerce their str arg via MeasureType(...); direct constructions use members. Suite green: tests/domain/modelling + orchestration + harness 253 pass + 3 xfail; pyright clean on production + tests (pre-existing moto + property- override-rowcount baselines untouched). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
177 lines
6.5 KiB
Python
177 lines
6.5 KiB
Python
"""Behaviour of the Postgres-backed PlanRepository: persisting a Plan and its
|
|
Plan Measures to the live ``plan`` / ``recommendation`` tables (ADR-0017).
|
|
|
|
The Plan is the parent; each selected Plan Measure is a ``recommendation`` row
|
|
linked by the new ``plan_id`` FK. A re-run replaces (delete the Plan for the
|
|
(property, scenario) → cascade its recommendations → insert fresh), so the
|
|
batch write is idempotent (ADR-0012). CO₂ is stored in tonnes (calculator kg
|
|
÷ 1000) to match the live column contract.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from sqlalchemy import Engine
|
|
from sqlmodel import Session, col, select
|
|
|
|
from datatypes.epc.domain.epc import Epc
|
|
from domain.modelling.measure_type import MeasureType
|
|
from domain.modelling.scoring.package_scorer import Score
|
|
from domain.modelling.plan import Plan, PlanMeasure
|
|
from domain.modelling.recommendation import Cost
|
|
from domain.modelling.scoring.scoring import MeasureImpact
|
|
from infrastructure.postgres.modelling import PlanModel, RecommendationModel
|
|
from repositories.plan.plan_postgres_repository import PlanPostgresRepository
|
|
|
|
|
|
def _plan() -> Plan:
|
|
measures: tuple[PlanMeasure, ...] = (
|
|
PlanMeasure(
|
|
measure_type=MeasureType.CAVITY_WALL_INSULATION,
|
|
description="Cavity wall insulation",
|
|
cost=Cost(total=1000.0, contingency_rate=0.10),
|
|
impact=MeasureImpact(
|
|
sap_points=8.0,
|
|
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(
|
|
measures=measures,
|
|
baseline=Score(
|
|
sap_continuous=40.0,
|
|
co2_kg_per_yr=4000.0,
|
|
primary_energy_kwh_per_yr=20000.0,
|
|
),
|
|
post_retrofit=Score(
|
|
sap_continuous=70.0,
|
|
co2_kg_per_yr=3500.0,
|
|
primary_energy_kwh_per_yr=18000.0,
|
|
),
|
|
)
|
|
|
|
|
|
def test_save_persists_plan_and_its_measures_with_tonnes_and_band(
|
|
db_engine: Engine,
|
|
) -> None:
|
|
# Act
|
|
with Session(db_engine) as session:
|
|
plan_id: int = PlanPostgresRepository(session).save(
|
|
_plan(), property_id=10, scenario_id=7, portfolio_id=1, is_default=True
|
|
)
|
|
session.commit()
|
|
|
|
# Assert
|
|
with Session(db_engine) as session:
|
|
plan_row = session.get(PlanModel, plan_id)
|
|
rec_rows = session.exec(
|
|
select(RecommendationModel).where(
|
|
col(RecommendationModel.plan_id) == plan_id
|
|
)
|
|
).all()
|
|
|
|
assert plan_row is not None
|
|
assert plan_row.property_id == 10
|
|
assert plan_row.scenario_id == 7
|
|
assert plan_row.portfolio_id == 1
|
|
assert plan_row.is_default is True
|
|
assert plan_row.post_sap_points is not None
|
|
assert plan_row.post_co2_emissions is not None
|
|
assert plan_row.co2_savings is not None
|
|
assert plan_row.cost_of_works is not None
|
|
assert plan_row.contingency_cost is not None
|
|
assert abs(plan_row.post_sap_points - 70.0) <= 1e-9
|
|
assert plan_row.post_epc_rating is Epc.C # SAP 70 → band C
|
|
assert abs(plan_row.post_co2_emissions - 3.5) <= 1e-9 # tonnes
|
|
assert abs(plan_row.co2_savings - 0.5) <= 1e-9 # (4000-3500)/1000
|
|
assert abs(plan_row.cost_of_works - 1000.0) <= 1e-9
|
|
assert abs(plan_row.contingency_cost - 100.0) <= 1e-9 # 1000 * 0.10
|
|
|
|
assert len(rec_rows) == 1
|
|
rec = rec_rows[0]
|
|
assert rec.estimated_cost is not None
|
|
assert rec.sap_points is not None
|
|
assert rec.co2_equivalent_savings is not None
|
|
assert rec.type == "cavity_wall_insulation"
|
|
assert rec.measure_type == "cavity_wall_insulation"
|
|
assert rec.description == "Cavity wall insulation"
|
|
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=MeasureType.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(RecommendationModel).where(col(RecommendationModel.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:
|
|
# Arrange — first run
|
|
with Session(db_engine) as session:
|
|
PlanPostgresRepository(session).save(
|
|
_plan(), property_id=10, scenario_id=7, portfolio_id=1, is_default=True
|
|
)
|
|
session.commit()
|
|
|
|
# Act — re-run the same (property, scenario)
|
|
with Session(db_engine) as session:
|
|
PlanPostgresRepository(session).save(
|
|
_plan(), property_id=10, scenario_id=7, portfolio_id=1, is_default=True
|
|
)
|
|
session.commit()
|
|
|
|
# Assert — replaced, not duplicated (cascade removed the old measures)
|
|
with Session(db_engine) as session:
|
|
plan_rows = session.exec(
|
|
select(PlanModel).where(col(PlanModel.property_id) == 10)
|
|
).all()
|
|
rec_rows = session.exec(
|
|
select(RecommendationModel).where(col(RecommendationModel.property_id) == 10)
|
|
).all()
|
|
|
|
assert len(plan_rows) == 1
|
|
assert len(rec_rows) == 1
|