mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
fixed costs tests
This commit is contained in:
parent
ed7cb6998b
commit
4ae439ddd3
2 changed files with 103 additions and 135 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue