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/ff.py ADDED
@@ -0,0 +1,522 @@
1
+ """Plots for far-field reconstructions (``Grains.csv``).
2
+
3
+ Far-field output is a **grain list**, not a voxel grid, so these are scatter and
4
+ distribution plots rather than the images :mod:`midas_plotting.maps` draws for
5
+ near-field ``.mic`` data. Colour, symmetry and the IPF triangle come from
6
+ :mod:`midas_plotting.ipf`, shared with the NF side so one grain gets the same
7
+ colour whichever modality found it.
8
+
9
+ Every function accepts a :class:`~midas_plotting.grains.GrainList` or a path,
10
+ takes an optional ``ax``, and returns the axes -- matching
11
+ :mod:`midas_plotting.maps`.
12
+
13
+ Two things worth knowing before reading any of these plots:
14
+
15
+ * **Grain positions are good to ~100 µm**, not to the six decimals
16
+ ``Grains.csv`` prints (``FF_HEDM_Lab_Notebook.md`` §2d). Do not over-read
17
+ small spatial structure.
18
+ * **``GrainRadius`` is only correct with ``midas-process-grains >= 0.6.1``.**
19
+ Older versions report approximately the sample-wide mean radius for *every*
20
+ grain, which looks like a suspiciously monodisperse microstructure.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from typing import Optional, Sequence
25
+
26
+ import numpy as np
27
+
28
+ from .grains import GrainList, read_grains
29
+ from .ipf import direction_rgb, ipf_rgb_from_matrix, laue_class, sym_matrices, CUBIC
30
+
31
+ __all__ = [
32
+ "ipf_legend", "grain_map", "grain_map_3d", "grain_size_distribution",
33
+ "completeness_hist", "strain_scalar", "strain_map", "strain_distribution",
34
+ "pole_figure", "summary",
35
+ ]
36
+
37
+ _PLANES = {"xy": (0, 1), "xz": (0, 2), "yz": (1, 2), "yx": (1, 0),
38
+ "zx": (2, 0), "zy": (2, 1)}
39
+
40
+
41
+ def _as_grains(g) -> GrainList:
42
+ return g if isinstance(g, GrainList) else read_grains(g)
43
+
44
+
45
+ def _sg(g: GrainList, space_group: Optional[int]) -> int:
46
+ """Resolve the space group: explicit argument, else the file's own header.
47
+
48
+ Defaulting to a hard-coded 225 would silently colour a hexagonal or
49
+ tetragonal sample with the cubic IPF triangle -- a figure that looks
50
+ entirely plausible and is wrong. ``Grains.csv`` states its space group in
51
+ the ``%\tSpaceGroup:`` preamble, so use that.
52
+ """
53
+ if space_group is not None:
54
+ return int(space_group)
55
+ sg = g.space_group
56
+ if sg is None:
57
+ raise ValueError(
58
+ f"{g.path.name} has no SpaceGroup in its header; pass "
59
+ "space_group=... explicitly rather than assuming cubic.")
60
+ return sg
61
+
62
+
63
+ def _marker_sizes(radius: Optional[np.ndarray], n: int,
64
+ smin: float = 12.0, smax: float = 320.0) -> np.ndarray:
65
+ """Marker AREA from grain radius.
66
+
67
+ Area is scaled linearly in radius, not in radius**2. A true area-accurate
68
+ encoding makes the largest grain dominate the figure so completely that the
69
+ rest of the microstructure is unreadable; this keeps the ordering honest
70
+ while staying legible. Do not measure grain size off this plot -- use
71
+ :func:`grain_size_distribution`.
72
+ """
73
+ if radius is None or not np.any(np.isfinite(radius)):
74
+ return np.full(n, 40.0)
75
+ r = np.nan_to_num(np.asarray(radius, dtype=float), nan=0.0)
76
+ lo, hi = float(np.min(r)), float(np.max(r))
77
+ if hi <= lo:
78
+ return np.full(n, 60.0)
79
+ return smin + (smax - smin) * (r - lo) / (hi - lo)
80
+
81
+
82
+ # ─── IPF legend ─────────────────────────────────────────────────────────────
83
+ def ipf_legend(space_group: int = 225, ax=None, *, n: int = 400,
84
+ axis_label: str = "Z", title: Optional[str] = None):
85
+ """Draw the IPF colour key (the standard stereographic triangle).
86
+
87
+ An IPF map without its key is not interpretable, and this package had no
88
+ way to draw one. Colours come from :func:`midas_plotting.ipf.direction_rgb`
89
+ -- the same function the maps use -- so the key cannot drift out of step
90
+ with the figure it explains.
91
+ """
92
+ import matplotlib.pyplot as plt
93
+
94
+ if ax is None:
95
+ _, ax = plt.subplots(figsize=(3.6, 3.2))
96
+
97
+ fam = laue_class(space_group)
98
+ # Sample the stereographic plane, back-project to directions, keep the ones
99
+ # already inside the standard triangle.
100
+ if fam == CUBIC:
101
+ corners = np.array([[0, 0, 1.0], [1, 0, 1.0], [1, 1, 1.0]])
102
+ else:
103
+ corners = np.array([[0, 0, 1.0], [1, 0, 0.0], [np.sqrt(3) / 2, 0.5, 0.0]])
104
+ # Normalise BEFORE projecting: the stereographic map is defined on unit
105
+ # vectors. Projecting the raw index triple puts [111] at (0.5, 0.5)
106
+ # instead of (0.366, 0.366), so the corner marker and its label sit
107
+ # outside the coloured triangle.
108
+ corners = corners / np.linalg.norm(corners, axis=1, keepdims=True)
109
+ cx = corners[:, 0] / (1.0 + corners[:, 2])
110
+ cy = corners[:, 1] / (1.0 + corners[:, 2])
111
+
112
+ pad = 0.02 * max(np.ptp(cx), np.ptp(cy))
113
+ x0, x1 = cx.min() - pad, cx.max() + pad
114
+ y0, y1 = cy.min() - pad, cy.max() + pad
115
+ gx, gy = np.meshgrid(np.linspace(x0, x1, n), np.linspace(y0, y1, n))
116
+ X, Y = gx.ravel(), gy.ravel()
117
+ den = 1.0 + X ** 2 + Y ** 2
118
+ d = np.stack([2 * X / den, 2 * Y / den, (1 - X ** 2 - Y ** 2) / den], axis=1)
119
+
120
+ if fam == CUBIC:
121
+ inside = (d[:, 0] >= -1e-9) & (d[:, 1] >= -1e-9) & \
122
+ (d[:, 1] <= d[:, 0] + 1e-9) & (d[:, 0] <= d[:, 2] + 1e-9)
123
+ else:
124
+ az = np.degrees(np.arctan2(d[:, 1], d[:, 0]))
125
+ inside = (d[:, 2] >= -1e-9) & (az >= -1e-9) & (az <= 30.0 + 1e-9)
126
+
127
+ rgb = np.ones((d.shape[0], 3))
128
+ if inside.any():
129
+ rgb[inside] = direction_rgb(d[inside], space_group)
130
+ img = rgb.reshape(n, n, 3)
131
+ alpha = inside.reshape(n, n).astype(float)
132
+
133
+ ax.imshow(img, origin="lower", alpha=alpha,
134
+ 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
+ for (px, py), lab in zip(zip(cx, cy), labels):
138
+ ax.plot(px, py, "k.", ms=4)
139
+ ax.annotate(lab, (px, py), textcoords="offset points",
140
+ xytext=(4, 4), fontsize=8)
141
+ ax.set_xticks([]); ax.set_yticks([])
142
+ for s in ax.spines.values():
143
+ s.set_visible(False)
144
+ ax.set_title(title or f"IPF-{axis_label} key (SG {space_group})", fontsize=9)
145
+ ax.set_aspect("equal")
146
+ return ax
147
+
148
+
149
+ # ─── grain maps ─────────────────────────────────────────────────────────────
150
+ def _colours(g: GrainList, color: str, space_group: int,
151
+ axis: Sequence[float], cmap: str, vmin, vmax):
152
+ """Returns (facecolors, scalar_for_colourbar or None, label)."""
153
+ if color == "ipf":
154
+ return ipf_rgb_from_matrix(g.orient_mat, space_group, axis), None, None
155
+ if color == "completeness":
156
+ v = g.completeness
157
+ if v is None:
158
+ raise ValueError("no Confidence column in this Grains.csv")
159
+ return None, v, "completeness"
160
+ if color == "radius":
161
+ if g.radius is None:
162
+ raise ValueError("no GrainRadius column in this Grains.csv")
163
+ return None, g.radius, "grain radius (µm)"
164
+ if color == "diffpos":
165
+ if g.diff_pos is None:
166
+ raise ValueError("no DiffPos column in this Grains.csv")
167
+ return None, g.diff_pos, "DiffPos (µm)"
168
+ raise ValueError(
169
+ f"unknown color={color!r}; use 'ipf', 'completeness', 'radius' or 'diffpos'")
170
+
171
+
172
+ def grain_map(
173
+ grains, ax=None, *, plane: str = "xy", space_group: Optional[int] = None,
174
+ axis: Sequence[float] = (0.0, 0.0, 1.0), color: str = "ipf",
175
+ size_by_radius: bool = True, cmin: float = 0.0, cmap: str = "viridis",
176
+ vmin=None, vmax=None, annotate_ids: bool = False,
177
+ title: Optional[str] = None,
178
+ ):
179
+ """Grain centres projected onto ``plane``, coloured by ``color``.
180
+
181
+ ``plane`` is one of xy, xz, yz (or their reverses). Marker size encodes
182
+ ``GrainRadius`` when available. ``cmin`` drops grains below a completeness.
183
+
184
+ NOTE this is a **projection**: grains at different depths overlap, and FF
185
+ positions carry ~100 µm uncertainty. Use :func:`grain_map_3d` to see the
186
+ layer volume.
187
+ """
188
+ import matplotlib.pyplot as plt
189
+
190
+ g = _as_grains(grains)
191
+ space_group = _sg(g, space_group)
192
+ if plane not in _PLANES:
193
+ raise ValueError(f"plane must be one of {sorted(_PLANES)}, got {plane!r}")
194
+ i, j = _PLANES[plane]
195
+
196
+ keep = np.ones(len(g), bool)
197
+ if cmin > 0 and g.completeness is not None:
198
+ keep = g.completeness >= cmin
199
+ if not keep.any():
200
+ raise ValueError(f"no grains with completeness >= {cmin}")
201
+
202
+ if ax is None:
203
+ _, ax = plt.subplots(figsize=(6.0, 5.6))
204
+
205
+ fc, scalar, clabel = _colours(g, color, space_group, axis, cmap, vmin, vmax)
206
+ sizes = _marker_sizes(g.radius if size_by_radius else None, len(g))[keep]
207
+
208
+ if fc is not None:
209
+ ax.scatter(g.pos[keep, i], g.pos[keep, j], s=sizes, c=fc[keep],
210
+ edgecolors="k", linewidths=0.3)
211
+ else:
212
+ sc = ax.scatter(g.pos[keep, i], g.pos[keep, j], s=sizes,
213
+ c=scalar[keep], cmap=cmap, vmin=vmin, vmax=vmax,
214
+ edgecolors="k", linewidths=0.3)
215
+ cb = ax.figure.colorbar(sc, ax=ax, fraction=0.046, pad=0.04)
216
+ cb.set_label(clabel)
217
+
218
+ if annotate_ids:
219
+ for k in np.where(keep)[0]:
220
+ ax.annotate(str(int(g.ids[k])), (g.pos[k, i], g.pos[k, j]),
221
+ fontsize=6, textcoords="offset points", xytext=(3, 3))
222
+
223
+ names = "XYZ"
224
+ ax.set_xlabel(f"{names[i]} (µm)")
225
+ ax.set_ylabel(f"{names[j]} (µm)")
226
+ ax.set_aspect("equal", adjustable="datalim")
227
+ n_shown = int(keep.sum())
228
+ extra = "" if n_shown == len(g) else f" of {len(g)}"
229
+ ax.set_title(title or
230
+ f"{g.path.name}: {n_shown}{extra} grains, "
231
+ f"{plane.upper()}, colour = {color}", fontsize=10)
232
+ return ax
233
+
234
+
235
+ def grain_map_3d(
236
+ grains, ax=None, *, space_group: Optional[int] = None,
237
+ axis: Sequence[float] = (0.0, 0.0, 1.0), cmin: float = 0.0,
238
+ size_by_radius: bool = True, title: Optional[str] = None,
239
+ ):
240
+ """IPF-coloured 3-D scatter of grain centres.
241
+
242
+ Useful for a few hundred grains to get a sense of the illuminated volume;
243
+ beyond that it is cluttered and :func:`grain_map` projections read better.
244
+ """
245
+ import matplotlib.pyplot as plt
246
+
247
+ g = _as_grains(grains)
248
+ space_group = _sg(g, space_group)
249
+ keep = np.ones(len(g), bool)
250
+ if cmin > 0 and g.completeness is not None:
251
+ keep = g.completeness >= cmin
252
+ if not keep.any():
253
+ raise ValueError(f"no grains with completeness >= {cmin}")
254
+
255
+ if ax is None:
256
+ fig = plt.figure(figsize=(6.4, 5.8))
257
+ ax = fig.add_subplot(111, projection="3d")
258
+
259
+ rgb = ipf_rgb_from_matrix(g.orient_mat, space_group, axis)
260
+ sizes = _marker_sizes(g.radius if size_by_radius else None, len(g))[keep]
261
+ ax.scatter(g.pos[keep, 0], g.pos[keep, 1], g.pos[keep, 2],
262
+ s=sizes, c=rgb[keep], edgecolors="k", linewidths=0.3, depthshade=False)
263
+ ax.set_xlabel("X (µm)"); ax.set_ylabel("Y (µm)"); ax.set_zlabel("Z (µm)")
264
+ ax.set_title(title or f"{g.path.name}: {int(keep.sum())} grains (IPF)",
265
+ fontsize=10)
266
+ return ax
267
+
268
+
269
+ # ─── distributions ──────────────────────────────────────────────────────────
270
+ def grain_size_distribution(grains, ax=None, *, bins: int = 30,
271
+ cmin: float = 0.0, title: Optional[str] = None):
272
+ """Histogram of ``GrainRadius`` with median/mean marked."""
273
+ import matplotlib.pyplot as plt
274
+
275
+ g = _as_grains(grains)
276
+ if g.radius is None:
277
+ raise ValueError("no GrainRadius column in this Grains.csv")
278
+ keep = np.isfinite(g.radius)
279
+ if cmin > 0 and g.completeness is not None:
280
+ keep &= g.completeness >= cmin
281
+ r = g.radius[keep]
282
+ if r.size == 0:
283
+ raise ValueError("no grains left after filtering")
284
+
285
+ if ax is None:
286
+ _, ax = plt.subplots(figsize=(6.0, 4.0))
287
+ ax.hist(r, bins=bins, color="#4a7fb5", edgecolor="k", linewidth=0.4)
288
+ med, mean = float(np.median(r)), float(np.mean(r))
289
+ ax.axvline(med, color="#e8453c", lw=1.6, label=f"median {med:.1f} µm")
290
+ ax.axvline(mean, color="k", ls="--", lw=1.2, label=f"mean {mean:.1f} µm")
291
+ ax.set_xlabel("grain radius (µm)")
292
+ ax.set_ylabel("grains")
293
+ ax.legend(fontsize=8)
294
+ ax.set_title(title or f"{g.path.name}: {r.size} grains", fontsize=10)
295
+ return ax
296
+
297
+
298
+ def completeness_hist(grains, ax=None, *, bins: int = 30,
299
+ title: Optional[str] = None):
300
+ """Histogram of per-grain completeness (``Confidence``)."""
301
+ import matplotlib.pyplot as plt
302
+
303
+ g = _as_grains(grains)
304
+ if g.completeness is None:
305
+ raise ValueError("no Confidence column in this Grains.csv")
306
+ if ax is None:
307
+ _, ax = plt.subplots(figsize=(6.0, 4.0))
308
+ ax.hist(g.completeness, bins=bins, range=(0, 1),
309
+ color="#2f855a", edgecolor="k", linewidth=0.4)
310
+ med = float(np.median(g.completeness))
311
+ ax.axvline(med, color="#e8453c", lw=1.6, label=f"median {med:.3f}")
312
+ ax.set_xlabel("completeness")
313
+ ax.set_ylabel("grains")
314
+ ax.legend(fontsize=8)
315
+ ax.set_title(title or f"{g.path.name}: {len(g)} grains", fontsize=10)
316
+ return ax
317
+
318
+
319
+ # ─── strain ─────────────────────────────────────────────────────────────────
320
+ def strain_scalar(grains, kind: str = "hydrostatic", *,
321
+ convention: str = "fab") -> np.ndarray:
322
+ """Reduce the per-grain strain tensor to one number per grain.
323
+
324
+ ``kind``:
325
+ ``hydrostatic`` trace/3
326
+ ``vonmises`` von Mises equivalent of the deviatoric part
327
+ ``11``/``22``/``33``/``12``/``13``/``23`` a single component
328
+
329
+ Returned in **microstrain**, which is the unit ``Grains.csv`` already
330
+ stores -- the ``eFab``/``eKen`` columns are NOT dimensionless strain and
331
+ must not be scaled by 1e6. Verified on `Au3_cubes_ff_000008`: ``eFab``
332
+ trace/3 gives 245.7 / 265.7 while the independent lattice dilation
333
+ ``(a - a0)/a0`` gives 390.6 / 405.3 µε -- same unit, same order. Reading
334
+ them as dimensionless would report a physically impossible 2.3e8 µε.
335
+ """
336
+ g = _as_grains(grains)
337
+ e = g.strain(convention)
338
+ k = kind.lower()
339
+ if k in ("hyd", "hydro", "hydrostatic", "mean"):
340
+ v = np.trace(e, axis1=1, axis2=2) / 3.0
341
+ elif k in ("vm", "vonmises", "von_mises", "equivalent"):
342
+ dev = e - (np.trace(e, axis1=1, axis2=2) / 3.0)[:, None, None] * np.eye(3)
343
+ v = np.sqrt(2.0 / 3.0 * np.einsum("nij,nij->n", dev, dev))
344
+ elif len(k) == 2 and set(k) <= set("123"):
345
+ i, j = int(k[0]) - 1, int(k[1]) - 1
346
+ v = e[:, i, j]
347
+ else:
348
+ raise ValueError(
349
+ f"unknown strain kind {kind!r}; use 'hydrostatic', 'vonmises' "
350
+ "or a component like '11' or '13'")
351
+ return v
352
+
353
+
354
+ def strain_map(grains, ax=None, *, kind: str = "hydrostatic",
355
+ convention: str = "fab", plane: str = "xy", cmin: float = 0.0,
356
+ cmap: str = "coolwarm", vmin=None, vmax=None,
357
+ symmetric: bool = True, title: Optional[str] = None):
358
+ """Grain map coloured by a strain scalar (µε).
359
+
360
+ For signed quantities the colour scale is symmetric about zero by default,
361
+ so the sign is readable and a diverging colormap means what it looks like.
362
+ """
363
+ import matplotlib.pyplot as plt
364
+
365
+ g = _as_grains(grains)
366
+ if plane not in _PLANES:
367
+ raise ValueError(f"plane must be one of {sorted(_PLANES)}")
368
+ i, j = _PLANES[plane]
369
+ v = strain_scalar(g, kind, convention=convention)
370
+
371
+ keep = np.isfinite(v)
372
+ if cmin > 0 and g.completeness is not None:
373
+ keep &= g.completeness >= cmin
374
+ if not keep.any():
375
+ raise ValueError("no grains left after filtering")
376
+
377
+ if ax is None:
378
+ _, ax = plt.subplots(figsize=(6.4, 5.6))
379
+ if symmetric and vmin is None and vmax is None:
380
+ lim = float(np.nanmax(np.abs(v[keep]))) or 1.0
381
+ vmin, vmax = -lim, lim
382
+ sizes = _marker_sizes(g.radius, len(g))[keep]
383
+ sc = ax.scatter(g.pos[keep, i], g.pos[keep, j], s=sizes, c=v[keep],
384
+ cmap=cmap, vmin=vmin, vmax=vmax,
385
+ edgecolors="k", linewidths=0.3)
386
+ cb = ax.figure.colorbar(sc, ax=ax, fraction=0.046, pad=0.04)
387
+ cb.set_label(f"{kind} strain (µε), {convention}")
388
+ names = "XYZ"
389
+ ax.set_xlabel(f"{names[i]} (µm)"); ax.set_ylabel(f"{names[j]} (µm)")
390
+ ax.set_aspect("equal", adjustable="datalim")
391
+ ax.set_title(title or f"{g.path.name}: {kind} strain", fontsize=10)
392
+ return ax
393
+
394
+
395
+ def strain_distribution(grains, ax=None, *, convention: str = "fab",
396
+ bins: int = 30, title: Optional[str] = None):
397
+ """Histograms of the three normal strain components (µε)."""
398
+ import matplotlib.pyplot as plt
399
+
400
+ g = _as_grains(grains)
401
+ if ax is None:
402
+ _, ax = plt.subplots(figsize=(6.4, 4.0))
403
+ for comp, colr in (("11", "#e8453c"), ("22", "#2f855a"), ("33", "#2b6cb0")):
404
+ v = strain_scalar(g, comp, convention=convention)
405
+ ax.hist(v, bins=bins, histtype="step", lw=1.6, color=colr,
406
+ label=f"ε{comp} median {np.median(v):+.0f} µε")
407
+ ax.axvline(0.0, color="k", lw=0.8, ls=":")
408
+ ax.set_xlabel(f"strain (µε), {convention}")
409
+ ax.set_ylabel("grains")
410
+ ax.legend(fontsize=8)
411
+ ax.set_title(title or f"{g.path.name}: {len(g)} grains", fontsize=10)
412
+ return ax
413
+
414
+
415
+ # ─── pole figure ────────────────────────────────────────────────────────────
416
+ def pole_figure(grains, ax=None, *, hkl: Sequence[float] = (0, 0, 1),
417
+ space_group: Optional[int] = None, cmin: float = 0.0,
418
+ projection: str = "stereographic", color: str = "ipf",
419
+ axis: Sequence[float] = (0.0, 0.0, 1.0),
420
+ title: Optional[str] = None):
421
+ """Discrete pole figure of a crystal direction, over all grains.
422
+
423
+ Every symmetry equivalent of ``hkl`` is plotted for every grain, projected
424
+ onto the upper hemisphere. With few grains this is a scatter of poles, not
425
+ a texture density -- do not read it as an ODF.
426
+
427
+ ``projection``: ``stereographic`` (equal-angle, the usual choice for
428
+ reading orientations) or ``equal_area`` (Schmidt, the usual choice when
429
+ comparing *densities*, since it does not distort area).
430
+ """
431
+ import matplotlib.pyplot as plt
432
+
433
+ g = _as_grains(grains)
434
+ space_group = _sg(g, space_group)
435
+ keep = np.ones(len(g), bool)
436
+ if cmin > 0 and g.completeness is not None:
437
+ keep = g.completeness >= cmin
438
+ if not keep.any():
439
+ raise ValueError(f"no grains with completeness >= {cmin}")
440
+
441
+ om = g.orient_mat[keep]
442
+ h = np.asarray(hkl, dtype=float)
443
+ h = h / np.linalg.norm(h)
444
+ sym = sym_matrices(space_group)
445
+ hs = np.einsum("sij,j->si", sym, h) # equivalents
446
+
447
+ # g maps sample -> crystal, so the sample-frame pole is g.T @ h_crystal.
448
+ d = np.einsum("nji,sj->nsi", om, hs).reshape(-1, 3)
449
+ d = d / np.linalg.norm(d, axis=1, keepdims=True)
450
+ d[d[:, 2] < 0] *= -1.0 # upper hemisphere
451
+
452
+ if projection.startswith("stereo"):
453
+ X, Y = d[:, 0] / (1.0 + d[:, 2]), d[:, 1] / (1.0 + d[:, 2])
454
+ elif projection.startswith("equal"):
455
+ f = np.sqrt(2.0 / (1.0 + d[:, 2]))
456
+ X, Y = d[:, 0] * f / np.sqrt(2), d[:, 1] * f / np.sqrt(2)
457
+ else:
458
+ raise ValueError("projection must be 'stereographic' or 'equal_area'")
459
+
460
+ if ax is None:
461
+ _, ax = plt.subplots(figsize=(5.0, 5.0))
462
+ if color == "ipf":
463
+ rgb = ipf_rgb_from_matrix(om, space_group, axis)
464
+ c = np.repeat(rgb, len(sym), axis=0)
465
+ else:
466
+ c = color
467
+ ax.scatter(X, Y, s=14, c=c, edgecolors="k", linewidths=0.2)
468
+ th = np.linspace(0, 2 * np.pi, 361)
469
+ ax.plot(np.cos(th), np.sin(th), "k-", lw=1.0)
470
+ ax.plot([-1, 1], [0, 0], "k:", lw=0.6)
471
+ ax.plot([0, 0], [-1, 1], "k:", lw=0.6)
472
+ ax.set_xlim(-1.08, 1.08); ax.set_ylim(-1.08, 1.08)
473
+ ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([])
474
+ for s in ax.spines.values():
475
+ s.set_visible(False)
476
+ lab = "".join(str(int(v)) for v in hkl)
477
+ ax.set_title(title or
478
+ f"{{{lab}}} pole figure — {int(keep.sum())} grains, "
479
+ f"{projection}", fontsize=10)
480
+ return ax
481
+
482
+
483
+ # ─── overview ───────────────────────────────────────────────────────────────
484
+ def summary(grains, *, space_group: Optional[int] = None, cmin: float = 0.0,
485
+ axis: Sequence[float] = (0.0, 0.0, 1.0), figsize=(13.0, 8.0)):
486
+ """One-page overview: IPF map + key, size, completeness, strain, poles.
487
+
488
+ The 'is this reconstruction sane' figure. Returns the Figure.
489
+ """
490
+ import matplotlib.pyplot as plt
491
+
492
+ g = _as_grains(grains)
493
+ space_group = _sg(g, space_group)
494
+ fig = plt.figure(figsize=figsize)
495
+ gs = fig.add_gridspec(2, 3, hspace=0.32, wspace=0.30)
496
+
497
+ grain_map(g, fig.add_subplot(gs[0, 0]), space_group=space_group,
498
+ axis=axis, cmin=cmin, title="grain map (IPF-Z)")
499
+ ipf_legend(space_group, fig.add_subplot(gs[0, 1]))
500
+ try:
501
+ pole_figure(g, fig.add_subplot(gs[0, 2]), hkl=(0, 0, 1),
502
+ space_group=space_group, cmin=cmin, axis=axis)
503
+ except Exception as e: # noqa: BLE001
504
+ fig.add_subplot(gs[0, 2]).set_title(f"pole figure unavailable: {e}",
505
+ fontsize=8)
506
+ try:
507
+ grain_size_distribution(g, fig.add_subplot(gs[1, 0]), cmin=cmin,
508
+ title="grain size")
509
+ except Exception as e: # noqa: BLE001
510
+ fig.add_subplot(gs[1, 0]).set_title(f"size unavailable: {e}", fontsize=8)
511
+ try:
512
+ completeness_hist(g, fig.add_subplot(gs[1, 1]), title="completeness")
513
+ except Exception as e: # noqa: BLE001
514
+ fig.add_subplot(gs[1, 1]).set_title(f"completeness unavailable: {e}",
515
+ fontsize=8)
516
+ try:
517
+ strain_distribution(g, fig.add_subplot(gs[1, 2]), title="strain")
518
+ except Exception as e: # noqa: BLE001
519
+ fig.add_subplot(gs[1, 2]).set_title(f"strain unavailable: {e}", fontsize=8)
520
+
521
+ fig.suptitle(f"{g.path.name} — {len(g)} grains", fontsize=12)
522
+ return fig