Model/backend/ml_models/AnnualBillSavings.py
2024-08-19 19:06:45 +01:00

322 lines
14 KiB
Python

import numpy as np
import pandas as pd
import backend.app.assumptions as assumptions
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 in ["Natural Gas", "Natural Gas (Community Scheme)"]:
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 in ["Wood Logs", "Wood Pellets"]:
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
if fuel == "Natural Gas + Solar Thermal":
# The solar thermal covers a % of the heating kwh, so we need to adjust the cost
return (kwh / cop) * assumptions.SOLAR_CONSUMPTION_PROPORTION * cls.GAS_PRICE_CAP
if fuel == "Electricity + Solar Thermal":
# The solar thermal covers a % of the heating kwh, so we need to adjust the cost
return (kwh / cop) * assumptions.SOLAR_CONSUMPTION_PROPORTION * cls.ELECTRICITY_PRICE_CAP
if fuel == "LPG + Solar Thermal":
# The solar thermal covers a % of the heating kwh, so we need to adjust the cost
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 * assumptions.SOLAR_CONSUMPTION_PROPORTION
if fuel == "Oil":
price_data = cls.FUEL_DATA[cls.FUEL_DATA["Fuel"] == "Kerosene"].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 in ["Smokeless Fuel", "Anthracite"]:
price_data = cls.FUEL_DATA[cls.FUEL_DATA["Fuel"] == "Smokeless fuel"].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
# We use coal's values for
if fuel == "Coal":
price_data = cls.FUEL_DATA[cls.FUEL_DATA["Fuel"] == "Coal"].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")