Model/domain/modelling/products.py
Khalim Conn-Kowlessar ae7e6a0c42 feat(modelling): composite per-dwelling boiler + tune-up costing (ADR-0027)
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>
2026-06-10 19:41:06 +00:00

457 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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