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: