mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
922 lines
36 KiB
Python
922 lines
36 KiB
Python
import numpy as np
|
|
from recommendations.county_to_region import county_to_region_map
|
|
from utils.logger import setup_logger
|
|
from backend.ml_models.AnnualBillSavings import AnnualBillSavings
|
|
|
|
logger = setup_logger()
|
|
|
|
# This data comes from SPONs 2023
|
|
regional_labour_variations = [
|
|
{"Region": "Outer London", "Adjustment_Factor": 1.00},
|
|
{"Region": "Inner London", "Adjustment_Factor": 1.05},
|
|
{"Region": "South East England", "Adjustment_Factor": 0.96},
|
|
{"Region": "South West England", "Adjustment_Factor": 0.90},
|
|
{"Region": "East of England", "Adjustment_Factor": 0.93},
|
|
{"Region": "East Midlands", "Adjustment_Factor": 0.88},
|
|
{"Region": "West Midlands", "Adjustment_Factor": 0.87},
|
|
{"Region": "North East England", "Adjustment_Factor": 0.83},
|
|
{"Region": "North West England", "Adjustment_Factor": 0.88},
|
|
{"Region": "Yorkshire and the Humber", "Adjustment_Factor": 0.86},
|
|
{"Region": "Wales", "Adjustment_Factor": 0.88},
|
|
{"Region": "Scotland", "Adjustment_Factor": 0.88},
|
|
{"Region": "Northern Ireland", "Adjustment_Factor": 0.76}
|
|
]
|
|
|
|
# Installers are now working with 435 watt panels
|
|
PANEL_SIZE = 0.435
|
|
|
|
INSTALLER_SOLAR_COSTS = [
|
|
{'n_panels': 4, 'array_kwp': 4 * PANEL_SIZE, 'cost': 4089.25, 'installer': 'CEG'},
|
|
{'n_panels': 5, 'array_kwp': 5 * PANEL_SIZE, 'cost': 4242.48, 'installer': 'CEG'},
|
|
{'n_panels': 6, 'array_kwp': 6 * PANEL_SIZE, 'cost': 4395.71, 'installer': 'CEG'},
|
|
{'n_panels': 7, 'array_kwp': 7 * PANEL_SIZE, 'cost': 4548.94, 'installer': 'CEG'},
|
|
{'n_panels': 8, 'array_kwp': 8 * PANEL_SIZE, 'cost': 4702.17, 'installer': 'CEG'},
|
|
{'n_panels': 9, 'array_kwp': 9 * PANEL_SIZE, 'cost': 4855.41, 'installer': 'CEG'},
|
|
{'n_panels': 10, 'array_kwp': 10 * PANEL_SIZE, 'cost': 5010.95, 'installer': 'CEG'},
|
|
{'n_panels': 11, 'array_kwp': 11 * PANEL_SIZE, 'cost': 5166.49, 'installer': 'CEG'},
|
|
{'n_panels': 12, 'array_kwp': 12 * PANEL_SIZE, 'cost': 5322.04, 'installer': 'CEG'},
|
|
{'n_panels': 13, 'array_kwp': 13 * PANEL_SIZE, 'cost': 5657.6, 'installer': 'CEG'},
|
|
{'n_panels': 14, 'array_kwp': 14 * PANEL_SIZE, 'cost': 5993.16, 'installer': 'CEG'},
|
|
{'n_panels': 15, 'array_kwp': 15 * PANEL_SIZE, 'cost': 6328.71, 'installer': 'CEG'},
|
|
{'n_panels': 16, 'array_kwp': 16 * PANEL_SIZE, 'cost': 6483.33, 'installer': 'CEG'},
|
|
{'n_panels': 17, 'array_kwp': 17 * PANEL_SIZE, 'cost': 6637.95, 'installer': 'CEG'},
|
|
{'n_panels': 18, 'array_kwp': 18 * PANEL_SIZE, 'cost': 6792.57, 'installer': 'CEG'}
|
|
]
|
|
|
|
# These are costs we received from CRG, for pricing up air source heat pumps
|
|
# These are costs that we have been provided from CRG specifically for air source heat pumps
|
|
ASHP_SMALL_SYSTEM_COST = 8812.92 # 4.8 to 8.5, based on their pricing
|
|
ASHP_LARGE_SYSTEM_COST = 11053.25
|
|
ASHP_SECURITY = 455.00
|
|
ASHP_WALL_BRACKET = 574.17
|
|
ASHP_DISTRIBUTION_SYSTEM_COSTS = [
|
|
{"n_radiators": 4, "cost": 3380.00},
|
|
{"n_radiators": 5, "cost": 3607.50},
|
|
{"n_radiators": 6, "cost": 4116.67},
|
|
{"n_radiators": 7, "cost": 4647.50},
|
|
{"n_radiators": 8, "cost": 5200.00},
|
|
{"n_radiators": 9, "cost": 5730.83},
|
|
{"n_radiators": 10, "cost": 6283.33},
|
|
{"n_radiators": 11, "cost": 6857.50},
|
|
{"n_radiators": 12, "cost": 7431.67},
|
|
{"n_radiators": 13, "cost": 8016.67},
|
|
{"n_radiators": 14, "cost": 8612.50},
|
|
{"n_radiators": 15, "cost": 9219.17},
|
|
{"n_radiators": 16, "cost": 9804.17},
|
|
{"n_radiators": 17, "cost": 10389.17},
|
|
]
|
|
ASHP_CYLINDER_COSTS = [
|
|
{"capacity_l": 120, "cost": 3318.25},
|
|
{"capacity_l": 180, "cost": 3480.75},
|
|
{"capacity_l": 200, "cost": 3853.42},
|
|
{"capacity_l": 250, "cost": 3961.75},
|
|
]
|
|
|
|
# CEG uses use Solshare as an inverter to provide solar PV to multiple flats. This costs £7500 for the inverter alone
|
|
# https://midsummerwholesale.co.uk/buy/solshare
|
|
INSTALLER_SOLAR_PV_INVERTER_COST = 7500
|
|
INSTALLER_SOLAR_PV_INVERTER_LABOUR_COST = 500 # Just a rough guess to labour costs
|
|
|
|
INSTALLER_SOLAR_BATTERY_COSTS = [
|
|
{'capacity_kwh': 5, 'description': 'Battery Add on', 'cost': 3769.89, 'installer': 'JJC'},
|
|
# {'capacity_kwh': 10, 'description': 'Battery Add on', 'cost': 4300.00, 'installer': 'CEG'},
|
|
# {'capacity_kwh': 5, 'description': 'Battery Retrofit existing system', 'cost': 4250.00, 'installer': 'CEG'},
|
|
# {'capacity_kwh': 10, 'description': 'Battery Retrofit Existing system', 'cost': 5950.00, 'installer': 'CEG'}
|
|
]
|
|
|
|
# This is based on https://www.checkatrade.com/blog/cost-guides/cost-smart-thermostat/
|
|
SMART_APPLIANCE_THERMOSTAT_COST = 400
|
|
PROGRAMMER_COST = 120
|
|
ROOM_THERMOSTAT_COST = 150
|
|
TRVS_COST = 35
|
|
BYPASS_COST = 350 # Based on desktop research for a complex installation
|
|
# https://www.checkatrade.com/blog/cost-guides/cost-install-water-shut-off-valve/
|
|
|
|
# Cost for TTZC
|
|
# Smart thermostat based on checkatrade https://www.checkatrade.com/blog/cost-guides/cost-smart-thermostat/
|
|
# Based on the Nest system
|
|
TTZC_SMART_THERMOSTAT_COST = 205
|
|
TTZC_SMART_THERMOSTAT_LABOUR_HOURS = 2
|
|
TTZC_ELECTRICIAN_HOURLY_RATE = 45
|
|
# Based on cost of a Nest temperature sensor
|
|
TTZC_ROOM_TEMPERATURE_SENSOR_COST = 50
|
|
TTZC_ROOM_TEMPERATURE_SENSOR_LABOUR_HOURS = 0.17 # (Assume ~ 10 mins install per sensor)
|
|
# Basedon an average cost of smart radiator values
|
|
TTZC_SMART_RADIATOR_VALUES = 50
|
|
TTZC_SMART_RADIATOR_VALUES_LABOUR_HOURS = 0.37 # (Assume ~ 15-30 mins install per valve)
|
|
|
|
# boiler prices based on
|
|
# This is the cost of a firs time central heating install from The Warm Front rate card
|
|
# These are exclusive of installation costs
|
|
CONDENSING_BOILER_COST = 2600
|
|
|
|
# Electric boiler prices base on
|
|
# https://www.greenmatch.co.uk/boilers/combi-boilers/electric-combi-boilers
|
|
# https://www.tlc-direct.co.uk/Products/ERMAC15.html
|
|
# The unit is a 15kw boiler, capable of outputting between 3kw and 15kw. Costs seem to be around £1800
|
|
ELECTRIC_BOILER_COSTS = 1800
|
|
|
|
# Assumes 1 hours to remove each heater (including re-decorating)
|
|
ROOM_HEATER_REMOVAL_COST = 25
|
|
ROOM_HEATER_REMOVAL_LABOUR_HOURS = 3
|
|
|
|
DOUBLE_RADIATOR_COST = 300
|
|
FLUE_COST = 600
|
|
PIPEWORK_COST = 750 # Min cost is £500
|
|
|
|
# Based on SCIS figures
|
|
# TODO: Add this to databse
|
|
CAVITY_EXTRACTION_COST = 25
|
|
|
|
|
|
class Costs:
|
|
"""
|
|
A class to calculate the costs associated with construction works,
|
|
specifically focusing on cavity wall insulation.
|
|
It includes contingency, preliminaries, profit margin, and VAT calculations.
|
|
|
|
As a sense check, there is a useful article from checkatrade on retrofitting and expected costs:
|
|
https://www.checkatrade.com/blog/cost-guides/retrofit-insulation-cost/
|
|
|
|
Another useful article for benchmarking the cost of floor insulation:
|
|
https://www.checkatrade.com/blog/cost-guides/floor-insulation-cost/
|
|
"""
|
|
|
|
# Contingency is a percentage of the total cost of the work and covers unforseen expenses
|
|
# We assume a conservative 10% contingency for all works which is a rate defined by SPONs
|
|
CONTINGENCY = 0.1
|
|
|
|
# Measure level contingency
|
|
CONTINGENCIES = {
|
|
"cavity_wall_insulation": 0.1,
|
|
"internal_wall_insulation": 0.26,
|
|
"external_wall_insulation": 0.26,
|
|
"loft_insulation": 0.1,
|
|
"solar_pv": 0.15,
|
|
"air_source_heat_pump": 0.25,
|
|
"flat_roof_insulation": 0.26,
|
|
"suspended_floor_insulation": 0.2,
|
|
"solid_floor_insulation": 0.26,
|
|
"low_energy_lighting": 0.26,
|
|
"high_heat_retention_storage_heaters": 0.1,
|
|
"windows_glazing": 0.15,
|
|
}
|
|
|
|
# Preliminaries are a percentage of the total cost of the work and covers the cost of site-specific costs
|
|
# such as site preparation, safety measures and project management. This rate can vary but we'll assume a 10%
|
|
# rate, on the total cost before VAT, as recommended by SPONs
|
|
PRELIMINARIES = 0.1
|
|
|
|
VAT_RATE = 0.2
|
|
PROFIT_MARGIN = 0.2
|
|
|
|
# Based on this greenmatch article, on average, a Sash window is around 50% more expensive than a casement window.
|
|
# Therefore, for a conservative cost estimate, and allowance for a more premium window type, we inflate the material
|
|
# cost of the windows to allow for a sash window type
|
|
# https://www.greenmatch.co.uk/windows/double-glazing/cost
|
|
SASH_WINDOW_INFLATION_FACTOR = 1.5
|
|
|
|
# Based on relative costs from SCIS
|
|
SECONDARY_GLAZING_SCALING_FACTOR = 0.85
|
|
|
|
def __init__(self, property_instance):
|
|
"""
|
|
Initializes the Costs class with a property instance.
|
|
|
|
:param property_instance: Instance of a Property class containing relevant details like wall area.
|
|
"""
|
|
if not hasattr(property_instance, 'insulation_wall_area'):
|
|
raise ValueError("Property instance must have an 'insulation_wall_area' attribute")
|
|
self.property = property_instance
|
|
self.regional_labour_variations = regional_labour_variations
|
|
|
|
self.region = county_to_region_map.get(self.property.data["county"], None)
|
|
if self.region is None:
|
|
# Try and grab using the local-authority-label
|
|
self.region = county_to_region_map.get(self.property.data["local-authority-label"], None)
|
|
|
|
if self.region is None:
|
|
# Try and get the region after converting the keys to lower
|
|
self.region = {
|
|
k.lower(): v for k, v in county_to_region_map.items()
|
|
}.get(self.property.data["local-authority-label"].lower(), None)
|
|
|
|
if self.region is None:
|
|
logger.warning("No region found for county %s, defaulting to South East England",
|
|
self.property.data["county"])
|
|
self.region = "South East England"
|
|
|
|
self.labour_adjustment_factor = [
|
|
x["Adjustment_Factor"] for x in self.regional_labour_variations if
|
|
x["Region"] == self.region
|
|
][0]
|
|
|
|
if not self.labour_adjustment_factor:
|
|
raise ValueError("Labour adjustment factor not found")
|
|
|
|
def cavity_wall_insulation(self, wall_area, material, is_extraction_and_refill=False):
|
|
"""
|
|
Calculates the total cost for cavity wall insulation based on material and labor costs,
|
|
including contingency, preliminaries, profit, and VAT.
|
|
|
|
Because of some limitations in the SPONs data, there are no materials that can be blown through a wall,
|
|
therefore we have adapted similar materials, basing our estimates on 75mm cavity slabs, and have halved the
|
|
labour time required. That is why we still price based on wall area despite volume actually being the correct
|
|
metric.
|
|
|
|
:return: A dictionary containing detailed cost breakdown.
|
|
"""
|
|
# CWI usually takes 1 day
|
|
labour_hours = 8
|
|
labour_days = 1
|
|
|
|
total_cost = material["total_cost"] * wall_area
|
|
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": self.CONTINGENCIES["cavity_wall_insulation"] * total_cost,
|
|
"contingency_rate": self.CONTINGENCIES["cavity_wall_insulation"],
|
|
"labour_hours": labour_hours,
|
|
"labour_days": labour_days,
|
|
}
|
|
|
|
def loft_and_flat_insulation(self, floor_area, material):
|
|
"""
|
|
Calculates the total cost for loft/flat roof insulation based on material and labor costs,
|
|
including contingency, preliminaries, profit, and VAT.
|
|
|
|
:return: A dictionary containing detailed cost breakdown.
|
|
"""
|
|
|
|
total_cost = material["total_cost"] * floor_area
|
|
if material["type"] == "loft_insulation":
|
|
contingency_rate = self.CONTINGENCIES["loft_insulation"]
|
|
contingency = contingency_rate * total_cost
|
|
else:
|
|
contingency_rate = self.CONTINGENCIES["flat_roof_insulation"]
|
|
contingency = contingency_rate * total_cost
|
|
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": contingency,
|
|
"contingency_rate": contingency_rate,
|
|
"labour_hours": 8,
|
|
"labour_days": 1,
|
|
}
|
|
|
|
def solid_wall_insulation(self, wall_area, material):
|
|
"""
|
|
Implements costing methodology now that we have direct quotes from installers.
|
|
:return:
|
|
"""
|
|
|
|
# if the material is based on an installer cost, we return the flat price
|
|
total_cost = material["total_cost"] * wall_area
|
|
|
|
if material["type"] == "internal_wall_insulation":
|
|
contingency_rate = self.CONTINGENCIES["internal_wall_insulation"]
|
|
else:
|
|
contingency_rate = self.CONTINGENCIES["external_wall_insulation"]
|
|
|
|
labour_hours = material["labour_hours_per_unit"] * wall_area
|
|
|
|
# To install internal wall insulation, a small to medium size project might be conducted by a team of 3-5
|
|
# people
|
|
labour_days = (labour_hours / 8) / 4
|
|
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": contingency_rate * total_cost,
|
|
"contingency_rate": contingency_rate,
|
|
"labour_hours": labour_hours,
|
|
"labour_days": labour_days,
|
|
}
|
|
|
|
def suspended_floor_insulation(self, insulation_floor_area, material):
|
|
"""
|
|
Given an installer cost for the works, produces an estimate for the cost of works.
|
|
Includes contingency
|
|
"""
|
|
|
|
# if the material is based on an installer cost, we return the flat price
|
|
total_cost = material["total_cost"] * insulation_floor_area
|
|
|
|
labour_hours = material["labour_hours_per_unit"] * insulation_floor_area
|
|
# To install suspended floor insulation, a small to medium size project might be conducted by a team of 3
|
|
# people
|
|
labour_days = (labour_hours / 8) / 3
|
|
|
|
return {
|
|
"total": 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
|
|
|
|
:param insulation_floor_area: Area of the floor to be insulated
|
|
:param material: Selected insulation material
|
|
:return:
|
|
"""
|
|
|
|
total_cost = material["total_cost"] * insulation_floor_area
|
|
daily_labour_rate = 300 # Based on checkatrade
|
|
|
|
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
|
|
|
|
total_cost = round(total_cost)
|
|
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": self.CONTINGENCIES["solid_floor_insulation"] * total_cost,
|
|
"contingency_rate": self.CONTINGENCIES["solid_floor_insulation"],
|
|
"labour_hours": labour_days * 8,
|
|
"labour_days": labour_days,
|
|
}
|
|
|
|
def low_energy_lighting(self, number_of_lights, material):
|
|
|
|
"""
|
|
Calculates the total cost for low energy lighting based on material and labor costs,
|
|
including contingency, preliminaries, profit, and VAT.
|
|
|
|
:param number_of_lights: Int, number of light
|
|
:material: Dict, material data containing costs of fittings
|
|
"""
|
|
|
|
# If there are no lights fitted in the property, we increase the contingency in case there are potential wiring
|
|
# blockers
|
|
|
|
total_cost = material["total_cost"] * number_of_lights
|
|
|
|
labour_hours = 1
|
|
labour_days = (labour_hours / 8)
|
|
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": self.CONTINGENCIES["low_energy_lighting"] * total_cost,
|
|
"contingency_rate": self.CONTINGENCIES["low_energy_lighting"],
|
|
"labour_hours": labour_hours,
|
|
"labour_days": labour_days,
|
|
}
|
|
|
|
def window_glazing(self, number_of_windows, material, is_secondary_glazing=False):
|
|
"""
|
|
Given an isntaller quote, produces an estimate for the cost of works.
|
|
|
|
"""
|
|
|
|
total_cost = material["total_cost"] * number_of_windows
|
|
|
|
labour_hours = material["labour_hours_per_unit"] * number_of_windows
|
|
# To install windows, a small to medium size project might be conducted by a team of 2-3 people
|
|
labour_days = (labour_hours / 8) / 2
|
|
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": self.CONTINGENCIES["windows_glazing"] * total_cost,
|
|
"contingency_rate": self.CONTINGENCIES["windows_glazing"],
|
|
"labour_hours": labour_hours,
|
|
"labour_days": labour_days,
|
|
}
|
|
|
|
@classmethod
|
|
def solar_pv(
|
|
cls,
|
|
solar_product,
|
|
scaffolding_options,
|
|
n_floors
|
|
):
|
|
|
|
"""
|
|
|
|
"""
|
|
|
|
system_cost = solar_product["total_cost"]
|
|
|
|
if not solar_product["includes_scaffolding"]:
|
|
# We base this on the number of floors
|
|
scaffolding = [x["total_cost"] for x in scaffolding_options if x["size"] == n_floors]
|
|
if not scaffolding:
|
|
# If we have no options, handle this
|
|
if n_floors <= 3:
|
|
raise ValueError("No scaffolding options available for 3 or fewer floors")
|
|
# We take the largest scaffolding option available
|
|
scaffolding_cost = max([x["total_cost"] for x in scaffolding_options])
|
|
else:
|
|
scaffolding_cost = min(scaffolding)
|
|
|
|
system_cost += scaffolding_cost
|
|
|
|
# Labour hours are based on estimates from online research but an average team seems to consist of 3 people
|
|
# and most jobs take around 2 days. Assuming an 8 hour day for 3 people across 2 days, gives us 48 hours of
|
|
# labour
|
|
return {
|
|
"total": system_cost,
|
|
"subtotal": system_cost,
|
|
"contingency": system_cost * cls.CONTINGENCIES["solar_pv"],
|
|
"contingency_rate": cls.CONTINGENCIES["solar_pv"],
|
|
"vat": 0,
|
|
"labour_hours": 48,
|
|
"labour_days": 2,
|
|
}
|
|
|
|
def programmer_and_appliance_thermostat(self, has_programmer):
|
|
"""
|
|
Calculate the total cost of installing a programmer and appliance thermostat
|
|
If the property already has a programmer, then the only thing we need to calculate the cost for is the
|
|
appliance thermostat
|
|
"""
|
|
|
|
if has_programmer:
|
|
labour_hours = 2
|
|
total_cost = SMART_APPLIANCE_THERMOSTAT_COST
|
|
else:
|
|
labour_hours = 4
|
|
total_cost = SMART_APPLIANCE_THERMOSTAT_COST + PROGRAMMER_COST
|
|
|
|
subtotal_before_vat = total_cost / (1 + self.VAT_RATE)
|
|
vat = total_cost - subtotal_before_vat
|
|
|
|
# We estimate the cost of an appliance thermostat at £400, which is the upper end of the range
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": total_cost * self.CONTINGENCY,
|
|
"contingency_rate": self.CONTINGENCY,
|
|
"subtotal": subtotal_before_vat,
|
|
"vat": vat,
|
|
"labour_hours": labour_hours,
|
|
"labour_days": 1,
|
|
}
|
|
|
|
def electric_room_heaters(self, number_heated_rooms):
|
|
"""
|
|
We base the estimates for the cost of electric room heaters on the cost per room as estimated by the
|
|
following article:
|
|
https://www.bestelectricradiators.co.uk/blog/cost-to-install-a-new-heating-system-uk/
|
|
|
|
:param number_heated_rooms: int, number of rooms to be heated
|
|
:return:
|
|
"""
|
|
|
|
total_cost = 500 * number_heated_rooms
|
|
subtotal_before_vat = total_cost / (1 + self.VAT_RATE)
|
|
vat = total_cost - subtotal_before_vat
|
|
|
|
# TODO: Rough estimate to be reviewed
|
|
labour_hours = 1 * number_heated_rooms
|
|
labour_days = np.ceil(labour_hours / 8)
|
|
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": total_cost * self.CONTINGENCY,
|
|
"contingency_rate": self.CONTINGENCY,
|
|
"subtotal": subtotal_before_vat,
|
|
"vat": vat,
|
|
"labour_hours": labour_hours,
|
|
"labour_days": labour_days,
|
|
}
|
|
|
|
def high_heat_electric_storage_heaters(
|
|
self, number_heated_rooms: int,
|
|
needs_cylinder: bool,
|
|
product: dict | None = None
|
|
):
|
|
|
|
"""
|
|
We base the estimates for the cost of electric storage heaters on the cost per room as estimated by the
|
|
energy saving trust
|
|
https://energysavingtrust.org.uk/advice/electric-heating/
|
|
|
|
The cost is based on the number of heated rooms
|
|
:param number_heated_rooms: int, number of rooms to be heated
|
|
:param needs_cylinder: bool, whether the property needs a hot water cylinder
|
|
:param product: dict, product data containing costs of heaters
|
|
"""
|
|
|
|
if needs_cylinder:
|
|
# 1500 is the cost of a new hot water cylinder
|
|
total_cost = product["total_cost"] * number_heated_rooms + 1500
|
|
else:
|
|
# 500 is the cost of a dual immersion heater - a rough estimate
|
|
total_cost = product["total_cost"] * number_heated_rooms + 500
|
|
|
|
subtotal_before_vat = total_cost / (1 + self.VAT_RATE)
|
|
vat = total_cost - subtotal_before_vat
|
|
|
|
labour_hours = 3 * number_heated_rooms
|
|
labour_days = np.ceil(labour_hours / 8)
|
|
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": total_cost * self.CONTINGENCIES["high_heat_retention_storage_heaters"],
|
|
"contingency_rate": self.CONTINGENCIES["high_heat_retention_storage_heaters"],
|
|
"subtotal": subtotal_before_vat,
|
|
"vat": vat,
|
|
"labour_hours": labour_hours,
|
|
"labour_days": labour_days,
|
|
}
|
|
|
|
def celect_type_controls(self):
|
|
"""
|
|
Calculate the cost of installing Celect type controls
|
|
"""
|
|
|
|
# The £50 cost is a rough estimate based on internet research
|
|
total_cost = 50
|
|
subtotal_before_vat = total_cost / (1 + self.VAT_RATE)
|
|
vat = total_cost - subtotal_before_vat
|
|
|
|
# We estimate the labour hours to be 4
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": total_cost * self.CONTINGENCY,
|
|
"contingency_rate": self.CONTINGENCY,
|
|
"subtotal": subtotal_before_vat,
|
|
"vat": vat,
|
|
"labour_hours": 4,
|
|
"labour_days": 1,
|
|
}
|
|
|
|
def cylinder_thermostat(self):
|
|
"""
|
|
Calculate the cost of installing a cylinder thermostat
|
|
"""
|
|
|
|
# The £200 cost is a rough estimate based on internet research
|
|
total_cost = 200
|
|
subtotal_before_vat = total_cost / (1 + self.VAT_RATE)
|
|
vat = total_cost - subtotal_before_vat
|
|
|
|
# We estimate the labour hours to be 2
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": total_cost * self.CONTINGENCY,
|
|
"contingency_rate": self.CONTINGENCY,
|
|
"subtotal": subtotal_before_vat,
|
|
"vat": vat,
|
|
"labour_hours": 2,
|
|
"labour_days": 1,
|
|
}
|
|
|
|
def hot_water_tank_insulation(self):
|
|
"""
|
|
Calculate the cost of installing hot water tank insulation
|
|
"""
|
|
|
|
# The £50 cost is a rough estimate based on internet research
|
|
total_cost = 50
|
|
subtotal_before_vat = total_cost / (1 + self.VAT_RATE)
|
|
vat = total_cost - subtotal_before_vat
|
|
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": total_cost * self.CONTINGENCY,
|
|
"contingency_rate": self.CONTINGENCY,
|
|
"subtotal": subtotal_before_vat,
|
|
"vat": vat,
|
|
"labour_hours": 0,
|
|
"labour_days": 0,
|
|
}
|
|
|
|
def roomstat_programmer_trvs(
|
|
self, number_heated_rooms, has_programmer, has_trvs, has_room_thermostat
|
|
):
|
|
"""
|
|
|
|
:return:
|
|
"""
|
|
|
|
total_cost = 0
|
|
labour_hours = 0
|
|
|
|
if not has_programmer:
|
|
total_cost += PROGRAMMER_COST
|
|
labour_hours += 1
|
|
|
|
if not has_trvs:
|
|
total_cost += TRVS_COST * number_heated_rooms
|
|
labour_hours += 0.25 * number_heated_rooms
|
|
|
|
if not has_room_thermostat:
|
|
total_cost += ROOM_THERMOSTAT_COST
|
|
labour_hours += 0.5
|
|
|
|
subtotal_before_vat = total_cost / (1 + self.VAT_RATE)
|
|
vat = total_cost - subtotal_before_vat
|
|
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": total_cost * self.CONTINGENCY,
|
|
"contingency_rate": self.CONTINGENCY,
|
|
"subtotal": subtotal_before_vat,
|
|
"vat": vat,
|
|
"labour_hours": labour_hours,
|
|
"labour_days": 1,
|
|
}
|
|
|
|
def time_and_temperature_zone_control(self, number_heated_rooms):
|
|
|
|
# The product costs are inclusive of VAT
|
|
product_costs = (
|
|
TTZC_SMART_THERMOSTAT_COST +
|
|
TTZC_ROOM_TEMPERATURE_SENSOR_COST * number_heated_rooms +
|
|
TTZC_SMART_RADIATOR_VALUES * number_heated_rooms
|
|
)
|
|
labour_hours = (
|
|
TTZC_SMART_THERMOSTAT_LABOUR_HOURS +
|
|
TTZC_ROOM_TEMPERATURE_SENSOR_LABOUR_HOURS * number_heated_rooms +
|
|
TTZC_SMART_RADIATOR_VALUES_LABOUR_HOURS * number_heated_rooms
|
|
)
|
|
labour_costs = TTZC_ELECTRICIAN_HOURLY_RATE * labour_hours
|
|
# Add continency and preliminaries to the labour to account for the complexity of the job
|
|
labour_costs = labour_costs * (1 + self.CONTINGENCY + self.PRELIMINARIES)
|
|
|
|
vat = labour_costs * self.VAT_RATE
|
|
|
|
subtotal_before_vat = product_costs + labour_costs
|
|
total_cost = subtotal_before_vat + vat
|
|
|
|
labour_days = np.ceil(labour_hours / 8)
|
|
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": total_cost * self.CONTINGENCY,
|
|
"contingency_rate": self.CONTINGENCY,
|
|
"subtotal": subtotal_before_vat,
|
|
"vat": vat,
|
|
"labour_hours": labour_hours,
|
|
"labour_days": labour_days,
|
|
}
|
|
|
|
def programmer_trvs_bypass(self, number_heated_rooms, has_programmer, has_trvs, has_bypass):
|
|
|
|
total_cost = 0
|
|
labour_hours = 0
|
|
|
|
if not has_programmer:
|
|
total_cost += PROGRAMMER_COST
|
|
labour_hours += 1
|
|
|
|
if not has_trvs:
|
|
total_cost += TRVS_COST * number_heated_rooms
|
|
labour_hours += 0.25 * number_heated_rooms
|
|
|
|
if not has_bypass:
|
|
total_cost += BYPASS_COST
|
|
labour_hours += 0.5
|
|
|
|
subtotal_before_vat = total_cost / (1 + self.VAT_RATE)
|
|
vat = total_cost - subtotal_before_vat
|
|
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": total_cost * self.CONTINGENCY,
|
|
"contingency_rate": self.CONTINGENCY,
|
|
"subtotal": subtotal_before_vat,
|
|
"vat": vat,
|
|
"labour_hours": labour_hours,
|
|
"labour_days": 1,
|
|
}
|
|
|
|
def heater_removal(self, n_rooms):
|
|
"""
|
|
Estimates the costs of removal of heaters, including the redecoration costs of the space behind the heater
|
|
:return:
|
|
"""
|
|
|
|
removal_cost = ROOM_HEATER_REMOVAL_COST * n_rooms
|
|
removal_labour_hours = ROOM_HEATER_REMOVAL_LABOUR_HOURS * n_rooms
|
|
|
|
vat = removal_cost * self.VAT_RATE
|
|
|
|
subtotal_before_vat = removal_cost
|
|
total_cost = subtotal_before_vat + vat
|
|
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": total_cost * self.CONTINGENCY,
|
|
"contingency_rate": self.CONTINGENCY,
|
|
"subtotal": subtotal_before_vat,
|
|
"vat": vat,
|
|
"labour_hours": removal_labour_hours,
|
|
"labour_days": np.ceil(removal_labour_hours / 8),
|
|
}
|
|
|
|
@staticmethod
|
|
def _estimate_n_radiators(number_habitable_rooms, total_floor_area, property_type, built_form):
|
|
# Base number of radiators: one per habitable room
|
|
base_radiators = number_habitable_rooms
|
|
|
|
# Additional radiators for non-habitable essential areas (e.g., kitchens, hallways)
|
|
additional_radiators = 3 # Initial assumption
|
|
|
|
# Adjust additional radiators based on property type
|
|
if property_type == 'Flat':
|
|
additional_radiators -= 1 # Flats may need fewer radiators due to less exposure
|
|
elif property_type in ['House', 'Bungalow', 'Maisonette']:
|
|
# Multiple floors in Maisonette may require additional heating points
|
|
additional_radiators += 2 # Houses and bungalows might need more due to greater exposure
|
|
else:
|
|
raise Exception("Invalid property type")
|
|
|
|
# Adjust total radiator needs based on built form
|
|
form_factor = {
|
|
'Enclosed Mid-Terrace': 0.9,
|
|
'Mid-Terrace': 0.95,
|
|
'Enclosed End-Terrace': 0.95,
|
|
'Semi-Detached': 1.05,
|
|
'Detached': 1.25,
|
|
'End-Terrace': 1.05
|
|
}
|
|
|
|
# Calculate total heating power needed and number of radiators based on standard output
|
|
total_heating_power_required = total_floor_area * 80 # Watts per square meter
|
|
radiator_output = 1000 # Average wattage per radiator
|
|
total_radiators_based_on_power = (total_heating_power_required / radiator_output) * form_factor[built_form]
|
|
|
|
# Final estimation taking the higher of calculated needs or base room count
|
|
estimated_radiators = max(total_radiators_based_on_power, base_radiators + additional_radiators)
|
|
return round(estimated_radiators)
|
|
|
|
def boiler(self, exising_room_heaters, system_change, n_heated_rooms, n_rooms, is_electric=False):
|
|
"""
|
|
Based on a basic estimate of median value £2600 to install a low carbon combi boiler
|
|
First time central heating vosts can als be found here:
|
|
https://www.checkatrade.com/blog/cost-guides/central-heating-installation-cost/
|
|
:return:
|
|
"""
|
|
|
|
if not is_electric:
|
|
unit_cost = CONDENSING_BOILER_COST
|
|
else:
|
|
unit_cost = ELECTRIC_BOILER_COSTS
|
|
# The unit cost is the cost without VAT
|
|
# We now need to estimate the cost of the works
|
|
labour_days = 2
|
|
labour_hours = labour_days * 8
|
|
labour_rate = 300
|
|
|
|
# Average cost of installation is 1 (maybe 2days) at £300 per day
|
|
# https://www.checkatrade.com/blog/cost-guides/new-boiler-cost/
|
|
# To be pessimistic, assume 2 days work
|
|
labour_cost = labour_rate * self.labour_adjustment_factor * labour_days
|
|
# Add contingency and preliminaries
|
|
|
|
vat = labour_cost * self.VAT_RATE
|
|
|
|
subtotal_before_vat = unit_cost + labour_cost
|
|
total_cost = subtotal_before_vat + vat
|
|
|
|
# if there are existing room heaters, we need to add the cost of removing them
|
|
if exising_room_heaters:
|
|
removal_costing = self.heater_removal(n_rooms=n_heated_rooms)
|
|
# Add the totals to the existing totals
|
|
total_cost += removal_costing["total"]
|
|
subtotal_before_vat += removal_costing["subtotal"]
|
|
labour_hours += removal_costing["labour_hours"]
|
|
labour_days += removal_costing["labour_days"]
|
|
vat += removal_costing["vat"]
|
|
|
|
if system_change:
|
|
# We need the cost of radiators
|
|
n_radiators = self._estimate_n_radiators(
|
|
number_habitable_rooms=n_rooms,
|
|
total_floor_area=self.property.floor_area,
|
|
property_type=self.property.data["property-type"],
|
|
built_form=self.property.data["built-form"]
|
|
)
|
|
|
|
additionals_labour_cost = labour_rate * self.labour_adjustment_factor
|
|
radiator_cost = DOUBLE_RADIATOR_COST * n_radiators
|
|
system_change_cost = radiator_cost + FLUE_COST + PIPEWORK_COST + additionals_labour_cost
|
|
system_change_cost_before_vat = system_change_cost / (1 + self.VAT_RATE)
|
|
system_change_vat = system_change_cost - system_change_cost_before_vat
|
|
# We add an extra labour day for the system change
|
|
labour_days += 1
|
|
labour_hours += 8
|
|
total_cost += system_change_cost
|
|
subtotal_before_vat += system_change_cost_before_vat
|
|
vat += system_change_vat
|
|
|
|
return {
|
|
"total": total_cost,
|
|
"contingency": total_cost * self.CONTINGENCY,
|
|
"contingency_rate": self.CONTINGENCY,
|
|
"subtotal": subtotal_before_vat,
|
|
"vat": vat,
|
|
"labour_hours": labour_hours,
|
|
"labour_days": labour_days,
|
|
}
|
|
|
|
@staticmethod
|
|
def _select_cylinder_capacity(occupants: float):
|
|
if occupants <= 2:
|
|
return 120
|
|
elif occupants <= 3:
|
|
return 180
|
|
elif occupants <= 4:
|
|
return 200
|
|
else:
|
|
return 250
|
|
|
|
def air_source_heat_pump(self, ashp_size: float, number_heated_rooms: int, total_floor_area: float) -> dict:
|
|
"""
|
|
We produce a cost estimation for an air source heat pump, based on costs we have received from installers.
|
|
|
|
"""
|
|
|
|
system_cost = (
|
|
(ASHP_SMALL_SYSTEM_COST if ashp_size <= 8.5 else ASHP_LARGE_SYSTEM_COST) + ASHP_SECURITY + ASHP_WALL_BRACKET
|
|
)
|
|
|
|
available_n_rads = [x["n_radiators"] for x in ASHP_DISTRIBUTION_SYSTEM_COSTS]
|
|
if number_heated_rooms < min(available_n_rads):
|
|
# We use the smallest value
|
|
rads_to_use = min(available_n_rads)
|
|
elif number_heated_rooms > max(available_n_rads):
|
|
# We use the largest value
|
|
rads_to_use = max(available_n_rads)
|
|
else:
|
|
rads_to_use = int(number_heated_rooms)
|
|
|
|
distribution_system_cost = [
|
|
x for x in ASHP_DISTRIBUTION_SYSTEM_COSTS if x["n_radiators"] == rads_to_use
|
|
][0]["cost"]
|
|
|
|
# Cylinder cost
|
|
est_n_occupants = AnnualBillSavings.calculate_occupants(total_floor_area)
|
|
cylinder_capacity = self._select_cylinder_capacity(est_n_occupants)
|
|
cylinder_cost = [
|
|
x for x in ASHP_CYLINDER_COSTS if x["capacity_l"] == cylinder_capacity
|
|
][0]["cost"]
|
|
|
|
total = system_cost + distribution_system_cost + cylinder_cost
|
|
|
|
return {
|
|
"total": total,
|
|
"contingency": total * self.CONTINGENCIES["air_source_heat_pump"],
|
|
"contingency_rate": self.CONTINGENCIES["air_source_heat_pump"],
|
|
"vat": 0,
|
|
"labour_hours": 80,
|
|
"labour_days": 10,
|
|
}
|