hvac: cooling has thresholds of its own, and the stove heats first

Watching it decide — which is what `commands: false` is for — caught two
things a type check never would.

It wanted to cool the house to 21 degrees in August. The comfort band is what
*heating* aims at; the reference cooled above 26 and at night above 24.5, and
this port had collapsed the two into one number. A compressor running every
summer afternoon to reach a heating setpoint is the most expensive kind of
correct-looking bug.

And it was willing to heat while the pellet stove was doing the same job. The
reference only ever let the heat pump heat when the stove reported itself
faulty, which is the right way round: the stove is what heats this house and
the pump is what covers for it.

Both tables now have a check behind them, along with the boilers' — the three
places in the house where a wrong threshold is a bill rather than a mistake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 16:27:13 +02:00
co-authored by Claude Opus 5
parent 1b455b5e55
commit dda1822fa9
2 changed files with 95 additions and 2 deletions
+21 -2
View File
@@ -148,8 +148,10 @@ def process(
home=True,
sleeping=False,
oven_on=False,
oven_faulty=False,
hot=29.0,
high=26.0,
night_high=24.5,
cool_soc=60.0,
cool_soc_away=90.0,
fan_soc=45.0,
@@ -160,7 +162,11 @@ def process(
if not enabled or watch:
return {"command": OFF, "why": "off: " + ("disabled" if not enabled else "power")}
# Cooling has thresholds of its own, well above the comfort band. The band
# is what *heating* aims at; cooling down to it in August would run the
# compressor all day for nothing. The reference drew the same line.
t_hot, t_high = hot + lift, high + lift
t_night = night_high + lift
def ask(mode, temp=None, fan="auto", why=""):
return {
@@ -177,6 +183,8 @@ def process(
# Nobody in: only ever to stop the house cooking, and only on the sun.
if top > t_hot and soc > cool_soc_away:
return ask("cool", t_hot, why="empty house is too hot")
if indoor < t_min and oven_faulty and soc > dry_soc:
return ask("heat", t_min + 3, why="empty house is freezing")
if humidity is not None and humidity > 60 and soc > dry_soc:
return ask("dry", t_min + 3, why="empty house is damp")
return {"command": OFF, "why": "nobody in"}
@@ -185,7 +193,7 @@ def process(
# Warm air at the ceiling and cold feet: move it rather than make more.
return ask("fan", fan="high" if oven_on else "low", why="stratified")
limit = t_max if not sleeping else t_max + 1.5
limit = t_night if sleeping else t_high
if top > limit:
if soc > (cool_soc if not sleeping else cool_soc_away):
return ask("cool", t_hot, why="too warm")
@@ -198,7 +206,12 @@ def process(
return ask("dry", t_min + 3, fan="low", why="damp")
if indoor < t_min and soc > dry_soc:
return ask("heat", t_min + 3, why="too cold")
# The stove is what heats this house; the heat pump is the fallback for
# when it cannot. Both running is the expensive one winning an argument
# with the cheap one.
if not oven_faulty:
return {"command": OFF, "why": "cold, but that is the stove's job"}
return ask("heat", t_min + 3, why="too cold and the stove is out")
return {"command": OFF, "why": "within the band"}
'''
@@ -370,6 +383,12 @@ def hvac(h: dict[str, Any], commands: bool) -> Flow:
"dtype": "bool",
"trigger": False,
},
{
"name": "oven.faulty",
"port": "oven_faulty",
"dtype": "bool",
"trigger": False,
},
{"name": "enabled", "dtype": "bool", "trigger": False},
],
"provides": [