A double pendulum: two beams on two pins#

Author Jørgen S. Dokken

License MIT

In this demo we will explore coupling two separately meshes elastic beams that are coupled to each other through a stiff spring, forming a double pendulum. Beam 1 turns about a fixed pin through a bore at its top. A second pin, through a bore lower down in beam 1, carries beam 2, which hangs beside beam 1 and turns about that pin. All the pins are created by using RBE2 spiders, as in Joining two bodies with a rigid spider (RBE2), and the two spiders of the lower pin are coupled by a stiff spring. We start by importing the required dependencies:

Hide code cell source

from pathlib import Path

from mpi4py import MPI
from petsc4py import PETSc

import basix.ufl
import dolfinx.fem.petsc
import gmsh
import numpy as np
import pyvista
import ufl
from dolfinx import default_real_type, default_scalar_type, fem, plot
from dolfinx.io import VTXWriter
from dolfinx.io import gmsh as gmshio

import dolfinx_mpc

The beams#

Two beams hang from two pins that run along \(y\); gravity points along \(-z\). Each beam is a box of depth \(H\) along \(x\), centred on \(x = 0\), with a bore of radius \(r\) around each pin it hangs on or carries:

  • Beam 1, of width \(W_0\) along the pins, \(y \in [0, W_0]\), hangs from the fixed pin at height \(z = 0\) and reaches down to \(z = -L\). A second bore, for the lower pin, is at \(z = -0.8 L\).

  • Beam 2, of width \(W_1\), hangs beside beam 1, a gap \(s\) away along the pins, \(y \in [W_0 + s, W_0 + s + W_1]\), from a bore on the lower pin, and reaches a length \(L_2\) below it.

Above its top bore, each beam has a margin \(e = 2.5 r\) of material. On the axis of each pin, at the middle of each beam’s bore, the points \(x_P\), \(x_A\) and \(x_B\) are the spiders of the next section.

comm = MPI.COMM_WORLD
L, W_0, H = 2.0, 0.4, 0.3  # Beam 1: length below its top bore, width along the pins, depth
L_2, W_1 = 1.5, 0.25  # Beam 2: length below its bore, width along the pins
radius, gap = 0.08, 0.05  # The bores' radius, and the gap between the beams along the pins
margin = 2.5 * radius  # The material above a beam's top bore
z_top, z_pin = 0.0, -0.8 * L  # The heights of the fixed pin and of the lower pin

# Each beam's extent along the pins (y) and along gravity (z), and the heights of its bores
beam_1_y, beam_1_z, beam_1_bores = (0.0, W_0), (-L, z_top + margin), [z_top, z_pin]
beam_2_y, beam_2_z, beam_2_bores = (W_0 + gap, W_0 + gap + W_1), (z_pin - L_2, z_pin + margin), [z_pin]

# The spiders, on the pins' axes at x = 0, at the middle of each bore along the pin
x_P = np.array([0.0, 0.5 * sum(beam_1_y), z_top], dtype=default_real_type)
x_A = np.array([0.0, 0.5 * sum(beam_1_y), z_pin], dtype=default_real_type)
x_B = np.array([0.0, 0.5 * sum(beam_2_y), z_pin], dtype=default_real_type)

The spiders#

The pins are rigid, and they are not part of the simulation mesh: neither the pins nor anything inside the bores is meshed. Instead, the surface of each bore is tied rigidly to a spider (RBE2, as in Joining two bodies with a rigid spider (RBE2)), so that the bore keeps its shape and moves as one rigid body with its spider. The spiders are the points \(x_P\), \(x_A\) and \(x_B\) defined above, on the pins’ axes at the middle of each bore: \(x_P\) in beam 1’s upper bore, and \(x_A\) and \(x_B\) in the lower bores of beam 1 and beam 2, so that \(d = x_B - x_A\) runs along the lower pin. Each spider has six unknowns, its translation \(t\) and its rotation \(\theta\).

  • The fixed pin lets beam 1 turn about it, but not move: the translation of spider P is pinned, \(t_P = 0\), by Dirichlet conditions on its translation dofs. Its rotations about \(x\) and \(z\) are pinned as well, as a pin through a bore holds them too, so only the rotation about the pin, \(\theta_P \cdot a\) with \(a = (0, 1, 0)\), is free.

  • The lower pin lets beam 2 turn about it, relative to beam 1, and holds every other relative motion. Beam 1 and beam 2 each have their own spider on it, A and B, joined by a 0-D spring that is stiff in every direction except rotation about the pin.

Each spider is the only point of its own spider mesh, made by create_spider_mesh, and create_spider_pair relates spider A to spider B, for the spring.

spiders_P, spiders_A, spiders_B = (dolfinx_mpc.create_spider_mesh(comm, x.reshape(1, 3)) for x in (x_P, x_A, x_B))
pair = dolfinx_mpc.create_spider_pair(spiders_A, spiders_B)

Next, we create the function spaces on the spider meshes, which should have six components, three for translation and three for rotation.

element = basix.ufl.element("DG", "point", 0, shape=(6,), dtype=default_real_type)
W_P, W_A, W_B = (fem.functionspace(spiders, element) for spiders in (spiders_P, spiders_A, spiders_B))

Meshing the two beams with bores#

Each beam is meshed separately from its extent and the heights of its bores, above, with second order cells: beam 1 with tetrahedra, beam 2 with hexahedra, by extruding a quadrilateral mesh of its side along the pin. The facets of bore \(k\) are tagged \(k + 1\).

Hide code cell source

def beam(z0: float, z1: float, y0: float, y1: float, bores: list[float], size: float, name: str, hexahedra=False):
    """The beam [-H/2, H/2] x [y0, y1] x [z0, z1] with a bore along y through (x=0, b) for each b in
    `bores`, meshed with second order cells, and its facet tags."""
    if not gmsh.isInitialized():
        gmsh.initialize()
    gmsh.option.setNumber("General.Terminal", 0)
    gmsh.model.add(name)
    if comm.rank == 0:
        if hexahedra:
            # The side in the xy-plane, turned into the xz-plane at y = y0: (x, y, 0) -> (x, y0, y)
            side = gmsh.model.occ.addRectangle(-H / 2, z0, 0, H, z1 - z0)
            holes = [(2, gmsh.model.occ.addDisk(0, b, 0, radius, radius)) for b in bores]
            section, _ = gmsh.model.occ.cut([(2, side)], holes)
            gmsh.model.occ.rotate(section, 0, 0, 0, 1, 0, 0, np.pi / 2)
            gmsh.model.occ.translate(section, 0, y0, 0)
            layers = int(np.ceil((y1 - y0) / size))
            extruded = gmsh.model.occ.extrude(section, 0, y1 - y0, 0, numElements=[layers], recombine=True)
            block = [entity for entity in extruded if entity[0] == 3]
            gmsh.model.occ.synchronize()
            gmsh.option.setNumber("Mesh.RecombineAll", 1)
        else:
            box = gmsh.model.occ.addBox(-H / 2, y0, z0, H, y1 - y0, z1 - z0)
            cylinders = [(3, gmsh.model.occ.addCylinder(0, y0, b, 0, y1 - y0, 0, radius)) for b in bores]
            block, _ = gmsh.model.occ.cut([(3, box)], cylinders)
            gmsh.model.occ.synchronize()
        gmsh.model.addPhysicalGroup(3, [block[0][1]], tag=1)
        # A bore's surfaces lie within its circle in x and z
        surfaces: dict[int, list[int]] = {k + 1: [] for k in range(len(bores))}
        for _, surface in gmsh.model.getBoundary(block, oriented=False):
            xmin, _, zmin, xmax, _, zmax = gmsh.model.getBoundingBox(2, surface)
            for k, b in enumerate(bores):
                within = zmin > b - 1.1 * radius and zmax < b + 1.1 * radius
                if within and xmin > -1.1 * radius and xmax < 1.1 * radius:
                    surfaces[k + 1].append(surface)
        for tag, entities in surfaces.items():
            gmsh.model.addPhysicalGroup(2, entities, tag=tag)
        gmsh.option.setNumber("Mesh.CharacteristicLengthMax", size)
        gmsh.option.setNumber("Mesh.MeshSizeFromCurvature", 12)
        gmsh.model.mesh.generate(3)
        gmsh.model.mesh.setOrder(2)
        gmsh.option.setNumber("Mesh.RecombineAll", 0)
    data = gmshio.model_to_mesh(gmsh.model, comm, 0, gdim=3, dtype=default_real_type)
    gmsh.model.remove()
    return data.mesh, data.facet_tags
beam_1, tags_1 = beam(*beam_1_z, *beam_1_y, beam_1_bores, 0.08, "beam_1")
beam_2, tags_2 = beam(*beam_2_z, *beam_2_y, beam_2_bores, 0.05, "beam_2", hexahedra=True)

The meshes of the beams, and the spider nodes as spheres, taken from the spider meshes. Each process makes a grid of the cells it owns, from the geometry of the mesh alone, and the pieces are gathered on one process. The cells are drawn curved, as the mesh’s second order cells, with the edges of the first order cells through their vertices. In the collapsed section below you will find some utlities for plotting the meshes with pyvista.

Hide code cell source

pyvista.global_theme.allow_empty_mesh = True


def pyvista_ugrid(input: dolfinx.mesh.Mesh | dolfinx.fem.FunctionSpace) -> pyvista.UnstructuredGrid:
    """The cells of `mesh` that this process owns, on its geometry nodes."""
    if isinstance(input, dolfinx.mesh.Mesh):
        imap = input.topology.index_map(3)
    elif isinstance(input, dolfinx.fem.FunctionSpace):
        imap = input.mesh.topology.index_map(3)
    else:
        raise TypeError(f"Expected a Mesh or FunctionSpace, got {type(input)}")
    owned = np.arange(imap.size_local, dtype=np.int32)
    if isinstance(input, dolfinx.mesh.Mesh):
        return pyvista.UnstructuredGrid(*plot.vtk_mesh(input, 3, owned))
    else:
        return pyvista.UnstructuredGrid(*plot.vtk_mesh(input, owned))


# The first order cell with the vertices of each Lagrange cell
first_order = {
    pyvista.CellType.LAGRANGE_HEXAHEDRON: (pyvista.CellType.HEXAHEDRON, 8),
    pyvista.CellType.LAGRANGE_TETRAHEDRON: (pyvista.CellType.TETRA, 4),
}


def cell_edges(grid: pyvista.UnstructuredGrid) -> pyvista.UnstructuredGrid:
    """The grid of the first order cells through the vertices of the Lagrange cells of `grid`, on its
    points, for drawing the cells' edges: pyvista draws the edges of a subdivision of a Lagrange cell.
    A VTK Lagrange cell lists its vertices first."""
    if grid.n_cells == 0:
        return grid
    cell_type, num_nodes = first_order[pyvista.CellType(grid.celltypes[0])]
    nodes = grid.cells.reshape(grid.n_cells, -1)[:, 1 : 1 + num_nodes]
    cells = np.hstack([np.full((grid.n_cells, 1), num_nodes), nodes]).ravel()
    return pyvista.UnstructuredGrid(cells, np.full(grid.n_cells, cell_type, dtype=np.uint8), grid.points)


def add_cells(plotter: pyvista.Plotter, grid: pyvista.UnstructuredGrid, **kwargs):
    """Draw the Lagrange cells of `grid`, and their edges."""
    plotter.add_mesh(grid, **kwargs)
    plotter.add_mesh(cell_edges(grid), style="wireframe", color="black", line_width=0.5)


beam_pieces = comm.gather([pyvista_ugrid(mesh) for mesh in (beam_1, beam_2)], root=0)
spider_nodes = comm.gather(
    np.vstack(
        [
            spiders.geometry.x[: spiders.topology.index_map(0).size_local]
            for spiders in (spiders_P, spiders_A, spiders_B)
        ]
    ),
    root=0,
)
if comm.rank == 0:
    plotter = pyvista.Plotter(window_size=(700, 900))
    # Merging the points shared by the processes leaves no face between their pieces
    for b, colour in enumerate(("lightsteelblue", "wheat")):
        add_cells(
            plotter, pyvista.merge([piece[b] for piece in beam_pieces]).clean(tolerance=1e-6), color=colour, opacity=0.6
        )
    for node in np.vstack(spider_nodes):
        plotter.add_mesh(pyvista.Sphere(radius=0.05, center=node), color="red")
    plotter.camera_position = [(4.5, -6.0, -0.4), (0.0, 0.45, -1.45), (0.0, 0.0, 1.0)]
    if pyvista.OFF_SCREEN:
        plotter.screenshot("demo_spider_hinge_setup.png")
    else:
        plotter.show()

The constraints#

Both beams are discretized in the same way: the displacement of each is in a space of second order vector Lagrange elements on its second order mesh, of tetrahedra and of hexahedra respectively. The upper bore of beam 1 follows spider P, its lower bore spider A, and the bore of beam 2 spider B. Spiders A and B have no constraint of their own, and we therefore create empty MultiPointConstraints for them.

The pinned dofs of spider P, its translation and its rotations about \(x\) and \(z\), are masters of the upper bore. Their Dirichlet conditions are therefore given to spider P’s constraint, which removes them from the bore’s relation, as well as to the problems below, which set them.

V_1 = fem.functionspace(beam_1, ("Lagrange", 2, (3,)))
V_2 = fem.functionspace(beam_2, ("Lagrange", 2, (3,)))

mpc_1 = dolfinx_mpc.MultiPointConstraint(V_1, dtype=default_scalar_type)
mpc_1.add_rbe2_topological(2, tags_1.find(1), W_P)
mpc_1.add_rbe2_topological(2, tags_1.find(2), W_A)

mpc_2 = dolfinx_mpc.MultiPointConstraint(V_2, dtype=default_scalar_type)
mpc_2.add_rbe2_topological(2, tags_2.find(1), W_B)
# The translation (0, 1, 2) and the rotations about x and z (3, 5) of spider P
cells_P = np.arange(spiders_P.topology.index_map(0).size_local, dtype=np.int32)
zero = default_scalar_type(0.0)
pinned = [
    fem.dirichletbc(zero, fem.locate_dofs_topological(W_P.sub(i), 0, cells_P), W_P.sub(i)) for i in (0, 1, 2, 3, 5)
]
mpc_P = dolfinx_mpc.MultiPointConstraint(W_P, dtype=default_scalar_type, bcs=pinned)
mpcs = [mpc_1, mpc_2, mpc_P] + [dolfinx_mpc.MultiPointConstraint(V, dtype=default_scalar_type) for V in (W_A, W_B)]

dolfinx_mpc.finalize_multipointconstraints(mpcs)

The spring coupling#

With \(w = (t, \theta)\) the translation and rotation of a spider, the two spiders of the lower pin, at \(x_A\) in beam 1 and \(x_B\) in beam 2, are held together if spider B moves as a rigid extension of spider A, \(t_B = t_A + \theta_A \times d\) with \(d = x_B - x_A\), and \(\theta_B = \theta_A\). The spring stores the energy \(\frac12 \delta \cdot K \delta\), with

\[\begin{split} \begin{aligned} \delta &= \begin{pmatrix} t_B - t_A - \theta_A \times d \\ \theta_B - \theta_A \end{pmatrix},\\ K &= \begin{pmatrix} k I & 0 \\ 0 & k (I - a a^T) + k_t a a^T \end{pmatrix}, \end{aligned} \end{split}\]

with \(a\) the direction of the pin, \(k\) large, and \(k_t\) the stiffness against turning about the pin, zero for a free pin. The fixed pin is held by its Dirichlet conditions, so its spring only acts against turning, \(K_P = k_t\, a a^T\) on the rotation of spider P. The springs involve only the spiders. With \(w = (w_P, w_A, w_B)\) the unknowns of the three spiders and \(z = (z_P, z_A, z_B)\) their test functions, the springs of both pins form the bilinear form

\[ s(w, z) = \int K \delta(w_A, w_B) \cdot \delta(z_A, z_B)~\mathrm{d}x + \int K_P w_P \cdot z_P~\mathrm{d}x, \]

the first integrated over spider mesh A, reaching spider B through the pair, the second over spider mesh P. The function springs(K_pins, w, z) builds it for given stiffnesses. Both springs are locked, \(k_t = k\), for the static deflection, and free, \(k_t = 0\), for the swing, the two stages of the solution below.

axis = np.array([0.0, 1.0, 0.0])
d = fem.Constant(spiders_A, (x_B - x_A).astype(default_scalar_type))


def spring_gap(w_A, w_B):
    """The motion of spider B away from the rigid extension of spider A."""
    t_A, theta_A = ufl.as_vector([w_A[i] for i in range(3)]), ufl.as_vector([w_A[i] for i in range(3, 6)])
    t_B, theta_B = ufl.as_vector([w_B[i] for i in range(3)]), ufl.as_vector([w_B[i] for i in range(3, 6)])
    translation = t_B - t_A - ufl.cross(theta_A, d)
    rotation = theta_B - theta_A
    return ufl.as_vector([translation[i] for i in range(3)] + [rotation[i] for i in range(3)])


def spring_stiffness(k_t: float) -> np.ndarray:
    """The 6x6 stiffness of the spring, with `k_t` against turning about the pin."""
    K = np.zeros((6, 6))
    K[:3, :3] = k * np.eye(3)
    K[3:, 3:] = k * (np.eye(3) - np.outer(axis, axis)) + k_t * np.outer(axis, axis)
    return K.astype(default_scalar_type)


def torsion(k_t: float) -> np.ndarray:
    """The 6x6 stiffness of the fixed pin against turning about it, its other dofs being pinned."""
    K_P = np.zeros((6, 6))
    K_P[3:, 3:] = k_t * np.outer(axis, axis)
    return K_P.astype(default_scalar_type)


def springs(K_pins, w, z):
    """The springs of both pins, s(w, z), with the stiffnesses `K_pins`, for the spider parts
    `w = (w_P, w_A, w_B)` and `z` of the unknowns and the test functions."""
    (w_P, w_A, w_B), (z_P, z_A, z_B) = w, z
    lower = ufl.inner(K_pins[0] * spring_gap(w_A, w_B), spring_gap(z_A, z_B)) * ufl.dx(spiders_A)
    return lower + ufl.inner(K_pins[1] * w_P, z_P) * ufl.dx(spiders_P)

The variational problem#

The motion of the system follows from its energies. With the unknowns \(u = (u_1, u_2, w)\) of the whole system, the displacements \(u_b\) of the beams \(\Omega_b\), \(b = 1, 2\), and the spiders’ \(w = (w_P, w_A, w_B)\), the kinetic energy is \(\frac12 M(\dot u, \dot u)\) and the potential energy is

\[\begin{split} \begin{aligned} \Pi(u) &= \sum_b \int_{\Omega_b} W_b\left(\mathcal{E}(u_b)\right)~\mathrm{d}x - F(u) + \frac12 s(w, w),\\ W_b(\mathcal{E}) &= \mu_b\, \mathcal{E} : \mathcal{E} + \frac{\lambda_b}{2} \left(\operatorname{tr} \mathcal{E}\right)^2,\\ \mathcal{E}(u) &= \varepsilon(u) + \frac12 \nabla u^T \nabla u, \end{aligned} \end{split}\]

with \(W_b\) the strain energy density of beam \(b\), of Lamé parameters \(\mu_b\) and \(\lambda_b\) from its Young’s modulus \(E_b\) and Poisson’s ratio \(\nu_b\), \(\mathcal{E}\) the Green–Lagrange strain, \(F\) the work of gravity \(g\), and \(s\) the springs of The spring coupling, above. Here

\[\begin{split} \begin{aligned} M(u, v) &= \sum_b \int_{\Omega_b} \rho_b u_b \cdot v_b~\mathrm{d}x,\\ F(v) &= \sum_b \int_{\Omega_b} \rho_b g \cdot v_b~\mathrm{d}x, \end{aligned} \end{split}\]

with \(\rho_b\) the density of beam \(b\). By Hamilton’s principle, the motion makes \(\int \left(\frac12 M(\dot u, \dot u) - \Pi(u)\right)~\mathrm{d}t\) stationary.

The meshes are the reference configuration: the beams as built, before gravity acts. We decompose the displacement from it into two parts, \(u_0 + u\):

  • \(u_0\), the static deflection under gravity, which brings the beams to the hanging state, at rest;

  • \(u\), the small motion about the hanging state: the swing.

Both follow from \(\Pi\). As \(u_0\) is small, both are computed on the reference configuration, and the animation below shows the swing \(u\).

\(M\), \(a\), \(a_{\sigma_0}\) and \(F\), below, involve only the beams, and \(s\) only the spiders.

The hanging state#

The deflection \(u_0 = (u_{0,1}, u_{0,2}, w_0)\) minimizes \(\Pi\). It is small, so \(\mathcal{E} \approx \varepsilon\), and the minimum satisfies

\[\begin{split} \begin{aligned} a(u_0, v) + s_\text{locked}(w_0, z) &= F(v),\\ a(u, v) &= \sum_b \int_{\Omega_b} \sigma_b(u_b) : \varepsilon(v_b)~\mathrm{d}x, \end{aligned} \end{split}\]

for all test functions \(v = (v_1, v_2, z)\), with the stress \(\sigma_b(u) = 2 \mu_b \varepsilon(u) + \lambda_b \operatorname{tr} \varepsilon(u) I\). Because the beams are symmetric about \(x = 0\), gravity has no moment about either pin, so the deflection is the same with the pins locked against turning, which makes the static problem well posed. It gives the prestress \(\sigma_{0,b} = \sigma_b(u_{0,b})\).

The forms of the hanging state, over the five spaces of the constraints, in the order of the blocks of the system: the materials, gravity, and the locked springs, with the stiffness \(k\) large next to the beams’. The static deflection is solved into functions u_0 in the constraints’ spaces.

materials = {"E": (2.0e4, 0.7e5), "nu": (0.3, 0.25), "rho": (1.0, 1.2)}
beams = (beam_1, beam_2)
g = 9.81
down = np.array([0.0, 0.0, -1.0])


def sigma(u, b: int):
    """The stress of displacement `u` in the material of beam `b`."""
    E, nu = materials["E"][b], materials["nu"][b]
    mu, lmbda = E / (2 * (1 + nu)), E * nu / ((1 + nu) * (1 - 2 * nu))
    return 2 * mu * ufl.sym(ufl.grad(u)) + lmbda * ufl.tr(ufl.sym(ufl.grad(u))) * ufl.Identity(3)


mixed = ufl.MixedFunctionSpace(*(mpc.input_space for mpc in mpcs))
trials, tests = ufl.TrialFunctions(mixed), ufl.TestFunctions(mixed)
u_1, u_2, w_P, w_A, w_B = trials
v_1, v_2, z_P, z_A, z_B = tests
dx_1, dx_2 = ufl.dx(beam_1), ufl.dx(beam_2)

a = ufl.inner(sigma(u_1, 0), ufl.sym(ufl.grad(v_1))) * dx_1
a += ufl.inner(sigma(u_2, 1), ufl.sym(ufl.grad(v_2))) * dx_2
# F by block: gravity has no spider blocks. One constant serves both beams.
gravity = fem.Constant(beam_1, (g * down).astype(default_scalar_type))
F = [
    ufl.inner(materials["rho"][0] * gravity, v_1) * dx_1,
    ufl.inner(materials["rho"][1] * gravity, v_2) * dx_2,
    ufl.ZeroBaseForm((z_P,)),
    ufl.ZeroBaseForm((z_A,)),
    ufl.ZeroBaseForm((z_B,)),
]

# The stiffness of the springs, large next to that of the beams. In single precision it is
# smaller, so that its rounding, k times the machine epsilon, stays small next to the weights

double_precision = np.finfo(default_real_type).bits == 64
k = (1e3 if double_precision else 1e1) * max(materials["E"])

# The lower pin's spring, on spider mesh A, and the fixed pin's, on spider mesh P, locked
K_locked = (fem.Constant(spiders_A, spring_stiffness(k)), fem.Constant(spiders_P, torsion(k)))
s_locked = springs(K_locked, trials[2:], tests[2:])


def in_constraint_spaces():
    """One function per block, in the space of its constraint."""
    return [fem.Function(mpc.function_space, dtype=default_scalar_type) for mpc in mpcs]


u_0 = in_constraint_spaces()

The static deflection, with the pins locked:

static = dolfinx_mpc.LinearProblem(
    ufl.extract_blocks(a + s_locked),
    F,
    mpcs,
    bcs=pinned,
    u=u_0,
    kind="mpi",
    entity_maps=[pair],
    petsc_options_prefix="demo_spider_hinge_static_",
    petsc_options={
        "ksp_type": "preonly",
        "pc_type": "lu",
        "pc_factor_mat_solver_type": "mumps",
        "ksp_error_if_not_converged": True,
    },
)
u_1_0, u_2_0, w_P_0, w_A_0, w_B_0 = static.solve()

The code for the verification described in the dropdown above can be inspected by expanding the cell below.

Hide code cell content

def integrate(form) -> float:
    """The value of a functional, summed over the processes."""
    return comm.allreduce(fem.assemble_scalar(fem.form(form, dtype=default_scalar_type)), op=MPI.SUM).real


mass_2 = integrate(materials["rho"][1] * ufl.dx(beam_2))
values_A, values_B = dolfinx_mpc.spider_values(w_A_0, 0).real, dolfinx_mpc.spider_values(w_B_0, 0).real
delta = np.concatenate([values_B[:3] - values_A[:3] - np.cross(values_A[3:], x_B - x_A), values_B[3:] - values_A[3:]])
spring_force = (spring_stiffness(k).real @ delta)[:3]
spring_moment = np.dot((spring_stiffness(k).real @ delta)[3:], axis)
weight_2 = mass_2 * g * down
lever_2 = mass_2 * g * L_2
# In single precision, the solve of the stiff system is accurate to a few percent only, by an
# amount that varies with the platform and the number of processes
tol = max(1e-3, 5e5 * np.finfo(default_real_type).eps)
if comm.rank == 0:
    print(f"Spring force {spring_force.round(5)} next to the weight of beam 2 {weight_2.round(5)}")
    print(f"Spring moment about the pin {spring_moment:.2e} next to m_2 g L_2 = {lever_2:.2e}")
assert np.allclose(spring_force, weight_2, atol=tol * np.abs(weight_2).max())
assert abs(spring_moment) < tol * lever_2
Spring force [ 0.      -0.      -1.44177] next to the weight of beam 2 [ 0.       0.      -1.44177]
Spring moment about the pin -2.68e-12 next to m_2 g L_2 = 2.16e+00

Visualization of the pins#

Both pins are meshed, as two cylinders of the bores’ radius in one mesh, with the cells of each tagged by its pin, only for viewing. Each moves as one rigid body with its spider, \(t + \theta \times (x - x_c)\): the fixed pin with spider P, the lower pin with spider A.

Hide code cell source

# Each pin, (spider, y0, y1), from y0 to y1 along y, a little beyond the beams it carries
pins = ((x_P, beam_1_y[0] - 0.05, beam_1_y[1] + 0.05), (x_A, beam_1_y[0] - 0.05, beam_2_y[1] + 0.05))


def pin_mesh(size: float):
    """The pins as cylinders along y, `(centre, y0, y1)` each, of second order, the cells of pin k
    tagged k + 1."""
    gmsh.model.add("pins")
    if comm.rank == 0:
        for tag, (x_c, y0, y1) in enumerate(pins, start=1):
            cylinder = gmsh.model.occ.addCylinder(x_c[0], y0, x_c[2], 0, y1 - y0, 0, radius)
            gmsh.model.occ.synchronize()
            gmsh.model.addPhysicalGroup(3, [cylinder], tag=tag)
        gmsh.option.setNumber("Mesh.CharacteristicLengthMax", size)
        gmsh.model.mesh.generate(3)
        gmsh.model.mesh.setOrder(2)
    data = gmshio.model_to_mesh(gmsh.model, comm, 0, gdim=3, dtype=default_real_type)
    gmsh.model.remove()
    return data.mesh, data.cell_tags


pin_domain, pin_tags = pin_mesh(0.05)
V_pins = fem.functionspace(pin_domain, ("Lagrange", 2, (3,)))
u_pins = fem.Function(V_pins, dtype=default_scalar_type, name="u")


def move_pins(u):
    """Move each pin rigidly with its spider in `u`. Collective."""
    for tag, (k, (x_c, _, _)) in enumerate(zip((2, 3), pins), start=1):
        values = dolfinx_mpc.spider_values(u[k], 0)
        t, theta = values[:3], values[3:]
        u_pins.interpolate(
            lambda x: (t[:, None] + np.cross(theta, (x - x_c[:, None]).T).T).astype(default_scalar_type),
            cells0=pin_tags.find(tag),
        )
    u_pins.x.scatter_forward()

Visualization of the hanging state#

The beams are coloured by their von Mises stress, \(\sigma_m = \sqrt{\frac32 s : s}\), with \(s = \sigma - \frac13 \operatorname{tr}(\sigma) I\) the deviatoric stress. As the displacement is of second order on second order cells, the stress varies within a cell; it is shown by its value at each cell’s midpoint, interpolated into a space of cell-wise constants (DG-0), and drawn on the displaced grid.

Hide code cell source

def von_mises(u, b: int):
    """The von Mises stress of the displacement `u` of beam `b`."""
    s = sigma(u, b) - ufl.tr(sigma(u, b)) / 3 * ufl.Identity(3)
    return ufl.sqrt(3 / 2 * ufl.inner(s, s))


Q = [fem.functionspace(m, ("DG", 0)) for m in beams]


def von_mises_functions(us):
    """The von Mises stress of the displacements `us` of the beams: the DG-0 functions that hold
    it, and the expressions that fill them."""
    functions = [fem.Function(Q_b, dtype=default_scalar_type) for Q_b in Q]
    expressions = [
        fem.Expression(von_mises(u, b), Q[b].element.interpolation_points, dtype=default_scalar_type)
        for b, u in enumerate(us)
    ]
    return functions, expressions


def owned_cell_values(f: fem.Function) -> np.ndarray:
    """The values of a DG-0 function in the cells this process owns, in the cells' order, which is
    that of the grid of :func:`pyvista_ugrid`."""
    num_owned = f.function_space.mesh.topology.index_map(3).size_local
    return f.x.array[f.function_space.dofmap.list[:num_owned, 0]].real.copy()

The deflection under gravity, magnified, coloured by the von Mises stress of the hanging state, the prestress \(\sigma_0\), over the outline of the undeformed beams in grey, with the pins in translucent red, so that the bores around them show. The pins are the meshed cylinders above, moved with their spiders.

Hide code cell source

stress_0, stress_0_expressions = von_mises_functions(u_0[:2])
for f, expression in zip(stress_0, stress_0_expressions):
    f.interpolate(expression)
move_pins(u_0)
grids = []
for V, u in ((V_1, u_1_0), (V_2, u_2_0), (V_pins, u_pins)):
    grid = pyvista_ugrid(V)
    grid["u"] = u.x.array.reshape(-1, 3)[: grid.n_points].real
    grids.append(grid)
for grid, f in zip(grids, stress_0):
    grid.cell_data["von Mises"] = owned_cell_values(f)
gathered = comm.gather(grids, root=0)
if comm.rank == 0:
    plotter = pyvista.Plotter(window_size=(700, 900))
    # One grid per beam and one for the pins, from the pieces of all processes, so that no
    # partition boundary is drawn
    *merged, merged_pins = [
        pyvista.merge([piece[b] for piece in gathered if piece[b].n_points > 0]).clean(tolerance=1e-6) for b in range(3)
    ]
    factor = 0.1 / max(np.abs(grid["u"]).max() for grid in merged)
    clim = [0.0, max(grid.cell_data["von Mises"].max() for grid in merged)]
    for grid in merged:
        add_cells(plotter, grid.warp_by_vector("u", factor=factor), scalars="von Mises", clim=clim, cmap="viridis")
        # The outline of the undeformed beam: the edges of its box and the rims of its bores
        outline = (
            cell_edges(grid)
            .extract_surface()
            .extract_feature_edges(
                boundary_edges=False, manifold_edges=False, non_manifold_edges=False, feature_angle=30
            )
        )
        plotter.add_mesh(outline, color="gray", line_width=2)
    # Translucent, so that the bore around each pin shows
    plotter.add_mesh(merged_pins.warp_by_vector("u", factor=factor), color="red", opacity=0.5)
    plotter.camera_position = [(4.5, -6.0, -0.4), (0.0, 0.45, -1.45), (0.0, 0.0, 1.0)]
    if pyvista.OFF_SCREEN:
        plotter.screenshot("demo_spider_hinge.png")
    else:
        plotter.show()
/tmp/ipykernel_3550/300965783.py:27: PyVistaFutureWarning: The default value of `algorithm` for the filter
`UnstructuredGrid.extract_surface` will change in the future. It currently defaults to
`'dataset_surface'`, but will change to `None`. Explicitly set the `algorithm` keyword to
silence this warning.
  .extract_surface()

The swing#

The motion \(u\) about the hanging state, with the pins free, follows from the expansion of the potential energy about its minimum \(u_0\), to second order in \(u\):

\[ \Pi(u_0 + u) = \Pi(u_0) + D\Pi(u_0)[u] + \frac12 D^2\Pi(u_0)[u, u] + \mathcal{O}(|u|^3). \]
  • Zeroth order: \(\Pi(u_0)\) is a constant, and does not enter the equation of motion.

  • First order: \(D\Pi(u_0)[v] = 0\) for all \(v\), as \(u_0\) is the minimum: with \(\mathcal{E} \approx \varepsilon\), this is exactly the system of the hanging state solved above. (It was solved with the pins locked, but the pins carry no moment there, so the locked and the free springs agree at \(u_0\).) The work of gravity, \(F\), is linear in the displacement, so it lies entirely in this term, balanced by the internal forces of the hanging state: gravity does not appear in the swing.

  • Second order: the second variation of \(\Pi\) at \(u_0\). The strain energy contributes through the variation of the strain, \(\varepsilon(u)\) to leading order in \(u_0\), and through the second variation of the strain itself, \(\operatorname{sym}(\nabla u^T \nabla v)\) from its term \(\frac12 \nabla u^T \nabla u\), paired with the stress of the hanging state, the prestress \(\sigma_0\). With the free springs,

\[\begin{split} \begin{aligned} D^2\Pi(u_0)[u, v] &= \hat K(u, v) = a(u, v) + a_{\sigma_0}(u, v) + s_\text{free}(w, z),\\ a_{\sigma_0}(u, v) &= \sum_b \int_{\Omega_b} \nabla u_b \, \sigma_{0,b} : \nabla v_b~\mathrm{d}x. \end{aligned} \end{split}\]

So the hanging state enters the swing only through \(\sigma_0\), in \(a_{\sigma_0}\).

The geometric stiffness \(a_{\sigma_0}\) is the term \(\frac12 \nabla u^T \nabla u\) of the strain, paired with the prestress: it is what linear elasticity, with \(\mathcal{E} \approx \varepsilon\) throughout, leaves out. A rigid rotation has no linear strain, so linear elasticity alone has no restoring torque; but as a pendulum swings, its mass rises along an arc against the tension of the hanging beam, and that work is \(\frac12 a_{\sigma_0}(u, u)\), as checked in Check: the energy of a rigid swing, below.

The equation of the swing follows from Hamilton’s principle, now with the displacement \(u_0 + u\). As \(u_0\) does not depend on time, \(\dot u\) is the velocity of the whole motion, and the action

\[ \mathcal{S}(u) = \int_{t_0}^{t_1} \left(\frac12 M(\dot u, \dot u) - \Pi(u_0 + u)\right) ~\mathrm{d}t \]

is stationary: its variation vanishes in every direction \(v\) that vanishes at \(t_0\) and \(t_1\). Integrating the kinetic term by parts in time,

\[ D\mathcal{S}(u)[v] = \int_{t_0}^{t_1} \left(M(\dot u, \dot v) - D\Pi(u_0 + u)[v]\right) ~\mathrm{d}t = -\int_{t_0}^{t_1} \left(M(\ddot u, v) + D\Pi(u_0 + u)[v]\right)~\mathrm{d}t = 0, \]

and as \(v\) is arbitrary in time, \(M(\ddot u, v) + D\Pi(u_0 + u)[v] = 0\) for all \(v\), at all times. This is the full, nonlinear, equation of motion. Its force \(D\Pi\) expands about the hanging state as

\[ D\Pi(u_0 + u)[v] = D\Pi(u_0)[v] + D^2\Pi(u_0)[u, v] + \mathcal{O}(|u|^2), \]

where the first term vanishes, as \(u_0\) is the minimum, and the second is \(\hat K(u, v)\). To first order in \(u\), the equation of the swing is therefore

\[ M(\ddot u, v) + \hat K(u, v) = 0 \quad \text{for all } v. \]

Hamilton’s principle itself takes only first variations, of the action: the second variation of \(\Pi\) enters as the linearization of the force \(D\Pi\) about \(u_0\), that is, as the stiffness of the swing. Equivalently, the expansion of \(\Pi\) to second order, above, turns the action into that of the Lagrangian \(\frac12 M(\dot u, \dot u) - \frac12 \hat K(u, u)\), with the same equation. As a second derivative, \(\hat K\) is symmetric, and the Lagrangian does not depend on time, so the energy \(\frac12 M(\dot u, \dot u) + \frac12 \hat K(u, u)\) of the swing is conserved, as checked in the energy conservation, below.

The forms of the swing: the mass, the free springs, and the prestress of the static deflection u_0.

M = materials["rho"][0] * ufl.inner(u_1, v_1) * dx_1 + materials["rho"][1] * ufl.inner(u_2, v_2) * dx_2
K_free = (fem.Constant(spiders_A, spring_stiffness(0.0)), fem.Constant(spiders_P, torsion(0.0)))
s_free = springs(K_free, trials[2:], tests[2:])
sigma_0 = (sigma(u_0[0], 0), sigma(u_0[1], 1))
a_sigma_0 = ufl.inner(ufl.grad(u_1) * sigma_0[0], ufl.grad(v_1)) * dx_1
a_sigma_0 += ufl.inner(ufl.grad(u_2) * sigma_0[1], ufl.grad(v_2)) * dx_2
K_hat = a + a_sigma_0 + s_free

Time stepping#

The whole coupled system, beams and spiders alike, is stepped with one time step \(\Delta t\). To step it, the second order equation \(M \ddot u + \hat K u = 0\), with \(M\) and \(\hat K\) the matrices of the forms above acting on the vectors of the unknowns, is written as a first order system, with the velocity \(\dot u\) as an unknown of its own:

\[\begin{split} \begin{aligned} \frac{\mathrm{d} u}{\mathrm{d} t} &= \dot u,\\ M \frac{\mathrm{d} \dot u}{\mathrm{d} t} &= -\hat K u. \end{aligned} \end{split}\]

The implicit midpoint rule replaces each time derivative by the difference of the values at the two ends of a step, \(u^n\) at time \(n \Delta t\) and \(u^{n+1}\) at \((n + 1) \Delta t\), divided by \(\Delta t\), and evaluates the right-hand side at the middle of the step, as the average of its values at the two ends:

\[\begin{split} \begin{aligned} \text{(i)} \quad \frac{u^{n+1} - u^n}{\Delta t} &= \frac{\dot u^{n+1} + \dot u^n}{2},\\ \text{(ii)} \quad M \frac{\dot u^{n+1} - \dot u^n}{\Delta t} &= -\hat K \frac{u^{n+1} + u^n}{2}. \end{aligned} \end{split}\]

Equation (i) gives the new velocity from the new displacement,

\[ \text{(iii)} \quad \dot u^{n+1} = \frac{2}{\Delta t}\left(u^{n+1} - u^n\right) - \dot u^n. \]

Inserting (iii) into (ii) removes \(\dot u^{n+1}\):

\[ M \frac{1}{\Delta t}\left(\frac{2}{\Delta t}\left(u^{n+1} - u^n\right) - 2 \dot u^n\right) = -\frac12 \hat K \left(u^{n+1} + u^n\right), \]

and multiplying by 2 and gathering the unknown \(u^{n+1}\) on the left leaves one linear system for the new displacement,

\[ \text{(iv)} \quad \left(\frac{4}{\Delta t^2} M + \hat K\right) u^{n+1} = \frac{4}{\Delta t^2} M u^n + \frac{4}{\Delta t} M \dot u^n - \hat K u^n. \]

A step is therefore:

  1. assemble the right-hand side of (iv) from \(u^n\) and \(\dot u^n\);

  2. solve (iv) for \(u^{n+1}\), whose matrix is the same in every step, so it is assembled and factorized once;

  3. update the velocity with (iii).

The pendulum is released at rest, \(\dot u^0 = 0\), from the displacement \(u^0\) given below.

The left- and right-hand sides of (iv), with the right-hand side in terms of the functions holding \(u^n\) and \(\dot u^n\), u_n and u_dot_n.

dt, num_steps = 0.02, 150
# The displacement u^n, the velocity u_dot^n and the new displacement u^{n+1}
u_n, u_dot_n, u_new = in_constraint_spaces(), in_constraint_spaces(), in_constraint_spaces()


def acting(form, functions):
    """The linear form of `form` with its trial functions replaced by `functions`."""
    return ufl.replace(form, dict(zip(trials, functions)))


step_lhs = 4 / dt**2 * M + K_hat
step_rhs = acting(4 / dt**2 * M, u_n) + acting(4 / dt * M, u_dot_n) - acting(K_hat, u_n)

Assembling the step#

The matrix of a step does not change, so it is assembled and factorized once.

a_step = fem.form(ufl.extract_blocks(step_lhs), entity_maps=[pair], dtype=default_scalar_type)
L_step = fem.form(ufl.extract_blocks(step_rhs), entity_maps=[pair], dtype=default_scalar_type)
A = dolfinx_mpc.assemble_matrix(a_step, mpcs, bcs=pinned, kind="mpi")
solver = PETSc.KSP().create(comm)
solver.setOperators(A)
solver.setErrorIfNotConverged(True)
solver.setType("preonly")
solver.getPC().setType("lu")
solver.getPC().setFactorSolverType("mumps")
b = dolfinx_mpc.create_vector(L_step, mpcs, kind="mpi")
xh = dolfinx_mpc.create_vector(L_step, mpcs, kind="mpi")
# The pinned dofs, by block, which are zero in every step
pinned_by_block = fem.bcs_by_block([mpc.input_space for mpc in mpcs], pinned)

Hide code cell content

def energy_forms(u, u_dot):
    """The forms of the energy of the motion `u`, with velocity `u_dot`, one per domain."""
    forms = []
    for b, dx_b in enumerate((dx_1, dx_2)):
        kinetic = 0.5 * materials["rho"][b] * ufl.inner(u_dot[b], u_dot[b])
        elastic_b = 0.5 * ufl.inner(sigma(u[b], b), ufl.sym(ufl.grad(u[b])))
        geometric_b = 0.5 * ufl.inner(ufl.grad(u[b]) * sigma_0[b], ufl.grad(u[b]))
        forms.append((kinetic + elastic_b + geometric_b) * dx_b)
    forms.append(0.5 * ufl.inner(K_free[0] * spring_gap(u[3], u[4]), spring_gap(u[3], u[4])) * ufl.dx(spiders_A))
    forms.append(0.5 * ufl.inner(K_free[1] * u[2], u[2]) * ufl.dx(spiders_P))
    return [fem.form(form, entity_maps=[pair], dtype=default_scalar_type) for form in forms]


def energy(forms) -> float:
    return sum(comm.allreduce(fem.assemble_scalar(form), op=MPI.SUM).real for form in forms)


energies = energy_forms(u_n, u_dot_n)

Releasing the pendulum#

The pendulum starts at rest, beam 1 turned by \(\theta_1 = 8°\) about the fixed pin and beam 2 by a further \(\theta_2 = -8°\) about the lower pin: the rigid motions \(\theta_1 a \times (x - x_P)\), and \(\theta_2 a \times (x - x_B)\) on top of it for beam 2 and spider B. They satisfy the constraints and leave the springs unstretched, so the energy they start with is that of \(\sigma_0\) alone, the pendulum’s potential.

def turn(theta: float, x_c: np.ndarray):
    """The rigid rotation by `theta` about the pin through `x_c`, at points `x`."""
    return lambda x: theta * np.cross(axis, (x - x_c[:, None]).T).T


def rigid_turns(phi_1: float, phi_2: float, u):
    """Set `u`, on the five spaces, to beam 1 turned by `phi_1` about the fixed pin and beam 2 by a
    further `phi_2` about the lower pin."""
    u[0].interpolate(turn(phi_1, x_P))
    u[1].interpolate(lambda x: turn(phi_1, x_P)(x) + turn(phi_2, x_B)(x))
    spiders = (
        (np.zeros(3), phi_1 * axis),
        (phi_1 * np.cross(axis, x_A - x_P), phi_1 * axis),
        (phi_1 * np.cross(axis, x_B - x_P), (phi_1 + phi_2) * axis),
    )
    for f, (t, theta) in zip(u[2:], spiders):
        f.x.array[:] = np.tile(np.concatenate([t, theta]), f.x.array.size // 6)


theta_1, theta_2 = np.deg2rad(8.0), np.deg2rad(-8.0)
rigid_turns(theta_1, theta_2, u_n)

for k in range(2):
    u_n[k].name = "u"
writers = [VTXWriter(comm, f"demo_spider_hinge_{k + 1}.bp", [u_n[k]], engine="BP4") for k in range(2)]
writers.append(VTXWriter(comm, "demo_spider_hinge_pins.bp", [u_pins], engine="BP4"))


def spider_turns(u) -> tuple[float, ...]:
    """The turns of beam 1, at spider P, and of beam 2, at spider B, about the pins."""
    return tuple(float(np.dot(dolfinx_mpc.spider_values(u[k], 0).real[3:], axis)) for k in (2, 4))

Visualization#

As in Anisotropic wave system with periodic boundary conditions, each process builds grids of the cells it owns, gathered once onto one process, as the meshes do not move; per frame only the nodal values and the stresses of the cells are gathered. The beams and the pins are drawn displaced by the swing \(u\) at true scale, the pins in translucent red, and the beams are coloured by the von Mises stress of the total stress, \(\sigma(u_0 + u)\): the prestress and the swing’s.

Hide code cell source

pyvista.OFF_SCREEN = True
shown = (u_n[0], u_n[1], u_pins)
stress, stress_expressions = von_mises_functions([u_0[b] + u_n[b] for b in range(2)])
local_grids = [pyvista_ugrid(u.function_space) for u in shown]
gathered_grids = comm.gather(local_grids, root=0)
if comm.rank == 0:
    # The pieces of every process, in rank order, in one grid per mesh
    grids = [pyvista.merge([piece[k] for piece in gathered_grids], merge_points=False) for k in range(3)]
    plotter = pyvista.Plotter(off_screen=True, window_size=(450, 600))
    plotter.open_gif(Path("demo_spider_hinge.py").with_suffix(".gif"), fps=1 / (2 * dt))


def write_frame(t: float):
    """Draw the beams at time `t`, coloured by their von Mises stress, and the pins, displaced.
    Collective."""
    for f, expression in zip(stress, stress_expressions):
        f.interpolate(expression)
    values = comm.gather(
        (
            [u.x.array.reshape(-1, 3)[: grid.n_points].real.copy() for u, grid in zip(shown, local_grids)],
            [owned_cell_values(f) for f in stress],
        )
    )
    if comm.rank != 0:
        return
    plotter.clear()
    for k, grid in enumerate(grids):
        grid["u"] = np.vstack([piece[0][k] for piece in values])
    for k, grid in enumerate(grids[:2]):
        grid.cell_data["von Mises"] = np.concatenate([piece[1][k] for piece in values])
    if not clim:
        clim.extend([0.0, 1.5 * max(grid.cell_data["von Mises"].max() for grid in grids[:2])])
    for k, grid in enumerate(grids):
        if k < 2:
            add_cells(
                plotter, grid.warp_by_vector("u"), scalars="von Mises", clim=clim, cmap="viridis", show_scalar_bar=False
            )
        else:
            plotter.add_mesh(grid.warp_by_vector("u"), color="red", opacity=0.5)
    plotter.add_text(f"t = {t:.2f}", position="upper_left", font_size=12, color="black")
    plotter.camera_position = [(2.5, -7.0, -1.0), (0.0, 0.45, -1.4), (0.0, 0.0, 1.0)]
    plotter.write_frame()

We can now perform the time stepping, writing a frame every two steps, and checking every 25 steps that the energy is conserved, as shown in the energy conservation.

history = [(0.0, *spider_turns(u_n))]
E_0 = energy(energies)
move_pins(u_n)
write_frame(0.0)
drift = 0.0
for writer in writers:
    writer.write(0.0)
for step in range(1, num_steps + 1):
    with b.localForm() as b_local:
        b_local.set(0.0)
    dolfinx_mpc.assemble_vector(L_step, mpcs, b)
    b.ghostUpdate(addv=PETSc.InsertMode.ADD_VALUES, mode=PETSc.ScatterMode.REVERSE)
    dolfinx.fem.petsc.set_bc(b, pinned_by_block)
    solver.solve(b, xh)
    xh.ghostUpdate(addv=PETSc.InsertMode.INSERT, mode=PETSc.ScatterMode.FORWARD)
    dolfinx.fem.petsc.assign(xh, u_new)
    # Every block is homogenized before any is back-substituted, as spider masters are read across blocks
    for mpc, u in zip(mpcs, u_new):
        mpc.homogenize(u)
    for mpc in mpcs:
        mpc.backsubstitution(u_new)
    # (iii): u_dot^{n+1} = 2 / dt (u^{n+1} - u^n) - u_dot^n, then u^n = u^{n+1}
    for u, u_old, u_dot in zip(u_new, u_n, u_dot_n):
        u_dot.x.array[:] = 2 / dt * (u.x.array - u_old.x.array) - u_dot.x.array
        u_old.x.array[:] = u.x.array
    history.append((step * dt, *spider_turns(u_n)))
    move_pins(u_n)
    for writer in writers:
        writer.write(step * dt)
    if step % 2 == 0:
        write_frame(step * dt)
    if step % 25 == 0:
        drift = max(drift, abs(energy(energies) / E_0 - 1))
        if comm.rank == 0:
            t, turn_1, turn_2 = history[-1]
            print(
                f"t = {t:.2f}: beam 1 at {np.rad2deg(turn_1):6.2f}°, beam 2 at {np.rad2deg(turn_2):6.2f}°,"
                f" |E / E_0 - 1| <= {drift:.1e}"
            )
for writer in writers:
    writer.close()
if comm.rank == 0:
    plotter.close()

# The PETSc objects of the step are destroyed explicitly, and those that Python's garbage
# collector has freed are cleaned up on every process together, so that no process is left
# waiting for another when PETSc finalizes
solver.destroy()
A.destroy()
b.destroy()
xh.destroy()
PETSc.garbage_cleanup(comm)
t = 0.50: beam 1 at   0.80°, beam 2 at   8.78°, |E / E_0 - 1| <= 4.7e-09
t = 1.00: beam 1 at  -3.66°, beam 2 at  -1.77°, |E / E_0 - 1| <= 4.7e-09
t = 1.50: beam 1 at  -2.33°, beam 2 at -16.01°, |E / E_0 - 1| <= 4.7e-09
t = 2.00: beam 1 at  -4.54°, beam 2 at   2.86°, |E / E_0 - 1| <= 4.7e-09
t = 2.50: beam 1 at   2.88°, beam 2 at   5.09°, |E / E_0 - 1| <= 4.7e-09
t = 3.00: beam 1 at   7.47°, beam 2 at   1.32°, |E / E_0 - 1| <= 4.7e-09

The swing, released from rest:

gif

The energy is conserved up to the rounding of the solves, which the stiff springs next to the mass make large.

if double_precision:
    assert drift < min(1e-2, 1e8 * np.finfo(default_real_type).eps)

Verification against rigid double pendulums#

Rigid beams turning by \(\varphi_1\) about the fixed pin and \(\varphi_2\) about the lower pin, for small angles, satisfy \(M_\varphi \ddot\varphi + K_\varphi \varphi = 0\). Two such models are stepped with the same midpoint rule as the beams, starting from the same turns.

The first restricts the finite element model to the two rigid turns \(R_1\) and \(R_2\), \((M_\varphi)_{ij} = M(R_i, R_j)\) and \((K_\varphi)_{ij} = \hat K(R_i, R_j)\). It differs from the beams only by their elasticity, which beam 1, the softer, shows the most.

The second is the textbook compound double pendulum,

\[\begin{split} \begin{aligned} M_\varphi &= \begin{pmatrix} I_1 + m_2 \ell^2 & m_2 \ell h_2 \\ m_2 \ell h_2 & I_2 \end{pmatrix},\\ K_\varphi &= g \begin{pmatrix} m_1 h_1 + m_2 \ell & 0 \\ 0 & m_2 h_2 \end{pmatrix}, \end{aligned} \end{split}\]

with \(\ell = 0.8 L\) the distance between the pins, and for each beam its mass, its centre of mass, the depth of that below the beam’s pin \(x_b\) (\(x_P\) for beam 1, \(x_B\) for beam 2), and its moment of inertia about the pin, integrated over its mesh, with its bores,

\[\begin{split} \begin{aligned} m_b &= \int_{\Omega_b} \rho_b~\mathrm{d}x,\\ c_b &= \frac{1}{m_b} \int_{\Omega_b} \rho_b\, x~\mathrm{d}x,\\ h_b &= (x_b - c_b) \cdot e_z,\\ I_b &= \int_{\Omega_b} \rho_b \left|(I - a a^T)(x - x_b)\right|^2~\mathrm{d}x. \end{aligned} \end{split}\]

Its masses are those of the first model, but not quite its stiffness, as the energy of a rigid swing shows.

Check: the energy of a rigid swing#

To see that the geometric stiffness is the pendulum’s potential, turn one beam rigidly by an angle \(\theta\) about its pin, through \(x_c\) along \(a = (0, 1, 0)\): \(u = \theta\, a \times \xi\) with \(\xi = x - x_c\). The two parts of the stored energy are then

  • elastic: \(\varepsilon(u) = \operatorname{sym} \nabla u = 0\), as \(\nabla u\) is the skew-symmetric matrix of \(\theta a \times\), so \(\frac12 a(u, u) = 0\);

  • geometric: \(\nabla u^T \nabla u = \theta^2 (I - a a^T)\), the projection onto the plane of the swing, so

    \[ \frac12 a_{\sigma_0}(u, u) = \frac{\theta^2}{2} \int_{\Omega_b} \sigma_0 : (I - a a^T)~\mathrm{d}x = \frac{\theta^2}{2} \int_{\Omega_b} \left(\sigma_{0,xx} + \sigma_{0,zz}\right)~\mathrm{d}x. \]

The integral of the prestress follows from the equilibrium of the hanging beam, \(-\nabla \cdot \sigma_0 = \rho g\) in \(\Omega_b\), with the traction \(\sigma_0 n = \tau\) that the spiders exert on the bores and no traction elsewhere. Multiplying by \(\xi\) and integrating by parts gives

\[ \int_{\Omega_b} \sigma_0~\mathrm{d}x = \int_{\partial \Omega_b} \tau \otimes \xi~\mathrm{d}s + \int_{\Omega_b} \rho g \otimes \xi~\mathrm{d}x, \]

and taking the trace in the plane of the swing,

\[ \int_{\Omega_b} \left(\sigma_{0,xx} + \sigma_{0,zz}\right)~\mathrm{d}x = \underbrace{\int_{\Omega_b} \rho g \cdot \xi~\mathrm{d}x}_{m_b g h_b} + \int_{\text{bores}} \tau \cdot (I - a a^T)\, \xi~\mathrm{d}s, \]

with \(m_b\) the mass of the beam and \(h_b\) the depth of its centre of mass below the pin, as \(g = (0, 0, -g)\) is perpendicular to \(a\). The first term gives \(\frac12 m_b g h_b \theta^2\), the exact potential of a rigid pendulum, \(m_b g h_b (1 - \cos\theta)\), to second order in \(\theta\). The bore terms add the work of the loads the beam carries:

  • for beam 1, the weight of beam 2 hangs from its lower bore, a distance \(\ell = 0.8 L\) below the pin, and adds \(\frac12 m_2 g \ell\, \theta^2\), as in \(K_\varphi\) of the textbook double pendulum above;

  • at the bore a beam hangs from, the pin’s force acts a bore radius \(r\) off the axis and adds a term of relative size \(r / h_b\). The textbook pendulum leaves this term out, so over a few swings it drifts out of phase with the beams.

So, for a swing, the stored energy \(\frac12 u \cdot \hat K u\) is the elastic energy of the beams’ deformation, \(\frac12 a(u, u)\), plus the pendulum’s potential, \(\frac12 a_{\sigma_0}(u, u)\), plus the energy of the springs, \(\frac12 s_\text{free}(w, w)\), which stays small as long as the pins hold the spiders together.

mass, centre, inertia = [], [], []
for b, (m, x_pivot) in enumerate(((beam_1, x_P), (beam_2, x_B))):
    x = ufl.SpatialCoordinate(m)
    rho = materials["rho"][b]
    mass.append(integrate(rho * ufl.dx(m)))
    centre.append(np.array([integrate(rho * x[i] * ufl.dx(m)) for i in range(3)]) / mass[-1])
    # The distance from the pin's axis, along y: its x and z components
    arm = ufl.as_vector([x[0] - x_pivot[0], x[2] - x_pivot[2]])
    inertia.append(integrate(rho * ufl.inner(arm, arm) * ufl.dx(m)))
if comm.rank == 0:
    print(f"Masses {mass[0]:.4f}, {mass[1]:.4f}; centres {centre[0].round(4)}, {centre[1].round(4)}")

ell = x_P[2] - x_B[2]
h = (x_P[2] - centre[0][2], x_B[2] - centre[1][2])
textbook = (
    np.array([[inertia[0] + mass[1] * ell**2, mass[1] * ell * h[1]], [mass[1] * ell * h[1], inertia[1]]]),
    g * np.diag([mass[0] * h[0] + mass[1] * ell, mass[1] * h[1]]),
)

modes = [[fem.Function(mpc.function_space, dtype=default_scalar_type) for mpc in mpcs] for _ in range(3)]
# Beam 2 turned by phi_2 in all, so that both models use the absolute turns
rigid_turns(1.0, -1.0, modes[0])
rigid_turns(0.0, 1.0, modes[1])
zero = [fem.Function(mpc.function_space, dtype=default_scalar_type) for mpc in mpcs]


def quadratic(i: int, j: int, kinetic: bool) -> float:
    """M(R_i, R_j) if `kinetic`, else K(R_i, R_j), by polarization of the energy."""
    for f, f_i, f_j in zip(modes[2], modes[i], modes[j]):
        f.x.array[:] = f_i.x.array + f_j.x.array

    def twice_energy(u):
        return 2 * energy(energy_forms(zero, u) if kinetic else energy_forms(u, zero))

    return 0.5 * (twice_energy(modes[2]) - twice_energy(modes[i]) - twice_energy(modes[j]))


ritz = tuple(np.array([[quadratic(i, j, kinetic) for j in range(2)] for i in range(2)]) for kinetic in (True, False))


def swing(M_phi: np.ndarray, K_phi: np.ndarray) -> np.ndarray:
    """The turns of a rigid double pendulum, stepped as the beams were."""
    step_matrix = np.linalg.inv(4 / dt**2 * M_phi + K_phi)
    phi, phi_dot, phi_new = np.array([theta_1, theta_1 + theta_2]), np.zeros(2), np.zeros(2)
    # The initial turns, and those after each step
    turns = np.zeros((num_steps + 1, 2))
    turns[0] = phi
    for step in range(1, num_steps + 1):
        phi_new[:] = step_matrix @ (4 / dt**2 * M_phi @ phi + 4 / dt * M_phi @ phi_dot - K_phi @ phi)
        phi_dot[:] = 2 / dt * (phi_new - phi) - phi_dot
        phi[:] = phi_new
        turns[step] = phi
    return turns


turns = np.array(history)[:, 1:]
mismatch = {
    name: np.abs(turns - swing(*model)).max() / np.abs(turns).max()
    for name, model in (("ritz", ritz), ("textbook", textbook))
}
mass_difference = np.abs(ritz[0] - textbook[0]).max() / np.abs(textbook[0]).max()
stiffness_difference = np.diag(ritz[1]) / np.diag(textbook[1]) - 1
if comm.rank == 0:
    print(f"Masses, relative to the textbook pendulum's: {mass_difference:.1e}")
    print(f"Stiffness, relative to the textbook pendulum's: {stiffness_difference.round(3)}")
    print(
        f"Largest difference of the turns to the rigid pendulums: {mismatch['ritz']:.1%} (finite elements),"
        f" {mismatch['textbook']:.1%} (textbook)"
    )
Masses 0.2479, 0.1470; centres [ 0.      0.2    -0.9065], [-0.      0.575  -2.2767]
Masses, relative to the textbook pendulum's: 8.3e-14
Stiffness, relative to the textbook pendulum's: [-0.061 -0.058]
Largest difference of the turns to the rigid pendulums: 2.1% (finite elements), 17.7% (textbook)

The masses agree to rounding, the stiffnesses to the order of \(r / h\), and the beams follow the rigid pendulum of the finite element model to a few percent. As noted above, the last two are only checked in double precision.

assert mass_difference < 1e4 * np.finfo(default_real_type).eps
if double_precision:
    assert np.all(np.abs(stiffness_difference) < radius / min(h))
    assert mismatch["ritz"] < 0.1

Finally, the static problem’s PETSc objects are released and cleaned up in the same way.