Source code for elastica._rotations

__doc__ = """ Rotation kernels """

import functools
from itertools import combinations

import numpy as np
from numpy import sin, cos, sqrt, arccos
from numpy.typing import NDArray

from numba import njit

from elastica.typing import RodType, RigidBodyType, ConnectionIndex
from elastica._linalg import _batch_matmul


@njit(cache=True)  # type: ignore
def _get_rotation_matrix(
    scale: np.float64, axis_collection: NDArray[np.float64]
) -> NDArray[np.float64]:
    """
    Compute rotation matrices from axis-angle representation using Rodrigues' formula.

    Parameters
    ----------
    scale : float
        Scale factor applied to rotation angles. The actual rotation angle for each
        axis is scale * ||axis||.
    axis_collection : numpy.ndarray
        2D array of shape (dim, blocksize) containing rotation axes. Each column
        represents an axis of rotation.

    Returns
    -------
    rot_mat : numpy.ndarray
        3D array of shape (dim, dim, blocksize) containing rotation matrices computed
        using Rodrigues' rotation formula.

    Notes
    -----
    The axes are normalized before computing the rotation matrices. A small epsilon
    (1e-14) is added to prevent division by zero for zero-length axes.
    """
    blocksize = axis_collection.shape[1]
    rot_mat = np.empty((3, 3, blocksize))

    for k in range(blocksize):
        v0 = axis_collection[0, k]
        v1 = axis_collection[1, k]
        v2 = axis_collection[2, k]

        theta = sqrt(v0 * v0 + v1 * v1 + v2 * v2)

        v0 /= theta + 1e-14
        v1 /= theta + 1e-14
        v2 /= theta + 1e-14

        theta *= scale
        u_prefix = sin(theta)
        u_sq_prefix = 1.0 - cos(theta)

        rot_mat[0, 0, k] = 1.0 - u_sq_prefix * (v1 * v1 + v2 * v2)
        rot_mat[1, 1, k] = 1.0 - u_sq_prefix * (v0 * v0 + v2 * v2)
        rot_mat[2, 2, k] = 1.0 - u_sq_prefix * (v0 * v0 + v1 * v1)

        rot_mat[0, 1, k] = u_prefix * v2 + u_sq_prefix * v0 * v1
        rot_mat[1, 0, k] = -u_prefix * v2 + u_sq_prefix * v0 * v1
        rot_mat[0, 2, k] = -u_prefix * v1 + u_sq_prefix * v0 * v2
        rot_mat[2, 0, k] = u_prefix * v1 + u_sq_prefix * v0 * v2
        rot_mat[1, 2, k] = u_prefix * v0 + u_sq_prefix * v1 * v2
        rot_mat[2, 1, k] = -u_prefix * v0 + u_sq_prefix * v1 * v2

    return rot_mat


@njit(cache=True)  # type: ignore
def _rotate(
    director_collection: NDArray[np.float64],
    scale: np.float64,
    axis_collection: NDArray[np.float64],
) -> NDArray[np.float64]:
    """
    Rotate director collection by specified axes and scale (alibi rotation).

    Performs alibi (active) rotations on a collection of director frames.
    Each director frame is rotated around its corresponding axis by an angle
    proportional to the scale factor. The rotation is applied using Rodrigues'
    rotation formula via `_get_rotation_matrix`.

    Parameters
    ----------
    director_collection : numpy.ndarray
        3D array of shape (dim, dim, blocksize) containing rotation matrices
        (director frames) to be rotated.
    scale : float
        Scale factor for rotation angles. The actual rotation angle for each
        frame is scale * ||axis||, where ||axis|| is the magnitude of the
        corresponding axis vector.
    axis_collection : numpy.ndarray
        2D array of shape (dim, blocksize) containing rotation axes for each
        director frame. Each column represents the axis of rotation for the
        corresponding director frame.

    Returns
    -------
    rotated_directors : numpy.ndarray
        3D array of shape (dim, dim, blocksize) containing the rotated director
        frames. Each frame is rotated around its corresponding axis by the
        scaled angle.

    Notes
    -----
    This function performs alibi (active) rotations, meaning the coordinate
    system is rotated. For more information on rotation matrix ambiguities, see:
    https://en.wikipedia.org/wiki/Rotation_matrix#Ambiguities

    The rotation is computed as: R(scale * axis) @ director, where R is the
    rotation matrix computed from the axis-angle representation.
    """
    # return _batch_matmul(
    #     director_collection, _get_rotation_matrix(scale, axis_collection)
    # )
    return _batch_matmul(
        _get_rotation_matrix(scale, axis_collection), director_collection
    )


@njit(cache=True)  # type: ignore
def _inv_rotate(director_collection: NDArray[np.float64]) -> NDArray[np.float64]:
    """
    Compute rotation axes between consecutive director frames using Rodrigues' formula.

    Calculates the rotation axis (in axis-angle representation) that transforms
    each director frame to the next one. This is the inverse operation of rotating
    directors and is used to extract the relative rotation between consecutive
    elements.

    Parameters
    ----------
    director_collection : numpy.ndarray
        The collection of frames/directors at every element, of shape (dim, dim, n)
        where n is the number of director frames.

    Returns
    -------
    vector_collection : numpy.ndarray
        The collection of rotation axes, of shape (dim, n-1). Each column represents
        the axis of rotation (scaled by angle) that transforms director[k] to director[k+1].

    Notes
    -----
    The output has n-1 elements because it computes the relative rotation between
    consecutive pairs of directors. The rotation axis is computed using the trace
    of the relative rotation matrix Q_{k+1} @ Q_k^T.
    """
    blocksize = director_collection.shape[2] - 1
    vector_collection = np.empty((3, blocksize))

    for k in range(blocksize):
        # Q_{i+i}Q^T_{i} collection
        vector_collection[0, k] = (
            director_collection[2, 0, k + 1] * director_collection[1, 0, k]
            + director_collection[2, 1, k + 1] * director_collection[1, 1, k]
            + director_collection[2, 2, k + 1] * director_collection[1, 2, k]
        ) - (
            director_collection[1, 0, k + 1] * director_collection[2, 0, k]
            + director_collection[1, 1, k + 1] * director_collection[2, 1, k]
            + director_collection[1, 2, k + 1] * director_collection[2, 2, k]
        )

        vector_collection[1, k] = (
            director_collection[0, 0, k + 1] * director_collection[2, 0, k]
            + director_collection[0, 1, k + 1] * director_collection[2, 1, k]
            + director_collection[0, 2, k + 1] * director_collection[2, 2, k]
        ) - (
            director_collection[2, 0, k + 1] * director_collection[0, 0, k]
            + director_collection[2, 1, k + 1] * director_collection[0, 1, k]
            + director_collection[2, 2, k + 1] * director_collection[0, 2, k]
        )

        vector_collection[2, k] = (
            director_collection[1, 0, k + 1] * director_collection[0, 0, k]
            + director_collection[1, 1, k + 1] * director_collection[0, 1, k]
            + director_collection[1, 2, k + 1] * director_collection[0, 2, k]
        ) - (
            director_collection[0, 0, k + 1] * director_collection[1, 0, k]
            + director_collection[0, 1, k + 1] * director_collection[1, 1, k]
            + director_collection[0, 2, k + 1] * director_collection[1, 2, k]
        )

        trace = (
            (
                director_collection[0, 0, k + 1] * director_collection[0, 0, k]
                + director_collection[0, 1, k + 1] * director_collection[0, 1, k]
                + director_collection[0, 2, k + 1] * director_collection[0, 2, k]
            )
            + (
                director_collection[1, 0, k + 1] * director_collection[1, 0, k]
                + director_collection[1, 1, k + 1] * director_collection[1, 1, k]
                + director_collection[1, 2, k + 1] * director_collection[1, 2, k]
            )
            + (
                director_collection[2, 0, k + 1] * director_collection[2, 0, k]
                + director_collection[2, 1, k + 1] * director_collection[2, 1, k]
                + director_collection[2, 2, k + 1] * director_collection[2, 2, k]
            )
        )

        # Clip the trace to between -1 and 3.
        # Any deviation beyond this is numerical error
        trace = min(trace, 3.0)
        trace = max(trace, -1.0)
        theta = arccos(0.5 * trace - 0.5) + 1e-14
        magnitude = -0.5 * theta / sin(theta)

        vector_collection[0, k] *= magnitude
        vector_collection[1, k] *= magnitude
        vector_collection[2, k] *= magnitude

    return vector_collection


_generate_skew_map_sentinel = (0, 0, 0)


# TODO: Below contains numpy-only implementations
@functools.lru_cache(maxsize=1)
def _generate_skew_map(dim: int) -> list[tuple[int, int, int]]:
    """
    Generate mapping indices for converting vectors to skew-symmetric matrices.

    Creates a mapping that defines how vector elements are arranged in a
    flattened skew-symmetric matrix representation. This is used for efficient
    conversion between vector and matrix forms in dimension-agnostic operations.

    Notes
    -----
    The mapping handles the conversion from a vector v = [x, y, z] to a
    skew-symmetric matrix M where M[i,j] = -M[j,i] and the off-diagonal
    elements correspond to vector components.

    The formula used (dim - (i + j)) works correctly for dimensions 2 and 3,
    but may need verification for higher dimensions.
    """
    # Preallocate
    mapping_list = [_generate_skew_map_sentinel] * ((dim**2 - dim) // 2)
    # Indexing (i,j), j is the fastest changing
    # r = 2, r here is rank, we deal with only matrices
    for index, (i, j) in enumerate(combinations(range(dim), r=2)):
        # matrix indices
        tgt_idx = dim * i + j
        # Sign-bit to check order of entries
        sign = (-1) ** tgt_idx
        # idx in v
        # TODO Wrong formulae, but works for two and three dimensions
        src_idx = dim - (i + j)

        # Check order to fill in the list
        if sign < 0:
            entry_t = (src_idx, j, i)
        else:
            entry_t = (src_idx, i, j)

        mapping_list[index] = entry_t

    return mapping_list


@functools.lru_cache(maxsize=1)
def _get_skew_map(dim: int) -> tuple[tuple[int, int, int], ...]:
    """Generates mapping from src to target skew-symmetric operator

    For input vector V and output Matrix M (represented in lexicographical index),
    we calculate mapping from

        |x|        |0 -z y|
    V = |y| to M = |z 0 -x|
        |z|        |-y x 0|

    in a dimension agnostic way.

    """
    mapping_list = _generate_skew_map(dim)

    # sort for non-strided access in source dimension, potentially faster copies
    mapping_list.sort(key=lambda tup: tup[0])

    # return iterator
    return tuple(mapping_list)


@functools.lru_cache(maxsize=1)
def _get_inv_skew_map(dim: int) -> tuple[tuple[int, int, int], ...]:
    """
    Generate inverse mapping for extracting vectors from skew-symmetric matrices.

    Creates a mapping that defines how to extract vector elements from a
    flattened skew-symmetric matrix representation. This is the inverse
    operation of `_generate_skew_map`.

    Notes
    -----
    This mapping is used to extract vector components from skew-symmetric
    matrices. The mapping is generated by inverting the tuple element order
    from `_generate_skew_map`.
    """
    # (vec_src, mat_i, mat_j, sign)
    mapping_list = _generate_skew_map(dim)

    # invert tuple elements order to have
    #             (mat_i, mat_j, vec_tgt, sign)
    return tuple((t[1], t[2], t[0]) for t in mapping_list)


@functools.lru_cache(maxsize=1)
def _get_diag_map(dim: int) -> tuple[int, ...]:
    """Generates lexicographic mapping to diagonal in a serialized matrix-type

    For input dimension dim  we calculate mapping to * in Matrix M below

        |* 0 0|
    M = |0 * 0|
        |0 0 *|

    in a dimension agnostic way.

    """
    return tuple([dim_iter * (dim + 1) for dim_iter in range(dim)])


def _skew_symmetrize(vector: NDArray[np.float64]) -> NDArray[np.float64]:
    """
    Convert vector collection to skew-symmetric matrix collection.

    Notes
    -----
    Gets close to the hard-coded implementation in time but with slightly
    high memory requirement for iteration.

    For blocksize=128,
    hardcoded : 5.9 µs ± 186 ns per loop
    this : 6.19 µs ± 79.2 ns per loop

    """
    dim, blocksize = vector.shape
    skewed = np.zeros((dim, dim, blocksize))

    # Iterate over generated indices and put stuff from v to m
    for src_index, tgt_i, tgt_j in _get_skew_map(dim):
        skewed[tgt_i, tgt_j] = vector[src_index]
        skewed[tgt_j, tgt_i] = -skewed[tgt_i, tgt_j]

    return skewed


# This is purely for testing and optimization sake
# While calculating u^2, use u with einsum instead, as it is tad bit faster
def _skew_symmetrize_sq(vector: NDArray[np.float64]) -> NDArray[np.float64]:
    """
    Generate the square of a skew-symmetric matrix from vector elements.

    Computes u^2 where u is the skew-symmetric matrix corresponding to the input
    vector. This is used in Rodrigues' rotation formula.

    Parameters
    ----------
    vector : numpy.ndarray
        Input vector collection of shape (dim, blocksize).

    Returns
    -------
    output : numpy.ndarray
        Square of skew-symmetric matrices of shape (dim, dim, blocksize).
        For a 3D vector [x, y, z], the corresponding matrix u^2 is:
        [[-(y^2+z^2), xy, xz],
         [yx, -(x^2+z^2), yz],
         [zx, zy, -(x^2+y^2)]]

    Note
    ----
    Faster than hard-coded implementation in time with slightly high
    memory requirement for einsum calculation.

    For blocksize=128,
    hardcoded : 23.1 µs ± 481 ns per loop
    this version: 14.1 µs ± 96.9 ns per loop
    """

    # First generate array of [x^2, xy, xz, yx, y^2, yz, zx, zy, z^2]
    # across blocksize
    # This is slightly faster than doing v[np.newaxis,:,:] * v[:,np.newaxis,:]
    products_xy: NDArray[np.float64] = np.einsum("ik,jk->ijk", vector, vector)

    # No copy made here, as we do not change memory layout
    # products_xy = products_xy.reshape((dim * dim, -1))

    # Now calculate (x^2 + y^2 + z^2) across blocksize
    # Interpret this as a contraction ji,ij->j with v.T, v
    mag = np.einsum("ij,ij->j", vector, vector)

    # Iterate over only the diagonal and subtract mag
    # Somewhat faster (5us for 128 blocksize) but more memory efficient than doing :
    # > eye_arr = np.ravel(np.eye(dim, dim))
    # > eye_arr = eye_arr[:, np.newaxis] * mag[np.newaxis, :]
    # > products_xy - mag

    # This version is faster for smaller blocksizes <= 128
    # Efficiently extracts only diagonal elements
    # reshape returns a view in this case
    np.einsum("iij->ij", products_xy)[...] -= mag

    # # This version is faster for larger blocksizes > 256
    # for diag_idx in _get_diag_map(dim):
    #     products_xy[diag_idx, :] -= mag

    # We expect this version to be superior, but due to numpy's advanced
    # indexing always returning a copy, rather than a view, it turns out
    # to be more expensive.
    #     products_xy[_get_diag_map(dim, :] -= mag

    return products_xy


def _get_skew_symmetric_pair(
    vector_collection: NDArray[np.float64],
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
    """
    Compute both the skew-symmetric matrix and its square from vector collection.

    This is a convenience function that computes both u and u^2 where u is the
    skew-symmetric matrix corresponding to the input vectors. These are commonly
    used together in Rodrigues' rotation formula.

    """
    u = _skew_symmetrize(vector_collection)
    u_sq = np.einsum("ijk,jlk->ilk", u, u)
    return u, u_sq


def _inv_skew_symmetrize(matrix: NDArray[np.float64]) -> NDArray[np.float64]:
    """
    Return the vector elements from a skew-symmetric matrix M.

    Parameters
    ----------
    matrix : numpy.ndarray
        3D (dim, dim, blocksize) array containing skew-symmetric matrices.

    Returns
    -------
    vector : numpy.ndarray
        2D (dim, blocksize) array containing the extracted vector elements.

    Notes
    -----
    Hardcoded: 2.28 µs ± 63.3 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
    This: 2.91 µs ± 58.3 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
    """
    dim, dim, blocksize = matrix.shape

    vector = np.zeros((dim, blocksize))

    # Iterate over generated indices and put stuff from v to m
    # The original skew_mapping function takes consecutive
    # indices in v and puts them in the matrix, so we skip
    # indices here
    for src_i, src_j, tgt_index in _get_inv_skew_map(dim):
        vector[tgt_index] = matrix[src_i, src_j]

    return vector


[docs] def get_relative_rotation_two_systems( system_one: "RodType | RigidBodyType", index_one: "ConnectionIndex", system_two: "RodType | RigidBodyType", index_two: "ConnectionIndex", ) -> NDArray[np.float64]: """ Compute the relative rotation matrix C_12 between system one and system two at the specified elements. Examples -------- How to get the relative rotation between two systems (e.g. the rotation from end of rod one to base of rod two): >>> rel_rot_mat = get_relative_rotation_two_systems(system1, -1, system2, 0) How to initialize a FixedJoint with a rest rotation between the two systems, which is enforced throughout the simulation: >>> simulator.connect( ... first_rod=system1, second_rod=system2, first_connect_idx=-1, second_connect_idx=0 ... ).using( ... FixedJoint, ... ku=1e6, nu=0.0, kt=1e3, nut=0.0, ... rest_rotation_matrix=get_relative_rotation_two_systems(system1, -1, system2, 0) ... ) See Also -------- FixedJoint Parameters ---------- system_one : RodType | RigidBodyType Rod or rigid-body object index_one : ConnectionIndex Index of first system for connection. system_two : RodType | RigidBodyType Rod or rigid-body object index_two : ConnectionIndex Index of second system for connection. Returns ------- relative_rotation_matrix : numpy.ndarray 2D (3, 3) array containing the relative rotation matrix C_12 between the two systems for their current state. """ director_one = system_one.director_collection[..., index_one] director_two = system_two.director_collection[..., index_two] return director_one @ director_two.T