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