I Taught My House to Dodge Peak Rates: A Home Assistant Peak-Shaving System

It’s been a couple of years since I posted here. Not for lack of material — the opposite. I’ve been heads-down building a Mac management product, doing endpoint and IT automation work, and filling a notebook with things I kept meaning to write up. So I’m starting again, and I’m starting with the one that’s been the most fun to live with: teaching my house to dodge peak electricity rates.


On June 1, Consumers Energy flipped Michigan into summer time-of-use pricing, and it runs through September 30. On weekdays from 2 to 7 PM, electricity costs about 24.5¢/kWh. Every other hour — nights, mornings, all weekend — it’s about 19.7¢/kWh. That’s roughly a 24% premium slapped onto the exact five-hour window when it’s hottest and your air conditioner wants to run the most.

The utility’s advice is the obvious move: pre-cool the house in the morning, then back off the thermostat before 2 PM so the AC coasts through the expensive window instead of grinding through it. Good advice. Nobody wants to babysit a thermostat at 1:55 every afternoon to follow it.

So I built a system in Home Assistant that does it for me — and last week, on the first real heat of the season, I watched it work without touching anything. By early afternoon the house had quietly pre-cooled itself, dropped every shade, and settled in to ride out peak while the compressor stayed off. I didn’t lift a finger. That’s the whole point.

Here’s how it’s built.

The idea: pre-cool, coast, restore

The strategy is three beats. Pre-cool the house below your comfortable setpoint while energy is still cheap, so you’re banking “cold” in the building’s thermal mass. Coast through the 2–7 PM peak by letting the thermostat drift up — the AC barely runs because you front-loaded the cooling and sealed the house against solar gain. Restore to normal the moment peak ends at 7.

Done crudely, that’s two thermostat schedules and you’re finished. What makes this worth a writeup is the parts that keep it from being miserable: making the pre-cool depth respond to how hot the day will actually be, and protecting the one room you’re sitting in during the coast so “saving money” doesn’t mean “sweating at your desk.”

The whole thing is seven automations and two helpers.

The control plane: two helpers

Before any automation, two helpers hold the system’s state:

  • input_boolean.peak_shaving_enabled — a master switch. One toggle disables the entire system: pre-cool, coast, shades, all of it. Every automation checks this first. When guests are over, or it’s a mild day I don’t care about, or something’s misbehaving, I flip one switch instead of disabling seven automations.
  • input_number.peak_shaving_forecast_high — stores today’s forecast high temperature. This is the number that makes the system adaptive instead of dumb. A 95° day and a 78° day should not get the same pre-cool, and this helper is how the automations know the difference.

If you build one thing from this post, build these two first. A master-enable boolean and a single shared input_number turn a pile of brittle, hardcoded automations into a system you can actually reason about and turn off.

The seven automations, in order

1 — Update Forecast High (6 AM). First thing each morning, this reads the day’s forecast high from OpenWeather and writes it into input_number.peak_shaving_forecast_high. Everything downstream keys off this one value. (I’m using OpenWeather for now — a rooftop weather station is on the list, but the forecast high is what drives the logic and OpenWeather is fine for that.)

2 — Pre-Cool (11:45 AM, adaptive). Late morning, while power is still off-peak, the Ecobee drives the house down to bank cold thermal mass before peak. This is the bank deposit: cooling the air and the building’s mass while it’s cheap, so there’s a reserve to spend during peak. And it’s genuinely adaptive — the target scales to how hot the day will be, read from that forecast-high helper:

  • ≥90°F forecast → pre-cool to 66
  • ≥82°F → 68
  • ≥76°F → 70
  • otherwise → 72

Below a 74°F forecast it skips pre-cooling entirely, because on a mild day the house coasts fine on its own (measured drift is about 1°F/hour). Measured pulldown is roughly 2.2°F/hour, which is why an 11:45 start reliably hits target by the 2 PM peak. The hotter the day, the deeper the deposit — that’s the whole idea.

3 — Close Shades (12:30 PM). Before the sun is at its worst, the interior shades drop — but only when it’s actually hotter outside than in. The automation gates on outdoor temperature being above the indoor reading, so on a cool day it doesn’t pointlessly seal the house in the dark. When it does fire, it’s the cheapest, highest-leverage move in the entire system and it uses zero compressor time — you’re just refusing to let solar gain into the house you spent money cooling. Blocking the windows before peak does more per dollar than any thermostat trick. (Evening re-opening is handled by a separate adaptive-cover automation, not this one.)

4 — Coast (2 PM, adaptive). Peak begins. The Ecobee setpoint jumps up and the house is allowed to drift. The ceiling is adaptive too — and here’s the counterintuitive part, it scales inversely:

  • ≥90°F forecast → coast ceiling 74
  • ≥82°F → 76
  • otherwise → 78

On a brutal day the ceiling goes lower, not higher, so the compressor re-engages a little sooner to keep the house (and the office) tolerable. That’s a deliberate trade: you give back a little savings on the worst days to stay comfortable. Because of the deep pre-cool and the sealed shades, indoor temperature climbs slowly — measured coast drift is about 1°F/hour, so on a typical day the house never even reaches the ceiling and the compressor stays off the entire window. Eight to twelve degrees of allowed drift is the lever; the pre-cooled mass and sealed shades are what make it take hours instead of minutes.

5 — Office Fan Follows Presence. During the coast, the office fan runs only when someone’s actually in the room — driven by an Apollo MSR-2 mmWave presence sensor, not a motion detector, so it doesn’t shut off when you’re sitting still at your desk. When presence is detected it runs the fan at 33% (it’s a big fan; 33% is plenty for about 4°F of felt cooling), and when the office empties for five minutes it shuts off. That ~4°F of felt cooling is what lets the whole house coast warmer without the compressor. The fan costs about six cents for the entire peak window — versus the compressor run it helps avoid.

6 — Office Comfort Guardrail (83°F / 10 min, presence-gated). The safety valve. The office is the WFH room and it runs hotter than the thermostat’s own sensors, so if its temperature stays above 83°F for ten minutes and the room is occupied, the guardrail drops the Ecobee to 72 to pull the house back. The presence gate is the critical money-saving detail: an empty office never triggers the expensive compressor override. The threshold sits at 83°F rather than 80° specifically because the fan’s ~4°F of felt cooling covers the difference — the fan does the cheap work first, and only if that’s not enough does the compressor get called.

7 — End Restore (7 PM). Peak ends, the Ecobee goes back to its normal 74°F summer setpoint, the office fan shuts off as a backstop, and the house eases back to baseline on now-cheap power. (Shades are left alone here — the separate adaptive-cover automation owns evening shade position.)

Why the adaptive piece matters

The difference between this and a dumb two-schedule setup is the forecast high flowing through it. A flat “cool to 68, coast at 76” works until you hit a 96° day where 68 wasn’t a deep enough deposit and the house blows past 76 by 4 PM — or a mild 76° day where you wasted money pre-cooling for a peak that was never going to bite. Reading the forecast each morning and letting the pre-cool respond to it is what keeps the system honest across a whole Michigan summer instead of just the average day.

The honest gaps

Two things I’d flag for anyone copying this:

I can’t show you a hard dollar figure, and I won’t fake one. I don’t have whole-home energy monitoring — no CT clamp on the panel, nothing measuring total draw — so I can’t put a “saved $X this month” chart in front of you. What I can tell you is the mechanism is sound: shifting AC load out of a 24.5¢ window into 19.7¢ hours, on the hottest days when the AC is the biggest load in the house, is exactly the arbitrage the rate structure is begging you to make. Whole-home monitoring is the next build, and when I have it, that’s its own post.

The weather station isn’t up yet. OpenWeather’s forecast high is driving the adaptive logic today, which is good enough — but a rooftop station would let me react to actual conditions, not just the morning forecast, and tighten the pre-cool depth further.

If you want to build it

The shape to copy, in order of leverage: start with the master-enable boolean and the forecast-high input_number — the control plane is what makes the rest sane. Then the pre-cool / coast / restore trio, which is 80% of the savings. Then close the shades before peak, which is free and punches above its weight. Then, last, the presence fan and comfort guardrail — the quality-of-life layer that makes the coast livable in the room you actually occupy.

The rates are real, they’re live right now through September, and the grid genuinely wants this load shifted — peak pricing exists because utilities have to build for a few dozen hours of peak demand a year. Dodging it is good for your bill and good for the grid at the same time. My house does it on autopilot, and the best part is that on the hottest afternoon of the week so far, I forgot it was even happening.


The full configs (copy, then swap the placeholders)

Everything below is genericized. Replace the YOUR_* placeholders with your own entity IDs. The legend:

  • climate.YOUR_THERMOSTAT — your thermostat (mine’s an Ecobee exposed as a single climate entity)
  • weather.YOUR_WEATHER — your weather provider (I use OpenWeatherMap)
  • binary_sensor.YOUR_WINDOWS — a group binary_sensor that’s on when any window is open (so pre-cool doesn’t run with the house open)
  • binary_sensor.YOUR_OFFICE_PRESENCE — an mmWave presence sensor (Apollo MSR-2 here); motion sensors work worse because they miss a still person
  • sensor.YOUR_OFFICE_TEMP — a temperature sensor in the room you occupy during peak
  • sensor.YOUR_INDOOR_TEMP / sensor.YOUR_OUTDOOR_TEMP — for the shade gate
  • fan.YOUR_OFFICE_FAN — a fan that supports percentage control
  • cover.YOUR_SHADE_* — your shade covers

The two helpers

# Helper 1: master enable. Create via Settings > Devices & Services > Helpers > Toggle.
#   input_boolean.peak_shaving_enabled

# Helper 2: today's forecast high. Create via Helpers > Number.
#   input_number.peak_shaving_forecast_high
#   min: 0, max: 120, step: 1, unit: °F
YAML

1 — Update Forecast High (6 AM)

alias: "Peak Shaving: 1 - Update Forecast High (6 AM)"
mode: single
trigger:
  - platform: time
    at: "06:00:00"
action:
  - action: weather.get_forecasts
    target:
      entity_id: weather.YOUR_WEATHER
    data:
      type: daily
    response_variable: fc
  - action: input_number.set_value
    target:
      entity_id: input_number.peak_shaving_forecast_high
    data:
      value: "{{ fc['weather.YOUR_WEATHER'].forecast[0].temperature | float(85) }}"
YAML

2 — Pre-Cool (11:45 AM, adaptive)

alias: "Peak Shaving: 2 - Pre-Cool (11:45 AM, adaptive)"
mode: single
trigger:
  - platform: time
    at: "11:45:00"
condition:
  - condition: state
    entity_id: input_boolean.peak_shaving_enabled
    state: "on"
  - condition: time
    weekday: [mon, tue, wed, thu, fri]
  - condition: state
    entity_id: binary_sensor.YOUR_WINDOWS
    state: "off"
  - condition: not
    conditions:
      - condition: state
        entity_id: climate.YOUR_THERMOSTAT
        attribute: preset_mode
        state: ["away", "away_indefinitely"]
  - condition: state
    entity_id: climate.YOUR_THERMOSTAT
    state: "cool"
  - condition: numeric_state
    entity_id: input_number.peak_shaving_forecast_high
    above: 74
action:
  - variables:
      fhigh: "{{ states('input_number.peak_shaving_forecast_high') | float(85) }}"
  - action: climate.set_temperature
    target:
      entity_id: climate.YOUR_THERMOSTAT
    data:
      temperature: "{% if fhigh >= 90 %}66{% elif fhigh >= 82 %}68{% elif fhigh >= 76 %}70{% else %}72{% endif %}"
YAML

3 — Close Shades (12:30 PM)

alias: "Peak Shaving: 4 - Close Shades (12:30 PM)"
mode: single
trigger:
  - platform: time
    at: "12:30:00"
condition:
  - condition: state
    entity_id: input_boolean.peak_shaving_enabled
    state: "on"
  - condition: time
    weekday: [mon, tue, wed, thu, fri]
  - condition: numeric_state
    entity_id: sensor.YOUR_OUTDOOR_TEMP
    above: sensor.YOUR_INDOOR_TEMP
action:
  - action: cover.set_cover_position
    target:
      entity_id:
        - cover.YOUR_SHADE_1
        - cover.YOUR_SHADE_2
        # ...add the rest of your shades
    data:
      position: 0
YAML

4 — Coast (2 PM, adaptive)

alias: "Peak Shaving: 3 - Coast (2 PM, adaptive)"
mode: single
trigger:
  - platform: time
    at: "14:00:30"
condition:
  - condition: state
    entity_id: input_boolean.peak_shaving_enabled
    state: "on"
  - condition: time
    weekday: [mon, tue, wed, thu, fri]
  - condition: not
    conditions:
      - condition: state
        entity_id: climate.YOUR_THERMOSTAT
        attribute: preset_mode
        state: ["away", "away_indefinitely"]
  - condition: state
    entity_id: climate.YOUR_THERMOSTAT
    state: "cool"
action:
  - variables:
      fhigh: "{{ states('input_number.peak_shaving_forecast_high') | float(85) }}"
  - action: climate.set_temperature
    target:
      entity_id: climate.YOUR_THERMOSTAT
    data:
      temperature: "{% if fhigh >= 90 %}74{% elif fhigh >= 82 %}76{% else %}78{% endif %}"
YAML

5 — Office Fan Follows Presence

alias: "Peak Shaving: 5 - Office Fan Follows Presence"
mode: restart
trigger:
  - platform: state
    entity_id: binary_sensor.YOUR_OFFICE_PRESENCE
    to: "on"
  - platform: state
    entity_id: binary_sensor.YOUR_OFFICE_PRESENCE
    to: "off"
    for:
      minutes: 5
  - platform: time
    at: "14:00:00"
condition:
  - condition: state
    entity_id: input_boolean.peak_shaving_enabled
    state: "on"
  - condition: time
    after: "14:00:00"
    before: "19:00:00"
    weekday: [mon, tue, wed, thu, fri]
action:
  - choose:
      - conditions:
          - condition: state
            entity_id: binary_sensor.YOUR_OFFICE_PRESENCE
            state: "on"
        sequence:
          - action: fan.set_percentage
            target:
              entity_id: fan.YOUR_OFFICE_FAN
            data:
              percentage: 33
    default:
      - action: fan.turn_off
        target:
          entity_id: fan.YOUR_OFFICE_FAN
YAML

6 — Office Comfort Guardrail (83°F / 10 min, presence-gated)

alias: "Peak Shaving: 6 - Office Comfort Guardrail"
mode: single
trigger:
  - platform: numeric_state
    entity_id: sensor.YOUR_OFFICE_TEMP
    above: 83
    for:
      minutes: 10
condition:
  - condition: state
    entity_id: input_boolean.peak_shaving_enabled
    state: "on"
  - condition: time
    after: "14:00:00"
    before: "19:00:00"
    weekday: [mon, tue, wed, thu, fri]
  - condition: state
    entity_id: binary_sensor.YOUR_OFFICE_PRESENCE
    state: "on"
  - condition: not
    conditions:
      - condition: state
        entity_id: climate.YOUR_THERMOSTAT
        attribute: preset_mode
        state: ["away", "away_indefinitely"]
  - condition: state
    entity_id: climate.YOUR_THERMOSTAT
    state: "cool"
action:
  - action: climate.set_temperature
    target:
      entity_id: climate.YOUR_THERMOSTAT
    data:
      temperature: 72
YAML

7 — End Restore (7 PM)

alias: "Peak Shaving: 7 - End Restore (7 PM)"
mode: single
trigger:
  - platform: time
    at: "19:00:00"
condition:
  - condition: state
    entity_id: input_boolean.peak_shaving_enabled
    state: "on"
  - condition: time
    weekday: [mon, tue, wed, thu, fri]
  - condition: not
    conditions:
      - condition: state
        entity_id: climate.YOUR_THERMOSTAT
        attribute: preset_mode
        state: ["away", "away_indefinitely"]
action:
  - action: climate.set_temperature
    target:
      entity_id: climate.YOUR_THERMOSTAT
    data:
      temperature: 74
  - action: fan.turn_off
    target:
      entity_id: fan.YOUR_OFFICE_FAN
YAML

A few notes for adapting it: the setpoint ladders (66/68/70/72 pre-cool, 74/76/78 coast) are tuned to my house’s measured ~2.2°F/hr pulldown and ~1°F/hr drift — yours will differ, so watch the first few days and adjust. The away/vacation preset_mode guards are Ecobee-specific; if your thermostat exposes presets differently, adjust those conditions. And the master-enable boolean is in every automation on purpose: one toggle stops the whole system.