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.
@@ -0,0 +1,59 @@
1
+ """Standard plots for MIDAS reconstructions.
2
+
3
+ from midas_plotting import read_mic, orientation_map
4
+ orientation_map("Ce5Y.0.mic", space_group=225, cmin=0.3)
5
+
6
+ Far-field ``Grains.csv`` lives in the ``ff`` submodule::
7
+
8
+ from midas_plotting import ff, read_grains
9
+ g = read_grains("Grains.csv")
10
+ ff.summary(g) # one-page overview
11
+ ff.grain_map(g, color="ipf") # IPF-coloured grain scatter
12
+ ff.ipf_legend(g.space_group) # the colour key
13
+
14
+ FF plots are namespaced rather than exported flat because both modalities have
15
+ a ``grain_map`` and they mean different things: ``maps.grain_map`` labels a
16
+ near-field voxel grid, ``ff.grain_map`` scatters far-field grain centres.
17
+
18
+ Laue microdiffraction lives in ``laue``, and reads the indexer's text output::
19
+
20
+ from midas_plotting import laue, read_solutions
21
+ sol = read_solutions("solutions.txt") # one row per frame, not per grain
22
+ sol = sol.gate(11) # the measured random-orientation null
23
+ c = laue.cluster(sol, 1.0, space_group=194) # grains, with full-field objects flagged
24
+ laue.tilt_histogram(c.representatives(sol.orient_mat)) # vs the random reference
25
+ laue.summary(sol)
26
+
27
+ or from the shell::
28
+
29
+ midas-plot Ce5Y.0.mic --kind orientation --cmin 0.3 --sg 225
30
+ midas-plot Grains.csv --kind summary
31
+
32
+ Written after the same IPF colouring, .mic parsing and map plotting were
33
+ re-implemented several times in one-off analysis scripts, each time with its own
34
+ conventions.
35
+ """
36
+ from .ipf import (
37
+ CUBIC, HEXAGONAL, direction_rgb, ipf_rgb, ipf_rgb_from_matrix,
38
+ laue_class, sym_matrices,
39
+ )
40
+ from .maps import (
41
+ TRUST_FLOOR, compare_maps, confidence_map, grain_labels, grain_map,
42
+ orientation_map,
43
+ )
44
+ from . import ff, laue
45
+ from .grains import GrainList, read_grains
46
+ from .solutions import (
47
+ LaueSolutions, LaueSpots, read_solutions, read_spots, read_validated,
48
+ )
49
+ from .mic import MicMap, read_mic
50
+
51
+ __version__ = "0.3.0"
52
+ __all__ = [
53
+ "MicMap", "read_mic", "GrainList", "read_grains", "ff", "laue",
54
+ "LaueSolutions", "LaueSpots", "read_solutions", "read_spots",
55
+ "read_validated", "ipf_rgb", "ipf_rgb_from_matrix", "direction_rgb",
56
+ "sym_matrices", "laue_class",
57
+ "CUBIC", "HEXAGONAL", "orientation_map", "confidence_map", "grain_map",
58
+ "grain_labels", "compare_maps", "TRUST_FLOOR", "__version__",
59
+ ]
midas_plotting/cli.py ADDED
@@ -0,0 +1,264 @@
1
+ """``midas-plot`` — one-shot reconstruction figures from the shell."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from pathlib import Path
6
+
7
+
8
+ def main(argv=None) -> int:
9
+ ap = argparse.ArgumentParser(
10
+ prog="midas-plot",
11
+ description="Standard MIDAS reconstruction maps (orientation, "
12
+ "confidence, grains).")
13
+ ap.add_argument("mics", nargs="+",
14
+ help="near-field .mic file(s), a far-field Grains.csv, or a "
15
+ "Laue solutions.txt / validated .npz")
16
+ ap.add_argument("--kind", default=None,
17
+ help="NF: orientation | confidence | grain. "
18
+ "FF: summary | orientation | pole | strain | size | "
19
+ "completeness | 3d. "
20
+ "Laue: summary | orientation | pole | tilt | size | "
21
+ "sweep. "
22
+ "Default: orientation for NF, summary for FF/Laue.")
23
+ ap.add_argument("--plane", default="xy",
24
+ help="FF only: projection plane (xy, xz, yz)")
25
+ ap.add_argument("--hkl", default="0,0,1",
26
+ help="FF pole figure: crystal direction")
27
+ ap.add_argument("--strain-kind", default="hydrostatic",
28
+ help="FF strain: hydrostatic | vonmises | 11 | 33 | ...")
29
+ ap.add_argument("--sg", default=None,
30
+ help="space group; a single value, or one per .mic "
31
+ "comma-separated when comparing PHASES (colouring a "
32
+ "cubic map with hexagonal symmetry silently produces "
33
+ "a meaningless figure). Unset: 225 for near-field, "
34
+ "the file's own header for far-field, 194 for Laue.")
35
+ ap.add_argument("--cmin", type=float, default=0.3,
36
+ help="confidence cut (default 0.3, the trust floor)")
37
+ ap.add_argument("--axis", default="0,0,1", help="IPF sample axis")
38
+ ap.add_argument("--titles", default=None, help="'|'-separated")
39
+ ap.add_argument("--suptitle", default=None)
40
+ ap.add_argument("-o", "--out", default="midas_plot.png")
41
+ ap.add_argument("--dpi", type=int, default=145)
42
+ ap.add_argument("--gate", type=int, default=None,
43
+ help="Laue: keep solutions matching MORE than this many "
44
+ "reflections. No default -- it is the measured "
45
+ "random-orientation null for that scan, not a "
46
+ "universal constant.")
47
+ ap.add_argument("--tol", type=float, default=1.0,
48
+ help="Laue: grain clustering tolerance in degrees")
49
+ a = ap.parse_args(argv)
50
+
51
+ import matplotlib
52
+ matplotlib.use("Agg")
53
+
54
+ if all(_looks_like_laue(m) for m in a.mics):
55
+ return _run_laue(a, ap)
56
+ if all(_looks_like_ff(m) for m in a.mics):
57
+ return _run_ff(a, ap)
58
+ if a.kind is None:
59
+ a.kind = "orientation"
60
+ if a.kind not in ("orientation", "confidence", "grain"):
61
+ ap.error(f"--kind {a.kind!r} is not valid for near-field .mic input")
62
+ from .maps import compare_maps
63
+ from .mic import read_mic
64
+
65
+ mics = [read_mic(m) for m in a.mics]
66
+ for m in mics:
67
+ print(f"{m.path.name}: {m.summary()}")
68
+
69
+ sgs = [int(v) for v in str(a.sg if a.sg is not None else "225").split(",")]
70
+ if len(sgs) == 1:
71
+ sgs *= len(mics)
72
+ elif len(sgs) != len(mics):
73
+ ap.error(f"--sg has {len(sgs)} values for {len(mics)} .mic files; "
74
+ "give one value or one per file")
75
+
76
+ titles = a.titles.split("|") if a.titles else [None] * len(mics)
77
+ axis = tuple(float(v) for v in a.axis.split(","))
78
+
79
+ import matplotlib.pyplot as plt
80
+ from .maps import confidence_map, grain_map, orientation_map
81
+ fn = {"orientation": orientation_map, "confidence": confidence_map,
82
+ "grain": grain_map}[a.kind]
83
+ fig, axes = plt.subplots(1, len(mics), figsize=(6.2 * len(mics), 6.4),
84
+ squeeze=False)
85
+ for ax, m, t, sg in zip(axes[0], mics, titles, sgs):
86
+ kw = {}
87
+ if a.kind == "orientation":
88
+ kw = dict(space_group=sg, cmin=a.cmin, axis=axis)
89
+ elif a.kind == "grain":
90
+ kw = dict(space_group=sg, cmin=a.cmin)
91
+ fn(m, ax=ax, title=t, **kw)
92
+ if a.suptitle:
93
+ fig.suptitle(a.suptitle, fontsize=12)
94
+ # `kind` (bare) used to be referenced here; it is only ever bound in the FF
95
+ # branch, so every near-field CLI run raised NameError after doing all the
96
+ # work and before writing the file. No test covered the CLI path.
97
+ fig.tight_layout()
98
+ fig.savefig(a.out, dpi=a.dpi, bbox_inches="tight")
99
+ print(f"wrote {Path(a.out).resolve()}")
100
+ return 0
101
+
102
+
103
+ def _looks_like_ff(path) -> bool:
104
+ """Far-field Grains.csv, by content not by filename.
105
+
106
+ Users rename these constantly (Grains_layer1.csv, au3_grains.csv), so sniff
107
+ for the header MIDAS actually writes instead of matching a name.
108
+ """
109
+ p = Path(path)
110
+ if not p.is_file():
111
+ return False
112
+ try:
113
+ with p.open() as fh:
114
+ for _ in range(40):
115
+ line = fh.readline()
116
+ if not line:
117
+ break
118
+ if line.startswith("%NumGrains") or "\tO11\t" in line:
119
+ return True
120
+ except OSError:
121
+ return False
122
+ return False
123
+
124
+
125
+ def _looks_like_laue(path) -> bool:
126
+ """Laue solutions.txt or a validated .npz, by content not by filename."""
127
+ p = Path(path)
128
+ if not p.is_file():
129
+ return False
130
+ if p.suffix == ".npz":
131
+ try:
132
+ import numpy as np
133
+ with np.load(p, allow_pickle=True) as d:
134
+ return {"oms", "X", "Z", "nhit"} <= set(d.files)
135
+ except Exception:
136
+ return False
137
+ try:
138
+ with p.open() as fh:
139
+ head = fh.readline()
140
+ except OSError:
141
+ return False
142
+ return head.startswith("%ImageNr") and "OrientMatrix0" in head
143
+
144
+
145
+ def _run_laue(a, ap) -> int:
146
+ """Laue plotting branch."""
147
+ import matplotlib.pyplot as plt
148
+
149
+ from . import laue
150
+ from .solutions import read_solutions, read_validated
151
+
152
+ kind = a.kind or "summary"
153
+ if a.sg is None:
154
+ sg = 194
155
+ print("note: --sg not given, using 194 (hexagonal). The wrong symmetry "
156
+ "silently changes grain counts, so pass it for another phase.")
157
+ else:
158
+ sg = int(str(a.sg).split(",")[0])
159
+ hkl = tuple(float(v) for v in a.hkl.split(","))
160
+
161
+ sols = [read_validated(m) if str(m).endswith(".npz") else read_solutions(m)
162
+ for m in a.mics]
163
+ for s in sols:
164
+ print(f"{Path(s.path).name}: {s.summary()}")
165
+ if a.gate is not None:
166
+ sols = [s.gate(a.gate) for s in sols]
167
+ for s in sols:
168
+ print(f" after gate >{a.gate}: {len(s)} solutions")
169
+ else:
170
+ print("note: no --gate given, so every solution is plotted including "
171
+ "ones a randomly oriented crystal could produce.")
172
+
173
+ if kind == "summary":
174
+ if len(sols) != 1:
175
+ ap.error("--kind summary takes exactly one Laue input")
176
+ fig = laue.summary(sols[0], tolerance=a.tol, space_group=sg, hkl=hkl)
177
+ else:
178
+ fig, axes = plt.subplots(1, len(sols),
179
+ figsize=(6.2 * len(sols), 5.4), squeeze=False)
180
+ for ax, s in zip(axes[0], sols):
181
+ if kind == "orientation":
182
+ laue.orientation_map(s, ax, hkl=hkl)
183
+ elif kind in ("pole", "tilt", "size"):
184
+ c = laue.cluster(s, a.tol, space_group=sg)
185
+ reps = c.representatives(s.orient_mat)
186
+ if kind == "pole":
187
+ laue.pole_figure(reps, ax, hkl=hkl)
188
+ elif kind == "tilt":
189
+ laue.tilt_histogram(reps, ax, hkl=hkl)
190
+ else:
191
+ laue.grain_size_distribution(c, ax)
192
+ elif kind == "sweep":
193
+ laue.tolerance_sweep(s, ax, space_group=sg)
194
+ else:
195
+ ap.error(f"--kind {kind!r} is not valid for Laue input; use "
196
+ "summary, orientation, pole, tilt, size or sweep")
197
+ fig.tight_layout()
198
+ if a.suptitle:
199
+ fig.suptitle(a.suptitle, fontsize=12)
200
+ fig.savefig(a.out, dpi=a.dpi, bbox_inches="tight")
201
+ print(f"wrote {Path(a.out).resolve()}")
202
+ return 0
203
+
204
+
205
+ def _run_ff(a, ap) -> int:
206
+ """Far-field plotting branch."""
207
+ import matplotlib.pyplot as plt
208
+
209
+ from . import ff
210
+ from .grains import read_grains
211
+
212
+ kind = a.kind or "summary"
213
+ axis = tuple(float(v) for v in a.axis.split(","))
214
+ # Unset means "use the file's own header" -- Grains.csv states its space
215
+ # group, and overriding it with a default would colour a hexagonal sample
216
+ # through the cubic triangle and produce a plausible, wrong figure.
217
+ sg = None if a.sg in (None, "", "auto") else int(str(a.sg).split(",")[0])
218
+
219
+ grains = [read_grains(m) for m in a.mics]
220
+ for g in grains:
221
+ print(f"{g.path.name}: {len(g)} grains, space group "
222
+ f"{g.space_group if sg is None else sg}")
223
+
224
+ if kind == "summary":
225
+ if len(grains) != 1:
226
+ ap.error("--kind summary takes exactly one Grains.csv")
227
+ fig = ff.summary(grains[0], space_group=sg, cmin=a.cmin, axis=axis)
228
+ else:
229
+ fns = {
230
+ "orientation": lambda g, ax: ff.grain_map(
231
+ g, ax, plane=a.plane, space_group=sg, axis=axis, cmin=a.cmin),
232
+ "pole": lambda g, ax: ff.pole_figure(
233
+ g, ax, hkl=tuple(float(v) for v in a.hkl.split(",")),
234
+ space_group=sg, cmin=a.cmin, axis=axis),
235
+ "strain": lambda g, ax: ff.strain_map(
236
+ g, ax, kind=a.strain_kind, plane=a.plane, cmin=a.cmin),
237
+ "size": lambda g, ax: ff.grain_size_distribution(g, ax, cmin=a.cmin),
238
+ "completeness": lambda g, ax: ff.completeness_hist(g, ax),
239
+ }
240
+ if kind == "3d":
241
+ fig = plt.figure(figsize=(6.6 * len(grains), 6.0))
242
+ for k, g in enumerate(grains):
243
+ ax = fig.add_subplot(1, len(grains), k + 1, projection="3d")
244
+ ff.grain_map_3d(g, ax, space_group=sg, axis=axis, cmin=a.cmin)
245
+ elif kind in fns:
246
+ fig, axes = plt.subplots(1, len(grains),
247
+ figsize=(6.2 * len(grains), 5.6),
248
+ squeeze=False)
249
+ for ax, g in zip(axes[0], grains):
250
+ fns[kind](g, ax)
251
+ else:
252
+ ap.error(f"--kind {kind!r} is not valid for far-field input; use "
253
+ "summary, orientation, pole, strain, size, completeness "
254
+ "or 3d")
255
+ if a.suptitle:
256
+ fig.suptitle(a.suptitle, fontsize=12)
257
+ fig.tight_layout()
258
+ fig.savefig(a.out, dpi=a.dpi, bbox_inches="tight")
259
+ print(f"wrote {Path(a.out).resolve()}")
260
+ return 0
261
+
262
+
263
+ if __name__ == "__main__":
264
+ raise SystemExit(main())