From d23b84209d26d4a4577cc7c811228bf53fd9a7be Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 6 Jun 2026 20:32:54 +0000 Subject: [PATCH] feat(modelling): Products.ashp_bundle_cost composite ASHP cost (tracer) First slice of the per-dwelling ASHP bundle costing (ADR-0025). Products is the rich catalogue collection over Product, owning the catalogue math: given a typed AshpCostInputs it sums the applicable Southern Housing rate lines (decommission + heat-pump band + fixed cylinder + full wet distribution) into a Cost with the separate 25% ASHP contingency. Pure -- no EpcPropertyData or calculator. Pinned exact (1e-9) against the real rate sheet. Reuse branch, decommission variants, fallbacks, band edges and radiator clamp follow. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/products.py | 123 ++++++++++++++++++++++++ tests/domain/modelling/test_products.py | 39 ++++++++ 2 files changed, 162 insertions(+) create mode 100644 domain/modelling/products.py create mode 100644 tests/domain/modelling/test_products.py diff --git a/domain/modelling/products.py b/domain/modelling/products.py new file mode 100644 index 00000000..99d10b0b --- /dev/null +++ b/domain/modelling/products.py @@ -0,0 +1,123 @@ +"""Products — the rich catalogue collection over `Product` (ADR-0025). + +`ProductRepository` is the IO port that fetches catalogue rows; `Products` is +the in-memory domain collection carrying the cost-composition behaviour a single +`Product` row cannot. A simple measure prices as one row (unit cost x area); a +composite measure — the ASHP bundle — prices by selecting and summing many +priced line items (the Southern Housing "HEAT PUMPS" rate sheet, ECOHT01-68). + +This module owns the **catalogue math** only: given a typed `AshpCostInputs` it +filters the relevant rate lines and sums them into a `Cost`. It is deliberately +free of `EpcPropertyData` and the `Sap10Calculator` — the dwelling +interpretation that produces the inputs (sizing, proxies, reuse detection) +lives in the modelling layer (ADR-0025). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from domain.modelling.contingencies import contingency_rate +from domain.modelling.recommendation import Cost + +_ASHP_MEASURE_TYPE = "air_source_heat_pump" + +# --- Southern Housing Group ASHP rates (committed constants; moved to the +# costs file in a later slice). Each is a fully-loaded supply+install rate. --- + +# Decommission an existing electric-storage system, by property size band. +_DECOMMISSION_ELECTRIC_STORAGE_SMALL = 570.0 +_DECOMMISSION_ELECTRIC_STORAGE_LARGE = 840.0 + +# Heat-pump install (MONOBLOC, brand-neutral), by kW size band — design heat +# loss is rounded up to the next band (ECOHT09-13). +_PUMP_BANDS: tuple[tuple[float, float], ...] = ( + (5.0, 9720.0), + (8.0, 9840.0), + (11.0, 10200.0), + (15.0, 10680.0), +) +_PUMP_TOP_PRICE = 11400.0 + +# Fixed unvented hot-water cylinder (200 L) — one per install; the cylinder-size +# spread on the sheet is £188, treated as noise (ADR-0025). +_CYLINDER = 2382.60 + +# Full new wet central-heating distribution, by radiator count (ECOHT40-48). +_DISTRIBUTION_BY_RADIATORS: dict[int, float] = { + 4: 2220.0, + 5: 2550.0, + 6: 3084.0, + 7: 3618.0, + 8: 4152.0, + 9: 4680.0, + 10: 5220.0, + 11: 5754.0, + 12: 6288.0, +} +_MIN_RADIATORS = 4 +_MAX_RADIATORS = 12 + + +class AshpExistingSystem(Enum): + """The dwelling's pre-retrofit heating system, as it bears on decommission + cost and whether a wet distribution system can be reused (ADR-0025). The + modelling layer maps fuel / SAP code to one of these.""" + + ELECTRIC_STORAGE = "electric_storage" + GAS = "gas" + OIL = "oil" + LPG = "lpg" + ELECTRIC_OTHER = "electric_other" + NONE = "none" + OTHER = "other" + + +@dataclass(frozen=True) +class AshpCostInputs: + """The dwelling facts the ASHP catalogue math needs — produced by the + modelling layer's interpretation, never read off the EPC here (ADR-0025).""" + + existing_system: AshpExistingSystem + is_small_property: bool + design_heat_loss_kw: float + radiator_count: int + has_reusable_wet_system: bool + + +class Products: + """The catalogue collection. Owns cost composition for measures whose price + is not a single catalogue scalar (the ASHP bundle — ADR-0025).""" + + def ashp_bundle_cost(self, inputs: AshpCostInputs) -> Cost: + """Compose the fully-loaded ASHP bundle total for a dwelling and pair it + with the separate ASHP contingency rate.""" + total: float = ( + self._decommission(inputs) + + self._heat_pump(inputs.design_heat_loss_kw) + + _CYLINDER + + self._distribution(inputs) + ) + return Cost( + total=total, contingency_rate=contingency_rate(_ASHP_MEASURE_TYPE) + ) + + def _heat_pump(self, design_heat_loss_kw: float) -> float: + """Price the install at the smallest band that covers the design heat + loss (round up); above the largest band, the top rate applies.""" + for max_kw, price in _PUMP_BANDS: + if design_heat_loss_kw <= max_kw: + return price + return _PUMP_TOP_PRICE + + def _decommission(self, inputs: AshpCostInputs) -> float: + return ( + _DECOMMISSION_ELECTRIC_STORAGE_SMALL + if inputs.is_small_property + else _DECOMMISSION_ELECTRIC_STORAGE_LARGE + ) + + def _distribution(self, inputs: AshpCostInputs) -> float: + radiators: int = max(_MIN_RADIATORS, min(_MAX_RADIATORS, inputs.radiator_count)) + return _DISTRIBUTION_BY_RADIATORS[radiators] diff --git a/tests/domain/modelling/test_products.py b/tests/domain/modelling/test_products.py new file mode 100644 index 00000000..799d8a46 --- /dev/null +++ b/tests/domain/modelling/test_products.py @@ -0,0 +1,39 @@ +"""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 domain.modelling.products import ( + AshpCostInputs, + AshpExistingSystem, + 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