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, home=True,
sleeping=False, sleeping=False,
oven_on=False, oven_on=False,
oven_faulty=False,
hot=29.0, hot=29.0,
high=26.0, high=26.0,
night_high=24.5,
cool_soc=60.0, cool_soc=60.0,
cool_soc_away=90.0, cool_soc_away=90.0,
fan_soc=45.0, fan_soc=45.0,
@@ -160,7 +162,11 @@ def process(
if not enabled or watch: if not enabled or watch:
return {"command": OFF, "why": "off: " + ("disabled" if not enabled else "power")} 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_hot, t_high = hot + lift, high + lift
t_night = night_high + lift
def ask(mode, temp=None, fan="auto", why=""): def ask(mode, temp=None, fan="auto", why=""):
return { return {
@@ -177,6 +183,8 @@ def process(
# Nobody in: only ever to stop the house cooking, and only on the sun. # Nobody in: only ever to stop the house cooking, and only on the sun.
if top > t_hot and soc > cool_soc_away: if top > t_hot and soc > cool_soc_away:
return ask("cool", t_hot, why="empty house is too hot") 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: if humidity is not None and humidity > 60 and soc > dry_soc:
return ask("dry", t_min + 3, why="empty house is damp") return ask("dry", t_min + 3, why="empty house is damp")
return {"command": OFF, "why": "nobody in"} 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. # 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") 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 top > limit:
if soc > (cool_soc if not sleeping else cool_soc_away): if soc > (cool_soc if not sleeping else cool_soc_away):
return ask("cool", t_hot, why="too warm") return ask("cool", t_hot, why="too warm")
@@ -198,7 +206,12 @@ def process(
return ask("dry", t_min + 3, fan="low", why="damp") return ask("dry", t_min + 3, fan="low", why="damp")
if indoor < t_min and soc > dry_soc: 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"} return {"command": OFF, "why": "within the band"}
''' '''
@@ -370,6 +383,12 @@ def hvac(h: dict[str, Any], commands: bool) -> Flow:
"dtype": "bool", "dtype": "bool",
"trigger": False, "trigger": False,
}, },
{
"name": "oven.faulty",
"port": "oven_faulty",
"dtype": "bool",
"trigger": False,
},
{"name": "enabled", "dtype": "bool", "trigger": False}, {"name": "enabled", "dtype": "bool", "trigger": False},
], ],
"provides": [ "provides": [
+74
View File
@@ -16,6 +16,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from tinyhouse.control import BOILER, HVAC # noqa: E402
from tinyhouse.library import ARBITER, MOTOR # noqa: E402 from tinyhouse.library import ARBITER, MOTOR # noqa: E402
@@ -27,6 +28,8 @@ def _load(source: str):
arbiter = _load(ARBITER) arbiter = _load(ARBITER)
motor = _load(MOTOR) motor = _load(MOTOR)
hvac = _load(HVAC)
boiler = _load(BOILER)
# ── the arbiter ────────────────────────────────────────────────────────── # ── the arbiter ──────────────────────────────────────────────────────────
@@ -137,6 +140,77 @@ def test_a_run_is_over_when_it_has_had_its_time():
assert motor(cmd="UP", state=expired)["run"] == "UP", "and free to go back" assert motor(cmd="UP", state=expired)["run"] == "UP", "and free to go back"
# ── the heat pump ────────────────────────────────────────────────────────
def _asked(**kw):
"""What the heat pump would be asked for, in one word."""
base = dict(indoor=25.7, top=25.7, t_min=19.5, t_max=21.0, soc=88.0, in_v=234.0)
answer = hvac(**{**base, **kw})
command = answer["command"]
return command["mode"] if command["operation"] else "off"
def test_cooling_starts_well_above_the_comfort_band():
"""The band is what heating aims at.
Cooling to it would run the compressor through every summer afternoon, so
it has thresholds of its own — which is the distinction the first port of
this lost, and it wanted to cool the house to 21 in August.
"""
assert _asked(indoor=25.7, top=25.7) == "off"
assert _asked(indoor=26.5, top=26.5) == "cool"
assert _asked(indoor=29.5, top=29.5) == "cool"
def test_at_night_it_starts_sooner_and_only_on_a_full_battery():
assert _asked(indoor=25.7, top=25.7, sleeping=True) == "fan"
assert _asked(indoor=25.7, top=25.7, sleeping=True, soc=95) == "cool"
def test_the_heat_pump_does_not_heat_while_the_stove_can():
"""Both running is the expensive one winning an argument with the cheap one."""
assert _asked(indoor=18.0, top=18.0) == "off"
assert _asked(indoor=18.0, top=18.0, oven_faulty=True) == "heat"
def test_nothing_optional_runs_on_a_weak_supply():
assert _asked(indoor=29.5, top=29.5, watch=4) == "off"
assert _asked(indoor=29.5, top=29.5, enabled=False) == "off"
assert _asked(indoor=29.5, top=29.5, soc=50) == "fan", "move air, do not make cold"
assert _asked(indoor=29.5, top=29.5, soc=40) == "off", (
"battery too low even for that"
)
# ── the boilers ──────────────────────────────────────────────────────────
def _heats(**kw):
base = dict(batt_v=50.5, hour=12, in_v=234.0, winter=0.0)
return boiler(**{**base, **kw})["want"]
def test_by_day_a_boiler_heats_on_surplus_and_not_on_the_grid():
assert _heats(batt_v=50.5) is True, "battery above the mark"
assert _heats(batt_v=48.0) is False, "battery below the floor"
assert _heats(batt_v=50.5, in_w=200.0) is False, "importing, so not surplus"
assert _heats(batt_v=50.5, heated=True) is False, "already heated today"
def test_the_turn_on_point_drops_as_the_year_does():
"""A winter battery waiting for a summer voltage waits all day."""
assert _heats(batt_v=50.0, winter=0.0) is False
assert _heats(batt_v=50.0, winter=1.0) is True
def test_at_night_it_falls_back_to_the_grid_unless_tomorrow_is_better():
assert _heats(hour=2) is True, "the day did not manage it"
assert _heats(hour=2, tomorrow_day=27.0, tomorrow_clouds=10.0) is False
assert _heats(hour=2, in_v=170.0) is False, "mains too weak"
assert _heats(hour=6) is False, "outside both windows"
if __name__ == "__main__": if __name__ == "__main__":
checks = [v for k, v in sorted(globals().items()) if k.startswith("test_")] checks = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for check in checks: for check in checks: