diff --git a/hexrd/phase_transition/texture/__init__.py b/hexrd/phase_transition/texture/__init__.py index fab91ee72..1b4c3d070 100644 --- a/hexrd/phase_transition/texture/__init__.py +++ b/hexrd/phase_transition/texture/__init__.py @@ -2,8 +2,10 @@ DeLaValleePoussinKernel, SO3Kernel, ) +from hexrd.phase_transition.texture.uniform_odf import UniformODF __all__ = [ 'DeLaValleePoussinKernel', 'SO3Kernel', + 'UniformODF', ] diff --git a/hexrd/phase_transition/texture/uniform_odf.py b/hexrd/phase_transition/texture/uniform_odf.py new file mode 100644 index 000000000..d00f8baae --- /dev/null +++ b/hexrd/phase_transition/texture/uniform_odf.py @@ -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)" + ) diff --git a/tests/test_uniform_odf.py b/tests/test_uniform_odf.py new file mode 100644 index 000000000..738ce38ce --- /dev/null +++ b/tests/test_uniform_odf.py @@ -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)