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/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