pydrex.diagnostics

PyDRex: Methods to calculate texture and strain diagnostics.

Calculations expect orientation matrices $a$ to represent passive (i.e. alias) rotations, which are defined in terms of the extrinsic ZXZ euler angles $ϕ, θ, φ$ as $$ a = \begin{bmatrix} \cosφ\cosϕ - \cosθ\sinϕ\sinφ & \cosθ\cosϕ\sinφ + \cosφ\sinϕ & \sinφ\sinθ \cr -\sinφ\cosϕ - \cosθ\sinϕ\cosφ & \cosθ\cosϕ\cosφ - \sinφ\sinϕ & \cosφ\sinθ \cr \sinθ\sinϕ & -\sinθ\cosϕ & \cosθ \end{bmatrix} $$ such that a[i, j] gives the direction cosine of the angle between the i-th grain axis and the j-th external axis (in the global Eulerian frame).

  1r"""> PyDRex: Methods to calculate texture and strain diagnostics.
  2
  3.. note::
  4    Calculations expect orientation matrices $a$ to represent passive
  5    (i.e. alias) rotations, which are defined in terms of the extrinsic ZXZ
  6    euler angles $ϕ, θ, φ$ as
  7    $$
  8    a = \begin{bmatrix}
  9            \cosφ\cosϕ - \cosθ\sinϕ\sinφ & \cosθ\cosϕ\sinφ + \cosφ\sinϕ & \sinφ\sinθ \cr
 10           -\sinφ\cosϕ - \cosθ\sinϕ\cosφ & \cosθ\cosϕ\cosφ - \sinφ\sinϕ & \cosφ\sinθ \cr
 11            \sinθ\sinϕ & -\sinθ\cosϕ & \cosθ
 12        \end{bmatrix}
 13    $$
 14    such that a[i, j] gives the direction cosine of the angle between the i-th
 15    grain axis and the j-th external axis (in the global Eulerian frame).
 16
 17"""
 18
 19import functools as ft
 20
 21import numba as nb
 22import numpy as np
 23import scipy.linalg as la
 24
 25from pydrex import geometry as _geo
 26from pydrex import logger as _log
 27from pydrex import stats as _stats
 28from pydrex import tensors as _tensors
 29from pydrex import utils as _utils
 30
 31Pool, HAS_RAY = _utils.import_proc_pool()
 32if HAS_RAY:
 33    import ray
 34
 35    from pydrex import distributed as _dstr
 36
 37
 38def elasticity_components(voigt_matrices: np.ndarray):
 39    """Calculate elasticity decompositions for the given elasticity tensors.
 40
 41    - `voigt_matrices` — the Nx6x6 Voigt matrix representations of the averaged
 42      elasticity tensors for a series of N polycrystal textures
 43
 44    Returns a dictionary with the following data series:
 45    - `'bulk_modulus'` — the computed bulk modulus for each Voigt matrix C
 46    - `'shear_modulus'` — the computed shear modulus for each Voigt matrix C
 47    - `'percent_anisotropy'` — for each Voigt matrix C, the total percentage of the
 48      norm of the elastic tensor ||C|| that deviates from the norm of the "closest"
 49      isotropic elasticity tensor
 50    - `'percent_hexagonal'` — for each C, the percentage of ||C|| attributed to
 51      a hexagonally symmetric bulk medium
 52    - `'percent_tetragonal'` — for each C, the percentage of ||C|| attributed to
 53      a tetragonally symmetric bulk medium
 54    - `'percent_orthorhombic'` — for each C, the percentage of ||C|| attributed to
 55      an orthorhombically symmetric bulk medium
 56    - `'percent_monoclinic'` — for each C, the percentage of ||C|| attributed to
 57      a monoclinically symmetric bulk medium
 58    - `'percent_triclinic'` — for each C, the percentage of ||C|| attributed to
 59      a triclinically "symmetric" bulk medium (no mirror planes)
 60    - `'hexagonal_axis'` — for each C, the axis of hexagonal symmetry for the "closest"
 61      hexagonally symmetric approximation to C, a.k.a. the "transverse isotropy" axis
 62
 63    .. note::
 64        Only 5 symmetry classes are relevant for elasticity decomposition,
 65        compared to the usual 6 used to describe crystal families.
 66        Crystals with cubic symmetry contribute to the isotropic elasticity tensor,
 67        because the lattice spacing is identical in all orthogonal directions.
 68        Note also that the trigonal crystal *system* is not a crystal family
 69        (it belongs to the hexagonal family).
 70
 71    """
 72    n_matrices = len(voigt_matrices)
 73    out = {
 74        "bulk_modulus": np.empty(n_matrices),
 75        "shear_modulus": np.empty(n_matrices),
 76        "percent_anisotropy": np.empty(n_matrices),
 77        "percent_hexagonal": np.empty(n_matrices),
 78        "percent_tetragonal": np.empty(n_matrices),
 79        "percent_orthorhombic": np.empty(n_matrices),
 80        "percent_monoclinic": np.empty(n_matrices),
 81        "percent_triclinic": np.empty(n_matrices),
 82        "hexagonal_axis": np.empty((n_matrices, 3)),
 83    }
 84    for m, matrix in enumerate(voigt_matrices):
 85        voigt_matrix = _tensors.upper_tri_to_symmetric(matrix)
 86        stiffness_dilat, stiffness_deviat = _tensors.voigt_decompose(voigt_matrix)
 87        K = np.trace(stiffness_dilat) / 9  # Bulk modulus
 88        G = (np.trace(stiffness_deviat) - 3 * K) / 10  # Shear modulus
 89        out["bulk_modulus"][m] = K
 90        out["shear_modulus"][m] = G
 91
 92        # Appendix A5, Browaeys & Chevrot, 2004.
 93        # The isotropic stiffness vector is independent of the coordinate system.
 94        isotropic_vector = np.hstack(
 95            (
 96                np.repeat(K + 4 * G / 3, 3),
 97                np.repeat(np.sqrt(2) * (K - 2 * G / 3), 3),
 98                np.repeat(2 * G, 3),
 99                np.repeat(0, 12),
100            )
101        )
102        voigt_vector = _tensors.voigt_matrix_to_vector(voigt_matrix)
103        out["percent_anisotropy"][m] = (
104            la.norm(voigt_vector - isotropic_vector) / la.norm(voigt_vector) * 100
105        )
106
107        # Search for SCCS axes (orthogonal axes defined intrinsically by
108        # eigenstrains of the elastic tensor).
109        unpermuted_SCCS = np.empty((3, 3))
110        eigv_dij = la.eigh(stiffness_dilat)[1]
111        eigv_vij = la.eigh(stiffness_deviat)[1]
112        # Averaging of eigenvectors to get the symmetry axes.
113        for i in range(3):
114            index_vij = 0  # [i(+1?)] of v_ij = eigvect that is nearest to the d_ij one.
115            angle = 10  # Initialise to any number > 2π radians.
116            for j in range(3):
117                # Calculate angle between a pair of eigenvectors.
118                # One eigenvector is taken from v_ij and one from d_ij.
119                # Do not distinguish between vectors in opposite directions.
120                dot_eigvects = np.dot(eigv_dij[:, i], eigv_vij[:, j])
121                angle_eigvects = smallest_angle(eigv_dij[:, i], eigv_vij[:, j])
122                if angle_eigvects < angle:
123                    angle = angle_eigvects
124                    # NOTE: Differences between ASPECT And original implementation:
125                    # - In C++, std::copysign(1.0, 0.0) returns -1.
126                    # - In Fortran 90, sign(1.0, 0.0) returns 1.
127                    # - ASPECT implementation uses 'j' instead of 'j+1', doesn't seem to
128                    # make a difference using the equivalent change for vec_SCSS = ...
129                    index_vij = np.sign(dot_eigvects) * j if dot_eigvects != 0 else j
130                    # index_vij = (
131                    #     np.sign(dot_eigvects) * (j + 1)
132                    #     if dot_eigvects != 0
133                    #     else (j + 1)
134                    # )
135
136            # Add/subtract the nearest v_ij eigenvector multiplied by the signed index,
137            # which effectively rotates the d_ij eigenvector, and then normalise it.
138            vec_SCCS = (
139                eigv_dij[:, i] + index_vij * eigv_vij[:, int(abs(index_vij))]
140            ) / 2
141            # vec_SCSS = (
142            #     eigv_dij[:, i] + index_vij * eigv_vij[:, int(abs(index_vij) - 1)]
143            # ) / 2
144            vec_SCCS /= la.norm(vec_SCCS)
145            unpermuted_SCCS[:, i] = vec_SCCS
146
147        # Determine SCCS permutation that gives best hexagonal approximation.
148        # This is achieved by minimising the "distance" between the voigt vector and its
149        # projection onto the hexagonal symmetry subspace.
150        # The elastic tensor is rotated into this system for decomposition.
151        elastic_tensor = _tensors.voigt_to_elastic_tensor(voigt_matrix)
152        distance = la.norm(voigt_vector)
153        for i in range(3):
154            permuted_SCCS = unpermuted_SCCS[:, [(i + j) % 3 for j in range(3)]]
155            rotated_voigt_matrix = _tensors.elastic_tensor_to_voigt(
156                _tensors.rotate(elastic_tensor, permuted_SCCS.transpose())
157            )
158            rotated_voigt_vector = _tensors.voigt_matrix_to_vector(rotated_voigt_matrix)
159            mono_and_higher_vector = _tensors.mono_project(rotated_voigt_vector)
160            tric_vector = rotated_voigt_vector - mono_and_higher_vector
161            ortho_and_higher_vector = _tensors.ortho_project(mono_and_higher_vector)
162            tetr_and_higher_vector = _tensors.tetr_project(ortho_and_higher_vector)
163            hex_and_higher_vector = _tensors.hex_project(tetr_and_higher_vector)
164            mono_vector = mono_and_higher_vector - ortho_and_higher_vector
165            ortho_vector = ortho_and_higher_vector - tetr_and_higher_vector
166            tetr_vector = tetr_and_higher_vector - hex_and_higher_vector
167            hex_vector = hex_and_higher_vector - isotropic_vector
168
169            δ = la.norm(rotated_voigt_vector - hex_and_higher_vector)
170            if δ < distance:
171                distance = δ
172
173                percent = 100 / la.norm(voigt_vector)
174                # print("\n", _tensors.voigt_vector_to_matrix(hex_vector))
175                out["percent_triclinic"][m] = la.norm(tric_vector) * percent
176                out["percent_monoclinic"][m] = la.norm(mono_vector) * percent
177                out["percent_orthorhombic"][m] = la.norm(ortho_vector) * percent
178                out["percent_tetragonal"][m] = la.norm(tetr_vector) * percent
179                out["percent_hexagonal"][m] = la.norm(hex_vector) * percent
180                # Last SCCS axis is always the hexagonal symmetry axis.
181                out["hexagonal_axis"][m, ...] = permuted_SCCS[:, 2]
182    return out
183
184
185def bingham_average(orientations: np.ndarray, axis: str = "a"):
186    """Compute Bingham average of orientation matrices.
187
188    Returns the antipodally symmetric average orientation
189    of the given crystallographic `axis`, or the a-axis by default.
190    Valid axis specifiers are "a" for [100], "b" for [010] and "c" for [001].
191
192    See also: [Watson (1966)](https://doi.org/10.1086%2F627211),
193    [Mardia & Jupp, “Directional Statistics”](https://doi.org/10.1002/9780470316979).
194
195    """
196    match axis:
197        case "a":
198            row = 0
199        case "b":
200            row = 1
201        case "c":
202            row = 2
203        case _:
204            raise ValueError(f"axis must be 'a', 'b', or 'c', not {axis}")
205
206    # https://courses.eas.ualberta.ca/eas421/lecturepages/orientation.html
207    # Eigenvector corresponding to largest eigenvalue is the mean direction.
208    # SciPy returns eigenvalues in ascending order (same order for vectors).
209    # SciPy uses lower triangular entries by default, no need for all components.
210    mean_vector = la.eigh(_stats._scatter_matrix(np.asarray(orientations), row))[1][
211        :, -1
212    ]
213    return mean_vector / la.norm(mean_vector)
214
215
216def finite_strain(deformation_gradient: np.ndarray, driver="ev"):
217    """Extract measures of finite strain from the deformation gradient.
218
219    Extracts the largest principal strain value and the vector defining the axis of
220    maximum extension (longest semiaxis of the finite strain ellipsoid) from the 3x3
221    deformation gradient tensor.
222
223    """
224    # Get eigenvalues and eigenvectors of the left stretch (Cauchy-Green) tensor.
225    B_λ, B_v = la.eigh(
226        deformation_gradient @ deformation_gradient.transpose(),
227        driver=driver,
228    )
229    # Stretch ratio is sqrt(λ) - 1, the (-1) is so that undeformed strain is 0 not 1.
230    return np.sqrt(B_λ[-1]) - 1, B_v[:, -1]
231
232
233def symmetry_pgr(orientations: np.ndarray, axis="a"):
234    r"""Compute texture symmetry eigenvalue diagnostics from grain orientation matrices.
235
236    Compute Point, Girdle and Random symmetry diagnostics
237    for ternary texture classification.
238    Returns the tuple (P, G, R) where
239    $$
240    \begin{align\*}
241    P &= (λ_{1} - λ_{2}) / N \cr
242    G &= 2 (λ_{2} - λ_{3}) / N \cr
243    R &= 3 λ_{3} / N
244    \end{align\*}
245    $$
246    with $N$ the sum of the eigenvalues $λ_{1} ≥ λ_{2} ≥ λ_{3}$
247    of the scatter (inertia) matrix.
248
249    See e.g. [Vollmer (1990)](https://doi.org/10.1130/0016-7606(1990)102%3C0786:AAOEMT%3E2.3.CO;2).
250
251    """
252    match axis:
253        case "a":
254            row = 0
255        case "b":
256            row = 1
257        case "c":
258            row = 2
259        case _:
260            raise ValueError(f"axis must be 'a', 'b', or 'c', not {axis}")
261
262    scatter = _stats._scatter_matrix(orientations, row)
263    # SciPy uses lower triangular entries by default, no need for all components.
264    eigvals_descending = la.eigvalsh(scatter)[::-1]
265    sum_eigvals = np.sum(eigvals_descending)
266    return (
267        (eigvals_descending[0] - eigvals_descending[1]) / sum_eigvals,
268        2 * (eigvals_descending[1] - eigvals_descending[2]) / sum_eigvals,
269        3 * eigvals_descending[2] / sum_eigvals,
270    )
271
272
273def misorientation_indices(
274    orientation_stack: np.ndarray,
275    system: _geo.LatticeSystem,
276    bins: int | None = None,
277    ncpus: int | None = None,
278    pool=None,
279):
280    """Calculate M-indices for a series of polycrystal textures.
281
282    Calculate M-index using `misorientation_index` for a series of texture snapshots.
283    The `orientation_stack` is a NxMx3x3 array of orientations where N is the number of
284    texture snapshots and M is the number of grains.
285
286    Uses either Ray or the Python multiprocessing library to calculate texture indices
287    for multiple snapshots simultaneously. The arguments `ncpus` and `pool` are only
288    relevant to the latter option: if `ncpus` is `None` the number of CPU cores to use
289    is chosen automatically based on the maximum number available to the Python
290    interpreter, otherwise the specified number of cores is requested. Alternatively, an
291    existing instance of `multiprocessing.Pool` can be provided.
292
293    If Ray is installed, it will be automatically preferred. In this case, the number of
294    parallel workers should be set upon initialisation of the Ray cluster (which can be
295    distributed over the network).
296
297    See `misorientation_index` for documentation of the remaining arguments.
298
299    """
300    if ncpus is not None and pool is not None:
301        _log.warning("ignoring `ncpus` argument because a Pool was provided")
302    m_indices = np.empty(len(orientation_stack))
303    _run = ft.partial(
304        misorientation_index,
305        system=system,
306        bins=bins,
307    )
308    if pool is None:
309        if ncpus is None:
310            ncpus = _utils.default_ncpus()
311        with Pool(processes=ncpus) as pool:
312            for i, out in enumerate(pool.imap(_run, orientation_stack)):
313                m_indices[i] = out
314    else:
315        if HAS_RAY:
316            m_indices = np.array(
317                ray.get(
318                    [
319                        _dstr.misorientation_index.remote(
320                            ray.put(a), system=system, bins=bins
321                        )
322                        for a in orientation_stack
323                    ]
324                )
325            )
326        else:
327            for i, out in enumerate(pool.imap(_run, orientation_stack)):
328                m_indices[i] = out
329    return m_indices
330
331
332def misorientation_index(
333    orientations: np.ndarray, system: _geo.LatticeSystem, bins: int | None = None
334):
335    r"""Calculate M-index for polycrystal orientations.
336
337    The `bins` argument is passed to `numpy.histogram`.
338    If left as `None`, 1° bins will be used as recommended by the reference paper.
339    The `symmetry` argument specifies the lattice system which determines intrinsic
340    symmetry degeneracies and the maximum allowable misorientation angle.
341    See `_geo.LatticeSystem` for supported systems.
342
343    .. warning::
344        This method must be able to allocate an array of shape
345        $ \frac{N!}{2(N-2)!}× M^{2} $
346        for N the length of `orientations` and M the number of symmetry operations for
347        the given `system`.
348
349    See [Skemer et al. (2005)](https://doi.org/10.1016/j.tecto.2005.08.023).
350
351    """
352    θmax = _stats._max_misorientation(system)
353    misorientations_count, bin_edges = _stats.misorientation_hist(
354        orientations, system, bins
355    )
356    # Compute counts of theoretical misorientation for an isotropic aggregate,
357    # using the same bins (Skemer 2005 recommend 1° bins).
358    misorientations_theory = np.array(
359        [
360            _stats.misorientations_random(bin_edges[i], bin_edges[i + 1], system)
361            for i in range(len(misorientations_count))
362        ]
363    )
364    # Equation 2 in Skemer 2005.
365    return (θmax / (2 * len(misorientations_count))) * np.sum(
366        np.abs(misorientations_theory - misorientations_count)
367    )
368
369
370def coaxial_index(orientations: np.ndarray, axis1: str = "b", axis2: str = "a"):
371    r"""Calculate coaxial “BA” index for a combination of two crystal axes.
372
373    The BA index of [Mainprice et al. (2015)](https://doi.org/10.1144/SP409.8)
374    is derived from the eigenvalue `symmetry` diagnostics and measures point vs girdle
375    symmetry in the aggregate. $BA ∈ [0, 1]$ where $BA = 0$ corresponds to a perfect
376    axial girdle texture and $BA = 1$ represents a point symmetry texture assuming that
377    the random component $R$ is negligible. May be less susceptible to random
378    fluctuations compared to the raw eigenvalue diagnostics.
379
380    """
381    P1, G1, _ = symmetry_pgr(orientations, axis=axis1)
382    P2, G2, _ = symmetry_pgr(orientations, axis=axis2)
383    return 0.5 * (2 - (P1 / (G1 + P1)) - (G2 / (G2 + P2)))
384
385
386@nb.njit(fastmath=True)
387def smallest_angle(
388    vector: np.ndarray, axis: np.ndarray, plane: np.ndarray | None = None
389):
390    """Get smallest angle between a unit `vector` and a bidirectional `axis`.
391
392    The axis is specified using either of its two parallel unit vectors.
393    Optionally project the vector onto the `plane` (given by its unit normal)
394    before calculating the angle.
395
396    Examples:
397
398    >>> import numpy as np
399    >>> def f64(x): return np.asarray(x, dtype=np.float64)
400    >>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([1e0, 0e0, 0e0])))
401    0
402    >>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([0e0, 1e0, 0e0])))
403    90
404    >>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([0e0, -1e0, 0e0])))
405    90
406    >>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([np.sqrt(2), np.sqrt(2), 0e0])))
407    45
408    >>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([-np.sqrt(2), np.sqrt(2), 0e0])))
409    45
410
411    """
412    if plane is not None:
413        _vector = vector - plane * np.dot(vector, plane)
414    else:
415        _vector = vector
416    angle = np.rad2deg(
417        np.arccos(
418            np.clip(
419                np.asarray(  # https://github.com/numba/numba/issues/3469
420                    np.dot(_vector, axis)
421                    / (np.linalg.norm(_vector) * np.linalg.norm(axis))
422                ),
423                -1,
424                1,
425            )
426        )
427    )
428    if angle > 90:
429        return 180 - angle
430    return angle
def elasticity_components(voigt_matrices: numpy.ndarray):
 39def elasticity_components(voigt_matrices: np.ndarray):
 40    """Calculate elasticity decompositions for the given elasticity tensors.
 41
 42    - `voigt_matrices` — the Nx6x6 Voigt matrix representations of the averaged
 43      elasticity tensors for a series of N polycrystal textures
 44
 45    Returns a dictionary with the following data series:
 46    - `'bulk_modulus'` — the computed bulk modulus for each Voigt matrix C
 47    - `'shear_modulus'` — the computed shear modulus for each Voigt matrix C
 48    - `'percent_anisotropy'` — for each Voigt matrix C, the total percentage of the
 49      norm of the elastic tensor ||C|| that deviates from the norm of the "closest"
 50      isotropic elasticity tensor
 51    - `'percent_hexagonal'` — for each C, the percentage of ||C|| attributed to
 52      a hexagonally symmetric bulk medium
 53    - `'percent_tetragonal'` — for each C, the percentage of ||C|| attributed to
 54      a tetragonally symmetric bulk medium
 55    - `'percent_orthorhombic'` — for each C, the percentage of ||C|| attributed to
 56      an orthorhombically symmetric bulk medium
 57    - `'percent_monoclinic'` — for each C, the percentage of ||C|| attributed to
 58      a monoclinically symmetric bulk medium
 59    - `'percent_triclinic'` — for each C, the percentage of ||C|| attributed to
 60      a triclinically "symmetric" bulk medium (no mirror planes)
 61    - `'hexagonal_axis'` — for each C, the axis of hexagonal symmetry for the "closest"
 62      hexagonally symmetric approximation to C, a.k.a. the "transverse isotropy" axis
 63
 64    .. note::
 65        Only 5 symmetry classes are relevant for elasticity decomposition,
 66        compared to the usual 6 used to describe crystal families.
 67        Crystals with cubic symmetry contribute to the isotropic elasticity tensor,
 68        because the lattice spacing is identical in all orthogonal directions.
 69        Note also that the trigonal crystal *system* is not a crystal family
 70        (it belongs to the hexagonal family).
 71
 72    """
 73    n_matrices = len(voigt_matrices)
 74    out = {
 75        "bulk_modulus": np.empty(n_matrices),
 76        "shear_modulus": np.empty(n_matrices),
 77        "percent_anisotropy": np.empty(n_matrices),
 78        "percent_hexagonal": np.empty(n_matrices),
 79        "percent_tetragonal": np.empty(n_matrices),
 80        "percent_orthorhombic": np.empty(n_matrices),
 81        "percent_monoclinic": np.empty(n_matrices),
 82        "percent_triclinic": np.empty(n_matrices),
 83        "hexagonal_axis": np.empty((n_matrices, 3)),
 84    }
 85    for m, matrix in enumerate(voigt_matrices):
 86        voigt_matrix = _tensors.upper_tri_to_symmetric(matrix)
 87        stiffness_dilat, stiffness_deviat = _tensors.voigt_decompose(voigt_matrix)
 88        K = np.trace(stiffness_dilat) / 9  # Bulk modulus
 89        G = (np.trace(stiffness_deviat) - 3 * K) / 10  # Shear modulus
 90        out["bulk_modulus"][m] = K
 91        out["shear_modulus"][m] = G
 92
 93        # Appendix A5, Browaeys & Chevrot, 2004.
 94        # The isotropic stiffness vector is independent of the coordinate system.
 95        isotropic_vector = np.hstack(
 96            (
 97                np.repeat(K + 4 * G / 3, 3),
 98                np.repeat(np.sqrt(2) * (K - 2 * G / 3), 3),
 99                np.repeat(2 * G, 3),
100                np.repeat(0, 12),
101            )
102        )
103        voigt_vector = _tensors.voigt_matrix_to_vector(voigt_matrix)
104        out["percent_anisotropy"][m] = (
105            la.norm(voigt_vector - isotropic_vector) / la.norm(voigt_vector) * 100
106        )
107
108        # Search for SCCS axes (orthogonal axes defined intrinsically by
109        # eigenstrains of the elastic tensor).
110        unpermuted_SCCS = np.empty((3, 3))
111        eigv_dij = la.eigh(stiffness_dilat)[1]
112        eigv_vij = la.eigh(stiffness_deviat)[1]
113        # Averaging of eigenvectors to get the symmetry axes.
114        for i in range(3):
115            index_vij = 0  # [i(+1?)] of v_ij = eigvect that is nearest to the d_ij one.
116            angle = 10  # Initialise to any number > 2π radians.
117            for j in range(3):
118                # Calculate angle between a pair of eigenvectors.
119                # One eigenvector is taken from v_ij and one from d_ij.
120                # Do not distinguish between vectors in opposite directions.
121                dot_eigvects = np.dot(eigv_dij[:, i], eigv_vij[:, j])
122                angle_eigvects = smallest_angle(eigv_dij[:, i], eigv_vij[:, j])
123                if angle_eigvects < angle:
124                    angle = angle_eigvects
125                    # NOTE: Differences between ASPECT And original implementation:
126                    # - In C++, std::copysign(1.0, 0.0) returns -1.
127                    # - In Fortran 90, sign(1.0, 0.0) returns 1.
128                    # - ASPECT implementation uses 'j' instead of 'j+1', doesn't seem to
129                    # make a difference using the equivalent change for vec_SCSS = ...
130                    index_vij = np.sign(dot_eigvects) * j if dot_eigvects != 0 else j
131                    # index_vij = (
132                    #     np.sign(dot_eigvects) * (j + 1)
133                    #     if dot_eigvects != 0
134                    #     else (j + 1)
135                    # )
136
137            # Add/subtract the nearest v_ij eigenvector multiplied by the signed index,
138            # which effectively rotates the d_ij eigenvector, and then normalise it.
139            vec_SCCS = (
140                eigv_dij[:, i] + index_vij * eigv_vij[:, int(abs(index_vij))]
141            ) / 2
142            # vec_SCSS = (
143            #     eigv_dij[:, i] + index_vij * eigv_vij[:, int(abs(index_vij) - 1)]
144            # ) / 2
145            vec_SCCS /= la.norm(vec_SCCS)
146            unpermuted_SCCS[:, i] = vec_SCCS
147
148        # Determine SCCS permutation that gives best hexagonal approximation.
149        # This is achieved by minimising the "distance" between the voigt vector and its
150        # projection onto the hexagonal symmetry subspace.
151        # The elastic tensor is rotated into this system for decomposition.
152        elastic_tensor = _tensors.voigt_to_elastic_tensor(voigt_matrix)
153        distance = la.norm(voigt_vector)
154        for i in range(3):
155            permuted_SCCS = unpermuted_SCCS[:, [(i + j) % 3 for j in range(3)]]
156            rotated_voigt_matrix = _tensors.elastic_tensor_to_voigt(
157                _tensors.rotate(elastic_tensor, permuted_SCCS.transpose())
158            )
159            rotated_voigt_vector = _tensors.voigt_matrix_to_vector(rotated_voigt_matrix)
160            mono_and_higher_vector = _tensors.mono_project(rotated_voigt_vector)
161            tric_vector = rotated_voigt_vector - mono_and_higher_vector
162            ortho_and_higher_vector = _tensors.ortho_project(mono_and_higher_vector)
163            tetr_and_higher_vector = _tensors.tetr_project(ortho_and_higher_vector)
164            hex_and_higher_vector = _tensors.hex_project(tetr_and_higher_vector)
165            mono_vector = mono_and_higher_vector - ortho_and_higher_vector
166            ortho_vector = ortho_and_higher_vector - tetr_and_higher_vector
167            tetr_vector = tetr_and_higher_vector - hex_and_higher_vector
168            hex_vector = hex_and_higher_vector - isotropic_vector
169
170            δ = la.norm(rotated_voigt_vector - hex_and_higher_vector)
171            if δ < distance:
172                distance = δ
173
174                percent = 100 / la.norm(voigt_vector)
175                # print("\n", _tensors.voigt_vector_to_matrix(hex_vector))
176                out["percent_triclinic"][m] = la.norm(tric_vector) * percent
177                out["percent_monoclinic"][m] = la.norm(mono_vector) * percent
178                out["percent_orthorhombic"][m] = la.norm(ortho_vector) * percent
179                out["percent_tetragonal"][m] = la.norm(tetr_vector) * percent
180                out["percent_hexagonal"][m] = la.norm(hex_vector) * percent
181                # Last SCCS axis is always the hexagonal symmetry axis.
182                out["hexagonal_axis"][m, ...] = permuted_SCCS[:, 2]
183    return out

Calculate elasticity decompositions for the given elasticity tensors.

  • voigt_matrices — the Nx6x6 Voigt matrix representations of the averaged elasticity tensors for a series of N polycrystal textures

Returns a dictionary with the following data series:

  • 'bulk_modulus' — the computed bulk modulus for each Voigt matrix C
  • 'shear_modulus' — the computed shear modulus for each Voigt matrix C
  • 'percent_anisotropy' — for each Voigt matrix C, the total percentage of the norm of the elastic tensor ||C|| that deviates from the norm of the "closest" isotropic elasticity tensor
  • 'percent_hexagonal' — for each C, the percentage of ||C|| attributed to a hexagonally symmetric bulk medium
  • 'percent_tetragonal' — for each C, the percentage of ||C|| attributed to a tetragonally symmetric bulk medium
  • 'percent_orthorhombic' — for each C, the percentage of ||C|| attributed to an orthorhombically symmetric bulk medium
  • 'percent_monoclinic' — for each C, the percentage of ||C|| attributed to a monoclinically symmetric bulk medium
  • 'percent_triclinic' — for each C, the percentage of ||C|| attributed to a triclinically "symmetric" bulk medium (no mirror planes)
  • 'hexagonal_axis' — for each C, the axis of hexagonal symmetry for the "closest" hexagonally symmetric approximation to C, a.k.a. the "transverse isotropy" axis

Only 5 symmetry classes are relevant for elasticity decomposition, compared to the usual 6 used to describe crystal families. Crystals with cubic symmetry contribute to the isotropic elasticity tensor, because the lattice spacing is identical in all orthogonal directions. Note also that the trigonal crystal system is not a crystal family (it belongs to the hexagonal family).

def bingham_average(orientations: numpy.ndarray, axis: str = 'a'):
186def bingham_average(orientations: np.ndarray, axis: str = "a"):
187    """Compute Bingham average of orientation matrices.
188
189    Returns the antipodally symmetric average orientation
190    of the given crystallographic `axis`, or the a-axis by default.
191    Valid axis specifiers are "a" for [100], "b" for [010] and "c" for [001].
192
193    See also: [Watson (1966)](https://doi.org/10.1086%2F627211),
194    [Mardia & Jupp, “Directional Statistics”](https://doi.org/10.1002/9780470316979).
195
196    """
197    match axis:
198        case "a":
199            row = 0
200        case "b":
201            row = 1
202        case "c":
203            row = 2
204        case _:
205            raise ValueError(f"axis must be 'a', 'b', or 'c', not {axis}")
206
207    # https://courses.eas.ualberta.ca/eas421/lecturepages/orientation.html
208    # Eigenvector corresponding to largest eigenvalue is the mean direction.
209    # SciPy returns eigenvalues in ascending order (same order for vectors).
210    # SciPy uses lower triangular entries by default, no need for all components.
211    mean_vector = la.eigh(_stats._scatter_matrix(np.asarray(orientations), row))[1][
212        :, -1
213    ]
214    return mean_vector / la.norm(mean_vector)

Compute Bingham average of orientation matrices.

Returns the antipodally symmetric average orientation of the given crystallographic axis, or the a-axis by default. Valid axis specifiers are "a" for [100], "b" for [010] and "c" for [001].

See also: Watson (1966), Mardia & Jupp, “Directional Statistics”.

def finite_strain(deformation_gradient: numpy.ndarray, driver='ev'):
217def finite_strain(deformation_gradient: np.ndarray, driver="ev"):
218    """Extract measures of finite strain from the deformation gradient.
219
220    Extracts the largest principal strain value and the vector defining the axis of
221    maximum extension (longest semiaxis of the finite strain ellipsoid) from the 3x3
222    deformation gradient tensor.
223
224    """
225    # Get eigenvalues and eigenvectors of the left stretch (Cauchy-Green) tensor.
226    B_λ, B_v = la.eigh(
227        deformation_gradient @ deformation_gradient.transpose(),
228        driver=driver,
229    )
230    # Stretch ratio is sqrt(λ) - 1, the (-1) is so that undeformed strain is 0 not 1.
231    return np.sqrt(B_λ[-1]) - 1, B_v[:, -1]

Extract measures of finite strain from the deformation gradient.

Extracts the largest principal strain value and the vector defining the axis of maximum extension (longest semiaxis of the finite strain ellipsoid) from the 3x3 deformation gradient tensor.

def symmetry_pgr(orientations: numpy.ndarray, axis='a'):
234def symmetry_pgr(orientations: np.ndarray, axis="a"):
235    r"""Compute texture symmetry eigenvalue diagnostics from grain orientation matrices.
236
237    Compute Point, Girdle and Random symmetry diagnostics
238    for ternary texture classification.
239    Returns the tuple (P, G, R) where
240    $$
241    \begin{align\*}
242    P &= (λ_{1} - λ_{2}) / N \cr
243    G &= 2 (λ_{2} - λ_{3}) / N \cr
244    R &= 3 λ_{3} / N
245    \end{align\*}
246    $$
247    with $N$ the sum of the eigenvalues $λ_{1} ≥ λ_{2} ≥ λ_{3}$
248    of the scatter (inertia) matrix.
249
250    See e.g. [Vollmer (1990)](https://doi.org/10.1130/0016-7606(1990)102%3C0786:AAOEMT%3E2.3.CO;2).
251
252    """
253    match axis:
254        case "a":
255            row = 0
256        case "b":
257            row = 1
258        case "c":
259            row = 2
260        case _:
261            raise ValueError(f"axis must be 'a', 'b', or 'c', not {axis}")
262
263    scatter = _stats._scatter_matrix(orientations, row)
264    # SciPy uses lower triangular entries by default, no need for all components.
265    eigvals_descending = la.eigvalsh(scatter)[::-1]
266    sum_eigvals = np.sum(eigvals_descending)
267    return (
268        (eigvals_descending[0] - eigvals_descending[1]) / sum_eigvals,
269        2 * (eigvals_descending[1] - eigvals_descending[2]) / sum_eigvals,
270        3 * eigvals_descending[2] / sum_eigvals,
271    )

Compute texture symmetry eigenvalue diagnostics from grain orientation matrices.

Compute Point, Girdle and Random symmetry diagnostics for ternary texture classification. Returns the tuple (P, G, R) where $$ \begin{align*} P &= (λ_{1} - λ_{2}) / N \cr G &= 2 (λ_{2} - λ_{3}) / N \cr R &= 3 λ_{3} / N \end{align*} $$ with $N$ the sum of the eigenvalues $λ_{1} ≥ λ_{2} ≥ λ_{3}$ of the scatter (inertia) matrix.

See e.g. Vollmer (1990).

def misorientation_indices( orientation_stack: numpy.ndarray, system: pydrex.geometry.LatticeSystem, bins: int | None = None, ncpus: int | None = None, pool=None):
274def misorientation_indices(
275    orientation_stack: np.ndarray,
276    system: _geo.LatticeSystem,
277    bins: int | None = None,
278    ncpus: int | None = None,
279    pool=None,
280):
281    """Calculate M-indices for a series of polycrystal textures.
282
283    Calculate M-index using `misorientation_index` for a series of texture snapshots.
284    The `orientation_stack` is a NxMx3x3 array of orientations where N is the number of
285    texture snapshots and M is the number of grains.
286
287    Uses either Ray or the Python multiprocessing library to calculate texture indices
288    for multiple snapshots simultaneously. The arguments `ncpus` and `pool` are only
289    relevant to the latter option: if `ncpus` is `None` the number of CPU cores to use
290    is chosen automatically based on the maximum number available to the Python
291    interpreter, otherwise the specified number of cores is requested. Alternatively, an
292    existing instance of `multiprocessing.Pool` can be provided.
293
294    If Ray is installed, it will be automatically preferred. In this case, the number of
295    parallel workers should be set upon initialisation of the Ray cluster (which can be
296    distributed over the network).
297
298    See `misorientation_index` for documentation of the remaining arguments.
299
300    """
301    if ncpus is not None and pool is not None:
302        _log.warning("ignoring `ncpus` argument because a Pool was provided")
303    m_indices = np.empty(len(orientation_stack))
304    _run = ft.partial(
305        misorientation_index,
306        system=system,
307        bins=bins,
308    )
309    if pool is None:
310        if ncpus is None:
311            ncpus = _utils.default_ncpus()
312        with Pool(processes=ncpus) as pool:
313            for i, out in enumerate(pool.imap(_run, orientation_stack)):
314                m_indices[i] = out
315    else:
316        if HAS_RAY:
317            m_indices = np.array(
318                ray.get(
319                    [
320                        _dstr.misorientation_index.remote(
321                            ray.put(a), system=system, bins=bins
322                        )
323                        for a in orientation_stack
324                    ]
325                )
326            )
327        else:
328            for i, out in enumerate(pool.imap(_run, orientation_stack)):
329                m_indices[i] = out
330    return m_indices

Calculate M-indices for a series of polycrystal textures.

Calculate M-index using misorientation_index for a series of texture snapshots. The orientation_stack is a NxMx3x3 array of orientations where N is the number of texture snapshots and M is the number of grains.

Uses either Ray or the Python multiprocessing library to calculate texture indices for multiple snapshots simultaneously. The arguments ncpus and pool are only relevant to the latter option: if ncpus is None the number of CPU cores to use is chosen automatically based on the maximum number available to the Python interpreter, otherwise the specified number of cores is requested. Alternatively, an existing instance of multiprocessing.Pool can be provided.

If Ray is installed, it will be automatically preferred. In this case, the number of parallel workers should be set upon initialisation of the Ray cluster (which can be distributed over the network).

See misorientation_index for documentation of the remaining arguments.

def misorientation_index( orientations: numpy.ndarray, system: pydrex.geometry.LatticeSystem, bins: int | None = None):
333def misorientation_index(
334    orientations: np.ndarray, system: _geo.LatticeSystem, bins: int | None = None
335):
336    r"""Calculate M-index for polycrystal orientations.
337
338    The `bins` argument is passed to `numpy.histogram`.
339    If left as `None`, 1° bins will be used as recommended by the reference paper.
340    The `symmetry` argument specifies the lattice system which determines intrinsic
341    symmetry degeneracies and the maximum allowable misorientation angle.
342    See `_geo.LatticeSystem` for supported systems.
343
344    .. warning::
345        This method must be able to allocate an array of shape
346        $ \frac{N!}{2(N-2)!}× M^{2} $
347        for N the length of `orientations` and M the number of symmetry operations for
348        the given `system`.
349
350    See [Skemer et al. (2005)](https://doi.org/10.1016/j.tecto.2005.08.023).
351
352    """
353    θmax = _stats._max_misorientation(system)
354    misorientations_count, bin_edges = _stats.misorientation_hist(
355        orientations, system, bins
356    )
357    # Compute counts of theoretical misorientation for an isotropic aggregate,
358    # using the same bins (Skemer 2005 recommend 1° bins).
359    misorientations_theory = np.array(
360        [
361            _stats.misorientations_random(bin_edges[i], bin_edges[i + 1], system)
362            for i in range(len(misorientations_count))
363        ]
364    )
365    # Equation 2 in Skemer 2005.
366    return (θmax / (2 * len(misorientations_count))) * np.sum(
367        np.abs(misorientations_theory - misorientations_count)
368    )

Calculate M-index for polycrystal orientations.

The bins argument is passed to numpy.histogram. If left as None, 1° bins will be used as recommended by the reference paper. The symmetry argument specifies the lattice system which determines intrinsic symmetry degeneracies and the maximum allowable misorientation angle. See _geo.LatticeSystem for supported systems.

This method must be able to allocate an array of shape $ \frac{N!}{2(N-2)!}× M^{2} $ for N the length of orientations and M the number of symmetry operations for the given system.

See Skemer et al. (2005).

def coaxial_index(orientations: numpy.ndarray, axis1: str = 'b', axis2: str = 'a'):
371def coaxial_index(orientations: np.ndarray, axis1: str = "b", axis2: str = "a"):
372    r"""Calculate coaxial “BA” index for a combination of two crystal axes.
373
374    The BA index of [Mainprice et al. (2015)](https://doi.org/10.1144/SP409.8)
375    is derived from the eigenvalue `symmetry` diagnostics and measures point vs girdle
376    symmetry in the aggregate. $BA ∈ [0, 1]$ where $BA = 0$ corresponds to a perfect
377    axial girdle texture and $BA = 1$ represents a point symmetry texture assuming that
378    the random component $R$ is negligible. May be less susceptible to random
379    fluctuations compared to the raw eigenvalue diagnostics.
380
381    """
382    P1, G1, _ = symmetry_pgr(orientations, axis=axis1)
383    P2, G2, _ = symmetry_pgr(orientations, axis=axis2)
384    return 0.5 * (2 - (P1 / (G1 + P1)) - (G2 / (G2 + P2)))

Calculate coaxial “BA” index for a combination of two crystal axes.

The BA index of Mainprice et al. (2015) is derived from the eigenvalue symmetry diagnostics and measures point vs girdle symmetry in the aggregate. $BA ∈ [0, 1]$ where $BA = 0$ corresponds to a perfect axial girdle texture and $BA = 1$ represents a point symmetry texture assuming that the random component $R$ is negligible. May be less susceptible to random fluctuations compared to the raw eigenvalue diagnostics.

@nb.njit(fastmath=True)
def smallest_angle( vector: numpy.ndarray, axis: numpy.ndarray, plane: numpy.ndarray | None = None):
387@nb.njit(fastmath=True)
388def smallest_angle(
389    vector: np.ndarray, axis: np.ndarray, plane: np.ndarray | None = None
390):
391    """Get smallest angle between a unit `vector` and a bidirectional `axis`.
392
393    The axis is specified using either of its two parallel unit vectors.
394    Optionally project the vector onto the `plane` (given by its unit normal)
395    before calculating the angle.
396
397    Examples:
398
399    >>> import numpy as np
400    >>> def f64(x): return np.asarray(x, dtype=np.float64)
401    >>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([1e0, 0e0, 0e0])))
402    0
403    >>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([0e0, 1e0, 0e0])))
404    90
405    >>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([0e0, -1e0, 0e0])))
406    90
407    >>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([np.sqrt(2), np.sqrt(2), 0e0])))
408    45
409    >>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([-np.sqrt(2), np.sqrt(2), 0e0])))
410    45
411
412    """
413    if plane is not None:
414        _vector = vector - plane * np.dot(vector, plane)
415    else:
416        _vector = vector
417    angle = np.rad2deg(
418        np.arccos(
419            np.clip(
420                np.asarray(  # https://github.com/numba/numba/issues/3469
421                    np.dot(_vector, axis)
422                    / (np.linalg.norm(_vector) * np.linalg.norm(axis))
423                ),
424                -1,
425                1,
426            )
427        )
428    )
429    if angle > 90:
430        return 180 - angle
431    return angle

Get smallest angle between a unit vector and a bidirectional axis.

The axis is specified using either of its two parallel unit vectors. Optionally project the vector onto the plane (given by its unit normal) before calculating the angle.

Examples:

>>> import numpy as np
>>> def f64(x): return np.asarray(x, dtype=np.float64)
>>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([1e0, 0e0, 0e0])))
0
>>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([0e0, 1e0, 0e0])))
90
>>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([0e0, -1e0, 0e0])))
90
>>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([np.sqrt(2), np.sqrt(2), 0e0])))
45
>>> int(smallest_angle(f64([1e0, 0e0, 0e0]), f64([-np.sqrt(2), np.sqrt(2), 0e0])))
45