Comed’s Hourly Pricing Program is an alternative to traditional fixed pricing, where you pay for electricity at the current hourly market rate instead of a fixed price. Using Home Assistant and an AppDaemon app, it’s possible to use automation to reduce your energy usage during peak times, saving you money. If you have a different power provider than ComEd that offers hourly pricing, the implementation here should work in principle though it may require tweaking depending on the terms of your provider’s service. You may also want to have other things happen in addition to or in place of turning off your thermostat.
The first way to save on your electrical bill in the Hourly Pricing Program is by reducing electricity usage when the current market rate for electricity spikes. The implementation for this is simple. I have a slider in Home Assistant that sets an electricity price threshold. If the current market price for electricity exceeds that threshold, the app turns off my thermostat and sends me a push notification. Once the price drops below the threshold the thermostat is restored to its previous state.
With the simple part out of the way, let’s jump into the more complicated part. One of the biggest factors contributing to your electricity cost in the Hourly Pricing Program is the Capacity Charge. The Capacity Charge is an amount that every ComEd customer pays monthly and allows ComEd to bring on additional energy capacity during peak demand times. Traditional fixed-price customers have their Capacity Charge built in to their fixed electricity rate but Hourly Pricing customers have a separate line item on their bill for it. Your Capacity Charge is a key factor in determining whether the Hourly Pricing Program ends up saving you money or costing you more than you would have paid using traditional fixed pricing.
Your Capacity Charge is recalculated once per year (in June) and you pay that same charge every month for the following year. The Capacity Charge is calculated by multiplying your Capacity Obligation by the Capacity Charge Rate. The Capacity Charge Rate is a fixed number calculated by ComEd (also once per year, in June) and you have no direct control over it. What you do have control over is your Capacity Obligation. This is calculated from your electricity usage during the previous summer. So in June of 2019 your Capacity Obligation will be recalculated based on your usage in the summer (defined as June 1st through September 30th) of 2018.
Your Capacity Obligation is determined by looking at your usage during 10 one hour windows in the previous summer. Five of those hours are the 5 peak load events for ComEd, and the other five are the peak load events for PJM (PJM is basically a regional group made up of multiple power companies such as ComEd). In other words, the calculation looks at how much electricity you used during the 10 times when the total power company demand was greatest last summer. The five ComEd peak hours may or may not be the same as the five PJM peak hours. Your usage during these ten peak hours is averaged together to determine your Capacity Obligation. The time periods begin and end on the hour. In other words, if one of the peak load times occurs at 3:45 PM, your usage for that peak load time will be your usage from 3:00 - 4:00 PM on that day. An important thing to note is that there can only be one peak hour event per day in each of the two categories (ComEd and PJM). In other words, the five actual ComEd peak load hours might all occur one after the other on the same day, however, only the single highest peak load hour from that day will be used.
There is no way to know for certain when the 10 peak hours have occurred until the summer is over. However, monitoring the load data in real time gives us a pretty good way of predicting when a peak hour event is about to happen. To do this we use sensors to track the current load data for ComEd and PJM. I’ve written an AppDaemon app that uses these sensors to help predict when a peak hour event is about to happen and take appropriate action, which for me consists of turning off my thermostat and sending me a push notification alerting me of the possible peak event. Here’s how it works:
First, we have two different SQL sensors, each used once for ComEd and once for PJM, so four in total. These sensors look at the historical data in your Home Assistant database for ComEd and PJM current loads. It’s important to note that you’ll need to have a database purge_interval long enough to not delete any of this year’s load data, so at least 4 months worth of data (June through September). The first SQL sensor grabs the 5th highest peak load value for this season. Because we’re concerned about the top 5 peak load events, we know that if the current load value surpasses at least the current 5th highest load value that it will make it into the top 5. Note that as new peak load events occur, this value will increase. The second SQL sensor grabs the highest load value for the current hour. In other words, if a peak load event occurs at 4:01 PM, the sensor will return that peak load value until that hour has passed and 5:00 PM comes around.
The app uses these four sensors and compares the current real time PJM and ComEd load data with historical load data to determine whether or not a peak load event is likely to occur. The logic contains a few checks. The app does nothing if it’s not summer. It then looks at a hard-coded value (PJM_COMED_MINIMUM_LOAD
) and if the load doesn’t meet that minimum threshold the app does nothing. This is because when summer first starts (June 1st) we will have no historical data yet for the current year and any high load would be treated as a potential peak load. After passing these checks, the app calculates the ratio of the current load to the 5th highest peak load so far. Once that ratio hits 99% (this is adjustable using LOAD_RATIO_THRESHOLD
) we know a peak load event could be approaching. Then it looks at the derivative (rate of change) of the current load and compares it to our rate thresholds (PJM_COMED_LOAD_RATE_PER_HOUR
and PJM_TOTAL_LOAD_RATE_PER_HOUR
). When the load is ramping up and the rate of increase is high, the peak event is not imminent. Prior to the peak occurring, the rate of change will slow down and approach 0. Once it hits 0 and begins to go negative, the peak has occurred. However, we need to predict the peak before it happens. For example, if the peak occurs at 3:59PM, we want to reduce our usage for the entire hour starting at 3:00PM. The rate thresholds can be tweaked if you like. If you set the thresholds 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. The rates I chose were based on looking at the load rate of change for the one hour period right before the peak load events occurred for 2018.
Once a peak occurs we remain in reduced usage mode for the entire hour after the peak is over. We consider the peak over once the load rate of change is sufficiently negative. After this, the thermostat is restored to its previous state (i.e. cooling).
Using this system last year I was able to reduce my Capacity Obligation from 3.5 kWh to about 0.7 kWh. Note that this system works much better if your house is well-insulated and able to go for a few hours without running the air conditioner during the heat of summer. If not, you may be miserable implementing this, though you could still use it to send yourself alerts during possible peaks and manually reduce your energy usage.
If you are not yet enrolled in Hourly Pricing, note that you can (and should) call ComEd’s Hourly Pricing department before switching to Hourly Pricing and ask them what your Capacity Charge would be if you were to switch. You can also check your Capacity Charge on by entering your account number on their website here. If you had a smart meter last summer, your Capacity Charge will be calculated based on your usage during last summer’s peak load times, just as if you had been enrolled in Hourly Pricing. You may determine that switching would not be to your advantage at this time. However, you could run this app this summer to keep your peak load usage low while still on traditional fixed pricing, and then switch to Hourly Pricing for next summer, taking advantage of the lower Capacity Charge you’ll see in June when it’s recalculated.
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 as a flag to know whether or not ‘Eco mode’ was activated due to this script vs. someone manually turning off the thermostat.
For reference, the list of ComEd coincident peaks is here, and the list of PJM coincident peaks is here.
Hopefully this is useful for someone else. Questions / comments / suggestions welcome.
PJM Sensor (place in custom_components/pjm
directory)
Home Assistant configuration:
Variables:
input_boolean:
# Used to keep track of whether Eco mode was activated by home assistant due to high prices
eco_activated_due_to_price:
name: Eco Mode Activated Due to High Electricity Price
initial: off
input_select:
# Used as a variable to store the current thermostat mode when we switch to Eco mode
# so that we can restore it to this state later
saved_thermostat_mode:
options:
- 'none'
- 'off'
- 'heat'
- 'cool'
- 'auto'
- 'eco'
initial: 'none'
input_number:
# This is used to set what electricity price threshold activates Nest eco mode
comed_price_threshold:
name: ComEd High Electricity Price Threshold
initial: 19.0
min: 10.0
max: 35.0
step: 0.1
Sensors:
sensor:
- platform: template
sensors:
nest_home_away:
friendly_name: "Nest Home/Away State"
value_template: "{% if is_state_attr('climate.kitchen', 'away_mode', 'off') %}home{% else %}away{% endif %}"
- platform: comed_hourly_pricing
monitored_feeds:
- type: five_minute
- type: current_hour_average
- type: five_minute
offset: 11.2
name: "ComEd Total 5 Minute Price"
- type: current_hour_average
offset: 11.2
name: "ComEd Total Current Hour Average Price"
- platform: pjm
monitored_variables:
- type: instantaneous_total_load
- type: instantaneous_zone_load
zone: 'COMED'
SQL sensors (add via the SQL integration in the web UI):
# Get the 5th highest PJM hourly total load value since June 1st of this year
Name: PJM Total Load High Marker
Column: 'state'
Unit of measurement: 'MW'
SELECT null; WITH data AS (
SELECT last_updated_ts, cast(state AS integer) AS state FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY date_trunc('day', to_timestamp(last_updated_ts))
ORDER BY cast(state as integer) DESC) AS _rn
FROM states
WHERE metadata_id = (
SELECT metadata_id
FROM states_meta
WHERE entity_id = 'sensor.pjm_total_load_current_hour_high'
)
AND state != 'unknown' AND state != ''
AND last_updated_ts > extract (epoch from make_date(cast(extract(year FROM (date_trunc('year', now() - interval '5 month'))) AS integer), 6, 1))
AND last_updated_ts < extract (epoch from make_date(cast(extract(year FROM (date_trunc('year', now() - interval '5 month'))) AS integer), 10, 1))
) AS _max
WHERE _rn = 1
ORDER BY cast(state AS integer) desc
LIMIT 5
), total_count AS (
SELECT LEAST(COUNT(*), 5) AS c FROM data
) SELECT last_updated_ts, state FROM data LIMIT 1 OFFSET ((SELECT c FROM total_count) - 1);
# Get the 5th highest Comed hourly load value since June 1st of this year
Name: PJM Comed Load High Marker
Column: 'state'
Unit of measurement: 'MW'
SELECT null; WITH data AS (
SELECT last_updated_ts, cast(state AS integer) AS state FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY date_trunc('day', to_timestamp(last_updated_ts))
ORDER BY cast(state as integer) DESC) AS _rn
FROM states
WHERE metadata_id = (
SELECT metadata_id
FROM states_meta
WHERE entity_id = 'sensor.pjm_comed_load_current_hour_high'
)
AND state != 'unknown' AND state != ''
AND last_updated_ts > extract (epoch from make_date(cast(extract(year FROM (date_trunc('year', now() - interval '5 month'))) AS integer), 6, 1))
AND last_updated_ts < extract (epoch from make_date(cast(extract(year FROM (date_trunc('year', now() - interval '5 month'))) AS integer), 10, 1))
) AS _max
WHERE _rn = 1
ORDER BY cast(state AS integer) desc
LIMIT 5
), total_count AS (
SELECT LEAST(COUNT(*), 5) AS c FROM data
) SELECT last_updated_ts, state FROM data LIMIT 1 OFFSET ((SELECT c FROM total_count) - 1);
# Get the highest PJM total load value from the last hour, starting on the hour,
# but if we don't have data yet for this hour, get the last value from the previous hour
# NOTE: The SQL sensor only works if the query starts with the word 'select' so we have
# to add in a dummy select. It also adds a LIMIT clause if the query doesn't contain
# the uppercase string 'LIMIT'
Name: PJM Total Load Current Hour High
Column: 'state'
Unit of measurement: 'MW'
SELECT null; WITH t1 AS
(SELECT MAX(CAST(state AS integer)) AS state
FROM states
WHERE metadata_id = (
SELECT metadata_id
FROM states_meta
WHERE entity_id = 'sensor.pjm_instantaneous_total_load'
)
AND last_updated_ts >= extract(epoch from date_trunc('hour', now()))
AND state != 'unknown'
AND state != ''
)
SELECT t1.*
FROM t1
UNION ALL
(SELECT CAST(state AS integer) AS state2
FROM states
WHERE metadata_id = (
SELECT metadata_id
FROM states_meta
WHERE entity_id = 'sensor.pjm_instantaneous_total_load'
)
AND last_updated_ts < extract(epoch from date_trunc('hour', now()))
AND state != 'unknown'
AND state != ''
AND NOT EXISTS
(SELECT state
FROM t1
WHERE state > 0
)
ORDER BY last_updated_ts DESC
LIMIT 1
);
# Get the highest PJM Comed load value from the last hour, starting on the hour,
# but if we don't have data yet for this hour, get the last value from the previous hour
# NOTE: The SQL sensor only works if the query starts with the word 'select' so we have
# to add in a dummy select. It also adds a LIMIT clause if the query doesn't contain
# the uppercase string 'LIMIT'
Name: PJM Comed Load Current Hour High
Column: 'state'
Unit of measurement: 'MW'
SELECT null; WITH t1 AS
(SELECT MAX(CAST(state AS integer)) AS state
FROM states
WHERE metadata_id = (
SELECT metadata_id
FROM states_meta
WHERE entity_id = 'sensor.pjm_instantaneous_zone_load_comed'
)
AND last_updated_ts >= extract (epoch from date_trunc('hour', now()))
AND state != 'unknown'
AND state != ''
)
SELECT t1.* FROM t1 UNION ALL
(SELECT CAST(state AS integer) AS state2
FROM states
WHERE metadata_id = (
SELECT metadata_id
FROM states_meta
WHERE entity_id = 'sensor.pjm_instantaneous_zone_load_comed'
)
AND last_updated_ts < extract (epoch from date_trunc('hour', now()))
AND state != 'unknown'
AND state != ''
AND NOT EXISTS
(SELECT state
FROM t1
WHERE state > 0
)
ORDER BY last_updated_ts DESC
LIMIT 1
);