Joining two bodies with a rigid spider (RBE2)#
Author Jørgen S. Dokken
License MIT
A spider joins a single point, its body, to any number of nodes, its feet (see FE Training on spiders). In the rigid variant, known as RBE2 in most finite element codes, the feet follow the rigid-body motion of the body,
where \(x_c\) is the body, \(t\) its translation and \(\theta\) its rotation.
This demo joins two separately meshed elastic cubes, one of tetrahedra and one of hexahedra, through a single spider between them. The body is a mesh of one point, with six unknowns: \(t\) and \(\theta\). Every node on the facing side of each cube is a foot. Nothing else couples the cubes: the first is clamped at its far end, the second carries a load at its far end, and the load reaches the clamp through the spider only.
Each foot is a slave of the body, so the masters of the constraint on a cube live in another block of the system, on another mesh.
from pathlib import Path
from mpi4py import MPI
from petsc4py import PETSc
import basix.ufl
import numpy as np
import pyvista
import ufl
from dolfinx import default_real_type, default_scalar_type, fem, la, mesh, plot
import dolfinx_mpc
The two cubes and the spider#
The tetrahedral cube occupies \([0, 1]^3\), the hexahedral one \([1.2, 2.2] \times [0, 1]^2\). The spider sits in the gap between them.
comm = MPI.COMM_WORLD
N = 6
cube_tet = mesh.create_box(
comm, [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]], [N, N, N], mesh.CellType.tetrahedron, dtype=default_real_type
)
cube_hex = mesh.create_box(
comm, [[1.2, 0.0, 0.0], [2.2, 1.0, 1.0]], [N, N, N], mesh.CellType.hexahedron, dtype=default_real_type
)
V_tet = fem.functionspace(cube_tet, ("Lagrange", 1, (3,)))
V_hex = fem.functionspace(cube_hex, ("Lagrange", 1, (3,)))
The bodies of all spiders are the points of one mesh, made by
create_spider_mesh. The points are
given on the first process; every other process passes none, as here, or the
same ones. A spider is named by its input index, its row in those points, so
this one is spider 0. Coinciding points stay distinct spiders. The space on the
mesh has six components per point: the translation \(t\) and the rotation
\(\theta\); with three, the spider only translates. The spider’s position \(x_c\)
is the coordinate of its dofs in this space.
x_c = np.array([1.1, 0.5, 0.5], dtype=default_real_type)
spiders = dolfinx_mpc.create_spider_mesh(comm, x_c.reshape(1, 3) if comm.rank == 0 else np.zeros((0, 3)))
W = fem.functionspace(spiders, basix.ufl.element("DG", "point", 0, shape=(6,), dtype=default_real_type))
The constraints#
The feet are the nodes on the facing sides, \(x = 1\) and \(x = 1.2\), tied to
spider 0 with add_rbe2_geometrical
and add_rbe2_topological.
With several spiders, either method takes a list with one entry per spider,
entry \(k\) holding the locator (or the entities) of the feet of spider \(k\).
The body has no constraint of its own, but is finalized together with the cubes,
as its space holds their masters.
Each process needs the global dofs, owner and position of the spiders its feet
are tied to. These are found through a post office keyed on the input index
(dolfinx_mpc::locate_spiders in C++), so no process gathers the spider mesh.
tol = 1e3 * np.finfo(default_real_type).eps
mpc_tet = dolfinx_mpc.MultiPointConstraint(V_tet, dtype=default_scalar_type)
mpc_tet.add_rbe2_geometrical(lambda x: np.isclose(x[0], 1.0, atol=tol), W)
mpc_hex = dolfinx_mpc.MultiPointConstraint(V_hex, dtype=default_scalar_type)
facets = mesh.locate_entities_boundary(cube_hex, 2, lambda x: np.isclose(x[0], 1.2, atol=tol))
mpc_hex.add_rbe2_topological(2, facets, W)
mpc_body = dolfinx_mpc.MultiPointConstraint(W, dtype=default_scalar_type)
mpcs = [mpc_tet, mpc_hex, mpc_body]
dolfinx_mpc.finalize_multipointconstraints(mpcs)
Linear elasticity on each cube#
The forms do not couple the blocks, and the body has no bilinear form. The off-diagonal blocks, and the body’s own block, are filled by the constraint.
A load can also act on the spider itself. A force \(F\) does work on the translation \(t\) and a moment \(M\) on the rotation \(\theta\), so the load is the linear form
on the space of the spider, with \(w_t\) and \(w_\theta\) the translation and
rotation components of the test function. On a point mesh, ufl.dx evaluates
its integrand at the point with weight one. The moment is about the spider
point, in the global axes. The load starts at zero and is set further down.
E, nu = 1.0e3, 0.3
mu, lmbda = E / (2 * (1 + nu)), E * nu / ((1 + nu) * (1 - 2 * nu))
def sigma(u):
return 2 * mu * ufl.sym(ufl.grad(u)) + lmbda * ufl.tr(ufl.sym(ufl.grad(u))) * ufl.Identity(3)
def elasticity(V):
u, v = ufl.TrialFunction(V), ufl.TestFunction(V)
return ufl.inner(sigma(u), ufl.sym(ufl.grad(v))) * ufl.dx
# A downward traction on the far end of the hexahedral cube
far_end = mesh.locate_entities_boundary(cube_hex, 2, lambda x: np.isclose(x[0], 2.2, atol=tol))
facet_tags = mesh.meshtags(cube_hex, 2, far_end, np.full(len(far_end), 1, dtype=np.int32))
ds = ufl.Measure("ds", domain=cube_hex, subdomain_data=facet_tags)
traction = fem.Constant(cube_hex, np.array([0.0, 0.0, -1.0], dtype=default_scalar_type))
# The force and moment (F, M) on the spider
spider_load = fem.Constant(spiders, np.zeros(6, dtype=default_scalar_type))
a = [[elasticity(V_tet), None, None], [None, elasticity(V_hex), None], [None, None, None]]
L = [
ufl.ZeroBaseForm((ufl.TestFunction(V_tet),)),
ufl.inner(traction, ufl.TestFunction(V_hex)) * ds(1),
ufl.inner(spider_load, ufl.TestFunction(W)) * ufl.dx(spiders),
]
# The tetrahedral cube is clamped at x = 0
clamped = fem.locate_dofs_geometrical(V_tet, lambda x: np.isclose(x[0], 0.0, atol=tol))
bc = fem.dirichletbc(np.zeros(3, dtype=default_scalar_type), clamped, V_tet)
Solve#
The blocks are assembled into one matrix and solved directly.
problem = dolfinx_mpc.LinearProblem(
a,
L,
mpcs,
bcs=[bc],
kind="mpi",
petsc_options_prefix="demo_spider_",
petsc_options={
"ksp_type": "preonly",
"pc_type": "lu",
"pc_factor_mat_solver_type": "mumps",
"ksp_error_if_not_converged": True,
},
)
u_tet, u_hex, spider = problem.solve()
Checks#
The feet move rigidly with the spider. The body’s values are held only where
they are owned or needed by a foot, so
spider_values gathers them on every process.
feet_tet = fem.locate_dofs_geometrical(V_tet, lambda x: np.isclose(x[0], 1.0, atol=tol))
feet_hex = fem.locate_dofs_geometrical(V_hex, lambda x: np.isclose(x[0], 1.2, atol=tol))
def rigid_motion_error(u, V, feet, x_c, t, theta):
"""Largest deviation of the feet from t + theta x (x - x_c), over all processes."""
r = V.tabulate_dof_coordinates()[feet] - x_c
expected = t + np.cross(theta, r)
error = np.abs(u.x.array.reshape(-1, 3)[feet] - expected).max() if len(feet) > 0 else 0.0
return comm.allreduce(error, op=MPI.MAX)
values = dolfinx_mpc.spider_values(spider, 0)
t, theta = values[:3], values[3:6]
errors = (
rigid_motion_error(u_tet, V_tet, feet_tet, x_c, t, theta),
rigid_motion_error(u_hex, V_hex, feet_hex, x_c, t, theta),
)
The load reaches the clamp: the reaction there balances the applied traction. The reaction is the residual of the tetrahedral cube’s equations at the clamped dofs, evaluated with the solution. Its moment is taken about the centre of the clamped face.
x_clamp = np.array([0.0, 0.5, 0.5])
def clamp_reaction(u):
"""Force and moment about `x_clamp` that the clamp exerts on the tetrahedral cube."""
u_ref = fem.Function(V_tet, dtype=default_scalar_type)
n_owned = V_tet.dofmap.index_map.size_local * 3
u_ref.x.array[:n_owned] = u.x.array[:n_owned]
u_ref.x.scatter_forward()
residual = fem.assemble_vector(fem.form(ufl.action(elasticity(V_tet), u_ref), dtype=default_scalar_type))
residual.scatter_reverse(la.InsertMode.add)
owned_clamped = clamped[clamped < V_tet.dofmap.index_map.size_local]
f = residual.array.reshape(-1, 3)[owned_clamped]
r = V_tet.tabulate_dof_coordinates()[owned_clamped] - x_clamp
force = comm.allreduce(f.sum(axis=0), op=MPI.SUM)
moment = comm.allreduce(np.cross(r, f).sum(axis=0), op=MPI.SUM)
return force, moment
reaction, _ = clamp_reaction(u_tet)
load = comm.allreduce(fem.assemble_scalar(fem.form(traction[2] * ds(1))), op=MPI.SUM)
if comm.rank == 0:
print(f"Spider translation t = {t}, rotation theta = {theta}")
print(f"Feet off the rigid motion: {errors[0]:.2e} (tetrahedra), {errors[1]:.2e} (hexahedra)")
print(f"Reaction at the clamp: {reaction}, applied load: {[0.0, 0.0, load]}")
atol = 1e4 * np.finfo(default_real_type).eps
assert max(errors) < atol
assert np.allclose(reaction, [0.0, 0.0, -load], atol=atol * abs(load) * 100)
Spider translation t = [-2.14165970e-05 3.87907342e-04 -1.38211110e-02], rotation theta = [0.00010178 0.01710804 0.00058545]
Feet off the rigid motion: 1.73e-18 (tetrahedra), 1.73e-18 (hexahedra)
Reaction at the clamp: [ 1.64035452e-14 -4.49709714e-14 1.00000000e+00], applied load: [0.0, 0.0, -0.999999999999999]
A force and a moment on the spider#
The traction is now replaced by a force and a moment on the spider. The load reaches the clamp through the tetrahedral cube alone, while the hexahedral cube, joined to the same spider, moves rigidly with it.
force = np.array([0.0, 0.2, -1.0])
moment = np.array([0.0, 0.3, 0.1])
traction.value[:] = 0.0
spider_load.value[:] = np.concatenate([force, moment])
u_tet_F, u_hex_F, spider_F = problem.solve()
In equilibrium, the clamp reacts with \(-F\), and with the opposite of the load’s moment about the centre of the clamped face, \(M + (x_c - x_0) \times F\).
reaction_force, reaction_moment = clamp_reaction(u_tet_F)
load_moment = moment + np.cross(x_c - x_clamp, force)
values = dolfinx_mpc.spider_values(spider_F, 0)
hex_error = rigid_motion_error(u_hex_F, V_hex, feet_hex, x_c, values[:3], values[3:6])
if comm.rank == 0:
print(f"Reaction force {reaction_force}, applied {force}")
print(f"Reaction moment {reaction_moment}, moment of the load {load_moment}")
assert np.allclose(reaction_force, -force, atol=100 * atol)
assert np.allclose(reaction_moment, -load_moment, atol=100 * atol)
assert hex_error < atol
Reaction force [ 1.45716772e-14 -2.00000000e-01 1.00000000e+00], applied [ 0. 0.2 -1. ]
Reaction moment [-1.56732266e-15 -1.40000000e+00 -3.20000000e-01], moment of the load [0. 1.4 0.32]
Following the motion of the spider#
The relation \(u = t + \theta \times (x - x_c)\) is linear in the rotation, so
it holds for small rotations about the current position of the spider. Under a
larger load we apply it in increments, each about the configuration reached by
the previous ones (an updated Lagrangian approach): solve for the displacement
increment, move both cubes and the spider by it, and recompute the coefficients
of the constraint about the new positions with
update_rbe2. It reads
the feet’s and the spider’s current dof coordinates. Every rotation term of the
constraint is kept, also where its coefficient is zero, so the masters stay the
same and the matrix layout is reused.
dolfinx_mpc.spider.move() moves a mesh by a displacement, through the
space of its coordinate element, whose dofs in each cell are the cell’s geometry
nodes. Given the increment on the spider space, it moves each spider by its
translation.
num_steps = 10
spider_load.value[:] = 0.0
traction.value[:] = [0.0, 0.0, -2.0]
u_total = [np.zeros((V.dofmap.index_map.size_local + V.dofmap.index_map.num_ghosts, 3)) for V in (V_tet, V_hex)]
x_spider = x_c.copy()
frames = []
Each process draws the cells it owns and the legs of the spider to the feet it owns; the pieces are gathered on one process, see Plotting in parallel.
pyvista.global_theme.allow_empty_mesh = True
def pieces(x_spider):
"""The cubes, coloured by the total displacement, and the legs of the spider."""
grids = []
for V, u, feet in ((V_tet, u_total[0], feet_tet), (V_hex, u_total[1], feet_hex)):
owned = np.arange(V.mesh.topology.index_map(3).size_local, dtype=np.int32)
grid = pyvista.UnstructuredGrid(*plot.vtk_mesh(V, entities=owned))
grid["|u|"] = np.linalg.norm(u[: grid.n_points], axis=1)
grids.append(grid)
feet = np.vstack(
[
V.tabulate_dof_coordinates()[f[f < V.dofmap.index_map.size_local]]
for V, f in ((V_tet, feet_tet), (V_hex, feet_hex))
]
)
legs = pyvista.PolyData(np.vstack([x_spider, feet]))
num_feet = len(feet)
legs.lines = np.column_stack(
[np.full(num_feet, 2), np.zeros(num_feet, dtype=np.int64), 1 + np.arange(num_feet)]
).reshape(-1)
return grids, legs
for step in range(num_steps + 1):
if step > 0:
du_tet, du_hex, du_spider = problem.solve()
values = dolfinx_mpc.spider_values(du_spider, 0)
# The constraint holds about the configuration the increment was computed in
error = max(
rigid_motion_error(du_tet, V_tet, feet_tet, x_spider, values[:3], values[3:6]),
rigid_motion_error(du_hex, V_hex, feet_hex, x_spider, values[:3], values[3:6]),
)
assert error < atol, f"Feet off the rigid motion by {error:.2e} at step {step}"
for domain, du, total in ((cube_tet, du_tet, u_total[0]), (cube_hex, du_hex, u_total[1])):
dolfinx_mpc.spider.move(domain, du)
total += du.x.array.reshape(-1, 3)[: len(total)].real
dolfinx_mpc.spider.move(spiders, du_spider)
x_spider += values[:3].real
mpc_tet.update_rbe2()
mpc_hex.update_rbe2()
# Collective: every process gathers, only the root draws
frame = comm.gather(pieces(x_spider), root=0)
if comm.rank == 0:
frames.append(frame)
if comm.rank == 0:
print(f"Spider after {num_steps} increments: {x_spider}, moved by {x_spider - x_c}")
Spider after 10 increments: [1.07446238 0.50740882 0.22510755], moved by [-0.02553762 0.00740882 -0.27489245]
The spider has moved down and the hexahedral cube has rotated with it.
assert x_spider[2] < x_c[2] - 0.1
if comm.rank == 0:
clim = [0.0, max(max(g["|u|"].max(initial=0.0) for g in grids) for grids, _ in frames[-1])]
plotter = pyvista.Plotter(off_screen=True, window_size=[800, 500])
plotter.open_gif(Path("demo_spider.py").with_suffix(".gif"), fps=3)
for frame in frames:
plotter.clear()
cubes = pyvista.merge([g for grids, _ in frame for g in grids])
legs = pyvista.merge([legs for _, legs in frame])
plotter.add_mesh(
cubes,
scalars="|u|",
clim=clim,
show_edges=True,
cmap="viridis",
scalar_bar_args={"title": "Displacement", "vertical": True, "position_x": 0.85, "position_y": 0.2},
)
plotter.add_mesh(legs, color="crimson", line_width=2)
plotter.add_mesh(pyvista.Sphere(radius=0.04, center=legs.points[0]), color="crimson")
plotter.camera_position = [(1.0, -5.2, 1.4), (1.1, 0.5, 0.0), (0.0, 0.0, 1.0)]
plotter.write_frame()
plotter.close()
The PETSc objects of the problem are freed, and those the garbage collector has released are cleaned up on every process together.