-
Notifications
You must be signed in to change notification settings - Fork 33
Connecting blocks
We will consider a more complex example here
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 parameterd
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 optionangles=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.
There are many different ways (maybe too many) to express the connections between the blocks.
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.
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.
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.
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 Plug
s 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.
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.
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)
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)
.
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.
No errors are checked for during the wiring phase. This happens in the compilation stage.
Copyright (c) Peter Corke 2020-23
- Home
- FAQ
- Changes
- Adding blocks
- Block path
- Connecting blocks
- Subsystems
- Compiling
- Running
- Runtime options
- Discrete-time blocks
- Figures
- Real time control
- PID control
- Coding patterns