Added furnace article
This commit is contained in:
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 |
BIN
content/personal-blog/furnace-automation/feature.png
Normal file
BIN
content/personal-blog/furnace-automation/feature.png
Normal file
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 |
152
content/personal-blog/furnace-automation/index.md
Normal file
152
content/personal-blog/furnace-automation/index.md
Normal 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)
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
2
run_local_debug.sh
Normal 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.
|
||||||
Loading…
Add table
Reference in a new issue