Yet Another Heating Degree Days Implementation

I’ve wanted to track heating degree days for a while. It’s come up before, like here, here and here. I took some of these ideas and came up with my own solution.

I decided to go for a real-time calculation, rather than simply taking the mean between the high and low for the day. I created an automation which triggers each time the temperature changes. This automation calculates the following:

  1. What portion of the day has elapsed since the last time the temperature changed.
  2. The “old” temperature which was recorded at the start of this period.
  3. The number of degrees above the base temperature.
  4. The number of degree days experienced during this time period.

This value is added to an input_number which keeps a running total for the day.

I already have an automation which records my heating system daily runtime data. To this I added two actions; save this daily heating degree days number, and zero it out for the next day.

The first step is to create an input_number in configuration.yaml to store the accumulated heating degree days:

input_number:
# Store heating degree days today
  heating_dd:
    name: "Heating Degree Days"
    min: 0
    max: 999
    step: 0.1
    mode: box
    unit_of_measurement: "°F"

Next, the automation to update that number each time the temperature changes:

- id: id_heating_degree_days
  alias: Heating Degree Days
  trigger:
  - platform: state
    entity_id: sensor.nws_currenttemp
  condition:
  - condition: template
    value_template: "{{ trigger.from_state.state|int < 65 }}"
  action:
  - service: input_number.set_value
    target:
      entity_id: input_number.heating_dd
    data:
      value: >
        {% set elapsed_d = ((as_timestamp(trigger.to_state.last_changed) - as_timestamp(trigger.from_state.last_changed)) / 60) / (24 * 60) %}
        {% set old_temp = (trigger.from_state.state | float) %}
        {% set delta_h = 65 - old_temp %}
        {% set partial_hdd = delta_h * elapsed_d %}
        {% set new_hdd = (states("input_number.heating_dd") | float) + partial_hdd %}
          {{ new_hdd }}

Yes, I know all the set statements are unnecessary. I tried doing it all in one long calculation, but there were so many expressions nested so deep in parenthesis that it was hard to follow.

That should be enough to get anyone started. I’ll fill in some more details in another post.

3 Likes

Some more thoughts:

I use the temperature reported by the US National Weather Service. Obviously if you have a local temperature sensor, you’ll have even more accuracy. The math will still work if your sensor changes every tenth of a degree, but that may be a bit much.

For more on heating degree days and cooling degree days, the US National Weather Service defines them here.

My real-time calculation differs from their method of just taking the mean between the day’s high and low. Here’s an exaggerated example of how my calculation works: Suppose the temperature sensor reports a new temperature of 49, and the last change was to 45, three hours ago.

3 hours is one-eighth of a day, or 0.125 of the day. 45 is 20 degrees below our base of 65. I multiply that 20 by the elapsed portion of the day (0.125). That adds 5 heating degree days to the total.

I use 65F as my base. You could use other temperatures. I have a similar automation for cooling degree days, for which I’m going to try using 75F as my base. I figure I wouldn’t run the air conditioner if it’s below that.

All of this data is used outside of HA. I started recording my heating and cooling system run-times to plain old text files long before the energy stuff was added. Presumably you could also keep it all within HA.

You could use a template sensor for this. No need for an input_number and automation.

Thanks! I was actually hoping for ideas on making this more elegant. When I think of coding logic (“if > 65 degrees…”) I tend to jump to automations. I forget that templates are a coding language of their own.

I think I have an idea how this would work as a template. I use lots of templates already. But this would be more complex than any I’ve used before.

This bit:

(as_timestamp(trigger.to_state.last_changed) - as_timestamp(trigger.from_state.last_changed)

Might require you to use a triggered template sensor, which I previously would not have recommended but as of 2022.5 they are restored after a restart/reload so I have no issues with them now.

1 Like

Hi @CaptTom (or anyone else who might know! :slight_smile: ) - did you ever figure out how this might be implemented as a triggered template sensor? I’m failing miserably here with my attempts so far… Any examples you could share please?

I never went down that path. My automation seems to be working fine.

I did notice that my method of keeping a running average over the course of the day has some odd quirks, too. For example, if it’s cool at night and hot during the day, I end up with a non-zero value for both heating an cooling degree days, even though it would be rare for me to run both the heating and cooling systems on the same day.

no worries @CaptTom , thanks for getting back. I was just looking for a shorter/quicker way to do this, as much for learning purpouses as anything else… I’ve been using your current strategy of number-inputs and a few automations very similar to yours. It works great btw. I think what you are seeing of + values for both HDD and CDD on the same day is a natural enough thing to happen when you are effectively integrating over time…If the natural conditions exist for this to happen, then you can get positive values for both…

Bumping this old thread because I wanted to share a working implementation of this using trigger templates instead of automations, answering @holeymoley’s question.

This pair of trigger templates generates a Heating Degree Days and Cooling Degree Days sensor, which accumulates each day, and then resets to 0 at midnight. It uses state_class: total_increasing which is for “utility meter” type entities that are always increasing in value, and reset to 0 periodically. This means that long term statistics will still work for the sensor, allowing you to look at weekly, monthly or yearly totals.

This updates every 15 minutes (just a good balance in my mind between fidelity and noise), and clears at midnight (really just before midnight, to avoid conflicting triggers).

I also have two temperature sources, so I or them as a little trick - if the first is unavailable, it will default to 0 and the other will be used. In the unlikely event that my weather station reports 0.00F temp, it will inadvertently failover to the 2nd source, but that would be very rare. If you only have one temperature data source you could just remove the or and the second state.

Base is set to 65F for heating and 75F for cooling. Choosing a good base is a bit of an art, and there are analytical ways to determine it, but really just want the outdoor temperature below which you would be likely to turn on the heat, and above which you would be likely to turn on cooling.

The statements and sets are a bit verbose, but I’m not paying by the character so I’m prioritizing readability.

Hope this helps a future searcher, and thanks to @CaptTom for the great idea and getting me thinking about this.

template:
  - trigger:
      - platform: time_pattern
        id: "update"
        minutes: "/15" # repeat every 15 minutes
      - platform: time_pattern
        id: "clear" # Reset the counter at midnight
        hours: "23"
        minutes: "59"
    sensor:
      - name: Heating Degree Days
        unique_id: heating_degree_days
        # Timestep is the portion of an hour you calculate on (15 min = 0.25 hours)
        # Base is the base temperature for calculating your degree day - 65F default
        # Current_temp is checking my weather station and openweathermap - it
        # defaults to my weather station, but will use the OWM sensor if it is unavailable.
        state: >-
          {% if trigger.id == "update" %}
            {% set timestep = 0.25 %}
            {% set base = 65 %}
            {% set current_temp = states('sensor.weatherflow_air_temperature')|float(0) or states('sensor.openweathermap_temperature')|float(0) %}
            {% set partial_dd = max(0, (base - current_temp) / (24 / timestep)) %}
            {{ this.state | float() + partial_dd }}
          {% else %}
            {{ 0 }}
          {% endif %}
        state_class: total_increasing
  - trigger:
      - platform: time_pattern
        id: "update"
        minutes: "/15" # repeat every 15 minutes
      - platform: time_pattern
        id: "clear" # Reset the counter at midnight
        hours: "23"
        minutes: "59"
    sensor:
      - name: Cooling Degree Days
        unique_id: cooling_degree_days
        state: >-
          {% if trigger.id == "update" %}
            {% set timestep = 0.25 %}
            {% set base = 75 %}
            {% set current_temp = states('sensor.weatherflow_air_temperature')|float(0) or states('sensor.openweathermap_temperature')|float(0) %}
            {% set partial_dd = max(0, (current_temp - base) / (24 / timestep)) %}
            {{ this.state | float() + partial_dd }}
          {% else %}
            {{ 0 }}
          {% endif %}
        state_class: total_increasing
3 Likes

Nice!! I don’t use trigger templates nearly enough!

Oh, and by the way, I appreciate prioritizing readability (hence, maintainability) over brevity. I honestly don’t know why some people want to write the most obtuse and undecipherable code possible. As you say, there’s no extra charge for making it understandable.

One question / issue that I have with calculating degree days is the data is delayed by one day in HA…You can’t calculate the final degree day value until the day ends at 23:59:59. At this point you have the value but it’s valid for the previous 24 hours? However, when you record the value it is timestamped at 23:59:59.5 whereas it should be 00:00:00 as the calculation is for the previous 24 hours.

@gedger Just seeing this now, but I get what you are saying. The reason I went with the reset just before midnight is that at least the HDD value for a day is the maximum anytime in that day. So in calculations I either use the max for the day, or the daily state change statistic. So if I ask for the max on Jan 13th, and the max occurred at 23:59:59 just before reset, then that is the value I get for the 13th, which makes sense.

It does make using the data tricky because you don’t really care what the HDD value was at noon, just the total for the entire day.

You can see here a statistic graph of my HDDs from last week, and a graph of my heatpump energy usage using a more traditional utility_meter sensor. The days line up as you’d expect (and boy it was a lot of energy).

1 Like