Equations of Motion with Holonomic Constraints¶
Note
You can download this example as a Python script:
holonomic-eom.py
or Jupyter Notebook:
holonomic-eom.ipynb
.
from IPython.display import HTML
from matplotlib.animation import FuncAnimation
from scipy.integrate import solve_ivp
import matplotlib.pyplot as plt
import numpy as np
import sympy as sm
import sympy.physics.mechanics as me
me.init_vprinting(use_latex='mathjax')
class ReferenceFrame(me.ReferenceFrame):
def __init__(self, *args, **kwargs):
kwargs.pop('latexs', None)
lab = args[0].lower()
tex = r'\hat{{{}}}_{}'
super(ReferenceFrame, self).__init__(*args,
latexs=(tex.format(lab, 'x'),
tex.format(lab, 'y'),
tex.format(lab, 'z')),
**kwargs)
me.ReferenceFrame = ReferenceFrame
Learning Objectives¶
After completing this chapter readers will be able to:
formulate the differential algebraic equations of motion for a multibody system that includes additional holonomic constraints
simulate a system with additional constraints using a differential algebraic equation integrator
compare simulation results that do and do not manage constraint drift
Introduction¶
When there are holonomic constraints present the equations of motion are comprised of the kinematical differential equations \(\bar{f}_k=0\), dynamical differential equations \(\bar{f}_d=0\), and the holonomic constraint equations \(\bar{f}_h=0\). This set of equations are called differential algebraic equations and the algebraic equations cannot be solved for explicitly, as we did with the nonholonomic algebraic constraint equations.
In a system such as this, there are \(N=n+M\) total coordinates, with \(n\) generalized coordinates \(\bar{q}\) and \(M\) additional dependent coordinates \(\bar{q}_r\). The holonomic constraints take this form:
\(n\) generalized speeds \(\bar{u}\) and \(M\) dependent speeds \(\bar{u}_r\) can be introduced using \(N\) kinematical differential equations.
We can formulate the equations of motion by transforming the holonomic constraints into a function of generalized speeds. These equations are then treated just like nonholonomic constraints described in the previous Chp. Equations of Motion with Nonholonomic Constraints.
We can solve for \(M\) dependent generalized speeds:
and then rewrite the kinematical and dynamical differential equations in terms of the generalized speeds, their time derivatives, the generalized coordinates, and the dependent coordinates.
This final set of equations has \(N+n\) state variables and can be integrated as a set of ordinary differential equations or the \(N+n+M\) equations can be integrated as a set of differential algebraic equations. We will demonstrate the differences in the results for the two approaches.
Four-bar Linkage Equations of Motion¶
To demonstrate the formulation of the equations of motion of a system with an explicit holonomic constraints, let’s revisit the four-bar linkage from Sec. Four-Bar Linkage. We will now make \(P_2\) and \(P_3\) particles, each with mass \(m\) and include the effects of gravity in the \(-\hat{n}_y\) direction.
Fig. 49 a) Shows four links in a plane \(A\), \(B\), \(C\), and \(N\) with respective lengths \(l_a,l_b,l_c,l_n\) connected in a closed loop at points \(P_1,P_2,P_3,P_4\). b) Shows the same linkage that has been separated at point \(P_4\) to make it an open chain of links.¶
As before, we setup the system by disconnecting the kinematic loop at point \(P_4\) and then use this open loop to derive equations for the holonomic constraints that close the loop.
1. Declare all of the variables¶
We have three coordinates, only one of which is a generalized coordinate. I use
q
to hold the single generalized coordinate, qr
for the two dependent
coordinates, and qN
to hold all the coordinates; similarly for the
generalized speeds.
q1, q2, q3 = me.dynamicsymbols('q1, q2, q3')
u1, u2, u3 = me.dynamicsymbols('u1, u2, u3')
la, lb, lc, ln = sm.symbols('l_a, l_b, l_c, l_n')
m, g = sm.symbols('m, g')
t = me.dynamicsymbols._t
p = sm.Matrix([la, lb, lc, ln, m, g])
q = sm.Matrix([q1])
qr = sm.Matrix([q2, q3])
qN = q.col_join(qr)
u = sm.Matrix([u1])
ur = sm.Matrix([u2, u3])
uN = u.col_join(ur)
qdN = qN.diff(t)
ud = u.diff(t)
p, q, qr, qN, u, ur, uN, qdN, ud
ur_zero = {ui: 0 for ui in ur}
uN_zero = {ui: 0 for ui in uN}
qdN_zero = {qdi: 0 for qdi in qdN}
ud_zero = {udi: 0 for udi in ud}
2. Setup the open loop kinematics and holonomic constraints¶
Start by defining the orientation of the reference frames and positions of the points in terms of the \(N=3\) coordinates, leaving \(P_4\) unconstrained.
N = me.ReferenceFrame('N')
A = me.ReferenceFrame('A')
B = me.ReferenceFrame('B')
C = me.ReferenceFrame('C')
A.orient_axis(N, q1, N.z)
B.orient_axis(A, q2, A.z)
C.orient_axis(B, q3, B.z)
P1 = me.Point('P1')
P2 = me.Point('P2')
P3 = me.Point('P3')
P4 = me.Point('P4')
P2.set_pos(P1, la*A.x)
P3.set_pos(P2, lb*B.x)
P4.set_pos(P3, lc*C.x)
3. Create the holonomic constraints¶
Now \(M=2\) holonomic constraints can be found by closing the loop.
loop = P4.pos_from(P1) - ln*N.x
fh = sm.Matrix([loop.dot(N.x), loop.dot(N.y)])
fh = sm.trigsimp(fh)
fh
Warning
Be careful about using trigsimp()
on larger problems, as it can really slow down the calculations. It is not
necessary to use, but I do so here so that the resulting equations are human
readable in this context.
Note that these constraints are only a function of the \(N\) coordinates, not their time derivatives.
me.find_dynamicsymbols(fh)
4. Specify the kinematical differential equations¶
Use simple definitions for the generalized speed \(u_1\) and the dependent speeds \(u_2\) and \(u_3\). We create \(N=3\) generalized speeds even though the degrees of freedom are \(n=1\).
fk = sm.Matrix([
q1.diff(t) - u1,
q2.diff(t) - u2,
q3.diff(t) - u3,
])
Mk = fk.jacobian(qdN)
gk = fk.xreplace(qdN_zero)
qdN_sol = -Mk.LUsolve(gk)
qd_repl = dict(zip(qdN, qdN_sol))
qd_repl
5. Solve for the dependent speeds¶
Differentiate the holonomic constraints with respect to time to arrive at a motion constraint. This is equivalent to setting \(^{N}\bar{v}^{P_4}=0\).
fhd = fh.diff(t).xreplace(qd_repl)
fhd = sm.trigsimp(fhd)
fhd
These holonomic motion constraints are functions of the coordinates and speeds.
me.find_dynamicsymbols(fhd)
Choose \(u_2\) and \(u_3\) as the dependent speeds and solve the linear equations for these dependent speeds.
Mhd = fhd.jacobian(ur)
ghd = fhd.xreplace(ur_zero)
ur_sol = sm.trigsimp(-Mhd.LUsolve(ghd))
ur_repl = dict(zip(ur, ur_sol))
ur_repl[u2]
ur_repl[u3]
6. Write velocities in terms of the generalized speeds¶
We have three simple rotations and we can write the three angular velocities only in terms of \(u_1\) by using the expressions for the independent speeds from the previous step.
A.set_ang_vel(N, u1*N.z)
B.set_ang_vel(A, ur_repl[u2]*A.z)
C.set_ang_vel(B, ur_repl[u3]*B.z)
Now, by using the two point velocity theorem the velocities of each point will also only be in terms of \(u_1\).
P1.set_vel(N, 0)
P2.v2pt_theory(P1, N, A)
P3.v2pt_theory(P2, N, B)
P4.v2pt_theory(P3, N, C)
(me.find_dynamicsymbols(P2.vel(N), reference_frame=N) |
me.find_dynamicsymbols(P3.vel(N), reference_frame=N) |
me.find_dynamicsymbols(P4.vel(N), reference_frame=N))
We’ll also need the kinematical differential equations only in terms of the one generalized speed \(u_1\), so replace the dependent speeds in \(\bar{g}_k\).
gk = gk.xreplace(ur_repl)
7. Form the generalized active forces¶
We have a holonomic system so the number of degrees of freedom is \(n=1\). There are two particles that move and gravity acts on each of them, as a contributing force. The resultant contributing forces on each of the particles are:
R_P2 = -m*g*N.y
R_P3 = -m*g*N.y
The partial velocities of each particle are easily found for the single generalized speed and \(\bar{F}_r\) is:
Fr = sm.Matrix([
P2.vel(N).diff(u1, N).dot(R_P2) + P3.vel(N).diff(u1, N).dot(R_P3)
])
Fr
Check to make sure our generalized active forces do not contain dependent speeds.
me.find_dynamicsymbols(Fr)
8. Form the generalized inertia forces¶
To calculate the generalized inertia forces we need the acceleration of each particle. These should be only functions of \(\dot{u}_1,u_1\), and the three coordinates. For \(P_2\), that is already true:
me.find_dynamicsymbols(P2.acc(N), reference_frame=N)
but for \(P_3\) we need to make some substitutions:
me.find_dynamicsymbols(P3.acc(N), reference_frame=N)
Knowing that, the inertia resultants can be written as:
Rs_P2 = -m*P2.acc(N)
Rs_P3 = -m*P3.acc(N).xreplace(qd_repl).xreplace(ur_repl)
and the generalized inertia forces can be formed and we can make sure they are not functions of the dependent speeds.
Frs = sm.Matrix([
P2.vel(N).diff(u1, N).dot(Rs_P2) + P3.vel(N).diff(u1, N).dot(Rs_P3)
])
me.find_dynamicsymbols(Frs)
9. Equations of motion¶
Finally, the matrix form of dynamical differential equations is found as we have done before.
Md = Frs.jacobian(ud)
gd = Frs.xreplace(ud_zero) + Fr
And we can check to make sure the dependent speeds have been eliminated.
me.find_dynamicsymbols(Mk), me.find_dynamicsymbols(gk)
me.find_dynamicsymbols(Md), me.find_dynamicsymbols(gd)
Simulate without constraint enforcement¶
The equations of motion are functions of all three coordinates, yet two of them are dependent on the other. For the evaluation of the right hand side of the equations to be valid, the coordinates must satisfy the holonomic constraints. As presented, Eqs. (211) only contain the constraints that the velocity and acceleration of point \(P_4\) must be zero, but the position constraint is not explicitly present. Neglecting the position constraint will cause numerical issues during integration, as we will see.
Create an eval_rhs(t, x, p)
as we have done before, noting that
\(\bar{f}_d \in \mathbb{R}^1\).
eval_k = sm.lambdify((qN, u, p), (Mk, gk))
eval_d = sm.lambdify((qN, u, p), (Md, gd))
def eval_rhs(t, x, p):
"""Return the derivative of the state at time t.
Parameters
==========
t : float
x : array_like, shape(4,)
x = [q1, q2, q3, u1]
p : array_like, shape(6,)
p = [la, lb, lc, ln, m, g]
Returns
=======
xd : ndarray, shape(4,)
xd = [q1d, q2d, q3d, u1d]
"""
qN = x[:3] # shape(3,)
u = x[3:] # shape(1,)
Mk, gk = eval_k(qN, u, p)
qNd = -np.linalg.solve(Mk, np.squeeze(gk))
# Md, gd, and ud are each shape(1,1)
Md, gd = eval_d(qN, u, p)
ud = -np.linalg.solve(Md, gd)[0]
return np.hstack((qNd, ud))
Here I select some feasible bar lengths. See the section on the Grashof condition to learn more about selecting lengths in four-bar linkages.
p_vals = np.array([
0.8, # la [m]
2.0, # lb [m]
1.0, # lc [m]
2.0, # ln [m]
1.0, # m [kg]
9.81, # g [m/s^2]
])
Now we need to generate coordinates that are consistent with the constraints.
\(\bar{f}_h\) is nonlinear in all of the coordinates. We can solve these
equations for the dependent coordinates using numerical root finding
methods. SciPy’s fsolve()
function is
capable of finding the roots for sets of nonlinear equations, given a good
guess.
We’ll import fsolve
directly like so:
from scipy.optimize import fsolve
fsolve()
requires a function that evaluates expressions that equal to zero
and a guess for the roots of that function, at a minimum. Nonlinear functions
will most certianly have multiple solutions for its roots and fsolve()
will
converge to one of the solutions. The better the provided the guess the more
likely it will converge on the desired solution. Our function should evaluate
the holonomic constraints given the dependent coordinates. We can use
lambdify()
to create this function. I make the first argument
\(\bar{q}_r\) because these are the values we want to solve for using
fsolve()
.
eval_fh = sm.lambdify((qr, q1, p), fh)
Now select a desired value for the generalized coordinate \(q_1\) and guesses for \(q_2\) and \(q_3\).
q1_val = np.deg2rad(10.0)
qr_guess = np.deg2rad([10.0, -150.0])
eval_fh()
returns a 2x1 array so a lambda
function is used to squeeze
the output. \(q_2\) and \(q_3\) that satisfy the constraints are then
found with:
q2_val, q3_val = fsolve(
lambda qr, q1, p: np.squeeze(eval_fh(qr, q1, p)), # squeeze to a 1d array
qr_guess, # initial guess for q2 and q3
args=(q1_val, p_vals)) # known values in fh
Now we have values of the coordinates that satisfy the constraints.
qN_vals = np.array([q1_val, q2_val, q3_val])
np.rad2deg(qN_vals)
array([ 10. , 6.57526576, -151.3836336 ])
We can check that they return zero (or better stated as within fsolve()
’s
tolerance):
eval_fh(qN_vals[1:], qN_vals[0], p_vals)
array([[-2.44249065e-14],
[ 5.88751270e-13]])
Exercise
There are most often multiple solutions for the dependent coordinates for a given value of the dependent coordinates. What are the other possible solutions for these parameter values?
Now that we have consistent coordinates, the initial state vector can be created. We will start at an initial state of rest with \(u_1(t_0)=0\).
u1_val = 0.0
x0 = np.hstack((qN_vals, u1_val))
x0
array([ 0.17453293, 0.11476004, -2.64214284, 0. ])
We will integrate over 30 seconds to show how the constraints hold up over a longer period of time.
t0, tf, fps = 0.0, 30.0, 20
With consistent coordinates the initial conditions can be set and
eval_rhs()
tested.
eval_rhs(t0, x0, p_vals)
array([ 0. , -0. , -0. , -9.4688079])
At every time step in the simulation the holonomic constraints should be satisfied. To check this we will need to evaluate the constraints \(\bar{f}_h\) at each time step. The following function does this and returns the constraint residuals at each time step.
def eval_constraints(xs, p):
"""Returns the value of the left hand side of the holonomic constraints
at each time instance.
Parameters
==========
xs : ndarray, shape(n, 4)
States at each of n time steps.
p : ndarray, shape(6,)
Constant parameters.
Returns
=======
con : ndarray, shape(n, 2)
fh evaluated at each xi in xs.
"""
con = []
for xi in xs: # xs is shape(n, 4)
con.append(eval_fh(xi[1:3], xi[0], p).squeeze())
return np.array(con)
The dependent initial conditions need to be solved before each simulation and
the constraints evaluated, so it will be helpful to package this process into a
reusable function. The following function takes the simulation parameters and
returns the simulation results. I have set the integration tolerances
explicitly as rtol=1e-3
and atol=1e-6
. These happen to be the default
tolerances for solve_ivp()
and we will use three different approaches and
we want to make sure the tolerances are set the same for each integration so we
can fairly compare the results.
def simulate(eval_rhs, t0, tf, fps, q1_0, u1_0, q2_0g, q3_0g, p):
"""Returns the simulation results.
Parameters
==========
eval_rhs : function
Function that returns the derivatives of the states in the form:
``eval_rhs(t, x, p)``.
t0 : float
Initial time in seconds.
tf : float
Final time in seconds.
fps : integer
Number of "frames" per second to output.
q1_0 : float
Initial q1 angle in radians.
u1_0 : float
Initial u1 rate in radians/s.
q2_0g : float
Guess for the initial q2 angle in radians.
q3_0g : float
Guess for the initial q3 angle in radians.
p : array_like, shape(6,)
Constant parameters p = [la, lb, lc, ln, m, g].
Returns
=======
ts : ndarray, shape(n,)
Time values.
xs : ndarray, shape(n, 4)
State values at each time.
con : ndarray, shape(n, 2)
Constraint violations at each time in meters.
"""
# generate the time steps
ts = np.linspace(t0, tf, num=int(fps*(tf - t0)))
# solve for the dependent coordinates
q2_val, q3_val = fsolve(
lambda qr, q1, p: np.squeeze(eval_fh(qr, q1, p)),
[q2_0g, q3_0g],
args=(q1_0, p))
# establish the initial conditions
x0 = np.array([q1_val, q2_val, q3_val, u1_0])
# integrate the equations of motion
sol = solve_ivp(eval_rhs, (ts[0], ts[-1]), x0, args=(p,), t_eval=ts,
rtol=1e-3, atol=1e-6)
xs = np.transpose(sol.y)
ts = sol.t
# evaluate the constraints
con = eval_constraints(xs, p)
return ts, xs, con
Similarly, create a function that can be reused for plotting the state trajectories and the constraint residuals.
def plot_results(ts, xs, con):
"""Returns the array of axes of a 4 panel plot of the state trajectory
versus time.
Parameters
==========
ts : array_like, shape(n,)
Values of time.
xs : array_like, shape(n, 4)
Values of the state trajectories corresponding to ``ts`` in order
[q1, q2, q3, u1].
con : array_like, shape(n, 2)
x and y constraint residuals of P4 at each time in ``ts``.
Returns
=======
axes : ndarray, shape(3,)
Matplotlib axes for each panel.
"""
fig, axes = plt.subplots(3, 1, sharex=True)
fig.set_size_inches((10.0, 6.0))
axes[0].plot(ts, np.rad2deg(xs[:, :3])) # q1(t), q2(t), q3(t)
axes[1].plot(ts, np.rad2deg(xs[:, 3])) # u1(t)
axes[2].plot(ts, np.squeeze(con)) # fh(t)
axes[0].legend(['$q_1$', '$q_2$', '$q_3$'])
axes[1].legend(['$u_1$'])
axes[2].legend([r'$\cdot\hat{n}_x$', r'$\cdot\hat{n}_y$'])
axes[0].set_ylabel('Angle [deg]')
axes[1].set_ylabel('Angular Rate [deg/s]')
axes[2].set_ylabel('Distance [m]')
axes[2].set_xlabel('Time [s]')
fig.tight_layout()
return axes
With the functions in place we can simulate the system and plot the results.
ts, xs, con = simulate(
eval_rhs,
t0=t0,
tf=tf,
fps=fps,
q1_0=np.deg2rad(10.0),
u1_0=0.0,
q2_0g=np.deg2rad(10.0),
q3_0g=np.deg2rad(-150.0),
p=p_vals,
)
plot_results(ts, xs, con);

At first glance, the linkage seems to simulate fine with realistic angle values and angular rates. The motion is periodic but looking closely, for example at \(u_1(t)\), you can see that the angular rate changes in each successive period. The last graph shows the holonomic constraint residuals across time. This graph shows that the constraints are satisfied at the beginning of the simulation but that the residuals grow over time. This accumulation of error grows as large as 8 cm near the end of the simulation. The drifting constraint residuals are the cause of the variations of motion among the oscillation periods. Tighter integration tolerances can reduce the drifting constraint residuals, but that will come at an unnecessary computational cost and not fully solve the issue.
The effect of the constraints not staying satisfied throughout the simulation can also be seen if the system is animated.
Animate the Motion¶
We’ll animate the four bar linkage multiple times so it is useful to create some functions to for the repeated use. Start by creating a function that evaluates the point locations, as we have done before.
coordinates = P2.pos_from(P1).to_matrix(N)
for point in [P3, P4, P1, P2]:
coordinates = coordinates.row_join(point.pos_from(P1).to_matrix(N))
eval_point_coords = sm.lambdify((qN, p), coordinates)
Now create a function that plots the initial configuration of the linkage and returns any objects we may need in the animation code.
def setup_animation_plot(ts, xs, p):
"""Returns objects needed for the animation.
Parameters
==========
ts : array_like, shape(n,)
Values of time.
xs : array_like, shape(n, 4)
Values of the state trajectories corresponding to ``ts`` in order
[q1, q2, q3, u1].
p : array_like, shape(6,)
"""
x, y, z = eval_point_coords(xs[0, :3], p)
fig, ax = plt.subplots()
fig.set_size_inches((10.0, 10.0))
ax.set_aspect('equal')
ax.grid()
lines, = ax.plot(x, y, color='black',
marker='o', markerfacecolor='blue', markersize=10)
title_text = ax.set_title('Time = {:1.1f} s'.format(ts[0]))
ax.set_xlim((-1.0, 3.0))
ax.set_ylim((-1.0, 1.0))
ax.set_xlabel('$x$ [m]')
ax.set_ylabel('$y$ [m]')
return fig, ax, title_text, lines
setup_animation_plot(ts, xs, p_vals);

Now we can create a function that initializes the plot, runs the animation and displays the results in Jupyter.
def animate_linkage(ts, xs, p):
"""Returns an animation object.
Parameters
==========
ts : array_like, shape(n,)
xs : array_like, shape(n, 4)
x = [q1, q2, q3, u1]
p : array_like, shape(6,)
p = [la, lb, lc, ln, m, g]
"""
# setup the initial figure and axes
fig, ax, title_text, lines = setup_animation_plot(ts, xs, p)
# precalculate all of the point coordinates
coords = []
for xi in xs:
coords.append(eval_point_coords(xi[:3], p))
coords = np.array(coords)
# define the animation update function
def update(i):
title_text.set_text('Time = {:1.1f} s'.format(ts[i]))
lines.set_data(coords[i, 0, :], coords[i, 1, :])
# close figure to prevent premature display
plt.close()
# create and return the animation
return FuncAnimation(fig, update, len(ts))
Now, keep an eye on \(P_4\) during the animation of the simulation.
HTML(animate_linkage(ts, xs, p_vals).to_jshtml(fps=fps))