From 7e131df4ca0c6ec593ba2eb1b543e50d2a60d2d4 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Fri, 19 Jun 2026 17:54:42 +0200 Subject: [PATCH] Add mash-schedule (Sud) automation: temp/rate sequencing + stirrer switching brewpi.py had a dead -m/--model CLI arg and sude/*.json schedules were pure data with no code to drive them, despite the README documenting them as if they were already wired up. Add components/sud.py's Sud, which loads a sude/*.json schedule and steps through its Rasten (ramp -> hold -> optional wait-for-user -> next rest), and tasks/sud.py's SudTask, which drives the temperature controller's theta_soll/heatrate_soll from the current rest and switches the stirrer between continuous (ramping) and intermittent (holding, via stirrSpeedRast/stirrDutyRast/stirrCycleTime) operation. Exposes progress on a new "Sud" WebSocket channel and accepts Start/Confirm commands. Enabled via a new optional top-level "sud" config key (see config.json.templ/.sim). "Reached target" is detected via abs(theta_ist - theta_soll_set) < tolerance rather than tc.state == HOLD, since the latter can still read HOLD left over from the previous rest for one tick after a new target is pushed (tc.state only updates on the controller's own, independently-scheduled process() tick). Co-Authored-By: Claude Sonnet 4.6 --- README.md | 30 +++++++++++++--- brewpi/brewpi.py | 9 ++++- components/__init__.py | 1 + components/sud.py | 70 ++++++++++++++++++++++++++++++++++++ config.json.sim | 1 + config.json.templ | 1 + tasks/__init__.py | 1 + tasks/sud.py | 81 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 components/sud.py create mode 100644 tasks/sud.py diff --git a/README.md b/README.md index 04c1572..76c2e50 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,9 @@ components/ Pluggable building blocks behind factories: actor/ heater and stirrer drivers: simulated, a Hendi induction hob (serial protocol), or a Pololu 1376 stirrer motor controller + sud.py optional mash-schedule sequencer that steps + through a sude/*.json schedule and drives + the temperature controller from it tasks/ Async tasks (one per component) that poll hardware/sim state at a fixed interval and @@ -46,7 +49,7 @@ tasks/ Async tasks (one per component) that poll ws/ Minimal WebSocket pub/sub layer: server (single- and multi-user), client, and a keyed message dispatcher (e.g. "Sensor", - "Heater", "TempCtrl", "Stirrer", "Pot"). + "Heater", "TempCtrl", "Stirrer", "Pot", "Sud"). tracer.py Logs traced variables to .mat files (for offline analysis/tuning in MATLAB/Octave, @@ -118,10 +121,27 @@ environment (`$WORKON_HOME`/`$BREWPI_HOME`) on a Raspberry Pi-style deployment. ## Mash schedules -Files under `sude/` describe a brew's mash schedule: pot weight, malt/water -weights, stirrer speed/duty, and a list of temperature rests, each with a -target temperature, heating rate, and whether to pause for user confirmation -before continuing (e.g. to add malt or check gravity). +Files under `sude/` describe a brew's mash schedule ("Sud"): pot weight, +malt/water weights, stirrer speed/duty, and a list of temperature rests +("Rasten"), each with a target `temp`, `heatRate`, hold `time` (minutes), and +whether to `waitForUser` before continuing (e.g. to add malt or check +gravity). + +Set the top-level `sud` config key to a path under `sude/` (see +`config.json.sim`/`config.json.templ`) to have the server step through it +automatically: `components/sud.py`'s `Sud` tracks the current rest and +`tasks/sud.py`'s `SudTask` drives the temperature controller's +`theta_soll`/`heatrate_soll` from it, advancing once `theta_ist` settles +close to the target and the rest's hold time has elapsed. It also switches +the stirrer between the schedule's `stirrSpeedHeat` (continuous, while +ramping) and `stirrSpeedRast`/`stirrDutyRast`/`stirrCycleTime` (intermittent, +while holding), stopping it entirely during a `waitForUser` pause. It +exposes progress (current rest, remaining hold time, state) on the `"Sud"` +WebSocket channel and accepts `{"Sud": {"Start": true}}` to begin the +schedule and `{"Sud": {"Confirm": true}}` to acknowledge a `waitForUser` +pause and move to the next rest. Omit `sud` from the config to +run without schedule automation (manual `theta_soll`/`heatrate_soll` control +via the GUI, as before). ## Logging & analysis diff --git a/brewpi/brewpi.py b/brewpi/brewpi.py index 29b1fd2..d9d528c 100755 --- a/brewpi/brewpi.py +++ b/brewpi/brewpi.py @@ -8,7 +8,8 @@ from components.sensor import TempSensorFactory from components.pid import PidFactory from components.plant import Pot from components.actor import HeaterFactory, StirrerFactory -from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, StirrerTask, TracerTask +from components.sud import Sud +from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, StirrerTask, TracerTask, SudTask from tracer import Tracer import argparse as ap @@ -61,6 +62,12 @@ if __name__ == '__main__': stirrer_task = StirrerTask(stirrer, DT_TASK, dispatcher.msgio_get("Stirrer")) taskmgr.add(stirrer_task) + # Mash schedule (optional) + sud_path = config.get('sud') + if sud_path: + sud = Sud(sud_path) + taskmgr.add(SudTask(sud, tc, stirrer, DT_TASK, dispatcher.msgio_get("Sud"))) + # Tracer taskmgr.add(TracerTask(sensor, heater, tc, trace_tc, DT_TASK_TRACER, dispatcher.msgio_get("Tracer"))) diff --git a/components/__init__.py b/components/__init__.py index 75455ab..419419a 100644 --- a/components/__init__.py +++ b/components/__init__.py @@ -2,3 +2,4 @@ from components.atemperatureSensor import * from components.astirrer import * from components.aheater import * from components.apid import * +from components.sud import * diff --git a/components/sud.py b/components/sud.py new file mode 100644 index 0000000..220adfe --- /dev/null +++ b/components/sud.py @@ -0,0 +1,70 @@ +import json +from enum import Enum +from utils.value import AttributeChange + + +class SudState(Enum): + IDLE = 0 + RAMPING = 1 + HOLDING = 2 + WAIT_USER = 3 + DONE = 4 + + +class Sud(AttributeChange): + def __init__(self, path): + AttributeChange.__init__(self) + with open(path) as f: + data = json.load(f) + + self.name = data.get('Name', '') + self.description = data.get('Description', '') + self.rasten = data['Rasten'] + + self.stirr_speed_heat = data.get('stirrSpeedHeat', 0) + self.stirr_speed_rast = data.get('stirrSpeedRast', 0) + self.stirr_duty_rast = data.get('stirrDutyRast', 1.0) + self.stirr_cycle_time = data.get('stirrCycleTime', 1.0) + + self.index = -1 + self.hold_remaining = 0.0 + self.state = SudState.IDLE + self.rast = None + + def start(self): + if self.state != SudState.IDLE: + return + self.index = -1 + self._advance() + + def confirm(self): + if self.state == SudState.WAIT_USER: + self._advance() + + def temp_reached(self): + if self.state == SudState.RAMPING: + self.state = SudState.HOLDING + + def tick(self, dt): + if self.state == SudState.HOLDING: + self.hold_remaining -= dt + if self.hold_remaining <= 0: + self._finish_rast() + + def _advance(self): + self.index += 1 + if self.index >= len(self.rasten): + self.state = SudState.DONE + self.rast = None + return + + next_rast = self.rasten[self.index] + self.hold_remaining = next_rast['time'] * 60.0 + self.state = SudState.RAMPING + self.rast = next_rast + + def _finish_rast(self): + if self.rast.get('waitForUser', False): + self.state = SudState.WAIT_USER + else: + self._advance() diff --git a/config.json.sim b/config.json.sim index 563b6e3..5db2512 100755 --- a/config.json.sim +++ b/config.json.sim @@ -1,5 +1,6 @@ { "ambient_temperature": 20, + "sud": "sude/sud_0010.json", "Controller" : { "dt": 0.1, "sim_warp_factor": 10.0, diff --git a/config.json.templ b/config.json.templ index 2c581a5..2f526b8 100644 --- a/config.json.templ +++ b/config.json.templ @@ -1,5 +1,6 @@ { "ambient_temperature": 20, + "sud": "sude/sud_0010.json", "Controller" : { "dt": 1, "sim_warp_factor": 10.0, diff --git a/tasks/__init__.py b/tasks/__init__.py index 0d2f6cc..c0c8578 100644 --- a/tasks/__init__.py +++ b/tasks/__init__.py @@ -5,3 +5,4 @@ from tasks.stirrer import * from tasks.pot import * from tasks.tempctrl import * from tasks.tracer import * +from tasks.sud import * diff --git a/tasks/sud.py b/tasks/sud.py new file mode 100644 index 0000000..77369b3 --- /dev/null +++ b/tasks/sud.py @@ -0,0 +1,81 @@ +import asyncio +from tasks import ATask +from ws.message import MsgIo +from utils.value import ChangedFloat +from components import APid, AStirrer +from components.sud import Sud, SudState + +# How close theta_ist needs to be to theta_soll_set to count as "reached". +TEMP_REACHED_TOLERANCE = 0.2 + + +class SudTask(ATask): + def __init__(self, sud: Sud, tc: APid, stirrer: AStirrer, interval, msg_handler: MsgIo): + ATask.__init__(self, interval) + self.sud = sud + self.tc = tc + self.stirrer = stirrer + self.msg_handler = msg_handler + msg_handler.set_recv_handler(self.recv) + + def on_rast_changed(self, rast): + if rast is not None: + self.tc.set_theta_soll(rast['temp']) + self.tc.set_heatrate_soll(rast['heatRate']) + + asyncio.create_task(self.send({'Rast': { + 'Index': self.sud.index, + 'Temp': rast['temp'] if rast else None, + 'HeatRate': rast['heatRate'] if rast else None, + 'Time': rast['time'] if rast else None, + 'WaitForUser': rast.get('waitForUser', False) if rast else None, + }})) + + def on_state_changed(self, value): + asyncio.create_task(self.send({'State': str(value)})) + + if value == SudState.RAMPING: + # Stir continuously while ramping to a new target temperature. + self.stirrer.set_duty_cycle(1.0) + self.stirrer.set_speed(self.sud.stirr_speed_heat) + elif value == SudState.HOLDING: + # Stir intermittently while holding at the rest's temperature. + self.stirrer.set_cycle_time(self.sud.stirr_cycle_time) + self.stirrer.set_duty_cycle(self.sud.stirr_duty_rast) + self.stirrer.set_speed(self.sud.stirr_speed_rast) + elif value in (SudState.WAIT_USER, SudState.DONE): + # Stop stirring while paused for the user or once the schedule is done. + self.stirrer.set_duty_cycle(1.0) + self.stirrer.set_speed(0) + + def on_hold_remaining_changed(self, value): + asyncio.create_task(self.send({'HoldRemaining': value})) + + async def recv(self, data): + for pair in data.items(): + if 'Start' in pair[0]: + self.sud.start() + elif 'Confirm' in pair[0]: + self.sud.confirm() + + async def send(self, data): + await self.msg_handler.send(data) + + async def on_process(self): + print("{}: Started with interval {} s".format(self.msg_handler.get_key(), self.interval)) + + self.sud.set_on_changed('rast', self.on_rast_changed) + self.sud.set_on_changed('state', self.on_state_changed) + self.sud.set_on_changed('hold_remaining', ChangedFloat(self.on_hold_remaining_changed, prec=0).set) + + asyncio.create_task(self.send({'Name': self.sud.name, 'Description': self.sud.description})) + + while True: + if self.sud.state == SudState.RAMPING: + # Compare against the controller's own live setpoint/measurement + # rather than its state machine, which can still read HOLD from + # the previous rest for one tick after a new target is pushed. + if abs(self.tc.get_theta_ist() - self.tc.get_theta_soll_set()) < TEMP_REACHED_TOLERANCE: + self.sud.temp_reached() + self.sud.tick(self.interval) + await asyncio.sleep(self.interval)