Source code for damask._rotation

import sys
import copy
import re
import builtins
from typing import Optional, Union, Sequence, Literal, TypeVar, NamedTuple

import numpy as np
import numpy.typing as npt
import scipy
from scipy.spatial.transform import Rotation as ScipyRotation

from ._typehints import FloatSequence, IntSequence, NumpyRngSeed
from . import tensor
from . import util
from . import grid_filters


class AxisAngleTuple(NamedTuple):
    axis: np.ndarray
    angle: np.ndarray


_P = -1

# parameters for conversion from/to cubochoric
_sc   = np.pi**(1./6.)/6.**(1./6.)
_beta = np.pi**(5./6.)/6.**(1./6.)/2.
_R1   = (3.*np.pi/4.)**(1./3.)

MyType = TypeVar('MyType', bound='Rotation')

[docs] class Rotation: u""" Rotation with functionality for conversion between different representations. The following conventions apply: - Coordinate frames are right-handed. - A rotation angle ω is taken to be positive for a counterclockwise rotation when viewing from the end point of the rotation axis towards the origin. - Rotations will be interpreted in the passive sense, i.e. as rotation of the coordinate frame. - P = -1 (as default). References ---------- D. Rowenhorst et al., Modelling and Simulation in Materials Science and Engineering 23:083501, 2015 https://doi.org/10.1088/0965-0393/23/8/083501 Examples -------- Rotate vector 'a' (defined in coordinate system 'A') to coordinates 'b' expressed in system 'B', where 'Q' represents the conversion from 'A' to 'B': >>> import numpy as np >>> import damask >>> Q = damask.Rotation.from_random() >>> a = np.random.rand(3) >>> b = Q @ a >>> np.allclose(np.dot(Q.as_matrix(),a),b) True Compound rotations R1 (first) and R2 (second): >>> import numpy as np >>> import damask >>> R1 = damask.Rotation.from_random() >>> R2 = damask.Rotation.from_random() >>> R = R2 * R1 >>> np.allclose(R.as_matrix(), np.dot(R2.as_matrix(),R1.as_matrix())) True Initialize from scipy.spatial.transform.Rotation: >>> import numpy as np >>> import damask >>> from scipy.spatial.transform import Rotation as ScipyRotation >>> R_SciPy = ScipyRotation.random() >>> R_DAMASK = damask.Rotation(R_SciPy) >>> v = np.random.rand(3) >>> np.allclose(R_DAMASK@v, R_SciPy.apply(v,inverse=True)) True >>> np.allclose(R_DAMASK.as_quaternion(),R_SciPy.as_quat(canonical=True,scalar_first=True)) True """ __slots__ = ['quaternion'] def __init__(self, rotation: Union[FloatSequence, ScipyRotation, 'Rotation'] = np.array([1.,0.,0.,0.])): """ New rotation. Parameters ---------- rotation : list, numpy.ndarray, Rotation, or scipy.spatial.transform.Rotation, optional Unit quaternion in positive real hemisphere or Rotation. Use .from_quaternion to perform a sanity check. Defaults to no rotation. Note ---- When passing in a Rotation from SciPy, it is interpreted passively even though SciPy uses an active convention. """ self.quaternion: np.ndarray if (isinstance(rotation,ScipyRotation)): if np.lib.NumpyVersion(scipy.__version__) >= '1.14.0': self.quaternion = rotation.as_quat(canonical=True,scalar_first=True) else: quat = rotation.as_quat(canonical=True) self.quaternion = np.block([quat[...,3:4],quat[...,0:3]]) elif len(s:=np.asarray(rotation).shape) > 0 and s[-1] == 4: self.quaternion = np.array(rotation,dtype=float) else: raise TypeError('"rotation" cannot be interpreted as quaternion') def __str__(self) -> str: """ Return str(self). Give short, human-readable summary. """ return re.sub(r'\[([ +-]*[0-9.eE+-]+)(\s*)([ +-]*[0-9.eE+-]+)(\s*)([ +-]*[0-9.eE+-]+)(\s*)([ +-]*[0-9.eE+-]+)(\s*)\]', r'\1\2 \3\4\5\6\7\8',self.quaternion.__str__()) def __repr__(self) -> str: """ Return repr(self). Give unambiguous representation. """ return re.sub(r'\[(\+|-| )*(?=\d)([^,]+,)\s*?(\+|-| )*(?=\d)([^,]+,)\s*?(\+|-| )*(?=\d)([^,]+,)\s*?(\+|-| )*(?=\d)(.+?)\]', r'(\1\2 \3\4 \5\6 \7\8)',self.quaternion.__repr__()) def __copy__(self: MyType, rotation: Optional[Union[FloatSequence, 'Rotation']] = None) -> MyType: """ Return deepcopy(self). Create deep copy. """ dup = copy.deepcopy(self) if rotation is not None: dup.quaternion = Rotation(rotation).quaternion return dup copy = __copy__ def __getitem__(self: MyType, item: Union[tuple[Union[None, int, slice, "builtins.ellipsis"], ...], int, bool, np.bool_, np.ndarray]) -> MyType: """ Return self[item]. Return slice according to item. """ return self.copy(np.stack([c[item] for c in np.rollaxis(self.quaternion,-1)],axis=-1)) def __eq__(self, # type: ignore[override] other: object) -> npt.NDArray[np.bool_]: """ Return self==other. Test equality of other. Parameters ---------- other : Rotation Rotation to check for equality. Returns ------- equal : numpy.ndarray Whether both arguments are equal. """ return NotImplemented if not isinstance(other, Rotation) else \ np.logical_or(np.all(self.quaternion == other.quaternion,axis=-1), np.all(self.quaternion == -1.*other.quaternion,axis=-1)) def __ne__(self, # type: ignore[override] other: object) -> npt.NDArray[np.bool_]: """ Return self!=other. Test inequality of other. Parameters ---------- other : Rotation Rotation to check for inequality. """ return np.logical_not(self==other) if isinstance(other, Rotation) else NotImplemented
[docs] def isclose(self: MyType, other: MyType, rtol: float = 1.e-5, atol: float = 1.e-8, equal_nan: bool = True) -> npt.NDArray[np.bool_]: """ Report where values are approximately equal to corresponding ones of other Rotation. Parameters ---------- other : Rotation Rotation to compare against. rtol : float, optional Relative tolerance of equality. atol : float, optional Absolute tolerance of equality. equal_nan : bool, optional Consider matching NaN values as equal. Defaults to True. Returns ------- mask : numpy.ndarray of bool, shape (self.shape) Mask indicating where corresponding rotations are close. """ s = self.quaternion o = other.quaternion return np.logical_or(np.all(np.isclose(s, o,rtol,atol,equal_nan),axis=-1), np.all(np.isclose(s,-1.*o,rtol,atol,equal_nan),axis=-1))
[docs] def allclose(self: MyType, other: MyType, rtol: float = 1.e-5, atol: float = 1.e-8, equal_nan: bool = True) -> np.bool_: """ Test whether all values are approximately equal to corresponding ones of other Rotation. Parameters ---------- other : Rotation Rotation to compare against. rtol : float, optional Relative tolerance of equality. atol : float, optional Absolute tolerance of equality. equal_nan : bool, optional Consider matching NaN values as equal. Defaults to True. Returns ------- allclose : bool Test whether all values are approximately equal to corresponding ones of other rotation. """ return np.all(self.isclose(other,rtol,atol,equal_nan))
@property def size(self) -> int: return self.quaternion[...,0].size @property def shape(self) -> tuple[int, ...]: return self.quaternion[...,0].shape def __array__(self: MyType, dtype: Optional[npt.DTypeLike] = None, *, copy: Optional[bool] = None) -> np.ndarray: """Initializer for numpy.""" return self.quaternion.__array__(dtype) if np.lib.NumpyVersion(np.__version__) < '2.0.0' else \ self.quaternion.__array__(dtype,copy=copy) # type: ignore[arg-type] def __len__(self: MyType) -> int: """ Return len(self). Length of leading/leftmost dimension of array. """ return 0 if self.shape == () else self.shape[0] def __invert__(self: MyType) -> MyType: """ Return ~self. Inverse rotation (backward rotation). """ dup = self.copy() dup.quaternion[...,1:] *= -1. return dup def __pow__(self: MyType, exp: Union[float, int]) -> MyType: """ Return self**exp. Perform the rotation 'exp' times. Parameters ---------- exp : float Exponent. """ phi = np.arccos(self.quaternion[...,0:1]) p = self.quaternion[...,1:]/np.linalg.norm(self.quaternion[...,1:],axis=-1,keepdims=True) return self.copy(Rotation(np.block([np.cos(exp*phi),np.sin(exp*phi)*p]))._standardize()) def __ipow__(self: MyType, exp: Union[float, int]) -> MyType: """ Return self**=exp. Perform the rotation 'exp' times (in-place). Parameters ---------- exp : float Exponent. """ return self**exp def __mul__(self: MyType, other: MyType) -> MyType: """ Return self*other. Compose with other. Parameters ---------- other : Rotation, shape (self.shape) Rotation for composition. Compatible innermost dimensions will blend. Returns ------- composition : Rotation Compound rotation self*other, i.e. first other then self rotation. """ if isinstance(other,Rotation): blend = util.shapeblender( self.shape,other.shape) s_m = util.shapeshifter( self.shape,blend,mode='right') s_o = util.shapeshifter(other.shape,blend,mode='left') q_m = self.broadcast_to(s_m).quaternion[...,0:1] p_m = self.broadcast_to(s_m).quaternion[...,1:] q_o = other.broadcast_to(s_o).quaternion[...,0:1] p_o = other.broadcast_to(s_o).quaternion[...,1:] qmo = q_m*q_o q = (qmo - np.einsum('...i,...i',p_m,p_o).reshape(qmo.shape)) p = q_m*p_o + q_o*p_m + _P * np.cross(p_m,p_o) return self.copy(Rotation(np.block([q,p]))._standardize()) else: raise TypeError('use "R@b", i.e. matmul, to apply rotation "R" to object "b"') def __imul__(self: MyType, other: MyType) -> MyType: """ Return self*=other. Compose with other (in-place). Parameters ---------- other : Rotation, shape (self.shape) Rotation for composition. Compatible innermost dimensions will blend. """ return self*other def __truediv__(self: MyType, other: MyType) -> MyType: """ Return self/other. Compose with inverse of other. Parameters ---------- other : damask.Rotation, shape (self.shape) Rotation to invert for composition. Compatible innermost dimensions will blend. Returns ------- composition : Rotation Compound rotation self*(~other), i.e. first inverse of other then self rotation. """ if isinstance(other,Rotation): return self*~other else: raise TypeError('use "R@b", i.e. matmul, to apply rotation "R" to object "b"') def __itruediv__(self: MyType, other: MyType) -> MyType: """ Return self/=other. Compose with inverse of other (in-place). Parameters ---------- other : Rotation, shape (self.shape) Rotation to invert for composition. """ return self/other def __matmul__(self, other: np.ndarray) -> np.ndarray: """ Return self@other. Rotate vector, second-order tensor, or fourth-order tensor. `other` is interpreted as an array of tensor quantities with the highest-possible order considering the shape of `self`. Compatible innermost dimensions will blend. For instance, shapes of (2,) and (3,3) for `self` and `other` prompt interpretation of `other` as a second-rank tensor and result in (2,) rotated tensors, whereas shapes of (2,1) and (3,3) for `self` and `other` result in (2,3) rotated vectors. Parameters ---------- other : numpy.ndarray, shape (...,3), (...,3,3), or (...,3,3,3,3) Vector or tensor on which to apply the rotation. Returns ------- rotated : numpy.ndarray, shape (...,3), (...,3,3), or (...,3,3,3,3) Rotated vector or tensor, i.e. transformed to frame defined by rotation. Examples -------- Application of twelve (random) rotations to a set of five vectors. >>> import numpy as np >>> import damask >>> r = damask.Rotation.from_random(shape=(12)) >>> o = np.ones((5,3)) >>> (r@o).shape # (12) @ (5, 3) (12, 5, 3) Application of a (random) rotation to all twelve second-rank tensors. >>> import numpy as np >>> import damask >>> r = damask.Rotation.from_random() >>> o = np.ones((12,3,3)) >>> (r@o).shape # (1) @ (12, 3,3) (12, 3, 3) Application of twelve (random) rotations to the corresponding twelve second-rank tensors. >>> import numpy as np >>> import damask >>> r = damask.Rotation.from_random(shape=(12)) >>> o = np.ones((12,3,3)) >>> (r@o).shape # (12) @ (3,3) (12, 3, 3) Application of each of three (random) rotations to all three vectors. >>> import numpy as np >>> import damask >>> r = damask.Rotation.from_random(shape=(3)) >>> o = np.ones((3,3)) >>> (r[...,np.newaxis]@o[np.newaxis,...]).shape # (3,1) @ (1,3, 3) (3, 3, 3) Application of twelve (random) rotations to all twelve second-rank tensors. >>> import numpy as np >>> import damask >>> r = damask.Rotation.from_random(shape=(12)) >>> o = np.ones((12,3,3)) >>> (r@o[np.newaxis,...]).shape # (12) @ (1,12, 3,3) (12, 12, 3, 3) """ if isinstance(other, np.ndarray): obs = (util.shapeblender(self.shape,other.shape[:-1])+other.shape[-1:])[len(self.shape):] for l in [4,2,1]: if obs[-l:] == l*(3,): bs = util.shapeblender(self.shape,other.shape[:-l],False) self_ = self.broadcast_to(bs) if self.shape != bs else self if l==1: q_m = self_.quaternion[...,0] p_m = self_.quaternion[...,1:] A = q_m**2 - np.einsum('...i,...i',p_m,p_m) B = 2. * np.einsum('...i,...i',p_m,other) C = 2. * _P * q_m return np.block([(A * other[...,i]) + (B * p_m[...,i]) + (C * ( p_m[...,(i+1)%3]*other[...,(i+2)%3] - p_m[...,(i+2)%3]*other[...,(i+1)%3])) for i in [0,1,2]]).reshape(bs+(3,),order='F') else: return np.einsum({2: '...im,...jn,...mn', 4: '...im,...jn,...ko,...lp,...mnop'}[l], *l*[self_.as_matrix()], other) raise ValueError('can only rotate vectors, second-order tensors, and fourth-order tensors') elif isinstance(other, Rotation): raise TypeError('use "R2*R1", i.e. multiplication, to compose rotations "R1" and "R2"') else: raise TypeError(f'cannot rotate "{type(other)}"') apply = __matmul__ def _standardize(self: MyType) -> MyType: """Standardize quaternion (ensure positive real hemisphere).""" self.quaternion[self.quaternion[...,0] < 0.] *= -1. return self
[docs] def append(self: MyType, other: Union[MyType, list[MyType]]) -> MyType: """ Extend array along first dimension with other array(s). Parameters ---------- other : (list of) damask.Rotation Rotation(s) to append. """ return self.copy(np.vstack(tuple(map(lambda x:x.quaternion, [self]+other if isinstance(other,list) else [self,other]))))
[docs] def flatten(self: MyType, order: Literal['C','F','A'] = 'C') -> MyType: """ Flatten array. Parameters ---------- order : {'C', 'F', 'A'}, optional 'C' flattens in row-major (C-style) order. 'F' flattens in column-major (Fortran-style) order. 'A' flattens in column-major order if object is Fortran contiguous in memory, row-major order otherwise. Defaults to 'C'. Returns ------- flattened : damask.Rotation Rotation flattened to single dimension. """ return self.copy() if self.shape == () else \ self.copy(self.quaternion.reshape((-1,4),order=order))
[docs] def reshape(self: MyType, newshape: Union[int, IntSequence], order: Literal['C','F','A'] = 'C') -> MyType: """ Reshape array. Parameters ---------- newshape : (sequence of) int The new shape should be compatible with the original shape. If an integer, then the result will be a 1-D array of that length. order : {'C', 'F', 'A'}, optional 'C' flattens in row-major (C-style) order. 'F' flattens in column-major (Fortran-style) order. 'A' flattens in column-major order if object is Fortran contiguous in memory, row-major order otherwise. Defaults to 'C'. Returns ------- reshaped : damask.Rotation Rotation of given shape. """ shape = (newshape,) if isinstance(newshape,(int,np.integer)) else newshape return self.copy(self.quaternion.reshape(tuple(shape)+(4,),order=order))
[docs] def broadcast_to(self: MyType, shape: Union[int, IntSequence], mode: Literal['left', 'right'] = 'right') -> MyType: """ Broadcast array. Parameters ---------- shape : (sequence of) int Shape of broadcasted array, needs to be compatible with the original shape. mode : str, optional Where to preferentially locate missing dimensions. Either 'left' or 'right' (default). Returns ------- broadcasted : damask.Rotation Rotation broadcasted to given shape. """ shape_ = (shape,) if isinstance(shape,(int,np.integer)) else tuple(shape) return self.copy(np.broadcast_to(self.quaternion.reshape(util.shapeshifter(self.shape,shape_,mode)+(4,)), shape_+(4,)))
[docs] def average(self: MyType, weights: Optional[FloatSequence] = None) -> MyType: """ Average along last array dimension. Parameters ---------- weights : numpy.ndarray, shape (self.shape), optional Relative weight of each rotation. Returns ------- average : damask.Rotation Weighted average of original Rotation field. References ---------- F. Landis Markley et al., Journal of Guidance, Control, and Dynamics 30(4):1193-1197, 2007 https://doi.org/10.2514/1.28949 """ def M(quat): """Intermediate representation supporting quaternion averaging.""" return np.einsum('...i,...j',quat,quat) weights_ = np.ones(self.shape,dtype=float) if weights is None else np.array(weights,float) eig, vec = np.linalg.eig(np.sum(M(self.quaternion) * weights_[...,np.newaxis,np.newaxis],axis=-3) /np.sum( weights_[...,np.newaxis,np.newaxis],axis=-3)) return self.copy(Rotation.from_quaternion(np.real( np.squeeze( np.take_along_axis(vec, eig.argmax(axis=-1)[...,np.newaxis,np.newaxis], axis=-1), axis=-1)), accept_homomorph = True))
[docs] def misorientation(self: MyType, other: MyType) -> MyType: """ Calculate misorientation to other Rotation. Parameters ---------- other : damask.Rotation Rotation to which the misorientation is computed. Compatible innermost dimensions will blend. Returns ------- g : damask.Rotation Misorientation. """ return ~(self*~other)
[docs] def misorientation_angle(self: MyType, other: MyType) -> np.ndarray: """ Calculate misorientation angle to other Rotation. Parameters ---------- other : damask.Rotation Rotation to which the misorientation angle is computed. Compatible innermost dimensions will blend. Returns ------- omega : np.ndarray Misorientation angle. """ trace_max = np.abs((self*~other).quaternion[...,0]) return 2.*np.arccos(np.clip(np.round(trace_max,15),None,1.))
################################################################################################ # convert to different orientation representations (numpy arrays)
[docs] def as_quaternion(self) -> np.ndarray: """ Represent as unit quaternion. Returns ------- q : numpy.ndarray, shape (...,4) Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1, q_0 ≥ 0. """ return self.quaternion.copy()
[docs] def as_Euler_angles(self, degrees: bool = False) -> np.ndarray: """ Represent as Bunge Euler angles. Parameters ---------- degrees : bool, optional Return angles in degrees. Defaults to False. Returns ------- phi : numpy.ndarray, shape (...,3) Bunge Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]) or (φ_1 ∈ [0,360], ϕ ∈ [0,180], φ_2 ∈ [0,360]) if degrees == True. Notes ----- Bunge Euler angles correspond to a rotation axis sequence of z–x'–z''. Examples -------- Cube orientation as Bunge Euler angles. >>> import damask >>> damask.Rotation([1,0,0,0]).as_Euler_angles() array([0., 0., 0.]) """ eu = Rotation._qu2eu(self.quaternion) return np.degrees(eu) if degrees else eu
[docs] def as_axis_angle(self, degrees: bool = False, pair: bool = False) -> Union[AxisAngleTuple, np.ndarray]: """ Represent as axis–angle pair. Parameters ---------- degrees : bool, optional Return rotation angle in degrees. Defaults to False. pair : bool, optional Return tuple of axis and angle. Defaults to False. Returns ------- n_omega : numpy.ndarray, shape (...,4) or tuple ((...,3), (...)) if pair == True Axis and angle [n_1, n_2, n_3, ω] with ǀnǀ = 1 and ω ∈ [0,π] or ω ∈ [0,180] if degrees == True. Examples -------- Cube orientation as axis–angle pair. >>> import damask >>> damask.Rotation([1,0,0,0]).as_axis_angle(pair=True) AxisAngleTuple(axis=array([0., 0., 1.]), angle=array(0.)) """ ax: np.ndarray = Rotation._qu2ax(self.quaternion) if degrees: ax[...,3] = np.degrees(ax[...,3]) if pair: return AxisAngleTuple(ax[...,:3],ax[...,3]) else: return ax
[docs] def as_matrix(self) -> np.ndarray: """ Represent as rotation matrix. Returns ------- R : numpy.ndarray, shape (...,3,3) Rotation matrix R with det(R) = 1, R.T ∙ R = I. Examples -------- Cube orientation as rotation matrix. >>> import damask >>> damask.Rotation([1,0,0,0]).as_matrix() array([[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]]) """ return Rotation._qu2om(self.quaternion)
[docs] def as_Rodrigues_vector(self, compact: bool = False) -> np.ndarray: """ Represent as Rodrigues–Frank vector with separate axis and angle argument. Parameters ---------- compact : bool, optional Return three-component Rodrigues–Frank vector, i.e. axis and angle argument are not separated. Returns ------- rho : numpy.ndarray, shape (...,4) or (...,3) if compact == True Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π] or [n_1, n_2, n_3] with ǀnǀ = tan(ω/2) and ω ∈ [0,π] if compact == True. Examples -------- Cube orientation as three-component Rodrigues–Frank vector. >>> import damask >>> damask.Rotation([1,0,0,0]).as_Rodrigues_vector(compact=True) array([ 0., 0., 0.]) """ ro = Rotation._qu2ro(self.quaternion) if compact: with np.errstate(invalid='ignore'): return ro[...,:3]*ro[...,3:4] else: return ro
[docs] def as_homochoric(self) -> np.ndarray: """ Represent as homochoric vector. Returns ------- h : numpy.ndarray, shape (...,3) Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3). Examples -------- Cube orientation as homochoric vector. >>> import damask >>> damask.Rotation([1,0,0,0]).as_homochoric() array([0., 0., 0.]) """ return Rotation._qu2ho(self.quaternion)
[docs] def as_cubochoric(self) -> np.ndarray: """ Represent as cubochoric vector. Returns ------- x : numpy.ndarray, shape (...,3) Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3). Examples -------- Cube orientation as cubochoric vector. >>> import damask >>> damask.Rotation([1,0,0,0]).as_cubochoric() array([0., 0., 0.]) """ return Rotation._qu2cu(self.quaternion)
################################################################################################ # Static constructors. The input data needs to follow the conventions, options allow to # relax the conventions.
[docs] @staticmethod def from_quaternion(q: Union[Sequence[FloatSequence], np.ndarray], accept_homomorph: bool = False, normalize: bool = False, P: Literal[1, -1] = -1) -> 'Rotation': """ Initialize from quaternion. Parameters ---------- q : numpy.ndarray, shape (...,4) Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0. accept_homomorph : bool, optional Allow homomorphic variants, i.e. q_0 < 0 (negative real hemisphere). Defaults to False. normalize : bool, optional Allow ǀqǀ ≠ 1. Defaults to False. P : int ∈ {-1,1}, optional Sign convention. Defaults to -1. Returns ------- new : damask.Rotation Rotation representing the given quaternion. Examples -------- >>> import damask >>> damask.Rotation.from_quaternion([[1,0,0,0],[0,1,0,0]]) array([(1., 0., 0., 0.), (0., 1., 0., 0.)]) """ qu = np.array(q,dtype=float) if qu.shape[:-2:-1] != (4,): raise ValueError(f'invalid shape: {qu.shape}') if abs(P) != 1: raise ValueError('P ∉ {-1,1}') if P == 1: qu[...,1:4] *= -1 if accept_homomorph: qu[qu[...,0]<0.] *= -1. elif np.any(qu[...,0] < 0.): with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'): raise ValueError(f'quaternion with negative first (real) component\n{qu}') if normalize: qu /= np.linalg.norm(qu,axis=-1,keepdims=True) elif not np.allclose(np.linalg.norm(qu,axis=-1),1.,rtol=1.e-8): with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'): raise ValueError(f'quaternion is not of unit length\n{qu}') return Rotation(qu)
[docs] @staticmethod def from_Euler_angles(phi: np.ndarray, degrees: bool = False) -> 'Rotation': """ Initialize from Bunge Euler angles. Parameters ---------- phi : numpy.ndarray, shape (...,3) Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]) or (φ_1 ∈ [0,360], ϕ ∈ [0,180], φ_2 ∈ [0,360]) if degrees == True. degrees : bool, optional Euler angles are given in degrees. Defaults to False. Returns ------- new : damask.Rotation Rotation representing the given Bunge Euler angles. Notes ----- Bunge Euler angles correspond to a rotation axis sequence of z–x'–z''. Examples -------- >>> import damask >>> damask.Rotation.from_Euler_angles([180,0,0],degrees=True) array((0., 0., 0., 1.)) """ eu = np.array(phi,dtype=float) if eu.shape[:-2:-1] != (3,): raise ValueError(f'invalid shape: {eu.shape}') eu = np.radians(eu) if degrees else eu if np.any(eu < 0.) or np.any(eu > np.pi*np.array([2.,1.,2.])): with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'): raise ValueError(f'Euler angles outside of [0..2π],[0..π],[0..2π]\n{eu}') return Rotation(Rotation._eu2qu(eu))
[docs] @staticmethod def from_axis_angle(n_omega: np.ndarray, degrees: bool = False, normalize: bool = False, P: Literal[1, -1] = -1) -> 'Rotation': """ Initialize from axis–angle pair. Parameters ---------- n_omega : numpy.ndarray, shape (...,4) Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π] or ω ∈ [0,180] if degrees == True. degrees : bool, optional Angle ω is given in degrees. Defaults to False. normalize : bool, optional Allow ǀnǀ ≠ 1. Defaults to False. P : int ∈ {-1,1}, optional Sign convention. Defaults to -1. Returns ------- new : damask.Rotation Rotation representing the given axis-angle pair. Examples -------- >>> import damask >>> damask.Rotation.from_axis_angle([[0,0,1,90],[1,0,0,90]],degrees=True) array([(0.707, 0. , 0. , 0.707), (0.707, 0.707, 0. , 0. )]) """ ax = np.array(n_omega,dtype=float) if ax.shape[:-2:-1] != (4,): raise ValueError(f'invalid shape: {ax.shape}') if abs(P) != 1: raise ValueError('P ∉ {-1,1}') if P == 1: ax[...,0:3] *= -1 if degrees: ax[..., 3] = np.radians(ax[...,3]) if np.any(ax[...,3] < 0.) or np.any(ax[...,3] > np.pi): with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'): raise ValueError(f'axis–angle rotation angle outside of [0..π]\n{ax}') if normalize: ax[...,0:3] /= np.linalg.norm(ax[...,0:3],axis=-1,keepdims=True) elif not np.allclose(np.linalg.norm(ax[...,0:3],axis=-1),1.): with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'): raise ValueError(f'axis–angle rotation axis is not of unit length\n{ax}') return Rotation(Rotation._ax2qu(ax))
[docs] @staticmethod def from_basis(basis: np.ndarray, orthonormal: bool = True, reciprocal: bool = False) -> 'Rotation': """ Initialize from basis vector triplet. Parameters ---------- basis : numpy.ndarray, shape (...,3,3) Three three-dimensional basis vectors. orthonormal : bool, optional Basis is strictly orthonormal, i.e. is free of stretch components. Defaults to True. reciprocal : bool, optional Basis vectors are given in reciprocal (instead of real) space. Defaults to False. Returns ------- new : damask.Rotation Rotation representing the given basis. """ om = np.array(basis,dtype=float) if om.shape[-2:] != (3,3): raise ValueError(f'invalid shape: {om.shape}') if reciprocal: om = np.linalg.inv(tensor.transpose(om)/np.pi) # transform reciprocal basis set orthonormal = False # contains stretch if not orthonormal: U, _, Vh = np.linalg.svd(om) # singular value decomposition om = np.matmul(U,Vh) elif (np.abs(np.einsum('...i,...i',om[...,0],om[...,1])) > 5.e-8).any() \ or (np.abs(np.einsum('...i,...i',om[...,1],om[...,2])) > 5.e-8).any() \ or (np.abs(np.einsum('...i,...i',om[...,2],om[...,0])) > 5.e-8).any(): with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'): raise ValueError(f'orientation matrix is not orthogonal\n{om}') if not np.allclose(np.linalg.det(om),1.): with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'): raise ValueError(f'orientation matrix has determinant ≠ 1\n{om}') return Rotation(Rotation._om2qu(om))
[docs] @staticmethod def from_matrix(R: np.ndarray, normalize: bool = False) -> 'Rotation': """ Initialize from rotation matrix. Parameters ---------- R : numpy.ndarray, shape (...,3,3) Rotation matrix with det(R) = 1 and R.T ∙ R = I. normalize : bool, optional Rescales rotation matrix to unit determinant. Defaults to False. Returns ------- new : damask.Rotation Rotation representing the given rotation matrix. Examples -------- >>> import damask >>> damask.Rotation.from_matrix([[1,0,0],[0,0,-1],[0,1,0]]) array(( 0.707, -0.707, -0. , -0. )) """ return Rotation.from_basis(np.array(R,dtype=float) * (np.linalg.det(R)**(-1./3.))[...,np.newaxis,np.newaxis] if normalize else R)
[docs] @staticmethod def from_parallel(source: np.ndarray, target: np.ndarray, active: bool = False ) -> 'Rotation': """ Initialize from pairs of two orthogonal basis vectors. Basis vectors are expressed in a common global frame (active == False) or constitute directions that are each expressed in their respective frame (active == True). Parameters ---------- source : numpy.ndarray, shape (...,2,3) First and second three-dimensional vector of orthogonal source basis. target : numpy.ndarray, shape (...,2,3) Corresponding three-dimensional vectors of target basis. active : bool, optional Consider rotations as active, i.e. return (B^-1⋅A) instead of (B⋅A^-1). Defaults to False. Returns ------- new : damask.Rotation Rotation representing the given basis vectors. Notes ----- If rotations $A = [s_1,s_2,s_1 × s_2]^T$ and B = $[t_1,t_2,t_1 × t_2]^T$ are considered "active", the resulting rotation will be $B^{-1}⋅A$ instead of the default result $B⋅A^{-1}$. Use of "active" enables the definition of a rotation based on two specific directions in each coordinate frame being parallel to each other. Examples -------- >>> import damask >>> damask.Rotation.from_parallel([[2,0,0],[0,1,0]],[[1,0,0],[0,2,0]]) array(( 1., 0., 0., 0.)) Direction x and y of the specimen frame are parallel to direction [ 1 1 1 ] and [ 1 -1 0 ] of the crystal frame, respectively. >>> import damask >>> damask.Rotation.from_parallel([[1,0,0],[0,1,0]],[[1,1,1],[1,-1,0]],active=True) array((0.1159169 , 0.88047624, 0.3647052 , 0.27984814)) """ s_ = np.array(source,dtype=float) t_ = np.array(target,dtype=float) if s_.shape[-2:] != (2,3) or t_.shape[-2:] != (2,3): raise ValueError(f'invalid shape: {s_.shape}/{t_.shape}') s_ /= np.linalg.norm(s_,axis=-1,keepdims=True) t_ /= np.linalg.norm(t_,axis=-1,keepdims=True) sm = np.stack([ s_[...,0,:], s_[...,1,:], np.cross(s_[...,0,:],s_[...,1,:]) ],axis=-1 if active else -2) tm = np.stack([ t_[...,0,:], t_[...,1,:], np.cross(t_[...,0,:],t_[...,1,:]) ],axis=-1 if active else -2) return Rotation.from_basis(sm).misorientation(Rotation.from_basis(tm))
[docs] @staticmethod def from_Rodrigues_vector(rho: np.ndarray, normalize: bool = False, P: Literal[1, -1] = -1) -> 'Rotation': """ Initialize from Rodrigues–Frank vector (with angle separated from axis). Parameters ---------- rho : numpy.ndarray, shape (...,4) Rodrigues–Frank vector (n_1, n_2, n_3, tan(ω/2)) with ǀnǀ = 1 and ω ∈ [0,π]. normalize : bool, optional Allow ǀnǀ ≠ 1. Defaults to False. P : int ∈ {-1,1}, optional Sign convention. Defaults to -1. Returns ------- new : damask.Rotation Rotation representing the given Rodrigues–Frank vector. Examples -------- >>> import damask >>> damask.Rotation.from_Rodrigues_vector([0,0,1,1]) array((0.707, 0. , 0. , 0.707)) """ ro = np.array(rho,dtype=float) if ro.shape[:-2:-1] != (4,): raise ValueError(f'invalid shape: {ro}') if abs(P) != 1: raise ValueError('P ∉ {-1,1}') if P == 1: ro[...,0:3] *= -1 if np.any(ro[...,3] < 0.): with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'): raise ValueError(f'Rodrigues vector rotation angle is negative\n{ro}') if normalize: ro[...,0:3] /= np.linalg.norm(ro[...,0:3],axis=-1,keepdims=True) elif not np.allclose(np.linalg.norm(ro[...,0:3],axis=-1),1.): with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'): raise ValueError(f'Rodrigues vector rotation axis is not of unit length\n{ro}') return Rotation(Rotation._ro2qu(ro))
[docs] @staticmethod def from_homochoric(h: np.ndarray, P: Literal[1, -1] = -1) -> 'Rotation': """ Initialize from homochoric vector. Parameters ---------- h : numpy.ndarray, shape (...,3) Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3). P : int ∈ {-1,1}, optional Sign convention. Defaults to -1. Returns ------- new : damask.Rotation Rotation representing the given homochoric vector. """ ho = np.array(h,dtype=float) if ho.shape[:-2:-1] != (3,): raise ValueError(f'invalid shape: {ho.shape}') if abs(P) != 1: raise ValueError('P ∉ {-1,1}') if P == 1: ho *= -1 if np.any(np.linalg.norm(ho,axis=-1) > _R1+1.e-9): with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'): raise ValueError(f'homochoric coordinate outside of the sphere\n{ho}') return Rotation(Rotation._ho2qu(ho))
[docs] @staticmethod def from_cubochoric(x: np.ndarray, P: Literal[1, -1] = -1) -> 'Rotation': """ Initialize from cubochoric vector. Parameters ---------- x : numpy.ndarray, shape (...,3) Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3). P : int ∈ {-1,1}, optional Sign convention. Defaults to -1. Returns ------- new : damask.Rotation Rotation representing the given cubochoric vector. """ cu = np.array(x,dtype=float) if cu.shape[:-2:-1] != (3,): raise ValueError(f'invalid shape: {cu.shape}') if abs(P) != 1: raise ValueError('P ∉ {-1,1}') if np.abs(np.max(cu)) > np.pi**(2./3.) * 0.5+1.e-9: with np.printoptions(threshold=sys.maxsize,precision=16,floatmode='fixed'): raise ValueError(f'cubochoric coordinate outside of the cube\n{cu}') ho = Rotation._cu2ho(cu) if P == -1 else Rotation._cu2ho(cu) * -1 return Rotation(Rotation._ho2qu(ho))
[docs] @staticmethod def from_random(shape: Union[None, int, IntSequence] = None, rng_seed: Optional[NumpyRngSeed] = None) -> 'Rotation': """ Initialize with samples from a uniform distribution. Parameters ---------- shape : (sequence of) int, optional Output shape. Defaults to None, which gives a scalar. rng_seed : {None, int, array_like[ints], SeedSequence, BitGenerator, Generator}, optional A seed to initialize the BitGenerator. Defaults to None, i.e. unpredictable entropy will be pulled from the OS. Returns ------- new : damask.Rotation Random rotation of given shape. """ rng = np.random.default_rng(rng_seed) r = rng.random(3 if shape is None else tuple(shape)+(3,) if hasattr(shape, '__iter__') else (shape,3)) A = np.sqrt(r[...,2]) B = np.sqrt(1.-r[...,2]) q = np.stack([np.cos(2.*np.pi*r[...,0])*A, np.sin(2.*np.pi*r[...,1])*B, np.cos(2.*np.pi*r[...,1])*B, np.sin(2.*np.pi*r[...,0])*A],axis=-1) return Rotation(q[:] if shape is None else q.reshape(r.shape[:-1]+(4,)))._standardize()
[docs] @staticmethod def from_ODF(weights: np.ndarray, phi: np.ndarray, shape: Union[None, int, IntSequence] = None, degrees: bool = False, fractions: bool = True, rng_seed: Optional[NumpyRngSeed] = None) -> 'Rotation': """ Initialize with samples from a binned orientation distribution function (ODF). Parameters ---------- weights : numpy.ndarray, shape (n) Texture intensity values (probability density or volume fraction) at Euler space grid points. phi : numpy.ndarray, shape (n,3) Grid coordinates in Euler space at which weights are defined. shape : (sequence of) int, optional Output shape. Defaults to None, which gives a scalar. degrees : bool, optional Euler space grid coordinates are in degrees. Defaults to True. fractions : bool, optional ODF values correspond to volume fractions, not probability densities. Defaults to True. rng_seed : {None, int, array_like[ints], SeedSequence, BitGenerator, Generator}, optional A seed to initialize the BitGenerator. Defaults to None, i.e. unpredictable entropy will be pulled from the OS. Returns ------- new : damask.Rotation Rotation sampled from given ODF. Notes ----- Due to the distortion of Euler space in the vicinity of ϕ = 0, probability densities, p, defined on grid points with ϕ = 0 will never result in reconstructed orientations as their dV/V = p dγ = p × 0. Hence, it is recommended to transform any such dataset to a cell-centered version, which avoids grid points at ϕ = 0. References ---------- P. Eisenlohr and F. Roters, Computational Materials Science 42(4):670-678, 2008 https://doi.org/10.1016/j.commatsci.2007.09.015 """ def _dg(eu,deg): """Return infinitesimal Euler space volume of bin(s).""" phi_sorted = eu[np.lexsort((eu[:,0],eu[:,1],eu[:,2]))] steps,size,_ = grid_filters.cellsSizeOrigin_coordinates0_point(phi_sorted) delta = np.radians(size/steps) if deg else size/steps return delta[0]*2.*np.sin(delta[1]/2.)*delta[2] / 8. / np.pi**2 * np.sin(np.radians(eu[:,1]) if deg else eu[:,1]) dg = 1. if fractions else _dg(phi,degrees) dV_V = dg * np.maximum(0.,weights.squeeze()) N = 1 if shape is None else np.prod(shape).astype(int) return Rotation.from_Euler_angles(phi[util.hybrid_IA(dV_V,N,rng_seed)],degrees).reshape(() if shape is None else shape)
[docs] @staticmethod def from_spherical_component(center: 'Rotation', sigma: float, shape: Union[None, int, IntSequence] = None, degrees: bool = False, rng_seed: Optional[NumpyRngSeed] = None) -> 'Rotation': """ Initialize with samples from a Gaussian distribution around a given center. Parameters ---------- center : Rotation or Orientation Central rotation. sigma : float Standard deviation of (Gaussian) misorientation distribution. shape : (sequence of) int, optional Output shape. Defaults to None, which gives a scalar. degrees : bool, optional Standard deviation is given in degrees. Defaults to False. rng_seed : {None, int, array_like[ints], SeedSequence, BitGenerator, Generator}, optional A seed to initialize the BitGenerator. Defaults to None, i.e. unpredictable entropy will be pulled from the OS. Returns ------- new : damask.Orientation Rotation sampled from normal distribution around a center. Examples -------- Create a brass texture consisting of 200 orientations: >>> import damask >>> center = damask.Rotation.from_Euler_angles([35.,45.,0.],degrees=True) >>> brass = damask.Rotation.from_spherical_component(center=center,sigma=3.,shape=200,degrees=True) Create a Goss texture consisting of 100 orientations: >>> import damask >>> center = damask.Rotation.from_Euler_angles([0.,45.,0.],degrees=True) >>> goss = damask.Rotation.from_spherical_component(center=center,sigma=3.,shape=100,degrees=True) """ rng = np.random.default_rng(rng_seed) sigma = np.radians(sigma) if degrees else sigma N = 1 if shape is None else np.prod(shape) u,Theta = (rng.random((N,2)) * 2. * np.array([1.,np.pi]) - np.array([1.,0.])).T omega = abs(rng.normal(scale=sigma,size=N)) p = np.column_stack([np.sqrt(1.-u**2)*np.cos(Theta), np.sqrt(1.-u**2)*np.sin(Theta), u, omega]) return Rotation.from_axis_angle(p).reshape(() if shape is None else shape) * center
[docs] @staticmethod def from_fiber_component(crystal: IntSequence, sample: IntSequence, sigma: float = 0., shape: Union[None, int, IntSequence] = None, degrees: bool = False, rng_seed: Optional[NumpyRngSeed] = None) -> 'Rotation': """ Initialize with samples from a Gaussian distribution around a given direction. Parameters ---------- crystal : numpy.ndarray, shape (2) Polar coordinates (polar angle θ from [0 0 1], azimuthal angle φ from [1 0 0]) of fiber direction in crystal frame. sample : numpy.ndarray, shape (2) Polar coordinates (polar angle θ from z, azimuthal angle φ from x) of fiber direction in sample frame. sigma : float, optional Standard deviation of (Gaussian) misorientation distribution. Defaults to 0. shape : (sequence of) int, optional Output shape. Defaults to None, which gives a scalar. degrees : bool, optional Standard deviation and polar coordinates are given in degrees. Defaults to False. rng_seed : {None, int, array_like[ints], SeedSequence, BitGenerator, Generator}, optional A seed to initialize the BitGenerator. Defaults to None, i.e. unpredictable entropy will be pulled from the OS. Returns ------- new : damask.Rotation Rotation sampled from normal distribution around a direction. Notes ----- The crystal direction for (θ=0,φ=0) is [0 0 1]; the sample direction for (θ=0,φ=0) is z. Polar coordinates follow the ISO 80000-2:2019 convention typically used in physics. See https://en.wikipedia.org/wiki/Spherical_coordinate_system. Ranges 0≤θ≤π and 0≤φ≤2π give a unique set of coordinates. The value for sigma should be small enough to avoid values larger than π when sampling from a normal distribution. A typically safe value is σ = π/5 ≈ 36°. Examples -------- Create an ideal bcc α-fiber texture ([1 0 1] ǀǀ x=RD) consisting of 600 orientations: >>> import damask >>> import numpy as np >>> alpha = damask.Rotation.from_fiber_component([np.pi/4.,0.],[np.pi/2.,0.],shape=600) Create an ideal γ-fiber texture ([1 1 1] ǀǀ z=ND) consisting of 250 orientations: >>> import damask >>> gamma = damask.Rotation.from_fiber_component([54.736,45.],[0.,0.],shape=250,degrees=True) Create a relatively strong basal texture ([0 0 0 1] ǀǀ z=ND) consisting of 320 orientations: >>> import damask >>> basal = damask.Rotation.from_fiber_component([0.,0.],[0.,0.],shape=320,sigma=.15) """ rng = np.random.default_rng(rng_seed) sigma_,alpha,beta = (np.radians(c) for c in (sigma,crystal,sample)) if degrees else \ map(np.asarray, (sigma,crystal,sample)) d_cr = np.array([np.sin(alpha[0])*np.cos(alpha[1]), np.sin(alpha[0])*np.sin(alpha[1]), np.cos(alpha[0])]) d_lab = np.array([np.sin( beta[0])*np.cos( beta[1]), np.sin( beta[0])*np.sin( beta[1]), np.cos( beta[0])]) ax_align = np.append(np.cross(d_cr,d_lab), np.arccos(np.dot(d_cr,d_lab))) # align crystal frame direction to sample frame direction if np.isclose(ax_align[3],0.): ax_align[:3] = np.array([1.,0.,0.]) R_align = Rotation.from_axis_angle(ax_align if ax_align[3] > 0. else -ax_align,normalize=True) N = 1 if shape is None else np.prod(shape).astype(int) u,Theta = (rng.random((N,2)) * 2. * np.array([1.,np.pi]) - np.array([1.,0.])).T omega = abs(rng.normal(scale=sigma_,size=N)) p = np.column_stack([np.sqrt(1.-u**2)*np.cos(Theta), np.sqrt(1.-u**2)*np.sin(Theta), u, omega]) p[:,:3] = np.einsum('ij,...j',np.eye(3)-np.outer(d_lab,d_lab),p[:,:3]) # remove component along fiber axis f = np.column_stack((np.broadcast_to(d_lab,(N,3)),rng.random(N)*np.pi)) f[::2,:3] *= -1. # flip half the rotation axes to negative sense return (R_align.broadcast_to(N) * Rotation.from_axis_angle(p,normalize=True) * Rotation.from_axis_angle(f)).reshape(() if shape is None else shape)
#################################################################################################### # Code below available according to the following conditions on https://github.com/MarDiehl/3Drotations #################################################################################################### # Copyright (c) 2017-2020, Martin Diehl/Max-Planck-Institut für Eisenforschung GmbH # Copyright (c) 2013-2014, Marc De Graef/Carnegie Mellon University # All rights reserved. # # Redistribution and use in source and binary forms, with or without modification, are # permitted provided that the following conditions are met: # # - Redistributions of source code must retain the above copyright notice, this list # of conditions and the following disclaimer. # - Redistributions in binary form must reproduce the above copyright notice, this # list of conditions and the following disclaimer in the documentation and/or # other materials provided with the distribution. # - Neither the names of Marc De Graef, Carnegie Mellon University nor the names # of its contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #################################################################################################### #---------- Quaternion ---------- @staticmethod def _qu2om(qu: np.ndarray) -> np.ndarray: """ Quaternion to rotation matrix. Parameters ---------- qu : numpy.ndarray, shape (...,4) Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0. Returns ------- om : numpy.ndarray, shape (...,3,3) Rotation matrix with det(R) = 1 and R.T ∙ R = I. References ---------- E. Bernardes and S. Viollet, PLoS ONE 17(11):e0276302, 2022 https://doi.org/10.1371/journal.pone.0276302 """ qq = qu[...,0:1]**2-(qu[...,1:2]**2 + qu[...,2:3]**2 + qu[...,3:4]**2) om = np.block([qq + 2.*qu[...,1:2]**2, 2.*(qu[...,2:3]*qu[...,1:2]-_P*qu[...,0:1]*qu[...,3:4]), 2.*(qu[...,3:4]*qu[...,1:2]+_P*qu[...,0:1]*qu[...,2:3]), 2.*(qu[...,1:2]*qu[...,2:3]+_P*qu[...,0:1]*qu[...,3:4]), qq + 2.*qu[...,2:3]**2, 2.*(qu[...,3:4]*qu[...,2:3]-_P*qu[...,0:1]*qu[...,1:2]), 2.*(qu[...,1:2]*qu[...,3:4]-_P*qu[...,0:1]*qu[...,2:3]), 2.*(qu[...,2:3]*qu[...,3:4]+_P*qu[...,0:1]*qu[...,1:2]), qq + 2.*qu[...,3:4]**2, ]).reshape(qu.shape[:-1]+(3,3)) return om @staticmethod def _qu2eu(qu: np.ndarray) -> np.ndarray: """ Quaternion to Bunge Euler angles. Parameters ---------- qu : numpy.ndarray, shape (...,4) Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0. Returns ------- eu : numpy.ndarray, shape (...,3) Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]). References ---------- E. Bernardes and S. Viollet, PLoS ONE 17(11):e0276302, 2022 https://doi.org/10.1371/journal.pone.0276302 """ a = qu[...,0:1] b = -_P*qu[...,3:4] c = -_P*qu[...,1:2] d = -_P*qu[...,2:3] eu = np.block([ np.arctan2(b,a), np.arccos(2*(a**2+b**2)/(a**2+b**2+c**2+d**2)-1), np.arctan2(-d,c), ]) eu_sum = eu[...,0] + eu[...,2] eu_diff = eu[...,0] - eu[...,2] is_zero = np.isclose(eu[...,1],0.0) is_pi = np.isclose(eu[...,1],np.pi) is_ok = ~np.logical_or(is_zero,is_pi) eu[...,0][is_zero] = 2*eu[...,0][is_zero] eu[...,0][is_pi] = -2*eu[...,2][is_pi] eu[...,2][~is_ok] = 0.0 eu[...,0][is_ok] = eu_diff[is_ok] eu[...,2][is_ok] = eu_sum [is_ok] eu[np.logical_or(np.abs(eu) < 1.e-6, np.abs(eu-2*np.pi) < 1.e-6)] = 0. return np.where(eu < 0., eu%(np.pi*np.array([2.,1.,2.])),eu) @staticmethod def _qu2ax(qu: np.ndarray) -> np.ndarray: """ Quaternion to axis–angle pair. Modified version of the original formulation, should be numerically more stable. Parameters ---------- qu : numpy.ndarray, shape (...,4) Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0. Returns ------- ax : numpy.ndarray, shape (...,4) Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π]. """ with np.errstate(invalid='ignore',divide='ignore'): s = np.sign(qu[...,0:1])/np.sqrt(qu[...,1:2]**2+qu[...,2:3]**2+qu[...,3:4]**2) omega = 2. * np.arccos(np.clip(qu[...,0:1],-1.,1.)) ax = np.where(np.broadcast_to(qu[...,0:1] < 1.e-8,qu.shape), np.block([qu[...,1:4],np.broadcast_to(np.pi,qu[...,0:1].shape)]), np.block([qu[...,1:4]*s,omega])) ax[np.isclose(qu[...,0],1.,rtol=0.)] = np.array([0.,0.,1.,0.]) return ax @staticmethod def _qu2ro(qu: np.ndarray) -> np.ndarray: """ Quaternion to Rodrigues–Frank vector. Parameters ---------- qu : numpy.ndarray, shape (...,4) Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0. Returns ------- ro : numpy.ndarray, shape (...,4) Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π]. """ with np.errstate(invalid='ignore',divide='ignore'): s = np.linalg.norm(qu[...,1:4],axis=-1,keepdims=True) ro = np.where(np.broadcast_to(np.abs(qu[...,0:1]) < 1.e-12,qu.shape), np.block([qu[...,1:2], qu[...,2:3], qu[...,3:4], np.broadcast_to(np.inf,qu[...,0:1].shape)]), np.block([qu[...,1:2]/s,qu[...,2:3]/s,qu[...,3:4]/s, np.tan(np.arccos(np.clip(qu[...,0:1],-1.,1.))) ]) ) ro[np.abs(s).squeeze(-1) < 1.e-12] = np.array([0.,0.,_P,0.]) return ro @staticmethod def _qu2ho(qu: np.ndarray) -> np.ndarray: """ Quaternion to homochoric vector. Parameters ---------- qu : numpy.ndarray, shape (...,4) Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0. Returns ------- ho : numpy.ndarray, shape (...,3) Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3). """ with np.errstate(invalid='ignore'): omega = 2. * np.arccos(np.clip(qu[...,0:1],-1.,1.)) ho = np.where(np.abs(omega) < 1.e-12, np.zeros(3), qu[...,1:4]/np.linalg.norm(qu[...,1:4],axis=-1,keepdims=True) * (0.75*(omega - np.sin(omega)))**(1./3.)) return ho @staticmethod def _qu2cu(qu: np.ndarray) -> np.ndarray: """ Quaternion to cubochoric vector. Parameters ---------- qu : numpy.ndarray, shape (...,4) Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0. Returns ------- cu : numpy.ndarray, shape (...,3) Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3). """ return Rotation._ho2cu(Rotation._qu2ho(qu)) #---------- Rotation matrix ---------- @staticmethod def _om2qu(om: np.ndarray) -> np.ndarray: """ Rotation matrix to quaternion. This formulation is from www.euclideanspace.com/maths/geometry/rotations/conversions/matrixToQuaternion. The original formulation had issues. Parameters ---------- om : numpy.ndarray, shape (...,3,3) Rotation matrix with det(R) = 1 and R.T ∙ R = I. Returns ------- qu : numpy.ndarray, shape (...,4) Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0. """ trace = om[...,0,0:1] + om[...,1,1:2] + om[...,2,2:3] with np.errstate(invalid='ignore',divide='ignore'): s = np.array([ 0.5 / np.sqrt( 1. + trace), 2. * np.sqrt( 1. + om[...,0,0:1] - om[...,1,1:2] - om[...,2,2:3]), 2. * np.sqrt( 1. + om[...,1,1:2] - om[...,2,2:3] - om[...,0,0:1]), 2. * np.sqrt( 1. + om[...,2,2:3] - om[...,0,0:1] - om[...,1,1:2] ) ]) qu = np.where(trace>0, np.block([0.25 / s[0], (om[...,2,1:2] - om[...,1,2:3] ) * s[0], (om[...,0,2:3] - om[...,2,0:1] ) * s[0], (om[...,1,0:1] - om[...,0,1:2] ) * s[0]]), np.where(om[...,0,0:1] > np.maximum(om[...,1,1:2],om[...,2,2:3]), np.block([(om[...,2,1:2] - om[...,1,2:3]) / s[1], 0.25 * s[1], (om[...,0,1:2] + om[...,1,0:1]) / s[1], (om[...,0,2:3] + om[...,2,0:1]) / s[1]]), np.where(om[...,1,1:2] > om[...,2,2:3], np.block([(om[...,0,2:3] - om[...,2,0:1]) / s[2], (om[...,0,1:2] + om[...,1,0:1]) / s[2], 0.25 * s[2], (om[...,1,2:3] + om[...,2,1:2]) / s[2]]), np.block([(om[...,1,0:1] - om[...,0,1:2]) / s[3], (om[...,0,2:3] + om[...,2,0:1]) / s[3], (om[...,1,2:3] + om[...,2,1:2]) / s[3], 0.25 * s[3]]), ) ) )*np.array([1.,_P,_P,_P]) qu[qu[...,0] < 0.] *= -1. return qu @staticmethod def _om2eu(om: np.ndarray) -> np.ndarray: """ Rotation matrix to Bunge Euler angles. Parameters ---------- om : numpy.ndarray, shape (...,3,3) Rotation matrix with det(R) = 1 and R.T ∙ R = I. Returns ------- eu : numpy.ndarray, shape (...,3) Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]). """ with np.errstate(invalid='ignore',divide='ignore'): zeta = 1./np.sqrt(1.-om[...,2,2:3]**2) eu = np.where(np.isclose(np.abs(om[...,2,2:3]),1.,0.), np.block([np.arctan2(om[...,0,1:2],om[...,0,0:1]), np.pi*0.5*(1.-om[...,2,2:3]), np.zeros(om.shape[:-2]+(1,)), ]), np.block([np.arctan2(om[...,2,0:1]*zeta,-om[...,2,1:2]*zeta), np.arccos( om[...,2,2:3]), np.arctan2(om[...,0,2:3]*zeta,+om[...,1,2:3]*zeta) ]) ) eu[np.abs(eu) < 1.e-8] = 0.0 return np.where(eu < 0., eu%(np.pi*np.array([2.,1.,2.])),eu) @staticmethod def _om2ax(om: np.ndarray) -> np.ndarray: """ Rotation matrix to axis–angle pair. Parameters ---------- om : numpy.ndarray, shape (...,3,3) Rotation matrix with det(R) = 1 and R.T ∙ R = I. Returns ------- ax : numpy.ndarray, shape (...,4) Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π]. """ diag_delta = -_P*np.block([om[...,1,2:3]-om[...,2,1:2], om[...,2,0:1]-om[...,0,2:3], om[...,0,1:2]-om[...,1,0:1] ]) t = 0.5*(om.trace(axis2=-2,axis1=-1) -1.).reshape(om.shape[:-2]+(1,)) w,vr = np.linalg.eig(om) # mask duplicated real eigenvalues w[np.isclose(w[...,0],1.+0.j),1:] = 0. w[np.isclose(w[...,1],1.+0.j),2:] = 0. vr = np.swapaxes(vr,-1,-2) ax = np.where(np.abs(diag_delta)<1.e-13, np.real(vr[np.isclose(w,1.+0.j)]).reshape(om.shape[:-2]+(3,)), np.abs(np.real(vr[np.isclose(w,1.+0.j)]).reshape(om.shape[:-2]+(3,))) *np.sign(diag_delta)) ax = np.block([ax,np.arccos(np.clip(t,-1.,1.))]) ax[np.abs(ax[...,3]) < 1.e-8] = np.array([0.,0.,1.,0.]) return ax @staticmethod def _om2ro(om: np.ndarray) -> np.ndarray: """ Rotation matrix to Rodrigues–Frank vector. Parameters ---------- om : numpy.ndarray, shape (...,3,3) Rotation matrix with det(R) = 1 and R.T ∙ R = I. Returns ------- ro : numpy.ndarray, shape (...,4) Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π]. """ return Rotation._eu2ro(Rotation._om2eu(om)) @staticmethod def _om2ho(om: np.ndarray) -> np.ndarray: """ Rotation matrix to homochoric vector. Parameters ---------- om : numpy.ndarray, shape (...,3,3) Rotation matrix with det(R) = 1 and R.T ∙ R = I. Returns ------- ho : numpy.ndarray, shape (...,3) Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3). """ return Rotation._ax2ho(Rotation._om2ax(om)) @staticmethod def _om2cu(om: np.ndarray) -> np.ndarray: """ Rotation matrix to cubochoric vector. Parameters ---------- om : numpy.ndarray, shape (...,3,3) Rotation matrix with det(R) = 1 and R.T ∙ R = I. Returns ------- cu : numpy.ndarray, shape (...,3) Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3). """ return Rotation._ho2cu(Rotation._om2ho(om)) #---------- Bunge Euler angles ---------- @staticmethod def _eu2qu(eu: np.ndarray) -> np.ndarray: """ Bunge Euler angles to quaternion. Parameters ---------- eu : numpy.ndarray, shape (...,3) Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]). Returns ------- qu : numpy.ndarray, shape (...,4) Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0. """ ee = 0.5*eu cPhi = np.cos(ee[...,1:2]) sPhi = np.sin(ee[...,1:2]) qu = np.block([ cPhi*np.cos(ee[...,0:1]+ee[...,2:3]), -_P*sPhi*np.cos(ee[...,0:1]-ee[...,2:3]), -_P*sPhi*np.sin(ee[...,0:1]-ee[...,2:3]), -_P*cPhi*np.sin(ee[...,0:1]+ee[...,2:3])]) qu[qu[...,0] < 0.] *= -1. return qu @staticmethod def _eu2om(eu: np.ndarray) -> np.ndarray: """ Bunge Euler angles to rotation matrix. Parameters ---------- eu : numpy.ndarray, shape (...,3) Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]). Returns ------- om : numpy.ndarray, shape (...,3,3) Rotation matrix with det(R) = 1 and R.T ∙ R = I. """ c = np.cos(eu) s = np.sin(eu) om = np.block([+c[...,0:1]*c[...,2:3]-s[...,0:1]*s[...,2:3]*c[...,1:2], +s[...,0:1]*c[...,2:3]+c[...,0:1]*s[...,2:3]*c[...,1:2], +s[...,2:3]*s[...,1:2], -c[...,0:1]*s[...,2:3]-s[...,0:1]*c[...,2:3]*c[...,1:2], -s[...,0:1]*s[...,2:3]+c[...,0:1]*c[...,2:3]*c[...,1:2], +c[...,2:3]*s[...,1:2], +s[...,0:1]*s[...,1:2], -c[...,0:1]*s[...,1:2], +c[...,1:2] ]).reshape(eu.shape[:-1]+(3,3)) om[np.abs(om) < 1.e-12] = 0. return om @staticmethod def _eu2ax(eu: np.ndarray) -> np.ndarray: """ Bunge Euler angles to axis–angle pair. Parameters ---------- eu : numpy.ndarray, shape (...,3) Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]). Returns ------- ax : numpy.ndarray, shape (...,4) Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π]. """ t = np.tan(eu[...,1:2]*0.5) sigma = 0.5*(eu[...,0:1]+eu[...,2:3]) delta = 0.5*(eu[...,0:1]-eu[...,2:3]) tau = np.linalg.norm(np.block([t,np.sin(sigma)]),axis=-1,keepdims=True) alpha = np.where(np.abs(np.cos(sigma))<1.e-12,np.pi,2.*np.arctan(tau/np.cos(sigma))) with np.errstate(invalid='ignore',divide='ignore'): ax = np.where(np.broadcast_to(np.abs(alpha)<1.e-12,eu.shape[:-1]+(4,)), [0.,0.,1.,0.], np.block([-_P/tau*t*np.cos(delta), -_P/tau*t*np.sin(delta), -_P/tau* np.sin(sigma), alpha ])) ax[(alpha<0.).squeeze()] *= -1. return ax @staticmethod def _eu2ro(eu: np.ndarray) -> np.ndarray: """ Bunge Euler angles to Rodrigues–Frank vector. Parameters ---------- eu : numpy.ndarray, shape (...,3) Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]). Returns ------- ro : numpy.ndarray, shape (...,4) Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π]. """ ax = Rotation._eu2ax(eu) ro = np.block([ax[...,:3],np.tan(ax[...,3:4]*.5)]) ro[ax[...,3] >= np.pi,3] = np.inf ro[np.abs(ax[...,3])<1.e-16] = np.array([0.,0.,_P,0.]) return ro @staticmethod def _eu2ho(eu: np.ndarray) -> np.ndarray: """ Bunge Euler angles to homochoric vector. Parameters ---------- eu : numpy.ndarray, shape (...,3) Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]). Returns ------- ho : numpy.ndarray, shape (...,3) Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3). """ return Rotation._ax2ho(Rotation._eu2ax(eu)) @staticmethod def _eu2cu(eu: np.ndarray) -> np.ndarray: """ Bunge Euler angles to cubochoric vector. Parameters ---------- eu : numpy.ndarray, shape (...,3) Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]). Returns ------- cu : numpy.ndarray, shape (...,3) Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3). """ return Rotation._ho2cu(Rotation._eu2ho(eu)) #---------- Axis angle pair ---------- @staticmethod def _ax2qu(ax: np.ndarray) -> np.ndarray: """ Axis–angle pair to quaternion. Parameters ---------- ax : numpy.ndarray, shape (...,4) Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π]. Returns ------- qu : numpy.ndarray, shape (...,4) Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0. """ c = np.cos(ax[...,3:4]*.5) s = np.sin(ax[...,3:4]*.5) qu = np.where(np.abs(ax[...,3:4]) < 1.e-6,[1.,0.,0.,0.],np.block([c,ax[...,:3]*s])) return qu @staticmethod def _ax2om(ax: np.ndarray) -> np.ndarray: """ Axis-angle pair to rotation matrix. Parameters ---------- ax : numpy.ndarray, shape (...,4) Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π]. Returns ------- om : numpy.ndarray, shape (...,3,3) Rotation matrix with det(R) = 1 and R.T ∙ R = I. """ c = np.cos(ax[...,3:4]) s = np.sin(ax[...,3:4]) omc = 1.-c om = np.block([c+omc*ax[...,0:1]**2, omc*ax[...,0:1]*ax[...,1:2] + s*ax[...,2:3], omc*ax[...,0:1]*ax[...,2:3] - s*ax[...,1:2], omc*ax[...,0:1]*ax[...,1:2] - s*ax[...,2:3], c+omc*ax[...,1:2]**2, omc*ax[...,1:2]*ax[...,2:3] + s*ax[...,0:1], omc*ax[...,0:1]*ax[...,2:3] + s*ax[...,1:2], omc*ax[...,1:2]*ax[...,2:3] - s*ax[...,0:1], c+omc*ax[...,2:3]**2]).reshape(ax.shape[:-1]+(3,3)) return om if _P < 0. else np.swapaxes(om,-1,-2) @staticmethod def _ax2eu(ax: np.ndarray) -> np.ndarray: """ Rotation matrix to Bunge Euler angles. Parameters ---------- ax : numpy.ndarray, shape (...,4) Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π]. Returns ------- eu : numpy.ndarray, shape (...,3) Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]). """ return Rotation._om2eu(Rotation._ax2om(ax)) @staticmethod def _ax2ro(ax: np.ndarray) -> np.ndarray: """ Axis–angle pair to Rodrigues–Frank vector. Parameters ---------- ax : numpy.ndarray, shape (...,4) Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π]. Returns ------- ro : numpy.ndarray, shape (...,4) Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π]. """ ro = np.block([ax[...,:3], np.where(np.isclose(ax[...,3:4],np.pi,atol=1.e-15,rtol=.0), np.inf, np.tan(ax[...,3:4]*0.5)) ]) ro[np.abs(ax[...,3]) < 1.e-6] = np.array([.0,.0,_P,.0]) return ro @staticmethod def _ax2ho(ax: np.ndarray) -> np.ndarray: """ Axis–angle pair to homochoric vector. Parameters ---------- ax : numpy.ndarray, shape (...,4) Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π]. Returns ------- ho : numpy.ndarray, shape (...,3) Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3). """ f = (0.75 * ( ax[...,3:4] - np.sin(ax[...,3:4]) ))**(1./3.) return ax[...,:3] * f @staticmethod def _ax2cu(ax: np.ndarray) -> np.ndarray: """ Axis–angle pair to cubochoric vector. Parameters ---------- ax : numpy.ndarray, shape (...,4) Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π]. Returns ------- cu : numpy.ndarray, shape (...,3) Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3). """ return Rotation._ho2cu(Rotation._ax2ho(ax)) #---------- Rodrigues-Frank vector ---------- @staticmethod def _ro2qu(ro: np.ndarray) -> np.ndarray: """ Rodrigues–Frank vector to quaternion. Parameters ---------- ro : numpy.ndarray, shape (...,4) Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π]. Returns ------- qu : numpy.ndarray, shape (...,4) Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0. """ return Rotation._ax2qu(Rotation._ro2ax(ro)) @staticmethod def _ro2om(ro: np.ndarray) -> np.ndarray: """ Rodgrigues–Frank vector to rotation matrix. Parameters ---------- ro : numpy.ndarray, shape (...,4) Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π]. Returns ------- om : numpy.ndarray, shape (...,3,3) Rotation matrix with det(R) = 1 and R.T ∙ R = I. """ return Rotation._ax2om(Rotation._ro2ax(ro)) @staticmethod def _ro2eu(ro: np.ndarray) -> np.ndarray: """ Rodrigues–Frank vector to Bunge Euler angles. Parameters ---------- ro : numpy.ndarray, shape (...,4) Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π]. Returns ------- eu : numpy.ndarray, shape (...,3) Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]). """ return Rotation._om2eu(Rotation._ro2om(ro)) @staticmethod def _ro2ax(ro: np.ndarray) -> np.ndarray: """ Rodrigues–Frank vector to axis–angle pair. Parameters ---------- ro : numpy.ndarray, shape (...,4) Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π]. Returns ------- ax : numpy.ndarray, shape (...,4) Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π]. """ with np.errstate(invalid='ignore',divide='ignore'): ax = np.where(np.isfinite(ro[...,3:4]), np.block([ro[...,0:3]*np.linalg.norm(ro[...,0:3],axis=-1,keepdims=True),2.*np.arctan(ro[...,3:4])]), np.block([ro[...,0:3],np.broadcast_to(np.pi,ro[...,3:4].shape)])) ax[np.abs(ro[...,3]) < 1.e-8] = np.array([0.,0.,1.,0.]) return ax @staticmethod def _ro2ho(ro: np.ndarray) -> np.ndarray: """ Rodrigues–Frank vector to homochoric vector. Parameters ---------- ro : numpy.ndarray, shape (...,4) Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π]. Returns ------- ho : numpy.ndarray, shape (...,3) Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3). """ f = np.where(np.isfinite(ro[...,3:4]),2.*np.arctan(ro[...,3:4]) -np.sin(2.*np.arctan(ro[...,3:4])),np.pi) return np.where(np.broadcast_to(np.sum(ro[...,0:3]**2,axis=-1,keepdims=True) < 1.e-8,ro[...,0:3].shape), np.zeros(3), ro[...,0:3]* (0.75*f)**(1./3.)) @staticmethod def _ro2cu(ro: np.ndarray) -> np.ndarray: """ Rodrigues–Frank vector to cubochoric vector. Parameters ---------- ro : numpy.ndarray, shape (...,4) Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π]. Returns ------- cu : numpy.ndarray, shape (...,3) Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3). """ return Rotation._ho2cu(Rotation._ro2ho(ro)) #---------- Homochoric vector---------- @staticmethod def _ho2qu(ho: np.ndarray) -> np.ndarray: """ Homochoric vector to quaternion. Parameters ---------- ho : numpy.ndarray, shape (...,3) Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3). Returns ------- qu : numpy.ndarray, shape (...,4) Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0. """ return Rotation._ax2qu(Rotation._ho2ax(ho)) @staticmethod def _ho2om(ho: np.ndarray) -> np.ndarray: """ Homochoric vector to rotation matrix. Parameters ---------- ho : numpy.ndarray, shape (...,3) Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3). Returns ------- om : numpy.ndarray, shape (...,3,3) Rotation matrix with det(R) = 1 and R.T ∙ R = I. """ return Rotation._ax2om(Rotation._ho2ax(ho)) @staticmethod def _ho2eu(ho: np.ndarray) -> np.ndarray: """ Homochoric vector to Bunge Euler angles. Parameters ---------- ho : numpy.ndarray, shape (...,3) Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3). Returns ------- eu : numpy.ndarray, shape (...,3) Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]). """ return Rotation._ax2eu(Rotation._ho2ax(ho)) @staticmethod def _ho2ax(ho: np.ndarray) -> np.ndarray: """ Homochoric vector to axis–angle pair. Parameters ---------- ho : numpy.ndarray, shape (...,3) Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3). Returns ------- ax : numpy.ndarray, shape (...,4) Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π]. """ tfit = np.array([+0.9999999999999968, -0.49999999999986866, -0.025000000000632055, -0.003928571496460683, -0.0008164666077062752, -0.00019411896443261646, -0.00004985822229871769, -0.000014164962366386031, -1.9000248160936107e-6, -5.72184549898506e-6, +7.772149920658778e-6, -0.00001053483452909705, +9.528014229335313e-6, -5.660288876265125e-6, +1.2844901692764126e-6, +1.1255185726258763e-6, -1.3834391419956455e-6, +7.513691751164847e-7, -2.401996891720091e-7, +4.386887017466388e-8, -3.5917775353564864e-9]) hmag_squared = np.sum(ho**2,axis=-1,keepdims=True) s = np.sum(tfit*hmag_squared**np.arange(len(tfit)),axis=-1,keepdims=True) with np.errstate(invalid='ignore'): return np.where(np.broadcast_to(np.abs(hmag_squared)<1.e-8,ho.shape[:-1]+(4,)), [0.,0.,1.,0.], np.block([ho/np.sqrt(hmag_squared),2.*np.arccos(np.clip(s,-1.,1.))])) @staticmethod def _ho2ro(ho: np.ndarray) -> np.ndarray: """ Homochoric vector to Rodrigues–Frank vector. Parameters ---------- ho : numpy.ndarray, shape (...,3) Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3). Returns ------- ro : numpy.ndarray, shape (...,4) Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π]. """ return Rotation._ax2ro(Rotation._ho2ax(ho)) @staticmethod def _ho2cu(ho: np.ndarray) -> np.ndarray: """ Homochoric vector to cubochoric vector. Parameters ---------- ho : numpy.ndarray, shape (...,3) Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3). Returns ------- cu : numpy.ndarray, shape (...,3) Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3). References ---------- D. Roşca et al., Modelling and Simulation in Materials Science and Engineering 22:075013, 2014 https://doi.org/10.1088/0965-0393/22/7/075013 """ rs = np.linalg.norm(ho,axis=-1,keepdims=True) xyz3 = np.take_along_axis(ho,Rotation._get_pyramid_order(ho,'forward'),-1) with np.errstate(invalid='ignore',divide='ignore'): # inverse M_3 xyz2 = xyz3[...,0:2] * np.sqrt( 2.*rs/(rs+np.abs(xyz3[...,2:3])) ) qxy = np.sum(xyz2**2,axis=-1,keepdims=True) q2 = qxy + np.max(np.abs(xyz2),axis=-1,keepdims=True)**2 sq2 = np.sqrt(q2) q = (_beta/np.sqrt(2.)/_R1) * np.sqrt(q2*qxy/(q2-np.max(np.abs(xyz2),axis=-1,keepdims=True)*sq2)) tt = np.clip((np.min(np.abs(xyz2),axis=-1,keepdims=True)**2\ +np.max(np.abs(xyz2),axis=-1,keepdims=True)*sq2)/np.sqrt(2.)/qxy,-1.,1.) T_inv = np.where(np.abs(xyz2[...,1:2]) <= np.abs(xyz2[...,0:1]), np.block([np.ones_like(tt),np.arccos(tt)/np.pi*12.]), np.block([np.arccos(tt)/np.pi*12.,np.ones_like(tt)]))*q T_inv[xyz2<0.] *= -1. T_inv[np.broadcast_to(np.isclose(qxy,0.,rtol=0.,atol=1.e-12),T_inv.shape)] = 0. cu = np.block([T_inv, np.where(xyz3[...,2:3]<0.,-np.ones_like(xyz3[...,2:3]),np.ones_like(xyz3[...,2:3])) \ * rs/np.sqrt(6./np.pi), ])/ _sc cu[np.isclose(np.sum(np.abs(ho),axis=-1),0.,rtol=0.,atol=1.e-16)] = 0. return np.take_along_axis(cu,Rotation._get_pyramid_order(ho,'backward'),-1) #---------- Cubochoric ---------- @staticmethod def _cu2qu(cu: np.ndarray) -> np.ndarray: """ Cubochoric vector to quaternion. Parameters ---------- cu : numpy.ndarray, shape (...,3) Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3). Returns ------- qu : numpy.ndarray, shape (...,4) Unit quaternion (q_0, q_1, q_2, q_3) in positive real hemisphere, i.e. ǀqǀ = 1 and q_0 ≥ 0. """ return Rotation._ho2qu(Rotation._cu2ho(cu)) @staticmethod def _cu2om(cu: np.ndarray) -> np.ndarray: """ Cubochoric vector to rotation matrix. Parameters ---------- cu : numpy.ndarray, shape (...,3) Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3). Returns ------- om : numpy.ndarray, shape (...,3,3) Rotation matrix with det(R) = 1 and R.T ∙ R = I. """ return Rotation._ho2om(Rotation._cu2ho(cu)) @staticmethod def _cu2eu(cu: np.ndarray) -> np.ndarray: """ Cubochoric vector to Bunge Euler angles. Parameters ---------- cu : numpy.ndarray, shape (...,3) Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3). Returns ------- eu : numpy.ndarray, shape (...,3) Euler angles (φ_1 ∈ [0,2π], ϕ ∈ [0,π], φ_2 ∈ [0,2π]). """ return Rotation._ho2eu(Rotation._cu2ho(cu)) @staticmethod def _cu2ax(cu: np.ndarray) -> np.ndarray: """ Cubochoric vector to axis–angle pair. Parameters ---------- cu : numpy.ndarray, shape (...,3) Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3). Returns ------- ax : numpy.ndarray, shape (...,4) Axis and angle (n_1, n_2, n_3, ω) with ǀnǀ = 1 and ω ∈ [0,π]. """ return Rotation._ho2ax(Rotation._cu2ho(cu)) @staticmethod def _cu2ro(cu: np.ndarray) -> np.ndarray: """ Cubochoric vector to Rodrigues–Frank vector. Parameters ---------- cu : numpy.ndarray, shape (...,3) Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3). Returns ------- ro : numpy.ndarray, shape (...,4) Rodrigues–Frank vector [n_1, n_2, n_3, tan(ω/2)] with ǀnǀ = 1 and ω ∈ [0,π]. """ return Rotation._ho2ro(Rotation._cu2ho(cu)) @staticmethod def _cu2ho(cu: np.ndarray) -> np.ndarray: """ Cubochoric vector to homochoric vector. Parameters ---------- cu : numpy.ndarray, shape (...,3) Cubochoric vector (x_1, x_2, x_3) with max(x_i) < 1/2 π^(2/3). Returns ------- ho : numpy.ndarray, shape (...,3) Homochoric vector (h_1, h_2, h_3) with ǀhǀ < (3π/4)^(1/3). References ---------- D. Roşca et al., Modelling and Simulation in Materials Science and Engineering 22:075013, 2014 https://doi.org/10.1088/0965-0393/22/7/075013 """ with np.errstate(invalid='ignore',divide='ignore'): # get pyramid and scale by grid parameter ratio XYZ = np.take_along_axis(cu,Rotation._get_pyramid_order(cu,'forward'),-1) * _sc order = np.abs(XYZ[...,1:2]) <= np.abs(XYZ[...,0:1]) q = np.pi/12. * np.where(order,XYZ[...,1:2],XYZ[...,0:1]) \ / np.where(order,XYZ[...,0:1],XYZ[...,1:2]) c = np.cos(q) s = np.sin(q) q = _R1*2.**0.25/_beta/ np.sqrt(np.sqrt(2.)-c) \ * np.where(order,XYZ[...,0:1],XYZ[...,1:2]) T = np.block([(np.sqrt(2.)*c - 1.), np.sqrt(2.) * s]) * q # transform to sphere grid (inverse Lambert) c = np.sum(T**2,axis=-1,keepdims=True) s = c * np.pi/24. /XYZ[...,2:3]**2 c = c * np.sqrt(np.pi/24.)/XYZ[...,2:3] q = np.sqrt( 1. - s) ho = np.where(np.isclose(np.sum(np.abs(XYZ[...,0:2]),axis=-1,keepdims=True),0.,rtol=0.,atol=1.e-16), np.block([np.zeros_like(XYZ[...,0:2]),np.sqrt(6./np.pi)*XYZ[...,2:3]]), np.block([np.where(order,T[...,0:1],T[...,1:2])*q, np.where(order,T[...,1:2],T[...,0:1])*q, np.sqrt(6./np.pi) * XYZ[...,2:3] - c]) ) ho[np.isclose(np.sum(np.abs(cu),axis=-1),0.,rtol=0.,atol=1.e-16)] = 0. return np.take_along_axis(ho,Rotation._get_pyramid_order(cu,'backward'),-1) @staticmethod def _get_pyramid_order(xyz: np.ndarray, direction: Literal['forward', 'backward']) -> np.ndarray: """ Get order of the coordinates. Depending on the pyramid in which the point is located, the order need to be adjusted. Parameters ---------- xyz : numpy.ndarray Coordinates of a point on a uniform refinable grid on a ball or in a uniform refinable cubical grid. direction : {'forward', 'backward'} Whether to map from ball to cube or from cube to ball. References ---------- D. Roşca et al., Modelling and Simulation in Materials Science and Engineering 22:075013, 2014 https://doi.org/10.1088/0965-0393/22/7/075013 """ order = {'forward': np.array([[0,1,2],[1,2,0],[2,0,1]]), 'backward':np.array([[0,1,2],[2,0,1],[1,2,0]])} p = np.where(np.maximum(np.abs(xyz[...,0]),np.abs(xyz[...,1])) <= np.abs(xyz[...,2]),0, np.where(np.maximum(np.abs(xyz[...,1]),np.abs(xyz[...,2])) <= np.abs(xyz[...,0]),1,2)) return order[direction][p]