Nullspace on a subspace

Hello everyone! I am elaborating on the example provided at Elastic 3D beam structures — Computational Mechanics Numerical Tours with FEniCSx which implements a beam element model on a 1D mesh embedded in 3D using a mixed function space ((P^n, P^n), the first vector component for the domain’s displacement and the second vector component for the beam’s local rotation). Since my problem has no Dirichlet boundary conditions [my domain is kinda floating in outer space] I tried to make it solvable by setting a nullspace containing rigid translations and rotations. I took inspiration from the demo_elasticity.py example where we have

def build_nullspace(V: FunctionSpace):
    """Build PETSc nullspace for 3D elasticity"""

    # Create vectors that will span the nullspace
    bs = V.dofmap.index_map_bs
    length0 = V.dofmap.index_map.size_local
    basis = [la.vector(V.dofmap.index_map, bs=bs, dtype=dtype) for i in range(6)]
    b = [b.array for b in basis]

    # Get dof indices for each subspace (x, y and z dofs)
    dofs = [V.sub(i).dofmap.list.flatten() for i in range(3)]

    # Set the three translational rigid body modes
    for i in range(3):
        b[i][dofs[i]] = 1.0

    # Set the three rotational rigid body modes
    x = V.tabulate_dof_coordinates()
    dofs_block = V.dofmap.list.flatten()
    x0, x1, x2 = x[dofs_block, 0], x[dofs_block, 1], x[dofs_block, 2]
    b[3][dofs[0]] = -x1
    b[3][dofs[1]] = x0
    b[4][dofs[0]] = x2
    b[4][dofs[2]] = -x0
    b[5][dofs[2]] = x1
    b[5][dofs[1]] = -x2

    la.orthonormalize(basis)

    basis_petsc = [
        PETSc.Vec().createWithArray(x[: bs * length0], bsize=3, comm=V.mesh.comm)  # type: ignore
        for x in b
    ]
    return PETSc.NullSpace().create(vectors=basis_petsc)  # type: ignore

but in my case I have a mixed function space [displacements and local rotation] and my nullspace needs to include [I believe] translations and rotations for the displacement and translations for the rotation.

I modified the code above as follows

def _build_nullspace(W: fem.FunctionSpace) -> PETSc.NullSpace:
    """Create the operator nullspace.

    This is used to impose average zero displacement and rotation to the solution.
    """

    # Create vectors that will span the nullspace
    block_size = W.dofmap.index_map_bs
    local_size = W.dofmap.index_map.size_local

    # AAA: We will need 9 vectors: six for the translations and rotations on the displacement,
    # which is the first component of the space W, and three for the local rotation, which is the
    # second component of the space W
    nullspace_basis = [
        la.vector(W.dofmap.index_map, bs=block_size, dtype=default_scalar_type)
        for _ in range(9)
    ]
    nullspace_basis_arrays = [b.array for b in nullspace_basis]

    # Get dof indices for each subspace (x, y and z dofs)

    # AAA: storing the dofs for the two subspaces separately
    dofs_d = [W.sub(0).sub(i).dofmap.list.flatten() for i in range(3)]
    dofs_r = [W.sub(1).sub(i).dofmap.list.flatten() for i in range(3)]

    # Set the three translational rigid body modes
    for i in range(3):
        nullspace_basis_arrays[i][dofs_d[i]] = 1.0

    # AAA: basis vectors 3, 4, 5 will be for the local rotation
    for i in range(3, 6):
        nullspace_basis_arrays[i][dofs_r[i-3]] = 1.0

    # AAA: ok, now I have to set the rotations on the displacement.
    # The main issue is that I cannot call W.tabulate_dofs_coordinates() so I
    # have to collapse a subspace and do it there, to later map the coordinates back.
    V, V_to_W = W.sub(0).collapse()
    # AAA: I need to invert the map V_to_W in order to map the dofs back to the space W.
    # For now I am creating a numpy array that maps the positions back so that I can use
    # it for indexed/masked access conveniently. I put a value that is out of range in the missing
    # entries so that if something goes wrong it will error out. Surely there's a better way to do this.
    W_to_V = []
    idx = 0
    V_to_W_set = set(V_to_W)
    for i in range(V_to_W[-1]+1):
        if i in V_to_W_set:
            W_to_V.append(idx)
            idx += 1
        else:
            W_to_V.append(len(V_to_W)+1)  # sketchy...
    W_to_V = np.array(W_to_V)

    # AAA: Now I can tabulate the dofs
    x = V.tabulate_dof_coordinates()
    # AAA: and get the dofmap, which refers to the space W
    dofs_block = W.sub(0).dofmap.list.flatten()
    # AAA: here I map the dofs back to the V space, dividing by three because
    # dofs are grouped by three for a vector-valued function in 3D 
    idx_V = W_to_V[dofs_block] // 3
    # AAA: Finally, I extract the dofs coordinates, picking only every third
    # dof coordinate because they repeat three times
    x0, x1, x2 = x[idx_V, 0][::3], x[idx_V, 1][::3], x[idx_V, 2][::3]
    nullspace_basis_arrays[6][dofs_d[0]] = -x1
    nullspace_basis_arrays[6][dofs_d[1]] = x0
    nullspace_basis_arrays[7][dofs_d[0]] = x2
    nullspace_basis_arrays[7][dofs_d[2]] = -x0
    nullspace_basis_arrays[8][dofs_d[2]] = x1
    nullspace_basis_arrays[8][dofs_d[1]] = -x2

    la.orthonormalize(nullspace_basis)

    basis_petsc = [
        PETSc.Vec().createWithArray(
            x[: block_size * local_size], bsize=3, comm=W.mesh.comm
        )
        for x in nullspace_basis_arrays
    ]
    return PETSc.NullSpace().create(vectors=basis_petsc)

Now, incredibly enough, this horrible thing here seems to work, at least for a toy problem, at least in serial.

Here come my questions:

  1. Is my implementation actually correct?
  2. Is there a better way of doing this that doesn’t require sketchy manipulations of the dofs?

Thanks for any help!

Edit: Actually, it only works for P1-P1 elements so there’s definitely something off here.