mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(modelling): ashp_cost_inputs reads a dwelling into AshpCostInputs
Slice 7 of ADR-0025 costing: the modelling-layer interpretation half of the split. ashp_cost_inputs derives existing system (mains_gas/fuel/SAP-code), size band (floor area <= 75 m2), design heat loss (floor_area x 0.05 -- the chosen proxy over HLC, ADR updated), radiator count (habitable + 3, floor-area fallback) and reusable-wet-system flag. Catalogue math (Products) stays EPC-free. ADR-0025 updated to record the floor-area pump-sizing choice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c7acf43b52
commit
f182f36802
3 changed files with 181 additions and 8 deletions
46
docs/adr/0025-ashp-bundle-costing.md
Normal file
46
docs/adr/0025-ashp-bundle-costing.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# ASHP Bundle Costing — Composite, Dwelling-Sized
|
||||
|
||||
The air-source-heat-pump bundle (ADR-0024) is the first Measure Option whose cost is **not** a single catalogue scalar. A real ASHP retrofit ranges ~£12k–£21k depending on the existing system, pump size, and — dominantly — whether a wet distribution system already exists. The Optimiser picks least-cost-to-band, so a flat number systematically mis-ranks ASHP. We therefore **compose the cost per dwelling** from the real Southern Housing Group rate sheet (the `HEAT PUMPS` tab, line items ECOHT01–68), interpreting the dwelling to select and sum the applicable priced lines.
|
||||
|
||||
## Decision
|
||||
|
||||
**The ASHP bundle cost is a composite, computed per dwelling at generation time:**
|
||||
|
||||
```
|
||||
decommission(existing system, property size) # by system type × beds band
|
||||
+ heat_pump(kW size band) # sized from heat loss
|
||||
+ cylinder (fixed £2,382.60) # one cylinder per install
|
||||
+ distribution: # the dominant lever
|
||||
no reusable wet system → full band(n_radiators)
|
||||
reusable wet system → £168 flush + 0.5 × band(n_radiators)
|
||||
→ Cost(total = sum, contingency_rate = 0.25)
|
||||
```
|
||||
|
||||
**`Products` owns the catalogue math; the modelling layer owns the dwelling interpretation.** A new rich domain collection over `Product` — `Products` — exposes per-measure cost methods (`ashp_bundle_cost`) that filter the relevant priced rows and sum them from a small typed `AshpCostInputs`. It stays free of `EpcPropertyData` and the `Sap10Calculator`. The dwelling→inputs interpretation (sizing, proxies, reuse detection) lives in modelling, which may depend on the calculator. `ProductRepository` remains the IO port; `Products` is the behaviour it yields. `Product` stays cost-only (ADR-0024 unchanged); the ECOHT rate lines land in the committed costs file as structured rows (category + qualifier), faithful to the rate sheet and the future materials table.
|
||||
|
||||
**Dwelling interpretation rules (each a documented proxy, not a survey):**
|
||||
|
||||
- **Pump size** = `floor_area × 0.05 kW/m²` (design heat loss in kW), rounded **up** to the next install band {5, 8, 11, 15, 16} kW. The more accurate `avg(HLC) × 24.2 K / 1000` is **deferred**: the generators run before the `PackageScorer`, so no `SapResult` exists at cost time, and threading one through every generator's call site is real wiring complexity for a *minor* lever (the whole 5→16 kW band spans only ~£1.7k). The floor-area proxy lands the same band for most dwellings and keeps the cost interpreter free of the calculator. HLC sizing is the natural upgrade when the SAP product itself becomes dwelling-aware (which already runs the calculator). The cost product is sized to the dwelling **even though the SAP product is fixed at the Vaillant aroTHERM plus 5 kW** — a deliberate, documented inconsistency (see Consequences).
|
||||
- **Existing system** from `main_fuel_type` + `sap_main_heating_code`: gas/oil/LPG/electric-storage map to their decommission lines; **no system → £0**; electric room/panel heaters → electric-storage line; anything else → gas line (£720) as a representative default — **never a strict-raise**, because raising would wrongly block ASHP eligibility for a real dwelling.
|
||||
- **Property-size band** (1–2 vs 3–4 bed, which only changes the electric-storage decommission line, a £270 swing): **floor area ≤ 75 m²** ⇒ 1–2 bed. Floor area is always present, unlike `habitable_rooms_count`.
|
||||
- **Reusable wet system** = an existing gas/oil/LPG boiler with radiator emitters. With one, the ASHP reuses the pipework but a meaningful subset of radiators is upsized for the lower flow temperature (MCS-007, ≤ 55 °C) — so reuse is **not** free: `£168 flush + 0.5 × full distribution`. Without one (electric/none/warm-air), a **full** new wet distribution is priced.
|
||||
- **Radiator count** = `clamp(habitable_rooms_count + 3, 4, 12)` (RdSAP excludes kitchen/hall/bathroom from habitable rooms); fallback `floor_area ÷ 13 m²`.
|
||||
- **Cylinder** = a single fixed line (£2,382.60); the cylinder-size spread on the sheet is £188 (noise).
|
||||
- **Extras** (ECOHT53–68: socket relocation, trenching, heat meter, Hive, …) are **excluded** from the base composite — unpredictable from EPC data and exactly what **Contingency** absorbs. Controls are already inside the pump line.
|
||||
|
||||
## Considered options
|
||||
|
||||
- **Single fixed representative cost (the loft/glazing pattern).** Rejected: those costs are genuinely ~uniform; ASHP's varies ~£12k–£21k per dwelling and the variance changes Optimiser selection. A flat number would systematically over- or under-recommend ASHP.
|
||||
- **Extend `Product` with a cost formula / line items.** Rejected: the `Product`-bloat ADR-0024 already declined; a heat pump's cost composition is not a catalogue scalar.
|
||||
- **Inline the composition in `recommend_heating`.** Rejected: pulls sizing/proxy/reuse logic into the generator, which is meant to be thin detection + bundle assembly; hard to test in isolation.
|
||||
- **`Products.ashp_bundle_cost(epc)` doing sizing too.** Rejected: inverts the layering (the priced catalogue depending on the SAP calculator and EPC). The catalogue math is kept pure and table-driven; dwelling physics stays in modelling.
|
||||
- **Pricing reuse as a £168 power-flush only.** Rejected after research: MCS reality is that existing radiators emit ~42% at 45 °C flow and a subset gets upsized, so flush-only is unrealistically optimistic.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A new `Products` collection + `AshpCostInputs` + an `ashp_bundle_cost` method; the ASHP rate lines added to the committed costs file as structured rows. `Product`, `recommend_heating`'s detection, and the other five generators are unchanged (they keep `.get` + unit×area; `Products` grows methods per measure later — no speculative six-way refactor).
|
||||
- **SAP/cost product-size mismatch:** the cost is sized to the dwelling while the SAP simulation uses the fixed 5 kW Vaillant. Bounded and documented — the pump-band cost spread is only ~£1.7k across 5→16 kW, and the size estimate built here is exactly what a future dwelling-aware SAP product (picking the Vaillant model in the right band for *both* SAP and cost) would reuse. That reconciliation is deferred, not designed away.
|
||||
- **The 0.5 reuse-distribution fraction is the headline uncertainty** — a single named constant (`_REUSE_DISTRIBUTION_FRACTION`) to recalibrate when real reuse-job costs or survey data arrive.
|
||||
- Realistic costs (~£15k–£21k vs the £12k placeholder) make ASHP win **less** often; this is why the held product-swap and its 5 broken integration tests land together with this costing, against stable Optimiser behaviour rather than churning twice.
|
||||
- Brand is **cost-neutral** (Daikin/Mitsubishi/Vaillant/Samsung/Grant priced identically), so the Vaillant SAP choice carries no cost penalty.
|
||||
- All proxies (pump size, beds, radiator count, reuse fraction) are whole-dwelling estimates standing in for a survey; each is documented at its call site as such.
|
||||
|
|
@ -17,6 +17,7 @@ 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
|
||||
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
|
||||
from domain.modelling.simulation import EpcSimulation, HeatingOverlay
|
||||
from repositories.product.product_repository import ProductRepository
|
||||
|
|
@ -54,17 +55,44 @@ _HHR_STORAGE_OVERLAY = HeatingOverlay(
|
|||
meter_type="Dual",
|
||||
)
|
||||
|
||||
# The ASHP bundle's absolute end-state (ADR-0024): a fixed, representative,
|
||||
# contractor-installable heat pump (PCDB index 101413, RdSAP category 4) with
|
||||
# time-and-temperature-zone control (2210), a heat-pump hot-water cylinder, a
|
||||
# single (non off-peak) meter, and the dwelling switched off mains gas. The
|
||||
# index is the efficiency anchor — the applicator clears any stale
|
||||
# `sap_main_heating_code` when an index is set, so the calculator resolves the
|
||||
# heat pump's SCOP from the PCDB record. Pinned against the relodged after-cert.
|
||||
# Representative heat-pump products Domna installs (one per brand we hold
|
||||
# contractor install rates for), as PCDB Table 362 indices — the catalogue we
|
||||
# may simulate the ASHP bundle with. Each is a valid, currently-available,
|
||||
# ~5 kW air-to-water unit providing space + water heating, chosen for high SAP
|
||||
# 10.2 Appendix N efficiency (space η at the dwelling's PSR, with a healthy
|
||||
# water η — many high-space records collapse on water and were rejected):
|
||||
#
|
||||
# Vaillant 110257 aroTHERM plus 5 kW space ~402% / water ~288%
|
||||
# Mitsubishi 104570 Ecodan PUZ-WM50VHA 5.0 kW space ~368% / water ~288%
|
||||
# Daikin 105008 Altherma ERGA04DVA 5.5 kW space ~376% / water ~288%
|
||||
# Samsung 108774 AE050CXYDEK 5 kW (R290) space ~394% / water ~309%
|
||||
# Grant 103768 AERONA3 HPID6R32 4.8 kW space ~395% / water ~332%
|
||||
#
|
||||
# We fix the Vaillant for the tracer: it is widely available for install and a
|
||||
# strong all-round SAP performer. (Promoting this to a per-dwelling choice is a
|
||||
# clean future change — see the sizing note below.)
|
||||
_VAILLANT_AROTHERM_PLUS_5KW_PCDB = 110257
|
||||
|
||||
# NOTE (sizing): the bundle installs ONE fixed ~5 kW product regardless of the
|
||||
# dwelling. SAP 10.2 Appendix N reads heat-pump efficiency at the dwelling's PSR
|
||||
# (= pump max output / design heat loss), so a fixed output is a deliberate
|
||||
# simplification: a 5 kW unit lands at a good PSR (~0.8-1.0) for modest
|
||||
# dwellings but is undersized for high-heat-loss ones (low PSR → lower space
|
||||
# efficiency), leaving SAP on the table. Sizing the pump to the dwelling (and
|
||||
# selecting the matching PCDB record) is future work — it also feeds the
|
||||
# size-banded ASHP costing.
|
||||
|
||||
# The ASHP bundle's absolute end-state (ADR-0024): the fixed, representative,
|
||||
# contractor-installable heat pump above (RdSAP category 4) with time-and-
|
||||
# temperature-zone control (2210), a heat-pump hot-water cylinder, a single
|
||||
# (non off-peak) meter, and the dwelling switched off mains gas. The index is
|
||||
# the efficiency anchor — the applicator clears any stale `sap_main_heating_code`
|
||||
# when an index is set, so the calculator resolves the heat pump's SCOP from the
|
||||
# PCDB record. Pinned against the relodged after-cert.
|
||||
_ASHP_OVERLAY = HeatingOverlay(
|
||||
main_fuel_type=_ELECTRICITY_FUEL,
|
||||
main_heating_control=2210,
|
||||
main_heating_index_number=101413,
|
||||
main_heating_index_number=_VAILLANT_AROTHERM_PLUS_5KW_PCDB,
|
||||
main_heating_category=_HEAT_PUMP_CATEGORY,
|
||||
# Hot water from the main heat-pump system via the new cylinder (code 901,
|
||||
# "from main system"). Set absolutely so a combi (909/611) or electric
|
||||
|
|
@ -82,6 +110,74 @@ _ASHP_OVERLAY = HeatingOverlay(
|
|||
)
|
||||
|
||||
|
||||
# --- ASHP cost interpretation (ADR-0025): read the dwelling into the typed
|
||||
# inputs the catalogue math needs. The modelling-layer half of the split; the
|
||||
# pricing itself lives on `Products`. ---
|
||||
|
||||
# A dwelling at or below this floor area is treated as a 1-2 bed property (only
|
||||
# affects the electric-storage decommission line — a £270 swing).
|
||||
_SMALL_PROPERTY_MAX_M2 = 75.0
|
||||
# Design heat loss proxy: industry rule of thumb ~50 W per m2 of floor area.
|
||||
# The cost pump-size band is a minor lever, so this floor-area proxy is used in
|
||||
# preference to the calculator's HLC (ADR-0025).
|
||||
_KW_PER_M2 = 0.05
|
||||
# Radiators ~= habitable rooms + kitchen + hall + bathroom (RdSAP excludes the
|
||||
# latter three from habitable rooms); fallback ~1 radiator per 13 m2.
|
||||
_RADIATOR_ROOM_OFFSET = 3
|
||||
_RADIATOR_M2_PER_RADIATOR = 13.0
|
||||
# SAP main-heating code lodged when a dwelling has no heating system.
|
||||
_NO_SYSTEM_SAP_CODE = 999
|
||||
# main_fuel_type codes (gov API enum and/or Table 12) for off-gas wet fuels.
|
||||
_OIL_FUEL_CODES = frozenset({28, 4, 71, 73, 75, 76})
|
||||
_LPG_FUEL_CODES = frozenset({27, 2, 3, 5, 9})
|
||||
|
||||
|
||||
def ashp_cost_inputs(epc: EpcPropertyData) -> AshpCostInputs:
|
||||
"""Read an `EpcPropertyData` into the typed inputs `Products.ashp_bundle_cost`
|
||||
needs: the existing system, property-size band, design heat loss (floor-area
|
||||
proxy), radiator count, and whether a wet system can be reused (ADR-0025)."""
|
||||
system: AshpExistingSystem = _existing_system(epc)
|
||||
floor_area: float = epc.total_floor_area_m2
|
||||
return AshpCostInputs(
|
||||
existing_system=system,
|
||||
is_small_property=floor_area <= _SMALL_PROPERTY_MAX_M2,
|
||||
design_heat_loss_kw=floor_area * _KW_PER_M2,
|
||||
radiator_count=_radiator_count(epc),
|
||||
has_reusable_wet_system=system
|
||||
in (AshpExistingSystem.GAS, AshpExistingSystem.OIL, AshpExistingSystem.LPG),
|
||||
)
|
||||
|
||||
|
||||
def _existing_system(epc: EpcPropertyData) -> AshpExistingSystem:
|
||||
"""Classify the dwelling's pre-retrofit system for decommission + reuse.
|
||||
Mains gas is the most reliable signal (`mains_gas`); electricity keys on the
|
||||
fuel code; oil/LPG on their fuel codes; an absent system on SAP code 999.
|
||||
The storage-vs-other-electric split is deliberately not made — both price
|
||||
the same decommission line (ADR-0025)."""
|
||||
main: MainHeatingDetail = epc.sap_heating.main_heating_details[0]
|
||||
if main.sap_main_heating_code == _NO_SYSTEM_SAP_CODE:
|
||||
return AshpExistingSystem.NONE
|
||||
if epc.sap_energy_source.mains_gas:
|
||||
return AshpExistingSystem.GAS
|
||||
if main.main_fuel_type == _ELECTRICITY_FUEL:
|
||||
return AshpExistingSystem.ELECTRIC_STORAGE
|
||||
if main.main_fuel_type in _OIL_FUEL_CODES:
|
||||
return AshpExistingSystem.OIL
|
||||
if main.main_fuel_type in _LPG_FUEL_CODES:
|
||||
return AshpExistingSystem.LPG
|
||||
return AshpExistingSystem.OTHER
|
||||
|
||||
|
||||
def _radiator_count(epc: EpcPropertyData) -> int:
|
||||
"""Estimate radiators from habitable rooms (+ kitchen/hall/bathroom), or
|
||||
from floor area when the room count is missing (ADR-0025). Products clamps
|
||||
to its distribution table bounds."""
|
||||
habitable: int = epc.habitable_rooms_count
|
||||
if habitable > 0:
|
||||
return habitable + _RADIATOR_ROOM_OFFSET
|
||||
return round(epc.total_floor_area_m2 / _RADIATOR_M2_PER_RADIATOR)
|
||||
|
||||
|
||||
def recommend_heating(
|
||||
epc: EpcPropertyData,
|
||||
products: ProductRepository,
|
||||
|
|
|
|||
31
tests/domain/modelling/test_ashp_cost_inputs.py
Normal file
31
tests/domain/modelling/test_ashp_cost_inputs.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""The dwelling interpretation that feeds `Products.ashp_bundle_cost` — reading
|
||||
an `EpcPropertyData` into a typed `AshpCostInputs` (ADR-0025). This is the
|
||||
modelling-layer half of the split: it derives the existing system, property
|
||||
size band, design heat loss (floor-area proxy), radiator count, and whether a
|
||||
wet system can be reused — the catalogue math (Products) stays EPC-free.
|
||||
"""
|
||||
|
||||
from domain.modelling.generators.heating_recommendation import ashp_cost_inputs
|
||||
from domain.modelling.products import AshpCostInputs, AshpExistingSystem
|
||||
from tests.domain.modelling._elmhurst_recommendation import (
|
||||
parse_recommendation_summary,
|
||||
)
|
||||
|
||||
|
||||
def test_mains_gas_dwelling_maps_to_a_reusable_wet_gas_system() -> None:
|
||||
# Arrange — a mains-gas regular boiler with radiators (90 m2, 7 habitable
|
||||
# rooms): an existing wet system the ASHP can reuse.
|
||||
epc = parse_recommendation_summary(
|
||||
"ashp_from_system_boiler_with_cylinder_001431_before.pdf"
|
||||
)
|
||||
|
||||
# Act
|
||||
inputs: AshpCostInputs = ashp_cost_inputs(epc)
|
||||
|
||||
# Assert — gas, large (90 > 75 m2), 4.5 kW (90 x 0.05), 10 radiators
|
||||
# (7 habitable + 3), reusable wet system.
|
||||
assert inputs.existing_system is AshpExistingSystem.GAS
|
||||
assert inputs.is_small_property is False
|
||||
assert abs(inputs.design_heat_loss_kw - 4.5) <= 1e-9
|
||||
assert inputs.radiator_count == 10
|
||||
assert inputs.has_reusable_wet_system is True
|
||||
Loading…
Add table
Reference in a new issue