mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Slice 10 of ADR-0025 costing. The Southern Housing rate table moves from code constants into ashp_rates.json (structured rows the flat scalar catalogue can't hold), loaded via AshpRates.from_json. Products takes an injected AshpRates (default: the committed sheet), so rates are now data -- tunable (e.g. reuse_distribution_fraction) without a code change, and ready for ETL/DB-supplied rates later. Behaviour-preserving: the 6 pinned cost tests still hold against the default, plus a new test proving injected rates drive the total. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
194 lines
8.2 KiB
Python
194 lines
8.2 KiB
Python
"""Behaviour of `Products.ashp_bundle_cost` — the composite, per-dwelling ASHP
|
|
bundle cost (ADR-0025). Pure catalogue math: given a typed `AshpCostInputs` it
|
|
selects and sums the applicable Southern Housing rate lines (decommission +
|
|
heat pump + cylinder + distribution) into a `Cost`, carrying the separate ASHP
|
|
contingency. No EpcPropertyData / calculator — the dwelling interpretation that
|
|
produces the inputs lives in the modelling layer.
|
|
|
|
Costs are pinned against the real Southern Housing Group rate sheet, so the
|
|
totals are exact (delta <= 1e-9), mirroring the cascade-pin philosophy.
|
|
"""
|
|
|
|
from dataclasses import replace
|
|
|
|
from domain.modelling.products import (
|
|
AshpCostInputs,
|
|
AshpExistingSystem,
|
|
AshpRates,
|
|
Products,
|
|
)
|
|
from domain.modelling.recommendation import Cost
|
|
|
|
|
|
def test_ashp_bundle_cost_composes_an_electric_storage_full_distribution_dwelling() -> None:
|
|
# Arrange — a small electric-storage dwelling: no reusable wet system, so a
|
|
# full new wet distribution is priced. 4 kW design heat loss (smallest pump
|
|
# band), 7 radiators.
|
|
products = Products()
|
|
inputs = AshpCostInputs(
|
|
existing_system=AshpExistingSystem.ELECTRIC_STORAGE,
|
|
is_small_property=True,
|
|
design_heat_loss_kw=4.0,
|
|
radiator_count=7,
|
|
has_reusable_wet_system=False,
|
|
)
|
|
|
|
# Act
|
|
cost: Cost = products.ashp_bundle_cost(inputs)
|
|
|
|
# Assert — decommission 570 + pump 9720 + cylinder 2382.60 + distribution
|
|
# (7 rads) 3618 = 16290.60, with the separate 25% ASHP contingency.
|
|
assert abs(cost.total - 16290.60) <= 1e-9
|
|
assert abs(cost.contingency_rate - 0.25) <= 1e-9
|
|
|
|
|
|
def test_ashp_bundle_cost_uses_injected_rates() -> None:
|
|
# Arrange — the rate table is data (ADR-0025): a Products built with a tweaked
|
|
# cylinder rate prices that cylinder, not the committed default.
|
|
rates: AshpRates = replace(AshpRates.default(), cylinder=1000.0)
|
|
products = Products(rates=rates)
|
|
inputs = AshpCostInputs(
|
|
existing_system=AshpExistingSystem.ELECTRIC_STORAGE,
|
|
is_small_property=True,
|
|
design_heat_loss_kw=4.0,
|
|
radiator_count=7,
|
|
has_reusable_wet_system=False,
|
|
)
|
|
|
|
# Act
|
|
cost: Cost = products.ashp_bundle_cost(inputs)
|
|
|
|
# Assert — decommission 570 + pump 9720 + injected cylinder 1000 +
|
|
# distribution 3618 = 14908.0.
|
|
assert abs(cost.total - 14908.0) <= 1e-9
|
|
|
|
|
|
def _large_no_reuse(system: AshpExistingSystem) -> AshpCostInputs:
|
|
"""A large dwelling, 8 kW band, 8 radiators, no reusable wet system — so the
|
|
only thing varying with ``system`` is the decommission line."""
|
|
return AshpCostInputs(
|
|
existing_system=system,
|
|
is_small_property=False,
|
|
design_heat_loss_kw=8.0,
|
|
radiator_count=8,
|
|
has_reusable_wet_system=False,
|
|
)
|
|
|
|
|
|
def test_decommission_cost_varies_by_existing_system() -> None:
|
|
# Arrange — common: pump (8 kW) 9840 + cylinder 2382.60 + distribution (8
|
|
# rads) 4152 = 16374.60; only decommission differs by system.
|
|
products = Products()
|
|
common = 16374.60
|
|
|
|
# Act / Assert — gas and oil are flat 720; LPG 960; electric-storage large
|
|
# 840 (small 570 is pinned by the tracer above).
|
|
assert abs(products.ashp_bundle_cost(_large_no_reuse(AshpExistingSystem.GAS)).total - (common + 720.0)) <= 1e-9
|
|
assert abs(products.ashp_bundle_cost(_large_no_reuse(AshpExistingSystem.OIL)).total - (common + 720.0)) <= 1e-9
|
|
assert abs(products.ashp_bundle_cost(_large_no_reuse(AshpExistingSystem.LPG)).total - (common + 960.0)) <= 1e-9
|
|
assert abs(products.ashp_bundle_cost(_large_no_reuse(AshpExistingSystem.ELECTRIC_STORAGE)).total - (common + 840.0)) <= 1e-9
|
|
|
|
|
|
def test_reusable_wet_system_prices_a_flush_plus_half_the_distribution() -> None:
|
|
# Arrange — a gas dwelling whose wet system is reusable: instead of a full
|
|
# new distribution, the ASHP pays a power-flush plus half the radiator band
|
|
# (a documented estimate for partial radiator upsizing — ADR-0025).
|
|
products = Products()
|
|
inputs = AshpCostInputs(
|
|
existing_system=AshpExistingSystem.GAS,
|
|
is_small_property=False,
|
|
design_heat_loss_kw=8.0,
|
|
radiator_count=8,
|
|
has_reusable_wet_system=True,
|
|
)
|
|
|
|
# Act
|
|
cost: Cost = products.ashp_bundle_cost(inputs)
|
|
|
|
# Assert — decommission 720 + pump 9840 + cylinder 2382.60 + distribution
|
|
# (flush 168 + 0.5 x 4152 = 2244) = 15186.60.
|
|
assert abs(cost.total - 15186.60) <= 1e-9
|
|
|
|
|
|
def _small_no_reuse(system: AshpExistingSystem) -> AshpCostInputs:
|
|
"""A small dwelling, 4 kW band, 7 radiators, no reusable wet system — pump
|
|
9720 + cylinder 2382.60 + distribution (7) 3618 = 15720.60 common base."""
|
|
return AshpCostInputs(
|
|
existing_system=system,
|
|
is_small_property=True,
|
|
design_heat_loss_kw=4.0,
|
|
radiator_count=7,
|
|
has_reusable_wet_system=False,
|
|
)
|
|
|
|
|
|
def test_decommission_falls_back_for_systems_not_on_the_rate_sheet() -> None:
|
|
# Arrange — the rate sheet covers gas/oil/LPG/electric-storage, but ASHP is
|
|
# offered to any house regardless of fuel (ADR-0025): no system costs nothing
|
|
# to remove; electric room/panel heaters use the electric-storage line; any
|
|
# other system defaults to the gas line — never a raise (that would wrongly
|
|
# block ASHP eligibility).
|
|
products = Products()
|
|
base = 15720.60
|
|
|
|
# Act / Assert
|
|
assert abs(products.ashp_bundle_cost(_small_no_reuse(AshpExistingSystem.NONE)).total - (base + 0.0)) <= 1e-9
|
|
assert abs(products.ashp_bundle_cost(_small_no_reuse(AshpExistingSystem.ELECTRIC_OTHER)).total - (base + 570.0)) <= 1e-9
|
|
assert abs(products.ashp_bundle_cost(_small_no_reuse(AshpExistingSystem.OTHER)).total - (base + 720.0)) <= 1e-9
|
|
|
|
|
|
def _pump_price(products: Products, design_heat_loss_kw: float) -> float:
|
|
"""Isolate the heat-pump line: no-system (decommission 0) + cylinder
|
|
2382.60 + distribution (7 rads) 3618 = 6000.60 base, so total - base is the
|
|
pump band price for ``design_heat_loss_kw``."""
|
|
inputs = AshpCostInputs(
|
|
existing_system=AshpExistingSystem.NONE,
|
|
is_small_property=True,
|
|
design_heat_loss_kw=design_heat_loss_kw,
|
|
radiator_count=7,
|
|
has_reusable_wet_system=False,
|
|
)
|
|
return products.ashp_bundle_cost(inputs).total - 6000.60
|
|
|
|
|
|
def test_heat_pump_rounds_design_heat_loss_up_to_the_next_band() -> None:
|
|
# Arrange
|
|
products = Products()
|
|
|
|
# Act / Assert — bands {5,8,11,15,16+} kW -> {9720,9840,10200,10680,11400};
|
|
# a load is rounded UP to the smallest band that covers it.
|
|
assert abs(_pump_price(products, 5.0) - 9720.0) <= 1e-9 # at the 5 kW edge
|
|
assert abs(_pump_price(products, 5.01) - 9840.0) <= 1e-9 # just over -> 8 kW
|
|
assert abs(_pump_price(products, 8.0) - 9840.0) <= 1e-9
|
|
assert abs(_pump_price(products, 8.01) - 10200.0) <= 1e-9
|
|
assert abs(_pump_price(products, 11.0) - 10200.0) <= 1e-9
|
|
assert abs(_pump_price(products, 15.0) - 10680.0) <= 1e-9
|
|
assert abs(_pump_price(products, 15.01) - 11400.0) <= 1e-9 # above largest
|
|
assert abs(_pump_price(products, 25.0) - 11400.0) <= 1e-9
|
|
|
|
|
|
def _full_distribution(products: Products, radiator_count: int) -> float:
|
|
"""Isolate the full distribution line: no-system (decommission 0) + pump
|
|
(4 kW) 9720 + cylinder 2382.60 = 12102.60 base."""
|
|
inputs = AshpCostInputs(
|
|
existing_system=AshpExistingSystem.NONE,
|
|
is_small_property=True,
|
|
design_heat_loss_kw=4.0,
|
|
radiator_count=radiator_count,
|
|
has_reusable_wet_system=False,
|
|
)
|
|
return products.ashp_bundle_cost(inputs).total - 12102.60
|
|
|
|
|
|
def test_radiator_count_is_clamped_to_the_distribution_table_bounds() -> None:
|
|
# Arrange — the distribution table only spans 4-12 radiators, so a proxy
|
|
# count outside that range is clamped to the nearest band (ADR-0025).
|
|
products = Products()
|
|
|
|
# Act / Assert — below 4 prices as 4 (2220); above 12 prices as 12 (6288);
|
|
# in-range is exact.
|
|
assert abs(_full_distribution(products, 2) - 2220.0) <= 1e-9
|
|
assert abs(_full_distribution(products, 4) - 2220.0) <= 1e-9
|
|
assert abs(_full_distribution(products, 9) - 4680.0) <= 1e-9
|
|
assert abs(_full_distribution(products, 12) - 6288.0) <= 1e-9
|
|
assert abs(_full_distribution(products, 15) - 6288.0) <= 1e-9
|