8.3  Kinematics - Constraint Solving

Three bar mechanism

Three bar CAD_sketchs

Given an input angle \(\theta \in [0,360^\circ]\) measured from the vertical arund point \(C\), going counter-clockwise, we want to find the positions of points \(A\) and \(B\) and thus determine the state of the mechanism.

The kinematic analysis is easy enough for a textbook example where the mechanism is set up such that the positions are trivial. In general, a mechanism is coupled and one needs to involve unknown variables and solve a system of equations, one for every body to determine the kinematics. There is no guarantee that the solution is unique in general so conditions need to be applied on these variables, i.e., they need to be defined such that a certain kinematic solution is aquired.

Preliminary kinematic analysis

Before diving into the mathematics of kinematics, it is recommended to create the system in e.g., CAD or LEGOs, to get familiar with the kinematics and get an intuition for it.

Quick Fusion 360 sketch over the situation

Definition of a valid topology. For each tiny change in \(\theta\) we get a valid topology

Definition of an invalid topology. Note that the angle is the same, but we get a “wrong” solution

In this configuration the CB arm can no longer be rotated clockwise

In this configuration the CB arm can no longer be rotated counter-clockwise

We have discovered the limits of the angle \(\theta \in [\theta_{min},\theta_{max}]\) for which the mechanism is valid. Outside these limits, the mechanism locks up and can no longer be rotated.

Kinematic analysis

Sketch of three bar linkage

Note that the unknown position can be solved for by defining the point \(B\) from both the point \(O\) and well as from the point \(C\) using vector addition, we “walk” from \(O\) to \(B\) via \(A\).

The direction towards \(A\) is given by defining \(\boldsymbol r_{OA}\) using a direction given by an angle \(\varphi\) defined as the angle between the \(y-\)axis and the line \(OA\).

We get the \(\boldsymbol r_{AB}\) direction, similarly, by defining the angle, \(\gamma\) between the \(y-\)axis and the line \(AB\) \(OB\).

Additionally we can “walk” to the point \(B\) from \(C\). This way we have two ways of getting to OB, thus a system of equations is established from which we can solve for the two unknowns, \(\varphi\) and \(\gamma\).

Equations of constraint

We have the following kinematics

\[ \boldsymbol{r}_{O B}=\boldsymbol{r}_{OC}+\boldsymbol{r}_{C B}=\boldsymbol{r}_{O A}+\boldsymbol{r}_{A B} \]

or

\[ \boxed{\boldsymbol{r}_{OC}+\boldsymbol{r}_{C B}(\theta)=\boldsymbol{r}_{O A}(\varphi)+\boldsymbol{r}_{A B}(\gamma)} \]

For a valid \(\theta\) we get two equations, where we can solve for the two unknown variables \(\varphi(\theta)\) and \(\gamma(\theta)\).

\[ \boldsymbol{r}_{OA}(\varphi)=r_{OA}\begin{bmatrix}\sin\varphi\\ \cos\varphi \end{bmatrix},\ \boldsymbol{r}_{AB}(\gamma)=r_{AB}\begin{bmatrix}\sin\gamma\\ \cos\gamma \end{bmatrix},\ \boldsymbol{r}_{CB}(\theta)=r_{CB}\begin{bmatrix}-\sin\varphi\\ \cos\varphi \end{bmatrix} \]

Numerical solution

import numpy as np
import matplotlib.pyplot as plt
import scipy

r_OA = 100.0  # Length of link OA
r_CB = 75.0   # Length of link CB
rr_OC = np.array([250, 50])  
r_AB = np.sqrt((250 - 75)**2 + (100 - 50)**2)

theta = np.deg2rad(30)  # Angle of link OC in radians

def equations(vars, theta):
    """
    Args:
        vars: A list or tuple [phi, gamma] containing the angles
              of links OA and AB in radians.
    Returns:
        The distance between points B from A and B from C, this distance is zero for the correct set of phi and gamma.
    """
    phi, gamma = vars

    rr_OA = r_OA * np.array([np.sin(phi), np.cos(phi)]) 
    rr_AB = r_AB * np.array([np.sin(gamma), np.cos(gamma)])
    # Position of B calculated from chain O->A->B
    rr_OAB = rr_OA + rr_AB

    # Position of B calculated from chain C->B
    # [-sind(θ), cosd(θ)] which corresponds to an angle measured from the +y axis
    rr_CB = r_CB * np.array([-np.sin(theta), np.cos(theta)])
    rr_OCB = rr_OC + rr_CB

    return rr_OAB - rr_OCB

def plot_mechanism(rr_OA, rr_OB, rr_OC):
    # Plot the links of the mechanism
    ax.plot(*zip([0,0], rr_OA), '-ko',lw=2)
    ax.plot(*zip(rr_OC, rr_OB), '-ko',lw=2)
    ax.plot(*zip(rr_OA, rr_OB), '-ko',lw=2)

    ax.text(rr_OA[0] + 5, rr_OA[1], 'A', fontsize=12)
    ax.text(rr_OB[0] + 5, rr_OB[1], 'B', fontsize=12)
    ax.set_xlabel('x')
    ax.set_ylabel('y')
    ax.set_title('Three-Bar Linkage')
    ax.grid(True)
    ax.set_aspect('equal', adjustable='box') # Ensures correct proportions
    ax.set_xlim(-50, 300)
    ax.set_ylim(-50, 150)

initial_guess = np.deg2rad([50, 70]) 
solution_rad = scipy.optimize.fsolve(equations, initial_guess, args=(theta,))
phi_sol, gamma_sol = solution_rad

rr_OA = r_OA * np.array([np.sin(phi_sol), np.cos(phi_sol)])

rr_OB = rr_OA + r_AB * np.array([np.sin(gamma_sol), np.cos(gamma_sol)])

fig, ax = plt.subplots()
plot_mechanism(rr_OA, rr_OB, rr_OC)

Code
import numpy as np
import matplotlib.pyplot as plt
import scipy
from matplotlib.animation import FuncAnimation
from IPython.display import HTML
import matplotlib
matplotlib.rcParams['animation.embed_limit'] = 60  # Increase if needed
from matplotlib.colors import to_rgba

r_OA = 100.0  # Length of link OA
r_CB = 75.0   # Length of link CB
rr_OC = np.array([250, 50])  
r_AB = np.sqrt((250 - 75)**2 + (100 - 50)**2)

def equations(vars, theta):
    """
    Args:
        vars: A list or tuple [phi, gamma] containing the angles
              of links OA and AB in radians.
    Returns:
        The distance between points B from A and B from C, this distance is zero for the correct set of phi and gamma.
    """
    phi, gamma = vars

    rr_OA = r_OA * np.array([np.sin(phi), np.cos(phi)]) 
    rr_AB = r_AB * np.array([np.sin(gamma), np.cos(gamma)])
    # Position of B calculated from chain O->A->B
    rr_OAB = rr_OA + rr_AB

    # Position of B calculated from chain C->B
    # [-sind(θ), cosd(θ)] which corresponds to an angle measured from the +y axis
    rr_CB = r_CB * np.array([-np.sin(theta), np.cos(theta)])
    rr_OCB = rr_OC + rr_CB

    return rr_OAB - rr_OCB

def is_valid_solution(theta, initial_guess, tolerance=1e-3):
    """Check if theta has a valid kinematic solution"""
    try:
        solution, info, ier, msg = scipy.optimize.fsolve(
            equations, initial_guess, args=(theta,), full_output=True
        )
        # Check if solver converged and residual is small
        residual = np.linalg.norm(info['fvec'])
        return ier == 1 and residual < tolerance, solution
    except:
        return False, None

# Find valid theta range by scanning
print("Finding valid theta range...")
theta_test = np.linspace(np.deg2rad(-30), np.deg2rad(300), 30+300)
thetas = []
initial_guess = np.deg2rad([50, 70])

for theta in theta_test:
    is_valid, sol = is_valid_solution(theta, initial_guess)
    if is_valid:
        thetas.append(theta)
        initial_guess = sol  # Use previous solution as next guess
        # print(f"Valid theta found: {np.rad2deg(theta):.1f}°")

if len(thetas) > 0:
    theta_min = thetas[0]
    theta_max = thetas[-1]
    print(f"Valid theta range: [{np.rad2deg(theta_min):.1f}°, {np.rad2deg(theta_max):.1f}°]")
    print(f"Number of valid configurations: {len(thetas)}")
else:
    print("No valid solutions found!")
    theta_min, theta_max = 0, 0

def plot_mechanism(rr_OA, rr_OB, rr_OC, theta):
    # Plot the links of the mechanism
    ax.plot(*zip([0,0], rr_OA), '-ko',lw=2)
    ax.plot(*zip(rr_OC, rr_OB), '-ko',lw=2)
    ax.plot(*zip(rr_OA, rr_OB), '-ko',lw=2)

    ax.text(rr_OA[0] + 5, rr_OA[1], 'A', fontsize=12)
    ax.text(rr_OB[0] + 5, rr_OB[1], 'B', fontsize=12)
    ax.text(rr_OC[0] + 5, rr_OC[1], 'C', fontsize=12)
    ax.text(5, 5, 'O', fontsize=12)
    ax.set_xlabel('x')
    ax.set_ylabel('y')
    ax.set_title(f'Three-Bar Linkage (θ = {np.rad2deg(theta):.1f}°)')
    ax.grid(True)
    ax.set_aspect('equal', adjustable='box') # Ensures correct proportions
    ax.set_xlim(-50, 300)
    ax.set_ylim(-50, 150)

# Only animate valid theta values
# thetas = np.linspace(theta_min, theta_max, 60)

fig, ax = plt.subplots(figsize=(10, 8))

def animate(frame_idx):
    """Animation function for each frame"""
    ax.clear()
    
    theta = thetas[frame_idx]
    initial_guess = np.deg2rad([50, 70]) 
    solution_rad = scipy.optimize.fsolve(equations, initial_guess, args=(theta,))
    phi_sol, gamma_sol = solution_rad

    rr_OA = r_OA * np.array([np.sin(phi_sol), np.cos(phi_sol)])
    rr_OB = rr_OA + r_AB * np.array([np.sin(gamma_sol), np.cos(gamma_sol)])

    plot_mechanism(rr_OA, rr_OB, rr_OC, theta)

# Create figure and axis (close to prevent static display)
plt.close(fig)  # Prevent static image from showing

# Create animation
anim = FuncAnimation(fig, animate, frames=len(thetas), 
                     interval=100, repeat=True)

# Get the HTML and wrap it with responsive CSS
anim_html = anim.to_jshtml()

# Wrap with CSS to make it responsive and fit to 100% width
responsive_html = f"""
<div style="width: 100%; max-width: 100%; overflow: hidden;">
    <style>
        .animation-container {{
            width: 100% !important;
            max-width: 100% !important;
        }}
        .animation-container img {{
            width: 100% !important;
            height: auto !important;
        }}
    </style>
    <div class="animation-container">
        {anim_html}
    </div>
</div>
"""
HTML(responsive_html)

# video_html = anim.to_html5_video()
# video_html = video_html.replace('controls', 'controls autoplay loop muted')
# video_html = video_html.replace(
#     '<video ', 
#     '<video style="width: 100%; height: auto;" '
# )
# HTML(video_html)
Finding valid theta range...
Valid theta range: [-1.9°, 204.7°]
Number of valid configurations: 207