midas-plotting 0.3.0__py3-none-any.whl
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/__init__.py +59 -0
- midas_plotting/cli.py +264 -0
- midas_plotting/ff.py +522 -0
- midas_plotting/grains.py +301 -0
- midas_plotting/ipf.py +184 -0
- midas_plotting/laue.py +671 -0
- midas_plotting/maps.py +197 -0
- midas_plotting/mic.py +87 -0
- midas_plotting/solutions.py +398 -0
- midas_plotting-0.3.0.dist-info/METADATA +161 -0
- midas_plotting-0.3.0.dist-info/RECORD +14 -0
- midas_plotting-0.3.0.dist-info/WHEEL +5 -0
- midas_plotting-0.3.0.dist-info/entry_points.txt +2 -0
- midas_plotting-0.3.0.dist-info/top_level.txt +1 -0
midas_plotting/laue.py
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
"""Plots for Laue microdiffraction reconstructions.
|
|
2
|
+
|
|
3
|
+
Namespaced like :mod:`midas_plotting.ff` rather than exported flat, because
|
|
4
|
+
``pole_figure`` and ``grain_map`` already mean different things per modality and
|
|
5
|
+
flattening them would collide.
|
|
6
|
+
|
|
7
|
+
Laue differs from FF and NF in ways the plots have to respect:
|
|
8
|
+
|
|
9
|
+
* **A "grain" is a cluster of per-frame orientations**, not a row in a file.
|
|
10
|
+
Nothing is a grain until it has been clustered at a stated tolerance, so every
|
|
11
|
+
grain number here carries the tolerance that produced it.
|
|
12
|
+
* **An orientation found at every raster position is not a grain.** The beam
|
|
13
|
+
moves a micron or two between frames, so a real grain leaves the probe volume.
|
|
14
|
+
Anything spanning more than half the map is the substrate, a detector
|
|
15
|
+
artefact, or a mis-index -- :func:`cluster` flags it and the plots exclude it.
|
|
16
|
+
* **Reading a pole figure needs its chance level.** Half of a randomly oriented
|
|
17
|
+
population has its c-axis more than 60 deg from any fixed direction, purely
|
|
18
|
+
from solid angle. Quoting "70% of grains lie near the surface plane" without
|
|
19
|
+
that reference turns a near-random distribution into a texture.
|
|
20
|
+
:func:`tilt_histogram` draws the reference by default.
|
|
21
|
+
|
|
22
|
+
The 45 deg stage geometry and the surface normal are arguments with 34-ID-E
|
|
23
|
+
defaults, never hard-coded: they differ by beamline and a wrong normal rotates
|
|
24
|
+
every pole figure without any other symptom.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import warnings
|
|
29
|
+
from typing import Optional, Sequence
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
from .ipf import direction_rgb, laue_class, sym_matrices # re-exported for tests/callers
|
|
34
|
+
from .solutions import COS45, LaueSolutions, LaueSpots
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"SURFACE_NORMAL_34IDE", "cluster", "GrainClusters", "effective_n",
|
|
38
|
+
"misorientation_matrix", "sym_matrices",
|
|
39
|
+
"orientation_map", "pole_figure", "grain_size_distribution",
|
|
40
|
+
"tilt_histogram", "random_tilt_fractions", "texture_strength",
|
|
41
|
+
"tolerance_sweep", "spot_overlay", "occupancy_map", "summary",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
#: Sample surface normal in the 34-ID-E sample frame, for a specimen mounted at
|
|
45
|
+
#: 45 deg. Every tilt and azimuth here is measured against this.
|
|
46
|
+
SURFACE_NORMAL_34IDE = np.array([0.0, -COS45, COS45])
|
|
47
|
+
|
|
48
|
+
#: Complete-linkage clustering needs every pairwise misorientation, so both time
|
|
49
|
+
#: and memory go as N^2. Above this the pairwise matrix stops fitting
|
|
50
|
+
#: comfortably in RAM (6000 instances is ~144 MB as float32) and the honest
|
|
51
|
+
#: response is to refuse with an instruction rather than to appear to hang.
|
|
52
|
+
MAX_CLUSTER = 6000
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _unit(v):
|
|
56
|
+
v = np.asarray(v, float).ravel()
|
|
57
|
+
n = np.linalg.norm(v)
|
|
58
|
+
if n == 0:
|
|
59
|
+
raise ValueError("direction must be non-zero")
|
|
60
|
+
return v / n
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _axis_dirs(om: np.ndarray, hkl=(0, 0, 1), *, normal=None, B=None):
|
|
64
|
+
"""Sample-frame unit vectors of a crystal direction, folded to one hemisphere.
|
|
65
|
+
|
|
66
|
+
``+d`` and ``-d`` are the same axis, so the sign is fixed by the surface
|
|
67
|
+
normal. Without that fold a single population straddles both hemispheres and
|
|
68
|
+
every density is halved somewhere.
|
|
69
|
+
"""
|
|
70
|
+
om = np.asarray(om, float).reshape(-1, 3, 3)
|
|
71
|
+
if om.size == 0:
|
|
72
|
+
return np.zeros((0, 3))
|
|
73
|
+
h = _unit(hkl)
|
|
74
|
+
v = np.einsum("nij,j->ni", om if B is None else om @ np.asarray(B), h)
|
|
75
|
+
v = v / np.linalg.norm(v, axis=1, keepdims=True)
|
|
76
|
+
n = SURFACE_NORMAL_34IDE if normal is None else _unit(normal)
|
|
77
|
+
return v * np.sign(v @ n)[:, None]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def effective_n(sizes) -> float:
|
|
81
|
+
"""Kish effective sample size of a set of cluster sizes.
|
|
82
|
+
|
|
83
|
+
``(sum w)^2 / sum w^2``. The number to quote next to a grain count: where a
|
|
84
|
+
few large clusters dominate it falls far below the nominal count, and every
|
|
85
|
+
per-grain percentage is correspondingly less certain than it looks. One G31
|
|
86
|
+
map had 99 clusters and an effective n of 2.5.
|
|
87
|
+
"""
|
|
88
|
+
s = np.asarray(sizes, float).ravel()
|
|
89
|
+
if s.size == 0 or s.sum() == 0:
|
|
90
|
+
return 0.0
|
|
91
|
+
w = s / s.sum()
|
|
92
|
+
return float(1.0 / np.sum(w ** 2))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class GrainClusters:
|
|
96
|
+
"""Result of :func:`cluster`: labels, sizes, extents and the full-field flag."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, labels, sizes, extent, full_field, tolerance, pos=None):
|
|
99
|
+
self.labels = labels
|
|
100
|
+
self.sizes = sizes
|
|
101
|
+
self.extent = extent
|
|
102
|
+
self.full_field = full_field
|
|
103
|
+
self.tolerance = float(tolerance)
|
|
104
|
+
self.pos = pos
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def n_clusters(self) -> int:
|
|
108
|
+
return int(self.sizes.size)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def n_grains(self) -> int:
|
|
112
|
+
"""Clusters that survive the full-field filter."""
|
|
113
|
+
return int((~self.full_field).sum())
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def n_eff(self) -> float:
|
|
117
|
+
return effective_n(self.sizes)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def n_eff_grains(self) -> float:
|
|
121
|
+
return effective_n(self.sizes[~self.full_field])
|
|
122
|
+
|
|
123
|
+
def representatives(self, om: np.ndarray) -> np.ndarray:
|
|
124
|
+
"""One orientation matrix per surviving grain."""
|
|
125
|
+
om = np.asarray(om).reshape(-1, 3, 3)
|
|
126
|
+
keep = np.where(~self.full_field)[0]
|
|
127
|
+
return np.stack([om[self.labels == g][0] for g in keep]) if keep.size \
|
|
128
|
+
else np.zeros((0, 3, 3))
|
|
129
|
+
|
|
130
|
+
def __repr__(self) -> str:
|
|
131
|
+
return (f"<GrainClusters {self.n_grains} grains at "
|
|
132
|
+
f"{self.tolerance}deg (of {self.n_clusters} clusters, "
|
|
133
|
+
f"{int(self.full_field.sum())} spanning >half the map), "
|
|
134
|
+
f"n_eff {self.n_eff_grains:.1f}>")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cluster(sol: LaueSolutions, tolerance: float = 1.0, *,
|
|
138
|
+
space_group: int = 194, full_field_frac: float = 0.5):
|
|
139
|
+
"""Complete-linkage clustering of per-frame orientations into grains.
|
|
140
|
+
|
|
141
|
+
Complete linkage ("diameter") rather than single linkage on purpose: it
|
|
142
|
+
guarantees every member of a grain lies within ``tolerance`` of **every**
|
|
143
|
+
other member. Single linkage only constrains nearest neighbours, which lets
|
|
144
|
+
a grain drift arbitrarily far across orientation space one small step at a
|
|
145
|
+
time.
|
|
146
|
+
|
|
147
|
+
Clusters whose spatial extent exceeds ``full_field_frac`` of the map are
|
|
148
|
+
flagged, not deleted -- the count of them is a diagnostic worth seeing.
|
|
149
|
+
"""
|
|
150
|
+
om = np.asarray(sol.orient_mat).reshape(-1, 3, 3)
|
|
151
|
+
n = len(om)
|
|
152
|
+
if n == 0:
|
|
153
|
+
z = np.zeros(0)
|
|
154
|
+
return GrainClusters(np.zeros(0, int), z, z, np.zeros(0, bool),
|
|
155
|
+
tolerance, sol.pos)
|
|
156
|
+
if n > MAX_CLUSTER:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"{n} instances exceeds the {MAX_CLUSTER} that complete-linkage "
|
|
159
|
+
f"clustering handles here (it needs the full N x N misorientation "
|
|
160
|
+
f"matrix). Subsample UNIFORMLY ACROSS POSITIONS first -- "
|
|
161
|
+
f"subsampling by match quality biases toward large bright grains "
|
|
162
|
+
f"and changes the answer.")
|
|
163
|
+
|
|
164
|
+
d = misorientation_matrix(om, sym_matrices(space_group))
|
|
165
|
+
tol = float(tolerance)
|
|
166
|
+
labels = np.full(n, -1, int)
|
|
167
|
+
nxt = 0
|
|
168
|
+
for i in range(n):
|
|
169
|
+
if labels[i] >= 0:
|
|
170
|
+
continue
|
|
171
|
+
labels[i] = nxt
|
|
172
|
+
members = [i]
|
|
173
|
+
for j in np.where((labels < 0) & (d[i] <= tol))[0]:
|
|
174
|
+
if labels[j] >= 0:
|
|
175
|
+
continue
|
|
176
|
+
if d[j, members].max() <= tol: # complete linkage
|
|
177
|
+
labels[j] = nxt
|
|
178
|
+
members.append(int(j))
|
|
179
|
+
nxt += 1
|
|
180
|
+
|
|
181
|
+
sizes = np.bincount(labels, minlength=nxt).astype(float)
|
|
182
|
+
if sol.pos is not None:
|
|
183
|
+
ext = np.array([
|
|
184
|
+
max(np.ptp(sol.pos[labels == g, 0]) if (labels == g).sum() > 1 else 0.0,
|
|
185
|
+
np.ptp(sol.pos[labels == g, 1]) if (labels == g).sum() > 1 else 0.0)
|
|
186
|
+
for g in range(nxt)])
|
|
187
|
+
lim = full_field_frac * max(np.ptp(sol.pos[:, 0]), np.ptp(sol.pos[:, 1]))
|
|
188
|
+
full = ext > lim
|
|
189
|
+
else:
|
|
190
|
+
ext = np.zeros(nxt)
|
|
191
|
+
full = np.zeros(nxt, bool)
|
|
192
|
+
return GrainClusters(labels, sizes, ext, full, tolerance, sol.pos)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def misorientation_matrix(om: np.ndarray, sym: np.ndarray,
|
|
196
|
+
chunk: int = 256) -> np.ndarray:
|
|
197
|
+
"""Symmetry-reduced pairwise misorientation angles in **degrees**, ``(N, N)``.
|
|
198
|
+
|
|
199
|
+
``min_S angle(A S B^T)`` over the proper rotations of the Laue group,
|
|
200
|
+
evaluated in row chunks because the intermediate is ``(chunk, N, n_sym)``.
|
|
201
|
+
"""
|
|
202
|
+
om = np.asarray(om, float).reshape(-1, 3, 3)
|
|
203
|
+
n = len(om)
|
|
204
|
+
out = np.zeros((n, n), np.float32)
|
|
205
|
+
AS = np.einsum("nij,sjk->nsik", om, sym) # (N, n_sym, 3, 3)
|
|
206
|
+
for i0 in range(0, n, chunk):
|
|
207
|
+
i1 = min(i0 + chunk, n)
|
|
208
|
+
# trace(A S B^T) contracted directly, without forming the product
|
|
209
|
+
tr = np.einsum("nsij,mij->nms", AS[i0:i1], om)
|
|
210
|
+
c = np.clip((tr - 1.0) / 2.0, -1.0, 1.0)
|
|
211
|
+
out[i0:i1] = np.degrees(np.arccos(c)).min(axis=2).astype(np.float32)
|
|
212
|
+
np.fill_diagonal(out, 0.0)
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# --------------------------------------------------------------------------
|
|
217
|
+
# the reference every Laue pole figure needs
|
|
218
|
+
# --------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def random_tilt_fractions(edges_deg=(0, 15, 30, 45, 60, 90)) -> np.ndarray:
|
|
221
|
+
"""Share of a **randomly oriented** population in each tilt band.
|
|
222
|
+
|
|
223
|
+
For directions uniform on the sphere and folded to one hemisphere, the
|
|
224
|
+
fraction with tilt in ``[a, b]`` is ``cos a - cos b``. For the default bands
|
|
225
|
+
that is 3.4 / 10.0 / 15.9 / 20.7 / **50.0** per cent.
|
|
226
|
+
|
|
227
|
+
That last number is the one that matters. Half of a random population lies
|
|
228
|
+
beyond 60 deg simply because that band is half the hemisphere, so a deposit
|
|
229
|
+
showing "70% of grains near the surface plane" is barely above random, and
|
|
230
|
+
one showing 30% is *depleted* there. Reading a tilt histogram without this
|
|
231
|
+
reference is how a non-texture gets reported as a prismatic one.
|
|
232
|
+
"""
|
|
233
|
+
e = np.radians(np.asarray(edges_deg, float))
|
|
234
|
+
return np.cos(e[:-1]) - np.cos(e[1:])
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def tilt_histogram(sol, ax=None, *, hkl=(0, 0, 1), normal=None,
|
|
238
|
+
edges_deg=(0, 15, 30, 45, 60, 90), weights=None,
|
|
239
|
+
reference: bool = True, label: Optional[str] = None,
|
|
240
|
+
title: Optional[str] = None):
|
|
241
|
+
"""Tilt of a crystal direction from the surface normal, against random.
|
|
242
|
+
|
|
243
|
+
Parameters
|
|
244
|
+
----------
|
|
245
|
+
sol : LaueSolutions or (N, 3, 3) array
|
|
246
|
+
One entry per **grain** if you want a per-grain statistic; passing raw
|
|
247
|
+
per-frame solutions weights the answer by how many frames each grain
|
|
248
|
+
was seen on, which is a different (area-weighted) question.
|
|
249
|
+
weights : (N,) array, optional
|
|
250
|
+
Cluster sizes, to show the share of mapped *area* rather than of grains.
|
|
251
|
+
reference : bool
|
|
252
|
+
Draw the random-orientation expectation. Leave this on.
|
|
253
|
+
"""
|
|
254
|
+
import matplotlib.pyplot as plt
|
|
255
|
+
|
|
256
|
+
om = sol.orient_mat if isinstance(sol, LaueSolutions) else sol
|
|
257
|
+
v = _axis_dirs(om, hkl, normal=normal)
|
|
258
|
+
n = SURFACE_NORMAL_34IDE if normal is None else _unit(normal)
|
|
259
|
+
tilt = np.degrees(np.arccos(np.clip(v @ n, -1.0, 1.0)))
|
|
260
|
+
|
|
261
|
+
edges = np.asarray(edges_deg, float)
|
|
262
|
+
w = np.ones(len(tilt)) if weights is None else np.asarray(weights, float)
|
|
263
|
+
counts, _ = np.histogram(tilt, bins=edges, weights=w)
|
|
264
|
+
share = 100.0 * counts / max(counts.sum(), 1e-12)
|
|
265
|
+
rand = 100.0 * random_tilt_fractions(edges)
|
|
266
|
+
|
|
267
|
+
if ax is None:
|
|
268
|
+
_, ax = plt.subplots(figsize=(6.4, 4.0))
|
|
269
|
+
centres = np.arange(len(share))
|
|
270
|
+
bw = 0.38
|
|
271
|
+
if reference:
|
|
272
|
+
ax.bar(centres - bw / 2, rand, bw, color="#9AA3AB", zorder=3,
|
|
273
|
+
label="random orientations")
|
|
274
|
+
ax.bar(centres + bw / 2, share, bw, color="#A8452F", zorder=3,
|
|
275
|
+
label=label or "measured")
|
|
276
|
+
ax.legend(fontsize=9, frameon=False)
|
|
277
|
+
else:
|
|
278
|
+
ax.bar(centres, share, 0.62, color="#A8452F", zorder=3,
|
|
279
|
+
label=label or "measured")
|
|
280
|
+
ax.set_xticks(centres)
|
|
281
|
+
ax.set_xticklabels([f"{a:.0f}–{b:.0f}°"
|
|
282
|
+
for a, b in zip(edges[:-1], edges[1:])])
|
|
283
|
+
ax.set_xlabel(f"{tuple(int(x) for x in hkl)} tilt from the surface normal")
|
|
284
|
+
ax.set_ylabel("share of grains (%)" if weights is None
|
|
285
|
+
else "share of mapped area (%)")
|
|
286
|
+
ax.grid(axis="y", lw=0.4, alpha=0.5, zorder=0)
|
|
287
|
+
ax.set_title(title or "Where the crystal axes point\n"
|
|
288
|
+
"grey = what random orientations give", fontsize=10)
|
|
289
|
+
return ax
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --------------------------------------------------------------------------
|
|
293
|
+
# pole figures and texture strength
|
|
294
|
+
# --------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def _kernel_grid(normal, step_deg=4.0):
|
|
297
|
+
n = _unit(normal)
|
|
298
|
+
a = np.cross(n, [1.0, 0.0, 0.0])
|
|
299
|
+
if np.linalg.norm(a) < 1e-8:
|
|
300
|
+
a = np.cross(n, [0.0, 1.0, 0.0])
|
|
301
|
+
a = a / np.linalg.norm(a)
|
|
302
|
+
b = np.cross(n, a)
|
|
303
|
+
g = []
|
|
304
|
+
for dec in np.arange(step_deg / 2.0, 90.0, step_deg):
|
|
305
|
+
naz = max(int(round(90 * np.sin(np.radians(dec)))), 1)
|
|
306
|
+
for az in np.linspace(0, 360, naz, endpoint=False):
|
|
307
|
+
t, p = np.radians(dec), np.radians(az)
|
|
308
|
+
g.append(np.cos(t) * n + np.sin(t) * (np.cos(p) * a + np.sin(p) * b))
|
|
309
|
+
return np.asarray(g), a, b
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _mrd(dirs, grid, bandwidth_deg):
|
|
313
|
+
"""Kernel density on the sphere, normalised so uniform -> 1."""
|
|
314
|
+
if len(dirs) == 0:
|
|
315
|
+
return 0.0
|
|
316
|
+
k = 1.0 / np.radians(bandwidth_deg) ** 2
|
|
317
|
+
w = np.exp(k * (np.abs(grid @ dirs.T) - 1.0))
|
|
318
|
+
d = w.sum(axis=1)
|
|
319
|
+
return float((d / d.mean()).max())
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def texture_strength(sol, *, hkl=(0, 0, 1), normal=None, bandwidth_deg=10.0,
|
|
323
|
+
n_null: int = 200, seed: int = 0):
|
|
324
|
+
"""Peak pole density and the chance level for **this** number of grains.
|
|
325
|
+
|
|
326
|
+
Returns ``(peak_mrd, chance_95, ratio)``.
|
|
327
|
+
|
|
328
|
+
The ratio is the number to compare across datasets. A raw peak density is
|
|
329
|
+
not comparable between populations of different size -- a small population
|
|
330
|
+
peaks higher by chance alone, and its chance level rises to match. Dividing
|
|
331
|
+
each by its own null is what makes 85 grains and 631 grains commensurable.
|
|
332
|
+
|
|
333
|
+
The null here is uniformly random orientations. If the indexing pipeline
|
|
334
|
+
accepts some orientations more readily than others, an *indexability-matched*
|
|
335
|
+
null is stricter and should be preferred; this one cannot see that bias.
|
|
336
|
+
"""
|
|
337
|
+
om = sol.orient_mat if isinstance(sol, LaueSolutions) else sol
|
|
338
|
+
v = _axis_dirs(om, hkl, normal=normal)
|
|
339
|
+
n = SURFACE_NORMAL_34IDE if normal is None else _unit(normal)
|
|
340
|
+
grid, _, _ = _kernel_grid(n)
|
|
341
|
+
obs = _mrd(v, grid, bandwidth_deg)
|
|
342
|
+
|
|
343
|
+
rng = np.random.default_rng(seed)
|
|
344
|
+
null = np.empty(int(n_null))
|
|
345
|
+
for i in range(int(n_null)):
|
|
346
|
+
r = rng.normal(size=(len(v), 3))
|
|
347
|
+
r /= np.linalg.norm(r, axis=1, keepdims=True)
|
|
348
|
+
r *= np.sign(r @ n)[:, None]
|
|
349
|
+
null[i] = _mrd(r, grid, bandwidth_deg)
|
|
350
|
+
c95 = float(np.percentile(null, 95))
|
|
351
|
+
return obs, c95, (obs / c95 if c95 > 0 else np.nan)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def pole_figure(sol, ax=None, *, hkl=(0, 0, 1), normal=None,
|
|
355
|
+
sizes=None, title: Optional[str] = None, s: float = 6.0):
|
|
356
|
+
"""Equal-angle pole figure, centre = the surface normal.
|
|
357
|
+
|
|
358
|
+
Rings at 30, 60 and 90 degrees. Pass grain representatives, not raw
|
|
359
|
+
per-frame solutions, unless you mean to weight by residence time.
|
|
360
|
+
"""
|
|
361
|
+
import matplotlib.pyplot as plt
|
|
362
|
+
|
|
363
|
+
om = sol.orient_mat if isinstance(sol, LaueSolutions) else sol
|
|
364
|
+
v = _axis_dirs(om, hkl, normal=normal)
|
|
365
|
+
n = SURFACE_NORMAL_34IDE if normal is None else _unit(normal)
|
|
366
|
+
_, a, b = _kernel_grid(n)
|
|
367
|
+
dec = np.degrees(np.arccos(np.clip(v @ n, 0.0, 1.0)))
|
|
368
|
+
az = np.arctan2(v @ b, v @ a)
|
|
369
|
+
r = np.tan(np.radians(dec) / 2.0)
|
|
370
|
+
|
|
371
|
+
if ax is None:
|
|
372
|
+
_, ax = plt.subplots(figsize=(4.6, 4.6))
|
|
373
|
+
ax.scatter(r * np.cos(az), r * np.sin(az),
|
|
374
|
+
s=s if sizes is None else np.clip(np.asarray(sizes, float), 1, None),
|
|
375
|
+
alpha=0.4, color="#A8542F", edgecolors="none", zorder=3)
|
|
376
|
+
th = np.linspace(0, 2 * np.pi, 240)
|
|
377
|
+
for d in (30, 60, 90):
|
|
378
|
+
rr = np.tan(np.radians(d) / 2.0)
|
|
379
|
+
ax.plot(rr * np.cos(th), rr * np.sin(th), lw=0.6, color="#999", zorder=2)
|
|
380
|
+
ax.set_aspect("equal")
|
|
381
|
+
ax.set_xticks([]); ax.set_yticks([])
|
|
382
|
+
ax.set_title(title or f"{tuple(int(x) for x in hkl)} pole figure "
|
|
383
|
+
f"({len(v)} points)\ncentre = surface normal, "
|
|
384
|
+
f"rings 30/60/90°", fontsize=10)
|
|
385
|
+
return ax
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# --------------------------------------------------------------------------
|
|
389
|
+
# maps
|
|
390
|
+
# --------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
def orientation_map(sol: LaueSolutions, ax=None, *, normal=None,
|
|
393
|
+
space_group: Optional[int] = None, hkl=(0, 0, 1),
|
|
394
|
+
color: str = "azimuth", title: Optional[str] = None):
|
|
395
|
+
"""Map of the scan, one colour per orientation.
|
|
396
|
+
|
|
397
|
+
``color='azimuth'`` (default) sets hue from the rotation about the surface
|
|
398
|
+
normal and paleness from alignment with it, so a single-coloured region is
|
|
399
|
+
one grain. ``color='ipf'`` uses the standard IPF triangle instead and needs
|
|
400
|
+
``space_group``.
|
|
401
|
+
|
|
402
|
+
Where several orientations share a position the strongest (most matched
|
|
403
|
+
reflections) wins the pixel, which is stated rather than silent: a Laue
|
|
404
|
+
frame routinely carries more than one crystal.
|
|
405
|
+
"""
|
|
406
|
+
import matplotlib.pyplot as plt
|
|
407
|
+
from matplotlib.colors import hsv_to_rgb
|
|
408
|
+
|
|
409
|
+
if sol.pos is None:
|
|
410
|
+
raise ValueError(
|
|
411
|
+
"this LaueSolutions has no positions; pass positions= to "
|
|
412
|
+
"read_solutions, or load a validated .npz which carries X and Z")
|
|
413
|
+
v = _axis_dirs(sol.orient_mat, hkl, normal=normal)
|
|
414
|
+
n = SURFACE_NORMAL_34IDE if normal is None else _unit(normal)
|
|
415
|
+
|
|
416
|
+
if color == "ipf":
|
|
417
|
+
if space_group is None:
|
|
418
|
+
raise ValueError("color='ipf' needs space_group")
|
|
419
|
+
laue_class(space_group) # refuse unknown families
|
|
420
|
+
rgb = direction_rgb(np.einsum("nij,j->ni", sol.orient_mat, _unit(hkl)),
|
|
421
|
+
space_group)
|
|
422
|
+
elif color == "azimuth":
|
|
423
|
+
_, a, b = _kernel_grid(n)
|
|
424
|
+
dec = np.degrees(np.arccos(np.clip(v @ n, 0.0, 1.0)))
|
|
425
|
+
az = (np.degrees(np.arctan2(v @ b, v @ a)) % 360.0) / 360.0
|
|
426
|
+
rgb = hsv_to_rgb(np.stack(
|
|
427
|
+
[az, np.clip(dec / 60.0, 0.15, 1.0), np.ones_like(az)], axis=1))
|
|
428
|
+
else:
|
|
429
|
+
raise ValueError("color must be 'azimuth' or 'ipf'")
|
|
430
|
+
|
|
431
|
+
x, y = sol.pos[:, 0], sol.pos[:, 1]
|
|
432
|
+
ux, uy = np.unique(x), np.unique(y)
|
|
433
|
+
img = np.ones((len(uy), len(ux), 3))
|
|
434
|
+
xi = np.searchsorted(ux, x)
|
|
435
|
+
yi = np.searchsorted(uy, y)
|
|
436
|
+
for k in np.argsort(sol.n_matches): # strongest wins the pixel
|
|
437
|
+
img[yi[k], xi[k]] = rgb[k]
|
|
438
|
+
|
|
439
|
+
if ax is None:
|
|
440
|
+
_, ax = plt.subplots(figsize=(6.0, 5.0))
|
|
441
|
+
ax.imshow(img, origin="lower", aspect="equal", interpolation="nearest",
|
|
442
|
+
extent=[ux.min(), ux.max(), uy.min(), uy.max()])
|
|
443
|
+
ax.set_xlabel("x (µm)")
|
|
444
|
+
ax.set_ylabel("y (µm, sample frame)")
|
|
445
|
+
ax.set_title(title or ("orientation map\nhue = rotation about the surface "
|
|
446
|
+
"normal, paleness = alignment with it"), fontsize=10)
|
|
447
|
+
return ax
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def grain_size_distribution(clusters: GrainClusters, ax=None, *,
|
|
451
|
+
bins: int = 30, title: Optional[str] = None):
|
|
452
|
+
"""Distribution of grain size in raster positions, full-field excluded."""
|
|
453
|
+
import matplotlib.pyplot as plt
|
|
454
|
+
|
|
455
|
+
s = clusters.sizes[~clusters.full_field]
|
|
456
|
+
if ax is None:
|
|
457
|
+
_, ax = plt.subplots(figsize=(5.2, 4.0))
|
|
458
|
+
if s.size == 0:
|
|
459
|
+
ax.text(0.5, 0.5, "no grains\n(every cluster spans >half the map)",
|
|
460
|
+
ha="center", va="center", transform=ax.transAxes, fontsize=11)
|
|
461
|
+
ax.set_xticks([]); ax.set_yticks([])
|
|
462
|
+
return ax
|
|
463
|
+
ax.hist(s, bins=np.logspace(0, np.log10(max(s.max(), 2)), bins),
|
|
464
|
+
color="#3E6E7E")
|
|
465
|
+
ax.set_xscale("log")
|
|
466
|
+
ax.set_yscale("log")
|
|
467
|
+
ax.set_xlabel("positions per grain")
|
|
468
|
+
ax.set_ylabel("grains")
|
|
469
|
+
ax.set_title(title or f"grain size distribution\n{clusters.n_grains} "
|
|
470
|
+
f"grains at {clusters.tolerance}°", fontsize=10)
|
|
471
|
+
return ax
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def tolerance_sweep(sol: LaueSolutions, ax=None, *,
|
|
475
|
+
tolerances: Sequence[float] = (0.5, 1.0, 2.0, 3.0, 5.0),
|
|
476
|
+
space_group: int = 194, title: Optional[str] = None):
|
|
477
|
+
"""Grain count and effective n against clustering tolerance.
|
|
478
|
+
|
|
479
|
+
The tolerance is a choice, so its consequences belong on the figure: a grain
|
|
480
|
+
count that moves wildly with it is telling you about the clustering, not the
|
|
481
|
+
sample. The right-hand axis carries the effective n, and the annotation
|
|
482
|
+
counts clusters spanning more than half the map -- on one dataset a single
|
|
483
|
+
such object held 59% of all measurements and dragged the effective n from 29
|
|
484
|
+
to 2.5.
|
|
485
|
+
|
|
486
|
+
Returns ``(ax, rows)`` where rows is a list of dicts.
|
|
487
|
+
"""
|
|
488
|
+
import matplotlib.pyplot as plt
|
|
489
|
+
|
|
490
|
+
rows = []
|
|
491
|
+
for t in tolerances:
|
|
492
|
+
c = cluster(sol, t, space_group=space_group)
|
|
493
|
+
rows.append(dict(tolerance=float(t), clusters=c.n_clusters,
|
|
494
|
+
grains=c.n_grains, n_eff=c.n_eff,
|
|
495
|
+
n_eff_grains=c.n_eff_grains,
|
|
496
|
+
full_field=int(c.full_field.sum()),
|
|
497
|
+
largest=int(c.sizes.max()) if c.sizes.size else 0))
|
|
498
|
+
if ax is None:
|
|
499
|
+
_, ax = plt.subplots(figsize=(6.2, 4.0))
|
|
500
|
+
t = [r["tolerance"] for r in rows]
|
|
501
|
+
ax.plot(t, [r["grains"] for r in rows], "o-", color="#A8452F",
|
|
502
|
+
label="grains (full-field excluded)")
|
|
503
|
+
ax.set_xlabel("clustering tolerance (°)")
|
|
504
|
+
ax.set_ylabel("grains", color="#A8452F")
|
|
505
|
+
ax.tick_params(axis="y", labelcolor="#A8452F")
|
|
506
|
+
ax2 = ax.twinx()
|
|
507
|
+
ax2.plot(t, [r["n_eff_grains"] for r in rows], "s--", color="#2F6B8F",
|
|
508
|
+
label="effective n")
|
|
509
|
+
ax2.set_ylabel("effective (Kish) n", color="#2F6B8F")
|
|
510
|
+
ax2.tick_params(axis="y", labelcolor="#2F6B8F")
|
|
511
|
+
nf = sum(r["full_field"] for r in rows)
|
|
512
|
+
if nf:
|
|
513
|
+
ax.annotate(f"{nf} cluster(s) across the sweep span >½ the map "
|
|
514
|
+
f"and are excluded", xy=(0.02, 0.02),
|
|
515
|
+
xycoords="axes fraction", fontsize=8.5, color="#9E4A48")
|
|
516
|
+
ax.set_title(title or "How the grain count depends on the tolerance",
|
|
517
|
+
fontsize=10)
|
|
518
|
+
return ax, rows
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# --------------------------------------------------------------------------
|
|
522
|
+
# detector-frame diagnostics
|
|
523
|
+
# --------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def spot_overlay(image: np.ndarray, spots: LaueSpots, ax=None, *,
|
|
526
|
+
grain: Optional[int] = None, percentile: float = 99.5,
|
|
527
|
+
title: Optional[str] = None, marker_size: float = 70.0):
|
|
528
|
+
"""A frame with an accepted orientation's assigned reflections drawn on it.
|
|
529
|
+
|
|
530
|
+
The check no orientation result should be published without. A solution can
|
|
531
|
+
match a large number of predicted reflections while sitting on the wrong
|
|
532
|
+
crystal, and the only cheap way to see that is to look at where its
|
|
533
|
+
reflections land against the actual peaks.
|
|
534
|
+
|
|
535
|
+
``spots`` should already be restricted to one frame
|
|
536
|
+
(``LaueSpots.for_frame``); ``grain`` further restricts to one solution.
|
|
537
|
+
"""
|
|
538
|
+
import matplotlib.pyplot as plt
|
|
539
|
+
|
|
540
|
+
img = np.asarray(image)
|
|
541
|
+
if img.ndim != 2:
|
|
542
|
+
raise ValueError(
|
|
543
|
+
f"expected a single 2-D frame, got shape {img.shape}. These files "
|
|
544
|
+
f"store one image per file as a 2-D dataset, so h5[...][0] is the "
|
|
545
|
+
f"first ROW, not the first frame -- read [:] instead.")
|
|
546
|
+
s = spots if grain is None else _spots_for_grain(spots, grain)
|
|
547
|
+
if ax is None:
|
|
548
|
+
_, ax = plt.subplots(figsize=(6.4, 6.4))
|
|
549
|
+
vmax = np.percentile(img, percentile)
|
|
550
|
+
ax.imshow(img, cmap="gray_r", vmin=float(np.median(img)), vmax=float(vmax),
|
|
551
|
+
origin="lower", interpolation="nearest")
|
|
552
|
+
if len(s):
|
|
553
|
+
ax.scatter(s.xy[:, 0], s.xy[:, 1], s=marker_size, facecolors="none",
|
|
554
|
+
edgecolors="#D24B3E", linewidths=1.1,
|
|
555
|
+
label=f"{len(s)} assigned reflections")
|
|
556
|
+
ax.legend(fontsize=9, loc="upper right", framealpha=0.85)
|
|
557
|
+
ax.set_xlabel("detector x (px)")
|
|
558
|
+
ax.set_ylabel("detector y (px)")
|
|
559
|
+
ax.set_title(title or "frame with assigned reflections overlaid",
|
|
560
|
+
fontsize=10)
|
|
561
|
+
return ax
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _spots_for_grain(spots: LaueSpots, grain: int) -> LaueSpots:
|
|
565
|
+
m = spots.grain == int(grain)
|
|
566
|
+
return LaueSpots(image=spots.image[m], grain=spots.grain[m],
|
|
567
|
+
hkl=spots.hkl[m], xy=spots.xy[m],
|
|
568
|
+
qhat=None if spots.qhat is None else spots.qhat[m],
|
|
569
|
+
intensity=None if spots.intensity is None
|
|
570
|
+
else spots.intensity[m], columns=spots.columns)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def occupancy_map(peaks_per_frame, ax=None, *, shape=(2048, 2048),
|
|
574
|
+
bin_px: int = 6, hi: float = 0.8,
|
|
575
|
+
title: Optional[str] = None):
|
|
576
|
+
"""How often each detector position carries a peak, across the raster.
|
|
577
|
+
|
|
578
|
+
A position that fires in nearly every frame cannot be a deposit grain: the
|
|
579
|
+
beam moves microns between frames and a small grain leaves the probe volume.
|
|
580
|
+
So this separates substrate from deposit **with no orientation, no lattice
|
|
581
|
+
and no assumption that the substrate is a single crystal** -- which makes it
|
|
582
|
+
an independent check on any substrate identified by indexing.
|
|
583
|
+
|
|
584
|
+
Parameters
|
|
585
|
+
----------
|
|
586
|
+
peaks_per_frame : sequence of (M_i, 2) arrays
|
|
587
|
+
Detector x, y of the peaks detected on each sampled frame.
|
|
588
|
+
hi : float
|
|
589
|
+
Occupancy at or above which a bin is called stage-invariant.
|
|
590
|
+
|
|
591
|
+
Returns ``(ax, stats)``.
|
|
592
|
+
"""
|
|
593
|
+
import matplotlib.pyplot as plt
|
|
594
|
+
|
|
595
|
+
nb = shape[0] // bin_px + 1
|
|
596
|
+
count = np.zeros((nb, nb), int)
|
|
597
|
+
total = seen = 0
|
|
598
|
+
frames = list(peaks_per_frame)
|
|
599
|
+
for pk in frames:
|
|
600
|
+
p = np.asarray(pk, float).reshape(-1, 2)
|
|
601
|
+
if not len(p):
|
|
602
|
+
continue
|
|
603
|
+
bx = (p[:, 0] / bin_px).astype(int)
|
|
604
|
+
by = (p[:, 1] / bin_px).astype(int)
|
|
605
|
+
ok = (bx >= 0) & (bx < nb) & (by >= 0) & (by < nb)
|
|
606
|
+
total += int(ok.sum())
|
|
607
|
+
for b, a in set(zip(by[ok].tolist(), bx[ok].tolist())):
|
|
608
|
+
count[b, a] += 1
|
|
609
|
+
frac = count / max(len(frames), 1)
|
|
610
|
+
inv = frac >= hi
|
|
611
|
+
for pk in frames:
|
|
612
|
+
p = np.asarray(pk, float).reshape(-1, 2)
|
|
613
|
+
if not len(p):
|
|
614
|
+
continue
|
|
615
|
+
bx = (p[:, 0] / bin_px).astype(int)
|
|
616
|
+
by = (p[:, 1] / bin_px).astype(int)
|
|
617
|
+
ok = (bx >= 0) & (bx < nb) & (by >= 0) & (by < nb)
|
|
618
|
+
seen += int(inv[by[ok], bx[ok]].sum())
|
|
619
|
+
share = seen / max(total, 1)
|
|
620
|
+
|
|
621
|
+
if ax is None:
|
|
622
|
+
_, ax = plt.subplots(figsize=(6.0, 5.2))
|
|
623
|
+
im = ax.imshow(frac, origin="lower", cmap="magma", vmin=0, vmax=1,
|
|
624
|
+
extent=[0, shape[1], 0, shape[0]], interpolation="nearest")
|
|
625
|
+
ax.figure.colorbar(im, ax=ax, fraction=0.046, label="fraction of frames")
|
|
626
|
+
ax.set_xlabel("detector x (px)")
|
|
627
|
+
ax.set_ylabel("detector y (px)")
|
|
628
|
+
ax.set_title(title or (f"detector occupancy over {len(frames)} frames\n"
|
|
629
|
+
f"{int(inv.sum())} bins fire in ≥{100*hi:.0f}% "
|
|
630
|
+
f"of frames, carrying {100*share:.1f}% of all peaks"),
|
|
631
|
+
fontsize=10)
|
|
632
|
+
return ax, dict(n_invariant_bins=int(inv.sum()), frac_peaks_invariant=share,
|
|
633
|
+
n_frames=len(frames), bin_px=bin_px, hi=hi)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def summary(sol: LaueSolutions, *, tolerance: float = 1.0,
|
|
637
|
+
space_group: int = 194, hkl=(0, 0, 1), normal=None,
|
|
638
|
+
suptitle: Optional[str] = None):
|
|
639
|
+
"""One-page overview: map, pole figure, tilt histogram, size distribution."""
|
|
640
|
+
import matplotlib.pyplot as plt
|
|
641
|
+
|
|
642
|
+
c = cluster(sol, tolerance, space_group=space_group)
|
|
643
|
+
reps = c.representatives(sol.orient_mat)
|
|
644
|
+
sizes = c.sizes[~c.full_field]
|
|
645
|
+
|
|
646
|
+
fig, ax = plt.subplots(2, 2, figsize=(12.0, 9.0))
|
|
647
|
+
if sol.pos is not None:
|
|
648
|
+
orientation_map(sol, ax[0, 0], normal=normal, hkl=hkl)
|
|
649
|
+
else:
|
|
650
|
+
ax[0, 0].text(0.5, 0.5, "no positions supplied", ha="center",
|
|
651
|
+
va="center", transform=ax[0, 0].transAxes)
|
|
652
|
+
ax[0, 0].set_xticks([]); ax[0, 0].set_yticks([])
|
|
653
|
+
pole_figure(reps, ax[0, 1], hkl=hkl, normal=normal,
|
|
654
|
+
title=f"{tuple(int(x) for x in hkl)} pole figure\n"
|
|
655
|
+
f"{len(reps)} grains, one point each")
|
|
656
|
+
tilt_histogram(reps, ax[1, 0], hkl=hkl, normal=normal)
|
|
657
|
+
grain_size_distribution(c, ax[1, 1])
|
|
658
|
+
|
|
659
|
+
head = (f"{len(sol)} solutions → {c.n_grains} grains at {tolerance}° "
|
|
660
|
+
f"(effective n {c.n_eff_grains:.0f})")
|
|
661
|
+
if len(reps) >= 2:
|
|
662
|
+
obs, c95, ratio = texture_strength(reps, hkl=hkl, normal=normal)
|
|
663
|
+
head += f"; texture {obs:.2f} vs chance {c95:.2f} = {ratio:.2f}×"
|
|
664
|
+
elif c.n_clusters:
|
|
665
|
+
# Every cluster spanned more than half the map, so there is no grain
|
|
666
|
+
# population to have a texture. Saying so beats printing "nan x".
|
|
667
|
+
head += (f"; no texture — all {c.n_clusters} clusters span >half the "
|
|
668
|
+
f"map")
|
|
669
|
+
fig.suptitle(suptitle or head, fontsize=12)
|
|
670
|
+
fig.tight_layout()
|
|
671
|
+
return fig
|