Skip to content

Connecting blocks

Peter Corke edited this page Apr 10, 2023 · 8 revisions

Connecting blocks

We will consider a more complex example here

RVC Figure 4.4

which has been hand annotated with the block names to make the codification process a bit easier.

We start by defining each of the blocks, using the same names as we scribbled on the diagram.

goal = bd.CONSTANT([5, 5])
error = bd.SUM('+-')
d2goal = bd.FUNCTION(lambda d: math.sqrt(d[0]**2 + d[1]**2))
h2goal = bd.FUNCTION(lambda d: math.atan2(d[1], d[0]))
heading_error = bd.SUM('+-', angles=True)
Kv = bd.GAIN(0.5)
Kh = bd.GAIN(4)
bike = bd.BICYCLE(x0=[5, 2, 0])

def background_graphics(ax):
    ax.plot(5, 5, '*')
    ax.plot(5, 2, 'o')

vplot = bd.VEHICLE(scale=[0, 10], size=0.7, shape='box', init=background_graphics)
vscope = bd.SCOPE(name='velocity')
hscope = bd.SCOPE(name='heading')
mux = bd.MUX(2)

Things to note:

  • the FUNCTION blocks are passed lambda functions. The value at the input port is assigned to the parameter d and the function result appears at the output port. In this case, the number of input and output ports are each one (default). A function can receive multiple input arguments, from multiple input ports, and can return results to multiple output ports using a list.
  • the second instance of SUM has the option angles=True to indicate that the signals are angles and to wrap the result into the range [-π π).
  • the VEHICLE block is passed a function which initialises the graphic display, in this case by marking the start and goal positions. The first argument scale=[0, 10] indicates that minimum and maximum coordinate, and since it is only a 2-vector it is applied to both the x- and y-axes. A 4-vector allows independent control over the scale for both axes.

Connecting the blocks

There are many different ways (maybe too many) to express the connections between the blocks.

Using connect: point to point

bd.connect(goal, error[0])
bd.connect(error, d2goal)
bd.connect(error, h2goal)
bd.connect(d2goal, Kv)
bd.connect(Kv, bike[0])
bd.connect(Kv, vscope)
bd.connect(h2goal, heading_error[0])
bd.connect(bike[2], heading_error[1])
bd.connect(heading_error, hscope)
bd.connect(heading_error, Kh)
bd.connect(Kh, bike[1])
bd.connect(bike[0], mux[0])
bd.connect(bike[1], mux[1])
bd.connect(bike[0], vplot[0])
bd.connect(bike[1], vplot[1])
bd.connect(bike[2], vplot[2])
bd.connect(mux, error[1])

which is 17 lines of code.

The first argument is implicitly an output port, and the second argument is implicitly an input port.

The arguments can be either blocks or plugs. In the first line, the first argument goal is to a block but the method builds a Plug for goal[0]. If no index given the first port is assumed. The second argument error[0] is a plug..

This format is easy to auto-generate, perhaps from some kind of graphical layout tool.

Using multi-connect

The connect method can accept multiple destinations, ie. connect(src, dest1, dest2, dest3) which creates 3 wires: src-dest1, src-dest2, src-dest3.

bd.connect(goal, error[0])
bd.connect(error, d2goal, h2goal)     # changed
bd.connect(d2goal, Kv)
bd.connect(Kv, bike[0], vscope)       # changed
bd.connect(h2goal, heading_error[0])
bd.connect(bike[2], heading_error[1])
bd.connect(heading_error, hscope, Kh) # changed
bd.connect(Kh, bike[1])
bd.connect(bike[0], mux[0])
bd.connect(bike[1], mux[1])
bd.connect(bike[0], vplot[0])
bd.connect(bike[1], vplot[1])
bd.connect(bike[2], vplot[2])
bd.connect(mux, error[1])

which is 14 lines of code.

Using slices

In the case where multiple wires, on different ports, connect two blocks we can use a more succint notation. Instead of a single port index we can use Python slice notation, with a start value, stop value and optional step. A slice can count upwards, eg. [0:5:2] which is (0, 2, 4)or downwards eg.[5:2:-1]which is(5,4,3)`.

bd.connect(goal, error[0])
bd.connect(error, d2goal, h2goal)
bd.connect(d2goal, Kv)
bd.connect(Kv, bike[0], vscope)
bd.connect(h2goal, heading_error[0])
bd.connect(bike[2], heading_error[1])
bd.connect(heading_error, hscope, Kh)
bd.connect(Kh, bike[1])
bd.connect(bike[0:2], mux[0:2])      # changed
bd.connect(bike[0:3], vplot[0:3])    # changed
bd.connect(mux, error[1])

which is 11 lines of code.

We have used slices to connect multiple output ports of bike to subsequent blocks. All blocks and plugs passed to the connect method must have the same number of wires.

If the source is a slice, then all the destingations must be a slice. A block name by itself is equivalent to block[0] which is a single wire.

Using named ports

bd.connect(goal, error[0])
bd.connect(error, d2goal, h2goal)
bd.connect(d2goal, Kv)
bd.connect(Kv, bike.v, vscope)        # changed
bd.connect(h2goal, heading_error[0])
bd.connect(bike[2], heading_error[1])
bd.connect(heading_error, hscope, Kh)
bd.connect(Kh, bike.gamma)            # changed
bd.connect(bike[0:2], mux[0:2])
bd.connect(bike[0:3], vplot[0:3])
bd.connect(mux, error[1])

which is 11 lines of code.

Some blocks have attributes which return Plugs just as indices do. For the BICYCLE block .v is equivalent to [0], and .gamma is equivalent to [1].

These name aliases can be established when you create your own block or as extra arguments to any block. For example, we could rewrite these block definitions as:

goal = bd.CONSTANT([5, 5], onames=('g',)
d2goal = bd.FUNCTION(lambda d: math.sqrt(d[0]**2 + d[1]**2), \
    onames=('d',), inames=('x',))

where we pass in tuples of names for the input or output ports. Now we can refer to goal.g or d2goal.x.

Names can also use matplotlib's mathtext notation which is a simple subset of LaTeX. For example:

goal = bd.CONSTANT([5, 5], onames=(r'$x_g$',)

will create an output port names xg where the LaTeX markup characters have been stripped, but the mathtext string would be propogated to other blocks and would be displayed on say the axis or legend of a plot. Note that you need to use a raw string with the r-prefix.

Using assignment

goal[0] = error[0]
d2goal[0] = error
h2goal[0] = error
Kv[0] = d2goal
bike.v = Kv
vscope[0] = Kv
heading_error[0] = h2goal
heading_error[1] = bike[2]
hscope[0] = heading_error
Kh[0] = = heading_error
bike.gamma = Kh
mux[0:2] = bike[0:2]
vplot[0:3] = bike[0:3]
error[1] = mux

which is 14 lines of code. It is not possible to express multi-connections using assignments. Note that the left-hand side must always be a Plug, ie. it must have an index or attribute.

Using implicit connections

We use the >> operator to indicate implicit wiring

goal[0] = error[0]
d2goal[0] = error
h2goal[0] = error
Kv[0] = d2goal
bike.v = Kv
vscope[0] = Kv
heading_error[0] = h2goal
heading_error[1] = bike[2]
hscope[0] = heading_error
bike.gamma = heading_error >> Kh   # changed
mux[0:2] = bike[0:2]
vplot[0:3] = bike[0:3]
error[1] = mux

which is 13 lines of code. Instead of connecting heading_error to Kh, and then to bike.gamma we have done it implicitly using the >> operator.

We could also have chosen to instantiate the Kh block inline by:

bike.gamma = heading_error * bd.GAIN(4)

Using explicit inputs

All blocks can accept additional positional arguments which are blocks or plugs that connect to it – they are given in the input port order. For example we could write the summation line from above as

sum = bd.SUM('+-', inputs=(goal, mux))

which says that the inputs to the summing junction are goal (+) and mux (-).

In early versions of bdsim this could be written more compactly as

sum = bd.SUM('+-', goal, mux)

but this required custom code in every block, whereas the inputs option allows all that logic to pushed off to the Block constructor. A compromise might be for simple arithmetic blocks like SUM, PROD and GAIN to support this syntax but that would introduce inconsistency which is probably not worth it. Support for this old syntax is being progressively removed.

Applying explicit inputs, implicit wiring, assignments and named ports we can now write our example, block declaration and wiring, as

bike = bd.BICYCLE(x0=[5, 2, 0])
error = bd.SUM('+-', inputs=(bd.CONSTANT([5, 5], name='goal'), bd.MUX(2, bike[0:2])), name='sum') 
bike.v = bd.FUNCTION(lambda d: math.sqrt(d[0]**2 + d[1]**2), inputs=(error,), name='d2goal') >> bd.GAIN(0.5, name='Kv')
h2goal = bd.FUNCTION(lambda d: math.atan2(d[1], d[0]), inputs=(error,), name='h2goal')
bike.gamma = bd.SUM('+-', inputs=(h2goal, bike[2]), angles=True, name='hsum') >> bd.GAIN(4, name='Kh')

which is just 5 lines of code.

However we have omitted the scopes, since this compact form doesn't conveniently support one-to-many connections. Two of the scopes are easily added using implicit inputs

bd.SCOPE(name='heading', bike.theta)
bd.VEHICLE(scale=[0, 10], bike[0:3], size=0.7, shape='box', init=background_graphics)

but the velocity signal we want is an intermediate value within the third line of the first code block in this section. We can use Python's new "walrus operator" written as := which is something like C's assignment expression. (From 3.8 and controversial.

To add the scopes to the above compact code we could write

bike.v = (velocity := bd.FUNCTION(lambda d: math.sqrt(d[0]**2 + d[1]**2), error, name='d2goal') * bd.GAIN(0.5, name='Kv'))  # walrus

and then connect a scope to that intermediate value

bd.SCOPE(name='velocity')

Note that it is not valid to write x = y := a, we must write x = (y := a).

Wiring summary

There are many ways to express your block diagram in code. The first form shown is very verbose but easy to write, and also suitable for auto-generated block diagrams. The final forms are compact and much more like regular programming, with blocks and wires being created under the hood to support the next step of evaluation and simulation.

You can mix and match approaches to suit your own preferences and style.

Errors during wiring

No errors are checked for during the wiring phase. This happens in the compilation stage.