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
+74
View File
@@ -16,6 +16,7 @@ from pathlib import Path
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
@@ -27,6 +28,8 @@ def _load(source: str):
arbiter = _load(ARBITER)
motor = _load(MOTOR)
hvac = _load(HVAC)
boiler = _load(BOILER)
# ── 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"
# ── 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__":
checks = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for check in checks: