mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
d9c7638b3c
commit
d23b84209d
2 changed files with 162 additions and 0 deletions
123
domain/modelling/products.py
Normal file
123
domain/modelling/products.py
Normal file
|
|
@ -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]
|
||||
39
tests/domain/modelling/test_products.py
Normal file
39
tests/domain/modelling/test_products.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue