From dda1822fa9c87f0c50cbee7afb20ae2cef150865 Mon Sep 17 00:00:00 2001 From: stroblme Date: Sat, 22 Aug 2026 16:27:13 +0200 Subject: [PATCH] hvac: cooling has thresholds of its own, and the stove heats first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/tinyhouse/control.py | 23 +++++++++- scripts/tinyhouse/test_library.py | 74 +++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/scripts/tinyhouse/control.py b/scripts/tinyhouse/control.py index 84335fa..1dc49c9 100644 --- a/scripts/tinyhouse/control.py +++ b/scripts/tinyhouse/control.py @@ -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": [ diff --git a/scripts/tinyhouse/test_library.py b/scripts/tinyhouse/test_library.py index 050ba71..950635d 100644 --- a/scripts/tinyhouse/test_library.py +++ b/scripts/tinyhouse/test_library.py @@ -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: