pydrex.stats

PyDRex: Statistical methods for orientation and elasticity data.

  1"""> PyDRex: Statistical methods for orientation and elasticity data."""
  2
  3import itertools as it
  4
  5import numpy as np
  6import scipy.special as sp
  7from scipy.spatial.transform import Rotation
  8
  9from pydrex import geometry as _geo
 10from pydrex import stats as _stats
 11from pydrex import utils as _utils
 12
 13
 14def resample_orientations(
 15    orientations: np.ndarray,
 16    fractions: np.ndarray,
 17    n_samples: int | None = None,
 18    seed: int | None = None,
 19) -> tuple[np.ndarray, np.ndarray]:
 20    """Return new samples from `orientations` weighted by the volume distribution.
 21
 22    - `orientations` — NxMx3x3 array of orientations
 23    - `fractions` — NxM array of grain volume fractions
 24    - `n_samples` — optional number of samples to return, default is M
 25    - `seed` — optional seed for the random number generator, which is used to pick
 26      random grain volume samples from the discrete distribution
 27
 28    Returns the Nx`n_samples`x3x3 orientations and associated sorted (ascending) grain
 29    volumes.
 30
 31    """
 32    # Allow lists of Rotation.as_matrix() inputs.
 33    _orientations = np.asarray(orientations)
 34    _fractions = np.asarray(fractions)
 35    # Fail early to prevent possibly expensive data processing on incorrect data arrays.
 36    if (
 37        len(_orientations.shape) != 4
 38        or len(_fractions.shape) != 2
 39        or _orientations.shape[0] != _fractions.shape[0]
 40        or _orientations.shape[1] != _fractions.shape[1]
 41        or _orientations.shape[2] != _orientations.shape[3] != 3
 42    ):
 43        raise ValueError(
 44            "invalid shape of input arrays,"
 45            + f" got orientations of shape {_orientations.shape}"
 46            + f" and fractions of shape {_fractions.shape}"
 47        )
 48    rng = np.random.default_rng(seed=seed)
 49    if n_samples is None:
 50        n_samples = _fractions.shape[1]
 51    out_orientations = np.empty((len(_fractions), n_samples, 3, 3))
 52    out_fractions = np.empty((len(_fractions), n_samples))
 53    for i, (frac, orient) in enumerate(zip(_fractions, _orientations, strict=True)):
 54        sort_ascending = np.argsort(frac)
 55        # Cumulative volume fractions.
 56        frac_ascending = frac[sort_ascending]
 57        cumfrac = frac_ascending.cumsum()
 58        # Force cumfrac[-1] to be equal to sum(frac_ascending) i.e. 1.
 59        cumfrac[-1] = 1.0
 60        # Number of new samples with volume less than each cumulative fraction.
 61        count_less = np.searchsorted(cumfrac, rng.random(n_samples))
 62        out_orientations[i, ...] = orient[sort_ascending][count_less]
 63        out_fractions[i, ...] = frac_ascending[count_less]
 64    return out_orientations, out_fractions
 65
 66
 67def _scatter_matrix(orientations, row):
 68    # Lower triangular part of the symmetric scatter (inertia) matrix,
 69    # see eq. 2.4 in Watson 1966 or eq. 9.2.10 in Mardia & Jupp 2009 (with n = 1),
 70    # it's a summation of the outer product of the [h, k, l] vector with itself,
 71    # so taking the row assumes that `orientations` are passive rotations of the
 72    # reference frame [h, k, l] vector.
 73    scatter = np.zeros((3, 3))
 74    scatter[0, 0] = np.sum(orientations[:, row, 0] ** 2)
 75    scatter[1, 1] = np.sum(orientations[:, row, 1] ** 2)
 76    scatter[2, 2] = np.sum(orientations[:, row, 2] ** 2)
 77    scatter[1, 0] = np.sum(orientations[:, row, 0] * orientations[:, row, 1])
 78    scatter[2, 0] = np.sum(orientations[:, row, 0] * orientations[:, row, 2])
 79    scatter[2, 1] = np.sum(orientations[:, row, 1] * orientations[:, row, 2])
 80    return scatter
 81
 82
 83def misorientation_hist(
 84    orientations: np.ndarray, system: _geo.LatticeSystem, bins: int | None = None
 85):
 86    r"""Calculate misorientation histogram for polycrystal orientations.
 87
 88    The `bins` argument is passed to `numpy.histogram`.
 89    If left as `None`, 1° bins will be used as recommended by the reference paper.
 90    The `symmetry` argument specifies the lattice system which determines intrinsic
 91    symmetry degeneracies and the maximum allowable misorientation angle.
 92    See `_geo.LatticeSystem` for supported systems.
 93
 94    .. warning::
 95        This method must be able to allocate $ \frac{N!}{(N-2)!} × 4M $ floats
 96        for N the length of `orientations` and M the number of symmetry operations for
 97        the given `system` (`numpy.float32` values are used to reduce the memory
 98        requirements)
 99
100    See [Skemer et al. (2005)](https://doi.org/10.1016/j.tecto.2005.08.023).
101
102    """
103    symmetry_ops = _geo.symmetry_operations(system)
104    # Compute and bin misorientation angles from orientation data.
105    q1_array = np.empty(
106        (sp.comb(len(orientations), 2, exact=True), len(symmetry_ops), 4),
107        dtype=np.float32,
108    )
109    q2_array = np.empty(
110        (sp.comb(len(orientations), 2, exact=True), len(symmetry_ops), 4),
111        dtype=np.float32,
112    )
113    for i, e in enumerate(  # Copy is required for proper object referencing in Ray.
114        it.combinations(Rotation.from_matrix(orientations.copy()).as_quat(), 2)
115    ):
116        q1, q2 = list(e)
117        for j, qs in enumerate(symmetry_ops):
118            if qs.shape == (4, 4):  # Reflection, not a proper rotation.
119                q1_array[i, j] = qs @ q1
120                q2_array[i, j] = qs @ q2
121            else:
122                q1_array[i, j] = _utils.quat_product(qs, q1)
123                q2_array[i, j] = _utils.quat_product(qs, q2)
124
125    misorientations_data = _geo.misorientation_angles(q1_array, q2_array)
126    θmax = _stats._max_misorientation(system)
127    return np.histogram(misorientations_data, bins=θmax, range=(0, θmax), density=True)
128
129
130def misorientations_random(low: float, high: float, system: _geo.LatticeSystem):
131    """Get expected count of misorientation angles for an isotropic aggregate.
132
133    Estimate the expected number of misorientation angles between grains
134    that would fall within $($`low`, `high`$)$ in degrees for an aggregate
135    with randomly oriented grains, where `low` $∈ [0, $`high`$)$,
136    and `high` is bounded by the maximum theoretical misorientation angle
137    for the given lattice symmetry system.
138    See `_geo.LatticeSystem` for supported systems.
139
140    """
141    # TODO: Add cubic system: [Handscomb 1958](https://doi.org/10.4153/CJM-1958-010-0)
142    max_θ = _max_misorientation(system)
143    M, N = system.value
144    if not 0 <= low <= high <= max_θ:
145        raise ValueError(
146            f"bounds must obey `low` ∈ [0, `high`) and `high` < {max_θ}.\n"
147            + f"You've supplied (`low`, `high`) = ({low}, {high})."
148        )
149
150    counts_low = 0  # Number of counts at the lower bin edge.
151    counts_high = 0  # ... at the higher bin edge.
152    counts_both = [counts_low, counts_high]
153
154    # Some constant factors.
155    a = np.tan(np.deg2rad(90 / M))
156    b = 2 * np.rad2deg(np.arctan(np.sqrt(1 + a**2)))
157    c = round(2 * np.rad2deg(np.arctan(np.sqrt(1 + 2 * a**2))))
158
159    for i, edgeval in enumerate([low, high]):
160        d = np.deg2rad(edgeval)
161
162        if 0 <= edgeval <= (180 / M):
163            counts_both[i] += (N / 180) * (1 - np.cos(d))
164
165        elif (180 / M) <= edgeval <= (180 * M / N):
166            counts_both[i] += (N / 180) * a * np.sin(d)
167
168        elif 90 <= edgeval <= b:
169            counts_both[i] += (M / 90) * ((M + a) * np.sin(d) - M * (1 - np.cos(d)))
170
171        elif b <= edgeval <= c:
172            ν = np.tan(np.deg2rad(edgeval / 2)) ** 2
173
174            counts_both[i] = (M / 90) * (
175                (M + a) * np.sin(d)
176                - M * (1 - np.cos(d))
177                + (M / 180)
178                * (
179                    (1 - np.cos(d))
180                    * (
181                        np.rad2deg(
182                            np.arccos((1 - ν * np.cos(np.deg2rad(180 / M))) / (ν - 1))
183                        )
184                        + 2
185                        * np.rad2deg(
186                            np.arccos(a / (np.sqrt(ν - a**2) * np.sqrt(ν - 1)))
187                        )
188                    )
189                    - 2
190                    * np.sin(d)
191                    * (
192                        2 * np.rad2deg(np.arccos(a / np.sqrt(ν - 1)))
193                        + a * np.rad2deg(np.arccos(1 / np.sqrt(ν - a**2)))
194                    )
195                )
196            )
197        else:
198            assert False  # Should never happen.
199
200    return np.sum(counts_both) / 2
201
202
203def _max_misorientation(system: _geo.LatticeSystem):
204    # Maximum misorientation angle for two grains of the given lattice symmetry system.
205    s = _geo.LatticeSystem
206    match system:
207        case s.orthorhombic | s.rhombohedral:
208            max_θ = 120
209        case s.tetragonal | s.hexagonal:
210            max_θ = 90
211        case s.triclinic | s.monoclinic:
212            max_θ = 180
213        case _:
214            raise ValueError(f"unsupported lattice system: {system}")
215    return max_θ
216
217
218def point_density(
219    x_data,
220    y_data,
221    z_data,
222    gridsteps=101,
223    weights=1,
224    kernel="linear_inverse_kamb",
225    axial=True,
226    **kwargs,
227):
228    """Estimate point density of orientation data on the unit sphere.
229
230    Estimates the density of orientations on the unit sphere by counting the input data
231    that falls within small areas around a uniform grid of spherical counting locations.
232    The input data is expected in cartesian coordinates, and the contouring is performed
233    using kernel functions defined in [Vollmer 1995](https://doi.org/10.1016/0098-3004(94)00058-3).
234    The following optional parameters control the contouring method:
235    - `gridsteps` (int) — the number of steps, i.e. number of points along a diameter of
236        the spherical counting grid
237    - `weights` (array) — auxiliary weights for each data point
238    - `kernel` (string) — the name of the kernel function to use, see
239      `SPHERICAL_COUNTING_KERNELS`
240    - `axial` (bool) — toggle axial versions of the kernel functions
241        (for crystallographic data this should normally be kept as `True`)
242
243    Any other keyword arguments are passed to the kernel function calls.
244    Most kernels accept a parameter `σ` to control the degree of smoothing.
245
246    """
247    if kernel not in SPHERICAL_COUNTING_KERNELS:
248        raise ValueError(f"kernel '{kernel}' is not supported")
249    weights = np.asarray(weights, dtype=np.float64)
250
251    # Create a grid of counters on a cylinder.
252    ρ_grid, h_grid = np.mgrid[-np.pi : np.pi : gridsteps * 1j, -1 : 1 : gridsteps * 1j]
253    # Project onto the sphere using the equal-area projection with centre at (0, 0).
254    λ_grid = ρ_grid
255    ϕ_grid = np.arcsin(h_grid)
256    x_counters, y_counters, z_counters = _geo.to_cartesian(
257        np.pi / 2 - λ_grid.ravel(), np.pi / 2 - ϕ_grid.ravel()
258    )
259
260    # Basically, we can't model this as a convolution as we're not in Euclidean space,
261    # so we have to iterate through and call the kernel function at each "counter".
262    data = np.column_stack([x_data, y_data, z_data])
263    counters = np.column_stack([x_counters, y_counters, z_counters])
264    totals = np.empty(counters.shape[0])
265    for i, counter in enumerate(counters):
266        products = np.dot(data, counter)
267        if axial:
268            products = np.abs(products)
269        density, scale = SPHERICAL_COUNTING_KERNELS[kernel](
270            products, axial=axial, **kwargs
271        )
272        density *= weights
273        totals[i] = (density.sum() - 0.5) / scale
274
275    X_counters, Y_counters = _geo.lambert_equal_area(x_counters, y_counters, z_counters)
276
277    # Normalise to mean, which estimates the density for a "uniform" distribution.
278    totals /= totals.mean()
279    totals[totals < 0] = 0
280    # print(totals.min(), totals.mean(), totals.max())
281    return (
282        np.reshape(X_counters, ρ_grid.shape),
283        np.reshape(Y_counters, ρ_grid.shape),
284        np.reshape(totals, ρ_grid.shape),
285    )
286
287
288def _kamb_radius(n, σ, axial):
289    """Radius of kernel for Kamb-style smoothing."""
290    r = σ**2 / (float(n) + σ**2)
291    if axial is True:
292        return 1 - r
293    return 1 - 2 * r
294
295
296def _kamb_units(n, radius):
297    """Normalization function for Kamb-style counting."""
298    return np.sqrt(n * radius * (1 - radius))
299
300
301def exponential_kamb(cos_dist, σ=10, axial=True):
302    """Kernel function from Vollmer 1995 for exponential smoothing."""
303    n = float(cos_dist.size)
304    if axial:
305        f = 2 * (1.0 + n / σ**2)
306        units = np.sqrt(n * (f / 2.0 - 1) / f**2)
307    else:
308        f = 1 + n / σ**2
309        units = np.sqrt(n * (f - 1) / (4 * f**2))
310
311    count = np.exp(f * (cos_dist - 1))
312    return count, units
313
314
315def linear_inverse_kamb(cos_dist, σ=10, axial=True):
316    """Kernel function from Vollmer 1995 for linear smoothing."""
317    n = float(cos_dist.size)
318    radius = _kamb_radius(n, σ, axial=axial)
319    f = 2 / (1 - radius)
320    cos_dist = cos_dist[cos_dist >= radius]
321    count = f * (cos_dist - radius)
322    return count, _kamb_units(n, radius)
323
324
325def square_inverse_kamb(cos_dist, σ=10, axial=True):
326    """Kernel function from Vollmer 1995 for inverse square smoothing."""
327    n = float(cos_dist.size)
328    radius = _kamb_radius(n, σ, axial=axial)
329    f = 3 / (1 - radius) ** 2
330    cos_dist = cos_dist[cos_dist >= radius]
331    count = f * (cos_dist - radius) ** 2
332    return count, _kamb_units(n, radius)
333
334
335def kamb_count(cos_dist, σ=10, axial=True):
336    """Original Kamb 1959 kernel function (raw count within radius)."""
337    n = float(cos_dist.size)
338    dist = _kamb_radius(n, σ, axial=axial)
339    count = (cos_dist >= dist).astype(float)
340    return count, _kamb_units(n, dist)
341
342
343def schmidt_count(cos_dist, axial=None):
344    """Schmidt (a.k.a. 1%) counting kernel function."""
345    radius = 0.01
346    count = ((1 - cos_dist) <= radius).astype(float)
347    # To offset the count.sum() - 0.5 required for the kamb methods...
348    count = 0.5 / count.size + count
349    return count, (cos_dist.size * radius)
350
351
352SPHERICAL_COUNTING_KERNELS = {
353    "kamb_count": kamb_count,
354    "schmidt_count": schmidt_count,
355    "exponential_kamb": exponential_kamb,
356    "linear_inverse_kamb": linear_inverse_kamb,
357    "square_inverse_kamb": square_inverse_kamb,
358}
359"""Kernel functions that return an un-summed distribution and a normalization factor.
360
361Supported kernel functions are based on the discussion in
362[Vollmer 1995](https://doi.org/10.1016/0098-3004(94)00058-3).
363Kamb methods accept the parameter `σ` (default: 10) to control the degree of smoothing.
364Values lower than 3 and higher than 20 are not recommended.
365
366"""
def resample_orientations( orientations: numpy.ndarray, fractions: numpy.ndarray, n_samples: int | None = None, seed: int | None = None) -> tuple[numpy.ndarray, numpy.ndarray]:
15def resample_orientations(
16    orientations: np.ndarray,
17    fractions: np.ndarray,
18    n_samples: int | None = None,
19    seed: int | None = None,
20) -> tuple[np.ndarray, np.ndarray]:
21    """Return new samples from `orientations` weighted by the volume distribution.
22
23    - `orientations` — NxMx3x3 array of orientations
24    - `fractions` — NxM array of grain volume fractions
25    - `n_samples` — optional number of samples to return, default is M
26    - `seed` — optional seed for the random number generator, which is used to pick
27      random grain volume samples from the discrete distribution
28
29    Returns the Nx`n_samples`x3x3 orientations and associated sorted (ascending) grain
30    volumes.
31
32    """
33    # Allow lists of Rotation.as_matrix() inputs.
34    _orientations = np.asarray(orientations)
35    _fractions = np.asarray(fractions)
36    # Fail early to prevent possibly expensive data processing on incorrect data arrays.
37    if (
38        len(_orientations.shape) != 4
39        or len(_fractions.shape) != 2
40        or _orientations.shape[0] != _fractions.shape[0]
41        or _orientations.shape[1] != _fractions.shape[1]
42        or _orientations.shape[2] != _orientations.shape[3] != 3
43    ):
44        raise ValueError(
45            "invalid shape of input arrays,"
46            + f" got orientations of shape {_orientations.shape}"
47            + f" and fractions of shape {_fractions.shape}"
48        )
49    rng = np.random.default_rng(seed=seed)
50    if n_samples is None:
51        n_samples = _fractions.shape[1]
52    out_orientations = np.empty((len(_fractions), n_samples, 3, 3))
53    out_fractions = np.empty((len(_fractions), n_samples))
54    for i, (frac, orient) in enumerate(zip(_fractions, _orientations, strict=True)):
55        sort_ascending = np.argsort(frac)
56        # Cumulative volume fractions.
57        frac_ascending = frac[sort_ascending]
58        cumfrac = frac_ascending.cumsum()
59        # Force cumfrac[-1] to be equal to sum(frac_ascending) i.e. 1.
60        cumfrac[-1] = 1.0
61        # Number of new samples with volume less than each cumulative fraction.
62        count_less = np.searchsorted(cumfrac, rng.random(n_samples))
63        out_orientations[i, ...] = orient[sort_ascending][count_less]
64        out_fractions[i, ...] = frac_ascending[count_less]
65    return out_orientations, out_fractions

Return new samples from orientations weighted by the volume distribution.

  • orientations — NxMx3x3 array of orientations
  • fractions — NxM array of grain volume fractions
  • n_samples — optional number of samples to return, default is M
  • seed — optional seed for the random number generator, which is used to pick random grain volume samples from the discrete distribution

Returns the Nxn_samplesx3x3 orientations and associated sorted (ascending) grain volumes.

def misorientation_hist( orientations: numpy.ndarray, system: pydrex.geometry.LatticeSystem, bins: int | None = None):
 84def misorientation_hist(
 85    orientations: np.ndarray, system: _geo.LatticeSystem, bins: int | None = None
 86):
 87    r"""Calculate misorientation histogram for polycrystal orientations.
 88
 89    The `bins` argument is passed to `numpy.histogram`.
 90    If left as `None`, 1° bins will be used as recommended by the reference paper.
 91    The `symmetry` argument specifies the lattice system which determines intrinsic
 92    symmetry degeneracies and the maximum allowable misorientation angle.
 93    See `_geo.LatticeSystem` for supported systems.
 94
 95    .. warning::
 96        This method must be able to allocate $ \frac{N!}{(N-2)!} × 4M $ floats
 97        for N the length of `orientations` and M the number of symmetry operations for
 98        the given `system` (`numpy.float32` values are used to reduce the memory
 99        requirements)
100
101    See [Skemer et al. (2005)](https://doi.org/10.1016/j.tecto.2005.08.023).
102
103    """
104    symmetry_ops = _geo.symmetry_operations(system)
105    # Compute and bin misorientation angles from orientation data.
106    q1_array = np.empty(
107        (sp.comb(len(orientations), 2, exact=True), len(symmetry_ops), 4),
108        dtype=np.float32,
109    )
110    q2_array = np.empty(
111        (sp.comb(len(orientations), 2, exact=True), len(symmetry_ops), 4),
112        dtype=np.float32,
113    )
114    for i, e in enumerate(  # Copy is required for proper object referencing in Ray.
115        it.combinations(Rotation.from_matrix(orientations.copy()).as_quat(), 2)
116    ):
117        q1, q2 = list(e)
118        for j, qs in enumerate(symmetry_ops):
119            if qs.shape == (4, 4):  # Reflection, not a proper rotation.
120                q1_array[i, j] = qs @ q1
121                q2_array[i, j] = qs @ q2
122            else:
123                q1_array[i, j] = _utils.quat_product(qs, q1)
124                q2_array[i, j] = _utils.quat_product(qs, q2)
125
126    misorientations_data = _geo.misorientation_angles(q1_array, q2_array)
127    θmax = _stats._max_misorientation(system)
128    return np.histogram(misorientations_data, bins=θmax, range=(0, θmax), density=True)

Calculate misorientation histogram 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 $ \frac{N!}{(N-2)!} × 4M $ floats for N the length of orientations and M the number of symmetry operations for the given system (numpy.float32 values are used to reduce the memory requirements)

See Skemer et al. (2005).

def misorientations_random(low: float, high: float, system: pydrex.geometry.LatticeSystem):
131def misorientations_random(low: float, high: float, system: _geo.LatticeSystem):
132    """Get expected count of misorientation angles for an isotropic aggregate.
133
134    Estimate the expected number of misorientation angles between grains
135    that would fall within $($`low`, `high`$)$ in degrees for an aggregate
136    with randomly oriented grains, where `low` $∈ [0, $`high`$)$,
137    and `high` is bounded by the maximum theoretical misorientation angle
138    for the given lattice symmetry system.
139    See `_geo.LatticeSystem` for supported systems.
140
141    """
142    # TODO: Add cubic system: [Handscomb 1958](https://doi.org/10.4153/CJM-1958-010-0)
143    max_θ = _max_misorientation(system)
144    M, N = system.value
145    if not 0 <= low <= high <= max_θ:
146        raise ValueError(
147            f"bounds must obey `low` ∈ [0, `high`) and `high` < {max_θ}.\n"
148            + f"You've supplied (`low`, `high`) = ({low}, {high})."
149        )
150
151    counts_low = 0  # Number of counts at the lower bin edge.
152    counts_high = 0  # ... at the higher bin edge.
153    counts_both = [counts_low, counts_high]
154
155    # Some constant factors.
156    a = np.tan(np.deg2rad(90 / M))
157    b = 2 * np.rad2deg(np.arctan(np.sqrt(1 + a**2)))
158    c = round(2 * np.rad2deg(np.arctan(np.sqrt(1 + 2 * a**2))))
159
160    for i, edgeval in enumerate([low, high]):
161        d = np.deg2rad(edgeval)
162
163        if 0 <= edgeval <= (180 / M):
164            counts_both[i] += (N / 180) * (1 - np.cos(d))
165
166        elif (180 / M) <= edgeval <= (180 * M / N):
167            counts_both[i] += (N / 180) * a * np.sin(d)
168
169        elif 90 <= edgeval <= b:
170            counts_both[i] += (M / 90) * ((M + a) * np.sin(d) - M * (1 - np.cos(d)))
171
172        elif b <= edgeval <= c:
173            ν = np.tan(np.deg2rad(edgeval / 2)) ** 2
174
175            counts_both[i] = (M / 90) * (
176                (M + a) * np.sin(d)
177                - M * (1 - np.cos(d))
178                + (M / 180)
179                * (
180                    (1 - np.cos(d))
181                    * (
182                        np.rad2deg(
183                            np.arccos((1 - ν * np.cos(np.deg2rad(180 / M))) / (ν - 1))
184                        )
185                        + 2
186                        * np.rad2deg(
187                            np.arccos(a / (np.sqrt(ν - a**2) * np.sqrt(ν - 1)))
188                        )
189                    )
190                    - 2
191                    * np.sin(d)
192                    * (
193                        2 * np.rad2deg(np.arccos(a / np.sqrt(ν - 1)))
194                        + a * np.rad2deg(np.arccos(1 / np.sqrt(ν - a**2)))
195                    )
196                )
197            )
198        else:
199            assert False  # Should never happen.
200
201    return np.sum(counts_both) / 2

Get expected count of misorientation angles for an isotropic aggregate.

Estimate the expected number of misorientation angles between grains that would fall within $($low, high$)$ in degrees for an aggregate with randomly oriented grains, where low $∈ [0, $high$)$, and high is bounded by the maximum theoretical misorientation angle for the given lattice symmetry system. See _geo.LatticeSystem for supported systems.

def point_density( x_data, y_data, z_data, gridsteps=101, weights=1, kernel='linear_inverse_kamb', axial=True, **kwargs):
219def point_density(
220    x_data,
221    y_data,
222    z_data,
223    gridsteps=101,
224    weights=1,
225    kernel="linear_inverse_kamb",
226    axial=True,
227    **kwargs,
228):
229    """Estimate point density of orientation data on the unit sphere.
230
231    Estimates the density of orientations on the unit sphere by counting the input data
232    that falls within small areas around a uniform grid of spherical counting locations.
233    The input data is expected in cartesian coordinates, and the contouring is performed
234    using kernel functions defined in [Vollmer 1995](https://doi.org/10.1016/0098-3004(94)00058-3).
235    The following optional parameters control the contouring method:
236    - `gridsteps` (int) — the number of steps, i.e. number of points along a diameter of
237        the spherical counting grid
238    - `weights` (array) — auxiliary weights for each data point
239    - `kernel` (string) — the name of the kernel function to use, see
240      `SPHERICAL_COUNTING_KERNELS`
241    - `axial` (bool) — toggle axial versions of the kernel functions
242        (for crystallographic data this should normally be kept as `True`)
243
244    Any other keyword arguments are passed to the kernel function calls.
245    Most kernels accept a parameter `σ` to control the degree of smoothing.
246
247    """
248    if kernel not in SPHERICAL_COUNTING_KERNELS:
249        raise ValueError(f"kernel '{kernel}' is not supported")
250    weights = np.asarray(weights, dtype=np.float64)
251
252    # Create a grid of counters on a cylinder.
253    ρ_grid, h_grid = np.mgrid[-np.pi : np.pi : gridsteps * 1j, -1 : 1 : gridsteps * 1j]
254    # Project onto the sphere using the equal-area projection with centre at (0, 0).
255    λ_grid = ρ_grid
256    ϕ_grid = np.arcsin(h_grid)
257    x_counters, y_counters, z_counters = _geo.to_cartesian(
258        np.pi / 2 - λ_grid.ravel(), np.pi / 2 - ϕ_grid.ravel()
259    )
260
261    # Basically, we can't model this as a convolution as we're not in Euclidean space,
262    # so we have to iterate through and call the kernel function at each "counter".
263    data = np.column_stack([x_data, y_data, z_data])
264    counters = np.column_stack([x_counters, y_counters, z_counters])
265    totals = np.empty(counters.shape[0])
266    for i, counter in enumerate(counters):
267        products = np.dot(data, counter)
268        if axial:
269            products = np.abs(products)
270        density, scale = SPHERICAL_COUNTING_KERNELS[kernel](
271            products, axial=axial, **kwargs
272        )
273        density *= weights
274        totals[i] = (density.sum() - 0.5) / scale
275
276    X_counters, Y_counters = _geo.lambert_equal_area(x_counters, y_counters, z_counters)
277
278    # Normalise to mean, which estimates the density for a "uniform" distribution.
279    totals /= totals.mean()
280    totals[totals < 0] = 0
281    # print(totals.min(), totals.mean(), totals.max())
282    return (
283        np.reshape(X_counters, ρ_grid.shape),
284        np.reshape(Y_counters, ρ_grid.shape),
285        np.reshape(totals, ρ_grid.shape),
286    )

Estimate point density of orientation data on the unit sphere.

Estimates the density of orientations on the unit sphere by counting the input data that falls within small areas around a uniform grid of spherical counting locations. The input data is expected in cartesian coordinates, and the contouring is performed using kernel functions defined in Vollmer 1995. The following optional parameters control the contouring method:

  • gridsteps (int) — the number of steps, i.e. number of points along a diameter of the spherical counting grid
  • weights (array) — auxiliary weights for each data point
  • kernel (string) — the name of the kernel function to use, see SPHERICAL_COUNTING_KERNELS
  • axial (bool) — toggle axial versions of the kernel functions (for crystallographic data this should normally be kept as True)

Any other keyword arguments are passed to the kernel function calls. Most kernels accept a parameter σ to control the degree of smoothing.

def exponential_kamb(cos_dist, σ=10, axial=True):
302def exponential_kamb(cos_dist, σ=10, axial=True):
303    """Kernel function from Vollmer 1995 for exponential smoothing."""
304    n = float(cos_dist.size)
305    if axial:
306        f = 2 * (1.0 + n / σ**2)
307        units = np.sqrt(n * (f / 2.0 - 1) / f**2)
308    else:
309        f = 1 + n / σ**2
310        units = np.sqrt(n * (f - 1) / (4 * f**2))
311
312    count = np.exp(f * (cos_dist - 1))
313    return count, units

Kernel function from Vollmer 1995 for exponential smoothing.

def linear_inverse_kamb(cos_dist, σ=10, axial=True):
316def linear_inverse_kamb(cos_dist, σ=10, axial=True):
317    """Kernel function from Vollmer 1995 for linear smoothing."""
318    n = float(cos_dist.size)
319    radius = _kamb_radius(n, σ, axial=axial)
320    f = 2 / (1 - radius)
321    cos_dist = cos_dist[cos_dist >= radius]
322    count = f * (cos_dist - radius)
323    return count, _kamb_units(n, radius)

Kernel function from Vollmer 1995 for linear smoothing.

def square_inverse_kamb(cos_dist, σ=10, axial=True):
326def square_inverse_kamb(cos_dist, σ=10, axial=True):
327    """Kernel function from Vollmer 1995 for inverse square smoothing."""
328    n = float(cos_dist.size)
329    radius = _kamb_radius(n, σ, axial=axial)
330    f = 3 / (1 - radius) ** 2
331    cos_dist = cos_dist[cos_dist >= radius]
332    count = f * (cos_dist - radius) ** 2
333    return count, _kamb_units(n, radius)

Kernel function from Vollmer 1995 for inverse square smoothing.

def kamb_count(cos_dist, σ=10, axial=True):
336def kamb_count(cos_dist, σ=10, axial=True):
337    """Original Kamb 1959 kernel function (raw count within radius)."""
338    n = float(cos_dist.size)
339    dist = _kamb_radius(n, σ, axial=axial)
340    count = (cos_dist >= dist).astype(float)
341    return count, _kamb_units(n, dist)

Original Kamb 1959 kernel function (raw count within radius).

def schmidt_count(cos_dist, axial=None):
344def schmidt_count(cos_dist, axial=None):
345    """Schmidt (a.k.a. 1%) counting kernel function."""
346    radius = 0.01
347    count = ((1 - cos_dist) <= radius).astype(float)
348    # To offset the count.sum() - 0.5 required for the kamb methods...
349    count = 0.5 / count.size + count
350    return count, (cos_dist.size * radius)

Schmidt (a.k.a. 1%) counting kernel function.

SPHERICAL_COUNTING_KERNELS = {'kamb_count': <function kamb_count>, 'schmidt_count': <function schmidt_count>, 'exponential_kamb': <function exponential_kamb>, 'linear_inverse_kamb': <function linear_inverse_kamb>, 'square_inverse_kamb': <function square_inverse_kamb>}

Kernel functions that return an un-summed distribution and a normalization factor.

Supported kernel functions are based on the discussion in Vollmer 1995. Kamb methods accept the parameter σ (default: 10) to control the degree of smoothing. Values lower than 3 and higher than 20 are not recommended.