Impeller Array

This sample project includes a robot which consists of a line of actuators with impellers driving balls inside a sloped table. The sample controller may also receive input from a separate computer vision system (see Camera Input).

This model is demonstrated in the impeller-array.wbt world. The base link for all actuators has a NULL Physics object so it does not move, simulating a rigid connection to the ground. Each link has a simple box boundingObject for collision detection and physics mass parameter calculations.

../_images/impeller-array.png

Screenshot of Webots model of the impeller-array robot.

Sample Robot Control Code

  1# impeller_array.py
  2#
  3# Sample Webots controller file for driving the array
  4# of one-DOF impellers.  Optionally uses OSC network input.
  5#
  6# No copyright, 2020-2023, Garth Zeglin.  This file is
  7# explicitly placed in the public domain.
  8
  9print("loading impeller_array.py...")
 10
 11import math
 12import random
 13import queue
 14import threading
 15import numpy as np
 16
 17# Import the Webots simulator API.
 18from controller import Supervisor
 19
 20# Define the time step in milliseconds between controller updates.
 21EVENT_LOOP_DT = 20
 22
 23# Set True to receive data over UDP from the camera system.
 24use_vision = True
 25
 26################################################################
 27if use_vision:
 28    # This uses python-osc to communicate with an OpenCV program
 29    #   installation:      pip3 install python-osc
 30    #   source code:       https://github.com/attwad/python-osc
 31    #   pypi description:  https://pypi.org/project/python-osc/
 32    from pythonosc import udp_client
 33    from pythonosc import dispatcher
 34    from pythonosc import osc_server
 35
 36# Manage a background thread running an OSC server listening for messages on an UDP socket.
 37
 38class OSCListener:
 39    def __init__(self):
 40
 41        # Create a thread-safe queue to communicate data to the robot thread.
 42        self.messages = queue.Queue()
 43
 44        # Initialize the OSC message dispatch system.
 45        self.dispatch = dispatcher.Dispatcher()
 46        self.dispatch.map("/motion", self.message)
 47        self.dispatch.set_default_handler(self.unknown_message)
 48
 49        # Start and run the server.
 50        vision_UDP_port = 22000
 51        self.server = osc_server.ThreadingOSCUDPServer(('127.0.0.1', vision_UDP_port), self.dispatch)
 52        self.server_thread = threading.Thread(target=self.server.serve_forever)
 53        self.server_thread.daemon = True
 54        self.server_thread.start()
 55        print(f"started OSC server on port {vision_UDP_port}")
 56
 57    def message(self, msgaddr, *args):
 58        """Process messages received via OSC over UDP."""
 59        print("Controller received message %s: %s" % (msgaddr, args))
 60        if msgaddr == '/motion':
 61            # convert the vision data into a numpy array and place in the queue
 62            data = np.array(args)
 63            self.messages.put(data)
 64
 65    def unknown_message(self, msgaddr, *args):
 66        """Default handler for unrecognized OSC messages."""
 67        print("Simulator received unmapped message %s: %s" % (msgaddr, args))
 68
 69################################################################
 70# The sample controller is defined as an class which is then instanced as a
 71# singleton control object.  This is conventional Python practice and also
 72# is closer to practices involving the physical hardware.
 73
 74class ArrayRobot(Supervisor):
 75    def __init__(self):
 76
 77        # Initialize the superclass which implements the Robot API.
 78        super().__init__()
 79
 80        robot_name = self.getName()
 81        print("%s: controller connected." % (robot_name))
 82
 83        # Read back the array dimensions.  This is possible because the Robot is
 84        # configured with Supervisor permissions.
 85        num_x = self.getSelf().getField('num_x').value
 86        num_y = self.getSelf().getField('num_y').value
 87        print("Array has %d x %d units." % (num_x, num_y))
 88
 89        # Fetch handles for the motors into a 2D array.  The naming scheme is defined in
 90        # the proto file for the array.
 91        j = []
 92        for x in range(1,num_x+1):
 93            row = []
 94            for y in range(1,num_y+1):
 95                name = "motor%d%d" % (x, y)
 96                # print("Fetching motor", name)
 97                row.append(self.getDevice(name))
 98            j.append(row)
 99        self.joints = j
100
101        # Configure the motors for velocity control by setting
102        # the position targets to infinity.
103        for row in self.joints:
104            for motor in row:
105                motor.setPosition(float('inf'))
106                motor.setVelocity(0.0)
107
108        # Create a numpy array with a velocity for each motor.
109        self.velocity = np.zeros((num_x, num_y))
110        print("Shape of velocity array:", self.velocity.shape)
111
112        # Create some additional constant index arrays useful for generative functions.
113        # Each of these has the same shape as the motor array.
114        # x_mesh varies only the grid 'X' direction, y_mesh varies only along the grid 'Y' direction.
115        self.y_mesh, self.x_mesh = np.meshgrid(np.linspace(0, num_y-1, num_y), np.linspace(0, num_x-1, num_x))
116
117        # Initialize the performance state.
118        self.state = 'init'
119        self.new_state = True
120        self.state_timer = 0
121        self.current_time = 0.0
122
123        # Initialize the camera receiver
124        if use_vision:
125            self.listener = OSCListener()
126        else:
127            self.listener = None
128        return
129
130    #----------------------------------------------------------------
131    # internal method to set the velocity of each motor to the current velocity grid value
132    def _update_motor(self):
133        for vel_row, motor_row in zip(self.velocity, self.joints):
134            for vel, motor in zip(vel_row, motor_row):
135                motor.setVelocity(vel)
136
137    #----------------------------------------------------------------
138    # set every motor to zero velocity
139    def stop_all(self):
140        self.velocity[:,:] = 0.0
141        self._update_motor()
142
143    # set every motor to a specific velocity
144    def unison(self, velocity):
145        self.velocity[:,:] = velocity
146        self._update_motor()
147
148    # set every motor to a reasonable random velocity (positive and negative)
149    def randomize_velocity(self):
150        self.velocity = 3 - 6*np.random.rand(*self.velocity.shape)
151        self._update_motor()
152
153    # set every motor to wave function varying along the X direction.  Note: in the sample model, the
154    # array has been rotated so this is vertical
155    def wave_x(self):
156        period = 3.0
157        phase_velocity = 2*math.pi / period
158        phase = self.x_mesh
159        self.velocity = 3 * np.sin((phase_velocity * self.current_time) + 0.5*phase)
160        self._update_motor()
161
162    # set every motor to wave function varying along the Y direction.  Note: in the sample model, this
163    # is the horizontal direction across the room.
164    def wave_y(self):
165        period = 3.0
166        phase_velocity = 2*math.pi / period
167        phase = self.y_mesh
168        self.velocity = 1.5 + 1.5 * np.sin((phase_velocity * self.current_time) + 0.3*phase)
169        self._update_motor()
170
171    def camera_mirror(self):
172        # use the vision data to drive actuators
173        if self.listener is not None:
174            while not self.listener.messages.empty():
175                motion = self.listener.messages.get()
176                # Note: this reverses the 'image' and samples it, but has the
177                # relative dimensions hard-coded into the indices.
178                self.velocity[0,:] = (3.0/255.0) * motion[15:1:-3]
179                # print("new velocity:", self.velocity)
180                self._update_motor()
181
182    #----------------------------------------------------------------
183    # Implement state machine for the overall performance state, updated
184    # at the main event rate.
185    def poll_performance_state(self):
186
187        # implement a basic timer used by many states
188        timer_expired = False
189        self.state_timer -= 0.001*EVENT_LOOP_DT
190        if self.state_timer < 0:
191            timer_expired = True
192
193        # implement entry flag logic used by many state
194        new_state = self.new_state
195        self.new_state = False
196
197        # -----------------------------------
198
199        # Evaluate the side-effects and transition rules for each state
200        if self.state == 'init':
201            if new_state:
202                self.state_timer = 1.0    # initial pause
203                self.stop_all()
204
205            elif timer_expired:
206                # self._transition('spinny')
207                self._transition('mirror')
208
209        elif self.state == 'mirror':
210            # apply side effects on entry
211            if new_state:
212                print("Entering", self.state)
213                self.state_timer = 30.0
214
215            # apply transition rules
216            elif timer_expired:
217                self._transition('mirror')
218
219            # continuously update targets
220            self.camera_mirror()
221
222        elif self.state == 'spinny':
223            # apply side effects on entry
224            if new_state:
225                print("Entering", self.state)
226                self.state_timer = 3.0
227                self.unison(-5.0)
228
229            # apply transition rules
230            elif timer_expired:
231                self._transition('horizontal_wave')
232
233        elif self.state == 'horizontal_wave':
234            # apply side effects on entry
235            if new_state:
236                print("Entering", self.state)
237                self.state_timer = 6.0
238
239            # apply transition rules
240            elif timer_expired:
241                self._transition('tremble')
242
243            # apply continuous side-effects
244            else:
245                self.wave_y()
246
247        elif self.state == 'tremble':
248            # apply side effects on entry
249            if new_state:
250                print("Entering", self.state)
251                self.state_timer = 3.0
252
253            # apply transition rules
254            elif timer_expired:
255                self._transition('rest')
256
257            # apply continuous side-effects
258            else:
259                self.randomize_velocity()
260
261        elif self.state == 'rest':
262            if new_state:
263                print("Entering", self.state)
264                self.state_timer = 6.0
265                self.stop_all()
266
267            elif timer_expired:
268                self._transition('spinny')
269
270        else:
271            print("Invalid state, resetting.")
272            self.state = 'init'
273
274    # internal methods for state machine transitions
275    def _transition(self, new_state):
276        self.new_state = True
277        self.state = new_state
278        self.state_timer = 0
279
280    #----------------------------------------------------------------
281    def run(self):
282        # Run loop to execute a periodic script until the simulation quits.
283        # If the controller returns -1, the simulator is quitting.
284
285        while self.step(EVENT_LOOP_DT) != -1:
286            # Read simulator clock time.
287            self.current_time = self.getTime()
288
289            # Poll the performance state machine.
290            self.poll_performance_state()
291
292
293################################################################
294# If running directly from Webots as a script, the following will begin execution.
295# This will have no effect when this file is imported as a module.
296
297if __name__ == "__main__":
298    robot = ArrayRobot()
299    robot.run()

Proto File

The robot is modeled in a proto file to allow scripted generation of the units. The number of units can be varied after creation and the robot model will be regenerated. The proto file is VRML with embedded Lua scripting.

  1#VRML_SIM R2023b utf8
  2# documentation url: https://courses.ideate.cmu.edu/16-375
  3# Array of rotating one-DOF impeller devices for course exercises.  The
  4# collision model and graphics uses an imported STL files for ease of
  5# customization.  Each unit base has NULL physics so it will be fixed in place.
  6# license: No copyright, 2020-2023 Garth Zeglin.  This file is explicitly placed in the public domain.
  7
  8PROTO impeller-array [
  9  field SFVec3f    translation  0 0 0
 10  field SFRotation rotation     0 1 0 0
 11  field SFString   controller   "impeller_array"
 12  field SFString   name         "impeller_array"
 13  field SFInt32    num_x        1
 14  field SFInt32    num_y        5
 15  field SFFloat    spacing_x    0.8
 16  field SFFloat    spacing_y    0.8
 17  field SFString   customData   ""
 18]
 19{
 20  Robot {
 21    # connect properties to user-visible data fields
 22    translation IS translation
 23    rotation IS rotation
 24    controller IS controller
 25    name IS name
 26    customData IS customData
 27
 28    # enable the bodies within the system to directly interact
 29    selfCollision TRUE
 30
 31    # enable additional API access for the controller to read back parameters
 32    supervisor TRUE
 33
 34    children [
 35      # 2D nested loops to create each motorized unit
 36      %{ for x = 1, fields.num_x.value do }%
 37        %{ for y = 1, fields.num_y.value do }%
 38
 39          # define individualized names
 40          %{ local motor_name = "\"motor" .. x .. y .. "\"" }%
 41          %{ local link1_name = "\"link1" .. x .. y .. "\"" }%
 42          %{ local shape1_id = "shape1" .. x .. y }%	  
 43
 44          # define the base of each unit representing the individual motor and mounting
 45          Pose {
 46            # locate the base to match each hinge joint location
 47            translation %{= fields.spacing_x.value * (x-1) }% %{= fields.spacing_y.value * (y-1) }% 0.02
 48            children [
 49              Shape {
 50                appearance PBRAppearance {
 51                  baseColor 0.180392 0 1
 52                  metalness 0.5
 53                }
 54                geometry Cylinder {
 55                  height 0.04
 56                  radius 0.1
 57                }
 58              }
 59            ] # close children of base Pose
 60          } # close the base Pose
 61
 62          # define the first pivot of each unit, representing a driven motor axis
 63          HingeJoint {
 64            jointParameters HingeJointParameters {
 65              axis 0 0 1
 66              # locate the body origin within the robot coordinates
 67              anchor %{= fields.spacing_x.value * (x-1) }% %{= fields.spacing_y.value * (y-1) }% 0.02
 68            }
 69            device [
 70              RotationalMotor {
 71                name %{= motor_name }%
 72              }
 73            ]
 74            # define the body of the first driven link of each unit
 75            endPoint Solid {
 76              # locate the link1 body origin within the overall robot coordinates
 77              translation %{= fields.spacing_x.value * (x-1) }% %{= fields.spacing_y.value * (y-1) }% 0.05
 78
 79              # provide a unique name for the link1 Solid to avoid warnings
 80              name %{= link1_name }%
 81
 82              physics Physics {
 83                density -1
 84                mass 2
 85              }
 86
 87              # define the link1 graphics
 88              children [ # first link Solid
 89                DEF %{= shape1_id }% Pose {
 90                  # locate the link1 visible model within link2 coordinates
 91                  translation 0 0 -0.003
 92                  children [ # first link Pose
 93                    Shape {
 94                      appearance PBRAppearance {
 95                        baseColor 0.772549 0.2 0.192157
 96                        metalness 0.5
 97                        roughness 0.5
 98                      }
 99                      # import the visible geometry; the physics properties and
100                      # collision use the boundingObject field which defines a
101                      # simple box
102                      geometry Mesh {
103                        url ["../stl/large-impeller.stl"]
104                      }
105                    }
106                  ] # close children of first link Pose
107                }
108              ] # close children of first link Solid
109
110              # define the collision and physics properties for link1
111              boundingObject USE %{= shape1_id }% 
112
113            } # end the link1 Solid
114          } # end first HingeJoint
115        %{ end }% # end inner loop to create each element
116      %{ end }% # end outer loop to create each element
117   ] # close children of Robot
118  } # close the Robot definition
119}

World File

  1#VRML_SIM R2023b utf8
  2
  3EXTERNPROTO "../protos/HL-A11-room.proto"
  4EXTERNPROTO "../protos/impeller-array.proto"
  5EXTERNPROTO "../protos/ball_array.proto"
  6EXTERNPROTO "../protos/well-table.proto"
  7
  8EXTERNPROTO "https://raw.githubusercontent.com/cyberbotics/webots/R2023b/projects/appearances/protos/VarnishedPine.proto"
  9
 10WorldInfo {
 11  basicTimeStep 5
 12}
 13Viewpoint {
 14  orientation -0.1198469502720241 -0.0009538446510321846 0.9927919211450486 2.9859745072513038
 15  position 5.95936025644994 -0.8386617204613961 2.5255548073357983
 16}
 17Background {
 18  skyColor [
 19    0.1 0.1 0.1
 20  ]
 21}
 22SpotLight {
 23  attenuation 0 0 1
 24  beamWidth 0.3
 25  cutOffAngle 0.4
 26  direction 1 0 -2
 27  intensity 5
 28  location 0 0 2
 29  castShadows TRUE
 30}
 31SpotLight {
 32  attenuation 0 0 1
 33  beamWidth 0.3
 34  cutOffAngle 0.4
 35  direction 1 0 -2
 36  intensity 5
 37  location 0 -1.2 2
 38  castShadows TRUE
 39}
 40SpotLight {
 41  attenuation 0 0 1
 42  beamWidth 0.3
 43  cutOffAngle 0.4
 44  direction 1 0 -2
 45  intensity 5
 46  location 0 1.2 2
 47  castShadows TRUE
 48}
 49HL-A11-room {
 50}
 51
 52well-table {
 53  translation 0.7 -2.0 0.5
 54  left_end TRUE
 55  name "table1"
 56}
 57
 58well-table {
 59  translation 0.7 -1.2 0.5
 60  name "table2"
 61}
 62
 63well-table {
 64  translation 0.7 -0.4 0.5
 65  name "table3"  
 66}
 67
 68well-table {
 69  translation 0.7  0.4 0.5
 70  name "table4"    
 71}
 72
 73well-table {
 74  translation 0.7  1.2 0.5
 75  name "table5"
 76  right_end TRUE
 77}
 78       
 79DEF ball Solid {
 80  translation 1.1 0 0.8
 81  children [
 82    DEF ballShape Pose {
 83      children [
 84        Shape {
 85          appearance PBRAppearance {
 86            baseColor 0.714443 0 0.998718
 87            roughness 0.2
 88            metalness 0.7
 89          }
 90          geometry Sphere {
 91            radius 0.1
 92            subdivision 2
 93          }
 94        }
 95      ]
 96    }
 97  ]
 98  name "ball"
 99  boundingObject USE ballShape
100  physics Physics {
101    density -1
102    mass 0.25
103  }
104}
105ball_array {
106  translation 1.1 0.8 0.6
107  rows 5
108  cols 5
109  lightRadius 0.0127
110  darkRadius 0.0127
111}
112impeller-array {
113  translation 1.1 -1.6 0.45
114  rotation 0 0 1 0
115}
116
117DEF front_facing Pose {
118  translation 1.5 0 0.350
119  children [
120    Shape {
121      appearance VarnishedPine {
122      }
123      geometry Box {
124        size 0.006 4.0 0.5
125      }
126    }
127  ]
128}