feat(modelling): ASHP option carries the composite per-dwelling cost

Slice 9 of ADR-0025 costing. _ashp_option now prices via Products.ashp_bundle_
cost(ashp_cost_inputs(epc)) instead of the flat catalogue scalar; the catalogue
row is still read for its material_id. Pinned on boiler-3: gas reuse dwelling
composes to 15600.60 (decommission 720 + pump 9720 + cylinder 2382.60 + reuse
distribution 2778) with 25% contingency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-06 21:17:06 +00:00
parent 6f136a8d6a
commit f06a048a6f
2 changed files with 31 additions and 5 deletions

View file

@ -17,7 +17,7 @@ from typing import Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData, MainHeatingDetail from datatypes.epc.domain.epc_property_data import EpcPropertyData, MainHeatingDetail
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.products import AshpCostInputs, AshpExistingSystem from domain.modelling.products import AshpCostInputs, AshpExistingSystem, Products
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, HeatingOverlay from domain.modelling.simulation import EpcSimulation, HeatingOverlay
from repositories.product.product_repository import ProductRepository from repositories.product.product_repository import ProductRepository
@ -214,7 +214,10 @@ def _ashp_option(
that is not listed/heritage and not already a heat pump.""" that is not listed/heritage and not already a heat pump."""
if not _ashp_eligible(epc, restrictions): if not _ashp_eligible(epc, restrictions):
return None return None
# Cost is composed per-dwelling from the rate sheet (ADR-0025), not the
# single catalogue scalar; the catalogue row is still read for its id.
product = products.get(_ASHP_MEASURE_TYPE) product = products.get(_ASHP_MEASURE_TYPE)
cost: Cost = Products().ashp_bundle_cost(ashp_cost_inputs(epc))
return MeasureOption( return MeasureOption(
measure_type=_ASHP_MEASURE_TYPE, measure_type=_ASHP_MEASURE_TYPE,
description=( description=(
@ -222,9 +225,7 @@ def _ashp_option(
"temperature-zone controls and a heat-pump hot-water cylinder" "temperature-zone controls and a heat-pump hot-water cylinder"
), ),
overlay=EpcSimulation(heating=_ASHP_OVERLAY), overlay=EpcSimulation(heating=_ASHP_OVERLAY),
cost=Cost( cost=cost,
total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate
),
material_id=product.id, material_id=product.id,
) )

View file

@ -12,6 +12,9 @@ from domain.modelling.product import Product
from domain.modelling.recommendation import Recommendation from domain.modelling.recommendation import Recommendation
from domain.modelling.simulation import HeatingOverlay from domain.modelling.simulation import HeatingOverlay
from repositories.product.product_repository import ProductRepository from repositories.product.product_repository import ProductRepository
from tests.domain.modelling._elmhurst_recommendation import (
parse_recommendation_summary,
)
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc, build_epc,
) )
@ -152,7 +155,7 @@ def test_gas_boiler_house_yields_an_ashp_bundle() -> None:
assert options["air_source_heat_pump"].overlay.heating == HeatingOverlay( assert options["air_source_heat_pump"].overlay.heating == HeatingOverlay(
main_fuel_type=30, main_fuel_type=30,
main_heating_control=2210, main_heating_control=2210,
main_heating_index_number=101413, main_heating_index_number=110257,
main_heating_category=4, main_heating_category=4,
water_heating_code=901, water_heating_code=901,
water_heating_fuel=30, water_heating_fuel=30,
@ -166,6 +169,28 @@ def test_gas_boiler_house_yields_an_ashp_bundle() -> None:
) )
def test_ashp_bundle_carries_the_composite_per_dwelling_cost() -> None:
# Arrange — a mains-gas regular boiler with a cylinder (90 m2, 7 habitable
# rooms): the ASHP reuses the existing wet system (ADR-0025).
epc: EpcPropertyData = parse_recommendation_summary(
"ashp_from_system_boiler_with_cylinder_001431_before.pdf"
)
# Act
recommendation: Recommendation | None = recommend_heating(epc, _StubProducts())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "air_source_heat_pump"
)
# Assert — composite: decommission (gas) 720 + pump (4.5 kW band) 9720 +
# cylinder 2382.60 + reuse distribution (flush 168 + 0.5 x dist(10) 5220 =
# 2778) = 15600.60, with the separate 25% ASHP contingency.
assert option.cost is not None
assert abs(option.cost.total - 15600.60) <= 1e-9
assert abs(option.cost.contingency_rate - 0.25) <= 1e-9
def test_listed_building_yields_no_ashp_bundle() -> None: def test_listed_building_yields_no_ashp_bundle() -> None:
# Arrange — a listed building protects the fabric; an external ASHP unit is # Arrange — a listed building protects the fabric; an external ASHP unit is
# not auto-offered (ADR-0024). The dwelling is on gas, so HHRSH is also out. # not auto-offered (ADR-0024). The dwelling is on gas, so HHRSH is also out.