Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions hexrd/phase_transition/texture/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
DeLaValleePoussinKernel,
SO3Kernel,
)
from hexrd.phase_transition.texture.uniform_odf import UniformODF

__all__ = [
'DeLaValleePoussinKernel',
'SO3Kernel',
'UniformODF',
]
177 changes: 177 additions & 0 deletions hexrd/phase_transition/texture/uniform_odf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"""
Uniform Orientation Distribution Function (ODF)

Implements a constant ODF representing completely random texture where all
orientations are equally likely. The uniform ODF has a constant value of
1 MRD (multiples of a random distribution), the standard normalization
for texture analysis on SO(3).
"""

from typing import Optional, Union

import numpy as np

from hexrd.phase_transition.texture.kernels import (
_symmetry_quaternions,
_Symmetry,
)


class UniformODF:
"""
Uniform (random) orientation distribution function.

Represents a completely isotropic texture where all crystal orientations
are equally likely. The value is constant at 1 MRD (multiples of a
random distribution), the standard normalization where the uniform
distribution serves as the reference density.

Parameters
----------
crystal_symmetry : str or numpy.ndarray, optional
Crystal symmetry as a Laue group label ('ci', 'c2h', 'd2h', 'c4h',
'd4h', 's6', 'd3d', 'c6h', 'd6h', 'th', 'oh') or a quaternion
symmetry array. Validated but inert (the value is 1 MRD regardless);
default None.
sample_symmetry : str or numpy.ndarray, optional
Sample symmetry as a label ('triclinic', 'monoclinic',
'orthorhombic') or a quaternion array. Validated but inert;
default None.

Attributes
----------
value : float
Constant ODF value = 1.0 (MRD)
crystal_symmetry : str or None
Crystal symmetry label, or None if unset or given as an array
sample_symmetry : str or None
Sample symmetry label, or None if unset or given as an array

Examples
--------
>>> odf = UniformODF('d6h', 'triclinic') # hexagonal crystal
>>> orientations = np.eye(3).reshape(1, 3, 3)
>>> values = odf.eval(orientations)
>>> print(values[0]) # 1.0
"""

# Constant value for uniform ODF: 1 MRD (standard normalization)
_UNIFORM_VALUE = 1.0

def __init__(
self,
crystal_symmetry: _Symmetry = None,
sample_symmetry: _Symmetry = None,
) -> None:
"""
Initialize uniform ODF with optional symmetries.

Parameters
----------
crystal_symmetry : str or numpy.ndarray, optional
Crystal symmetry notation, default None
sample_symmetry : str or numpy.ndarray, optional
Sample symmetry notation, default None
"""
# Validate through the same path the kernel uses, so the texture
# package shares one accepted symmetry set and one error behavior.
# Symmetry is inert for a uniform ODF (the value is 1 MRD for every
# orientation); it is kept as metadata and for parity with the
# kernel-backed ODFs (e.g. UnimodalODF).
_symmetry_quaternions(crystal_symmetry, symtype='crystal')
_symmetry_quaternions(sample_symmetry, symtype='sample')

# Retain the string label; a symmetry given as a quaternion array
# has no recoverable label (mirrors the kernel's behavior).
self._crystal_symmetry = (
crystal_symmetry if isinstance(crystal_symmetry, str) else None
)
self._sample_symmetry = (
sample_symmetry if isinstance(sample_symmetry, str) else None
)

@property
def crystal_symmetry(self) -> Optional[str]:
"""Crystal symmetry label, or None if unset or given as an array."""
return self._crystal_symmetry

@property
def sample_symmetry(self) -> Optional[str]:
"""Sample symmetry label, or None if unset or given as an array."""
return self._sample_symmetry

@property
def value(self) -> float:
"""Constant ODF value in MRD."""
return self._UNIFORM_VALUE

def eval(
self, orientations: np.ndarray,
) -> Union[float, np.ndarray]:
"""
Evaluate uniform ODF at given orientations.

For a uniform ODF, all orientations return 1.0 MRD
(multiples of a random distribution).

Parameters
----------
orientations : array_like
Orientation matrices. Can be:
- Single 3x3 rotation matrix
- Array of shape (N, 3, 3) for N orientations
- Any shape ending in (3, 3) for rotation matrices

Returns
-------
float or numpy.ndarray
ODF values, all equal to 1.0 (MRD). A scalar float for a single
(3, 3) orientation; otherwise an array whose shape matches the
leading dimensions of the input.

Examples
--------
>>> odf = UniformODF('oh', 'triclinic')
>>>
>>> # Single orientation
>>> R = np.eye(3)
>>> value = odf.eval(R) # scalar
>>>
>>> # Multiple orientations
>>> Rs = np.array([np.eye(3), np.eye(3)]) # shape (2, 3, 3)
>>> values = odf.eval(Rs) # shape (2,)
"""
orientations = np.asarray(orientations)

# Validate input shape - must end with (3, 3)
if orientations.shape[-2:] != (3, 3):
raise ValueError(
f"Orientation matrices must have shape (..., 3, 3), "
f"got {orientations.shape}"
)

# Return array of uniform values with shape matching input leading dims
output_shape = orientations.shape[:-2]

if output_shape == ():
# Single orientation - return scalar
return self._UNIFORM_VALUE
else:
# Multiple orientations - return array
return np.full(output_shape, self._UNIFORM_VALUE)

def __repr__(self) -> str:
"""String representation of UniformODF."""
return (
f"UniformODF(crystal_symmetry={self.crystal_symmetry!r}, "
f"sample_symmetry={self.sample_symmetry!r}, "
f"value={self.value:.6f})"
)

def __str__(self) -> str:
"""String representation of UniformODF."""
return (
f"Uniform ODF with {self.crystal_symmetry or 'none'} crystal "
f"symmetry and {self.sample_symmetry or 'none'} sample symmetry\n"
f"Constant value: {self.value:.6f} (random texture)"
)
187 changes: 187 additions & 0 deletions tests/test_uniform_odf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"""Tests for UniformODF."""

import numpy as np
import unittest

from hexrd.phase_transition.texture.uniform_odf import UniformODF


class TestUniformODF(unittest.TestCase):
"""Test UniformODF."""

def test_package_export(self):
"""Test that UniformODF is importable from the texture package."""
from hexrd.phase_transition.texture import UniformODF as Cls
self.assertIs(Cls, UniformODF)

def test_uniform_value_is_one_mrd(self):
"""Test that uniform ODF value is 1 MRD."""
odf = UniformODF('oh', 'triclinic')
self.assertEqual(odf.value, 1.0)

def test_symmetry_validation(self):
"""Test crystal and sample symmetry validation."""
# Valid symmetries should work
odf = UniformODF('oh', 'triclinic')
self.assertEqual(odf.crystal_symmetry, 'oh')
self.assertEqual(odf.sample_symmetry, 'triclinic')

# Test different valid combinations
odf_hex = UniformODF('d6h', 'orthorhombic')
self.assertEqual(odf_hex.crystal_symmetry, 'd6h')
self.assertEqual(odf_hex.sample_symmetry, 'orthorhombic')

def test_single_orientation_evaluation(self):
"""Test ODF evaluation at a single orientation."""
odf = UniformODF('oh', 'triclinic')

identity = np.eye(3)
value = odf.eval(identity)

self.assertEqual(value, 1.0)
self.assertTrue(np.isscalar(value))

def test_multiple_orientations_evaluation(self):
"""Test ODF evaluation at multiple orientations."""
odf = UniformODF('oh', 'triclinic')

orientations = np.array([
np.eye(3),
[[-1, 0, 0], [0, -1, 0], [0, 0, 1]],
[[0, -1, 0], [1, 0, 0], [0, 0, 1]],
])

values = odf.eval(orientations)

np.testing.assert_array_equal(values, np.ones(3))
self.assertEqual(values.shape, (3,))

def test_batch_orientations(self):
"""Test evaluation with different batch shapes."""
odf = UniformODF('d6h', 'triclinic')

# 2D batch: (2, 2, 3, 3)
batch_2d = np.tile(np.eye(3), (2, 2, 1, 1))
values_2d = odf.eval(batch_2d)
self.assertEqual(values_2d.shape, (2, 2))
np.testing.assert_array_equal(values_2d, np.ones((2, 2)))

# 1D batch: (5, 3, 3)
batch_1d = np.tile(np.eye(3), (5, 1, 1))
values_1d = odf.eval(batch_1d)
self.assertEqual(values_1d.shape, (5,))
np.testing.assert_array_equal(values_1d, np.ones(5))

def test_invalid_orientation_shapes(self):
"""Test that invalid orientation shapes raise errors."""
odf = UniformODF('oh', 'triclinic')

with self.assertRaises(ValueError):
odf.eval(np.zeros((3, 2)))

with self.assertRaises(ValueError):
odf.eval(np.zeros((2, 3, 2)))

with self.assertRaises(ValueError):
odf.eval(np.zeros((2, 2, 3)))

def test_mrd_convention(self):
"""Test MRD normalization: uniform ODF = 1 everywhere.

In MRD (multiples of a random distribution), the uniform
ODF is the reference density at 1.0. Textured ODFs have
values > 1 near preferred orientations and < 1 elsewhere.
"""
odf = UniformODF('oh', 'triclinic')
self.assertEqual(odf.value, 1.0)

def test_all_orientations_equal(self):
"""Test that all orientations return the same value."""
odf = UniformODF('d6h', 'triclinic')

identity = np.eye(3)
rot_90z = np.array([[0, -1, 0], [1, 0, 0], [0, 0, 1]],
dtype=float)

self.assertEqual(odf.eval(identity), odf.eval(rot_90z))
self.assertEqual(odf.eval(identity), odf.value)

def test_string_representations(self):
"""Test string representation methods."""
odf = UniformODF('oh', 'triclinic')

# Test __repr__
repr_str = repr(odf)
self.assertIn('UniformODF', repr_str)
self.assertIn('oh', repr_str)
self.assertIn('triclinic', repr_str)

# Test __str__
str_repr = str(odf)
self.assertIn('Uniform ODF', str_repr)
self.assertIn('oh', str_repr)
self.assertIn('random texture', str_repr)

def test_different_crystal_symmetries(self):
"""Test that value is 1 MRD regardless of symmetry."""
for sym in ['oh', 'd6h', 'c6h', 'd4h', 'th']:
with self.subTest(crystal_symmetry=sym):
odf = UniformODF(sym, 'triclinic')
self.assertEqual(odf.value, 1.0)
self.assertEqual(odf.eval(np.eye(3)), 1.0)

def test_all_crystal_symmetries_accepted(self):
"""Every supported crystal symmetry is accepted and stored."""
crystal_symmetries = [
'ci', 'c2h', 'd2h', 'c4h', 'd4h',
's6', 'd3d', 'c6h', 'd6h', 'th', 'oh',
]
for sym in crystal_symmetries:
with self.subTest(crystal_symmetry=sym):
odf = UniformODF(sym, 'triclinic')
self.assertEqual(odf.crystal_symmetry, sym)

def test_all_sample_symmetries_accepted(self):
"""Every supported sample symmetry is accepted and stored."""
for sym in ['triclinic', 'monoclinic', 'orthorhombic']:
with self.subTest(sample_symmetry=sym):
odf = UniformODF('oh', sym)
self.assertEqual(odf.sample_symmetry, sym)

def test_invalid_crystal_symmetry(self):
"""Test that invalid crystal symmetry raises ValueError."""
with self.assertRaises(ValueError):
UniformODF('invalid', 'triclinic')
with self.assertRaises(ValueError):
UniformODF('6/mmm', 'triclinic')

def test_invalid_sample_symmetry(self):
"""Test that invalid sample symmetry raises ValueError."""
with self.assertRaises(ValueError):
UniformODF('oh', 'invalid')
with self.assertRaises(ValueError):
UniformODF('oh', 'cubic')
# 'axial' is intentionally excluded to match the kernel.
with self.assertRaises(ValueError):
UniformODF('oh', 'axial')

def test_no_symmetry_defaults_to_none(self):
"""UniformODF can be built without symmetry; labels are None."""
odf = UniformODF()
self.assertIsNone(odf.crystal_symmetry)
self.assertIsNone(odf.sample_symmetry)
self.assertEqual(odf.value, 1.0)
self.assertEqual(odf.eval(np.eye(3)), 1.0)

def test_array_symmetry_has_no_label(self):
"""Symmetry given as a quaternion array is accepted; label is None."""
from hexrd.core import rotations
odf = UniformODF(rotations.quatOfLaueGroup('d4h'))
self.assertIsNone(odf.crystal_symmetry)
self.assertEqual(odf.eval(np.eye(3)), 1.0)

def test_string_representations_without_symmetry(self):
"""repr/str render gracefully when no symmetry is set."""
odf = UniformODF()
self.assertIn('crystal_symmetry=None', repr(odf))
self.assertIn('none', str(odf))
Loading