Model/tests/domain/modelling/test_products.py
Khalim Conn-Kowlessar ae7e6a0c42 feat(modelling): composite per-dwelling boiler + tune-up costing (ADR-0027)
Replace the flat placeholder scalars (boiler £3000; tune-up £500/£900) with a
per-dwelling composite cost, mirroring the ASHP architecture (ADR-0025): a
`HeatingRates` table (data, `heating_rates.json`), typed `BoilerCostInputs` /
`TuneUpCostInputs`, pure `Products.boiler_bundle_cost` / `tune_up_cost`, and
modelling-layer interpreters that read the dwelling into those inputs.

The cost mirrors the Simulation Overlay component-for-component, sharing the
controls + cylinder pricing across both options:

- tune-up (standard) = standard controls + cylinder fixes
- tune-up (zone)     = zone controls + cylinder fixes
- boiler upgrade     = £3200 all-in + standard controls (only when the upgrade
  fired a controls change) + cylinder fixes

Standard controls are priced INCREMENTALLY — only the parts missing to reach
SAP 2106 (programmer £120 / room thermostat £150 / TRV £35×radiators), read
from a Table 4e Group-1 feature map so a dwelling that already has a room
thermostat + TRVs is only charged the programmer. Zone controls are a full
smart kit (hub £205 + smart TRV £50×radiators) — the smart TRV is itself the
room sensor, so there is no separate per-room sensor line. Cylinder fixes:
jacket £50 (when under-insulated) + thermostat £150 (when absent). The boiler
is a like-for-like wet swap (no radiators/flue/pipework — eligibility already
requires an existing wet boiler), so those dead-code extras are not modelled.

Figures are research-validated 2025/26 UK installed costs (legacy Costs.py
lineage); fully-loaded totals with one contingency on top (Model B, not the
legacy VAT/preliminaries engine). Contingency: boiler 0.26; tune-ups 0.10
(was a 0.15 placeholder). ADR-0027 records the design; CONTEXT.md's Heating
Eligibility entry updated to cover the partial boiler/tune-up family + composed
cost. Products cost pins (delta<=1e-9) + interpreter tests + generator
composite-cost assertions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 19:41:06 +00:00

313 lines
12 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,
BoilerCostInputs,
Products,
TuneUpCostInputs,
)
from domain.modelling.recommendation import Cost
_PIN: float = 1e-9
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
# --- Boiler / tune-up composite costs (ADR-0027) --------------------------
def test_tune_up_standard_from_no_controls_with_cylinder_fixes() -> None:
# Arrange — a 7-radiator dwelling with no existing controls, an uninsulated
# un-thermostatted cylinder: the standard tune-up fits the full control set
# plus both cylinder fixes.
products = Products()
inputs = TuneUpCostInputs(
is_zoned=False,
radiator_count=7,
has_programmer=False,
has_room_thermostat=False,
has_trvs=False,
needs_cylinder_jacket=True,
needs_cylinder_thermostat=True,
)
# Act
cost: Cost = products.tune_up_cost(inputs)
# Assert — programmer 120 + room stat 150 + TRVs 7x35=245 = 515 controls,
# + jacket 50 + cylinder stat 150 = 715, with the 0.10 tune-up contingency.
assert abs(cost.total - 715.0) <= _PIN
assert abs(cost.contingency_rate - 0.10) <= _PIN
def test_tune_up_standard_charges_only_the_missing_control_parts() -> None:
# Arrange — the dwelling already has a room thermostat + TRVs (only the
# programmer is missing), and the cylinder is already sorted.
products = Products()
inputs = TuneUpCostInputs(
is_zoned=False,
radiator_count=7,
has_programmer=False,
has_room_thermostat=True,
has_trvs=True,
needs_cylinder_jacket=False,
needs_cylinder_thermostat=False,
)
# Act
cost: Cost = products.tune_up_cost(inputs)
# Assert — only the programmer is charged (incremental, no double-charge).
assert abs(cost.total - 120.0) <= _PIN
def test_tune_up_zoned_prices_a_full_smart_kit_no_per_room_sensor() -> None:
# Arrange — a 7-radiator dwelling, zone tune-up, both cylinder fixes.
products = Products()
inputs = TuneUpCostInputs(
is_zoned=True,
radiator_count=7,
has_programmer=False,
has_room_thermostat=False,
has_trvs=False,
needs_cylinder_jacket=True,
needs_cylinder_thermostat=True,
)
# Act
cost: Cost = products.tune_up_cost(inputs)
# Assert — hub 205 + smart TRVs 7x50=350 = 555 (no separate sensor line),
# + cylinder 200 = 755, 0.10 contingency. Zone is a full kit regardless of
# the existing parts.
assert abs(cost.total - 755.0) <= _PIN
assert abs(cost.contingency_rate - 0.10) <= _PIN
def test_boiler_bundle_cost_controls_already_adequate() -> None:
# Arrange — a like-for-like gas boiler swap whose controls are already
# adequate (no controls upgrade), with both cylinder fixes.
products = Products()
inputs = BoilerCostInputs(
upgrades_controls=False,
radiator_count=7,
has_programmer=True,
has_room_thermostat=True,
has_trvs=True,
needs_cylinder_jacket=True,
needs_cylinder_thermostat=True,
)
# Act
cost: Cost = products.boiler_bundle_cost(inputs)
# Assert — boiler 3200 + cylinder 200 = 3400, with the 0.26 boiler
# contingency. No controls, no system-change extras.
assert abs(cost.total - 3400.0) <= _PIN
assert abs(cost.contingency_rate - 0.26) <= _PIN
def test_boiler_bundle_cost_adds_standard_controls_when_upgraded() -> None:
# Arrange — a gas boiler swap that also fixes inadequate controls (from
# nothing) on a 7-radiator dwelling, no cylinder.
products = Products()
inputs = BoilerCostInputs(
upgrades_controls=True,
radiator_count=7,
has_programmer=False,
has_room_thermostat=False,
has_trvs=False,
needs_cylinder_jacket=False,
needs_cylinder_thermostat=False,
)
# Act
cost: Cost = products.boiler_bundle_cost(inputs)
# Assert — boiler 3200 + standard controls 515 = 3715.
assert abs(cost.total - 3715.0) <= _PIN