From bb2c0068ff7817a736e85915d7bd14f0bde7e3ad Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 08:35:52 +0000 Subject: [PATCH] =?UTF-8?q?feat(modelling):=20price=20the=20cavity=20Optio?= =?UTF-8?q?n=20from=20area=20x=20Product=20=E2=80=94=20closes=20#1155?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recommend_cavity_wall now takes a ProductRepository and prices the Measure Option: Cost(total = gross_heat_loss_wall_area(MAIN) x product.unit_cost_per_m2, contingency_rate = product.contingency_rate). Detection is unchanged and runs before pricing, so ineligible walls still return None without a catalogue hit. Completes #1155 — the cavity-wall Recommendation Generator now detects an uninsulated main cavity wall and emits a priced Option carrying the filled- cavity overlay. Four behaviour tests (detection x3 + fully-loaded cost). pyright strict clean. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/wall_recommendation.py | 23 ++++++++++--- .../modelling/test_wall_recommendation.py | 33 +++++++++++++++++-- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/domain/modelling/wall_recommendation.py b/domain/modelling/wall_recommendation.py index 0251a172..6b263ba2 100644 --- a/domain/modelling/wall_recommendation.py +++ b/domain/modelling/wall_recommendation.py @@ -11,8 +11,12 @@ from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, ) -from domain.modelling.recommendation import MeasureOption, Recommendation +from domain.building_geometry import gross_heat_loss_wall_area +from domain.modelling.recommendation import Cost, MeasureOption, Recommendation from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation +from repositories.product.product_repository import ProductRepository + +_CAVITY_MEASURE_TYPE = "cavity_wall_insulation" # RdSAP 10 Table 5 wall_construction: 4 = "Cavity". Table 6 # wall_insulation_type: 4 = "as-built / assumed" (uninsulated), 2 = "Filled @@ -23,9 +27,12 @@ _WALL_UNINSULATED = 4 _FILLED_CAVITY = 2 -def recommend_cavity_wall(epc: EpcPropertyData) -> Optional[Recommendation]: +def recommend_cavity_wall( + epc: EpcPropertyData, products: ProductRepository +) -> Optional[Recommendation]: """Return a cavity-fill Recommendation for the main wall when it is an - uninsulated cavity wall, else None.""" + uninsulated cavity wall, else None. The Option's cost is the heat-loss wall + area priced at the Product's fully-loaded unit cost, with its contingency.""" main = next( part for part in epc.sap_building_parts @@ -38,8 +45,15 @@ def recommend_cavity_wall(epc: EpcPropertyData) -> Optional[Recommendation]: ): return None + product = products.get(_CAVITY_MEASURE_TYPE) + wall_area: float = gross_heat_loss_wall_area(epc, BuildingPartIdentifier.MAIN) + cost = Cost( + total=wall_area * product.unit_cost_per_m2, + contingency_rate=product.contingency_rate, + ) + option = MeasureOption( - measure_type="cavity_wall_insulation", + measure_type=_CAVITY_MEASURE_TYPE, description="Cavity wall insulation (fill the existing cavity)", overlay=EpcSimulation( building_parts={ @@ -48,5 +62,6 @@ def recommend_cavity_wall(epc: EpcPropertyData) -> Optional[Recommendation]: ) } ), + cost=cost, ) return Recommendation(surface="Main wall", options=(option,)) diff --git a/tests/domain/modelling/test_wall_recommendation.py b/tests/domain/modelling/test_wall_recommendation.py index 74162f4e..2f850a8a 100644 --- a/tests/domain/modelling/test_wall_recommendation.py +++ b/tests/domain/modelling/test_wall_recommendation.py @@ -9,13 +9,24 @@ from datatypes.epc.domain.epc_property_data import ( SapBuildingPart, ) from domain.modelling.overlay_applicator import apply_simulations +from domain.modelling.product import Product from domain.modelling.recommendation import Recommendation from domain.modelling.wall_recommendation import recommend_cavity_wall +from repositories.product.product_repository import ProductRepository from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) +class _StubProducts(ProductRepository): + """In-memory ProductRepository returning a fixed cavity Product.""" + + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, unit_cost_per_m2=18.5, contingency_rate=0.10 + ) + + def _part(epc: EpcPropertyData, identifier: BuildingPartIdentifier) -> SapBuildingPart: return next(p for p in epc.sap_building_parts if p.identifier is identifier) @@ -25,7 +36,7 @@ def test_uninsulated_main_cavity_wall_yields_a_cavity_fill_recommendation() -> N baseline: EpcPropertyData = build_epc() # MAIN: cavity (4), uninsulated (4) # Act - recommendation: Recommendation | None = recommend_cavity_wall(baseline) + recommendation: Recommendation | None = recommend_cavity_wall(baseline, _StubProducts()) # Assert assert recommendation is not None @@ -43,12 +54,28 @@ def test_already_insulated_main_wall_yields_no_recommendation() -> None: _part(baseline, BuildingPartIdentifier.MAIN).wall_insulation_type = 2 # filled # Act - recommendation: Recommendation | None = recommend_cavity_wall(baseline) + recommendation: Recommendation | None = recommend_cavity_wall(baseline, _StubProducts()) # Assert assert recommendation is None +def test_cavity_option_carries_fully_loaded_cost_from_area_and_product() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() # MAIN gross heat-loss area 45.93 m^2 + products = _StubProducts() # cavity 18.5 GBP/m^2, contingency 0.10 + + # Act + recommendation: Recommendation | None = recommend_cavity_wall(baseline, products) + + # Assert + assert recommendation is not None + cost = recommendation.options[0].cost + assert cost is not None + assert abs(cost.total - 45.93 * 18.5) <= 0.01 + assert abs(cost.contingency_rate - 0.10) <= 1e-9 + + def test_non_cavity_main_wall_yields_no_recommendation() -> None: # Arrange baseline: EpcPropertyData = build_epc() @@ -56,7 +83,7 @@ def test_non_cavity_main_wall_yields_no_recommendation() -> None: main.wall_construction = 2 # solid (not cavity); still uninsulated # Act - recommendation: Recommendation | None = recommend_cavity_wall(baseline) + recommendation: Recommendation | None = recommend_cavity_wall(baseline, _StubProducts()) # Assert assert recommendation is None