Skip to content

Commit 3336a78

Browse files
committed
Add from_path_ends plus example
1 parent 674c95b commit 3336a78

File tree

5 files changed

+194
-0
lines changed

5 files changed

+194
-0
lines changed

orix/quaternion/misorientation.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,39 @@ def from_scipy_rotation(
273273
M.symmetry = symmetry
274274
return M
275275

276+
@classmethod
277+
def from_path_ends(
278+
cls, points: Misorientation, closed: bool = False, steps: int = 100
279+
) -> Misorientation:
280+
"""Return misorientations tracing the shortest path between
281+
two or more consecutive points.
282+
Parameters
283+
----------
284+
points
285+
Two or more misorientations that define waypoints along
286+
a path through rotation space (SO3).
287+
closed
288+
Option to add a final trip from the last waypoint back to
289+
the first, thus closing the loop. The default is False.
290+
steps
291+
Number of misorientations to return along the path
292+
between each pair of waypoints. The default is 100.
293+
Returns
294+
-------
295+
path
296+
misorientations that map a path between the given waypoints.
297+
"""
298+
# Confirm `points` are (mis)orientations.
299+
if not isinstance(points, Misorientation):
300+
raise TypeError(
301+
f"Points must be a Misorientation, not of type {type(points)}"
302+
)
303+
# Create a path through Quaternion space, then reapply the symmetry.
304+
out = super().from_path_ends(points=points, closed=closed, steps=steps)
305+
path = cls(out.data)
306+
path._symmetry = points._symmetry
307+
return path
308+
276309
@classmethod
277310
def random(
278311
cls,

orix/quaternion/orientation.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,39 @@ def from_scipy_rotation(
352352
O.symmetry = symmetry
353353
return O
354354

355+
@classmethod
356+
def from_path_ends(
357+
cls, points: Orientation, closed: bool = False, steps: int = 100
358+
) -> Misorientation:
359+
"""Return orientations tracing the shortest path between
360+
two or more consecutive points.
361+
Parameters
362+
----------
363+
points
364+
Two or more orientations that define waypoints along
365+
a path through rotation space (SO3).
366+
closed
367+
Option to add a final trip from the last waypoint back to
368+
the first, thus closing the loop. The default is False.
369+
steps
370+
Number of orientations to return along the path
371+
between each pair of waypoints. The default is 100.
372+
Returns
373+
-------
374+
path
375+
orientations that map a path between the given waypoints.
376+
"""
377+
# Confirm `points` are orientations.
378+
if not isinstance(points, Orientation):
379+
raise TypeError(
380+
f"Points must be an Orientation instance, not of type {type(points)}"
381+
)
382+
# Create a path through Quaternion space, then reapply the symmetry.
383+
out = super().from_path_ends(points=points, closed=closed, steps=steps)
384+
path = cls(out.data)
385+
path._symmetry = points._symmetry
386+
return path
387+
355388
@classmethod
356389
def random(
357390
cls, shape: int | tuple[int, ...] = 1, symmetry: Symmetry | None = None

orix/quaternion/quaternion.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,47 @@ def from_align_vectors(
691691

692692
return out[0] if len(out) == 1 else tuple(out)
693693

694+
@classmethod
695+
def from_path_ends(
696+
cls, points: Quaternion, closed: bool = False, steps: int = 100
697+
) -> Quaternion:
698+
"""Return quaternions tracing the shortest path between two or
699+
more consecutive points.
700+
Parameters
701+
----------
702+
points
703+
Two or more quaternions that define points along a path
704+
through rotation space (SO3).
705+
closed
706+
Option to add a final trip from the last waypoint back to
707+
the first, thus closing the loop. The default is False.
708+
steps
709+
Number of quaternions to return along the path between each
710+
pair of waypoints. The default is 100.
711+
Returns
712+
-------
713+
path
714+
quaternions that map a path between the given waypoints.
715+
"""
716+
points = points.flatten()
717+
n = points.size
718+
if not closed:
719+
n = n - 1
720+
721+
path_list = []
722+
for i in range(n):
723+
# get start and end for this leg of the trip
724+
q1 = points[i]
725+
q2 = points[(i + 1) % (points.size)]
726+
# find the ax/ang describing the trip between points
727+
ax, ang = _conversions.qu2ax((~q1 * q2).data)
728+
# get 'steps=n' steps along the trip and add them to the journey
729+
trip = Quaternion.from_axes_angles(ax, np.linspace(0, ang, steps))
730+
path_list.append((q1 * (trip.flatten())).data)
731+
path_data = np.concatenate(path_list, axis=0)
732+
path = cls(path_data)
733+
return path
734+
694735
@classmethod
695736
def triple_cross(cls, q1: Quaternion, q2: Quaternion, q3: Quaternion) -> Quaternion:
696737
"""Pointwise cross product of three quaternions.

orix/tests/test_quaternion/test_orientation.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,61 @@ def test_symmetry_property_wrong_number_of_values_misorientation(error_type, val
311311
o.symmetry = value
312312

313313

314+
def test_from_path_ends():
315+
"""check from_path_ends returns what you would expect and
316+
preserves symmetry information.
317+
In particular, ensure the class of the returned object matches the class
318+
used for creating it, NOT the class of the object passed in.
319+
"""
320+
q = Quaternion.random(10)
321+
r = Rotation.random(10)
322+
o = Orientation.random(10, Oh)
323+
m = Misorientation.random(10, [D3, Oh])
324+
325+
# Quaternion sanity checks
326+
a = Quaternion.from_path_ends(q)
327+
assert isinstance(a, Quaternion)
328+
b = Quaternion.from_path_ends(r)
329+
assert isinstance(b, Quaternion)
330+
c = Quaternion.from_path_ends(o)
331+
assert isinstance(c, Quaternion)
332+
d = Quaternion.from_path_ends(m)
333+
assert isinstance(d, Quaternion)
334+
335+
# Rotation sanity checks
336+
a = Rotation.from_path_ends(q)
337+
assert isinstance(a, Rotation)
338+
b = Rotation.from_path_ends(r)
339+
assert isinstance(b, Rotation)
340+
c = Rotation.from_path_ends(o)
341+
assert isinstance(c, Rotation)
342+
d = Rotation.from_path_ends(m)
343+
assert isinstance(d, Rotation)
344+
345+
# Misorientation sanity checks
346+
with pytest.raises(TypeError, match="Points must be a Misorientation"):
347+
a = Misorientation.from_path_ends(q)
348+
with pytest.raises(TypeError, match="Points must be a Misorientation"):
349+
b = Misorientation.from_path_ends(r)
350+
c = Misorientation.from_path_ends(o)
351+
assert isinstance(c, Misorientation)
352+
d = Misorientation.from_path_ends(m)
353+
assert isinstance(d, Misorientation)
354+
assert c.symmetry[1] == o.symmetry
355+
assert d.symmetry == m.symmetry
356+
357+
# Orientation sanity checks
358+
with pytest.raises(TypeError, match="Points must be an Orientation"):
359+
a = Orientation.from_path_ends(q)
360+
with pytest.raises(TypeError, match="Points must be an Orientation"):
361+
b = Orientation.from_path_ends(r)
362+
c = Orientation.from_path_ends(o)
363+
assert c.symmetry == o.symmetry
364+
assert isinstance(c, Orientation)
365+
with pytest.raises(TypeError, match="Points must be an Orientation"):
366+
d = Orientation.from_path_ends(m)
367+
368+
314369
class TestMisorientation:
315370
def test_get_distance_matrix(self):
316371
"""Compute distance between every misorientation in an instance
@@ -811,6 +866,11 @@ def test_in_fundamental_region(self):
811866
region = np.radians(pg.euler_fundamental_region)
812867
assert np.all(np.max(ori.in_euler_fundamental_region(), axis=0) <= region)
813868

869+
def test_from_path_ends(self):
870+
# generate paths with orientations to check symmetry copying
871+
wp_o = Orientation(data=np.eye(4)[:2], symmetry=Oh)
872+
assert Orientation.from_path_ends(wp_o)._symmetry == (C1, Oh)
873+
814874
def test_inverse(self):
815875
O1 = Orientation([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], D6)
816876
O2 = ~O1

orix/tests/test_quaternion/test_quaternion.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,33 @@ def test_equality(self):
336336
assert Q1 != Q2
337337
assert Q1 == Q2.inv()
338338

339+
[
340+
[1, 0, 0, 0],
341+
[1, 0, 0, 0],
342+
[1, 1, 0, 0],
343+
[1, 0, 1, 0],
344+
[1, 0, 0, 1],
345+
[1, 0, 0, 1],
346+
[-1, 0, 0, -1],
347+
[-1, 0, 0, 1],
348+
]
349+
)
350+
)
351+
path = Quaternion.from_path_ends(waypoints)
352+
loop = Quaternion.from_path_ends(waypoints, closed=True, steps=11)
353+
path_spacing = [(x[1:]).dot(x[:-1]) for x in path.reshape(11, 100)]
354+
loop_spacing = [(x[1:]).dot(x[:-1]) for x in loop.reshape(12, 11)]
355+
assert np.all(np.std(path_spacing, axis=1) < 1e-12)
356+
assert np.all(np.std(loop_spacing, axis=1) < 1e-12)
357+
358+
def test_from_path_ends_fiber(self):
359+
# check that a linear path in quaternion space follows an explicitly defined
360+
Q2 = Quaternion.from_axes_angles([1, 1, 1], 60, degrees=True)
361+
Q12 = Quaternion.stack((Q1, Q2))
362+
Q_path1 = Quaternion.from_axes_angles([1, 1, 1], np.arange(59), degrees=True)
363+
Q_path2 = Quaternion.from_path_ends(Q12, steps=Q_path1.size)
364+
assert np.allclose(Q_path1.dot(Q_path2), 1, atol=1e-3)
365+
339366

340367
class TestToFromEuler:
341368
"""These tests address .to_euler() and .from_euler()."""

0 commit comments

Comments
 (0)