mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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>
This commit is contained in:
parent
07f534ee11
commit
ae7e6a0c42
9 changed files with 595 additions and 12 deletions
|
|
@ -271,8 +271,8 @@ The rule fixing the single lighting Measure the **Lighting** Recommendation offe
|
|||
_Avoid_: "low energy lighting" as the upgrade target (we go to **LED**); treating it as a forced dependency (it is a free candidate); pricing by floor area (it's per-bulb count × average)
|
||||
|
||||
**Heating Eligibility**:
|
||||
The rule fixing which whole-system **Measure Options** the single **Heating & Hot Water** Recommendation offers (ADR-0024). The competing bundles — `high_heat_retention_storage_heaters`, `air_source_heat_pump`, and `boiler_upgrade` (deferred) — are **mutually-exclusive system replacements**; the Optimiser picks at most one. Each bundle is a **whole system change at once** — main heating + **controls + fuel + meter + the implied hot water** folded in, never a separate or complementary HW measure (the legacy heating-vs-HW split double-counted). Each is a **fixed, real, contractor-installable end-state** (a representative product Domna installs — ASHP via a fixed PCDB heat-pump index, HHR storage via `sap_main_heating_code=409`), not a derived ideal; **Product** stays cost-only. Eligibility encodes **only physical/planning installability** — the **Optimiser owns the economics**, so it must not re-gate on cost proxies: **ASHP** → houses/bungalows that are not **listed**/**heritage** and not already a heat pump (flats excluded — individual siting needs a survey; a **conservation area** still gets the offer, unlike glazing); **HHR storage** → off-gas or currently-electric dwellings, not community-heated or already HHR. Floor area, fabric, fuel, and built form are **not** gates (the legacy ASHP built-form / 120 m² rule is dropped — no authoritative basis). A free Optimiser candidate, not a forced **Measure Dependency**.
|
||||
_Avoid_: separate "heating" and "hot water" recommendations (HW folds into the bundle); gating ASHP on floor area / built form / fabric (eligibility is physical/planning only — the Optimiser decides cost-effectiveness); "heating controls" as a standalone competing measure (folded into the bundle)
|
||||
The rule fixing which **Measure Options** the single **Heating & Hot Water** Recommendation offers (ADR-0024, expanded). The competing Options are **mutually-exclusive** (the Optimiser picks at most one) and fall in two families: **whole-system replacements** — `high_heat_retention_storage_heaters`, `air_source_heat_pump` — which change main heating + **controls + fuel + meter + the implied hot water** at once (never a separate HW measure; the legacy heating-vs-HW split double-counted); and, for a dwelling keeping a serviceable wet boiler, **partial upgrades** — `gas_boiler_upgrade` (a like-for-like condensing **gas** boiler: gas→gas, or non-gas→gas only where mains gas is present; combi or regular-plus-cylinder, shaped by the dwelling) and the **system tune-up** (keep the boiler; install better **controls** + fix the **cylinder**), the tune-up offered at two competing control levels: `system_tune_up` (standard, SAP code 2106) and `system_tune_up_zoned` (time-and-temperature zone control, 2110 — more SAP uplift, more cost). Each Option is a **fixed, real, contractor-installable end-state** (ASHP via a fixed PCDB heat-pump index; HHR storage via `sap_main_heating_code=409`; the gas boiler via Table 4b code 102/104; controls via 2106/2110), not a derived ideal; **Product** stays cost-only, but a partial/bundle cost is **composed per dwelling** from the components the overlay installs (ADR-0025/0027), not a flat scalar. Eligibility encodes **only physical/planning installability** — the **Optimiser owns the economics**, so it must not re-gate on cost proxies: **ASHP** → houses/bungalows that are not **listed**/**heritage** and not already a heat pump (flats excluded — individual siting needs a survey; a **conservation area** still gets the offer, unlike glazing); **HHR storage** → off-gas or currently-electric dwellings, not community-heated or already HHR; **boiler upgrade / tune-up** → an existing (non-electric) wet boiler, the gas end-state gated on a mains-gas connection, a partial control upgrade offered only when it genuinely improves the existing control (never a downgrade or no-op). Floor area, fabric, fuel, and built form are **not** gates (the legacy ASHP built-form / 120 m² rule is dropped — no authoritative basis). A free Optimiser candidate, not a forced **Measure Dependency**.
|
||||
_Avoid_: separate "heating" and "hot water" recommendations (HW folds into each Option); gating ASHP on floor area / built form / fabric (eligibility is physical/planning only — the Optimiser decides cost-effectiveness); treating the whole-system replacements and the partial boiler/tune-up upgrades as **separate** Recommendations (they are mutually-exclusive Options within the one heating rec — separate recs would let the Optimiser co-select and double-charge); a standalone hot-water-only or controls-only Recommendation (controls + cylinder fold into the boiler/tune-up Option)
|
||||
|
||||
### Valuation
|
||||
|
||||
|
|
|
|||
56
docs/adr/0027-boiler-and-tune-up-costing.md
Normal file
56
docs/adr/0027-boiler-and-tune-up-costing.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Boiler-Upgrade & System-Tune-Up Costing — Composite, Component-Mirrored
|
||||
|
||||
The boiler-upgrade and system-tune-up Options (the Heating & Hot Water expansion to ADR-0024) currently carry flat placeholder catalogue scalars (boiler £3000; tune-up £500/£900). Like the ASHP bundle (ADR-0025), their real cost varies per dwelling — a tune-up that only fits TRVs on a dwelling that already has a programmer + room thermostat costs a fraction of a full controls-from-scratch job, and the Optimiser picks least-cost-to-band, so a flat number mis-ranks them against each other and against ASHP/HHRSH. We therefore **compose each cost per dwelling**, mirroring ADR-0025's architecture and reusing the (research-validated) legacy `recommendations/Costs.py` figures as the rate source.
|
||||
|
||||
## Decision
|
||||
|
||||
**The cost mirrors the Simulation Overlay component-for-component.** The overlay is the source of truth for what is installed; the cost prices exactly those components, using the *same conditional predicates* the overlay uses to decide what to write. This guarantees cost↔overlay consistency — we never charge for a thermostat the overlay didn't add, nor omit controls the boiler did upgrade — and keeps "one Option = one composite Plan line" honest.
|
||||
|
||||
```
|
||||
tune-up (standard) = standard_controls + cylinder_fixes
|
||||
tune-up (zone) = zone_controls + cylinder_fixes
|
||||
boiler upgrade = boiler(£3200 all-in)
|
||||
+ standard_controls (only when the boiler fired a controls upgrade)
|
||||
+ cylinder_fixes (only when a cylinder is present)
|
||||
```
|
||||
|
||||
`standard_controls` and `cylinder_fixes` are **shared** between boiler and tune-up.
|
||||
|
||||
- **standard_controls = incremental** — price only the parts missing to reach SAP code 2106, read from a Table 4e Group-1 → `(has_programmer, has_room_thermostat, has_TRVs)` feature map: `programmer £120` + `room_thermostat £150` + `TRV £35 × radiators`, each charged only when absent. (A 7-radiator dwelling from "no controls" = £515, matching the Energy Saving Trust's ~£550 quoted figure; the £35 TRV is the *marginal* in-bundle rate, since the drain-down labour is shared once.)
|
||||
- **zone_controls = full smart-zone kit, not incremental** — `smart_thermostat hub £205` + `smart_TRV £50 × radiators`. A smart zone system replaces whatever's there, and **the smart TRV is itself the per-room sensor** — so there is **no** separate per-room temperature-sensor line (the legacy double-counted it; corrected after research).
|
||||
- **cylinder_fixes** — `jacket £50` (when under-insulated) + `cylinder_thermostat £150` (when absent), each conditional, and only when a cylinder exists.
|
||||
- **boiler = £3200 all-in** (condensing gas boiler, flue + labour included). **No system-change extras** (radiators / separate flue / pipework): boiler-upgrade eligibility already requires an *existing wet boiler* (SAP code 101-141 / 151-161, electric 191-196 excluded, mains gas present), so every upgrade is a like-for-like swap that reuses the existing wet distribution — the dry→wet conversion lines can never fire under the gate, so they are not modelled.
|
||||
|
||||
**`Products` owns the catalogue math; the modelling layer owns the dwelling interpretation** (ADR-0025 unchanged). New `Products.boiler_bundle_cost(BoilerCostInputs)` and `tune_up_cost(TuneUpCostInputs)` sum the applicable lines from a `HeatingRates` table (data, loaded from `heating_rates.json`) into a `Cost`, staying free of `EpcPropertyData` / `Sap10Calculator`. The modelling-layer interpreters read the dwelling (radiator count via the existing `_radiator_count` proxy; existing control features from the SAP control code; cylinder insulation/thermostat state) into those typed inputs. Per-radiator items (TRVs, smart TRVs) scale on `_radiator_count`; everything else is fixed per dwelling.
|
||||
|
||||
**Fully-loaded totals, separate contingency** (Model B — the ADR-0025 shape, *not* the legacy VAT/preliminaries engine). The legacy per-item £ figures are reused as fully-loaded rates and summed; one `contingency_rate` is applied on top (boiler 0.26; both tune-ups 0.10, per legacy `Costs.CONTINGENCIES`). The legacy's separate VAT-on-labour / preliminaries arithmetic is *not* reproduced — the cost exists for Optimiser *ranking*, where those scale near-uniformly and don't change the order, and the per-item figures are themselves estimates, so sub-£100 tax precision is false fidelity.
|
||||
|
||||
**Rate table (8 lines, research-validated 2025/26 UK installed figures):**
|
||||
|
||||
| line | £ | driver |
|
||||
|---|---|---|
|
||||
| programmer | 120 | fixed |
|
||||
| room_thermostat | 150 | fixed |
|
||||
| trv_per_radiator | 35 | per radiator |
|
||||
| zone_hub (smart thermostat) | 205 | fixed |
|
||||
| smart_trv_per_radiator | 50 | per radiator |
|
||||
| cylinder_thermostat | 150 | fixed |
|
||||
| cylinder_jacket | 50 | fixed |
|
||||
| boiler | 3200 | fixed |
|
||||
|
||||
## Considered options
|
||||
|
||||
- **Replicate the legacy VAT/labour/preliminaries arithmetic exactly.** Rejected: re-introduces a tax engine ADR-0025 deliberately avoided; false precision over rough estimates; ranking is insensitive to near-uniform tax/preliminaries.
|
||||
- **Flat catalogue scalar (the placeholder).** Rejected: a tune-up's cost varies ~£200–£900 with what's already fitted and the radiator count; a flat number mis-ranks it against the boiler upgrade and ASHP.
|
||||
- **Price controls as a flat job (no per-radiator term).** Rejected after research: TRVs and smart TRVs are genuinely per-radiator; a flat job over- or under-charges with dwelling size, and the per-radiator marginal rate is what makes the bundle sum match the EST reference.
|
||||
- **Keep the legacy zone-control build-up (per-room sensor + per-radiator smart TRV).** Rejected after research: the smart TRV *is* the room sensor in real multi-zone systems (Tado/Wiser/evohome); the separate sensor line double-counts.
|
||||
- **Keep the dry→wet system-change extras for robustness.** Rejected: dead code under the eligibility gate (existing wet boiler required); ADR-0025 likewise declined to price extras the data path can't reach.
|
||||
- **Boiler cost stays boiler-only; controls/cylinder priced as separate measures.** Rejected: they're folded into the one Option's overlay, so pricing them separately would split one Plan line and risk double-charging against a tune-up.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New `HeatingRates` + `heating_rates.json`, `BoilerCostInputs` / `TuneUpCostInputs`, and `Products.boiler_bundle_cost` / `tune_up_cost`; the boiler/tune-up Options swap their flat scalar for the composite (the catalogue row is still read for its `id`, as ASHP does). Contingencies for the two tune-up types drop 0.15 → 0.10 to match the legacy reference.
|
||||
- A new **Table 4e Group-1 control-feature map** lives in the modelling interpreter — the single place that reads "what controls does this dwelling already have" from a SAP code. An unrecognised/absent control code defaults to "no parts present" (charge the full standard kit) — conservative, and the standard option is only offered when the control is improvable anyway.
|
||||
- The figures are research-validated installed UK estimates, not a contractor rate sheet (unlike the ASHP Southern Housing lines). When a real boiler/controls rate sheet arrives it replaces `heating_rates.json` with no code change — the rates are data.
|
||||
- Cost↔overlay consistency is structural: both read the same cylinder/control predicates, so they cannot drift (e.g. the overlay adding a thermostat the cost forgot).
|
||||
- All dwelling reads (radiator count, existing control parts, cylinder state) are whole-dwelling proxies standing in for a survey, documented at each call site.
|
||||
|
|
@ -21,8 +21,8 @@ _CONTINGENCY_RATES: dict[str, float] = {
|
|||
"high_heat_retention_storage_heaters": 0.10,
|
||||
"air_source_heat_pump": 0.25,
|
||||
"gas_boiler_upgrade": 0.26,
|
||||
"system_tune_up": 0.15,
|
||||
"system_tune_up_zoned": 0.15,
|
||||
"system_tune_up": 0.10,
|
||||
"system_tune_up_zoned": 0.10,
|
||||
"solar_pv": 0.15,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,13 @@ from typing import Optional
|
|||
from datatypes.epc.domain.epc_property_data import EpcPropertyData, MainHeatingDetail
|
||||
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
|
||||
from domain.geospatial.planning_restrictions import PlanningRestrictions
|
||||
from domain.modelling.products import AshpCostInputs, AshpExistingSystem, Products
|
||||
from domain.modelling.products import (
|
||||
AshpCostInputs,
|
||||
AshpExistingSystem,
|
||||
BoilerCostInputs,
|
||||
Products,
|
||||
TuneUpCostInputs,
|
||||
)
|
||||
from domain.modelling.measure_type import MeasureType
|
||||
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
|
||||
from domain.modelling.simulation import EpcSimulation, HeatingOverlay
|
||||
|
|
@ -341,15 +347,17 @@ def _tune_up_option(
|
|||
description: str,
|
||||
) -> MeasureOption:
|
||||
"""One tune-up Option: the existing boiler is kept; only the heating control
|
||||
and the conditional cylinder fixes change."""
|
||||
and the conditional cylinder fixes change. Cost is composed per dwelling from
|
||||
those components (ADR-0027); the catalogue row is read for its id."""
|
||||
product = products.get(measure_type)
|
||||
cost: Cost = Products().tune_up_cost(
|
||||
tune_up_cost_inputs(epc, is_zoned=control == _ZONE_CONTROL)
|
||||
)
|
||||
return MeasureOption(
|
||||
measure_type=measure_type,
|
||||
description=description,
|
||||
overlay=EpcSimulation(heating=_tune_up_overlay(epc, control)),
|
||||
cost=Cost(
|
||||
total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate
|
||||
),
|
||||
cost=cost,
|
||||
material_id=product.id,
|
||||
)
|
||||
|
||||
|
|
@ -400,14 +408,16 @@ def _boiler_upgrade_option(
|
|||
if has_cylinder
|
||||
else "Replace the boiler with a gas condensing combi boiler"
|
||||
)
|
||||
# Cost is composed per dwelling from the boiler + the controls/cylinder
|
||||
# fixes the overlay installs (ADR-0027), not the flat catalogue scalar; the
|
||||
# catalogue row is still read for its id.
|
||||
product = products.get(_GAS_BOILER_UPGRADE_MEASURE_TYPE)
|
||||
cost: Cost = Products().boiler_bundle_cost(boiler_cost_inputs(epc))
|
||||
return MeasureOption(
|
||||
measure_type=_GAS_BOILER_UPGRADE_MEASURE_TYPE,
|
||||
description=description,
|
||||
overlay=EpcSimulation(heating=overlay),
|
||||
cost=Cost(
|
||||
total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate
|
||||
),
|
||||
cost=cost,
|
||||
material_id=product.id,
|
||||
)
|
||||
|
||||
|
|
@ -500,6 +510,91 @@ def _upgraded_boiler_control(main: MainHeatingDetail) -> Optional[int]:
|
|||
return None
|
||||
|
||||
|
||||
# --- Boiler / tune-up cost interpretation (ADR-0027): read the dwelling into the
|
||||
# typed inputs the catalogue math needs. The pricing itself lives on `Products`;
|
||||
# this is the modelling-layer half that the catalogue stays free of. ---
|
||||
|
||||
# SAP 10.2 Table 4e Group 1 (PDF p.172) — which standard-control parts each
|
||||
# boiler control code already provides: (has_programmer, has_room_thermostat,
|
||||
# has_TRVs). Lets the standard-controls cost charge only the missing parts to
|
||||
# reach 2106 (programmer + room thermostat + TRVs). Zone codes (2110/2112) are
|
||||
# omitted — a standard upgrade is never offered to them.
|
||||
_CONTROL_FEATURES_BY_CODE: dict[int, tuple[bool, bool, bool]] = {
|
||||
2101: (False, False, False), # No time or thermostatic control
|
||||
2102: (True, False, False), # Programmer, no room thermostat
|
||||
2103: (False, True, False), # Room thermostat only
|
||||
2104: (True, True, False), # Programmer and room thermostat
|
||||
2105: (True, True, False), # Programmer and at least two room thermostats
|
||||
2106: (True, True, True), # Programmer, room thermostat and TRVs
|
||||
2107: (True, False, True), # Programmer, TRVs and bypass
|
||||
2108: (True, False, True), # Programmer, TRVs and flow switch
|
||||
2109: (True, False, True), # Programmer, TRVs and boiler energy manager
|
||||
2111: (False, False, True), # TRVs and bypass
|
||||
2113: (False, True, True), # Room thermostat and TRVs
|
||||
}
|
||||
|
||||
|
||||
def _control_features(main: MainHeatingDetail) -> tuple[bool, bool, bool]:
|
||||
"""The standard-control parts a dwelling already has, from its SAP control
|
||||
code. An unrecognised/absent code defaults to none present (charge the full
|
||||
standard kit) — conservative, and the standard option is only offered when
|
||||
the control is improvable anyway."""
|
||||
control = main.main_heating_control
|
||||
code: Optional[int] = control if isinstance(control, int) else None
|
||||
return _CONTROL_FEATURES_BY_CODE.get(code, (False, False, False)) if (
|
||||
code is not None
|
||||
) else (False, False, False)
|
||||
|
||||
|
||||
def _cylinder_fix_needs(epc: EpcPropertyData) -> tuple[bool, bool]:
|
||||
"""Whether the dwelling needs a cylinder jacket and/or a thermostat — the
|
||||
same predicates the overlay uses (only when a cylinder exists)."""
|
||||
if not epc.has_hot_water_cylinder:
|
||||
return (False, False)
|
||||
sap_heating = epc.sap_heating
|
||||
needs_jacket: bool = _cylinder_under_insulated(
|
||||
sap_heating.cylinder_insulation_thickness_mm
|
||||
)
|
||||
needs_thermostat: bool = sap_heating.cylinder_thermostat != "Y"
|
||||
return (needs_jacket, needs_thermostat)
|
||||
|
||||
|
||||
def tune_up_cost_inputs(epc: EpcPropertyData, *, is_zoned: bool) -> TuneUpCostInputs:
|
||||
"""Read a dwelling into the inputs `Products.tune_up_cost` needs: the control
|
||||
level, the radiator count (per-radiator items), the standard-control parts
|
||||
already fitted, and the cylinder fixes that apply (ADR-0027)."""
|
||||
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
|
||||
has_programmer, has_room_thermostat, has_trvs = _control_features(main)
|
||||
needs_jacket, needs_thermostat = _cylinder_fix_needs(epc)
|
||||
return TuneUpCostInputs(
|
||||
is_zoned=is_zoned,
|
||||
radiator_count=_radiator_count(epc),
|
||||
has_programmer=has_programmer,
|
||||
has_room_thermostat=has_room_thermostat,
|
||||
has_trvs=has_trvs,
|
||||
needs_cylinder_jacket=needs_jacket,
|
||||
needs_cylinder_thermostat=needs_thermostat,
|
||||
)
|
||||
|
||||
|
||||
def boiler_cost_inputs(epc: EpcPropertyData) -> BoilerCostInputs:
|
||||
"""Read a dwelling into the inputs `Products.boiler_bundle_cost` needs: the
|
||||
boiler is always priced; controls are added only when the upgrade fires a
|
||||
controls change, and the cylinder fixes when applicable (ADR-0027)."""
|
||||
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
|
||||
has_programmer, has_room_thermostat, has_trvs = _control_features(main)
|
||||
needs_jacket, needs_thermostat = _cylinder_fix_needs(epc)
|
||||
return BoilerCostInputs(
|
||||
upgrades_controls=_upgraded_boiler_control(main) is not None,
|
||||
radiator_count=_radiator_count(epc),
|
||||
has_programmer=has_programmer,
|
||||
has_room_thermostat=has_room_thermostat,
|
||||
has_trvs=has_trvs,
|
||||
needs_cylinder_jacket=needs_jacket,
|
||||
needs_cylinder_thermostat=needs_thermostat,
|
||||
)
|
||||
|
||||
|
||||
def _ashp_option(
|
||||
epc: EpcPropertyData,
|
||||
products: ProductRepository,
|
||||
|
|
|
|||
10
domain/modelling/heating_rates.json
Normal file
10
domain/modelling/heating_rates.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"programmer": 120,
|
||||
"room_thermostat": 150,
|
||||
"trv_per_radiator": 35,
|
||||
"zone_hub": 205,
|
||||
"smart_trv_per_radiator": 50,
|
||||
"cylinder_thermostat": 150,
|
||||
"cylinder_jacket": 50,
|
||||
"boiler": 3200
|
||||
}
|
||||
|
|
@ -26,6 +26,9 @@ 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`.
|
||||
|
|
@ -33,6 +36,10 @@ _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
|
||||
|
|
@ -159,6 +166,78 @@ class SolarCostInputs:
|
|||
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
|
||||
|
|
@ -194,11 +273,97 @@ class Products:
|
|||
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
|
||||
|
|
|
|||
97
tests/domain/modelling/test_heating_cost_inputs.py
Normal file
97
tests/domain/modelling/test_heating_cost_inputs.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""The dwelling interpretation that feeds `Products.boiler_bundle_cost` /
|
||||
`tune_up_cost` — reading an `EpcPropertyData` into typed cost inputs (ADR-0027).
|
||||
The modelling-layer half of the split: it derives the radiator count, which
|
||||
standard-control parts are already fitted (from the SAP Table 4e control code),
|
||||
whether the boiler upgrade fires a controls change, and which cylinder fixes
|
||||
apply — the catalogue math (Products) stays EPC-free.
|
||||
"""
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from domain.modelling.generators.heating_recommendation import (
|
||||
boiler_cost_inputs,
|
||||
tune_up_cost_inputs,
|
||||
)
|
||||
from domain.modelling.products import BoilerCostInputs, TuneUpCostInputs
|
||||
from tests.domain.modelling._elmhurst_recommendation import (
|
||||
parse_recommendation_summary,
|
||||
)
|
||||
|
||||
|
||||
def test_tune_up_inputs_from_no_controls_charge_every_part() -> None:
|
||||
# Arrange — control 2101 ("no control"): no programmer, room thermostat or
|
||||
# TRVs fitted; an uninsulated, un-thermostatted cylinder; 10 radiators.
|
||||
epc: EpcPropertyData = parse_recommendation_summary(
|
||||
"tune_up_from_2101_001431_before.pdf"
|
||||
)
|
||||
|
||||
# Act
|
||||
inputs: TuneUpCostInputs = tune_up_cost_inputs(epc, is_zoned=False)
|
||||
|
||||
# Assert
|
||||
assert inputs == TuneUpCostInputs(
|
||||
is_zoned=False,
|
||||
radiator_count=10,
|
||||
has_programmer=False,
|
||||
has_room_thermostat=False,
|
||||
has_trvs=False,
|
||||
needs_cylinder_jacket=True,
|
||||
needs_cylinder_thermostat=True,
|
||||
)
|
||||
|
||||
|
||||
def test_tune_up_inputs_read_existing_control_parts() -> None:
|
||||
# Arrange — control 2113 ("room thermostat and TRVs"): already has a room
|
||||
# thermostat + TRVs, only the programmer is missing. The is_zoned flag is
|
||||
# passed through.
|
||||
epc: EpcPropertyData = parse_recommendation_summary(
|
||||
"tune_up_from_2113_001431_before.pdf"
|
||||
)
|
||||
|
||||
# Act
|
||||
inputs: TuneUpCostInputs = tune_up_cost_inputs(epc, is_zoned=True)
|
||||
|
||||
# Assert — so the standard cost would charge only the programmer.
|
||||
assert inputs.is_zoned is True
|
||||
assert inputs.has_programmer is False
|
||||
assert inputs.has_room_thermostat is True
|
||||
assert inputs.has_trvs is True
|
||||
|
||||
|
||||
def test_boiler_inputs_flag_a_controls_upgrade_for_inadequate_controls() -> None:
|
||||
# Arrange — a combi (no cylinder) with inadequate controls (2111 "TRVs and
|
||||
# bypass", no room thermostat): the boiler upgrade also fires the standard
|
||||
# controls, which already has TRVs but no programmer/room thermostat.
|
||||
epc: EpcPropertyData = parse_recommendation_summary(
|
||||
"boiler_combi_gas_001431_before.pdf"
|
||||
)
|
||||
|
||||
# Act
|
||||
inputs: BoilerCostInputs = boiler_cost_inputs(epc)
|
||||
|
||||
# Assert
|
||||
assert inputs == BoilerCostInputs(
|
||||
upgrades_controls=True,
|
||||
radiator_count=7,
|
||||
has_programmer=False,
|
||||
has_room_thermostat=False,
|
||||
has_trvs=True,
|
||||
needs_cylinder_jacket=False,
|
||||
needs_cylinder_thermostat=False,
|
||||
)
|
||||
|
||||
|
||||
def test_boiler_inputs_no_controls_upgrade_when_already_adequate() -> None:
|
||||
# Arrange — a gas boiler with a cylinder and already-adequate controls
|
||||
# (2106): the boiler doesn't fire a controls change, but both cylinder fixes
|
||||
# apply (uninsulated, un-thermostatted).
|
||||
epc: EpcPropertyData = parse_recommendation_summary(
|
||||
"boiler_cyl_gas_001431_before.pdf"
|
||||
)
|
||||
|
||||
# Act
|
||||
inputs: BoilerCostInputs = boiler_cost_inputs(epc)
|
||||
|
||||
# Assert
|
||||
assert inputs.upgrades_controls is False
|
||||
assert inputs.needs_cylinder_jacket is True
|
||||
assert inputs.needs_cylinder_thermostat is True
|
||||
|
|
@ -459,6 +459,47 @@ def test_tune_up_neither_offered_when_controls_already_zoned() -> None:
|
|||
assert "system_tune_up_zoned" not in measure_types
|
||||
|
||||
|
||||
def test_tune_up_carries_the_composite_per_dwelling_cost() -> None:
|
||||
# Arrange — a wet boiler with "no control" (2101) and an uninsulated, un-
|
||||
# thermostatted cylinder, 10 radiators: the standard tune-up fits the full
|
||||
# control set + both cylinder fixes (ADR-0027).
|
||||
baseline: EpcPropertyData = _tune_up_baseline()
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
|
||||
assert recommendation is not None
|
||||
option = next(
|
||||
o for o in recommendation.options if o.measure_type == "system_tune_up"
|
||||
)
|
||||
|
||||
# Assert — programmer 120 + room stat 150 + TRVs 10x35=350 = 620 controls +
|
||||
# jacket 50 + cylinder stat 150 = 820, with the 0.10 tune-up contingency
|
||||
# (composed, not the stub catalogue scalar).
|
||||
assert option.cost is not None
|
||||
assert abs(option.cost.total - 820.0) <= 1e-9
|
||||
assert abs(option.cost.contingency_rate - 0.10) <= 1e-9
|
||||
|
||||
|
||||
def test_boiler_upgrade_carries_the_composite_per_dwelling_cost() -> None:
|
||||
# Arrange — a gas boiler with a cylinder and adequate controls (2106): the
|
||||
# boiler is a like-for-like swap (no controls upgrade, no system-change
|
||||
# extras), with both cylinder fixes (ADR-0027).
|
||||
baseline: EpcPropertyData = _gas_boiler_with_cylinder_baseline()
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_heating(baseline, _StubProducts())
|
||||
assert recommendation is not None
|
||||
option = next(
|
||||
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
|
||||
)
|
||||
|
||||
# Assert — boiler 3200 + jacket 50 + cylinder stat 150 = 3400, 0.26 boiler
|
||||
# contingency.
|
||||
assert option.cost is not None
|
||||
assert abs(option.cost.total - 3400.0) <= 1e-9
|
||||
assert abs(option.cost.contingency_rate - 0.26) <= 1e-9
|
||||
|
||||
|
||||
def test_boiler_upgrade_leaves_adequate_controls_unchanged() -> None:
|
||||
# Arrange — the same combi but with already-adequate controls (2113, room
|
||||
# thermostat and TRVs): the upgrade must not move the controls (and must
|
||||
|
|
|
|||
|
|
@ -15,10 +15,14 @@ from domain.modelling.products import (
|
|||
AshpCostInputs,
|
||||
AshpExistingSystem,
|
||||
AshpRates,
|
||||
BoilerCostInputs,
|
||||
Products,
|
||||
TuneUpCostInputs,
|
||||
)
|
||||
from domain.modelling.recommendation import Cost
|
||||
|
||||
_PIN: float = 1e-9
|
||||
|
||||
|
||||
def test_ashp_bundle_cost_composes_an_electric_storage_full_distribution_dwelling() -> None:
|
||||
# Arrange — a small electric-storage dwelling: no reusable wet system, so a
|
||||
|
|
@ -192,3 +196,118 @@ def test_radiator_count_is_clamped_to_the_distribution_table_bounds() -> None:
|
|||
assert abs(_full_distribution(products, 9) - 4680.0) <= 1e-9
|
||||
assert abs(_full_distribution(products, 12) - 6288.0) <= 1e-9
|
||||
assert abs(_full_distribution(products, 15) - 6288.0) <= 1e-9
|
||||
|
||||
|
||||
# --- Boiler / tune-up composite costs (ADR-0027) --------------------------
|
||||
|
||||
|
||||
def test_tune_up_standard_from_no_controls_with_cylinder_fixes() -> None:
|
||||
# Arrange — a 7-radiator dwelling with no existing controls, an uninsulated
|
||||
# un-thermostatted cylinder: the standard tune-up fits the full control set
|
||||
# plus both cylinder fixes.
|
||||
products = Products()
|
||||
inputs = TuneUpCostInputs(
|
||||
is_zoned=False,
|
||||
radiator_count=7,
|
||||
has_programmer=False,
|
||||
has_room_thermostat=False,
|
||||
has_trvs=False,
|
||||
needs_cylinder_jacket=True,
|
||||
needs_cylinder_thermostat=True,
|
||||
)
|
||||
|
||||
# Act
|
||||
cost: Cost = products.tune_up_cost(inputs)
|
||||
|
||||
# Assert — programmer 120 + room stat 150 + TRVs 7x35=245 = 515 controls,
|
||||
# + jacket 50 + cylinder stat 150 = 715, with the 0.10 tune-up contingency.
|
||||
assert abs(cost.total - 715.0) <= _PIN
|
||||
assert abs(cost.contingency_rate - 0.10) <= _PIN
|
||||
|
||||
|
||||
def test_tune_up_standard_charges_only_the_missing_control_parts() -> None:
|
||||
# Arrange — the dwelling already has a room thermostat + TRVs (only the
|
||||
# programmer is missing), and the cylinder is already sorted.
|
||||
products = Products()
|
||||
inputs = TuneUpCostInputs(
|
||||
is_zoned=False,
|
||||
radiator_count=7,
|
||||
has_programmer=False,
|
||||
has_room_thermostat=True,
|
||||
has_trvs=True,
|
||||
needs_cylinder_jacket=False,
|
||||
needs_cylinder_thermostat=False,
|
||||
)
|
||||
|
||||
# Act
|
||||
cost: Cost = products.tune_up_cost(inputs)
|
||||
|
||||
# Assert — only the programmer is charged (incremental, no double-charge).
|
||||
assert abs(cost.total - 120.0) <= _PIN
|
||||
|
||||
|
||||
def test_tune_up_zoned_prices_a_full_smart_kit_no_per_room_sensor() -> None:
|
||||
# Arrange — a 7-radiator dwelling, zone tune-up, both cylinder fixes.
|
||||
products = Products()
|
||||
inputs = TuneUpCostInputs(
|
||||
is_zoned=True,
|
||||
radiator_count=7,
|
||||
has_programmer=False,
|
||||
has_room_thermostat=False,
|
||||
has_trvs=False,
|
||||
needs_cylinder_jacket=True,
|
||||
needs_cylinder_thermostat=True,
|
||||
)
|
||||
|
||||
# Act
|
||||
cost: Cost = products.tune_up_cost(inputs)
|
||||
|
||||
# Assert — hub 205 + smart TRVs 7x50=350 = 555 (no separate sensor line),
|
||||
# + cylinder 200 = 755, 0.10 contingency. Zone is a full kit regardless of
|
||||
# the existing parts.
|
||||
assert abs(cost.total - 755.0) <= _PIN
|
||||
assert abs(cost.contingency_rate - 0.10) <= _PIN
|
||||
|
||||
|
||||
def test_boiler_bundle_cost_controls_already_adequate() -> None:
|
||||
# Arrange — a like-for-like gas boiler swap whose controls are already
|
||||
# adequate (no controls upgrade), with both cylinder fixes.
|
||||
products = Products()
|
||||
inputs = BoilerCostInputs(
|
||||
upgrades_controls=False,
|
||||
radiator_count=7,
|
||||
has_programmer=True,
|
||||
has_room_thermostat=True,
|
||||
has_trvs=True,
|
||||
needs_cylinder_jacket=True,
|
||||
needs_cylinder_thermostat=True,
|
||||
)
|
||||
|
||||
# Act
|
||||
cost: Cost = products.boiler_bundle_cost(inputs)
|
||||
|
||||
# Assert — boiler 3200 + cylinder 200 = 3400, with the 0.26 boiler
|
||||
# contingency. No controls, no system-change extras.
|
||||
assert abs(cost.total - 3400.0) <= _PIN
|
||||
assert abs(cost.contingency_rate - 0.26) <= _PIN
|
||||
|
||||
|
||||
def test_boiler_bundle_cost_adds_standard_controls_when_upgraded() -> None:
|
||||
# Arrange — a gas boiler swap that also fixes inadequate controls (from
|
||||
# nothing) on a 7-radiator dwelling, no cylinder.
|
||||
products = Products()
|
||||
inputs = BoilerCostInputs(
|
||||
upgrades_controls=True,
|
||||
radiator_count=7,
|
||||
has_programmer=False,
|
||||
has_room_thermostat=False,
|
||||
has_trvs=False,
|
||||
needs_cylinder_jacket=False,
|
||||
needs_cylinder_thermostat=False,
|
||||
)
|
||||
|
||||
# Act
|
||||
cost: Cost = products.boiler_bundle_cost(inputs)
|
||||
|
||||
# Assert — boiler 3200 + standard controls 515 = 3715.
|
||||
assert abs(cost.total - 3715.0) <= _PIN
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue