Added furnace article

This commit is contained in:
Jeremy Karst 2026-04-27 19:18:31 -04:00
parent 7a464e60c6
commit 67db610534
6 changed files with 154 additions and 0 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View file

@ -0,0 +1,152 @@
+++
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.

2
run_local_debug.sh Normal file
View file

@ -0,0 +1,2 @@
hugo serve
# This serves no purpose other than to remind me that hugo serve exists when I don't mess with this for months at a time.