From 4ae439ddd31f279e23642290d7cd038640797834 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 22 Jan 2026 09:45:32 +0000 Subject: [PATCH] fixed costs tests --- recommendations/Costs.py | 70 +++++++++--- recommendations/tests/test_costs.py | 168 ++++++++-------------------- 2 files changed, 103 insertions(+), 135 deletions(-) diff --git a/recommendations/Costs.py b/recommendations/Costs.py index 86062433..60b1d8a2 100644 --- a/recommendations/Costs.py +++ b/recommendations/Costs.py @@ -308,12 +308,64 @@ class Costs: return { "total": total_cost, - "contengency": self.CONTINGENCIES["suspended_floor_insulation"] * total_cost, + "contingency": self.CONTINGENCIES["suspended_floor_insulation"] * total_cost, "contingency_rate": self.CONTINGENCIES["suspended_floor_insulation"], "labour_hours": labour_hours, "labour_days": labour_days, } + @staticmethod + def _estimate_number_of_days_for_solid_floor(insulation_floor_area: float) -> float: + """ + Estimate the number of labour days required to install solid floor insulation, + based on the floor area being treated. + + This is a heuristic (rule-of-thumb) estimate designed for early-stage planning + and costing. It deliberately avoids strict linear scaling because real-world + construction work includes fixed overheads and efficiency gains. + + Core assumptions: + - A typical solid floor insulation job covering ~45 m2 takes around 7 working days. + This is based on market guidance (e.g. Checkatrade). + - Very small jobs still require multiple days due to setup, preparation, + curing/drying time, and inspections — even if the area is small. + - Larger jobs take longer, but each additional square metre adds slightly less + time than the previous one, because crews become more efficient once work + is underway. + + The estimate therefore: + - Scales with floor area + - Applies a minimum realistic duration + - Uses non-linear scaling to reflect economies of scale + + :param insulation_floor_area: float - total floor area to be insulated + """ + # Reference case: + # A "typical" job (about half of a 90 m² house) takes ~7 days to complete + base_days = 7 + base_area = 45 # m2 of solid floor insulated in the reference case + + # Exponent < 1 means sub-linear scaling: + # doubling the area does NOT double the time, because setup costs + # and learning effects reduce the marginal effort per extra m² + labour_exponent = 0.85 + + # Minimum number of days for any solid floor job. + # Even small areas require mobilisation, preparation, installation, + # and finishing time, so jobs realistically won't complete faster than this. + min_days = 3 + + # Calculate estimated labour days: + # - Scale relative to the reference job + # - Apply sub-linear scaling for realism + # - Enforce a minimum duration so estimates are not unrealistically low + labour_days = max( + min_days, + base_days * (insulation_floor_area / base_area) ** labour_exponent + ) + + return labour_days + def solid_floor_insulation(self, insulation_floor_area, material): """ based on costing data from installers, produces an estimate for the cost of works. Returns contingency @@ -324,21 +376,9 @@ class Costs: """ total_cost = material["total_cost"] * insulation_floor_area - - # We assume the average house takes ~7 days to complete at £300/day incl. VAT, as per checkatrade - # which can be seen here: https://www.checkatrade.com/blog/cost-guides/floor-insulation-cost - # Assumptions - base_days = 7 # The quickest it will be completed - base_area = 45 # The area that can be completed in that time (for a typical 90m2 house) - labour_exponent = 0.85 # Non-linear scaling daily_labour_rate = 300 # Based on checkatrade - min_days = 3 # Fewest days it will take - labour_days = max( - min_days, - base_days * (insulation_floor_area / base_area) ** labour_exponent - ) - + labour_days = self._estimate_number_of_days_for_solid_floor(insulation_floor_area) labour_cost = labour_days * daily_labour_rate total_cost = total_cost + labour_cost @@ -460,7 +500,7 @@ class Costs: # We estimate the cost of an appliance thermostat at £400, which is the upper end of the range return { "total": total_cost, - "contengency": total_cost * self.CONTINGENCY, + "contingency": total_cost * self.CONTINGENCY, "contingency_rate": self.CONTINGENCY, "subtotal": subtotal_before_vat, "vat": vat, diff --git a/recommendations/tests/test_costs.py b/recommendations/tests/test_costs.py index 4b8d74db..752caf8c 100644 --- a/recommendations/tests/test_costs.py +++ b/recommendations/tests/test_costs.py @@ -27,7 +27,8 @@ class TestCosts: material=cwi_material, ) - assert cwi_results == {'total': 1342.7459938871539, 'labour_hours': 8, 'labour_days': 1} + assert cwi_results["total"] == 1342.7459938871539 + assert cwi_results["contingency"] == 134.2745993887154 def test_loft_insulation(self): mock_property = Mock() @@ -37,6 +38,7 @@ class TestCosts: costs = Costs(mock_property) loft_material = { + "type": "loft_insulation", "description": "Crown Loft Roll 44 glass fibre roll", "depth": 270, "thermal_conductivity": 0.044, @@ -50,7 +52,8 @@ class TestCosts: material=loft_material, ) - assert loft_results == {'total': 368.5, 'labour_hours': 8, 'labour_days': 1} + assert loft_results["total"] == 368.5 + assert loft_results["contingency"] == 36.85 def test_internal_wall_insulation(self): mock_property = Mock() @@ -79,9 +82,8 @@ class TestCosts: material=iwi_material, ) - assert iwi_results == { - 'total': 19182.085626959342, 'labour_hours': 17.263877064263404, 'labour_days': 0.5394961582582314 - } + assert iwi_results["total"] == 19182.085626959342 + assert iwi_results["contingency"] == 4987.342263009429 def test_suspended_floor_insulation(self): mock_property = Mock() @@ -98,53 +100,38 @@ class TestCosts: 'thermal_conductivity_unit': 'watt_per_meter_kelvin', 'prime_material_cost': 0, 'material_cost': 11.68, 'labour_cost': 1.78, 'labour_hours_per_unit': 0.1, 'plant_cost': 0, - 'total_cost': 13.46, 'link': 'SPONs', - 'Notes': 'Spons did not contain labour costs so we use values for similar insulations. ' - 'We use the ' - 'same values as in Crown loft roll 44, since it is also an insulation roll', + 'total_cost': 75, "is_installer_quote": False } - sus_floor_non_insulation_materials = [ - {'type': 'suspended_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11, - 'plant_cost': 0, 'total_cost': 3.32, 'link': 'SPONs', - 'Notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and ' - 'therefore there is no need for a skip'}, - {'type': 'suspended_floor_demolition', - 'description': 'Remove boarding; withdraw nails; set aside for reuse; ground level', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 9.34, 'labour_hours_per_unit': 0.3, - 'plant_cost': 0, 'total_cost': 9.34, 'link': 'SPONs', 'Notes': 0}, - {'type': 'suspended_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', - 'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, - 'thermal_conductivity_unit': 0, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, - 'labour_hours_per_unit': 0.02, 'plant_cost': 0, 'total_cost': 1.69, 'link': 'SPONs', 'Notes': 0}, - {'type': 'suspended_floor_redecoration', 'description': 'refix floorboards previously set aside', - 'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, - 'thermal_conductivity_unit': 0, 'prime_material_cost': 0, 'material_cost': 1.54, 'labour_cost': 24.98, - 'labour_hours_per_unit': 0.74, 'plant_cost': 0, 'total_cost': 26.52, 'link': 'SPONs', 'Notes': 0}, - {'type': 'suspended_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0, 'depth_unit': 0, - 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37, - 'plant_cost': 0, 'total_cost': 6.59, 'link': 'SPONs', - 'Notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; ' - 'Gradus woven polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, ' - 'therefore we need just labour rates'}] - sus_floor_results = costs.suspended_floor_insulation( insulation_floor_area=33.5, material=sus_floor_material, - non_insulation_materials=sus_floor_non_insulation_materials ) - assert sus_floor_results == { - 'total': 3337.07436, 'subtotal': 2780.8953, 'vat': 556.17906, 'contingency': 370.78604, - 'preliminaries': 185.39302, 'material': 483.405, 'profit': 370.78604, 'labour_hours': 54.940000000000005, - 'labour_days': 2.289166666666667, 'labour_cost': 1370.5252 + assert sus_floor_results["total"] == 2512.5 + assert sus_floor_results["contingency"] == 502.5 + + @pytest.mark.parametrize("insulation_floor_area, expected_result", [ + (5, 3), + (33.5, 5.446976345666071), + (45, 7), + (70, 10.190623048464415), + (90, 12.617506476551622), + (150, 19.47802744828744), + (200, 24.873843619763473), + ]) + def test_estimate_estimate_number_of_days_for_solid_floor( + self, insulation_floor_area, expected_result + ): + mock_property = Mock() + mock_property.data = { + "county": "Northamptonshire" } + costs = Costs(mock_property) + assert costs._estimate_number_of_days_for_solid_floor(insulation_floor_area) == expected_result + def test_solid_floor_insulation(self): mock_property = Mock() mock_property.data = { @@ -160,75 +147,13 @@ class TestCosts: 'total_cost': 16.42, 'link': 'SPONs', 'Notes': 0, "is_installer_quote": False } - sol_floor_non_insulation_materials = [ - {'type': 'solid_floor_demolition', 'description': 'Removal of carpet and underfelt', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 3.32, 'labour_hours_per_unit': 0.11, - 'plant_cost': 0, 'total_cost': 3.32, 'link': 'SPONs', - 'Notes': 'We ignore the plant cost that is in SPONs because we assume the carpet is not scrapped and ' - 'therefore there is no need for a skip'}, - {'type': 'solid_floor_preparation', - 'description': 'clean surface of concrete to receive new damp-proof membrane', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 4.36, 'labour_hours_per_unit': 0.14, - 'plant_cost': 0, 'total_cost': 4.36, 'link': 0, 'Notes': 0}, { - 'type': 'solid_floor_preparation', - 'description': 'Clean out crack to ' - 'form a 20mm×20mm ' - 'groove and fill with ' - 'cement: mortar mixed ' - 'with bonding agent', - 'depth': 0, 'depth_unit': 0, - 'cost_unit': 0, - 'thermal_conductivity': 0, - 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, - 'material_cost': 6.91, - 'labour_cost': 18.99, - 'labour_hours_per_unit': 0.61, - 'plant_cost': 0.16, - 'total_cost': 26.06, 'link': 0, - 'Notes': 'This step is the ' - 'assessment and repair of ' - 'any damage to the concrete ' - 'floor such as filling ' - 'cracks or levelling uneven ' - 'areas'}, - {'type': 'solid_floor_vapour_barrier', 'description': 'Visqueen High Performance Vapour Barrier', - 'depth': 0, 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, - 'thermal_conductivity_unit': 0, 'prime_material_cost': 0.58, 'material_cost': 1.21, 'labour_cost': 0.48, - 'labour_hours_per_unit': 0.02, 'plant_cost': 0, 'total_cost': 1.69, 'link': 'SPONs', 'Notes': 0}, - {'type': 'solid_floor_redecoration', - 'description': 'Screeded beds; protection to compressible formwork exceeding 600mm wide', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 9.6, 'material_cost': 9.89, 'labour_cost': 2.67, 'labour_hours_per_unit': 0.15, - 'plant_cost': 0, 'total_cost': 12.56, 'link': 'SPONs', - 'Notes': 'This is the screed layer, placed on top of the insulation'}, - {'type': 'solid_floor_redecoration', 'description': 'Fitting carpet', 'depth': 0, 'depth_unit': 0, - 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0, 'labour_cost': 6.59, 'labour_hours_per_unit': 0.37, - 'plant_cost': 0, 'total_cost': 6.59, 'link': 'SPONs', - 'Notes': 'SPONs does not have data on re-fitting the carpet so we use the data in Fitted carpeting; ' - 'Gradus woven polypropylene tufted loop\n\n as a baseline. We assume re-use of carpets, ' - 'therefore we need just labour rates'}, - {'type': 'solid_floor_redecoration', - 'description': 'Fitting existing softwood skirting or architrave to new frames; 150mm high', 'depth': 0, - 'depth_unit': 0, 'cost_unit': 0, 'thermal_conductivity': 0, 'thermal_conductivity_unit': 0, - 'prime_material_cost': 0, 'material_cost': 0.01, 'labour_cost': 4.87, 'labour_hours_per_unit': 0.12, - 'plant_cost': 0, 'total_cost': 4.88, 'link': 'SPONs', 'Notes': 0} - ] - sol_floor_results = costs.solid_floor_insulation( insulation_floor_area=33.5, material=sol_floor_material, - non_insulation_materials=sol_floor_non_insulation_materials ) - assert sol_floor_results == { - 'total': 4245.023520000001, 'subtotal': 3537.5196, 'vat': 707.5039200000001, 'contingency': 471.66928, - 'preliminaries': 235.83464, 'material': 1006.3399999999999, 'profit': 471.66928, 'labour_hours': 57.285, - 'labour_days': 2.386875, 'labour_cost': 1346.6464 - } + assert sol_floor_results["total"] == 2184 + assert sol_floor_results["contingency"] == 567.84 def test_external_wall_insulation(self): mock_property = Mock() @@ -253,9 +178,8 @@ class TestCosts: material=ewi_material, ) - assert ewi_results == { - 'total': 28773.12844043901, 'labour_hours': 134.2745993887154, 'labour_days': 4.196081230897356 - } + assert ewi_results["total"] == 28773.12844043901 + assert ewi_results["contingency"] == 7481.013394514142 def test_flat_roof_insulation(self): mock_property = Mock() @@ -288,23 +212,27 @@ class TestCosts: material=flat_roof_material, ) - assert flat_roof_floor_results == { - 'total': 2063.935, 'subtotal': 1719.9458333333334, 'vat': 343.9891666666665, 'labour_hours': 8, - 'labour_days': 1 - } + assert flat_roof_floor_results["total"] == 2063.935 + assert flat_roof_floor_results["contingency"] == 536.6231 assert costs.labour_adjustment_factor == 0.88 # Test for different wattages - @pytest.mark.parametrize("n_panels, expected_cost", [ - (7, 5458.727999999999), - (10, 6013.139999999999), - (12, 6386.447999999999), - (15, 7594.451999999999), + @pytest.mark.parametrize("solar_product, expected_cost", [ + ({"total_cost": 5000, "includes_scaffolding": False}, 6000), + ({"total_cost": 5000, "includes_scaffolding": True}, 5000), ]) - def test_solar_pv_different_wattages(self, n_panels, expected_cost): + def test_solar_pv_different_wattages(self, solar_product, expected_cost): mock_property = Mock() mock_property.data = {"county": "Mansfield"} + scaffolding_options = [ + {"size": 2, "total_cost": 1000} + ] costs = Costs(mock_property) - result = costs.solar_pv(n_panels) + result = costs.solar_pv( + solar_product=solar_product, + scaffolding_options=scaffolding_options, + n_floors=2 + ) + assert result['total'] == pytest.approx(expected_cost, rel=0.01)