mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Replace the flat placeholder scalars (boiler £3000; tune-up £500/£900) with a per-dwelling composite cost, mirroring the ASHP architecture (ADR-0025): a `HeatingRates` table (data, `heating_rates.json`), typed `BoilerCostInputs` / `TuneUpCostInputs`, pure `Products.boiler_bundle_cost` / `tune_up_cost`, and modelling-layer interpreters that read the dwelling into those inputs. The cost mirrors the Simulation Overlay component-for-component, sharing the controls + cylinder pricing across both options: - tune-up (standard) = standard controls + cylinder fixes - tune-up (zone) = zone controls + cylinder fixes - boiler upgrade = £3200 all-in + standard controls (only when the upgrade fired a controls change) + cylinder fixes Standard controls are priced INCREMENTALLY — only the parts missing to reach SAP 2106 (programmer £120 / room thermostat £150 / TRV £35×radiators), read from a Table 4e Group-1 feature map so a dwelling that already has a room thermostat + TRVs is only charged the programmer. Zone controls are a full smart kit (hub £205 + smart TRV £50×radiators) — the smart TRV is itself the room sensor, so there is no separate per-room sensor line. Cylinder fixes: jacket £50 (when under-insulated) + thermostat £150 (when absent). The boiler is a like-for-like wet swap (no radiators/flue/pipework — eligibility already requires an existing wet boiler), so those dead-code extras are not modelled. Figures are research-validated 2025/26 UK installed costs (legacy Costs.py lineage); fully-loaded totals with one contingency on top (Model B, not the legacy VAT/preliminaries engine). Contingency: boiler 0.26; tune-ups 0.10 (was a 0.15 placeholder). ADR-0027 records the design; CONTEXT.md's Heating Eligibility entry updated to cover the partial boiler/tune-up family + composed cost. Products cost pins (delta<=1e-9) + interpreter tests + generator composite-cost assertions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
457 lines
18 KiB
Python
457 lines
18 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"
|
||
_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
|