Skip to content

Instantly share code, notes, and snippets.

@joe248
Last active June 21, 2023 12:56
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save joe248/4c35be8001981eee7cd016cf05b97c42 to your computer and use it in GitHub Desktop.
Save joe248/4c35be8001981eee7cd016cf05b97c42 to your computer and use it in GitHub Desktop.
Home Assistant AppDaemon App to reduce energy usage during ComEd peak load events
# Helpful links:
# https://www.pjm.com/-/media/planning/res-adeq/load-forecast/20181017-summer-2018-peaks-and-5cps.ashx?la=en
# https://www.comed.com/SiteCollectionDocuments/MyAccount/MyService/ComEd_5_Peaks.pdf
# https://www.citizensutilityboard.org/blog/2019/01/23/comeds-hourly-pricing-how-to-calculate-the-customer-capacity-charge/
# https://secure.comed.com/MyAccount/MyService/Pages/UsageDataTool.aspx
# NOTE: This was originally written for a Nest thermostat, hence the use of the term 'Eco mode'. Instead of turning the thermostat off during times of high load or high price, I would just switch it into Eco mode. I no longer have a Nest so I now just switch my thermostat off, but I still refer to this as Eco mode in the script. I use the ECO_ACTIVATED_DUE_TO_PRICE input_boolean in Home Assistant as a flag to know whether or not 'Eco mode' was activated due to this script vs. someone manually turning off the thermostat.
import appdaemon.plugins.hass.hassapi as hass
import datetime
import sqlalchemy
import random
# The load ratio is the ratio of the current load to the previous peak load. If the current load ratio doesn't meet this threshold the script doesn't take action
LOAD_RATIO_THRESHOLD = 0.99
# The script won't take action if the load is less than the minimum
PJM_COMED_MINIMUM_LOAD = 17500
PJM_TOTAL_MINIMUM_LOAD = 130000
# The hourly rate of change of the load is compared against these values. When the peak occurs, the rate of change will go from positive to negative, but we want to catch it before the peak occurs so we can reduce our usage. If you set the rates too high you'll err on the side of caution and may end up reducing your usage (i.e. not running your air conditioner when it's really hot out) for several hours before the actual peak event. If you set the rates too low you may end up not predicting the peak event before it occurs and therefore not reducing your usage when you needed to.
PJM_COMED_LOAD_RATE_PER_HOUR = 350
PJM_TOTAL_LOAD_RATE_PER_HOUR = 2200
PJM_COMED_1H_3H_THRESHOLD = 0.50
PJM_TOTAL_1H_3H_THRESHOLD = 0.66667
DB_URL = 'postgresql://@/homeassistant'
NOTIFY_SERVICE = 'notify/mobile_app_joes_iphone_12_pro'
ECO_ACTIVATED_DUE_TO_PRICE = 'input_boolean.eco_activated_due_to_price'
SAVED_THERMOSTAT_MODE = 'input_select.saved_thermostat_mode'
THERMOSTAT = 'climate.downstairs_thermostat'
COMED_CURRENT_HOUR_AVERAGE_PRICE = 'sensor.comed_total_current_hour_average_price'
COMED_FIVE_MINUTE_PRICE = 'sensor.comed_total_5_minute_price'
COMED_PRICE_THRESHOLD = 'input_number.comed_price_threshold'
PJM_COMED_INSTANTANEOUS_LOAD = 'sensor.pjm_instantaneous_zone_load_comed'
PJM_COMED_LOAD_CURRENT_HOUR_HIGH = 'sensor.pjm_comed_load_current_hour_high'
PJM_COMED_LOAD_HIGH_MARKER = 'sensor.pjm_comed_load_high_marker'
PJM_TOTAL_INSTANTANEOUS_LOAD = 'sensor.pjm_instantaneous_total_load'
PJM_TOTAL_LOAD_CURRENT_HOUR_HIGH = 'sensor.pjm_total_load_current_hour_high'
PJM_TOTAL_LOAD_HIGH_MARKER = 'sensor.pjm_total_load_high_marker'
class ElectricityCostManager(hass.Hass):
def initialize(self):
from sqlalchemy.orm import sessionmaker, scoped_session
try:
engine = sqlalchemy.create_engine(DB_URL)
self.sessionmaker = scoped_session(sessionmaker(bind=engine))
# run a dummy query just to test the db_url
sess = self.sessionmaker()
sess.execute("SELECT 1;")
except sqlalchemy.exc.SQLAlchemyError as err:
_LOGGER.error("Couldn't connect using %s DB_URL: %s", DB_URL, err)
return
# Flag stating whether or not we are currently updating
self.is_updating = False
# Store the last time we updated
self.last_update_time = datetime.datetime.fromtimestamp(0)
# Store the reason for entering Eco mode
self.last_reason = None
# Store the last time that we changed the thermostat state
self.last_thermostat_state_change_time = None
# Listen for ComEd hourly average price change
self.listen_state(self.comed_price_change_callback, entity_id=COMED_CURRENT_HOUR_AVERAGE_PRICE)
# Listen for PJM regional ComEd load high marker, current hour high, and instantaneous load changes
self.listen_state(self.pjm_comed_load_current_hour_high_callback,
entity_id=PJM_COMED_LOAD_CURRENT_HOUR_HIGH)
self.listen_state(self.pjm_comed_load_high_marker_callback,
entity_id=PJM_COMED_LOAD_HIGH_MARKER)
self.listen_state(self.pjm_comed_instantaneous_load_callback,
entity_id=PJM_COMED_INSTANTANEOUS_LOAD)
# Listen for PJM total load high marker, current hour high, and instantaneous load changes
self.listen_state(self.pjm_total_load_current_hour_high_callback,
entity_id=PJM_TOTAL_LOAD_CURRENT_HOUR_HIGH)
self.listen_state(self.pjm_total_load_high_marker_callback,
entity_id=PJM_TOTAL_LOAD_HIGH_MARKER)
self.listen_state(self.pjm_total_instantaneous_load_callback,
entity_id=PJM_TOTAL_INSTANTANEOUS_LOAD)
# Listen for ComEd price threshold slider changes
self.listen_state(self.comed_price_threshold_callback,
entity_id=COMED_PRICE_THRESHOLD)
self.update_state()
def comed_price_change_callback(self, entity, attribute, old, new, kwargs):
#self.log("ComEd current hour average price changed to {}".format(new))
price_threshold = float(self.get_state(COMED_PRICE_THRESHOLD))
if float(old) <= price_threshold and float(new) > price_threshold:
self.log("ComEd hourly price went over threshold from {} to {}".format(old, new))
self.update_state()
elif float(old) > price_threshold and float(new) <= price_threshold:
self.log("ComEd hourly price went below threshold from {} to {}".format(old, new))
self.update_state()
def pjm_comed_load_current_hour_high_callback(self, entity, attribute, old, new, kwargs):
self.log("PJM ComEd current hour high load changed to {}".format(new))
self.update_state()
def pjm_comed_load_high_marker_callback(self, entity, attribute, old, new, kwargs):
self.log("PJM ComEd all-summer high load marker changed to {}".format(new))
self.update_state()
def pjm_comed_instantaneous_load_callback(self, entity, attribute, old, new, kwargs):
self.log("PJM ComEd instantaneous load changed to {}".format(new))
self.update_state()
def pjm_total_load_current_hour_high_callback(self, entity, attribute, old, new, kwargs):
self.log("PJM total current hour high load changed to {}".format(new))
self.update_state()
def pjm_total_load_high_marker_callback(self, entity, attribute, old, new, kwargs):
self.log("PJM total all-summer high load marker changed to {}".format(new))
self.update_state()
def pjm_total_instantaneous_load_callback(self, entity, attribute, old, new, kwargs):
self.log("PJM total instantaneous load changed to {}".format(new))
self.update_state()
def comed_price_threshold_callback(self, entity, attribute, old, new, kwargs):
self.log("ComEd price threshold changed to {}".format(new))
comed_hourly_price = float(self.get_state(COMED_CURRENT_HOUR_AVERAGE_PRICE))
if float(old) < comed_hourly_price and float(new) >= comed_hourly_price:
self.log("ComEd price threshold {} increased above hourly price {}".format(new, comed_hourly_price))
self.update_state()
elif float(old) >= comed_hourly_price and float(new) < comed_hourly_price:
self.log("ComEd price threshold {} decreased below hourly price {}".format(new, comed_hourly_price))
self.update_state()
def update_state(self):
#self.log("Update state")
# Determine the last time we updated, in case something happens and our is_updating flag doesn't get reset, use a watchdog of 60 seconds to ensure we keep updating
current_time = datetime.datetime.now()
time_delta = current_time - self.last_update_time
if (not self.is_updating) or (time_delta.seconds > 60):
# Some events, such as the PJM total and ComEd load values changing, fire two events very quickly. We want to delay slightly before processing so that the second value has updated before we attempt to read it
self.is_updating = True
# Delay for a random amount of time so that two threads trying to update at the same time won't do so
delay_time = random.randint(1, 4)
self.log("Delaying for {} seconds...".format(delay_time))
self.run_in(self.do_update_state, delay_time)
def do_update_state(self, kwargs):
self.log("Running")
eco_activated_due_to_price = self.get_state(ECO_ACTIVATED_DUE_TO_PRICE)
saved_thermostat_mode = self.get_state(SAVED_THERMOSTAT_MODE)
thermostat_current_operation_mode = self.get_state(THERMOSTAT)
comed_hourly_price = float(self.get_state(COMED_CURRENT_HOUR_AVERAGE_PRICE))
comed_five_minute_price = float(self.get_state(COMED_FIVE_MINUTE_PRICE))
price_threshold = float(self.get_state(COMED_PRICE_THRESHOLD))
pjm_comed_load_current_hour_high = int(self.get_state(PJM_COMED_LOAD_CURRENT_HOUR_HIGH))
pjm_comed_load_last_hour_high = int(self.get_entity_previous_hour_high(PJM_COMED_LOAD_CURRENT_HOUR_HIGH))
pjm_comed_load_high_marker = int(self.get_state(PJM_COMED_LOAD_HIGH_MARKER))
pjm_total_load_current_hour_high = int(self.get_state(PJM_TOTAL_LOAD_CURRENT_HOUR_HIGH))
pjm_total_load_last_hour_high = int(self.get_entity_previous_hour_high(PJM_TOTAL_LOAD_CURRENT_HOUR_HIGH))
pjm_total_load_high_marker = int(self.get_state(PJM_TOTAL_LOAD_HIGH_MARKER))
# Calculate the current PJM and ComEd loads as a percentage of our high load markers
pjm_comed_load_ratio = float(pjm_comed_load_current_hour_high) / float(pjm_comed_load_high_marker)
pjm_total_load_ratio = float(pjm_total_load_current_hour_high) / float(pjm_total_load_high_marker)
# We add 5 seconds to each interval because of the fact that we have a random delay when we update, when we're looking for the old value to calculate the derivative
pjm_total_load_rate_of_change = self.get_entity_rate_of_change(PJM_TOTAL_INSTANTANEOUS_LOAD, '1 hour 5 second')
pjm_comed_load_rate_of_change = self.get_entity_rate_of_change(PJM_COMED_INSTANTANEOUS_LOAD, '1 hour 5 second')
pjm_total_load_rate_of_change_15_minutes = self.get_entity_rate_of_change(PJM_TOTAL_INSTANTANEOUS_LOAD, '15 minute 5 second')
pjm_comed_load_rate_of_change_15_minutes = self.get_entity_rate_of_change(PJM_COMED_INSTANTANEOUS_LOAD, '15 minute 5 second')
pjm_total_load_rate_of_change_3_hours = self.get_entity_rate_of_change(PJM_TOTAL_INSTANTANEOUS_LOAD, '3 hour 5 second')
pjm_comed_load_rate_of_change_3_hours = self.get_entity_rate_of_change(PJM_COMED_INSTANTANEOUS_LOAD, '3 hour 5 second')
pjm_total_1h_3h_rate = pjm_total_load_rate_of_change / pjm_total_load_rate_of_change_3_hours
pjm_comed_1h_3h_rate = pjm_comed_load_rate_of_change / pjm_comed_load_rate_of_change_3_hours
pjm_total_15m_1h_rate = pjm_total_load_rate_of_change_15_minutes / pjm_total_load_rate_of_change
pjm_comed_15m_1h_rate = pjm_comed_load_rate_of_change_15_minutes / pjm_comed_load_rate_of_change
# We only need to worry about PJM and ComEd load during the summer months (June 1 - Sept 30)
month = datetime.datetime.today().month
is_summer = month >= 6 and month <= 9
if is_summer:
# Log Load Rates of Change (LROC)
self.log("PJM LROC: 3h: {}, 1h: {}, 15m: {}".format(pjm_total_load_rate_of_change_3_hours, pjm_total_load_rate_of_change, pjm_total_load_rate_of_change_15_minutes))
#self.log("PJM 1h/3h: {}".format(pjm_total_1h_3h_rate))
#self.log("PJM 15m/1h: {}".format(pjm_total_15m_1h_rate))
self.log("ComEd LROC: 3h: {}, 1h: {}, 15m: {}".format(pjm_comed_load_rate_of_change_3_hours, pjm_comed_load_rate_of_change, pjm_comed_load_rate_of_change_15_minutes))
#self.log("ComEd 1h/3h: {}".format(pjm_comed_1h_3h_rate))
#self.log("ComEd 15m/1h: {}".format(pjm_comed_15m_1h_rate))
self.log("PJM total load last hour high = {}, ComEd load last hour high = {}".format(pjm_total_load_last_hour_high, pjm_comed_load_last_hour_high))
self.log("Updating state. eco_activated_due_to_price = {}, saved_thermostat_mode = {}, thermostat_current_operation_mode = {}, comed_load_ratio = {}, pjm_total_load_ratio = {}, comed_hourly_price = {}".format(eco_activated_due_to_price, saved_thermostat_mode, thermostat_current_operation_mode, pjm_comed_load_ratio, pjm_total_load_ratio, comed_hourly_price))
eco_required = False
reason = None # If reason is set we'll send a notification
if thermostat_current_operation_mode != 'heat' and thermostat_current_operation_mode != 'cool' and not eco_activated_due_to_price:
# Only activate Eco mode if we're currently heating or cooling, or we're already in Eco mode
self.log("Passing")
pass
elif comed_hourly_price > price_threshold:
# The current hourly price is over our threshold
eco_required = True
reason = "high energy price"
elif (pjm_comed_load_current_hour_high > PJM_COMED_MINIMUM_LOAD and \
is_summer and \
( \
# If the current load is higher than the marker we always activate eco
# Actually, don't do this. The actual peak for the day might not occur for
# a few more hours so we don't want to activate Eco until we know we're close to peak
#(pjm_comed_load_current_hour_high >= pjm_comed_load_high_marker) \
#or \
# We are approaching the load marker, on the way up, and over the ratio threshold
(
pjm_comed_load_rate_of_change < PJM_COMED_LOAD_RATE_PER_HOUR \
#(pjm_comed_1h_3h_rate < PJM_COMED_1H_3H_THRESHOLD) \
#or pjm_comed_15m_1h_rate < 0.6) \
and pjm_comed_load_ratio > LOAD_RATIO_THRESHOLD \
and (pjm_comed_load_current_hour_high >= pjm_comed_load_last_hour_high \
or pjm_comed_load_rate_of_change > -80) \
) \
)
):
eco_required = True
reason = "high ComEd energy load event"
elif (pjm_total_load_current_hour_high > PJM_TOTAL_MINIMUM_LOAD and \
is_summer and \
( \
# If the current load is higher than the marker we always activate eco
# Actually, don't do this. The actual peak for the day might not occur for
# a few more hours so we don't want to activate Eco until we know we're close to peak
#(pjm_total_load_current_hour_high >= pjm_total_load_high_marker) \
#or \
# We are approaching the load marker, on the way up, and over the ratio threshold
(
pjm_total_load_rate_of_change < PJM_TOTAL_LOAD_RATE_PER_HOUR \
#(pjm_total_1h_3h_rate < PJM_TOTAL_1H_3H_THRESHOLD \
# or pjm_total_15m_1h_rate < 1.0) \
and pjm_total_load_ratio > LOAD_RATIO_THRESHOLD \
and (pjm_total_load_current_hour_high >= pjm_total_load_last_hour_high \
or pjm_total_load_rate_of_change > -100) \
) \
)
):
eco_required = True
reason = "high PJM energy load event"
else:
# Conditions state we shouldn't use Eco
if thermostat_current_operation_mode.lower() == 'off' and comed_five_minute_price > price_threshold:
# The ComEd five minute price is still higher than our threshold so don't deactivate Eco yet
eco_required = True
if eco_required:
#self.log("Would activate eco")
self.activate_eco(thermostat_current_operation_mode, reason)
else:
self.deactivate_eco(thermostat_current_operation_mode)
self.is_updating = False
self.last_update_time = datetime.datetime.now()
def activate_eco(self, thermostat_current_operation_mode, reason):
if thermostat_current_operation_mode.lower() == 'off':
self.log("Already in Eco mode")
return
eco_activated_due_to_price = self.get_state(ECO_ACTIVATED_DUE_TO_PRICE)
# Only activate if our boolean is set to off. This handles the case where we activate Eco,
# then someone manually deactivates Eco using the thermostat. We don't want to then
# immediately reactivate it. We wait until the boolean gets reset after the price
# decreases below the threshold
if eco_activated_due_to_price == 'on':
self.log("Not activating Eco mode because eco_activated_due_to_price already on")
return
# Sometimes multiple triggers cause us to update state several times within a short period.
# We don't want to send multiple commands to the thermostat so we use a buffer period of
# a few seconds
if self.last_thermostat_state_change_time:
current_time = datetime.datetime.now()
time_delta = current_time - self.last_thermostat_state_change_time
if time_delta.seconds < 5:
self.log("Only {} seconds since we last activated Eco. Doing nothing".format(time_delta.seconds))
return
self.log("Activating Eco mode")
if reason:
# Send a notification
message = "Activating Eco due to " + reason
self.call_service(NOTIFY_SERVICE, message = message)
# Store the reason, even if it's None
self.last_reason = reason
# Store the current thermostat mode so we can recall it later
self.set_state(SAVED_THERMOSTAT_MODE, state=thermostat_current_operation_mode)
# Set our boolean so we know that we activated Eco due to price
self.set_state(ECO_ACTIVATED_DUE_TO_PRICE, state='on')
# Now set the thermostat to Eco mode
self.call_service("climate/set_hvac_mode", entity_id=THERMOSTAT, hvac_mode='off')
# Store the time that we changed the thermostat state
self.last_thermostat_state_change_time = datetime.datetime.now()
def deactivate_eco(self, thermostat_current_operation_mode):
if thermostat_current_operation_mode.lower() != 'off':
#self.log("Not in Eco mode so not deactivating. Resetting eco_activated_due_to_price boolean.")
# This handles the case where Eco was automatically activated but then someone manually
# deactivated it. Once the price drops and this script attempts to deactivate Eco, it
# will do nothing because Eco was already deactivated manually, however, we want to
# reset our boolean so that the next high price event will trigger Eco mode again
self.set_state(ECO_ACTIVATED_DUE_TO_PRICE, state='off')
return
eco_activated_due_to_price = self.get_state(ECO_ACTIVATED_DUE_TO_PRICE)
if (eco_activated_due_to_price != 'on'):
self.log("Eco mode wasn't activated by us so not deactivating")
return
# Sometimes multiple triggers cause us to update state several times within a short period.
# We don't want to send multiple commands to the thermostat so we use a buffer period of
# a few seconds
if self.last_thermostat_state_change_time:
current_time = datetime.datetime.now()
time_delta = current_time - self.last_thermostat_state_change_time
if time_delta.seconds < 5:
self.log("Only {} seconds since we last deactivated Eco. Doing nothing".format(time_delta.seconds))
return
self.log("Deactivating Eco mode")
if self.last_reason:
# Only send a deactivate message if we sent an activate message
self.call_service(NOTIFY_SERVICE, message = "Deactivated Eco mode")
self.last_reason = None
# Clear our boolean
self.set_state(ECO_ACTIVATED_DUE_TO_PRICE, state='off')
# Restore the thermostat to the operation mode it was in when we initiated eco mode
saved_thermostat_mode = self.get_state(SAVED_THERMOSTAT_MODE)
self.call_service("climate/set_hvac_mode", entity_id=THERMOSTAT, hvac_mode=saved_thermostat_mode)
# Store the time that we changed the thermostat state
self.last_thermostat_state_change_time = datetime.datetime.now()
# Reset our saved thermostat mode variables
self.set_state(SAVED_THERMOSTAT_MODE, state='none')
# This gets the derivative of entity_id over interval
def get_entity_rate_of_change(self, entity_id, interval):
try:
sess = self.sessionmaker()
query = "with t1 as (\
select cast(state as integer) as state, \
last_updated_ts as last_updated_epoch, \
to_timestamp(last_updated_ts) as last_updated \
from states \
where metadata_id = (select metadata_id from states_meta \
where entity_id = :entity_id) \
and state != 'unknown' and state != '' \
order by last_updated_ts desc limit 1 \
), t2 as (\
select cast(state as integer) as state, \
last_updated_ts as last_updated_epoch, \
to_timestamp(last_updated_ts) as last_updated \
from states \
where metadata_id = (select metadata_id from states_meta \
where entity_id = :entity_id) \
and state != 'unknown' and state != '' \
and to_timestamp(last_updated_ts) <= (now() - interval :interval) \
order by last_updated_ts desc limit 1 \
) \
select (t1.state - t2.state) as rate, \
t1.state as t1state, t2.state as t2state, \
t1.last_updated as t1lastupdated, t2.last_updated as t2lastupdated, \
(t1.last_updated_epoch - t2.last_updated_epoch) as time_difference \
from t1 inner join t2 on \
t1.state >= t2.state or t1.state <= t2.state;"
result = sess.execute(query, {'entity_id': entity_id, 'interval': interval})
if not result.returns_rows or result.rowcount == 0:
#self.log("query returned no results")
return None
for res in result:
rate = res['rate']
time_difference = res['time_difference']
hourly_rate = (rate / time_difference) * 3600
#t1state = res['t1state']
#t2state = res['t2state']
#t1lastupdated = res['t1lastupdated']
#t2lastupdated = res['t2lastupdated']
#self.log("t1state = {}, t2state = {}. t1lastupdated = {}. t2lastupdated = {}".format(t1state, t2state, t1lastupdated, t2lastupdated))
return int(round(hourly_rate))
except sqlalchemy.exc.SQLAlchemyError as err:
self.log("Error executing query: {}".format(err))
return None
finally:
sess.close()
# This gets the higest value for entity_id during the previous hour
def get_entity_previous_hour_high(self, entity_id):
try:
sess = self.sessionmaker()
query = "select max(cast(state as integer)) as state \
from states \
where metadata_id = (select metadata_id from states_meta \
where entity_id = :entity_id) \
and state != 'unknown' and state != '' \
and last_updated_ts >= extract (epoch from date_trunc('hour', now() - interval '1 hour')) \
and last_updated_ts < extract (epoch from date_trunc('hour', now()));"
result = sess.execute(query, {'entity_id': entity_id})
if not result.returns_rows or result.rowcount == 0:
#self.log("query returned no results")
return None
for res in result:
return res['state']
except sqlalchemy.exc.SQLAlchemyError as err:
self.log("Error executing query: {}".format(err))
return None
finally:
sess.close()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment