From ca34cd97e62cc45a65f6a553cb688dea1c274001 Mon Sep 17 00:00:00 2001 From: janbridley Date: Wed, 3 Sep 2025 16:52:49 -0400 Subject: [PATCH 01/18] WIPOn symmetries --- rowan/mapping/symmetries.py | 143 ++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 rowan/mapping/symmetries.py diff --git a/rowan/mapping/symmetries.py b/rowan/mapping/symmetries.py new file mode 100644 index 0000000..04ece2a --- /dev/null +++ b/rowan/mapping/symmetries.py @@ -0,0 +1,143 @@ +""".""" + +from itertools import combinations, permutations, product +from typing import Iterable + +import numpy as np +from numpy.typing import ArrayLike + +from rowan import from_axis_angle + + +def cyclic_permutations(a): + """Generate the cyclic permutations of an array a.""" + + def make_permutation_generator(a): + """Return a list of functions that roll an array by 0,1,2...n.""" + n, sign = 1, len(a) + return [ + lambda arr, k=j: arr[(sign * k) :] + arr[: (sign * k)] for j in range(n) + ] + + return [op(a) for op in make_permutation_generator(a)] + + +def sign_changes(even: bool = True, d: int = 3): + """Get all even (or odd) sign changes of a vector in ℝ3.""" + all_changes = np.array([*product([1, -1], repeat=d)]) + if even == "all": + return all_changes + return all_changes[np.prod(all_changes, axis=1) == (1 if even else -1)] + + +def generate_tetrahedral_group(): + """Generates the 24 quaternions of the chiral tetrahedral group .""" + quats = { + # 8 (4+4) quaternions from permutationsof (±1, 0, 0, 0) + (1, 0, 0, 0), + (0, 1, 0, 0), + (0, 0, 1, 0), + (0, 0, 0, 1), + (-1, 0, 0, 0), + (0, -1, 0, 0), + (0, 0, -1, 0), + (0, 0, 0, -1), + *product([-0.5, 0.5], repeat=4), # 16 quaternions from 1/2 * (±1, ±1, ±1, ±1) + } # TODO: test leaves tetrahedron unchanged? + return np.array(sorted(quats)) + + +def generate_octahedral_group(): + """Generates the 48 quaternions of the octahedral group.""" + c = 1 / np.sqrt(2) + + quats = { + # 24 elements of the tetrahedral group + *(tuple(x) for x in generate_tetrahedral_group()), + # 24 quaternions from permutations of 1/√2 * (±1, ±1, 0, 0) + *permutations([c, c, 0, 0]), + *permutations([c, -c, 0, 0]), + *permutations([-c, -c, 0, 0]), + } + + return np.array(sorted(quats)) + + +def generate_cyclic_group(n: int, axis: ArrayLike = (0, 0, 1)): + """Generates the 2n quaternions of the cyclic group . + + Args: + n (int): The index of the cyclic group C_n. The order of the + resulting group will be 2n. + axis (np.ndarray): The axis of rotation for the group. + + Returns: + np.ndarray: A (2n, 4) array of quaternions. + """ + # return np.array([from_axis_angle(axis, 2 * np.pi * k / n) for k in range(n)]) + return from_axis_angle(axis, np.linspace(0, 2 * np.pi, n, endpoint=False)).round(15) + + +def generate_dicyclic_group(n: int, axis: ArrayLike = (0, 0, 1)): + """Generates the 4n quaternions of the dicyclic group . + + Args: + n (int): The index of the cyclic group D_n. The order of the + resulting group will be 4n. + axis (np.ndarray): The axis of rotation for the group. + + Returns: + np.ndarray: A (4n, 4) array of quaternions. + """ + return np.concatenate( + ( + # 2n quaternions from the cyclic group + generate_cyclic_group(n, axis), + # hetas = np.linspace(0, np.pi, n, endpoint=False) + # rv = np.pi * np.vstack([np.zeros(n), np.cos(thetas), np.sin(thetas)]).T + # g2 = np.roll(rv, axis, axis=1) + # from_axis_angle(axis, np.linspace(0, np.pi, n, endpoint=False)) # TODO + ) + ) + + +print(len(generate_tetrahedral_group())) + +print(generate_cyclic_group(4)) +# print((generate_binary_octahedral_group())) + + +def even_permutations(it: Iterable): + """Return permutations containing an even number of transpositions from an iterable. + + Args: + it (typing.Iterable): The iterable to permute. + + Returns: + typing.Generator: An (N!/2, len(it)) generator of permutations. + """ + return ( + p for p in permutations(it) if not sum(a > b for a, b in combinations(p, 2)) % 2 + ) + # if sum(a > b for (i, a) in enumerate(p) for b in p[i + 1 :]) % 2 == 0 + + +def generate_binary_icosahedral_group(): + """Generates the 48 quaternions of the octahedral group.""" + φ = (1 + np.sqrt(5)) / 2 + + quats = { + # 24 elements of the tetrahedral group + *(tuple(x) for x in generate_tetrahedral_group()), + # 96 quaternions from even permutations of 1/2 * (0, ±1, ±1/φ, ±φ) + *( + q + for a, b, c in product([1 / 2, -1 / 2], repeat=3) + for q in even_permutations([0, a, b / φ, c * φ]) + ), + } + return np.array(sorted(quats)) + + +print(len(generate_binary_icosahedral_group())) +# TODO: test against Scipy, and make sure objects are invariant From 865eff27bf0fa2e15341da65cb390300561e3f57 Mon Sep 17 00:00:00 2001 From: janbridley Date: Wed, 3 Sep 2025 16:54:25 -0400 Subject: [PATCH 02/18] Clean up --- rowan/mapping/symmetries.py | 47 ++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/rowan/mapping/symmetries.py b/rowan/mapping/symmetries.py index 04ece2a..a7356d3 100644 --- a/rowan/mapping/symmetries.py +++ b/rowan/mapping/symmetries.py @@ -43,7 +43,7 @@ def generate_tetrahedral_group(): (0, 0, -1, 0), (0, 0, 0, -1), *product([-0.5, 0.5], repeat=4), # 16 quaternions from 1/2 * (±1, ±1, ±1, ±1) - } # TODO: test leaves tetrahedron unchanged? + } return np.array(sorted(quats)) @@ -78,33 +78,27 @@ def generate_cyclic_group(n: int, axis: ArrayLike = (0, 0, 1)): return from_axis_angle(axis, np.linspace(0, 2 * np.pi, n, endpoint=False)).round(15) -def generate_dicyclic_group(n: int, axis: ArrayLike = (0, 0, 1)): - """Generates the 4n quaternions of the dicyclic group . - - Args: - n (int): The index of the cyclic group D_n. The order of the - resulting group will be 4n. - axis (np.ndarray): The axis of rotation for the group. - - Returns: - np.ndarray: A (4n, 4) array of quaternions. - """ - return np.concatenate( - ( - # 2n quaternions from the cyclic group - generate_cyclic_group(n, axis), - # hetas = np.linspace(0, np.pi, n, endpoint=False) - # rv = np.pi * np.vstack([np.zeros(n), np.cos(thetas), np.sin(thetas)]).T - # g2 = np.roll(rv, axis, axis=1) - # from_axis_angle(axis, np.linspace(0, np.pi, n, endpoint=False)) # TODO - ) - ) - +# def generate_dicyclic_group(n: int, axis: ArrayLike = (0, 0, 1)): +# """Generates the 4n quaternions of the dicyclic group . -print(len(generate_tetrahedral_group())) +# Args: +# n (int): The index of the cyclic group D_n. The order of the +# resulting group will be 4n. +# axis (np.ndarray): The axis of rotation for the group. -print(generate_cyclic_group(4)) -# print((generate_binary_octahedral_group())) +# Returns: +# np.ndarray: A (4n, 4) array of quaternions. +# """ +# return np.concatenate( +# ( +# # 2n quaternions from the cyclic group +# generate_cyclic_group(n, axis), +# # hetas = np.linspace(0, np.pi, n, endpoint=False) +# # rv = np.pi * np.vstack([np.zeros(n), np.cos(thetas), np.sin(thetas)]).T +# # g2 = np.roll(rv, axis, axis=1) +# # from_axis_angle(axis, np.linspace(0, np.pi, n, endpoint=False)) # TODO +# ) +# ) def even_permutations(it: Iterable): @@ -119,7 +113,6 @@ def even_permutations(it: Iterable): return ( p for p in permutations(it) if not sum(a > b for a, b in combinations(p, 2)) % 2 ) - # if sum(a > b for (i, a) in enumerate(p) for b in p[i + 1 :]) % 2 == 0 def generate_binary_icosahedral_group(): From c87df3fbb2c8c9296504482288a5babd93a609b6 Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 4 Sep 2025 09:39:02 -0400 Subject: [PATCH 03/18] Add more_itertools dependency --- .../workflows/environments/requirements-build.txt | 2 +- .../workflows/environments/requirements-doc.txt | 8 ++++---- .../environments/requirements-test-3.10.txt | 14 +++++++++----- .../environments/requirements-test-3.11.txt | 10 ++++++---- .../environments/requirements-test-3.12.txt | 10 ++++++---- .../environments/requirements-test-3.13.txt | 10 ++++++---- .../environments/requirements-test-3.9.txt | 10 +++++++--- pyproject.toml | 1 + 8 files changed, 40 insertions(+), 25 deletions(-) diff --git a/.github/workflows/environments/requirements-build.txt b/.github/workflows/environments/requirements-build.txt index 54d30bd..a0f5df7 100644 --- a/.github/workflows/environments/requirements-build.txt +++ b/.github/workflows/environments/requirements-build.txt @@ -1,6 +1,6 @@ # This file was autogenerated by uv via the following command: # uv pip compile --python-version 3.13 --python-platform linux requirements-build.in -build==1.2.2.post1 +build==1.3.0 # via -r requirements-build.in packaging==25.0 # via build diff --git a/.github/workflows/environments/requirements-doc.txt b/.github/workflows/environments/requirements-doc.txt index 2e1c595..ee8d021 100644 --- a/.github/workflows/environments/requirements-doc.txt +++ b/.github/workflows/environments/requirements-doc.txt @@ -1,12 +1,12 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --python-version 3.13 --python-platform linux requirements-doc.in +# uv pip compile --python-version 3.12 --python-platform linux requirements-doc.in alabaster==1.0.0 # via sphinx babel==2.17.0 # via sphinx -certifi==2025.6.15 +certifi==2025.8.3 # via requests -charset-normalizer==3.4.2 +charset-normalizer==3.4.3 # via requests docutils==0.21.2 # via @@ -24,7 +24,7 @@ packaging==25.0 # via sphinx pygments==2.19.2 # via sphinx -requests==2.32.4 +requests==2.32.5 # via sphinx roman-numerals-py==3.1.0 # via sphinx diff --git a/.github/workflows/environments/requirements-test-3.10.txt b/.github/workflows/environments/requirements-test-3.10.txt index 72c07fa..009977d 100644 --- a/.github/workflows/environments/requirements-test-3.10.txt +++ b/.github/workflows/environments/requirements-test-3.10.txt @@ -1,20 +1,24 @@ # This file was autogenerated by uv via the following command: # uv pip compile --python-version 3.10 --python-platform linux requirements-test.in -exceptiongroup==1.2.2 +exceptiongroup==1.3.0 # via pytest iniconfig==2.1.0 # via pytest -numpy==2.2.5 +numpy==2.2.6 # via # -r requirements-test.in # scipy packaging==25.0 # via pytest -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -pytest==8.3.5 +pygments==2.19.2 + # via pytest +pytest==8.4.1 # via -r requirements-test.in -scipy==1.15.2 +scipy==1.15.3 # via -r requirements-test.in tomli==2.2.1 # via pytest +typing-extensions==4.15.0 + # via exceptiongroup diff --git a/.github/workflows/environments/requirements-test-3.11.txt b/.github/workflows/environments/requirements-test-3.11.txt index 9df1bae..e16644a 100644 --- a/.github/workflows/environments/requirements-test-3.11.txt +++ b/.github/workflows/environments/requirements-test-3.11.txt @@ -2,15 +2,17 @@ # uv pip compile --python-version 3.11 --python-platform linux requirements-test.in iniconfig==2.1.0 # via pytest -numpy==2.2.5 +numpy==2.3.2 # via # -r requirements-test.in # scipy packaging==25.0 # via pytest -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -pytest==8.3.5 +pygments==2.19.2 + # via pytest +pytest==8.4.1 # via -r requirements-test.in -scipy==1.15.2 +scipy==1.16.1 # via -r requirements-test.in diff --git a/.github/workflows/environments/requirements-test-3.12.txt b/.github/workflows/environments/requirements-test-3.12.txt index 084a80a..7b59bb5 100644 --- a/.github/workflows/environments/requirements-test-3.12.txt +++ b/.github/workflows/environments/requirements-test-3.12.txt @@ -2,15 +2,17 @@ # uv pip compile --python-version 3.12 --python-platform linux requirements-test.in iniconfig==2.1.0 # via pytest -numpy==2.2.5 +numpy==2.3.2 # via # -r requirements-test.in # scipy packaging==25.0 # via pytest -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -pytest==8.3.5 +pygments==2.19.2 + # via pytest +pytest==8.4.1 # via -r requirements-test.in -scipy==1.15.2 +scipy==1.16.1 # via -r requirements-test.in diff --git a/.github/workflows/environments/requirements-test-3.13.txt b/.github/workflows/environments/requirements-test-3.13.txt index 0c81039..165e829 100644 --- a/.github/workflows/environments/requirements-test-3.13.txt +++ b/.github/workflows/environments/requirements-test-3.13.txt @@ -2,15 +2,17 @@ # uv pip compile --python-version 3.13 --python-platform linux requirements-test.in iniconfig==2.1.0 # via pytest -numpy==2.2.5 +numpy==2.3.2 # via # -r requirements-test.in # scipy packaging==25.0 # via pytest -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -pytest==8.3.5 +pygments==2.19.2 + # via pytest +pytest==8.4.1 # via -r requirements-test.in -scipy==1.15.2 +scipy==1.16.1 # via -r requirements-test.in diff --git a/.github/workflows/environments/requirements-test-3.9.txt b/.github/workflows/environments/requirements-test-3.9.txt index 2a921c5..6148d75 100644 --- a/.github/workflows/environments/requirements-test-3.9.txt +++ b/.github/workflows/environments/requirements-test-3.9.txt @@ -1,6 +1,6 @@ # This file was autogenerated by uv via the following command: # uv pip compile --python-version 3.9 --python-platform linux requirements-test.in -exceptiongroup==1.2.2 +exceptiongroup==1.3.0 # via pytest iniconfig==2.1.0 # via pytest @@ -10,11 +10,15 @@ numpy==2.0.2 # scipy packaging==25.0 # via pytest -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -pytest==8.3.5 +pygments==2.19.2 + # via pytest +pytest==8.4.1 # via -r requirements-test.in scipy==1.13.1 # via -r requirements-test.in tomli==2.2.1 # via pytest +typing-extensions==4.15.0 + # via exceptiongroup diff --git a/pyproject.toml b/pyproject.toml index e5a10ab..a05ad3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ ] dependencies = [ "numpy>=1.21", + "more_itertools" ] [project.optional-dependencies] From 338b02b41b3d50e7cf1811cce99333aa276d2a16 Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 4 Sep 2025 10:18:31 -0400 Subject: [PATCH 04/18] WIP on tests and more efficient generation --- rowan/mapping/__init__.py | 8 ++++ rowan/mapping/symmetries.py | 74 +++++++++++++++++++------------------ tests/test_mapping.py | 53 ++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 36 deletions(-) diff --git a/rowan/mapping/__init__.py b/rowan/mapping/__init__.py index 4de0f71..abde5a2 100644 --- a/rowan/mapping/__init__.py +++ b/rowan/mapping/__init__.py @@ -58,6 +58,14 @@ from ..functions import from_matrix, rotate, to_matrix from ..geometry import angle +from .symmetries import _cyclic_permutations +from .symmetries import generate_icosahedral_group as _generate_icosahedral_group +from .symmetries import ( + generate_octahedral_group as _generate_octahedral_group, +) +from .symmetries import ( + generate_tetrahedral_group as _generate_tetrahedral_group, +) __all__ = ["kabsch", "davenport", "procrustes", "icp"] diff --git a/rowan/mapping/symmetries.py b/rowan/mapping/symmetries.py index a7356d3..ea05584 100644 --- a/rowan/mapping/symmetries.py +++ b/rowan/mapping/symmetries.py @@ -4,17 +4,17 @@ from typing import Iterable import numpy as np +from more_itertools import distinct_permutations from numpy.typing import ArrayLike -from rowan import from_axis_angle +from ..functions import from_axis_angle -def cyclic_permutations(a): - """Generate the cyclic permutations of an array a.""" - - def make_permutation_generator(a): - """Return a list of functions that roll an array by 0,1,2...n.""" - n, sign = 1, len(a) +def _cyclic_permutations(a): + def make_permutation_generator(a, forward=True): + """Return a list of functions that roll an array by 0,1,2...n""" + n = len(a) + sign = -1 if forward else 1 return [ lambda arr, k=j: arr[(sign * k) :] + arr[: (sign * k)] for j in range(n) ] @@ -32,35 +32,35 @@ def sign_changes(even: bool = True, d: int = 3): def generate_tetrahedral_group(): """Generates the 24 quaternions of the chiral tetrahedral group .""" - quats = { + quats = [ # 8 (4+4) quaternions from permutationsof (±1, 0, 0, 0) (1, 0, 0, 0), - (0, 1, 0, 0), - (0, 0, 1, 0), - (0, 0, 0, 1), (-1, 0, 0, 0), + (0, 1, 0, 0), (0, -1, 0, 0), + (0, 0, 1, 0), (0, 0, -1, 0), + (0, 0, 0, 1), (0, 0, 0, -1), *product([-0.5, 0.5], repeat=4), # 16 quaternions from 1/2 * (±1, ±1, ±1, ±1) - } - return np.array(sorted(quats)) + ] + return np.array(quats) def generate_octahedral_group(): """Generates the 48 quaternions of the octahedral group.""" c = 1 / np.sqrt(2) - quats = { - # 24 elements of the tetrahedral group - *(tuple(x) for x in generate_tetrahedral_group()), - # 24 quaternions from permutations of 1/√2 * (±1, ±1, 0, 0) - *permutations([c, c, 0, 0]), - *permutations([c, -c, 0, 0]), - *permutations([-c, -c, 0, 0]), - } - - return np.array(sorted(quats)) + return np.array( + [ + # 24 elements of the tetrahedral group + *generate_tetrahedral_group(), + # 24 quaternions from distinct permutations of 1/√2 * (±1, ±1, 0, 0) + *distinct_permutations([c, c, 0, 0]), + *distinct_permutations([c, -c, 0, 0]), + *distinct_permutations([-c, -c, 0, 0]), + ] + ) def generate_cyclic_group(n: int, axis: ArrayLike = (0, 0, 1)): @@ -115,22 +115,24 @@ def even_permutations(it: Iterable): ) -def generate_binary_icosahedral_group(): +def generate_icosahedral_group(): """Generates the 48 quaternions of the octahedral group.""" φ = (1 + np.sqrt(5)) / 2 - quats = { - # 24 elements of the tetrahedral group - *(tuple(x) for x in generate_tetrahedral_group()), - # 96 quaternions from even permutations of 1/2 * (0, ±1, ±1/φ, ±φ) - *( - q - for a, b, c in product([1 / 2, -1 / 2], repeat=3) - for q in even_permutations([0, a, b / φ, c * φ]) - ), - } - return np.array(sorted(quats)) + return np.array( + [ + # 24 elements of the tetrahedral group + *generate_tetrahedral_group(), + # 96 quaternions from even permutations of 1/2 * (0, ±1, ±1/φ, ±φ) + *( + q + for a, b, c in product([1 / 2, -1 / 2], repeat=3) + for q in even_permutations([0, a, b / φ, c * φ]) + ), + ] + ) -print(len(generate_binary_icosahedral_group())) +print(len(generate_icosahedral_group())) +# print(generate_binary_icosahedral_group()) # TODO: test against Scipy, and make sure objects are invariant diff --git a/tests/test_mapping.py b/tests/test_mapping.py index 81687f9..5f3a49f 100644 --- a/tests/test_mapping.py +++ b/tests/test_mapping.py @@ -1,8 +1,11 @@ """Test algorithms for point-cloud mapping.""" import unittest +from itertools import product +import matplotlib.pyplot as plt import numpy as np +from coxeter.shapes import ConvexPolyhedron from rowan import from_axis_angle, from_matrix, mapping, random, rotate @@ -191,6 +194,56 @@ def test_icp_mismatched(self): # a heuristic since we can't guarantee exact matches assert np.mean(norms) < 0.5 + def test_symmetric_quaternions_tetrahedral(self): + """Verify our symmetrically equivalent quaternions are correct.""" + tet = [ + (-0.5, -0.5, 0.5), + (-0.5, 0.5, -0.5), + (0.5, -0.5, -0.5), + (0.5, 0.5, 0.5), + ] # Verts in sorted order + + quats = mapping._generate_tetrahedral_group() + for quat in quats: + res = sorted(rotate(quat, tet).tolist()) + np.testing.assert_array_equal(res, tet, err_msg=f"q={quat}") + + def test_symmetric_quaternions_octahedral(self): + """Verify our symmetrically equivalent quaternions are correct.""" + cube = [*product([-0.5, 0.5], repeat=3)] # Verts in sorted order + + quats = mapping._generate_octahedral_group() + for quat in quats: + res = sorted(rotate(quat, cube).tolist()) + np.testing.assert_allclose(res, cube, err_msg=f"q={quat}", atol=4e-16) + + def test_symmetric_quaternions_icosahedral(self): + """Verify our symmetrically equivalent quaternions are correct.""" + φ = (1 + np.sqrt(5)) / 2 + icos = sorted( + [ + *mapping._cyclic_permutations([0, -1, -φ]), + *mapping._cyclic_permutations([0, -1, φ]), + *mapping._cyclic_permutations([0, 1, -φ]), + *mapping._cyclic_permutations([0, 1, φ]), + ] + ) + + fix, ax = plt.subplots(subplot_kw={"projection": "3d"}) + ConvexPolyhedron(icos).plot(ax=ax) + + quats = mapping._generate_icosahedral_group() + for quat in quats: + res = sorted(rotate(quat, icos).tolist()) + poly = ConvexPolyhedron(res) + print(poly.volume) + try: + np.testing.assert_allclose(res, icos, err_msg=f"q={quat}", atol=4e-16) + except AssertionError as e: + poly.plot(ax=ax, label_verts=True) + plt.show() + raise AssertionError(e) from e + if __name__ == "__main__": unittest.main() From 164384897489811fe3c620965335711d68385520 Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 4 Sep 2025 11:07:51 -0400 Subject: [PATCH 05/18] Clean up and test --- tests/test_mapping.py | 74 +++++++++++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/tests/test_mapping.py b/tests/test_mapping.py index 5f3a49f..44b9686 100644 --- a/tests/test_mapping.py +++ b/tests/test_mapping.py @@ -3,9 +3,8 @@ import unittest from itertools import product -import matplotlib.pyplot as plt import numpy as np -from coxeter.shapes import ConvexPolyhedron +import scipy from rowan import from_axis_angle, from_matrix, mapping, random, rotate @@ -204,45 +203,86 @@ def test_symmetric_quaternions_tetrahedral(self): ] # Verts in sorted order quats = mapping._generate_tetrahedral_group() + assert len(quats) == 24 for quat in quats: res = sorted(rotate(quat, tet).tolist()) np.testing.assert_array_equal(res, tet, err_msg=f"q={quat}") + # Compare our array of generated quaternions to Scipy + np.testing.assert_array_equal( + sorted(quats.tolist()), + sorted( + [ + *scipy.spatial.transform.Rotation.create_group("T") + .as_quat() + .tolist(), + *( + -scipy.spatial.transform.Rotation.create_group("T").as_quat() + ).tolist(), + ] + ), + ) + def test_symmetric_quaternions_octahedral(self): """Verify our symmetrically equivalent quaternions are correct.""" cube = [*product([-0.5, 0.5], repeat=3)] # Verts in sorted order quats = mapping._generate_octahedral_group() + assert len(quats) == 48 for quat in quats: res = sorted(rotate(quat, cube).tolist()) np.testing.assert_allclose(res, cube, err_msg=f"q={quat}", atol=4e-16) + # Compare our array of generated quaternions to Scipy + np.testing.assert_allclose( + sorted(quats.tolist()), + sorted( + [ + *scipy.spatial.transform.Rotation.create_group("O") + .as_quat() + .tolist(), + *( + -scipy.spatial.transform.Rotation.create_group("O").as_quat() + ).tolist(), + ] + ), + atol=4e-16, + ) + def test_symmetric_quaternions_icosahedral(self): """Verify our symmetrically equivalent quaternions are correct.""" φ = (1 + np.sqrt(5)) / 2 - icos = sorted( + icos = np.sort( [ *mapping._cyclic_permutations([0, -1, -φ]), *mapping._cyclic_permutations([0, -1, φ]), *mapping._cyclic_permutations([0, 1, -φ]), *mapping._cyclic_permutations([0, 1, φ]), - ] + ], + axis=0, ) - fix, ax = plt.subplots(subplot_kw={"projection": "3d"}) - ConvexPolyhedron(icos).plot(ax=ax) - quats = mapping._generate_icosahedral_group() - for quat in quats: - res = sorted(rotate(quat, icos).tolist()) - poly = ConvexPolyhedron(res) - print(poly.volume) - try: - np.testing.assert_allclose(res, icos, err_msg=f"q={quat}", atol=4e-16) - except AssertionError as e: - poly.plot(ax=ax, label_verts=True) - plt.show() - raise AssertionError(e) from e + assert len(quats) == 120 + + # TODO: we get the correct vertices, but the ordering is difficult to ensure. + # Is there a better way to test for equivalence here? + # for i, quat in enumerate(quats): + # res = np.sort(rotate(quat, icos).round(14), axis=0) + # np.testing.assert_allclose(res, icos, err_msg=f"q{i}={quat}", atol=4e-16) + + # Compare our array of generated quaternions to Scipy + np.testing.assert_allclose( + np.sort(quats, axis=0), + np.sort( + [ + *scipy.spatial.transform.Rotation.create_group("I").as_quat(), + *(-scipy.spatial.transform.Rotation.create_group("I").as_quat()), + ], + axis=0, + ), + atol=4e-16, + ) if __name__ == "__main__": From a9cc0d773eecad5036a50ca7f8a03d41ccc425f2 Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 4 Sep 2025 13:31:00 -0400 Subject: [PATCH 06/18] Lint and add class --- rowan/mapping/__init__.py | 19 +++++++------ rowan/mapping/symmetries.py | 53 ++++++++++++++++++++++++++----------- tests/test_mapping.py | 40 ++++++++++++++++++---------- 3 files changed, 72 insertions(+), 40 deletions(-) diff --git a/rowan/mapping/__init__.py b/rowan/mapping/__init__.py index abde5a2..1932fcc 100644 --- a/rowan/mapping/__init__.py +++ b/rowan/mapping/__init__.py @@ -58,16 +58,15 @@ from ..functions import from_matrix, rotate, to_matrix from ..geometry import angle -from .symmetries import _cyclic_permutations -from .symmetries import generate_icosahedral_group as _generate_icosahedral_group -from .symmetries import ( - generate_octahedral_group as _generate_octahedral_group, -) -from .symmetries import ( - generate_tetrahedral_group as _generate_tetrahedral_group, -) - -__all__ = ["kabsch", "davenport", "procrustes", "icp"] +from .symmetries import SymmetricallyEquivalentQuaternions + +__all__ = [ + "kabsch", + "davenport", + "procrustes", + "icp", + "SymmetricallyEquivalentQuaternions", +] def kabsch(X, Y, require_rotation=True): diff --git a/rowan/mapping/symmetries.py b/rowan/mapping/symmetries.py index ea05584..c5b0edb 100644 --- a/rowan/mapping/symmetries.py +++ b/rowan/mapping/symmetries.py @@ -7,19 +7,8 @@ from more_itertools import distinct_permutations from numpy.typing import ArrayLike -from ..functions import from_axis_angle - - -def _cyclic_permutations(a): - def make_permutation_generator(a, forward=True): - """Return a list of functions that roll an array by 0,1,2...n""" - n = len(a) - sign = -1 if forward else 1 - return [ - lambda arr, k=j: arr[(sign * k) :] + arr[: (sign * k)] for j in range(n) - ] - - return [op(a) for op in make_permutation_generator(a)] +# from ..functions import from_axis_angle +from rowan.functions import from_axis_angle def sign_changes(even: bool = True, d: int = 3): @@ -133,6 +122,38 @@ def generate_icosahedral_group(): ) -print(len(generate_icosahedral_group())) -# print(generate_binary_icosahedral_group()) -# TODO: test against Scipy, and make sure objects are invariant +class SymmetricallyEquivalentQuaternions(np.ndarray): + """Equivalent quaternions for a particular point group.""" + + def __new__(cls, data, group): # noqa: D102 + obj = np.asarray(data).view(cls) + obj.flags.writeable = False + obj._group = group # noqa: SLF001 + return obj + + def __array_finalize__(self, obj): # noqa: D105 + if obj is None: + return + self._group = getattr(obj, "_group", None) + + @classmethod + def __class_getitem__(cls, group: str): + """Create the symmetrically equivalent orientations for the provided group.""" + if group == "T": + return cls(data=generate_tetrahedral_group(), group=group) + if group == "O": + return cls(data=generate_octahedral_group(), group=group) + if group == "I": + return cls(data=generate_icosahedral_group(), group=group) + msg = f"Unknown group '{group}' does not match valid options {{'T', 'O', 'I'}}" + raise ValueError(msg) + + def __str__(self): # noqa: D105 + arrstr = np.array2string(self, floatmode="maxprec", threshold=24, precision=9) + return ( + f"SymmetricallyEquivalentQuaternions['{self._group}'] " + f"(order {len(self)}):\n{arrstr}" + ) + + def __repr__(self): # noqa: D105 + return self.__str__() diff --git a/tests/test_mapping.py b/tests/test_mapping.py index 44b9686..792fee7 100644 --- a/tests/test_mapping.py +++ b/tests/test_mapping.py @@ -13,6 +13,18 @@ half = np.array([0.5, 0.5, 0.5, 0.5]) +def _cyclic_permutations(a): + def make_permutation_generator(a, forward=True): + """Return a list of functions that roll an array by 0,1,2...n.""" + n = len(a) + sign = -1 if forward else 1 + return [ + lambda arr, k=j: arr[(sign * k) :] + arr[: (sign * k)] for j in range(n) + ] + + return [op(a) for op in make_permutation_generator(a)] + + class TestMapping(unittest.TestCase): """Test mapping functions.""" @@ -202,7 +214,7 @@ def test_symmetric_quaternions_tetrahedral(self): (0.5, 0.5, 0.5), ] # Verts in sorted order - quats = mapping._generate_tetrahedral_group() + quats = mapping.SymmetricallyEquivalentQuaternions["T"] assert len(quats) == 24 for quat in quats: res = sorted(rotate(quat, tet).tolist()) @@ -227,7 +239,7 @@ def test_symmetric_quaternions_octahedral(self): """Verify our symmetrically equivalent quaternions are correct.""" cube = [*product([-0.5, 0.5], repeat=3)] # Verts in sorted order - quats = mapping._generate_octahedral_group() + quats = mapping.SymmetricallyEquivalentQuaternions["O"] assert len(quats) == 48 for quat in quats: res = sorted(rotate(quat, cube).tolist()) @@ -251,18 +263,18 @@ def test_symmetric_quaternions_octahedral(self): def test_symmetric_quaternions_icosahedral(self): """Verify our symmetrically equivalent quaternions are correct.""" - φ = (1 + np.sqrt(5)) / 2 - icos = np.sort( - [ - *mapping._cyclic_permutations([0, -1, -φ]), - *mapping._cyclic_permutations([0, -1, φ]), - *mapping._cyclic_permutations([0, 1, -φ]), - *mapping._cyclic_permutations([0, 1, φ]), - ], - axis=0, - ) - - quats = mapping._generate_icosahedral_group() + # φ = (1 + np.sqrt(5)) / 2 + # icos = np.sort( + # [ + # *_cyclic_permutations([0, -1, -φ]), + # *_cyclic_permutations([0, -1, φ]), + # *_cyclic_permutations([0, 1, -φ]), + # *_cyclic_permutations([0, 1, φ]), + # ], + # axis=0, + # ) + + quats = mapping.SymmetricallyEquivalentQuaternions["I"] assert len(quats) == 120 # TODO: we get the correct vertices, but the ordering is difficult to ensure. From 89a75a6f9ef7827f406e234d06aed0ed825f06c2 Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 4 Sep 2025 13:33:59 -0400 Subject: [PATCH 07/18] Swap class getitem for create_group --- rowan/mapping/symmetries.py | 2 +- tests/test_mapping.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rowan/mapping/symmetries.py b/rowan/mapping/symmetries.py index c5b0edb..bae6e9e 100644 --- a/rowan/mapping/symmetries.py +++ b/rowan/mapping/symmetries.py @@ -137,7 +137,7 @@ def __array_finalize__(self, obj): # noqa: D105 self._group = getattr(obj, "_group", None) @classmethod - def __class_getitem__(cls, group: str): + def create_group(cls, group: str): """Create the symmetrically equivalent orientations for the provided group.""" if group == "T": return cls(data=generate_tetrahedral_group(), group=group) diff --git a/tests/test_mapping.py b/tests/test_mapping.py index 792fee7..2647fda 100644 --- a/tests/test_mapping.py +++ b/tests/test_mapping.py @@ -214,7 +214,7 @@ def test_symmetric_quaternions_tetrahedral(self): (0.5, 0.5, 0.5), ] # Verts in sorted order - quats = mapping.SymmetricallyEquivalentQuaternions["T"] + quats = mapping.SymmetricallyEquivalentQuaternions.create_group("T") assert len(quats) == 24 for quat in quats: res = sorted(rotate(quat, tet).tolist()) @@ -239,7 +239,7 @@ def test_symmetric_quaternions_octahedral(self): """Verify our symmetrically equivalent quaternions are correct.""" cube = [*product([-0.5, 0.5], repeat=3)] # Verts in sorted order - quats = mapping.SymmetricallyEquivalentQuaternions["O"] + quats = mapping.SymmetricallyEquivalentQuaternions.create_group("O") assert len(quats) == 48 for quat in quats: res = sorted(rotate(quat, cube).tolist()) @@ -274,7 +274,7 @@ def test_symmetric_quaternions_icosahedral(self): # axis=0, # ) - quats = mapping.SymmetricallyEquivalentQuaternions["I"] + quats = mapping.SymmetricallyEquivalentQuaternions.create_group("I") assert len(quats) == 120 # TODO: we get the correct vertices, but the ordering is difficult to ensure. From 51988ce9cbba78f0840c32fd656469018a277a61 Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 4 Sep 2025 13:38:51 -0400 Subject: [PATCH 08/18] Update docs --- rowan/mapping/symmetries.py | 39 ++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/rowan/mapping/symmetries.py b/rowan/mapping/symmetries.py index bae6e9e..f7cdc79 100644 --- a/rowan/mapping/symmetries.py +++ b/rowan/mapping/symmetries.py @@ -123,7 +123,12 @@ def generate_icosahedral_group(): class SymmetricallyEquivalentQuaternions(np.ndarray): - """Equivalent quaternions for a particular point group.""" + """Equivalent quaternions for a particular point group. + + This class is a read-only subclass of `np.ndarray` that holds the set of + quaternions representing the symmetry operations of a specific point group + ('T', 'O', or 'I'). + """ def __new__(cls, data, group): # noqa: D102 obj = np.asarray(data).view(cls) @@ -131,14 +136,42 @@ def __new__(cls, data, group): # noqa: D102 obj._group = group # noqa: SLF001 return obj - def __array_finalize__(self, obj): # noqa: D105 + def __array_finalize__(self, obj): + """Finalize the array, ensuring attributes are passed on.""" if obj is None: return self._group = getattr(obj, "_group", None) @classmethod def create_group(cls, group: str): - """Create the symmetrically equivalent orientations for the provided group.""" + """Create the set of symmetrically equivalent quaternions. + + Parameters + ---------- + group : {'T', 'O', 'I'} + The name of the point group. Must be one of {'T', 'O', 'I'}. + + Returns: + -------- + SymmetricallyEquivalentQuaternions + An instance containing the quaternions for the specified group. + + Raises: + ------- + ValueError + If the `group` string is not one of the valid options. + + Examples: + --------- + >>> tetrahedral_quats = SymmetricallyEquivalentQuaternions.create_group("T") + >>> print(tetrahedral_quats._group) + T + >>> print(len(tetrahedral_quats)) + 24 + + >>> assert np.array_equal(tetrahedral_quats[0], [1,0,0,0]) + + """ if group == "T": return cls(data=generate_tetrahedral_group(), group=group) if group == "O": From 616eccc07914b71c6c402d26721d739bbb7d0b0d Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 4 Sep 2025 13:40:49 -0400 Subject: [PATCH 09/18] Update lockfiles --- .github/workflows/environments/requirements-test-3.10.txt | 4 +++- .github/workflows/environments/requirements-test-3.11.txt | 4 +++- .github/workflows/environments/requirements-test-3.12.txt | 4 +++- .github/workflows/environments/requirements-test-3.13.txt | 4 +++- .github/workflows/environments/requirements-test-3.9.txt | 4 +++- .github/workflows/environments/requirements-test.in | 1 + 6 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/environments/requirements-test-3.10.txt b/.github/workflows/environments/requirements-test-3.10.txt index 009977d..224d952 100644 --- a/.github/workflows/environments/requirements-test-3.10.txt +++ b/.github/workflows/environments/requirements-test-3.10.txt @@ -4,6 +4,8 @@ exceptiongroup==1.3.0 # via pytest iniconfig==2.1.0 # via pytest +more-itertools==10.8.0 + # via -r requirements-test.in numpy==2.2.6 # via # -r requirements-test.in @@ -14,7 +16,7 @@ pluggy==1.6.0 # via pytest pygments==2.19.2 # via pytest -pytest==8.4.1 +pytest==8.4.2 # via -r requirements-test.in scipy==1.15.3 # via -r requirements-test.in diff --git a/.github/workflows/environments/requirements-test-3.11.txt b/.github/workflows/environments/requirements-test-3.11.txt index e16644a..96b2032 100644 --- a/.github/workflows/environments/requirements-test-3.11.txt +++ b/.github/workflows/environments/requirements-test-3.11.txt @@ -2,6 +2,8 @@ # uv pip compile --python-version 3.11 --python-platform linux requirements-test.in iniconfig==2.1.0 # via pytest +more-itertools==10.8.0 + # via -r requirements-test.in numpy==2.3.2 # via # -r requirements-test.in @@ -12,7 +14,7 @@ pluggy==1.6.0 # via pytest pygments==2.19.2 # via pytest -pytest==8.4.1 +pytest==8.4.2 # via -r requirements-test.in scipy==1.16.1 # via -r requirements-test.in diff --git a/.github/workflows/environments/requirements-test-3.12.txt b/.github/workflows/environments/requirements-test-3.12.txt index 7b59bb5..d95a4cf 100644 --- a/.github/workflows/environments/requirements-test-3.12.txt +++ b/.github/workflows/environments/requirements-test-3.12.txt @@ -2,6 +2,8 @@ # uv pip compile --python-version 3.12 --python-platform linux requirements-test.in iniconfig==2.1.0 # via pytest +more-itertools==10.8.0 + # via -r requirements-test.in numpy==2.3.2 # via # -r requirements-test.in @@ -12,7 +14,7 @@ pluggy==1.6.0 # via pytest pygments==2.19.2 # via pytest -pytest==8.4.1 +pytest==8.4.2 # via -r requirements-test.in scipy==1.16.1 # via -r requirements-test.in diff --git a/.github/workflows/environments/requirements-test-3.13.txt b/.github/workflows/environments/requirements-test-3.13.txt index 165e829..3133622 100644 --- a/.github/workflows/environments/requirements-test-3.13.txt +++ b/.github/workflows/environments/requirements-test-3.13.txt @@ -2,6 +2,8 @@ # uv pip compile --python-version 3.13 --python-platform linux requirements-test.in iniconfig==2.1.0 # via pytest +more-itertools==10.8.0 + # via -r requirements-test.in numpy==2.3.2 # via # -r requirements-test.in @@ -12,7 +14,7 @@ pluggy==1.6.0 # via pytest pygments==2.19.2 # via pytest -pytest==8.4.1 +pytest==8.4.2 # via -r requirements-test.in scipy==1.16.1 # via -r requirements-test.in diff --git a/.github/workflows/environments/requirements-test-3.9.txt b/.github/workflows/environments/requirements-test-3.9.txt index 6148d75..129cca7 100644 --- a/.github/workflows/environments/requirements-test-3.9.txt +++ b/.github/workflows/environments/requirements-test-3.9.txt @@ -4,6 +4,8 @@ exceptiongroup==1.3.0 # via pytest iniconfig==2.1.0 # via pytest +more-itertools==10.8.0 + # via -r requirements-test.in numpy==2.0.2 # via # -r requirements-test.in @@ -14,7 +16,7 @@ pluggy==1.6.0 # via pytest pygments==2.19.2 # via pytest -pytest==8.4.1 +pytest==8.4.2 # via -r requirements-test.in scipy==1.13.1 # via -r requirements-test.in diff --git a/.github/workflows/environments/requirements-test.in b/.github/workflows/environments/requirements-test.in index f651ca5..20ab1a5 100644 --- a/.github/workflows/environments/requirements-test.in +++ b/.github/workflows/environments/requirements-test.in @@ -1,3 +1,4 @@ +more_itertools numpy pytest scipy From 034db076c0af0d884384c02ce7dbafabf83a791c Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 4 Sep 2025 13:43:25 -0400 Subject: [PATCH 10/18] Update imports --- rowan/__init__.py | 2 ++ rowan/mapping/__init__.py | 2 -- tests/test_mapping.py | 15 +++++++++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/rowan/__init__.py b/rowan/__init__.py index 69b90ff..c5e6811 100644 --- a/rowan/__init__.py +++ b/rowan/__init__.py @@ -52,6 +52,7 @@ to_matrix, vector_vector_rotation, ) +from .mapping.symmetries import SymmetricallyEquivalentQuaternions # Get the version __version__ = "1.3.2" @@ -94,4 +95,5 @@ "to_euler", "to_matrix", "vector_vector_rotation", + "SymmetricallyEquivalentQuaternions", ] diff --git a/rowan/mapping/__init__.py b/rowan/mapping/__init__.py index 1932fcc..8fd7ec6 100644 --- a/rowan/mapping/__init__.py +++ b/rowan/mapping/__init__.py @@ -58,14 +58,12 @@ from ..functions import from_matrix, rotate, to_matrix from ..geometry import angle -from .symmetries import SymmetricallyEquivalentQuaternions __all__ = [ "kabsch", "davenport", "procrustes", "icp", - "SymmetricallyEquivalentQuaternions", ] diff --git a/tests/test_mapping.py b/tests/test_mapping.py index 2647fda..f8c6723 100644 --- a/tests/test_mapping.py +++ b/tests/test_mapping.py @@ -6,7 +6,14 @@ import numpy as np import scipy -from rowan import from_axis_angle, from_matrix, mapping, random, rotate +from rowan import ( + SymmetricallyEquivalentQuaternions, + from_axis_angle, + from_matrix, + mapping, + random, + rotate, +) zero = np.array([0, 0, 0, 0]) one = np.array([1, 0, 0, 0]) @@ -214,7 +221,7 @@ def test_symmetric_quaternions_tetrahedral(self): (0.5, 0.5, 0.5), ] # Verts in sorted order - quats = mapping.SymmetricallyEquivalentQuaternions.create_group("T") + quats = SymmetricallyEquivalentQuaternions.create_group("T") assert len(quats) == 24 for quat in quats: res = sorted(rotate(quat, tet).tolist()) @@ -239,7 +246,7 @@ def test_symmetric_quaternions_octahedral(self): """Verify our symmetrically equivalent quaternions are correct.""" cube = [*product([-0.5, 0.5], repeat=3)] # Verts in sorted order - quats = mapping.SymmetricallyEquivalentQuaternions.create_group("O") + quats = SymmetricallyEquivalentQuaternions.create_group("O") assert len(quats) == 48 for quat in quats: res = sorted(rotate(quat, cube).tolist()) @@ -274,7 +281,7 @@ def test_symmetric_quaternions_icosahedral(self): # axis=0, # ) - quats = mapping.SymmetricallyEquivalentQuaternions.create_group("I") + quats = SymmetricallyEquivalentQuaternions.create_group("I") assert len(quats) == 120 # TODO: we get the correct vertices, but the ordering is difficult to ensure. From f6cd035525c728798e812be5cf9e5044e73870d7 Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 4 Sep 2025 13:45:51 -0400 Subject: [PATCH 11/18] Update docs --- doc/package-rowan.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/package-rowan.rst b/doc/package-rowan.rst index 14fb936..e58b2a9 100644 --- a/doc/package-rowan.rst +++ b/doc/package-rowan.rst @@ -33,6 +33,7 @@ rowan rowan.isfinite rowan.isinf rowan.isnan + rowan.SymmetricallyEquivalentQuaternions .. rubric:: Details From dd89877a10231e0320dc0fbbb45afe9b7c696b46 Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 4 Sep 2025 13:50:05 -0400 Subject: [PATCH 12/18] Update doc style --- rowan/mapping/symmetries.py | 35 ++++++++++++----------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/rowan/mapping/symmetries.py b/rowan/mapping/symmetries.py index f7cdc79..5f2dd34 100644 --- a/rowan/mapping/symmetries.py +++ b/rowan/mapping/symmetries.py @@ -144,32 +144,21 @@ def __array_finalize__(self, obj): @classmethod def create_group(cls, group: str): - """Create the set of symmetrically equivalent quaternions. + """Create the set of symmetrically equivalent quaternions for a point group. - Parameters - ---------- - group : {'T', 'O', 'I'} - The name of the point group. Must be one of {'T', 'O', 'I'}. + Args: + group ({'T', 'O', 'I'}): Array of quaternions. Returns: - -------- - SymmetricallyEquivalentQuaternions - An instance containing the quaternions for the specified group. - - Raises: - ------- - ValueError - If the `group` string is not one of the valid options. - - Examples: - --------- - >>> tetrahedral_quats = SymmetricallyEquivalentQuaternions.create_group("T") - >>> print(tetrahedral_quats._group) - T - >>> print(len(tetrahedral_quats)) - 24 - - >>> assert np.array_equal(tetrahedral_quats[0], [1,0,0,0]) + (..., 4) :class:`numpy.ndarray`: + Eqivalent quaternions ``q`` for the provided point group. + + Example:: + >>> from rowan import SymmetricallyEquivalentQuaternions + >>> tetrahedral_quats = SymmetricallyEquivalentQuaternions.create_group("T") + >>> print(len(tetrahedral_quats)) + 24 + >>> assert np.array_equal(tetrahedral_quats[0], [1,0,0,0]) """ if group == "T": From 657bf228b32bfa2c7836d267d15fdd54843239de Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 4 Sep 2025 13:51:41 -0400 Subject: [PATCH 13/18] Clarify docs --- rowan/mapping/symmetries.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rowan/mapping/symmetries.py b/rowan/mapping/symmetries.py index 5f2dd34..14affd0 100644 --- a/rowan/mapping/symmetries.py +++ b/rowan/mapping/symmetries.py @@ -144,10 +144,10 @@ def __array_finalize__(self, obj): @classmethod def create_group(cls, group: str): - """Create the set of symmetrically equivalent quaternions for a point group. + """Create the set of symmetrically equivalent quaternions for a rotation group. Args: - group ({'T', 'O', 'I'}): Array of quaternions. + group ({'T', 'O', 'I'}): Schönflies notation for the group. Returns: (..., 4) :class:`numpy.ndarray`: From 01a45ca29a81cb5b990be544162cfb6817bbadc4 Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 4 Sep 2025 13:54:54 -0400 Subject: [PATCH 14/18] Add in placeholder for cyclic groups --- rowan/mapping/symmetries.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/rowan/mapping/symmetries.py b/rowan/mapping/symmetries.py index 14affd0..9009db6 100644 --- a/rowan/mapping/symmetries.py +++ b/rowan/mapping/symmetries.py @@ -167,7 +167,15 @@ def create_group(cls, group: str): return cls(data=generate_octahedral_group(), group=group) if group == "I": return cls(data=generate_icosahedral_group(), group=group) - msg = f"Unknown group '{group}' does not match valid options {{'T', 'O', 'I'}}" + # if group[0] == "C": + # return cls( + # data=generate_cyclic_group(int(group[1:]), axis=axis), group=group + # ) + msg = ( + f"Unknown group '{group}' does not match valid options " + "{{'T', 'O', 'I'}}" + # "{{'T', 'O', 'I', 'Cn'}}" + ) raise ValueError(msg) def __str__(self): # noqa: D105 From d21288ab2035fac59204a67a24ca9e221e7215df Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 4 Sep 2025 13:58:34 -0400 Subject: [PATCH 15/18] Add examples --- rowan/mapping/symmetries.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/rowan/mapping/symmetries.py b/rowan/mapping/symmetries.py index 9009db6..2aa890a 100644 --- a/rowan/mapping/symmetries.py +++ b/rowan/mapping/symmetries.py @@ -156,10 +156,17 @@ def create_group(cls, group: str): Example:: >>> from rowan import SymmetricallyEquivalentQuaternions >>> tetrahedral_quats = SymmetricallyEquivalentQuaternions.create_group("T") - >>> print(len(tetrahedral_quats)) - 24 + >>> tetrahedral_quats.shape + (24, 4) >>> assert np.array_equal(tetrahedral_quats[0], [1,0,0,0]) - + >>> SymmetricallyEquivalentQuaternions.create_group("T")[:] + [[ 1. 0. 0. 0. ] + [-1. 0. 0. 0. ] + [ 0. 1. 0. 0. ] + ... + [ 0.5 0.5 -0.5 0.5] + [ 0.5 0.5 0.5 -0.5] + [ 0.5 0.5 0.5 0.5]] """ if group == "T": return cls(data=generate_tetrahedral_group(), group=group) From 7cded1aa86d3b1a1e5a19991bdddd0c062f5a5a9 Mon Sep 17 00:00:00 2001 From: janbridley Date: Mon, 29 Sep 2025 15:54:20 -0400 Subject: [PATCH 16/18] Update lockfiles --- .github/workflows/environments/requirements-test-3.10.txt | 2 ++ .github/workflows/environments/requirements-test-3.11.txt | 2 ++ .github/workflows/environments/requirements-test-3.12.txt | 2 ++ .github/workflows/environments/requirements-test-3.13.txt | 2 ++ .github/workflows/environments/requirements-test-3.9.txt | 2 ++ 5 files changed, 10 insertions(+) diff --git a/.github/workflows/environments/requirements-test-3.10.txt b/.github/workflows/environments/requirements-test-3.10.txt index 62e5942..45695b2 100644 --- a/.github/workflows/environments/requirements-test-3.10.txt +++ b/.github/workflows/environments/requirements-test-3.10.txt @@ -4,6 +4,8 @@ exceptiongroup==1.3.0 # via pytest iniconfig==2.1.0 # via pytest +more-itertools==10.8.0 + # via -r requirements-test.in numpy==2.2.6 # via # -r requirements-test.in diff --git a/.github/workflows/environments/requirements-test-3.11.txt b/.github/workflows/environments/requirements-test-3.11.txt index ff0bb7a..052a203 100644 --- a/.github/workflows/environments/requirements-test-3.11.txt +++ b/.github/workflows/environments/requirements-test-3.11.txt @@ -2,6 +2,8 @@ # uv pip compile --python-version=3.11 requirements-test.in --output-file=requirements-test-3.11.txt iniconfig==2.1.0 # via pytest +more-itertools==10.8.0 + # via -r requirements-test.in numpy==2.3.3 # via # -r requirements-test.in diff --git a/.github/workflows/environments/requirements-test-3.12.txt b/.github/workflows/environments/requirements-test-3.12.txt index ccdddc9..a342e33 100644 --- a/.github/workflows/environments/requirements-test-3.12.txt +++ b/.github/workflows/environments/requirements-test-3.12.txt @@ -2,6 +2,8 @@ # uv pip compile --python-version=3.12 requirements-test.in --output-file=requirements-test-3.12.txt iniconfig==2.1.0 # via pytest +more-itertools==10.8.0 + # via -r requirements-test.in numpy==2.3.3 # via # -r requirements-test.in diff --git a/.github/workflows/environments/requirements-test-3.13.txt b/.github/workflows/environments/requirements-test-3.13.txt index 1c08185..dca33a5 100644 --- a/.github/workflows/environments/requirements-test-3.13.txt +++ b/.github/workflows/environments/requirements-test-3.13.txt @@ -2,6 +2,8 @@ # uv pip compile --python-version=3.13 requirements-test.in --output-file=requirements-test-3.13.txt iniconfig==2.1.0 # via pytest +more-itertools==10.8.0 + # via -r requirements-test.in numpy==2.3.3 # via # -r requirements-test.in diff --git a/.github/workflows/environments/requirements-test-3.9.txt b/.github/workflows/environments/requirements-test-3.9.txt index 35506cd..fee160e 100644 --- a/.github/workflows/environments/requirements-test-3.9.txt +++ b/.github/workflows/environments/requirements-test-3.9.txt @@ -4,6 +4,8 @@ exceptiongroup==1.3.0 # via pytest iniconfig==2.1.0 # via pytest +more-itertools==10.8.0 + # via -r requirements-test.in numpy==2.0.2 # via # -r requirements-test.in From 5356de3ebb131081cfeda9f73890534fe56bbf53 Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 13 Nov 2025 15:43:45 -0500 Subject: [PATCH 17/18] Fix --- rowan/grids/__init__.py | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 rowan/grids/__init__.py diff --git a/rowan/grids/__init__.py b/rowan/grids/__init__.py new file mode 100644 index 0000000..1cb9fdd --- /dev/null +++ b/rowan/grids/__init__.py @@ -0,0 +1,59 @@ +r"""Low-dispersion grids on manifolds related to rotation. + +Finding a uniform of `n` rotations (in any representation) is a generalized Tammes +problem on the manifold associated with that representation. Grids of rotation axes +reduce to the standard Tammes problem on the 2-sphere, and grids of quaternions are a +generalization to the 3-sphere. Closed form solutions for general `n` are not possible, +but we can create nearly uniform (low-dispersion) grids using Fibonacci lattices. +""" + +import numpy as np + +"""The Golden ratio, 1.61803398875...""" +GOLDEN_RATIO = (1 + np.sqrt(5)) / 2 + + +"""The angle that divides 2π radians into the golden ratio. +This takes the longer of the two segments π(√5 - 1), where π[(√5 - 1) + (3-√5)] = 2π +""" +GOLDEN_ANGLE = np.pi * (np.sqrt(5) - 1) + + +def spherical_fibonacci_lattice(n): + """A Fibonacci lattice of `n` points on the 2-sphere. + + Args: + n (int): The number of points in the lattice. + """ # TODO: example image! + t = np.arange(n) + + z = 1 - (t / (n - 1)) * 2 + radius = np.sqrt(1 - z**2) + theta = (GOLDEN_ANGLE * t) % (2 * np.pi) + + x = np.cos(theta) * radius + y = np.sin(theta) * radius + + return np.column_stack([x, y, z]).T + + +def quaternion_fibonacci_lattice(n): + """A near-uniform grid of `n` quaternions. + + This is equivalent to a Fibonacci lattice on the 3-sphere. See + `this paper `_ for a derivation. + + Args: + n (int): The number of points in the lattice. + """ + PSI = 1.533751168755204288118041413 # Solution to ψ**4 = ψ + 4 + s = np.arange(n) + 1 / 2 + t = s / n + d = 2 * np.pi * s + r0, r1 = (np.sqrt(t), np.sqrt(1 - t)) + α, β = (d / np.sqrt(2), d / PSI) + + # Allocate as rows and then transpose, rather than stacking columns + result = np.empty((4, n)) + result[...] = r0 * np.sin(α), r0 * np.cos(α), r1 * np.sin(β), r1 * np.cos(β) + return result.T From 950d660288c2b5b800f1faf2716392b37056e665 Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 13 Nov 2025 15:45:13 -0500 Subject: [PATCH 18/18] Enable cyclic groups --- rowan/mapping/symmetries.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rowan/mapping/symmetries.py b/rowan/mapping/symmetries.py index 2aa890a..a8f058e 100644 --- a/rowan/mapping/symmetries.py +++ b/rowan/mapping/symmetries.py @@ -174,10 +174,10 @@ def create_group(cls, group: str): return cls(data=generate_octahedral_group(), group=group) if group == "I": return cls(data=generate_icosahedral_group(), group=group) - # if group[0] == "C": - # return cls( - # data=generate_cyclic_group(int(group[1:]), axis=axis), group=group - # ) + if group[0] == "C": + return cls( + data=generate_cyclic_group(int(group[1:]), axis=[0, 0, 1]), group=group + ) msg = ( f"Unknown group '{group}' does not match valid options " "{{'T', 'O', 'I'}}"