Model/domain/modelling/products.py
Khalim Conn-Kowlessar 46bca47365 feat(modelling): Products.solar_bundle_cost + committed solar rate sheet
Slice 7 of the Solar PV Recommendation Generator (ADR-0026). Adds the
composite per-dwelling Solar PV cost on the Products collection (ADR-0025
pattern): pv_system(kWp band, nearest of the ECOPV06-13 EA bands 1.0→4.5 kWp,
floor/cap at the ends) + scaffolding(£900 first elevation + £450 each
additional, default 2) + enabling base (EICR £150 + DNO £50 + 2-way consumer
unit £330) + [diverter £980 if cylinder] + [battery if the with-battery
variant] → Cost(total, contingency_rate 0.15).

Rates are data in the committed solar_rates.json (Southern Housing "SOLAR PV &
BATTERY" EA column), loaded via SolarRates.from_json/.default and injectable.
The £2,000 / 5 kWh battery is NOT on the rate sheet — a flagged estimate
(battery_estimate=true), confirmed with the user to stand in until a DB rate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:10:27 +00:00

292 lines
12 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
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"
# 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"
_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
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,
) -> 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()
)
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