diff --git a/domain/building_geometry.py b/domain/building_geometry.py index f4ba844b..b6e535fe 100644 --- a/domain/building_geometry.py +++ b/domain/building_geometry.py @@ -45,3 +45,18 @@ def roof_area(epc: EpcPropertyData, identifier: BuildingPartIdentifier) -> float return round( max(fd.total_floor_area_m2 for fd in part.sap_floor_dimensions), 2 ) + + +def ground_floor_area( + epc: EpcPropertyData, identifier: BuildingPartIdentifier +) -> float: + """Ground-floor area of one building part, in m^2 — the area of its lowest + floor (``floor == 0``), the surface a ground-floor insulation measure + treats.""" + part = next( + candidate + for candidate in epc.sap_building_parts + if candidate.identifier is identifier + ) + ground = next(fd for fd in part.sap_floor_dimensions if fd.floor == 0) + return round(ground.total_floor_area_m2, 2) diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py index f40c4851..8d0230ff 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -8,6 +8,8 @@ extended as each measure type lands. _CONTINGENCY_RATES: dict[str, float] = { "cavity_wall_insulation": 0.10, "loft_insulation": 0.10, + "suspended_floor_insulation": 0.20, + "solid_floor_insulation": 0.26, } diff --git a/domain/modelling/floor_recommendation.py b/domain/modelling/floor_recommendation.py new file mode 100644 index 00000000..a6992a85 --- /dev/null +++ b/domain/modelling/floor_recommendation.py @@ -0,0 +1,84 @@ +"""The floor Recommendation Generator. + +Detects an uninsulated ground floor and its construction (suspended timber vs +solid) and emits a Recommendation whose single Measure Option carries the +matching insulation Simulation Overlay and a priced Cost. A floor is one +construction, so — like a cavity wall — there is one Option, chosen by +detection. No scoring, no persistence (ADR-0016). +""" + +from typing import Optional, Union + +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, + SapBuildingPart, +) +from domain.building_geometry import ground_floor_area +from domain.modelling.recommendation import Cost, MeasureOption, Recommendation +from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation +from repositories.product.product_repository import ProductRepository + +# Recommended ground-floor insulation depth (mm). +_RECOMMENDED_FLOOR_THICKNESS_MM = 100 + + +def _is_uninsulated(thickness: Optional[Union[str, int]]) -> bool: + """A lodged floor-insulation thickness of nothing / blank / zero is an + uninsulated floor; any positive thickness is already insulated.""" + if thickness is None: + return True + if isinstance(thickness, int): + return thickness == 0 + return thickness.strip() in ("", "0") + + +def _floor_measure_type(construction_type: Optional[str]) -> Optional[str]: + """Map the lodged floor construction to the insulation Measure Type, or + None when the construction is not a treatable suspended/solid floor.""" + text = (construction_type or "").lower() + if "suspended" in text: + return "suspended_floor_insulation" + if "solid" in text: + return "solid_floor_insulation" + return None + + +def recommend_floor_insulation( + epc: EpcPropertyData, products: ProductRepository +) -> Optional[Recommendation]: + """Return a ground-floor insulation Recommendation for the main part's + uninsulated ground floor, else None.""" + main: SapBuildingPart = next( + part + for part in epc.sap_building_parts + if part.identifier is BuildingPartIdentifier.MAIN + ) + + if not _is_uninsulated(main.floor_insulation_thickness): + return None + + measure_type = _floor_measure_type(main.floor_construction_type) + if measure_type is None: + return None + + product = products.get(measure_type) + area: float = ground_floor_area(epc, BuildingPartIdentifier.MAIN) + cost = Cost( + total=area * product.unit_cost_per_m2, + contingency_rate=product.contingency_rate, + ) + + option = MeasureOption( + measure_type=measure_type, + description="Ground-floor insulation", + overlay=EpcSimulation( + building_parts={ + BuildingPartIdentifier.MAIN: BuildingPartOverlay( + floor_insulation_thickness=_RECOMMENDED_FLOOR_THICKNESS_MM + ) + } + ), + cost=cost, + ) + return Recommendation(surface="Ground floor", options=(option,)) diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index 9a3c7e64..5b2ba8a6 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -23,6 +23,7 @@ class BuildingPartOverlay: wall_insulation_type: Optional[int] = None roof_insulation_thickness: Optional[int] = None + floor_insulation_thickness: Optional[int] = None def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]: diff --git a/tests/domain/modelling/test_floor_recommendation.py b/tests/domain/modelling/test_floor_recommendation.py new file mode 100644 index 00000000..8ed2871f --- /dev/null +++ b/tests/domain/modelling/test_floor_recommendation.py @@ -0,0 +1,97 @@ +"""Behaviour of the floor Recommendation Generator: detecting an uninsulated +ground floor and its construction (suspended vs solid), emitting the matching +single insulation Option with overlay + priced Cost. A floor is one +construction, so this is a single-Option Recommendation (like cavity walls). +""" + +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.floor_recommendation import recommend_floor_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=25.0, contingency_rate=0.20 + ) + + +def _main(epc: EpcPropertyData) -> SapBuildingPart: + return next( + p for p in epc.sap_building_parts if p.identifier is BuildingPartIdentifier.MAIN + ) + + +def test_uninsulated_suspended_floor_yields_suspended_insulation() -> None: + # Arrange — 000490 MAIN: "Suspended timber", "As built" (uninsulated) + baseline: EpcPropertyData = build_epc() + + # Act + recommendation: Recommendation | None = recommend_floor_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + assert recommendation.surface == "Ground floor" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "suspended_floor_insulation" + simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay]) + assert _main(simulated).floor_insulation_thickness == 100 + + +def test_uninsulated_solid_floor_yields_solid_insulation() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + _main(baseline).floor_construction_type = "Solid" + + # Act + recommendation: Recommendation | None = recommend_floor_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + assert recommendation.options[0].measure_type == "solid_floor_insulation" + + +def test_already_insulated_floor_yields_no_recommendation() -> None: + # Arrange + baseline: EpcPropertyData = build_epc() + _main(baseline).floor_insulation_thickness = "100" + + # Act + recommendation: Recommendation | None = recommend_floor_insulation( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_floor_option_carries_cost_from_ground_floor_area_and_product() -> None: + # Arrange — 000490 MAIN ground floor area 14.85 m^2 + baseline: EpcPropertyData = build_epc() + + # Act + recommendation: Recommendation | None = recommend_floor_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 * 25.0) <= 0.01 + assert abs(cost.contingency_rate - 0.20) <= 1e-9 diff --git a/tests/domain/test_building_geometry.py b/tests/domain/test_building_geometry.py index 548e6675..403d79bd 100644 --- a/tests/domain/test_building_geometry.py +++ b/tests/domain/test_building_geometry.py @@ -2,7 +2,11 @@ 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, roof_area +from domain.building_geometry import ( + gross_heat_loss_wall_area, + ground_floor_area, + roof_area, +) from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc, ) @@ -33,3 +37,14 @@ def test_roof_area_is_the_parts_greatest_floor_area() -> None: # Assert assert abs(area - 14.85) <= 0.01 + + +def test_ground_floor_area_is_the_lowest_floors_area() -> None: + # Arrange — 000490 MAIN floor 0 total area is 14.85 m^2 + epc = build_epc() + + # Act + area: float = ground_floor_area(epc, BuildingPartIdentifier.MAIN) + + # Assert + assert abs(area - 14.85) <= 0.01