Skip to content
Draft
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',
]
186 changes: 186 additions & 0 deletions hexrd/phase_transition/texture/uniform_odf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""
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 Union

import numpy as np

# Valid symmetry labels. Crystal symmetries mirror the crystal portion of
# hexrd.powder.wppf.texture.SYMLIST. Sample symmetries are the subset
# currently supported by DeLaValleePoussinKernel; 'axial' is intentionally
# excluded to stay consistent with the kernel's sample symmetry handling.
_CRYSTAL_SYMMETRIES = frozenset([
'ci', 'c2h', 'd2h', 'c4h', 'd4h',
's6', 'd3d', 'c6h', 'd6h', 'th', 'oh',
])
_SAMPLE_SYMMETRIES = frozenset([
'triclinic', 'monoclinic', 'orthorhombic',
])


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
Crystal symmetry using standard crystallographic notation.
Supported: 'ci', 'c2h', 'd2h', 'c4h', 'd4h', 's6', 'd3d',
'c6h', 'd6h', 'th', 'oh'
sample_symmetry : str
Sample symmetry. Supported: 'triclinic', 'monoclinic',
'orthorhombic'

Attributes
----------
value : float
Constant ODF value = 1.0 (MRD)
crystal_symmetry : str
Crystal symmetry string
sample_symmetry : str
Sample symmetry string

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: str,
sample_symmetry: str = 'triclinic',
) -> None:
"""
Initialize uniform ODF with specified symmetries.

Parameters
----------
crystal_symmetry : str
Crystal symmetry notation
sample_symmetry : str, optional
Sample symmetry notation, default 'triclinic'
"""
if crystal_symmetry not in _CRYSTAL_SYMMETRIES:
raise ValueError(
f"Invalid crystal symmetry '{crystal_symmetry}'. "
f"Must be one of: "
f"{', '.join(sorted(_CRYSTAL_SYMMETRIES))}"
)

if sample_symmetry not in _SAMPLE_SYMMETRIES:
raise ValueError(
f"Invalid sample symmetry '{sample_symmetry}'. "
f"Must be one of: "
f"{', '.join(sorted(_SAMPLE_SYMMETRIES))}"
)

# Symmetry is validated and stored to satisfy the common ODF
# interface used by the upcoming UnimodalODF work. It is
# intentionally inert here: the uniform ODF value is constant at
# 1 MRD for all orientations regardless of crystal or sample
# symmetry.
self._crystal_symmetry = crystal_symmetry
self._sample_symmetry = sample_symmetry

@property
def crystal_symmetry(self) -> str:
"""Crystal symmetry notation."""
return self._crystal_symmetry

@property
def sample_symmetry(self) -> str:
"""Sample symmetry notation."""
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
-------
numpy.ndarray
ODF values with shape matching the leading dimensions of input.
All values equal to 1.0 (MRD).

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}', "
f"sample_symmetry='{self.sample_symmetry}', "
f"value={self.value:.6f})"
)

def __str__(self) -> str:
"""String representation of UniformODF."""
return (
f"Uniform ODF with {self.crystal_symmetry} crystal symmetry "
f"and {self.sample_symmetry} sample symmetry\n"
f"Constant value: {self.value:.6f} (random texture)"
)
167 changes: 167 additions & 0 deletions tests/test_uniform_odf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""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_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_symmetry_sets_in_sync_with_wppf(self):
"""Guard against drift from WPPF's SYMLIST.

Crystal symmetries must match WPPF's crystal portion exactly.
Sample symmetries must be a subset of WPPF's sample portion;
'axial' is intentionally excluded to match the kernel.
"""
from hexrd.powder.wppf.texture import SYMLIST
from hexrd.phase_transition.texture.uniform_odf import (
_CRYSTAL_SYMMETRIES,
_SAMPLE_SYMMETRIES,
)

wppf_crystal = set(SYMLIST[0:11])
wppf_sample = set(SYMLIST[11:])

self.assertEqual(set(_CRYSTAL_SYMMETRIES), wppf_crystal)
self.assertTrue(set(_SAMPLE_SYMMETRIES) <= wppf_sample)