midas-plotting 0.4.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/PKG-INFO +1 -1
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/__init__.py +1 -1
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/ff.py +11 -9
- midas_plotting-0.5.0/midas_plotting/ipf.py +346 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting.egg-info/PKG-INFO +1 -1
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/pyproject.toml +1 -1
- midas_plotting-0.5.0/tests/test_ipf.py +204 -0
- midas_plotting-0.4.0/midas_plotting/ipf.py +0 -184
- midas_plotting-0.4.0/tests/test_ipf.py +0 -100
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/README.md +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/cli.py +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/grains.py +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/laue.py +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/maps.py +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/mic.py +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/solutions.py +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting.egg-info/SOURCES.txt +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting.egg-info/dependency_links.txt +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting.egg-info/entry_points.txt +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting.egg-info/requires.txt +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting.egg-info/top_level.txt +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/setup.cfg +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/tests/test_ff.py +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/tests/test_laue.py +0 -0
- {midas_plotting-0.4.0 → midas_plotting-0.5.0}/tests/test_mic.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: midas-plotting
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Standard plots for MIDAS reconstructions - near-field, far-field and Laue: IPF maps and legends, grain maps, pole figures, strain and size distributions, and Laue texture diagnostics against their chance levels.
|
|
5
5
|
Author-email: Hemant Sharma <hsharma@anl.gov>
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -48,7 +48,7 @@ from .solutions import (
|
|
|
48
48
|
)
|
|
49
49
|
from .mic import MicMap, read_mic
|
|
50
50
|
|
|
51
|
-
__version__ = "0.
|
|
51
|
+
__version__ = "0.5.0"
|
|
52
52
|
__all__ = [
|
|
53
53
|
"MicMap", "read_mic", "GrainList", "read_grains", "ff", "laue",
|
|
54
54
|
"LaueSolutions", "LaueSpots", "read_solutions", "read_spots",
|
|
@@ -26,7 +26,8 @@ from typing import Optional, Sequence
|
|
|
26
26
|
import numpy as np
|
|
27
27
|
|
|
28
28
|
from .grains import GrainList, read_grains
|
|
29
|
-
from .ipf import direction_rgb, ipf_rgb_from_matrix, laue_class, sym_matrices,
|
|
29
|
+
from .ipf import (direction_rgb, ipf_rgb_from_matrix, laue_class, sym_matrices,
|
|
30
|
+
sector_deg, triangle_corners, CUBIC)
|
|
30
31
|
|
|
31
32
|
__all__ = [
|
|
32
33
|
"ipf_legend", "grain_map", "grain_map_3d", "grain_size_distribution",
|
|
@@ -96,11 +97,13 @@ def ipf_legend(space_group: int = 225, ax=None, *, n: int = 400,
|
|
|
96
97
|
|
|
97
98
|
fam = laue_class(space_group)
|
|
98
99
|
# Sample the stereographic plane, back-project to directions, keep the ones
|
|
99
|
-
# already inside the standard triangle.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
else
|
|
103
|
-
|
|
100
|
+
# already inside the standard triangle. Corners and the azimuthal sector
|
|
101
|
+
# come from midas_plotting.ipf, the SAME source the colour ramps fold by,
|
|
102
|
+
# so the key cannot describe a different triangle from the one drawn. The
|
|
103
|
+
# `else` branch here used to hard-code the 30 deg hexagonal triangle, which
|
|
104
|
+
# would have drawn a hexagonal key over a 4/mmm map once the tetragonal and
|
|
105
|
+
# orthorhombic triangles were added.
|
|
106
|
+
corners, labels = triangle_corners(space_group)
|
|
104
107
|
# Normalise BEFORE projecting: the stereographic map is defined on unit
|
|
105
108
|
# vectors. Projecting the raw index triple puts [111] at (0.5, 0.5)
|
|
106
109
|
# instead of (0.366, 0.366), so the corner marker and its label sit
|
|
@@ -122,7 +125,8 @@ def ipf_legend(space_group: int = 225, ax=None, *, n: int = 400,
|
|
|
122
125
|
(d[:, 1] <= d[:, 0] + 1e-9) & (d[:, 0] <= d[:, 2] + 1e-9)
|
|
123
126
|
else:
|
|
124
127
|
az = np.degrees(np.arctan2(d[:, 1], d[:, 0]))
|
|
125
|
-
inside = (d[:, 2] >= -1e-9) & (az >= -1e-9) &
|
|
128
|
+
inside = (d[:, 2] >= -1e-9) & (az >= -1e-9) & \
|
|
129
|
+
(az <= sector_deg(space_group) + 1e-9)
|
|
126
130
|
|
|
127
131
|
rgb = np.ones((d.shape[0], 3))
|
|
128
132
|
if inside.any():
|
|
@@ -132,8 +136,6 @@ def ipf_legend(space_group: int = 225, ax=None, *, n: int = 400,
|
|
|
132
136
|
|
|
133
137
|
ax.imshow(img, origin="lower", alpha=alpha,
|
|
134
138
|
extent=(x0, x1, y0, y1))
|
|
135
|
-
labels = (["[001]", "[101]", "[111]"] if fam == CUBIC
|
|
136
|
-
else ["[0001]", r"[10$\bar{1}$0]", r"[2$\bar{1}\bar{1}$0]"])
|
|
137
139
|
for (px, py), lab in zip(zip(cx, cy), labels):
|
|
138
140
|
ax.plot(px, py, "k.", ms=4)
|
|
139
141
|
ax.annotate(lab, (px, py), textcoords="offset points",
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Inverse-pole-figure colouring.
|
|
2
|
+
|
|
3
|
+
Colour encodes which crystal direction is parallel to a chosen sample axis, so
|
|
4
|
+
one grain is one colour and boundaries appear as colour discontinuities. That is
|
|
5
|
+
the property an Euler-to-RGB dump does NOT have: two orientations a fraction of
|
|
6
|
+
a degree apart can land on very different Euler triplets (and hence very
|
|
7
|
+
different colours) near the gimbal-lock line, which makes a single grain look
|
|
8
|
+
like several.
|
|
9
|
+
|
|
10
|
+
Symmetry operators come from :mod:`midas_stress`, never hand-listed here.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Sequence
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
__all__ = ["ipf_rgb", "ipf_rgb_from_matrix", "direction_rgb", "laue_class",
|
|
20
|
+
"sym_matrices", "sector_deg", "triangle_corners",
|
|
21
|
+
"CUBIC", "HEXAGONAL", "TRIGONAL", "TETRAGONAL", "ORTHORHOMBIC"]
|
|
22
|
+
|
|
23
|
+
CUBIC = "cubic"
|
|
24
|
+
TETRAGONAL = "tetragonal"
|
|
25
|
+
ORTHORHOMBIC = "orthorhombic"
|
|
26
|
+
HEXAGONAL = "hexagonal"
|
|
27
|
+
TRIGONAL = "trigonal"
|
|
28
|
+
|
|
29
|
+
# Laue class per space-group range, for the ones MIDAS actually reconstructs.
|
|
30
|
+
# Deliberately explicit rather than clever: a wrong guess here silently
|
|
31
|
+
# recolours a map without any other symptom.
|
|
32
|
+
#
|
|
33
|
+
# The ranges are LAUE CLASSES, not crystal systems, and the two do not coincide
|
|
34
|
+
# in the tetragonal case: 89-142 is 4/mmm, whose triangle closes at 45 deg on
|
|
35
|
+
# [110], while 75-88 is 4/m, which has no in-plane mirror and whose triangle
|
|
36
|
+
# spans the full 90 deg to [010]. Colouring a 4/m crystal with the 4/mmm
|
|
37
|
+
# triangle folds by a 2-fold it does not have, so 75-88 is left OUT and refuses
|
|
38
|
+
# rather than guessing. Implement `_rgb_tetragonal_4m` before widening it.
|
|
39
|
+
_SG_LAUE = [
|
|
40
|
+
(195, 230, CUBIC),
|
|
41
|
+
(168, 194, HEXAGONAL),
|
|
42
|
+
(143, 167, TRIGONAL),
|
|
43
|
+
(89, 142, TETRAGONAL), # 4/mmm only -- see the note above
|
|
44
|
+
(16, 74, ORTHORHOMBIC),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def laue_class(space_group: int) -> str:
|
|
49
|
+
"""Laue family used for the IPF triangle.
|
|
50
|
+
|
|
51
|
+
Raises for space groups whose triangle is not implemented, rather than
|
|
52
|
+
falling back to cubic -- a silent fallback would produce a plausible-looking
|
|
53
|
+
but meaningless map.
|
|
54
|
+
"""
|
|
55
|
+
for lo, hi, name in _SG_LAUE:
|
|
56
|
+
if lo <= int(space_group) <= hi:
|
|
57
|
+
return name
|
|
58
|
+
raise NotImplementedError(
|
|
59
|
+
f"IPF colouring for space group {space_group} is not implemented "
|
|
60
|
+
f"(have: cubic 195-230, hexagonal 168-194, trigonal 143-167, "
|
|
61
|
+
f"tetragonal 4/mmm 89-142, orthorhombic 16-74). Refusing to guess. "
|
|
62
|
+
f"Note 75-88 is Laue class 4/m, whose triangle spans 90 deg, not the "
|
|
63
|
+
f"45 deg of 4/mmm; it is deliberately not implemented."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sym_matrices(space_group: int) -> np.ndarray:
|
|
68
|
+
"""``(n_sym, 3, 3)`` proper-rotation operators from midas_stress."""
|
|
69
|
+
from midas_stress.orientation import make_symmetries, quat_to_orient_mat
|
|
70
|
+
|
|
71
|
+
n, quats = make_symmetries(int(space_group))
|
|
72
|
+
q = np.asarray(quats)[: int(n)]
|
|
73
|
+
return np.stack([np.asarray(quat_to_orient_mat(qi)).reshape(3, 3) for qi in q])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _reduce_cubic(d: np.ndarray) -> np.ndarray:
|
|
77
|
+
"""Fold directions into the standard [001]-[101]-[111] triangle."""
|
|
78
|
+
d = np.abs(d)
|
|
79
|
+
d = np.sort(d, axis=-1) # u <= v <= w
|
|
80
|
+
return d
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _rgb_cubic(d: np.ndarray) -> np.ndarray:
|
|
84
|
+
u, v, w = d[:, 0], d[:, 1], d[:, 2]
|
|
85
|
+
rgb = np.stack([w - v, (v - u) * np.sqrt(2.0), u * np.sqrt(3.0)], axis=1)
|
|
86
|
+
return rgb
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _rgb_tetragonal(d: np.ndarray) -> np.ndarray:
|
|
91
|
+
"""Standard [001]-[100]-[110] triangle for Laue class 4/mmm.
|
|
92
|
+
|
|
93
|
+
Same construction as :func:`_rgb_hexagonal`: red is the c-axis component,
|
|
94
|
+
and the in-plane azimuth interpolates green -> blue across the triangle's
|
|
95
|
+
angular width, here 45 degrees rather than 30. Added for I4/mmm (SG 139),
|
|
96
|
+
the Ruddlesden-Popper subcell — before this the function raised, which was
|
|
97
|
+
correct: colouring a tetragonal crystal with the cubic triangle folds by a
|
|
98
|
+
3-fold the crystal does not have.
|
|
99
|
+
"""
|
|
100
|
+
dz = np.abs(d[:, 2])
|
|
101
|
+
planar = np.hypot(d[:, 0], d[:, 1])
|
|
102
|
+
phi = np.degrees(np.arctan2(np.abs(d[:, 1]), np.abs(d[:, 0])))
|
|
103
|
+
phi = np.minimum(phi % 90.0, 90.0 - (phi % 90.0)) # fold to [0, 45]
|
|
104
|
+
t = np.clip(phi / 45.0, 0.0, 1.0)
|
|
105
|
+
return np.stack([dz, planar * (1.0 - t), planar * t], axis=1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _rgb_orthorhombic(d: np.ndarray) -> np.ndarray:
|
|
109
|
+
"""Standard [001]-[100]-[010] triangle for Laue class mmm.
|
|
110
|
+
|
|
111
|
+
The triangle spans a full 90 degrees of azimuth because mmm has no
|
|
112
|
+
in-plane rotation relating a to b. Added for Fmmm (SG 69).
|
|
113
|
+
"""
|
|
114
|
+
dz = np.abs(d[:, 2])
|
|
115
|
+
planar = np.hypot(d[:, 0], d[:, 1])
|
|
116
|
+
phi = np.degrees(np.arctan2(np.abs(d[:, 1]), np.abs(d[:, 0])))
|
|
117
|
+
t = np.clip(phi / 90.0, 0.0, 1.0)
|
|
118
|
+
return np.stack([dz, planar * (1.0 - t), planar * t], axis=1)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _rgb_hexagonal(d: np.ndarray) -> np.ndarray:
|
|
122
|
+
"""Standard [0001]-[10-10]-[2-1-10] triangle.
|
|
123
|
+
|
|
124
|
+
``d`` is Cartesian with c along +z. After symmetry reduction the
|
|
125
|
+
representative has ``dz >= 0`` and azimuth in ``[0, 30]`` degrees.
|
|
126
|
+
"""
|
|
127
|
+
dz = np.abs(d[:, 2])
|
|
128
|
+
planar = np.hypot(d[:, 0], d[:, 1])
|
|
129
|
+
phi = np.degrees(np.arctan2(np.abs(d[:, 1]), np.abs(d[:, 0])))
|
|
130
|
+
phi = np.minimum(phi % 60.0, 60.0 - (phi % 60.0)) # fold to [0, 30]
|
|
131
|
+
t = np.clip(phi / 30.0, 0.0, 1.0)
|
|
132
|
+
return np.stack([dz, planar * (1.0 - t), planar * t], axis=1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _trigonal_sector_deg(sym: np.ndarray) -> float:
|
|
136
|
+
"""Azimuthal fundamental-sector width, measured from the operators.
|
|
137
|
+
|
|
138
|
+
Counts the distinct upper-hemisphere azimuths a generic direction is sent
|
|
139
|
+
to by the group plus the Laue centre. For -3m that is 6 images, so 60 deg
|
|
140
|
+
-- twice the hexagonal 30. Measured rather than hard-coded so a change in
|
|
141
|
+
the operator set cannot silently mis-scale the colour ramp.
|
|
142
|
+
"""
|
|
143
|
+
g = np.array([0.3411, 0.1297, 0.4271]) # generic, no special azimuth
|
|
144
|
+
g = g / np.linalg.norm(g)
|
|
145
|
+
both = np.concatenate([sym @ g, -(sym @ g)], axis=0)
|
|
146
|
+
up = both[both[:, 2] >= -1e-12]
|
|
147
|
+
phi = np.sort(np.degrees(np.arctan2(up[:, 1], up[:, 0])) % 360.0)
|
|
148
|
+
keep = [phi[0]]
|
|
149
|
+
for a in phi[1:]:
|
|
150
|
+
if a - keep[-1] > 1e-6:
|
|
151
|
+
keep.append(a)
|
|
152
|
+
return 360.0 / len(keep)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _rgb_trigonal(d_all: np.ndarray, sym: np.ndarray) -> np.ndarray:
|
|
156
|
+
"""Standard [0001]-[10-10]-[01-10] triangle for Laue class -3m.
|
|
157
|
+
|
|
158
|
+
D3 has 6 proper rotations (3-fold about c, three 2-fold in the basal
|
|
159
|
+
plane) against 6/mmm's 12, so the sector spans 60 deg, not 30. Colouring
|
|
160
|
+
R-3m with the hexagonal triangle folds by a 6-fold axis the crystal does
|
|
161
|
+
not have and silently gives distinct orientations the same colour.
|
|
162
|
+
|
|
163
|
+
The azimuth is NOT hand-folded. Where MIDAS puts the 2-fold axes is a
|
|
164
|
+
convention, and assuming one sits at azimuth 0 gives a colouring that is
|
|
165
|
+
not symmetry invariant (measured: colour moved by up to 0.99 under
|
|
166
|
+
``g -> S.g``). Instead take the whole orbit, add ``-d`` for the Laue
|
|
167
|
+
centre, keep the upper hemisphere and pick the smallest azimuth --
|
|
168
|
+
canonical whatever the operator convention.
|
|
169
|
+
|
|
170
|
+
``d_all`` is ``(n, n_sym, 3)``, every symmetry image of each direction.
|
|
171
|
+
"""
|
|
172
|
+
both = np.concatenate([d_all, -d_all], axis=1) # (n, 2s, 3)
|
|
173
|
+
phi = np.degrees(np.arctan2(both[:, :, 1], both[:, :, 0])) % 360.0
|
|
174
|
+
phi = np.where(both[:, :, 2] >= -1e-12, phi, np.inf) # upper only
|
|
175
|
+
k = np.argmin(phi, axis=1)
|
|
176
|
+
idx = np.arange(both.shape[0])
|
|
177
|
+
rep, phi_c = both[idx, k], phi[idx, k]
|
|
178
|
+
phi_c = np.where(np.isfinite(phi_c), phi_c, 0.0)
|
|
179
|
+
rep = rep / np.linalg.norm(rep, axis=1, keepdims=True)
|
|
180
|
+
|
|
181
|
+
dz = np.abs(rep[:, 2])
|
|
182
|
+
planar = np.hypot(rep[:, 0], rep[:, 1])
|
|
183
|
+
t = np.clip(phi_c / _trigonal_sector_deg(sym), 0.0, 1.0)
|
|
184
|
+
return np.stack([dz, planar * (1.0 - t), planar * t], axis=1)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def ipf_rgb(
|
|
188
|
+
euler: np.ndarray,
|
|
189
|
+
space_group: int = 225,
|
|
190
|
+
axis: Sequence[float] = (0.0, 0.0, 1.0),
|
|
191
|
+
*,
|
|
192
|
+
gamma: float = 0.5,
|
|
193
|
+
) -> np.ndarray:
|
|
194
|
+
"""RGB per orientation for the crystal direction parallel to ``axis``.
|
|
195
|
+
|
|
196
|
+
Parameters
|
|
197
|
+
----------
|
|
198
|
+
euler : (N, 3) array
|
|
199
|
+
Bunge ZXZ Euler angles in **radians** -- the MIDAS ``.mic`` convention.
|
|
200
|
+
space_group : int
|
|
201
|
+
Used for the symmetry operators and to pick the triangle.
|
|
202
|
+
axis : length-3
|
|
203
|
+
Sample-frame direction. ``(0,0,1)`` gives the usual IPF-Z.
|
|
204
|
+
gamma : float
|
|
205
|
+
Perceptual lift applied as ``rgb ** gamma``. 0.5 (sqrt) matches the
|
|
206
|
+
common convention; 1.0 disables it.
|
|
207
|
+
|
|
208
|
+
Returns
|
|
209
|
+
-------
|
|
210
|
+
(N, 3) float array in [0, 1].
|
|
211
|
+
"""
|
|
212
|
+
from midas_stress.orientation import euler_to_orient_mat_batch
|
|
213
|
+
|
|
214
|
+
euler = np.asarray(euler, dtype=float).reshape(-1, 3)
|
|
215
|
+
if euler.size == 0:
|
|
216
|
+
return np.zeros((0, 3))
|
|
217
|
+
g = np.asarray(euler_to_orient_mat_batch(euler)).reshape(-1, 3, 3)
|
|
218
|
+
return ipf_rgb_from_matrix(g, space_group, axis, gamma=gamma)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def ipf_rgb_from_matrix(
|
|
222
|
+
orient_mat: np.ndarray,
|
|
223
|
+
space_group: int = 225,
|
|
224
|
+
axis: Sequence[float] = (0.0, 0.0, 1.0),
|
|
225
|
+
*,
|
|
226
|
+
gamma: float = 0.5,
|
|
227
|
+
) -> np.ndarray:
|
|
228
|
+
"""RGB per orientation, from ``(N, 3, 3)`` orientation matrices.
|
|
229
|
+
|
|
230
|
+
The same colouring as :func:`ipf_rgb`, entered from the matrix rather than
|
|
231
|
+
from Euler angles. Far-field ``Grains.csv`` carries both (``O11..O33`` and
|
|
232
|
+
``Eul0..2``); this avoids a needless matrix -> Euler -> matrix round trip,
|
|
233
|
+
which is lossy near the gimbal-lock configurations of the ZXZ convention.
|
|
234
|
+
"""
|
|
235
|
+
g = np.asarray(orient_mat, dtype=float).reshape(-1, 3, 3)
|
|
236
|
+
if g.size == 0:
|
|
237
|
+
return np.zeros((0, 3))
|
|
238
|
+
|
|
239
|
+
a = np.asarray(axis, dtype=float)
|
|
240
|
+
n = np.linalg.norm(a)
|
|
241
|
+
if n == 0:
|
|
242
|
+
raise ValueError("axis must be non-zero")
|
|
243
|
+
a = a / n
|
|
244
|
+
|
|
245
|
+
# TRANSPOSE. MIDAS orientation matrices map CRYSTAL -> LAB (v_lab = g v_crystal),
|
|
246
|
+
# so the crystal direction parallel to the sample axis `a` is g^T a, not g a.
|
|
247
|
+
# This is not cosmetic: with g a, the colour is NOT symmetry-invariant for this
|
|
248
|
+
# convention -- the 24 equally valid representations of one grain spread over
|
|
249
|
+
# 0.96 in RGB, so a map's colour depended on which variant the indexer happened
|
|
250
|
+
# to store. Two grains agreeing to 0.5 deg came out 0.42 apart in RGB.
|
|
251
|
+
# Fixed 2026-09-03; see tests::test_ipf_colour_is_symmetry_invariant.
|
|
252
|
+
d = np.einsum("nji,j->ni", g, a) # crystal dir of the axis = g^T a
|
|
253
|
+
return direction_rgb(d, space_group, gamma=gamma)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def direction_rgb(
|
|
257
|
+
dirs: np.ndarray, space_group: int = 225, *, gamma: float = 0.5,
|
|
258
|
+
) -> np.ndarray:
|
|
259
|
+
"""RGB for **crystal directions** -- the colouring core.
|
|
260
|
+
|
|
261
|
+
``dirs`` is ``(N, 3)`` in crystal coordinates; it is normalised here.
|
|
262
|
+
Both :func:`ipf_rgb` and the legend drawn by
|
|
263
|
+
``midas_plotting.ff.ipf_legend`` go through this, so the key on a figure
|
|
264
|
+
is guaranteed to match the colours in the map beside it. A legend computed
|
|
265
|
+
by a separate copy of the triangle maths is a legend that eventually lies.
|
|
266
|
+
"""
|
|
267
|
+
d = np.asarray(dirs, dtype=float).reshape(-1, 3)
|
|
268
|
+
if d.size == 0:
|
|
269
|
+
return np.zeros((0, 3))
|
|
270
|
+
nrm = np.linalg.norm(d, axis=1, keepdims=True)
|
|
271
|
+
d = np.divide(d, nrm, out=np.zeros_like(d), where=nrm > 0)
|
|
272
|
+
|
|
273
|
+
fam = laue_class(space_group)
|
|
274
|
+
sym = sym_matrices(space_group)
|
|
275
|
+
d = np.einsum("sij,nj->nsi", sym, d) # every equivalent
|
|
276
|
+
|
|
277
|
+
if fam == CUBIC:
|
|
278
|
+
red = _reduce_cubic(d)
|
|
279
|
+
pick = np.argmax(red[:, :, 2], axis=1) # closest to [001]
|
|
280
|
+
red = red[np.arange(red.shape[0]), pick]
|
|
281
|
+
red /= np.linalg.norm(red, axis=1, keepdims=True)
|
|
282
|
+
rgb = _rgb_cubic(red)
|
|
283
|
+
elif fam == TRIGONAL:
|
|
284
|
+
rgb = _rgb_trigonal(d, sym)
|
|
285
|
+
elif fam in (TETRAGONAL, ORTHORHOMBIC):
|
|
286
|
+
dd = d.copy()
|
|
287
|
+
dd[:, :, 2] = np.abs(dd[:, :, 2])
|
|
288
|
+
pick = np.argmax(dd[:, :, 2], axis=1) # closest to [001]
|
|
289
|
+
red = dd[np.arange(dd.shape[0]), pick]
|
|
290
|
+
red /= np.linalg.norm(red, axis=1, keepdims=True)
|
|
291
|
+
rgb = (_rgb_tetragonal(red) if fam == TETRAGONAL
|
|
292
|
+
else _rgb_orthorhombic(red))
|
|
293
|
+
else:
|
|
294
|
+
dd = d.copy()
|
|
295
|
+
dd[:, :, 2] = np.abs(dd[:, :, 2])
|
|
296
|
+
pick = np.argmax(dd[:, :, 2], axis=1) # closest to [0001]
|
|
297
|
+
red = dd[np.arange(dd.shape[0]), pick]
|
|
298
|
+
red /= np.linalg.norm(red, axis=1, keepdims=True)
|
|
299
|
+
rgb = _rgb_hexagonal(red)
|
|
300
|
+
|
|
301
|
+
rgb = np.clip(rgb, 0.0, None)
|
|
302
|
+
mx = rgb.max(axis=1, keepdims=True)
|
|
303
|
+
rgb = np.where(mx > 0, rgb / mx, rgb)
|
|
304
|
+
return np.clip(rgb ** float(gamma), 0.0, 1.0)
|
|
305
|
+
|
|
306
|
+
def sector_deg(space_group: int) -> float:
|
|
307
|
+
"""Azimuthal width of the standard triangle, in degrees.
|
|
308
|
+
|
|
309
|
+
The SAME number the colour ramps fold by, so a legend built from this
|
|
310
|
+
cannot disagree with the map it explains. Cubic is excluded: its triangle
|
|
311
|
+
closes on [111], not on an in-plane direction, so an azimuth does not
|
|
312
|
+
describe it.
|
|
313
|
+
"""
|
|
314
|
+
fam = laue_class(space_group)
|
|
315
|
+
if fam == CUBIC:
|
|
316
|
+
raise ValueError("the cubic triangle is not defined by an azimuth; "
|
|
317
|
+
"its third corner is [111]")
|
|
318
|
+
if fam == TETRAGONAL:
|
|
319
|
+
return 45.0
|
|
320
|
+
if fam == ORTHORHOMBIC:
|
|
321
|
+
return 90.0
|
|
322
|
+
if fam == HEXAGONAL:
|
|
323
|
+
return 30.0
|
|
324
|
+
if fam == TRIGONAL:
|
|
325
|
+
return _trigonal_sector_deg(sym_matrices(space_group))
|
|
326
|
+
raise NotImplementedError(f"no triangle implemented for {fam}")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def triangle_corners(space_group: int):
|
|
330
|
+
"""``(corners, labels)`` for the standard triangle of this Laue class.
|
|
331
|
+
|
|
332
|
+
Cubic closes on [111]; every other implemented class is
|
|
333
|
+
[001] - [100] - (the in-plane direction at :func:`sector_deg`).
|
|
334
|
+
"""
|
|
335
|
+
fam = laue_class(space_group)
|
|
336
|
+
if fam == CUBIC:
|
|
337
|
+
return (np.array([[0., 0., 1.], [1., 0., 1.], [1., 1., 1.]]),
|
|
338
|
+
["[001]", "[101]", "[111]"])
|
|
339
|
+
phi = math.radians(sector_deg(space_group))
|
|
340
|
+
corners = np.array([[0., 0., 1.], [1., 0., 0.],
|
|
341
|
+
[math.cos(phi), math.sin(phi), 0.]])
|
|
342
|
+
lab = {TETRAGONAL: ["[001]", "[100]", "[110]"],
|
|
343
|
+
ORTHORHOMBIC: ["[001]", "[100]", "[010]"],
|
|
344
|
+
HEXAGONAL: ["[0001]", r"[10$\bar{1}$0]", r"[2$\bar{1}\bar{1}$0]"],
|
|
345
|
+
TRIGONAL: ["[0001]", r"[10$\bar{1}$0]", r"[01$\bar{1}$0]"]}[fam]
|
|
346
|
+
return corners, lab
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: midas-plotting
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Standard plots for MIDAS reconstructions - near-field, far-field and Laue: IPF maps and legends, grain maps, pole figures, strain and size distributions, and Laue texture diagnostics against their chance levels.
|
|
5
5
|
Author-email: Hemant Sharma <hsharma@anl.gov>
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "midas-plotting"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Standard plots for MIDAS reconstructions - near-field, far-field and Laue: IPF maps and legends, grain maps, pole figures, strain and size distributions, and Laue texture diagnostics against their chance levels."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "BSD-3-Clause" }
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""IPF colouring.
|
|
2
|
+
|
|
3
|
+
The property that matters: colour is a function of the crystal direction along
|
|
4
|
+
a sample axis, so it is invariant under symmetry-equivalent descriptions of the
|
|
5
|
+
same orientation. That is what makes one grain one colour, and it is what
|
|
6
|
+
Euler->RGB does not give.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from midas_plotting.ipf import CUBIC, HEXAGONAL, ipf_rgb, laue_class, sym_matrices
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_laue_class_known_families():
|
|
17
|
+
assert laue_class(225) == CUBIC
|
|
18
|
+
assert laue_class(229) == CUBIC
|
|
19
|
+
assert laue_class(194) == HEXAGONAL
|
|
20
|
+
assert laue_class(168) == HEXAGONAL
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_laue_class_refuses_unimplemented_rather_than_guessing():
|
|
24
|
+
"""A silent fallback to cubic would recolour a map with no other symptom.
|
|
25
|
+
|
|
26
|
+
SG 139 (tetragonal) and 69 (orthorhombic) were on this list until their
|
|
27
|
+
triangles were implemented; triclinic and monoclinic still are not, and the
|
|
28
|
+
contract is that they REFUSE rather than guess.
|
|
29
|
+
|
|
30
|
+
SG 75-88 is the subtle one. It is tetragonal by CRYSTAL SYSTEM but its Laue
|
|
31
|
+
class is 4/m, not 4/mmm: no in-plane mirror, so its triangle spans 90 deg to
|
|
32
|
+
[010] rather than 45 deg to [110]. Colouring it with the 4/mmm ramp folds by
|
|
33
|
+
a 2-fold the crystal does not have and yields a plausible, wrong map. It is
|
|
34
|
+
deliberately excluded until `_rgb_tetragonal_4m` exists.
|
|
35
|
+
"""
|
|
36
|
+
for sg in (1, 2, 5, 15): # triclinic + monoclinic
|
|
37
|
+
with pytest.raises(NotImplementedError, match="not implemented"):
|
|
38
|
+
laue_class(sg)
|
|
39
|
+
for sg in (75, 81, 88): # Laue class 4/m, NOT 4/mmm
|
|
40
|
+
with pytest.raises(NotImplementedError, match="not implemented"):
|
|
41
|
+
laue_class(sg)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_tetragonal_range_is_the_laue_class_not_the_crystal_system():
|
|
45
|
+
"""4/mmm starts at 89. The boundary is the whole point of the split."""
|
|
46
|
+
from midas_plotting.ipf import laue_class, sector_deg, TETRAGONAL
|
|
47
|
+
with pytest.raises(NotImplementedError):
|
|
48
|
+
laue_class(88)
|
|
49
|
+
assert laue_class(89) == TETRAGONAL
|
|
50
|
+
assert sector_deg(89) == 45.0 and sector_deg(142) == 45.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_tetragonal_and_orthorhombic_triangles_are_distinct():
|
|
54
|
+
"""4/mmm and mmm must not be the same colouring, and neither may be cubic.
|
|
55
|
+
|
|
56
|
+
The discriminator is [010]: under 4/mmm a 4-fold makes it equivalent to
|
|
57
|
+
[100] (both GREEN), while mmm has no in-plane rotation so [010] is the
|
|
58
|
+
third vertex (BLUE). A tetragonal map coloured with the mmm triangle -- or
|
|
59
|
+
with the cubic one, which folds by a 3-fold the crystal does not have --
|
|
60
|
+
would look plausible and be wrong.
|
|
61
|
+
"""
|
|
62
|
+
import numpy as np
|
|
63
|
+
from midas_plotting.ipf import direction_rgb
|
|
64
|
+
dirs = np.array([[0., 0., 1.], [1., 0., 0.], [0., 1., 0.]])
|
|
65
|
+
tet = direction_rgb(dirs, space_group=139)
|
|
66
|
+
ort = direction_rgb(dirs, space_group=69)
|
|
67
|
+
assert tet[0, 0] > 0.9 and ort[0, 0] > 0.9 # [001] is red in both
|
|
68
|
+
assert np.allclose(tet[1], tet[2], atol=1e-6), "4/mmm: [100] and [010] must match"
|
|
69
|
+
assert not np.allclose(ort[1], ort[2], atol=0.5), "mmm: [100] and [010] must differ"
|
|
70
|
+
assert ort[2, 2] > 0.9, "mmm: [010] is the blue vertex"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_sym_matrices_are_proper_rotations():
|
|
74
|
+
for sg, n_expected in ((225, 24), (194, 12)):
|
|
75
|
+
S = sym_matrices(sg)
|
|
76
|
+
assert S.shape == (n_expected, 3, 3), (sg, S.shape)
|
|
77
|
+
dets = np.linalg.det(S)
|
|
78
|
+
np.testing.assert_allclose(dets, 1.0, atol=1e-9)
|
|
79
|
+
for M in S:
|
|
80
|
+
np.testing.assert_allclose(M @ M.T, np.eye(3), atol=1e-9)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_rgb_in_unit_range():
|
|
84
|
+
rng = np.random.default_rng(0)
|
|
85
|
+
e = rng.uniform(0, np.pi, size=(200, 3))
|
|
86
|
+
for sg in (225, 194):
|
|
87
|
+
rgb = ipf_rgb(e, sg)
|
|
88
|
+
assert rgb.shape == (200, 3)
|
|
89
|
+
assert rgb.min() >= 0.0 and rgb.max() <= 1.0
|
|
90
|
+
assert np.isfinite(rgb).all()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_cube_on_axis_is_red():
|
|
94
|
+
"""The identity orientation puts [001] along Z -> the [001] triangle corner."""
|
|
95
|
+
rgb = ipf_rgb(np.zeros((1, 3)), 225)[0]
|
|
96
|
+
assert rgb[0] > 0.9
|
|
97
|
+
assert rgb[1] < 0.2 and rgb[2] < 0.2
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_symmetry_equivalent_orientations_get_the_same_colour():
|
|
101
|
+
"""The whole point: one grain, one colour.
|
|
102
|
+
|
|
103
|
+
Rotating by a symmetry operator describes the SAME crystal, so the colour
|
|
104
|
+
must not move. An Euler->RGB scheme fails this badly.
|
|
105
|
+
"""
|
|
106
|
+
from midas_stress.orientation import (
|
|
107
|
+
euler_to_orient_mat_batch, orient_mat_to_euler,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
rng = np.random.default_rng(3)
|
|
111
|
+
e0 = rng.uniform(0, np.pi, size=(12, 3))
|
|
112
|
+
g0 = np.asarray(euler_to_orient_mat_batch(e0)).reshape(-1, 3, 3)
|
|
113
|
+
S = sym_matrices(225)
|
|
114
|
+
|
|
115
|
+
base = ipf_rgb(e0, 225)
|
|
116
|
+
for op in S[1:6]:
|
|
117
|
+
# symmetry acts on the RIGHT for crystal->lab matrices (MIDAS convention):
|
|
118
|
+
# midas_stress.misorientation_om_batch calls g.S equivalent and S.g a
|
|
119
|
+
# DIFFERENT orientation (61 deg). Asserting the wrong side is what let the
|
|
120
|
+
# g-vs-g^T bug survive until 2026-09-03.
|
|
121
|
+
g1 = np.einsum("nij,jk->nik", g0, op)
|
|
122
|
+
e1 = np.array([np.asarray(orient_mat_to_euler(m.ravel().tolist())).ravel()
|
|
123
|
+
for m in g1])
|
|
124
|
+
np.testing.assert_allclose(ipf_rgb(e1, 225), base, atol=1e-6)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_empty_input():
|
|
128
|
+
assert ipf_rgb(np.zeros((0, 3)), 225).shape == (0, 3)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_zero_axis_rejected():
|
|
132
|
+
with pytest.raises(ValueError, match="non-zero"):
|
|
133
|
+
ipf_rgb(np.zeros((1, 3)), 225, axis=(0, 0, 0))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_gamma_one_disables_the_perceptual_lift():
|
|
137
|
+
e = np.array([[0.3, 0.4, 0.5]])
|
|
138
|
+
lin = ipf_rgb(e, 225, gamma=1.0)
|
|
139
|
+
sq = ipf_rgb(e, 225, gamma=0.5)
|
|
140
|
+
np.testing.assert_allclose(sq, np.sqrt(lin), atol=1e-9)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_hexagonal_c_axis_along_z_is_red():
|
|
144
|
+
"""[0001] || Z is the first corner of the hexagonal triangle."""
|
|
145
|
+
rgb = ipf_rgb(np.zeros((1, 3)), 194)[0]
|
|
146
|
+
assert rgb[0] > 0.9
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --- trigonal (Laue -3m), added for NMC811 R-3m / SG 166 -------------------
|
|
150
|
+
|
|
151
|
+
def _random_oms(n, seed=0):
|
|
152
|
+
rng = np.random.default_rng(seed)
|
|
153
|
+
q = rng.normal(size=(n, 4))
|
|
154
|
+
q /= np.linalg.norm(q, axis=1, keepdims=True)
|
|
155
|
+
w, x, y, z = q.T
|
|
156
|
+
return np.stack([
|
|
157
|
+
1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w),
|
|
158
|
+
2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w),
|
|
159
|
+
2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y),
|
|
160
|
+
], axis=1).reshape(-1, 3, 3)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@pytest.mark.parametrize("sg", [166, 194, 225])
|
|
164
|
+
def test_ipf_colour_is_symmetry_invariant(sg):
|
|
165
|
+
"""colour(S.g) == colour(g) for every proper rotation S.
|
|
166
|
+
|
|
167
|
+
This is the property that makes an IPF map mean anything: one grain, one
|
|
168
|
+
colour, whichever symmetrically equivalent orientation the indexer
|
|
169
|
+
happened to report. It is also what caught the first -3m triangle, which
|
|
170
|
+
hand-folded the azimuth assuming a 2-fold axis at 0 deg and moved the
|
|
171
|
+
colour by up to 0.99 under this test.
|
|
172
|
+
"""
|
|
173
|
+
from midas_plotting.ipf import ipf_rgb_from_matrix, sym_matrices
|
|
174
|
+
g = _random_oms(2000, seed=sg)
|
|
175
|
+
base = ipf_rgb_from_matrix(g, sg)
|
|
176
|
+
for s in sym_matrices(sg):
|
|
177
|
+
got = ipf_rgb_from_matrix(np.einsum("nij,jk->nik", g, s), sg)
|
|
178
|
+
np.testing.assert_allclose(got, base, atol=1e-9)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_trigonal_sector_is_twice_the_hexagonal_one():
|
|
182
|
+
"""-3m has 6 proper rotations to 6/mmm's 12, so a 60 deg sector, not 30.
|
|
183
|
+
|
|
184
|
+
Measured from the operator set, so a change there cannot silently
|
|
185
|
+
mis-scale the ramp.
|
|
186
|
+
"""
|
|
187
|
+
from midas_plotting.ipf import _trigonal_sector_deg, sym_matrices
|
|
188
|
+
assert _trigonal_sector_deg(sym_matrices(166)) == pytest.approx(60.0)
|
|
189
|
+
assert _trigonal_sector_deg(sym_matrices(194)) == pytest.approx(30.0)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_trigonal_is_not_the_hexagonal_colouring():
|
|
193
|
+
"""R-3m must not be quietly coloured as 6/mmm -- that merges orientations."""
|
|
194
|
+
from midas_plotting.ipf import ipf_rgb_from_matrix
|
|
195
|
+
g = _random_oms(3000, seed=7)
|
|
196
|
+
assert np.abs(ipf_rgb_from_matrix(g, 166)
|
|
197
|
+
- ipf_rgb_from_matrix(g, 194)).mean() > 0.05
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_laue_class_still_refuses_what_it_cannot_draw():
|
|
201
|
+
from midas_plotting.ipf import laue_class
|
|
202
|
+
assert laue_class(166) == "trigonal"
|
|
203
|
+
with pytest.raises(NotImplementedError):
|
|
204
|
+
laue_class(2) # triclinic: no triangle implemented
|
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
"""Inverse-pole-figure colouring.
|
|
2
|
-
|
|
3
|
-
Colour encodes which crystal direction is parallel to a chosen sample axis, so
|
|
4
|
-
one grain is one colour and boundaries appear as colour discontinuities. That is
|
|
5
|
-
the property an Euler-to-RGB dump does NOT have: two orientations a fraction of
|
|
6
|
-
a degree apart can land on very different Euler triplets (and hence very
|
|
7
|
-
different colours) near the gimbal-lock line, which makes a single grain look
|
|
8
|
-
like several.
|
|
9
|
-
|
|
10
|
-
Symmetry operators come from :mod:`midas_stress`, never hand-listed here.
|
|
11
|
-
"""
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
from typing import Sequence
|
|
15
|
-
|
|
16
|
-
import numpy as np
|
|
17
|
-
|
|
18
|
-
__all__ = ["ipf_rgb", "sym_matrices", "CUBIC", "HEXAGONAL"]
|
|
19
|
-
|
|
20
|
-
CUBIC = "cubic"
|
|
21
|
-
HEXAGONAL = "hexagonal"
|
|
22
|
-
|
|
23
|
-
# Laue class per space-group range, for the ones MIDAS actually reconstructs.
|
|
24
|
-
# Deliberately explicit rather than clever: a wrong guess here silently
|
|
25
|
-
# recolours a map without any other symptom.
|
|
26
|
-
_SG_LAUE = [
|
|
27
|
-
(195, 230, CUBIC),
|
|
28
|
-
(168, 194, HEXAGONAL),
|
|
29
|
-
]
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def laue_class(space_group: int) -> str:
|
|
33
|
-
"""Laue family used for the IPF triangle.
|
|
34
|
-
|
|
35
|
-
Raises for space groups whose triangle is not implemented, rather than
|
|
36
|
-
falling back to cubic -- a silent fallback would produce a plausible-looking
|
|
37
|
-
but meaningless map.
|
|
38
|
-
"""
|
|
39
|
-
for lo, hi, name in _SG_LAUE:
|
|
40
|
-
if lo <= int(space_group) <= hi:
|
|
41
|
-
return name
|
|
42
|
-
raise NotImplementedError(
|
|
43
|
-
f"IPF colouring for space group {space_group} is not implemented "
|
|
44
|
-
f"(have: cubic 195-230, hexagonal 168-194). Refusing to guess."
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def sym_matrices(space_group: int) -> np.ndarray:
|
|
49
|
-
"""``(n_sym, 3, 3)`` proper-rotation operators from midas_stress."""
|
|
50
|
-
from midas_stress.orientation import make_symmetries, quat_to_orient_mat
|
|
51
|
-
|
|
52
|
-
n, quats = make_symmetries(int(space_group))
|
|
53
|
-
q = np.asarray(quats)[: int(n)]
|
|
54
|
-
return np.stack([np.asarray(quat_to_orient_mat(qi)).reshape(3, 3) for qi in q])
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _reduce_cubic(d: np.ndarray) -> np.ndarray:
|
|
58
|
-
"""Fold directions into the standard [001]-[101]-[111] triangle."""
|
|
59
|
-
d = np.abs(d)
|
|
60
|
-
d = np.sort(d, axis=-1) # u <= v <= w
|
|
61
|
-
return d
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _rgb_cubic(d: np.ndarray) -> np.ndarray:
|
|
65
|
-
u, v, w = d[:, 0], d[:, 1], d[:, 2]
|
|
66
|
-
rgb = np.stack([w - v, (v - u) * np.sqrt(2.0), u * np.sqrt(3.0)], axis=1)
|
|
67
|
-
return rgb
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _rgb_hexagonal(d: np.ndarray) -> np.ndarray:
|
|
71
|
-
"""Standard [0001]-[10-10]-[2-1-10] triangle.
|
|
72
|
-
|
|
73
|
-
``d`` is Cartesian with c along +z. After symmetry reduction the
|
|
74
|
-
representative has ``dz >= 0`` and azimuth in ``[0, 30]`` degrees.
|
|
75
|
-
"""
|
|
76
|
-
dz = np.abs(d[:, 2])
|
|
77
|
-
planar = np.hypot(d[:, 0], d[:, 1])
|
|
78
|
-
phi = np.degrees(np.arctan2(np.abs(d[:, 1]), np.abs(d[:, 0])))
|
|
79
|
-
phi = np.minimum(phi % 60.0, 60.0 - (phi % 60.0)) # fold to [0, 30]
|
|
80
|
-
t = np.clip(phi / 30.0, 0.0, 1.0)
|
|
81
|
-
return np.stack([dz, planar * (1.0 - t), planar * t], axis=1)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def ipf_rgb(
|
|
85
|
-
euler: np.ndarray,
|
|
86
|
-
space_group: int = 225,
|
|
87
|
-
axis: Sequence[float] = (0.0, 0.0, 1.0),
|
|
88
|
-
*,
|
|
89
|
-
gamma: float = 0.5,
|
|
90
|
-
) -> np.ndarray:
|
|
91
|
-
"""RGB per orientation for the crystal direction parallel to ``axis``.
|
|
92
|
-
|
|
93
|
-
Parameters
|
|
94
|
-
----------
|
|
95
|
-
euler : (N, 3) array
|
|
96
|
-
Bunge ZXZ Euler angles in **radians** -- the MIDAS ``.mic`` convention.
|
|
97
|
-
space_group : int
|
|
98
|
-
Used for the symmetry operators and to pick the triangle.
|
|
99
|
-
axis : length-3
|
|
100
|
-
Sample-frame direction. ``(0,0,1)`` gives the usual IPF-Z.
|
|
101
|
-
gamma : float
|
|
102
|
-
Perceptual lift applied as ``rgb ** gamma``. 0.5 (sqrt) matches the
|
|
103
|
-
common convention; 1.0 disables it.
|
|
104
|
-
|
|
105
|
-
Returns
|
|
106
|
-
-------
|
|
107
|
-
(N, 3) float array in [0, 1].
|
|
108
|
-
"""
|
|
109
|
-
from midas_stress.orientation import euler_to_orient_mat_batch
|
|
110
|
-
|
|
111
|
-
euler = np.asarray(euler, dtype=float).reshape(-1, 3)
|
|
112
|
-
if euler.size == 0:
|
|
113
|
-
return np.zeros((0, 3))
|
|
114
|
-
g = np.asarray(euler_to_orient_mat_batch(euler)).reshape(-1, 3, 3)
|
|
115
|
-
return ipf_rgb_from_matrix(g, space_group, axis, gamma=gamma)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def ipf_rgb_from_matrix(
|
|
119
|
-
orient_mat: np.ndarray,
|
|
120
|
-
space_group: int = 225,
|
|
121
|
-
axis: Sequence[float] = (0.0, 0.0, 1.0),
|
|
122
|
-
*,
|
|
123
|
-
gamma: float = 0.5,
|
|
124
|
-
) -> np.ndarray:
|
|
125
|
-
"""RGB per orientation, from ``(N, 3, 3)`` orientation matrices.
|
|
126
|
-
|
|
127
|
-
The same colouring as :func:`ipf_rgb`, entered from the matrix rather than
|
|
128
|
-
from Euler angles. Far-field ``Grains.csv`` carries both (``O11..O33`` and
|
|
129
|
-
``Eul0..2``); this avoids a needless matrix -> Euler -> matrix round trip,
|
|
130
|
-
which is lossy near the gimbal-lock configurations of the ZXZ convention.
|
|
131
|
-
"""
|
|
132
|
-
g = np.asarray(orient_mat, dtype=float).reshape(-1, 3, 3)
|
|
133
|
-
if g.size == 0:
|
|
134
|
-
return np.zeros((0, 3))
|
|
135
|
-
|
|
136
|
-
a = np.asarray(axis, dtype=float)
|
|
137
|
-
n = np.linalg.norm(a)
|
|
138
|
-
if n == 0:
|
|
139
|
-
raise ValueError("axis must be non-zero")
|
|
140
|
-
a = a / n
|
|
141
|
-
|
|
142
|
-
d = np.einsum("nij,j->ni", g, a) # crystal dir of the axis
|
|
143
|
-
return direction_rgb(d, space_group, gamma=gamma)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def direction_rgb(
|
|
147
|
-
dirs: np.ndarray, space_group: int = 225, *, gamma: float = 0.5,
|
|
148
|
-
) -> np.ndarray:
|
|
149
|
-
"""RGB for **crystal directions** -- the colouring core.
|
|
150
|
-
|
|
151
|
-
``dirs`` is ``(N, 3)`` in crystal coordinates; it is normalised here.
|
|
152
|
-
Both :func:`ipf_rgb` and the legend drawn by
|
|
153
|
-
``midas_plotting.ff.ipf_legend`` go through this, so the key on a figure
|
|
154
|
-
is guaranteed to match the colours in the map beside it. A legend computed
|
|
155
|
-
by a separate copy of the triangle maths is a legend that eventually lies.
|
|
156
|
-
"""
|
|
157
|
-
d = np.asarray(dirs, dtype=float).reshape(-1, 3)
|
|
158
|
-
if d.size == 0:
|
|
159
|
-
return np.zeros((0, 3))
|
|
160
|
-
nrm = np.linalg.norm(d, axis=1, keepdims=True)
|
|
161
|
-
d = np.divide(d, nrm, out=np.zeros_like(d), where=nrm > 0)
|
|
162
|
-
|
|
163
|
-
fam = laue_class(space_group)
|
|
164
|
-
sym = sym_matrices(space_group)
|
|
165
|
-
d = np.einsum("sij,nj->nsi", sym, d) # every equivalent
|
|
166
|
-
|
|
167
|
-
if fam == CUBIC:
|
|
168
|
-
red = _reduce_cubic(d)
|
|
169
|
-
pick = np.argmax(red[:, :, 2], axis=1) # closest to [001]
|
|
170
|
-
red = red[np.arange(red.shape[0]), pick]
|
|
171
|
-
red /= np.linalg.norm(red, axis=1, keepdims=True)
|
|
172
|
-
rgb = _rgb_cubic(red)
|
|
173
|
-
else:
|
|
174
|
-
dd = d.copy()
|
|
175
|
-
dd[:, :, 2] = np.abs(dd[:, :, 2])
|
|
176
|
-
pick = np.argmax(dd[:, :, 2], axis=1) # closest to [0001]
|
|
177
|
-
red = dd[np.arange(dd.shape[0]), pick]
|
|
178
|
-
red /= np.linalg.norm(red, axis=1, keepdims=True)
|
|
179
|
-
rgb = _rgb_hexagonal(red)
|
|
180
|
-
|
|
181
|
-
rgb = np.clip(rgb, 0.0, None)
|
|
182
|
-
mx = rgb.max(axis=1, keepdims=True)
|
|
183
|
-
rgb = np.where(mx > 0, rgb / mx, rgb)
|
|
184
|
-
return np.clip(rgb ** float(gamma), 0.0, 1.0)
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
"""IPF colouring.
|
|
2
|
-
|
|
3
|
-
The property that matters: colour is a function of the crystal direction along
|
|
4
|
-
a sample axis, so it is invariant under symmetry-equivalent descriptions of the
|
|
5
|
-
same orientation. That is what makes one grain one colour, and it is what
|
|
6
|
-
Euler->RGB does not give.
|
|
7
|
-
"""
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import numpy as np
|
|
11
|
-
import pytest
|
|
12
|
-
|
|
13
|
-
from midas_plotting.ipf import CUBIC, HEXAGONAL, ipf_rgb, laue_class, sym_matrices
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def test_laue_class_known_families():
|
|
17
|
-
assert laue_class(225) == CUBIC
|
|
18
|
-
assert laue_class(229) == CUBIC
|
|
19
|
-
assert laue_class(194) == HEXAGONAL
|
|
20
|
-
assert laue_class(168) == HEXAGONAL
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def test_laue_class_refuses_unimplemented_rather_than_guessing():
|
|
24
|
-
"""A silent fallback to cubic would recolour a map with no other symptom."""
|
|
25
|
-
with pytest.raises(NotImplementedError, match="not implemented"):
|
|
26
|
-
laue_class(2) # triclinic
|
|
27
|
-
with pytest.raises(NotImplementedError):
|
|
28
|
-
laue_class(139) # tetragonal
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def test_sym_matrices_are_proper_rotations():
|
|
32
|
-
for sg, n_expected in ((225, 24), (194, 12)):
|
|
33
|
-
S = sym_matrices(sg)
|
|
34
|
-
assert S.shape == (n_expected, 3, 3), (sg, S.shape)
|
|
35
|
-
dets = np.linalg.det(S)
|
|
36
|
-
np.testing.assert_allclose(dets, 1.0, atol=1e-9)
|
|
37
|
-
for M in S:
|
|
38
|
-
np.testing.assert_allclose(M @ M.T, np.eye(3), atol=1e-9)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def test_rgb_in_unit_range():
|
|
42
|
-
rng = np.random.default_rng(0)
|
|
43
|
-
e = rng.uniform(0, np.pi, size=(200, 3))
|
|
44
|
-
for sg in (225, 194):
|
|
45
|
-
rgb = ipf_rgb(e, sg)
|
|
46
|
-
assert rgb.shape == (200, 3)
|
|
47
|
-
assert rgb.min() >= 0.0 and rgb.max() <= 1.0
|
|
48
|
-
assert np.isfinite(rgb).all()
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def test_cube_on_axis_is_red():
|
|
52
|
-
"""The identity orientation puts [001] along Z -> the [001] triangle corner."""
|
|
53
|
-
rgb = ipf_rgb(np.zeros((1, 3)), 225)[0]
|
|
54
|
-
assert rgb[0] > 0.9
|
|
55
|
-
assert rgb[1] < 0.2 and rgb[2] < 0.2
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def test_symmetry_equivalent_orientations_get_the_same_colour():
|
|
59
|
-
"""The whole point: one grain, one colour.
|
|
60
|
-
|
|
61
|
-
Rotating by a symmetry operator describes the SAME crystal, so the colour
|
|
62
|
-
must not move. An Euler->RGB scheme fails this badly.
|
|
63
|
-
"""
|
|
64
|
-
from midas_stress.orientation import (
|
|
65
|
-
euler_to_orient_mat_batch, orient_mat_to_euler,
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
rng = np.random.default_rng(3)
|
|
69
|
-
e0 = rng.uniform(0, np.pi, size=(12, 3))
|
|
70
|
-
g0 = np.asarray(euler_to_orient_mat_batch(e0)).reshape(-1, 3, 3)
|
|
71
|
-
S = sym_matrices(225)
|
|
72
|
-
|
|
73
|
-
base = ipf_rgb(e0, 225)
|
|
74
|
-
for op in S[1:6]:
|
|
75
|
-
g1 = np.einsum("ij,njk->nik", op, g0)
|
|
76
|
-
e1 = np.array([np.asarray(orient_mat_to_euler(m.ravel().tolist())).ravel()
|
|
77
|
-
for m in g1])
|
|
78
|
-
np.testing.assert_allclose(ipf_rgb(e1, 225), base, atol=1e-6)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def test_empty_input():
|
|
82
|
-
assert ipf_rgb(np.zeros((0, 3)), 225).shape == (0, 3)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def test_zero_axis_rejected():
|
|
86
|
-
with pytest.raises(ValueError, match="non-zero"):
|
|
87
|
-
ipf_rgb(np.zeros((1, 3)), 225, axis=(0, 0, 0))
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def test_gamma_one_disables_the_perceptual_lift():
|
|
91
|
-
e = np.array([[0.3, 0.4, 0.5]])
|
|
92
|
-
lin = ipf_rgb(e, 225, gamma=1.0)
|
|
93
|
-
sq = ipf_rgb(e, 225, gamma=0.5)
|
|
94
|
-
np.testing.assert_allclose(sq, np.sqrt(lin), atol=1e-9)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def test_hexagonal_c_axis_along_z_is_red():
|
|
98
|
-
"""[0001] || Z is the first corner of the hexagonal triangle."""
|
|
99
|
-
rgb = ipf_rgb(np.zeros((1, 3)), 194)[0]
|
|
100
|
-
assert rgb[0] > 0.9
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|