From f06a048a6feb65ce5fae2b129a9be7365cb65cd8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 6 Jun 2026 21:17:06 +0000 Subject: [PATCH] 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 --- .../generators/heating_recommendation.py | 9 ++++--- .../modelling/test_heating_recommendation.py | 27 ++++++++++++++++++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/domain/modelling/generators/heating_recommendation.py b/domain/modelling/generators/heating_recommendation.py index ea2c93b4..c1ec0f3f 100644 --- a/domain/modelling/generators/heating_recommendation.py +++ b/domain/modelling/generators/heating_recommendation.py @@ -17,7 +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.products import AshpCostInputs, AshpExistingSystem, Products from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.simulation import EpcSimulation, HeatingOverlay 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.""" if not _ashp_eligible(epc, restrictions): 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) + cost: Cost = Products().ashp_bundle_cost(ashp_cost_inputs(epc)) return MeasureOption( measure_type=_ASHP_MEASURE_TYPE, description=( @@ -222,9 +225,7 @@ def _ashp_option( "temperature-zone controls and a heat-pump hot-water cylinder" ), overlay=EpcSimulation(heating=_ASHP_OVERLAY), - cost=Cost( - total=product.unit_cost_per_m2, contingency_rate=product.contingency_rate - ), + cost=cost, material_id=product.id, ) diff --git a/tests/domain/modelling/test_heating_recommendation.py b/tests/domain/modelling/test_heating_recommendation.py index 52bf8b48..50344d14 100644 --- a/tests/domain/modelling/test_heating_recommendation.py +++ b/tests/domain/modelling/test_heating_recommendation.py @@ -12,6 +12,9 @@ from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation from domain.modelling.simulation import HeatingOverlay 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 ( 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( main_fuel_type=30, main_heating_control=2210, - main_heating_index_number=101413, + main_heating_index_number=110257, main_heating_category=4, water_heating_code=901, 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: # 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.