"""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 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 raise ValueError(f"no decommission rate for {inputs.existing_system}") def _distribution(self, inputs: AshpCostInputs) -> float: radiators: int = max(_MIN_RADIATORS, min(_MAX_RADIATORS, inputs.radiator_count)) return _DISTRIBUTION_BY_RADIATORS[radiators]