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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user