"""
Unimodal Orientation Distribution Function (ODF)
Implements a kernel-based ODF with a single or multiple preferred orientations.
Uses a combination of modal orientations and kernel functions to create
smooth, localized texture distributions.
"""
import numpy as np
from .kernels import SO3Kernel
[docs]class UnimodalODF:
"""
Unimodal orientation distribution function.
Represents a texture with one or more preferred orientations around which
crystal orientations are concentrated. Uses kernel functions to create
smooth distributions around modal orientations.
Mathematical form: f(g) = Σᵢ wᵢ K(g, g₀ᵢ), Σᵢ wᵢ = 1
Because the de la Vallée Poussin kernel is normalized so its mean over
SO(3) is 1 (MRD), this weighted sum is itself a valid ODF in MRD units
(uniform texture = 1).
Parameters
----------
modal_orientations : array_like
Modal (preferred) orientation(s). Can be:
- Single 3x3 rotation matrix
- Array of shape (N, 3, 3) for N modal orientations
kernel : DeLaValleePoussinKernel
Kernel function defining the shape of the distribution. The kernel
is the single source of truth for symmetry: any crystal/sample
symmetry must be set on the kernel, and the ODF exposes it via the
``crystal_symmetry``/``sample_symmetry`` properties.
weights : array_like, optional
Weights for multiple modal orientations. Must sum to 1.
If None, equal weights are used for multiple orientations.
Attributes
----------
modal_orientations : numpy.ndarray
Array of modal orientations, shape (N, 3, 3)
kernel : DeLaValleePoussinKernel
Kernel function used for the distribution
weights : numpy.ndarray
Component weights, shape (N,)
crystal_symmetry : str or None
Crystal symmetry label, delegated from the kernel
sample_symmetry : str or None
Sample symmetry label, delegated from the kernel
n_components : int
Number of modal orientations
Examples
--------
>>> from hexrd.phase_transition.texture import UnimodalODF
>>> from hexrd.phase_transition.texture import DeLaValleePoussinKernel
>>>
>>> # Single modal orientation (cubic crystal symmetry on the kernel)
>>> kernel = DeLaValleePoussinKernel(
... halfwidth=np.radians(15), crystal_symmetry='oh'
... )
>>> modal = np.eye(3) # Identity orientation
>>> odf = UnimodalODF(modal, kernel)
>>>
>>> # Evaluate at modal orientation (should give maximum value)
>>> value = odf.eval(modal)
"""
def __init__(self, modal_orientations, kernel, weights=None):
"""
Initialize unimodal ODF.
Parameters
----------
modal_orientations : array_like
Modal orientation(s) as rotation matrices
kernel : DeLaValleePoussinKernel
Kernel function for the distribution. Symmetry, if any, must be
configured on the kernel; the ODF delegates to it.
weights : array_like, optional
Component weights for multiple modal orientations
"""
# Validate and store kernel. The kernel is the single source of
# truth for symmetry (validated when the kernel was constructed).
# Accept any SO3Kernel subclass rather than a single concrete type.
if not isinstance(kernel, SO3Kernel):
raise TypeError("kernel must be an SO3Kernel instance")
self._kernel = kernel
# Process modal orientations
modal_orientations = np.asarray(modal_orientations)
# Handle single vs multiple modal orientations
if modal_orientations.ndim == 2 and modal_orientations.shape == (3, 3):
# Single modal orientation
self._modal_orientations = modal_orientations.reshape(1, 3, 3)
self._n_components = 1
elif modal_orientations.ndim == 3 and modal_orientations.shape[-2:] == (3, 3):
# Multiple modal orientations
self._modal_orientations = modal_orientations
self._n_components = modal_orientations.shape[0]
else:
raise ValueError(
f"Modal orientations must have shape (3, 3) or (N, 3, 3), "
f"got {modal_orientations.shape}"
)
# Process weights
if weights is None:
# Equal weights for all components
self._weights = np.full(self._n_components, 1.0 / self._n_components)
else:
weights = np.asarray(weights)
if weights.shape != (self._n_components,):
raise ValueError(
f"Weights must have shape ({self._n_components},), "
f"got {weights.shape}"
)
if not np.allclose(np.sum(weights), 1.0):
raise ValueError("Weights must sum to 1.0")
if np.any(weights < 0):
raise ValueError("Weights must be non-negative")
self._weights = weights.copy()
@property
def modal_orientations(self):
"""numpy.ndarray: Modal orientations, shape (N, 3, 3)."""
return self._modal_orientations.copy()
@property
def kernel(self):
"""DeLaValleePoussinKernel: Kernel function."""
return self._kernel
@property
def weights(self):
"""numpy.ndarray: Component weights, shape (N,)."""
return self._weights.copy()
@property
def crystal_symmetry(self):
"""str or None: Crystal symmetry label, delegated from the kernel."""
return self._kernel.crystal_symmetry
@property
def sample_symmetry(self):
"""str or None: Sample symmetry label, delegated from the kernel."""
return self._kernel.sample_symmetry
@property
def n_components(self):
"""int: Number of modal orientations."""
return self._n_components
[docs] def eval(self, orientations):
"""
Evaluate unimodal ODF at given orientations.
Computes f(g) = Σᵢ wᵢ K(g, g₀ᵢ) for all input orientations, in MRD.
Parameters
----------
orientations : array_like
Orientation matrices of shape (..., 3, 3)
Returns
-------
numpy.ndarray
ODF values with shape matching leading dimensions of input
Examples
--------
>>> modal = np.eye(3)
>>> kernel = DeLaValleePoussinKernel(halfwidth=np.radians(10))
>>> odf = UnimodalODF(modal, kernel)
>>>
>>> # Single evaluation
>>> value = odf.eval(modal) # Should give maximum
>>>
>>> # Batch evaluation
>>> Rs = np.array([np.eye(3), rotation_matrix_z(np.pi/4)])
>>> values = odf.eval(Rs) # shape (2,)
"""
orientations = np.asarray(orientations)
# Validate input shape
if orientations.shape[-2:] != (3, 3):
raise ValueError(
f"Orientation matrices must have shape (..., 3, 3), "
f"got {orientations.shape}"
)
# Determine output shape
output_shape = orientations.shape[:-2]
if output_shape == ():
# Single orientation
orientations = orientations.reshape(1, 3, 3)
squeeze_output = True
else:
# Multiple orientations - flatten for processing
n_orientations = int(np.prod(output_shape))
orientations = orientations.reshape(n_orientations, 3, 3)
squeeze_output = False
# Initialize results
n_query = orientations.shape[0]
results = np.zeros(n_query)
# Evaluate each component and sum with weights
for i in range(self.n_components):
modal_i = self._modal_orientations[i] # Shape (3, 3)
weight_i = self._weights[i]
# Evaluate the kernel between all query orientations and this single
# modal orientation. Pass modal_i as a single (3, 3): the kernel
# broadcasts it across the query batch, and its symmetry-reduced
# path requires one operand to be a single reference orientation.
kernel_values = self.kernel.eval(orientations, modal_i)
# Add weighted contribution to results
results += weight_i * kernel_values
# The de la Vallée Poussin kernel is normalized so its mean over
# SO(3) is 1 (MRD). With weights that sum to 1, this weighted sum is
# already a valid ODF in MRD units (uniform texture = 1), so no
# additional normalization is required.
# Reshape to match input
if squeeze_output:
return float(results[0])
else:
return results.reshape(output_shape)
[docs] def estimated_max_value(self):
"""
Estimate the maximum ODF value, in MRD.
The ODF maxima occur at (or very near) the modal orientations, so
this evaluates the full ODF at each mode and returns the largest
value.
Returns
-------
float
Maximum ODF value in MRD (multiples of a random distribution)
"""
# ODF maxima occur at (or very near) the modal orientations.
# Evaluating the full ODF at each mode accounts for overlap between
# nearby modes.
modal_values = np.atleast_1d(self.eval(self._modal_orientations))
return float(np.max(modal_values))
def __repr__(self):
"""String representation of UnimodalODF."""
return (
f"UnimodalODF(n_components={self.n_components}, "
f"kernel_halfwidth={np.degrees(self.kernel.halfwidth):.1f}°, "
f"crystal_symmetry={self.crystal_symmetry!r}, "
f"sample_symmetry={self.sample_symmetry!r})"
)
def __str__(self):
"""Human-readable description."""
desc = (
f"Unimodal ODF with {self.n_components} component(s)\n"
f"Crystal symmetry: {self.crystal_symmetry or 'none'}\n"
f"Sample symmetry: {self.sample_symmetry or 'none'}\n"
f"Kernel: {self.kernel}\n"
)
if self.n_components == 1:
desc += "Modal orientations: 1 component\n"
else:
desc += f"Modal orientations: {self.n_components} components\n"
desc += f"Weights: {self.weights}\n"
desc += f"Estimated max value: {self.estimated_max_value():.1f} MRD"
return desc