mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Slice 10 of ADR-0025 costing. The Southern Housing rate table moves from code constants into ashp_rates.json (structured rows the flat scalar catalogue can't hold), loaded via AshpRates.from_json. Products takes an injected AshpRates (default: the committed sheet), so rates are now data -- tunable (e.g. reuse_distribution_fraction) without a code change, and ready for ETL/DB-supplied rates later. Behaviour-preserving: the 6 pinned cost tests still hold against the default, plus a new test proving injected rates drive the total. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
187 lines
7.8 KiB
Python
187 lines
7.8 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"
|
|
|
|
# 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"
|
|
|
|
_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"]),
|
|
)
|
|
|
|
|
|
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) -> None:
|
|
self._rates: AshpRates = rates if rates is not None else AshpRates.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 _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
|