+++
title = 'Boiler Duty Cycle Control in Home Assistant'
description = 'A non-piecewise integer formula for predictive hydronic heating control'
date = 2026-02-27T10:00:00-05:00
draft = false
categories = ['projects']
tags = ['Home Assistant', 'Hydronic Heating', 'Boiler', 'Automation', 'Duty Cycle', 'Math']
+++
 
{{< lead >}}
Looking forward at the forecast to keep the boiler from short cycling.
{{< /lead >}}
 
Hydronic heating is wonderful, but it has a real problem: thermal mass. There is a small lake of water sitting in the system, and that water takes hours to respond to anything. By the time the indoor thermometer notices it is cold, the cold weather has already won. The reactive control loop that ships with most thermostats is too late by design.
 
So instead of reacting, I wanted to look forward. Pull the forecast, decide how hard the boiler needs to work over the next several hours, and then dribble that work out so the boiler is not slamming on and off every few minutes. Short cycling is bad for the boiler, bad for efficiency, and bad for my electric bill.
 
## Good isn't good enough

I started with a simple Jinja template sensor in HomeAssistant using forecasted temperatures (two hours in advance) to turn the boiler on or off. This works.... but it had issues. The most important of which is that the only short-cycle protection was to add a huge delay so it only evaluates every 30 minutes or so. This delay means that script can't respond to rapid changes in forecast, or real temperatures which deviate from the forecast.

I turned to math to fix my problem. I wrote a function that will adapt to a given duty cycle for any 1/integer ratio of on/off, and because it is temporally stable and with a configurable duty cycle, transitions between any neighboring duty cycles will minimize the number of cycles of the boiler.

## Math:
{{< katex >}}
$$
f(t, D, P) = \phi_0 \cdot \left( \phi_+ \cdot g_+ + \phi_- \cdot g_- \right)
$$

$$
n = \left\lfloor \frac{t}{P} \right\rfloor \qquad r = n \bmod (|D| + 1)
$$
$$
\phi_0 = \min(1, |D|) \qquad \phi_+ = \min(1, \max(0, D)) \qquad \phi_- = \min(1, \max(0, -D))
$$
$$
g_+ = \min(1, \max(0, |D| - r)) \qquad g_- = \min(1, \max(0, r - |D| + 1))
$$

Variables:

$$
t \geq 0, \quad t =\text{Time in arbitrary units}
$$
$$
D \in \{integers} =\text{Duty cycle}
$$
$$
P > 0, \quad P \in \text{integers} =\text{Cycle Period}
$$

<br>
Or as a single expanded expression:

$$
f(t, D, P) = \min(1, |D|) \cdot \Big[ \min(1, \max(0, D)) \cdot \min(1, \max(0, |D| - r)) + \min(1, \max(0, -D)) \cdot \min(1, \max(0, r - |D| + 1)) \Big]
$$

$$
r = \left\lfloor \frac{t}{P} \right\rfloor \bmod (|D| + 1)
$$

## Working through the math
 
The whole structure is really just two patterns:
 
- For `D > 0`: output `1` for the first `|D|` slots of each cycle, then `0` for one slot
- For `D < 0`: output `0` for the first `|D|` slots of each cycle, then `1` for one slot
- For `D = 0`: always `0`
Define `n = floor(t / P)` as the time slot, and `r = n mod (|D| + 1)` as the position within the cycle. Then the positive case is just `1 if r < |D| else 0`, which in pure arithmetic is `min(1, max(0, |D| - r))`. The negative case is `1 if r >= |D| else 0`, which is `min(1, max(0, r - |D| + 1))`.
 
That gives us the piecewise version, but I wanted to go further and kill the conditionals entirely. The trick is to build flag variables that select the right branch:
 
- `phi_plus = min(1, (D + |D|) / (2|D|))` is `1` when `D > 0`, else `0`
- `phi_minus = min(1, (|D| - D) / (2|D|))` is `1` when `D < 0`, else `0`
- `phi_zero = min(1, |D|)` is `0` when `D = 0`, else `1`
These are mutually exclusive flags built from nothing but absolute value and division. Multiply each branch by its flag, sum them up, and gate the whole thing with `phi_zero`:
 
```
f(t, D, P) = phi_zero * (phi_plus * g_plus(r, D) + phi_minus * g_minus(r, D))
```
 
where `g_plus = min(1, max(0, |D| - r))` and `g_minus = min(1, max(0, r - |D| + 1))`.
 
# The Python reference
 
Before touching the Jinja template again, I wanted a reference implementation I could test against:
 
```python
def furnace_state(t, D, P):
    """
    t: time in unitless increments (e.g., seconds)
    D: duty cycle integer in {-3, -2, -1, 0, 1, 2, 3}
    P: period (time increments per slot)
    Returns 1 (on) or 0 (off)
    """
    n = t // P
    absD = abs(D)
    r = n % (absD + 1) if absD > 0 else 0
 
    phi_zero = min(1, absD)
    phi_plus = min(1, max(0, D))
    phi_minus = min(1, max(0, -D))
 
    g_plus = min(1, max(0, absD - r))
    g_minus = min(1, max(0, r - absD + 1))
 
    return phi_zero * (phi_plus * g_plus + phi_minus * g_minus)
```
 
![Furnace Automation Duty Cycle Equation](furnace_automation_plot.png)
 
## The cleaned-up Jinja template
 
Translating the formula back into Jinja:
 
```jinja
{%- set mode = states('input_select.furnace_mode') -%}
{%- set period = 1800 -%}
{%- set t = now().timestamp() | int -%}
 
{# Map mode to duty cycle integer (Max and Off are short-circuited) #}
{%- if mode == 'Max' -%}
  true
{%- elif mode == 'Off' -%}
  false
{%- else -%}
  {%- set D = {'High': 2, 'Medium': 1, 'Low': -2}.get(mode, 0) -%}
 
  {%- set absD = D | abs -%}
  {%- set n = (t / period) | int -%}
  {%- set r = n % (absD + 1) if absD > 0 else 0 -%}
 
  {%- set phi_zero  = [1, absD]      | min -%}
  {%- set phi_plus  = [1, [0, D]    | max] | min -%}
  {%- set phi_minus = [1, [0, -D]   | max] | min -%}
 
  {%- set g_plus  = [1, [0, absD - r]      | max] | min -%}
  {%- set g_minus = [1, [0, r - absD + 1]  | max] | min -%}
 
  {%- set out = phi_zero * (phi_plus * g_plus + phi_minus * g_minus) -%}
  {{ out == 1 }}
{%- endif -%}
``` 

The `Max` and `Off` short-circuits stay because they are user-facing override modes that should not depend on the duty cycle math at all. Everything else collapses into a single expression.
 
## Why bother
 
Honestly? The original worked fine. I am not going to pretend this rewrite saved me CPU cycles or fixed a bug. The win is that when I come back to this in six months and want to add a new mode, or change the period from 30 minutes to 20, I can read the formula instead of tracing through nested conditionals. And the formula is the same shape as the math I scribbled in my notebook, which means I can change one and know how to change the other. Figuring stuff out is fun, and if I ever need a super efficient duty cycle function as a oneliner now I have one!!!
 
If you are running hydronic heat and getting frustrated by short cycling, I cannot recommend the forecast-driven approach enough. The boiler runs in longer, smoother cycles and is making the heat before you need it so that you have it at the right time.