Coverage for src/pygnd/quaternions.py: 83%
237 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-03 23:45 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-03 23:45 +0000
1"""
3Unit normal quaternions (points that sit on the surface of the 3-sphere with
4unit radius in 4D Euclidean space) are used to represent 3D rotations. This
5module provides a set of operations for working with quaternions in general.
6Often times only the angle of the rotation is needed for comparison amongst
7quaternions, so separate functions are provided for accelerating this common
8operation. The quaternion (w, x, y, z) is used to represent a rotation that is
9indistinguishable from the quaternion (-w, x, y, z), so the standardization
10function is provided to make the real part non-negative by conjugation, limiting
11the hypervolume we work with to the positive w hemisphere of the 3-sphere.
13For more information on quaternions, see:
15https://en.wikipedia.org/wiki/Quaternion
17Adopted from Pynp3D
19https://github.com/facebookresearch/pynp3d
21"""
23import numpy as np
24from tqdm import tqdm
25from pygnd.rotations import _EPSIJK
26from joblib import Parallel, delayed
29# Basic operations
32def qu_std(qu: np.ndarray) -> np.ndarray:
33 """
34 Standardize unit quaternion to have non-negative real part.
36 Args:
37 qu: shape (..., 4) quaternions in form (w, x, y, z)
39 Returns:
40 Standardized quaternions as array of shape (..., 4).
41 """
42 return np.where(qu[..., 0:1] >= 0, qu, -qu)
45def qu_norm(qu: np.ndarray) -> np.ndarray:
46 """
47 Normalize quaternions to unit norm.
49 Args:
50 qu: shape (..., 4) quaternions in form (w, x, y, z)
52 Returns:
53 np.ndarray of normalized quaternions.
54 """
55 norms = np.linalg.norm(qu, axis=-1, keepdims=True)
56 with np.errstate(divide="ignore", invalid="ignore"):
57 return np.where(norms > 0, qu / norms, 0)
60def qu_norm_std(qu: np.ndarray) -> np.ndarray:
61 """
62 Normalize a quaternion to unit norm and make real part non-negative.
64 Args:
65 qu: shape (..., 4) quaternions in form (w, x, y, z)
67 Returns:
68 np.ndarray of normalized and standardized quaternions.
69 """
70 return qu_std(qu_norm(qu))
73def qu_conj(qu: np.ndarray) -> np.ndarray:
74 """
75 Get the unit quaternions for the inverse action.
77 Args:
78 qu: shape (..., 4) quaternions in form (w, x, y, z)
80 Returns:
81 The inverse, a array of quaternions of shape (..., 4).
82 """
83 scaling = np.array([1, -1, -1, -1], dtype=qu.dtype)
84 return qu * scaling
87def qu_angle(qu: np.ndarray) -> np.ndarray:
88 """
89 Compute angles of rotation for quaternions.
91 Args:
92 qu: shape (..., 4) quaternions in form (w, x, y, z)
94 Returns:
95 array of shape (..., ) of rotation angles.
96 """
97 out = np.where(np.isclose(qu[..., 0], 1.0), 0.0, 2 * np.arccos(qu[..., 0]))
98 return out
101def qu_axis(qu: np.ndarray) -> np.ndarray:
102 """
103 Compute the axis of rotation for quaternions.
105 Args:
106 qu: shape (..., 4) quaternions in form (w, x, y, z)
108 Returns:
109 array of shape (..., 3) of rotation axes.
110 """
111 mag = np.linalg.norm(qu[..., 1:], axis=-1, keepdims=True)
112 return np.where(np.isclose(mag, 0.0), [0.0, 0.0, 1.0], qu[..., 1:] / mag)
115# Multiplication operations
118def qu_prod_raw(a: np.ndarray, b: np.ndarray) -> np.ndarray:
119 """
120 Multiply two quaternions.
121 Usual np rules for broadcasting apply.
123 Args:
124 a: shape (..., 4) quaternions in form (w, x, y, z)
125 b: shape (..., 4) quaternions in form (w, x, y, z)
127 Returns:
128 The product of a and b, a array of quaternions shape (..., 4).
129 """
130 aw, ax, ay, az = a[..., 0], a[..., 1], a[..., 2], a[..., 3]
131 bw, bx, by, bz = b[..., 0], b[..., 1], b[..., 2], b[..., 3]
133 ow = aw * bw - ax * bx - (ay * by + az * bz)
134 ow = aw * bw - ax * bx - (ay * by + az * bz)
135 ox = aw * bx + ax * bw + _EPSIJK * (ay * bz - az * by)
136 oy = aw * by + ay * bw + _EPSIJK * (az * bx - ax * bz)
137 oz = aw * bz + az * bw + _EPSIJK * (ax * by - ay * bx)
139 return np.stack((ow, ox, oy, oz), -1)
142def qu_prod(a: np.ndarray, b: np.ndarray) -> np.ndarray:
143 """
144 Quaternion multiplication, then make real part non-negative.
146 Args:
147 a: shape (..., 4) quaternions in form (w, x, y, z)
148 b: shape (..., 4) quaternions in form (w, x, y, z)
150 Returns:
151 a*b np.ndarray shape (..., 4) of the quaternion product.
153 """
154 ab = qu_prod_raw(a, b)
155 return qu_std(ab)
158def qu_prod_axis(a: np.ndarray, b: np.ndarray) -> np.ndarray:
159 """
160 Return the axis of the quaternion product.
162 Args:
163 a: shape (..., 4) quaternions in form (w, x, y, z)
164 b: shape (..., 4) quaternions in form (w, x, y, z)
166 Returns:
167 a*b np.ndarray shape (..., 3) of quaternion product axes.
168 """
169 aw, ax, ay, az = a[..., 0], a[..., 1], a[..., 2], a[..., 3]
170 bw, bx, by, bz = b[..., 0], b[..., 1], b[..., 2], b[..., 3]
171 ox = aw * bx + ax * bw + _EPSIJK * ay * bz - _EPSIJK * az * by
172 oy = aw * by - _EPSIJK * ax * bz + ay * bw + _EPSIJK * az * bx
173 oz = aw * bz + _EPSIJK * ax * by - _EPSIJK * ay * bx + az * bw
175 return np.stack((ox, oy, oz), -1)
178def qu_prod_pos_real(a: np.ndarray, b: np.ndarray) -> np.ndarray:
179 """
180 Return only the magnitude of the real part of the quaternion product.
182 Args:
183 a: shape (..., 4) quaternions in form (w, x, y, z)
184 b: shape (..., 4) quaternions in form (w, x, y, z)
186 Returns:
187 a*b np.ndarray shape (..., ) of quaternion product real part magnitudes.
188 """
189 aw, ax, ay, az = a[..., 0], a[..., 1], a[..., 2], a[..., 3]
190 bw, bx, by, bz = b[..., 0], b[..., 1], b[..., 2], b[..., 3]
191 ow = aw * bw - ax * bx - ay * by - az * bz
192 return np.abs(ow)
195def qu_triple_prod_pos_real(a: np.ndarray, b: np.ndarray, c: np.ndarray) -> np.ndarray:
196 """
197 Return only the magnitude of the real part of the quaternion triple product.
199 Args:
200 a: shape (..., 4) quaternions in form (w, x, y, z)
201 b: shape (..., 4) quaternions in form (w, x, y, z)
202 c: shape (..., 4) quaternions in form (w, x, y, z)
204 Returns:
205 a*b*c np.ndarray shape (..., ) of quaternion triple product real part magnitudes.
206 """
207 return qu_prod_pos_real(a, qu_prod(b, c))
210# Misorientations
213def qu_misorientation(q1: np.ndarray, q2: np.ndarray) -> np.ndarray:
214 """Calculates the misorientation quaternion between two quaternions.
216 Args:
217 q1: shape (..., 4) quaternions
218 q2: shape (..., 4) quaternions
220 Returns:
221 The misorientation quaternion, a np.ndarray of shape (..., 4)
222 """
223 return qu_prod(q1, qu_conj(q2))
226def qu_disorientation(
227 quats1: np.ndarray, quats2: np.ndarray, laue_id_1: int, laue_id_2: int, naive=True
228):
229 """
231 Return the disorientation quaternion between the given quaternions.
233 Args:
234 quats1: quaternions of shape (..., 4)
235 quats2: quaternions of shape (..., 4)
236 laue_id_1: laue group ID of quats1
237 laue_id_2: laue group ID of quats2
238 naive: whether to use the naive method or the more accurate method
240 Returns:
241 disorientation quaternion of shape (..., 4)
243 """
245 # get the important shapes
246 data_shape = quats2.shape
248 # check that the shapes are the same
249 if data_shape == (4,): 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true
250 data_shape = (1, 4)
251 quats1 = quats1.reshape(data_shape)
252 elif data_shape == (1, 4): 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true
253 pass
254 elif data_shape == quats1.shape:
255 pass
256 else:
257 raise ValueError(
258 f"quats1 and quats2 must have the same data shape, or quats1 must be a single quaternion, but got {data_shape} and {quats2.shape}"
259 )
260 if not ((quats1.dtype == np.float64) or (quats1.dtype == np.float32)):
261 raise ValueError("Quaternions must be of type float32 or float64")
262 if not ((quats2.dtype == np.float64) or (quats2.dtype == np.float32)): 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true
263 raise ValueError("Quaternions must be of type float32 or float64")
265 # multiply by inverse of second (without symmetry)
266 misori_quats = qu_prod(quats1, qu_conj(quats2))
268 # find the number of quaternions (generic input shapes are supported)
269 N = int(np.prod(np.array(data_shape[:-1])))
271 # retrieve the laue group elements for the first quaternions
272 laue_group_1 = laue_elements(laue_id_1)
273 # laue_group_1 = np.vstack((laue_group_1, qu_conj(laue_group_1)))
275 # if the laue groups are the same, then the second laue group is the same as the first
276 if laue_id_1 == laue_id_2: 276 ↛ 279line 276 didn't jump to line 279 because the condition on line 276 was always true
277 laue_group_2 = laue_group_1
278 else:
279 laue_group_2 = laue_elements(laue_id_2)
280 # laue_group_2 = np.vstack((laue_group_2, qu_conj(laue_group_2)))
282 # pre / post mult by Laue operators of the second and first symmetry groups respectively
283 # broadcasting is done so that the output is of shape (N, |laue_group_2|, |laue_group_1|, 4)
284 equivalent_quaternions = qu_prod_raw(
285 laue_group_2.reshape(1, -1, 1, 4),
286 qu_prod_raw(misori_quats.reshape((N, 1, 1, 4)), laue_group_1.reshape((1, 1, -1, 4))),
287 )
289 # flatten along the laue group dimensions
290 equivalent_quaternions = equivalent_quaternions.reshape(N, -1, 4)
291 equivalent_quaternions = np.unique(equivalent_quaternions, axis=1)
293 # find the quaternion with the largest real part value (smallest angle)
294 row_maximum_indices = np.argmax(
295 np.abs(equivalent_quaternions[..., 0]),
296 axis=-1,
297 )
299 # if naive, just grab the first quaternion that has the largest real part value
300 if naive:
301 output = equivalent_quaternions[np.arange(N), row_maximum_indices]
303 # if not naive, then we find the quaternion with the largest real part and with the axis in the fundamental sector
304 else:
305 output = np.zeros((N, 4), dtype=quats1.dtype)
306 abs_scalars = np.abs(equivalent_quaternions[..., 0])
307 for i in tqdm(range(N)):
308 mask = np.isclose(
309 abs_scalars[i],
310 abs_scalars[i, row_maximum_indices[i]],
311 rtol=1e-6,
312 atol=1e-6,
313 )
314 out_equivalent = qu_norm_std(equivalent_quaternions[i][mask]) + 0.0
315 if [1.0, 0.0, 0.0, 0.0] in out_equivalent.tolist(): 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true
316 print("Found identity quaternion")
317 output[i] = [1.0, 0.0, 0.0, 0.0]
318 continue
319 angles = qu_angle(out_equivalent)
320 axes = qu_axis(out_equivalent)
321 angles[axes[..., 2] < 0] *= -1
322 axes[axes[..., 2] < 0] *= -1
323 mask1 = (axes[..., 0] >= 0) & (axes[..., 1] >= 0) & (axes[..., 2] >= 0) # all positive
324 mask2 = (axes[..., 2] >= axes[..., 1]) & (
325 axes[..., 1] >= axes[..., 0]
326 ) # ascending order
327 mask3 = mask1 & mask2
328 if mask3.sum() == 0: 328 ↛ 329line 328 didn't jump to line 329 because the condition on line 328 was never true
329 print(out_equivalent)
330 print("Number of minimum angle quaternions", out_equivalent.shape[0])
331 print(axes)
332 print("mask1", mask1.sum())
333 print("mask2", mask2.sum())
334 print("mask all", mask3.sum())
335 print(equivalent_quaternions[i][mask][mask3])
336 print(out_equivalent[mask3])
337 print(axes[mask3])
338 print(angles[mask3])
339 raise ValueError("No equivalent quaternion found in the fundamental sector")
340 output[i] = out_equivalent[mask3][0]
342 return qu_norm_std(output.reshape(data_shape))
345def qu_disorientation_directional(quats1: np.ndarray, quats2: np.ndarray, laue_id: int):
346 """
348 Return the disorientation quaternion between the given quaternions.
350 Args:
351 quats1: quaternions of shape (..., 4)
352 quats2: quaternions of shape (..., 4)
353 laue_id: laue group ID of quats
355 Returns:
356 disorientation quaternion of shape (..., 4)
358 """
360 # get the important shapes
361 data_shape = quats2.shape
363 # check that the shapes are the same
364 if data_shape == (4,): 364 ↛ 365line 364 didn't jump to line 365 because the condition on line 364 was never true
365 data_shape = (1, 4)
366 quats1 = quats1.reshape(data_shape)
367 elif data_shape == (1, 4): 367 ↛ 368line 367 didn't jump to line 368 because the condition on line 367 was never true
368 pass
369 elif data_shape == quats1.shape:
370 pass
371 else:
372 raise ValueError(
373 f"quats1 and quats2 must have the same data shape, or quats1 must be a single quaternion, but got {data_shape} and {quats2.shape}"
374 )
375 if not ((quats1.dtype == np.float64) or (quats1.dtype == np.float32)): 375 ↛ 376line 375 didn't jump to line 376 because the condition on line 375 was never true
376 raise ValueError("Quaternions must be of type float32 or float64")
377 if not ((quats2.dtype == np.float64) or (quats2.dtype == np.float32)): 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true
378 raise ValueError("Quaternions must be of type float32 or float64")
380 # find the number of quaternions (generic input shapes are supported)
381 N = int(np.prod(np.array(data_shape[:-1])))
383 # retrieve the laue group elements for the first quaternions
384 laue_group = laue_elements(laue_id)
386 # symmetrize the quaternions
387 quats2 = qu_prod_raw(laue_group, quats2.reshape(N, 1, 4))
389 # get misorientation quaternions, pairing each quats1[i] with the symmetric
390 # equivalents of quats2[i] (not every quats2[j])
391 misori_quats = qu_prod(quats1.reshape(N, 1, 4), qu_conj(quats2))
393 # Collapse to only the unique quaternions
394 misori_quats = np.unique(misori_quats, axis=1)
396 # find the quaternion with the largest real part value (smallest angle)
397 row_maximum_indices = np.argmax(np.abs(misori_quats[..., 0]), axis=-1)
399 # if naive, just grab the first quaternion that has the largest real part value
400 disori_quats = misori_quats[np.arange(N), row_maximum_indices]
401 return qu_std(disori_quats.reshape(data_shape))
404# Advanced / Unique operations
407def qu_slerp(a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray:
408 """
409 Spherical linear interpolation between two quaternions.
411 Args:
412 a: shape (..., 4) quaternions in form (w, x, y, z)
413 b: shape (..., 4) quaternions in form (w, x, y, z)
414 t: interpolation parameter between 0 and 1
416 Returns:
417 The interpolated quaternions, a array of shape (..., 4).
418 """
419 a = qu_norm(a)
420 b = qu_norm(b)
421 cos_theta = np.sum(a * b, axis=-1)
422 angle = np.acos(cos_theta)
423 sin_theta = np.sin(angle)
424 w1 = np.sin((1 - t) * angle) / sin_theta
425 w2 = np.sin(t * angle) / sin_theta
426 return a * w1[..., None] + b * w2[..., None]
429def qu_log(q: np.ndarray, tol=1e-6) -> np.ndarray:
430 """Logarithm of a quaternion.
431 log(q) = [0, theta*n] where q = [cos(theta), sin(theta)*n]
432 quaternion should be scalar first, vector second.
433 """
434 # Make sure the quaternion is a unit quaternion
435 q = qu_norm_std(q)
436 # Separate into scalar and vector components
437 s, v = q[..., 0], q[..., 1:]
438 # Get the angle
439 theta = np.arccos(s)
440 # Use the angle to get the rotation vector
441 norm_v = np.linalg.norm(v, axis=-1)
442 with np.errstate(divide="ignore", invalid="ignore"):
443 qlog = v * np.where(norm_v > tol, theta / norm_v, 0).reshape(q.shape[:-1] + (1,))
444 return qlog
447def qu_avg(q: np.ndarray, laue_id, chunk_size=None) -> np.ndarray:
448 """Calculates the average quaternion from a set of quaternions.
450 Args:
451 q: shape (N, 4) quaternions
452 laue_id: integer between inclusive [1, 11]
454 Returns:
455 The average quaternion, a np.ndarray of shape (4,)
456 """
457 S = laue_elements(laue_id)
458 q = qu_norm_std(q)
459 q0, qn = q[0], q[1:]
460 if chunk_size is not None: 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true
461 chunk_size = min(chunk_size, qn.shape[0])
462 idx = np.random.choice(qn.shape[0], size=chunk_size, replace=False)
463 qn = qn[idx]
464 qn_sym = qu_prod(S[None], qn[:, None])
465 dots = np.abs(q0.dot(qn_sym.transpose(0, 2, 1)))
466 idx = dots.argmax(axis=1)
467 qn_close = qn_sym[np.arange(qn_sym.shape[0]), idx]
468 return qn_close.mean(axis=0)
471# Rotations
474def qu_apply(qu: np.ndarray, point: np.ndarray) -> np.ndarray:
475 """
476 Rotate 3D points by unit quaternions.
478 Args:
479 qu: shape (..., 4) of quaternions in the form (w, x, y, z)
480 point: shape (..., 3) of 3D points.
482 Returns:
483 np.ndarray of rotated points of shape (..., 3).
484 """
485 point_as_quaternion = np.concatenate([np.zeros_like(point[..., :1]), point], axis=-1)
486 qu_i = qu_prod_raw(qu, point_as_quaternion)
487 qu_o = qu_prod_raw(qu_i, qu_conj(qu))
488 v_out = qu_o[..., 1:] # Extract the vector part
490 return v_out
493def qu_rotate_sets_sphere(points_start: np.ndarray, points_finish) -> np.ndarray:
494 """
495 Determine the quaternions that rotate the points_start to the points_finish.
496 All points are assumed to be on the unit sphere. The cross product is used
497 as the axis of rotation, but there are an infinite number of quaternions that
498 fulfill the requirement as the points can be rotated around their axis by
499 an arbitrary angle, and they will still have the same latitude and longitude.
501 Args:
502 points_start: Starting points as array of shape (..., 3).
503 points_finish: Ending points as array of shape (..., 3).
505 Returns:
506 The quaternions, as array of shape (..., 4).
508 """
509 # determine mask for numerical stability
510 valid = np.abs(np.sum(points_start * points_finish, axis=-1)) < 0.999999
511 # get the cross product of the two sets of points
512 cross = np.cross(points_start[valid], points_finish[valid], axis=-1)
513 # get the dot product of the two sets of points
514 dot = np.sum(points_start[valid] * points_finish[valid], axis=-1)
515 # get the angle
516 angle = np.atan2(np.linalg.norm(cross, axis=-1), dot)
517 # add tau to the angle if the cross product is negative
518 angle[angle < 0] += 2 * np.pi
519 # set the output
520 out = np.zeros((points_start.shape[0], 4), dtype=points_start.dtype)
521 out[valid, 0] = np.cos(angle / 2)
522 out[valid, 1:] = np.sin(angle / 2)[..., None] * (
523 cross / np.linalg.norm(cross, axis=-1, keepdims=True)
524 )
525 out[~valid, 0] = 1
526 out[~valid, 1:] = 0
527 return out
530def ori_to_fz_laue(quats: np.ndarray, laue_id: int) -> np.ndarray:
531 """
532 This function moves the given quaternions to the fundamental zone of the
533 given Laue group. This computes the orientation fundamental zone, not the
534 misorientation fundamental zone.
536 Args:
537 quats: quaternions to move to fundamental zone of shape (..., 4)
538 laue_id: laue group of quaternions to move to fundamental zone
540 Returns:
541 orientations in fundamental zone of shape (..., 4)
543 Notes:
545 1) Laue C1 Triclinic: 1-, 1
546 2) Laue C2 Monoclinic: 2/m, m, 2
547 3) Laue D2 Orthorhombic: mmm, mm2, 222
548 4) Laue C4 Tetragonal low: 4/m, 4-, 4
549 5) Laue D4 Tetragonal high: 4/mmm, 4-2m, 4mm, 422
550 6) Laue C3 Trigonal low: 3-, 3
551 7) Laue D3 Trigonal high: 3-m, 3m, 32
552 8) Laue C6 Hexagonal low: 6/m, 6-, 6
553 9) Laue D6 Hexagonal high: 6/mmm, 6-m2, 6mm, 622
554 10) Laue T Cubic low: m3-, 23
555 11) Laue O Cubic high: m3-m, 4-3m, 432
557 """
558 # get the important shapes
559 data_shape = quats.shape
560 N = np.prod(np.array(data_shape[:-1]))
561 laue_group = laue_elements(laue_id)
562 card = laue_group.shape[0]
564 # reshape so that quaternions is (N, 1, 4) and laue_group is (1, card, 4) then use broadcasting
565 equivalent_quaternions_real = qu_prod_pos_real(
566 quats.reshape(N, 1, 4), laue_group.reshape(card, 4)
567 )
569 # find the quaternion with the largest w value
570 row_maximum_indices = np.argmax(equivalent_quaternions_real, axis=-1)
572 # gather the equivalent quaternions with the largest w value for each equivalent quaternion set
573 output = qu_prod(quats.reshape(N, 4), laue_group[row_maximum_indices])
575 return output.reshape(data_shape)
578def get_preferred_rotation_axis(q_dis, chunk_size=None) -> np.ndarray:
579 """Calculate the preferred rotation axis from a set of disorientation quaternions.
580 The preferred rotation axis is the eigenvector of the Q tensor corresponding to the largest eigenvalue.
581 The preferred rotation axis is used to calculate the sign carrying disorientation angle.
583 Args:
584 q_dis: shape (N, 4) of disorientation quaternions
586 Returns:
587 r_star: shape (3,) of the preferred rotation axis
588 """
589 Q = get_Q_tensor(q_dis, chunk_size=chunk_size)
590 evals, evecs = np.linalg.eig(Q)
591 evecs = np.real(evecs[:, np.argsort(evals)[::-1]])
592 evals = np.real(evals[np.argsort(evals)[::-1]])
593 r_star = evecs[:, 0]
594 r_star = r_star / np.linalg.norm(r_star)
595 return r_star
598def get_Q_tensor(q_dis, chunk_size=None) -> np.ndarray:
599 """Calculate the Q tensor from a set of disorientation quaternions.
600 The Q tensor is a 3x3 symmetric tensor of the second order central moments of disorientations.
601 The Q tensor is used to calculate the reference axis of disorientation.
603 Args:
604 q_dis: shape (N, 4) of disorientation quaternions
606 Returns:
607 Q: shape (3, 3) of the Q tensor
608 """
609 if chunk_size is not None: 609 ↛ 610line 609 didn't jump to line 610 because the condition on line 609 was never true
610 chunk_size = min(chunk_size, q_dis.shape[0])
611 idx = np.random.choice(q_dis.shape[0], size=chunk_size, replace=False)
612 q_dis = q_dis[idx]
613 if q_dis.shape[0] > 10000: 613 ↛ 614line 613 didn't jump to line 614 because the condition on line 613 was never true
614 q_dis_s = np.array_split(q_dis, q_dis.shape[0] // 1000, axis=0)
615 Q_s = Parallel(n_jobs=5)(
616 delayed(np.einsum)("ij,ik->jk", q_dis_s[i][..., 1:], q_dis_s[i][..., 1:])
617 for i in range(len(q_dis_s))
618 )
619 Q = np.sum(Q_s, axis=0) / q_dis.shape[0]
620 else:
621 v = q_dis[..., 1:]
622 Q = np.einsum("ij,ik->jk", v, v) / q_dis.shape[0]
623 return Q
626def get_sign_carrying_disorientation_angle(q_dis, chunk_size=None, r_star=None) -> np.ndarray:
627 """Calculate the sign carrying disorientation angle from a set of disorientation quaternions.
628 As opposed to the disorientation angle, the sign carrying disorientation angle is maintains
629 the direction around the rotation axis, using a global preferred rotation axis.
631 Args:
632 q_dis: shape (N, 4) of disorientation quaternions
633 method: method to use for calculating the angle, either "refaxis" or "axangle"
634 "refaxis": use the preferred rotation axis to calculate the angle
635 "axangle": use the axis-angle representation to calculate the angle
637 Returns:
638 angles: shape (N,) of the sign carrying disorientation angles
639 """
640 if r_star is None: 640 ↛ 641line 640 didn't jump to line 641 because the condition on line 640 was never true
641 r_star = get_preferred_rotation_axis(q_dis, chunk_size=chunk_size)
642 with np.errstate(divide="ignore", invalid="ignore"):
643 angles = (
644 np.arccos(q_dis[..., 0]) / np.sqrt(1 - q_dis[..., 0] ** 2) * q_dis[..., 1:].dot(r_star)
645 )
646 angles[np.isnan(angles)] = 0
647 angles[np.isinf(angles)] = 0
649 return angles
652# Symmetry
655def symmetrize(q: np.ndarray, laue_id: int) -> np.ndarray:
656 """Symmetrizes a quaternion using the Laue group.
658 Args:
659 q: shape (N, 4) quaternions
660 laue_id: integer between inclusive [1, 11]
662 Returns:
663 The symmetrized quaternions, a np.ndarray of shape (N, |Laue group|, 4)
664 """
665 S = laue_elements(laue_id)
666 q = qu_norm_std(q)
667 q = q.reshape((-1, 1, 4))
668 S = S.reshape((1, -1, 4))
669 q_sym = qu_prod(S[None], q[:, None]).reshape((q.shape[0], -1, 4))
670 return q_sym
673def laue_elements(laue_id: int) -> np.ndarray:
674 """
675 Generators for Laue group specified by the laue_id parameter. The first
676 element is always the identity.
678 1) Laue C1 Triclinic: 1-, 1
679 2) Laue C2 Monoclinic: 2/m, m, 2
680 3) Laue D2 Orthorhombic: mmm, mm2, 222
681 4) Laue C4 Tetragonal low: 4/m, 4-, 4
682 5) Laue D4 Tetragonal high: 4/mmm, 4-2m, 4mm, 422
683 6) Laue C3 Trigonal low: 3-, 3
684 7) Laue D3 Trigonal high: 3-m, 3m, 32
685 8) Laue C6 Hexagonal low: 6/m, 6-, 6
686 9) Laue D6 Hexagonal high: 6/mmm, 6-m2, 6mm, 622
687 10) Laue T Cubic low: m3-, 23
688 11) Laue O Cubic high: m3-m, 4-3m, 432
690 Args:
691 laue_id: integer between inclusive [1, 11]
693 Returns:
694 np array of shape (cardinality, 4) containing the elements of the
696 Notes:
698 https://en.wikipedia.org/wiki/Space_group
700 """
702 # sqrt(2) / 2 and sqrt(3) / 2
703 R2 = 1.0 / (2.0**0.5)
704 R3 = (3.0**0.5) / 2.0
706 LAUE_O = np.array(
707 [
708 [1.0, 0.0, 0.0, 0.0],
709 [R2, 0.0, 0.0, R2],
710 [0.0, 0.0, 0.0, 1.0],
711 [-R2, 0.0, 0.0, R2], #
712 [0.5, 0.5, 0.5, 0.5],
713 [0.0, 0.0, R2, R2],
714 [-0.5, -0.5, 0.5, 0.5], #
715 [-R2, -R2, 0.0, 0.0], #
716 [0.0, 1.0, 0.0, 0.0],
717 [0.0, R2, R2, 0.0],
718 [0.0, 0.0, 1.0, 0.0],
719 [0.0, -R2, R2, 0.0],
720 [-0.5, 0.5, 0.5, -0.5], #
721 [0.0, 0.0, R2, -R2], #
722 [0.5, -0.5, 0.5, -0.5],
723 [R2, -R2, 0.0, 0.0],
724 [0.0, R2, 0.0, R2],
725 [-0.5, 0.5, 0.5, 0.5], #
726 [-R2, 0.0, R2, 0.0], #
727 [-0.5, -0.5, 0.5, -0.5], #
728 [0.0, R2, 0.0, -R2], #
729 [0.5, 0.5, 0.5, -0.5],
730 [R2, 0.0, R2, 0.0],
731 [0.5, -0.5, 0.5, 0.5],
732 ],
733 dtype=np.float64,
734 )
735 LAUE_O_alt = np.array(
736 [
737 [1.0, 0.0, 0.0, 0.0],
738 [R2, 0.0, 0.0, R2],
739 [0.0, 0.0, 0.0, 1.0],
740 [R2, 0.0, 0.0, -R2], #
741 [0.5, 0.5, 0.5, 0.5],
742 [0.0, 0.0, R2, R2],
743 [0.5, 0.5, -0.5, -0.5], #
744 [R2, R2, 0.0, 0.0], #
745 [0.0, 1.0, 0.0, 0.0],
746 [0.0, R2, R2, 0.0],
747 [0.0, 0.0, 1.0, 0.0],
748 [0.0, -R2, R2, 0.0],
749 [0.5, -0.5, -0.5, 0.5], #
750 [0.0, 0.0, -R2, R2], #
751 [0.5, -0.5, 0.5, -0.5],
752 [R2, -R2, 0.0, 0.0],
753 [0.0, R2, 0.0, R2],
754 [0.5, -0.5, -0.5, -0.5], #
755 [R2, 0.0, -R2, 0.0], #
756 [0.5, 0.5, -0.5, 0.5], #
757 [0.0, -R2, 0.0, R2], #
758 [0.5, 0.5, 0.5, -0.5],
759 [R2, 0.0, R2, 0.0],
760 [0.5, -0.5, 0.5, 0.5],
761 ],
762 dtype=np.float64,
763 )
764 LAUE_O_ = np.array(
765 [
766 [1.0, 0.0, 0.0, 0.0],
767 [R2, R2, 0.0, 0.0],
768 [R2, 0.0, R2, 0.0],
769 [R2, 0.0, 0.0, R2],
770 [R2, -R2, 0.0, 0.0],
771 [R2, 0.0, -R2, 0.0],
772 [R2, 0.0, 0.0, -R2],
773 [0.5, 0.5, 0.5, 0.5],
774 [0.5, -0.5, -0.5, -0.5],
775 [0.5, 0.5, -0.5, 0.5],
776 [0.5, -0.5, 0.5, -0.5],
777 [0.5, -0.5, 0.5, 0.5],
778 [0.5, 0.5, -0.5, -0.5],
779 [0.5, -0.5, -0.5, 0.5],
780 [0.5, 0.5, 0.5, -0.5],
781 [0.0, 1.0, 0.0, 0.0],
782 [0.0, 0.0, 1.0, 0.0],
783 [0.0, 0.0, 0.0, 1.0],
784 [0.0, R2, R2, 0.0],
785 [0.0, -R2, R2, 0.0],
786 [0.0, 0.0, R2, R2],
787 [0.0, 0.0, -R2, R2],
788 [0.0, R2, 0.0, R2],
789 [0.0, -R2, 0.0, R2],
790 ],
791 dtype=np.float64,
792 )
793 LAUE_T = np.array(
794 [
795 [1.0, 0.0, 0.0, 0.0],
796 [0.0, 0.0, 0.0, 1.0],
797 [0.0, 1.0, 0.0, 0.0],
798 [0.0, 0.0, 1.0, 0.0],
799 [0.5, 0.5, -0.5, 0.5],
800 [0.5, 0.5, 0.5, -0.5],
801 [0.5, 0.5, -0.5, -0.5],
802 [0.5, -0.5, -0.5, -0.5],
803 [0.5, -0.5, 0.5, 0.5],
804 [0.5, -0.5, 0.5, -0.5],
805 [0.5, -0.5, -0.5, 0.5],
806 [0.5, 0.5, 0.5, 0.5],
807 ],
808 dtype=np.float64,
809 )
811 LAUE_D6 = np.array(
812 [
813 [1.0, 0.0, 0.0, 0.0],
814 [0.5, 0.0, 0.0, R3],
815 [0.5, 0.0, 0.0, -R3],
816 [0.0, 0.0, 0.0, 1.0],
817 [R3, 0.0, 0.0, 0.5],
818 [R3, 0.0, 0.0, -0.5],
819 [0.0, 1.0, 0.0, 0.0],
820 [0.0, -0.5, R3, 0.0],
821 [0.0, 0.5, R3, 0.0],
822 [0.0, R3, 0.5, 0.0],
823 [0.0, -R3, 0.5, 0.0],
824 [0.0, 0.0, 1.0, 0.0],
825 ],
826 dtype=np.float64,
827 )
829 LAUE_C6 = np.array(
830 [
831 [1.0, 0.0, 0.0, 0.0],
832 [0.5, 0.0, 0.0, R3],
833 [0.5, 0.0, 0.0, -R3],
834 [0.0, 0.0, 0.0, 1.0],
835 [R3, 0.0, 0.0, 0.5],
836 [R3, 0.0, 0.0, -0.5],
837 ],
838 dtype=np.float64,
839 )
841 LAUE_D3 = np.array(
842 [
843 [1.0, 0.0, 0.0, 0.0],
844 [0.5, 0.0, 0.0, R3],
845 [0.5, 0.0, 0.0, -R3],
846 [0.0, 1.0, 0.0, 0.0],
847 [0.0, -0.5, R3, 0.0],
848 [0.0, 0.5, R3, 0.0],
849 ],
850 dtype=np.float64,
851 )
853 LAUE_C3 = np.array(
854 [
855 [1.0, 0.0, 0.0, 0.0],
856 [0.5, 0.0, 0.0, R3],
857 [0.5, 0.0, 0.0, -R3],
858 ],
859 dtype=np.float64,
860 )
862 LAUE_D4 = np.array(
863 [
864 [1.0, 0.0, 0.0, 0.0],
865 [0.0, 0.0, 0.0, 1.0],
866 [0.0, 1.0, 0.0, 0.0],
867 [0.0, 0.0, 1.0, 0.0],
868 [R2, 0.0, 0.0, R2],
869 [R2, 0.0, 0.0, -R2],
870 [0.0, R2, R2, 0.0],
871 [0.0, -R2, R2, 0.0],
872 ],
873 dtype=np.float64,
874 )
876 LAUE_C4 = np.array(
877 [
878 [1.0, 0.0, 0.0, 0.0],
879 [0.0, 0.0, 0.0, 1.0],
880 [R2, 0.0, 0.0, R2],
881 [R2, 0.0, 0.0, -R2],
882 ],
883 dtype=np.float64,
884 )
886 LAUE_D2 = np.array(
887 [
888 [1.0, 0.0, 0.0, 0.0],
889 [0.0, 0.0, 0.0, 1.0],
890 [0.0, 1.0, 0.0, 0.0],
891 [0.0, 0.0, 1.0, 0.0],
892 ],
893 dtype=np.float64,
894 )
896 LAUE_C2 = np.array(
897 [
898 [1.0, 0.0, 0.0, 0.0],
899 [0.0, 0.0, 0.0, 1.0],
900 ],
901 dtype=np.float64,
902 )
904 LAUE_C1 = np.array(
905 [
906 [1.0, 0.0, 0.0, 0.0],
907 ],
908 dtype=np.float64,
909 )
911 LAUE_GROUPS = [
912 LAUE_C1, # 1 - Triclinic
913 LAUE_C2, # 2 - Monoclinic
914 LAUE_D2, # 3 - Orthorhombic
915 LAUE_C4, # 4 - Tetragonal low
916 LAUE_D4, # 5 - Tetragonal high
917 LAUE_C3, # 6 - Trigonal low
918 LAUE_D3, # 7 - Trigonal high
919 LAUE_C6, # 8 - Hexagonal low
920 LAUE_D6, # 9 - Hexagonal high
921 LAUE_T, # 10 - Cubic low
922 LAUE_O, # 11 - Cubic high
923 ]
925 return LAUE_GROUPS[laue_id - 1]