Model/tests/domain/modelling/test_plan.py
Khalim Conn-Kowlessar 26de28aae8 feat(modelling): Plan carries baseline/post Bills and derives the energy figures
Plan gains optional baseline_bill / post_bill (the Bills derived for the
unmodified and post-package end-states at one Fuel Rates snapshot) and derives
the four plan-level columns: post_energy_bill (post total), energy_bill_savings
(baseline - post), post_energy_consumption (Σ post section kWh), and
energy_consumption_savings (baseline - post delivered kWh). All return None until
billing runs (persisted as NULL), so existing Plan construction and the
not-yet-wired orchestrator stay green. Plan-level only; per-measure savings are a
later slice (ADR-0014 amendment).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:23:20 +00:00

96 lines
3.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Behaviour of the Plan / PlanMeasure domain types: the per-Property output
of one Scenario's modelling run. A Plan carries its selected Plan Measures
(the Optimised Package) plus the baseline/post-retrofit Scores, and derives
the persisted headline figures (cost aggregates, CO₂ saving, post-retrofit
band). Single-phase, flat post-retrofit figures (ADR-0005 / ADR-0017).
"""
from __future__ import annotations
from datatypes.epc.domain.epc import Epc
from domain.billing.bill import Bill, BillSection, BillSectionCost
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
def _bill(*, heating_kwh: float, total_gbp: float) -> Bill:
return Bill(
sections={
BillSection.HEATING: BillSectionCost(kwh=heating_kwh, cost_gbp=total_gbp)
},
standing_charges_gbp=0.0,
seg_credit_gbp=0.0,
total_gbp=total_gbp,
)
def _measure(measure_type: str, total: float, rate: float) -> PlanMeasure:
return PlanMeasure(
measure_type=measure_type,
description=measure_type.replace("_", " "),
cost=Cost(total=total, contingency_rate=rate),
impact=MeasureImpact(
sap_points=1.0, co2_savings_kg_per_yr=1.0, energy_savings_kwh_per_yr=1.0
),
)
def test_plan_aggregates_cost_and_savings_and_bands_the_post_sap() -> None:
# Arrange
measures: tuple[PlanMeasure, ...] = (
_measure("cavity_wall_insulation", total=1000.0, rate=0.10),
_measure("loft_insulation", total=500.0, rate=0.20),
)
baseline = Score(
sap_continuous=40.0, co2_kg_per_yr=4000.0, primary_energy_kwh_per_yr=20000.0
)
post = Score(
sap_continuous=70.4, co2_kg_per_yr=3600.0, primary_energy_kwh_per_yr=18000.0
)
plan = Plan(measures=measures, baseline=baseline, post_retrofit=post)
# Act / Assert
assert abs(plan.cost_of_works - 1500.0) <= 1e-9
assert abs(plan.contingency_cost - 200.0) <= 1e-9 # 1000*0.10 + 500*0.20
assert abs(plan.co2_savings_kg_per_yr - 400.0) <= 1e-9 # baseline - post
assert abs(plan.post_sap_continuous - 70.4) <= 1e-9
assert plan.post_epc_rating is Epc.C # round(70.4) = 70 → band C (6980)
def test_plan_derives_post_bill_and_savings_from_the_baseline_and_post_bills() -> None:
# Arrange — a Plan whose baseline and post-package Bills have been derived.
baseline = Score(
sap_continuous=40.0, co2_kg_per_yr=4000.0, primary_energy_kwh_per_yr=20000.0
)
post = Score(
sap_continuous=70.0, co2_kg_per_yr=3600.0, primary_energy_kwh_per_yr=18000.0
)
plan = Plan(
measures=(),
baseline=baseline,
post_retrofit=post,
baseline_bill=_bill(heating_kwh=10000.0, total_gbp=2000.0),
post_bill=_bill(heating_kwh=6000.0, total_gbp=1400.0),
)
# Act / Assert — plan-level energy/bill figures (ADR-0014 amendment).
assert plan.post_energy_bill == 1400.0
assert plan.energy_bill_savings == 600.0 # 2000 1400
assert plan.post_energy_consumption == 6000.0 # Σ post section kWh
assert plan.energy_consumption_savings == 4000.0 # 10000 6000
def test_plan_energy_figures_are_none_without_bills() -> None:
# Arrange — a Plan with no bills derived (the figures persist as NULL).
score = Score(
sap_continuous=55.0, co2_kg_per_yr=3000.0, primary_energy_kwh_per_yr=15000.0
)
plan = Plan(measures=(), baseline=score, post_retrofit=score)
# Act / Assert
assert plan.post_energy_bill is None
assert plan.energy_bill_savings is None
assert plan.post_energy_consumption is None
assert plan.energy_consumption_savings is None