mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Slice 4 of ADR-0025 costing. ASHP is offered to any house regardless of fuel, so _decommission now prices a fallback instead of raising: no system -> 0, electric room/panel heaters -> electric-storage line, anything else -> gas line (representative default). Never blocks ASHP eligibility. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
6.2 KiB
Python
156 lines
6.2 KiB
Python
"""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
|
|
# Decommission an existing wet (boiler) system — flat across property size for
|
|
# gas and oil; LPG carries the extra tank/fuel removal (ECOHT06-08, 03-04).
|
|
_DECOMMISSION_GAS = 720.0
|
|
_DECOMMISSION_OIL = 720.0
|
|
_DECOMMISSION_LPG = 960.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
|
|
# Power-flush + inhibitor when reusing an existing wet system (ECOHT67).
|
|
_DISTRIBUTION_FLUSH = 168.0
|
|
# Fraction of a full new distribution charged when reusing an existing wet
|
|
# system — a stand-in for partial radiator upsizing at low ASHP flow temps.
|
|
# The headline uncertainty in the model; recalibrate against real reuse-job
|
|
# costs / survey data (ADR-0025).
|
|
_REUSE_DISTRIBUTION_FRACTION = 0.5
|
|
|
|
|
|
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:
|
|
if inputs.existing_system is AshpExistingSystem.ELECTRIC_STORAGE:
|
|
return (
|
|
_DECOMMISSION_ELECTRIC_STORAGE_SMALL
|
|
if inputs.is_small_property
|
|
else _DECOMMISSION_ELECTRIC_STORAGE_LARGE
|
|
)
|
|
if inputs.existing_system is AshpExistingSystem.GAS:
|
|
return _DECOMMISSION_GAS
|
|
if inputs.existing_system is AshpExistingSystem.OIL:
|
|
return _DECOMMISSION_OIL
|
|
if inputs.existing_system is AshpExistingSystem.LPG:
|
|
return _DECOMMISSION_LPG
|
|
# Systems off the rate sheet: ASHP is still offered (ADR-0025), so price
|
|
# a fallback rather than raise. Nothing to remove for no system; electric
|
|
# room/panel heaters are comparable work to storage heaters; anything
|
|
# else takes the gas wet-system line as a representative default.
|
|
if inputs.existing_system is AshpExistingSystem.NONE:
|
|
return 0.0
|
|
if inputs.existing_system is AshpExistingSystem.ELECTRIC_OTHER:
|
|
return _DECOMMISSION_ELECTRIC_STORAGE_SMALL if inputs.is_small_property else _DECOMMISSION_ELECTRIC_STORAGE_LARGE
|
|
return _DECOMMISSION_GAS
|
|
|
|
def _distribution(self, inputs: AshpCostInputs) -> float:
|
|
radiators: int = max(_MIN_RADIATORS, min(_MAX_RADIATORS, inputs.radiator_count))
|
|
full: float = _DISTRIBUTION_BY_RADIATORS[radiators]
|
|
# An existing wet system is reused, not rebuilt: a flush plus a fraction
|
|
# of the full distribution to cover partial radiator upsizing.
|
|
if inputs.has_reusable_wet_system:
|
|
return _DISTRIBUTION_FLUSH + _REUSE_DISTRIBUTION_FRACTION * full
|
|
return full
|