"""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 import json from dataclasses import dataclass from enum import Enum from pathlib import Path from typing import Any from domain.modelling.contingencies import contingency_rate from domain.modelling.recommendation import Cost _ASHP_MEASURE_TYPE = "air_source_heat_pump" _SOLAR_MEASURE_TYPE = "solar_pv" _GAS_BOILER_UPGRADE_MEASURE_TYPE = "gas_boiler_upgrade" _SYSTEM_TUNE_UP_MEASURE_TYPE = "system_tune_up" _SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE = "system_tune_up_zoned" # The committed ASHP rate sheet (ADR-0025) — structured rate rows the flat # scalar catalogue cannot hold; loaded into `AshpRates`. _ASHP_RATES_PATH = Path(__file__).resolve().parent / "ashp_rates.json" # The committed Solar PV rate sheet (ADR-0026) — the Southern Housing "SOLAR PV # & BATTERY" EA-rate column; loaded into `SolarRates`. _SOLAR_RATES_PATH = Path(__file__).resolve().parent / "solar_rates.json" # The committed boiler / tune-up rate table (ADR-0027) — research-validated # fully-loaded UK installed figures (legacy `Costs.py` lineage); loaded into # `HeatingRates`. _HEATING_RATES_PATH = Path(__file__).resolve().parent / "heating_rates.json" _MIN_RADIATORS = 4 _MAX_RADIATORS = 12 @dataclass(frozen=True) class AshpRates: """The Southern Housing Group ASHP rate table (ADR-0025) — fully-loaded supply+install rates, one row per priced line item. Data, not code: the committed default loads from `ashp_rates.json`, and a caller can inject a variant (e.g. to recalibrate `reuse_distribution_fraction`).""" decommission_electric_storage_small: float decommission_electric_storage_large: float decommission_gas: float decommission_oil: float decommission_lpg: float # Heat-pump install bands (max_kw, price), ascending; design heat loss rounds # up to the first covering band, else `heat_pump_top_price`. heat_pump_bands: tuple[tuple[float, float], ...] heat_pump_top_price: float # Fixed unvented cylinder — one per install (size spread on the sheet is £188). cylinder: float # Full new wet distribution, by radiator count. distribution_by_radiators: dict[int, float] # Power-flush + inhibitor when reusing an existing wet system. distribution_flush: float # Fraction of a full distribution charged on reuse — a stand-in for partial # radiator upsizing at low ASHP flow temps; the headline uncertainty. reuse_distribution_fraction: float @classmethod def default(cls) -> "AshpRates": """Load the committed Southern Housing rate sheet.""" return cls.from_json(_ASHP_RATES_PATH) @classmethod def from_json(cls, path: Path) -> "AshpRates": with path.open(encoding="utf-8") as handle: raw: dict[str, Any] = json.load(handle) decommission: dict[str, Any] = raw["decommission"] return cls( decommission_electric_storage_small=float( decommission["electric_storage_small"] ), decommission_electric_storage_large=float( decommission["electric_storage_large"] ), decommission_gas=float(decommission["gas"]), decommission_oil=float(decommission["oil"]), decommission_lpg=float(decommission["lpg"]), heat_pump_bands=tuple( (float(kw), float(price)) for kw, price in raw["heat_pump_bands"] ), heat_pump_top_price=float(raw["heat_pump_top_price"]), cylinder=float(raw["cylinder"]), distribution_by_radiators={ int(rads): float(price) for rads, price in raw["distribution_by_radiators"].items() }, distribution_flush=float(raw["distribution_flush"]), reuse_distribution_fraction=float(raw["reuse_distribution_fraction"]), ) @dataclass(frozen=True) class SolarRates: """The Southern Housing "SOLAR PV & BATTERY" EA rate table (ADR-0026) — fully-loaded supply+install rates. Data, not code: the committed default loads from `solar_rates.json`, and a caller can inject a variant (e.g. to replace the flagged battery estimate with a DB rate).""" # pv_system install price by kWp band (ECOPV06-13, slate roof), ascending. pv_system_by_kwp: tuple[tuple[float, float], ...] scaffolding_first_elevation: float scaffolding_additional_elevation: float enabling_eicr: float enabling_dno: float enabling_consumer_unit: float # Myenergi Eddi microgeneration diverter (ECOPV30). diverter: float # Battery supply+install — NOT on the rate sheet; a flagged estimate # (`battery_estimate`) confirmed with the user to stand in until a DB rate. battery: float battery_estimate: bool @classmethod def default(cls) -> "SolarRates": """Load the committed Southern Housing solar rate sheet.""" return cls.from_json(_SOLAR_RATES_PATH) @classmethod def from_json(cls, path: Path) -> "SolarRates": with path.open(encoding="utf-8") as handle: raw: dict[str, Any] = json.load(handle) bands: dict[str, Any] = raw["pv_system_by_kwp"] return cls( pv_system_by_kwp=tuple( sorted( (float(kwp), float(price)) for kwp, price in bands.items() ) ), scaffolding_first_elevation=float(raw["scaffolding_first_elevation"]), scaffolding_additional_elevation=float( raw["scaffolding_additional_elevation"] ), enabling_eicr=float(raw["enabling_eicr"]), enabling_dno=float(raw["enabling_dno"]), enabling_consumer_unit=float(raw["enabling_consumer_unit"]), diverter=float(raw["diverter"]), battery=float(raw["battery"]), battery_estimate=bool(raw["battery_estimate"]), ) @dataclass(frozen=True) class SolarCostInputs: """The dwelling facts the Solar PV catalogue math needs — produced by the modelling layer's interpretation of a chosen array config (ADR-0026).""" peak_power_kwp: float has_cylinder: bool has_battery: bool elevations: int = 2 @dataclass(frozen=True) class HeatingRates: """The boiler / tune-up rate table (ADR-0027) — research-validated, fully-loaded UK installed figures (the legacy `Costs.py` lineage). Data, not code: the committed default loads from `heating_rates.json`; a caller can inject a variant (e.g. when a real contractor rate sheet arrives). Per- radiator lines are priced × the dwelling's radiator count; the rest are fixed per dwelling.""" programmer: float room_thermostat: float trv_per_radiator: float zone_hub: float smart_trv_per_radiator: float cylinder_thermostat: float cylinder_jacket: float boiler: float @classmethod def default(cls) -> "HeatingRates": """Load the committed boiler / tune-up rate table.""" return cls.from_json(_HEATING_RATES_PATH) @classmethod def from_json(cls, path: Path) -> "HeatingRates": with path.open(encoding="utf-8") as handle: raw: dict[str, Any] = json.load(handle) return cls( programmer=float(raw["programmer"]), room_thermostat=float(raw["room_thermostat"]), trv_per_radiator=float(raw["trv_per_radiator"]), zone_hub=float(raw["zone_hub"]), smart_trv_per_radiator=float(raw["smart_trv_per_radiator"]), cylinder_thermostat=float(raw["cylinder_thermostat"]), cylinder_jacket=float(raw["cylinder_jacket"]), boiler=float(raw["boiler"]), ) @dataclass(frozen=True) class TuneUpCostInputs: """The dwelling facts the system-tune-up catalogue math needs (ADR-0027): which control level (standard vs zone), the radiator count driving the per- radiator items, which standard-control parts are already fitted (so only the missing parts are charged), and which cylinder fixes apply. Produced by the modelling-layer interpreter, never read off the EPC here.""" is_zoned: bool radiator_count: int has_programmer: bool has_room_thermostat: bool has_trvs: bool needs_cylinder_jacket: bool needs_cylinder_thermostat: bool @dataclass(frozen=True) class BoilerCostInputs: """The dwelling facts the boiler-upgrade catalogue math needs (ADR-0027): the boiler is always priced; the standard-controls cost is added only when the upgrade fired a controls change, and the cylinder fixes only when applicable. No system-change extras — the upgrade is always a like-for-like wet swap.""" upgrades_controls: bool radiator_count: int has_programmer: bool has_room_thermostat: bool has_trvs: bool needs_cylinder_jacket: bool needs_cylinder_thermostat: bool 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). The ASHP rate table is data, injected as `AshpRates` (default: the committed rate sheet).""" def __init__( self, rates: AshpRates | None = None, solar_rates: SolarRates | None = None, heating_rates: HeatingRates | None = None, ) -> None: self._rates: AshpRates = rates if rates is not None else AshpRates.default() self._solar_rates: SolarRates = ( solar_rates if solar_rates is not None else SolarRates.default() ) self._heating_rates: HeatingRates = ( heating_rates if heating_rates is not None else HeatingRates.default() ) def tune_up_cost(self, inputs: TuneUpCostInputs) -> Cost: """Compose the fully-loaded system-tune-up total: the control upgrade (zone full kit, or standard priced only for its missing parts) plus the conditional cylinder fixes, with the tune-up contingency (ADR-0027).""" controls: float = ( self._zone_controls(inputs.radiator_count) if inputs.is_zoned else self._standard_controls( inputs.radiator_count, inputs.has_programmer, inputs.has_room_thermostat, inputs.has_trvs, ) ) total: float = controls + self._cylinder_fixes( inputs.needs_cylinder_jacket, inputs.needs_cylinder_thermostat ) measure_type: str = ( _SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE if inputs.is_zoned else _SYSTEM_TUNE_UP_MEASURE_TYPE ) return Cost(total=total, contingency_rate=contingency_rate(measure_type)) def boiler_bundle_cost(self, inputs: BoilerCostInputs) -> Cost: """Compose the fully-loaded gas-boiler-upgrade total: the all-in boiler, plus the standard-controls cost only when the upgrade fired a controls change, plus the conditional cylinder fixes (ADR-0027).""" total: float = self._heating_rates.boiler if inputs.upgrades_controls: total += self._standard_controls( inputs.radiator_count, inputs.has_programmer, inputs.has_room_thermostat, inputs.has_trvs, ) total += self._cylinder_fixes( inputs.needs_cylinder_jacket, inputs.needs_cylinder_thermostat ) return Cost( total=total, contingency_rate=contingency_rate(_GAS_BOILER_UPGRADE_MEASURE_TYPE), ) def _standard_controls( self, radiator_count: int, has_programmer: bool, has_room_thermostat: bool, has_trvs: bool, ) -> float: """Price the standard controls (SAP 2106) incrementally — only the parts missing to reach programmer + room thermostat + a TRV per radiator.""" rates = self._heating_rates total: float = 0.0 if not has_programmer: total += rates.programmer if not has_room_thermostat: total += rates.room_thermostat if not has_trvs: total += rates.trv_per_radiator * radiator_count return total def _zone_controls(self, radiator_count: int) -> float: """Price the zone controls (SAP 2110) as a full smart kit: one hub plus a smart TRV per radiator (the smart TRV is itself the room sensor).""" rates = self._heating_rates return rates.zone_hub + rates.smart_trv_per_radiator * radiator_count def _cylinder_fixes( self, needs_jacket: bool, needs_thermostat: bool ) -> float: """Price the conditional cylinder fixes — an 80 mm jacket and/or a cylinder thermostat, each only when needed.""" rates = self._heating_rates total: float = 0.0 if needs_jacket: total += rates.cylinder_jacket if needs_thermostat: total += rates.cylinder_thermostat return total 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) + self._rates.cylinder + self._distribution(inputs) ) return Cost( total=total, contingency_rate=contingency_rate(_ASHP_MEASURE_TYPE) ) def solar_bundle_cost(self, inputs: SolarCostInputs) -> Cost: """Compose the fully-loaded Solar PV bundle total for a dwelling and pair it with the separate 15% solar contingency (ADR-0026).""" rates = self._solar_rates total: float = ( self._pv_system(inputs.peak_power_kwp) + self._scaffolding(inputs.elevations) + rates.enabling_eicr + rates.enabling_dno + rates.enabling_consumer_unit + (rates.diverter if inputs.has_cylinder else 0.0) + (rates.battery if inputs.has_battery else 0.0) ) return Cost( total=total, contingency_rate=contingency_rate(_SOLAR_MEASURE_TYPE) ) def _pv_system(self, peak_power_kwp: float) -> float: """Price the pv_system install at the kWp band nearest the array size, flooring below the smallest band and capping at the largest.""" bands = self._solar_rates.pv_system_by_kwp nearest_kwp, _ = min(bands, key=lambda band: abs(band[0] - peak_power_kwp)) return dict(bands)[nearest_kwp] def _scaffolding(self, elevations: int) -> float: """£900 for the first elevation + £450 for each additional.""" rates = self._solar_rates additional: int = max(0, elevations - 1) return ( rates.scaffolding_first_elevation + additional * rates.scaffolding_additional_elevation ) 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 self._rates.heat_pump_bands: if design_heat_loss_kw <= max_kw: return price return self._rates.heat_pump_top_price def _decommission(self, inputs: AshpCostInputs) -> float: rates = self._rates electric_storage: float = ( rates.decommission_electric_storage_small if inputs.is_small_property else rates.decommission_electric_storage_large ) if inputs.existing_system is AshpExistingSystem.ELECTRIC_STORAGE: return electric_storage if inputs.existing_system is AshpExistingSystem.GAS: return rates.decommission_gas if inputs.existing_system is AshpExistingSystem.OIL: return rates.decommission_oil if inputs.existing_system is AshpExistingSystem.LPG: return rates.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 electric_storage return rates.decommission_gas def _distribution(self, inputs: AshpCostInputs) -> float: radiators: int = max(_MIN_RADIATORS, min(_MAX_RADIATORS, inputs.radiator_count)) full: float = self._rates.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 ( self._rates.distribution_flush + self._rates.reuse_distribution_fraction * full ) return full