mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
285 lines
12 KiB
Python
285 lines
12 KiB
Python
import numpy as np
|
|
import pandas as pd
|
|
|
|
QUARTERLY_ENERGY_PRICES = [
|
|
# 2024 Q1
|
|
{"start": "2024-01-01", "end": "2024-03-31", "electricity": 0.2, "gas": 0.042},
|
|
# 2023 Q4
|
|
{"start": "2023-10-01", "end": "2023-12-31", "electricity": 0.202, "gas": 0.51},
|
|
# 2023 Q3
|
|
{"start": "2023-07-01", "end": "2023-09-30", "electricity": 0.188, "gas": 0.46},
|
|
# 2023 Q2
|
|
{"start": "2023-04-01", "end": "2023-06-30", "electricity": 0.177, "gas": 0.456},
|
|
]
|
|
|
|
|
|
class AnnualBillSavings:
|
|
"""
|
|
This is a simple class which will estimate the annual bill savings, based on the kwh savings.
|
|
This class uses data from Ofgem, including their price caps, to provide us with an estimate for
|
|
1KWH of energy.
|
|
"""
|
|
|
|
# These gas an electricity consumption figures are based off of figures presented by Ofgem
|
|
# https://www.ofgem.gov.uk/information-consumers/energy-advice-households/average-gas-and-electricity-use-explained
|
|
AVERAGE_ELECTRICITY_CONSUMPTION = 2700
|
|
AVERAGE_GAS_CONSUMPTION = 11500
|
|
|
|
# Latest price cap figures from Ofgem are for April 2024
|
|
# https://www.ofgem.gov.uk/energy-price-cap
|
|
ELECTRICITY_PRICE_CAP = 0.2236
|
|
GAS_PRICE_CAP = 0.0548
|
|
# This is the most recent export payment figure, at 9.28p/kWh
|
|
# Smart export guarantee rates can be found here:
|
|
# https://www.sunsave.energy/solar-panels-advice/exporting-to-the-grid/best-seg-rates
|
|
ELECTRICITY_EXPORT_PAYMENT = 0.0928
|
|
|
|
# This is a weighted mean of the price caps, using the consumption figures above as weights
|
|
PRICE_FACTOR = 0.09549999999999999
|
|
|
|
# Daily standard charge, based on average across England, Scotland and Wales, and includes VAT
|
|
DAILY_STANDARD_CHARGE_GAS = 0.3143
|
|
DAILY_STANDARD_CHARGE_ELECTRICITY = 0.601
|
|
|
|
# Based on https://www.nottenergy.com/advice-and-tools/project-energy-cost-comparison
|
|
# For July 2024. These quotes are based on the east midlands region, so we
|
|
FUEL_DATA = pd.DataFrame([
|
|
{"Fuel": "Electricity Standard", "Price (p)": 28.58, "Unit": "kWh", "Boiler Efficiency (%)": 100,
|
|
"Energy Content, Net Calorific value (kWh/unit)": 1.00, "Price per kWh (p) (inc boiler efficiency)": 28.58,
|
|
"CO2eq emission factor kgCO2eq/kWh (Gross CV)": 0.275},
|
|
{"Fuel": "Mains Gas Standard", "Price (p)": 6.31, "Unit": "kWh", "Boiler Efficiency (%)": 90,
|
|
"Energy Content, Net Calorific value (kWh/unit)": 1.00, "Price per kWh (p) (inc boiler efficiency)": 7.01,
|
|
"CO2eq emission factor kgCO2eq/kWh (Gross CV)": 0.213},
|
|
{"Fuel": "Kerosene", "Price (p)": 62.49, "Unit": "Litre", "Boiler Efficiency (%)": 90,
|
|
"Energy Content, Net Calorific value (kWh/unit)": 9.79, "Price per kWh (p) (inc boiler efficiency)": 7.09,
|
|
"CO2eq emission factor kgCO2eq/kWh (Gross CV)": 0.298},
|
|
{"Fuel": "Gas oil", "Price (p)": 94.50, "Unit": "Litre", "Boiler Efficiency (%)": 90,
|
|
"Energy Content, Net Calorific value (kWh/unit)": 9.96, "Price per kWh (p) (inc boiler efficiency)": 10.54,
|
|
"CO2eq emission factor kgCO2eq/kWh (Gross CV)": 0.316},
|
|
{"Fuel": "LPG", "Price (p)": 55.00, "Unit": "Litre", "Boiler Efficiency (%)": 90,
|
|
"Energy Content, Net Calorific value (kWh/unit)": 6.78, "Price per kWh (p) (inc boiler efficiency)": 9.01,
|
|
"CO2eq emission factor kgCO2eq/kWh (Gross CV)": 0.240},
|
|
{"Fuel": "Butane", "Price (p)": 216.58, "Unit": "Litre", "Boiler Efficiency (%)": 90,
|
|
"Energy Content, Net Calorific value (kWh/unit)": 6.64, "Price per kWh (p) (inc boiler efficiency)": 36.24,
|
|
"CO2eq emission factor kgCO2eq/kWh (Gross CV)": 0.248},
|
|
{"Fuel": "Propane", "Price (p)": 157.67, "Unit": "Litre", "Boiler Efficiency (%)": 90,
|
|
"Energy Content, Net Calorific value (kWh/unit)": 7.22, "Price per kWh (p) (inc boiler efficiency)": 24.25,
|
|
"CO2eq emission factor kgCO2eq/kWh (Gross CV)": 0.239},
|
|
{"Fuel": "Kiln Dried (logs)", "Price (p)": 36.52, "Unit": "kg", "Boiler Efficiency (%)": 85,
|
|
"Energy Content, Net Calorific value (kWh/unit)": 4.09, "Price per kWh (p) (inc boiler efficiency)": 10.51,
|
|
"CO2eq emission factor kgCO2eq/kWh (Gross CV)": 0.024},
|
|
{"Fuel": "Pellets (Bagged)", "Price (p)": 39.62, "Unit": "kg", "Boiler Efficiency (%)": 90,
|
|
"Energy Content, Net Calorific value (kWh/unit)": 4.80, "Price per kWh (p) (inc boiler efficiency)": 9.17,
|
|
"CO2eq emission factor kgCO2eq/kWh (Gross CV)": 0.049},
|
|
{"Fuel": "Pellets (Blown bulk)", "Price (p)": 33.92, "Unit": "kg", "Boiler Efficiency (%)": 90,
|
|
"Energy Content, Net Calorific value (kWh/unit)": 4.80, "Price per kWh (p) (inc boiler efficiency)": 7.85,
|
|
"CO2eq emission factor kgCO2eq/kWh (Gross CV)": 0.049},
|
|
{"Fuel": "Smokeless fuel", "Price (p)": 67.26, "Unit": "kg", "Boiler Efficiency (%)": 75,
|
|
"Energy Content, Net Calorific value (kWh/unit)": 6.70, "Price per kWh (p) (inc boiler efficiency)": 13.38,
|
|
"CO2eq emission factor kgCO2eq/kWh (Gross CV)": 0.404},
|
|
{"Fuel": "Coal", "Price (p)": 48.50, "Unit": "kg", "Boiler Efficiency (%)": 75,
|
|
"Energy Content, Net Calorific value (kWh/unit)": 7.95, "Price per kWh (p) (inc boiler efficiency)": 8.13,
|
|
"CO2eq emission factor kgCO2eq/kWh (Gross CV)": 0.404},
|
|
{"Fuel": "GSHP", "Price (p)": 28.58, "Unit": "kWh", "Boiler Efficiency (%)": 350,
|
|
"Energy Content, Net Calorific value (kWh/unit)": 1.00, "Price per kWh (p) (inc boiler efficiency)": 8.17,
|
|
"CO2eq emission factor kgCO2eq/kWh (Gross CV)": 0.079},
|
|
{"Fuel": "ASHP", "Price (p)": 28.58, "Unit": "kWh", "Boiler Efficiency (%)": 294,
|
|
"Energy Content, Net Calorific value (kWh/unit)": 1.00, "Price per kWh (p) (inc boiler efficiency)": 9.72,
|
|
"CO2eq emission factor kgCO2eq/kWh (Gross CV)": 0.094}
|
|
])
|
|
|
|
EPC_BANDS = ["G", "F", "E", "D", "C", "B", "A"]
|
|
|
|
@classmethod
|
|
def estimate(cls, kwh: float):
|
|
"""
|
|
Estimate the annual bill savings based on the kwh savings
|
|
:param kwh: The kwh savings
|
|
:return: An estimate for annual bill savings
|
|
"""
|
|
return cls.PRICE_FACTOR * kwh
|
|
|
|
@classmethod
|
|
def estimate_electric(cls, kwh: float):
|
|
"""
|
|
Estimate the annual bill savings based on the kwh savings
|
|
:param kwh: The kwh savings
|
|
:return: An estimate for annual bill savings
|
|
"""
|
|
return cls.ELECTRICITY_PRICE_CAP * kwh
|
|
|
|
@classmethod
|
|
def calculate_annual_bill(cls, kwh, mains_gas=True):
|
|
"""
|
|
This method will estimate the total annual bill for a property
|
|
It assumed gas & electricity are used
|
|
:param kwh: The total kwh consumption
|
|
:param mains_gas: Whether the property uses mains gas
|
|
:return: An estimate for annual bill
|
|
"""
|
|
|
|
if mains_gas:
|
|
return cls.PRICE_FACTOR * kwh + (
|
|
cls.DAILY_STANDARD_CHARGE_GAS + cls.DAILY_STANDARD_CHARGE_ELECTRICITY * 365)
|
|
|
|
return cls.ELECTRICITY_PRICE_CAP * kwh + (cls.DAILY_STANDARD_CHARGE_ELECTRICITY * 365)
|
|
|
|
@staticmethod
|
|
def calculate_occupants(total_floor_area):
|
|
"""
|
|
From Table 1b of the SAP 2012 documentation https://bregroup.com/documents/d/bre-group/sap-2012_9-92
|
|
Provides a methodology to estimate occupancy, based on floor area. This is used to calculate the amount of
|
|
electricity used be appliances and during cooking.
|
|
:param total_floor_area:
|
|
:return:
|
|
"""
|
|
|
|
if total_floor_area <= 13.9:
|
|
return 1
|
|
|
|
return 1 + (1.76 * (1 - np.exp(-0.000349 * (total_floor_area - 13.9) * (total_floor_area - 13.9))) + 0.0013 * (
|
|
total_floor_area - 13.9))
|
|
|
|
@staticmethod
|
|
def estimate_electrical_appliances(occupants, total_floor_area):
|
|
"""
|
|
From secion L2 of SAP2012 Electrical appliances
|
|
https://bregroup.com/documents/d/bre-group/sap-2012_9-92
|
|
Used to estimate the amount of energy used by electrical appliances
|
|
:param occupants:
|
|
:param total_floor_area:
|
|
:return:
|
|
"""
|
|
e_a = 207.8 * np.power(total_floor_area * occupants, 0.4717)
|
|
|
|
days_in_month = {
|
|
1: 31,
|
|
2: 28,
|
|
3: 31,
|
|
4: 30,
|
|
5: 31,
|
|
6: 30,
|
|
7: 31,
|
|
8: 31,
|
|
9: 30,
|
|
10: 31,
|
|
11: 30,
|
|
12: 31
|
|
}
|
|
|
|
eam = 0
|
|
for m in range(1, 13):
|
|
nm = days_in_month[m]
|
|
eam += e_a * (1 + 0.157 * np.cos(2 * np.pi * (m - 1.78) / 12)) * nm / 365
|
|
|
|
return eam
|
|
|
|
@classmethod
|
|
def estimate_appliances_energy_use(cls, total_floor_area):
|
|
# The EPC energy consumption does not factor in cooking and applicance use, so this is estimated using the
|
|
# methodology outlined in SAP, and is discussed in the UCL paper in section 3.1.1
|
|
estimated_occupants = cls.calculate_occupants(total_floor_area=total_floor_area)
|
|
appliances_energy_use = cls.estimate_electrical_appliances(estimated_occupants, total_floor_area)
|
|
return appliances_energy_use
|
|
|
|
@classmethod
|
|
def adjust_energy_to_metered(cls, epc_energy, current_epc_rating):
|
|
"""
|
|
The over-prediction of energy use by EPCs in Great Britain: A comparison
|
|
of EPC-modelled and metered primary energy use intensity
|
|
|
|
Which can be found here: https://www.sciencedirect.com/science/article/pii/S0378778823002542
|
|
We implement the results on page 10
|
|
|
|
This is used to just re-map the cost from the EPC to the metered cost
|
|
epc_energy could be cost or kwh
|
|
:return:
|
|
"""
|
|
|
|
gradients = {
|
|
"A": -0.1,
|
|
"B": -0.1,
|
|
"C": -0.43,
|
|
"D": -0.52,
|
|
"E": -0.7,
|
|
"F": -0.76,
|
|
"G": -0.76
|
|
}
|
|
|
|
intercepts = {
|
|
"A": 28,
|
|
"B": 28,
|
|
"C": 97,
|
|
"D": 119,
|
|
"E": 160,
|
|
"F": 157,
|
|
"G": 157
|
|
}
|
|
|
|
gradient = gradients[current_epc_rating]
|
|
intercept = intercepts[current_epc_rating]
|
|
|
|
# This should be negative
|
|
consumption_difference = gradient * epc_energy + intercept
|
|
consumption_difference = 0 if consumption_difference > 0 else consumption_difference
|
|
|
|
adjusted_consumption = (epc_energy + consumption_difference)
|
|
if adjusted_consumption < 0:
|
|
raise ValueError("consumption_difference should be negative")
|
|
|
|
return adjusted_consumption
|
|
|
|
@classmethod
|
|
def adjust_expected_band(cls, expected_epc_rating, current_epc_rating):
|
|
"""
|
|
Because of the differing intercepts and intercepts when adjusting, it's possible for
|
|
expected_adjusted_energy to be bigger than current_adjusted_energy. In this case, we'll
|
|
adjust, against at most 1 EPC band above the curent. This function performs the EPC adjustment
|
|
:param expected_epc_rating: The expected EPC rating
|
|
:param current_epc_rating: The current EPC rating
|
|
"""
|
|
|
|
# Find index of expected EPC rating
|
|
expected_index = cls.EPC_BANDS.index(expected_epc_rating)
|
|
current_index = cls.EPC_BANDS.index(current_epc_rating)
|
|
|
|
if expected_index - 1 < current_index:
|
|
return current_epc_rating
|
|
|
|
return cls.EPC_BANDS[expected_index - 1]
|
|
|
|
@staticmethod
|
|
def cost_per_kwh(price_per_unit, energy_content_per_unit):
|
|
"""
|
|
Calculate the cost of fuel per kWh given the price per unit in GBP and the energy content per unit in kWh.
|
|
"""
|
|
cost_per_kwh = price_per_unit / energy_content_per_unit
|
|
# Tgis data is returned in pennies so we convert to pounds
|
|
return cost_per_kwh / 100
|
|
|
|
@classmethod
|
|
def calculate_recommendation_fuel_cost(cls, kwh, fuel, cop):
|
|
if fuel == "Electricity":
|
|
return (kwh / cop) * cls.ELECTRICITY_PRICE_CAP
|
|
|
|
if fuel == "Natural Gas":
|
|
return (kwh / cop) * cls.GAS_PRICE_CAP
|
|
|
|
if fuel == "LPG":
|
|
# Get the cost per kwh
|
|
price_data = cls.FUEL_DATA[cls.FUEL_DATA["Fuel"] == "LPG"].squeeze()
|
|
cost_per_kwh = cls.cost_per_kwh(
|
|
price_data["Price (p)"], price_data["Energy Content, Net Calorific value (kWh/unit)"]
|
|
)
|
|
|
|
return (kwh / cop) * cost_per_kwh
|
|
|
|
if fuel == "Wood":
|
|
price_data = cls.FUEL_DATA[cls.FUEL_DATA["Fuel"] == "Pellets (Bagged)"].squeeze()
|
|
cost_per_kwh = cls.cost_per_kwh(
|
|
price_data["Price (p)"], price_data["Energy Content, Net Calorific value (kWh/unit)"]
|
|
)
|
|
|
|
return (kwh / cop) * cost_per_kwh
|
|
|
|
raise Exception("Fuel not recognised")
|