diff --git a/domain/building_geometry.py b/domain/building_geometry.py index a76e0468..f4ba844b 100644 --- a/domain/building_geometry.py +++ b/domain/building_geometry.py @@ -31,3 +31,17 @@ def gross_heat_loss_wall_area( for fd in part.sap_floor_dimensions ) return round(area, 2) + + +def roof_area(epc: EpcPropertyData, identifier: BuildingPartIdentifier) -> float: + """Roof area of one building part, in m^2. Per RdSAP10 §3.8 the roof area is + the greatest of the part's per-storey floor areas (not the top-floor area, + which can be smaller).""" + part = next( + candidate + for candidate in epc.sap_building_parts + if candidate.identifier is identifier + ) + return round( + max(fd.total_floor_area_m2 for fd in part.sap_floor_dimensions), 2 + ) diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py index f036b786..f40c4851 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -7,6 +7,7 @@ extended as each measure type lands. _CONTINGENCY_RATES: dict[str, float] = { "cavity_wall_insulation": 0.10, + "loft_insulation": 0.10, } diff --git a/domain/modelling/roof_recommendation.py b/domain/modelling/roof_recommendation.py new file mode 100644 index 00000000..76c60241 --- /dev/null +++ b/domain/modelling/roof_recommendation.py @@ -0,0 +1,61 @@ +"""The roof Recommendation Generator. + +Detects an uninsulated loft on an EpcPropertyData and emits a Recommendation +whose Measure Option carries the loft-insulation Simulation Overlay and a priced +Cost (roof area x the Product's fully-loaded unit cost, with its contingency). +No scoring, no persistence — impact is produced later by scoring (ADR-0016). +""" + +from typing import Optional + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, +) +from domain.building_geometry import roof_area +from domain.modelling.recommendation import Cost, MeasureOption, Recommendation +from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation +from repositories.product.product_repository import ProductRepository + +_LOFT_MEASURE_TYPE = "loft_insulation" +# RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft. +_ROOF_UNINSULATED_MM = 0 +# Recommended loft-insulation depth (mm) — the building-regs standard top-up. +_RECOMMENDED_LOFT_THICKNESS_MM = 270 + + +def recommend_loft_insulation( + epc: EpcPropertyData, products: ProductRepository +) -> Optional[Recommendation]: + """Return a loft-insulation Recommendation for the main roof when it is + uninsulated, else None. The Option's cost is the roof area priced at the + Product's fully-loaded unit cost, with its contingency.""" + main = next( + part + for part in epc.sap_building_parts + if part.identifier is BuildingPartIdentifier.MAIN + ) + + if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM: + return None + + product = products.get(_LOFT_MEASURE_TYPE) + area: float = roof_area(epc, BuildingPartIdentifier.MAIN) + cost = Cost( + total=area * product.unit_cost_per_m2, + contingency_rate=product.contingency_rate, + ) + + option = MeasureOption( + measure_type=_LOFT_MEASURE_TYPE, + description="Loft insulation (top up to recommended depth)", + overlay=EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay( + roof_insulation_thickness=_RECOMMENDED_LOFT_THICKNESS_MM + ) + } + ), + cost=cost, + ) + return Recommendation(surface="Roof", options=(option,)) diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index a5f36dfa..9a3c7e64 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -22,6 +22,7 @@ class BuildingPartOverlay: """ wall_insulation_type: Optional[int] = None + roof_insulation_thickness: Optional[int] = None def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]: diff --git a/tests/domain/modelling/test_roof_recommendation.py b/tests/domain/modelling/test_roof_recommendation.py new file mode 100644 index 00000000..8acfc5c6 --- /dev/null +++ b/tests/domain/modelling/test_roof_recommendation.py @@ -0,0 +1,81 @@ +"""Behaviour of the roof Recommendation Generator: detecting an uninsulated +loft and emitting a Recommendation whose Measure Option carries the loft- +insulation Simulation Overlay and a priced Cost. Mirrors the wall generator. +""" + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, + SapBuildingPart, +) +from domain.modelling.overlay_applicator import apply_simulations +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.roof_recommendation import recommend_loft_insulation +from repositories.product.product_repository import ProductRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + + +class _StubProducts(ProductRepository): + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, unit_cost_per_m2=30.0, 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) + + +def test_uninsulated_loft_yields_a_loft_insulation_recommendation() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + _part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 0 + + # Act + recommendation: Recommendation | None = recommend_loft_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + assert recommendation.surface == "Roof" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "loft_insulation" + simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay]) + assert _part(simulated, BuildingPartIdentifier.MAIN).roof_insulation_thickness == 270 + + +def test_already_insulated_loft_yields_no_recommendation() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() # MAIN roof already 300 mm + _part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 300 + + # Act + recommendation: Recommendation | None = recommend_loft_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_loft_option_carries_cost_from_roof_area_and_product() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() # MAIN roof area 14.85 m^2 + _part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 0 + + # Act + recommendation: Recommendation | None = recommend_loft_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + cost = recommendation.options[0].cost + assert cost is not None + assert abs(cost.total - 14.85 * 30.0) <= 0.01 + assert abs(cost.contingency_rate - 0.10) <= 1e-9 diff --git a/tests/domain/test_building_geometry.py b/tests/domain/test_building_geometry.py index 816e334d..548e6675 100644 --- a/tests/domain/test_building_geometry.py +++ b/tests/domain/test_building_geometry.py @@ -2,7 +2,7 @@ reusable outside the SAP calculator (e.g. for Modelling cost quantities).""" from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier -from domain.building_geometry import gross_heat_loss_wall_area +from domain.building_geometry import gross_heat_loss_wall_area, roof_area from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) @@ -20,3 +20,16 @@ def test_gross_heat_loss_wall_area_sums_perimeter_times_height_per_storey() -> N # Assert assert abs(area - 45.93) <= 0.01 + + +def test_roof_area_is_the_parts_greatest_floor_area() -> None: + # Arrange + # RdSAP10 §3.8: roof area is the greatest of the floor areas on each + # level. 000490 MAIN has two floors of 14.85 m^2, so the roof is 14.85. + epc = build_epc() + + # Act + area: float = roof_area(epc, BuildingPartIdentifier.MAIN) + + # Assert + assert abs(area - 14.85) <= 0.01