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/maps.py ADDED
@@ -0,0 +1,197 @@
1
+ """Standard reconstruction maps: orientation, confidence, grains."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional, Sequence
5
+
6
+ import numpy as np
7
+
8
+ from .ipf import ipf_rgb
9
+ from .mic import MicMap, read_mic
10
+
11
+ __all__ = [
12
+ "orientation_map", "confidence_map", "grain_labels", "grain_map",
13
+ "compare_maps",
14
+ ]
15
+
16
+ # A low-confidence region on an orientation map looks like microstructure --
17
+ # the fit returns *an* orientation for every voxel it is asked about, whether
18
+ # or not there is material there. Anything below this is annotated on the
19
+ # figure rather than left for the reader to infer.
20
+ TRUST_FLOOR = 0.3
21
+
22
+
23
+ def _marker_size(pitch_um: float, ax_span_um: float = 1200.0) -> float:
24
+ if pitch_um <= 0:
25
+ return 4.0
26
+ return max(1.0, (pitch_um / 2.5) ** 2 * 1.6)
27
+
28
+
29
+ def orientation_map(
30
+ mic: MicMap | str,
31
+ ax=None,
32
+ *,
33
+ space_group: int = 225,
34
+ cmin: float = 0.1,
35
+ axis: Sequence[float] = (0.0, 0.0, 1.0),
36
+ show_unindexed: bool = True,
37
+ annotate_trust: bool = True,
38
+ title: Optional[str] = None,
39
+ ):
40
+ """IPF map of a ``.mic``.
41
+
42
+ ``cmin`` below :data:`TRUST_FLOOR` is allowed but annotated: the fit assigns
43
+ an orientation to every voxel it evaluates, so a permissive cut fills the
44
+ whole grid with plausible-looking colour whether or not material is there.
45
+ """
46
+ import matplotlib.pyplot as plt
47
+
48
+ if not isinstance(mic, MicMap):
49
+ mic = read_mic(mic)
50
+ if ax is None:
51
+ _, ax = plt.subplots(figsize=(6.4, 6.4))
52
+
53
+ k = mic.mask(cmin)
54
+ s = _marker_size(mic.pitch)
55
+ if show_unindexed:
56
+ ax.scatter(mic.x, mic.y, c="0.94", s=s * 0.5, linewidths=0)
57
+ if k.any():
58
+ ax.scatter(mic.x[k], mic.y[k],
59
+ c=ipf_rgb(mic.euler[k], space_group, axis), s=s,
60
+ linewidths=0)
61
+ ax.set_aspect("equal")
62
+ ax.set_xlabel("x (um)")
63
+ ax.set_ylabel("y (um)")
64
+ ax.set_title(title or f"{mic.path.name}\n{int(k.sum()):,} of {len(mic):,} "
65
+ f"voxels at C >= {cmin}", fontsize=10)
66
+ if annotate_trust and cmin < TRUST_FLOOR:
67
+ ax.text(0.02, 0.02,
68
+ f"C >= {cmin} is BELOW the trust floor ({TRUST_FLOOR}).\n"
69
+ "Low-confidence colour is an assigned orientation,\n"
70
+ "not evidence of material.",
71
+ transform=ax.transAxes, fontsize=7.5, va="bottom",
72
+ bbox=dict(boxstyle="round", fc="#fff3cd", ec="#d39e00",
73
+ alpha=0.9))
74
+ return ax
75
+
76
+
77
+ def confidence_map(
78
+ mic: MicMap | str, ax=None, *, vmin: float = 0.0,
79
+ vmax: Optional[float] = None, cmap: str = "viridis",
80
+ title: Optional[str] = None,
81
+ ):
82
+ """Confidence (FracOverlap) map with a colourbar."""
83
+ import matplotlib.pyplot as plt
84
+
85
+ if not isinstance(mic, MicMap):
86
+ mic = read_mic(mic)
87
+ if ax is None:
88
+ _, ax = plt.subplots(figsize=(6.4, 6.4))
89
+ vmax = float(mic.confidence.max()) if vmax is None else vmax
90
+ sc = ax.scatter(mic.x, mic.y, c=mic.confidence, s=_marker_size(mic.pitch),
91
+ cmap=cmap, vmin=vmin, vmax=vmax, linewidths=0)
92
+ ax.figure.colorbar(sc, ax=ax, fraction=0.046)
93
+ ax.set_aspect("equal")
94
+ ax.set_xlabel("x (um)")
95
+ ax.set_ylabel("y (um)")
96
+ ax.set_title(title or f"{mic.path.name}\nconfidence, max {vmax:.4f}",
97
+ fontsize=10)
98
+ return ax
99
+
100
+
101
+ def grain_labels(
102
+ mic: MicMap | str, *, space_group: int = 225, cmin: float = 0.3,
103
+ miso_tol_deg: float = 5.0, min_voxels: int = 3,
104
+ ):
105
+ """Label voxels into grains: spatially adjacent AND within ``miso_tol_deg``.
106
+
107
+ Orientation connectivity is the point -- adjacency alone merges neighbouring
108
+ grains that happen to touch.
109
+
110
+ Returns ``(indices, labels, n_grains_with_min_voxels)``.
111
+ """
112
+ import torch
113
+ from scipy.sparse import coo_matrix
114
+ from scipy.sparse.csgraph import connected_components
115
+ from scipy.spatial import cKDTree
116
+ from midas_stress.orientation import (
117
+ euler_to_orient_mat_batch, misorientation_om_batch,
118
+ )
119
+
120
+ if not isinstance(mic, MicMap):
121
+ mic = read_mic(mic)
122
+ k = np.where(mic.mask(cmin))[0]
123
+ if k.size == 0:
124
+ return k, np.zeros(0, int), 0
125
+
126
+ pairs = cKDTree(np.column_stack([mic.x[k], mic.y[k]])).query_pairs(
127
+ r=mic.pitch * 1.6, output_type="ndarray")
128
+ if pairs.size:
129
+ e = mic.euler[k]
130
+ om = lambda a: torch.as_tensor(
131
+ np.asarray(euler_to_orient_mat_batch(a)), dtype=torch.float64)
132
+ miso = np.degrees(misorientation_om_batch(
133
+ om(e[pairs[:, 0]]), om(e[pairs[:, 1]]), int(space_group)).numpy())
134
+ linked = pairs[miso < miso_tol_deg]
135
+ else:
136
+ linked = np.zeros((0, 2), int)
137
+
138
+ g = coo_matrix((np.ones(len(linked)), (linked[:, 0], linked[:, 1])),
139
+ shape=(k.size, k.size))
140
+ n, lab = connected_components(g, directed=False)
141
+ sizes = np.bincount(lab)
142
+ return k, lab, int((sizes >= min_voxels).sum())
143
+
144
+
145
+ def grain_map(
146
+ mic: MicMap | str, ax=None, *, space_group: int = 225, cmin: float = 0.3,
147
+ min_voxels: int = 3, seed: int = 0, title: Optional[str] = None,
148
+ ):
149
+ """One random colour per resolved grain.
150
+
151
+ Colours are NOT stable between figures -- only patch count and size are
152
+ meaningful. Use :func:`orientation_map` when colour has to mean something.
153
+ """
154
+ import matplotlib.pyplot as plt
155
+
156
+ if not isinstance(mic, MicMap):
157
+ mic = read_mic(mic)
158
+ if ax is None:
159
+ _, ax = plt.subplots(figsize=(6.4, 6.4))
160
+ k, lab, n_big = grain_labels(mic, space_group=space_group, cmin=cmin,
161
+ min_voxels=min_voxels)
162
+ s = _marker_size(mic.pitch)
163
+ ax.scatter(mic.x, mic.y, c="0.94", s=s * 0.5, linewidths=0)
164
+ if k.size:
165
+ sizes = np.bincount(lab)
166
+ keep = (sizes >= min_voxels)[lab]
167
+ pal = np.random.default_rng(seed).random((max(lab.max() + 1, 1), 3))
168
+ pal = pal * 0.75 + 0.2
169
+ ax.scatter(mic.x[k][keep], mic.y[k][keep], c=pal[lab[keep]], s=s,
170
+ linewidths=0)
171
+ ax.set_aspect("equal")
172
+ ax.set_xlabel("x (um)")
173
+ ax.set_ylabel("y (um)")
174
+ ax.set_title(title or f"{mic.path.name}\n{n_big} grains "
175
+ f"(>={min_voxels} voxels, C >= {cmin})", fontsize=10)
176
+ return ax
177
+
178
+
179
+ def compare_maps(
180
+ mics: Sequence[MicMap | str], kind: str = "orientation", *,
181
+ titles: Optional[Sequence[str]] = None, suptitle: Optional[str] = None,
182
+ **kw,
183
+ ):
184
+ """Row of maps sharing one figure. ``kind``: orientation|confidence|grain."""
185
+ import matplotlib.pyplot as plt
186
+
187
+ fn = {"orientation": orientation_map, "confidence": confidence_map,
188
+ "grain": grain_map}[kind]
189
+ n = len(mics)
190
+ fig, axes = plt.subplots(1, n, figsize=(6.2 * n, 6.4), squeeze=False)
191
+ for ax, m, t in zip(axes[0], mics,
192
+ titles or [None] * n):
193
+ fn(m, ax=ax, title=t, **kw)
194
+ if suptitle:
195
+ fig.suptitle(suptitle, fontsize=12)
196
+ fig.tight_layout()
197
+ return fig
midas_plotting/mic.py ADDED
@@ -0,0 +1,87 @@
1
+ """Reading MIDAS ``.mic`` reconstructions.
2
+
3
+ One place that knows the column layout, so analysis scripts stop re-deriving it
4
+ (and stop getting it wrong -- column 2 is ``RunTime``, which differs on every
5
+ run and has repeatedly been mistaken for a physical quantity when diffing two
6
+ reconstructions).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ import numpy as np
15
+
16
+ __all__ = ["MicMap", "read_mic"]
17
+
18
+ # text .mic column layout (0-indexed)
19
+ _COL_X, _COL_Y = 3, 4
20
+ _COL_EULER = slice(7, 10)
21
+ _COL_CONF = 10
22
+ _COL_RUNTIME = 2
23
+
24
+
25
+ @dataclass
26
+ class MicMap:
27
+ """A parsed ``.mic``.
28
+
29
+ Attributes
30
+ ----------
31
+ x, y : (N,) float
32
+ Voxel centre positions in microns.
33
+ euler : (N, 3) float
34
+ Bunge ZXZ Euler angles in **radians**.
35
+ confidence : (N,) float
36
+ FracOverlap in [0, 1].
37
+ pitch : float
38
+ Voxel pitch in microns, derived from the positions rather than read
39
+ from a column -- the grid-size column does not track the actual
40
+ spacing in multi-resolution output.
41
+ path : Path
42
+ """
43
+ x: np.ndarray
44
+ y: np.ndarray
45
+ euler: np.ndarray
46
+ confidence: np.ndarray
47
+ pitch: float
48
+ path: Path
49
+ raw: np.ndarray
50
+
51
+ def __len__(self) -> int:
52
+ return int(self.x.shape[0])
53
+
54
+ def mask(self, cmin: float = 0.0, cmax: float = 1.01) -> np.ndarray:
55
+ return (self.confidence >= cmin) & (self.confidence < cmax)
56
+
57
+ def summary(self) -> str:
58
+ c = self.confidence
59
+ parts = [f"{len(self)} voxels", f"pitch {self.pitch:.2f} um",
60
+ f"maxC {c.max():.4f}", f"medC {np.median(c):.4f}"]
61
+ parts += [f">={t}: {int((c >= t).sum())}" for t in (0.1, 0.3, 0.5)]
62
+ return " ".join(parts)
63
+
64
+
65
+ def read_mic(path: str | Path, *, skip_header: int = 4) -> MicMap:
66
+ """Parse a text ``.mic``.
67
+
68
+ Note the binary ``MicFileBinary`` is a different format: 11 **float64** per
69
+ voxel, whose column 2 is ``RunTime``. Reading it as float32, or diffing that
70
+ column between runs, produces spurious "changes" -- use this text reader
71
+ for analysis.
72
+ """
73
+ path = Path(path)
74
+ d = np.genfromtxt(path, skip_header=skip_header)
75
+ if d.ndim == 1:
76
+ d = d[None, :]
77
+ if d.shape[1] <= _COL_CONF:
78
+ raise ValueError(
79
+ f"{path}: expected >{_COL_CONF + 1} columns, got {d.shape[1]}"
80
+ )
81
+ x, y = d[:, _COL_X], d[:, _COL_Y]
82
+ uniq = np.unique(np.round(x, 4))
83
+ pitch = float(2 * np.median(np.diff(uniq))) if uniq.size > 1 else 0.0
84
+ return MicMap(
85
+ x=x, y=y, euler=d[:, _COL_EULER], confidence=d[:, _COL_CONF],
86
+ pitch=pitch, path=path, raw=d,
87
+ )
@@ -0,0 +1,398 @@
1
+ """Reading Laue indexing output.
2
+
3
+ The Laue analogue of :mod:`midas_plotting.grains`: one place that knows the
4
+ column layout so analysis scripts stop re-deriving it.
5
+
6
+ ``LaueMatchingGPUStream`` writes two text files per run into its ``ResultDir``:
7
+
8
+ ``solutions.txt``
9
+ one row per accepted orientation per frame -- 35 columns, ending
10
+ ``... OrientMatrix0..8, CoarseNMatches*sqrt(Intensity),
11
+ misOrientationPostRefinement, orientationRowNr``.
12
+
13
+ ``spots.txt``
14
+ one row per assigned reflection -- ``ImageNr GrainNr SpotNr h k l X Y
15
+ Qhat[0..2] Intensity``.
16
+
17
+ Columns are looked up **by name** from the ``%ImageNr ...`` header, never by
18
+ position. The failure this prevents is not hypothetical: ``orientationRowNr`` is
19
+ column 34 and ``misOrientationPostRefinement`` is column 33, and reading 33 for
20
+ 34 does not raise -- it returns a float near zero for every row, so every
21
+ "distinct orientations" count silently collapses to single digits and the scan
22
+ looks like it found one crystal.
23
+
24
+ Nothing here imports the indexer. These are stable text formats, and a plotting
25
+ package that could not open a results directory without the GPU pipeline
26
+ installed would not be much use at a beamline.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import warnings
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Optional, Sequence
34
+
35
+ import numpy as np
36
+
37
+ __all__ = [
38
+ "LaueSolutions", "LaueSpots", "read_solutions", "read_spots",
39
+ "read_validated", "COS45",
40
+ ]
41
+
42
+ #: The out-of-plane stage axis at 34-ID-E sits at 45 deg to the beam, so a
43
+ #: recorded Z step is ``1/cos45`` shorter than the distance travelled across the
44
+ #: sample surface. Divide recorded Z by this to get true in-sample micrometres.
45
+ #: Quoting the raw stage extent as a map size understates it by 1.41x -- a
46
+ #: 200 x 100 um map reads as 200 x 71.
47
+ COS45 = float(np.sqrt(0.5))
48
+
49
+ _OM_NAMES = [f"OrientMatrix{i}" for i in range(9)]
50
+ _LATTICE_NAMES = ["LatticeParameterFit[a]", "LatticeParameterFit[b]",
51
+ "LatticeParameterFit[c]", "LatticeParameterFit[alpha]",
52
+ "LatticeParameterFit[beta]", "LatticeParameterFit[gamma]"]
53
+
54
+ #: An orientation matrix should be a proper rotation. Text output at ~7
55
+ #: significant figures round-trips to well under this, so anything larger means
56
+ #: the row is being sliced wrong rather than that the fit was poor.
57
+ ORTHONORMAL_TOL = 1e-3
58
+
59
+
60
+ @dataclass
61
+ class LaueSolutions:
62
+ """Accepted orientations from a Laue run.
63
+
64
+ One row per *orientation per frame*, not per grain: a crystal spanning
65
+ twenty raster positions appears twenty times. Grains come from clustering
66
+ these, which :func:`midas_plotting.laue.cluster` does.
67
+
68
+ Attributes
69
+ ----------
70
+ image : (N,) int
71
+ Frame number within the shard, as written by the indexer. This indexes
72
+ into that run's ``frame_mapping.json``; it is **not** a position.
73
+ grain : (N,) int
74
+ Solution index within the frame (0, 1, 2 ... for multiple orientations
75
+ on one frame). Not a grain identity across frames.
76
+ n_matches : (N,) int
77
+ Reflections the orientation matched. This is the quantity every
78
+ acceptance gate in the MIDAS Laue work is applied to.
79
+ orient_mat : (N, 3, 3) float
80
+ lattice : (N, 6) float or None
81
+ a, b, c (nm as written by the indexer) and alpha, beta, gamma (degrees).
82
+ row_nr : (N,) int or None
83
+ ``orientationRowNr`` -- the row in the orientation library. Two rows
84
+ with the same value are the same orientation to the library's spacing,
85
+ which makes this the cheap way to count *distinct* orientations without
86
+ clustering.
87
+ intensity : (N,) float or None
88
+ misorientation : (N,) float or None
89
+ ``misOrientationPostRefinement``, degrees.
90
+ pos : (N, 2) float or None
91
+ Sample-frame x, y in micrometres, when positions were supplied. The
92
+ out-of-plane axis is already divided by :data:`COS45`.
93
+ columns : list of str
94
+ raw : (N, C) float
95
+ path : Path or None
96
+ """
97
+
98
+ image: np.ndarray
99
+ grain: np.ndarray
100
+ n_matches: np.ndarray
101
+ orient_mat: np.ndarray
102
+ lattice: Optional[np.ndarray] = None
103
+ row_nr: Optional[np.ndarray] = None
104
+ intensity: Optional[np.ndarray] = None
105
+ misorientation: Optional[np.ndarray] = None
106
+ pos: Optional[np.ndarray] = None
107
+ columns: Optional[list] = None
108
+ raw: Optional[np.ndarray] = None
109
+ path: Optional[Path] = None
110
+ frame_name: Optional[np.ndarray] = None
111
+
112
+ def __len__(self) -> int:
113
+ return int(self.image.shape[0])
114
+
115
+ @property
116
+ def n_distinct(self) -> Optional[int]:
117
+ """Distinct library rows, or None if ``orientationRowNr`` was absent.
118
+
119
+ A useful sanity number on its own: a scan whose thousands of solutions
120
+ use a handful of library rows has found one object many times, not many
121
+ objects -- the signature of a substrate being indexed as the deposit.
122
+ """
123
+ if self.row_nr is None:
124
+ return None
125
+ return int(np.unique(self.row_nr).size)
126
+
127
+ def gate(self, n_matches: int) -> "LaueSolutions":
128
+ """Keep solutions matching **more than** ``n_matches`` reflections.
129
+
130
+ The threshold is a property of the scan, not of this package: it should
131
+ be the largest number of matches a *randomly oriented* crystal achieves
132
+ on these frames. There is no default here on purpose.
133
+ """
134
+ return self[self.n_matches > int(n_matches)]
135
+
136
+ def __getitem__(self, m) -> "LaueSolutions":
137
+ def sel(a):
138
+ return None if a is None else a[m]
139
+ return LaueSolutions(
140
+ image=self.image[m], grain=self.grain[m],
141
+ n_matches=self.n_matches[m], orient_mat=self.orient_mat[m],
142
+ lattice=sel(self.lattice), row_nr=sel(self.row_nr),
143
+ intensity=sel(self.intensity), misorientation=sel(self.misorientation),
144
+ pos=sel(self.pos), columns=self.columns, raw=sel(self.raw),
145
+ path=self.path, frame_name=sel(self.frame_name))
146
+
147
+ def summary(self) -> str:
148
+ parts = [f"{len(self)} solutions",
149
+ f"{np.unique(self.image).size} frames"]
150
+ if self.n_distinct is not None:
151
+ parts.append(f"{self.n_distinct} distinct orientations")
152
+ parts.append(f"matches {int(self.n_matches.min())}-"
153
+ f"{int(self.n_matches.max())} (median "
154
+ f"{int(np.median(self.n_matches))})")
155
+ if self.pos is not None and len(self):
156
+ parts.append(f"map {np.ptp(self.pos[:, 0]):.0f} x "
157
+ f"{np.ptp(self.pos[:, 1]):.0f} um")
158
+ return "; ".join(parts)
159
+
160
+
161
+ @dataclass
162
+ class LaueSpots:
163
+ """Reflections assigned to accepted orientations (``spots.txt``)."""
164
+
165
+ image: np.ndarray
166
+ grain: np.ndarray
167
+ hkl: np.ndarray
168
+ xy: np.ndarray
169
+ qhat: Optional[np.ndarray] = None
170
+ intensity: Optional[np.ndarray] = None
171
+ columns: Optional[list] = None
172
+ path: Optional[Path] = None
173
+
174
+ def __len__(self) -> int:
175
+ return int(self.image.shape[0])
176
+
177
+ def for_frame(self, image: int) -> "LaueSpots":
178
+ m = self.image == int(image)
179
+ def sel(a):
180
+ return None if a is None else a[m]
181
+ return LaueSpots(image=self.image[m], grain=self.grain[m],
182
+ hkl=self.hkl[m], xy=self.xy[m], qhat=sel(self.qhat),
183
+ intensity=sel(self.intensity), columns=self.columns,
184
+ path=self.path)
185
+
186
+
187
+ def _read_named(path, expect: str):
188
+ """Header-named table -> (dict name->index, columns, data)."""
189
+ path = Path(path)
190
+ with open(path) as fh:
191
+ header = fh.readline()
192
+ if not header.startswith("%"):
193
+ raise ValueError(
194
+ f"{path} does not start with a '%'-prefixed header line; this does "
195
+ f"not look like a MIDAS Laue {expect} file")
196
+ cols = header.lstrip("%").split()
197
+ idx = {c: i for i, c in enumerate(cols)}
198
+ data = np.atleast_2d(np.loadtxt(path, skiprows=1, ndmin=2))
199
+ if data.size and data.shape[1] != len(cols):
200
+ raise ValueError(
201
+ f"{path}: header names {len(cols)} columns but rows have "
202
+ f"{data.shape[1]}")
203
+ return idx, cols, data
204
+
205
+
206
+ def _need(idx, name, path):
207
+ if name not in idx:
208
+ raise KeyError(
209
+ f"{path}: column {name!r} not found. Present: {sorted(idx)}. "
210
+ f"Reading Laue output positionally is what this reader exists to "
211
+ f"prevent, so it will not guess an index.")
212
+ return idx[name]
213
+
214
+
215
+ def read_solutions(path, positions=None, *, check: bool = True) -> LaueSolutions:
216
+ """Parse a ``solutions.txt``.
217
+
218
+ Parameters
219
+ ----------
220
+ path : str or Path
221
+ positions : (M, 2) array, dict, or None
222
+ Optional sample-frame coordinates. An ``(M, 2)`` array is indexed by
223
+ ``image - 1``; a dict maps image number to ``(x, y)``. Supply these in
224
+ **micrometres already corrected** for the 45 deg stage axis (see
225
+ :data:`COS45`), or pass ``raw_z=True`` style corrected values yourself.
226
+ check : bool
227
+ Verify each orientation matrix is a proper rotation and warn if not.
228
+
229
+ Notes
230
+ -----
231
+ Rows are *orientation per frame*. Counting grains from ``len(sol)`` counts
232
+ one crystal once per position it was seen at.
233
+ """
234
+ path = Path(path)
235
+ idx, cols, data = _read_named(path, "solutions.txt")
236
+ if data.size == 0:
237
+ return LaueSolutions(
238
+ image=np.zeros(0, int), grain=np.zeros(0, int),
239
+ n_matches=np.zeros(0, int), orient_mat=np.zeros((0, 3, 3)),
240
+ columns=cols, raw=data, path=path)
241
+
242
+ image = data[:, _need(idx, "ImageNr", path)].astype(int)
243
+ grain = data[:, _need(idx, "GrainNr", path)].astype(int)
244
+ nmat = data[:, _need(idx, "NMatches", path)].astype(int)
245
+ om = data[:, [_need(idx, n, path) for n in _OM_NAMES]].reshape(-1, 3, 3)
246
+
247
+ def opt(name, cast=float):
248
+ return data[:, idx[name]].astype(cast) if name in idx else None
249
+
250
+ lattice = (data[:, [idx[n] for n in _LATTICE_NAMES]]
251
+ if all(n in idx for n in _LATTICE_NAMES) else None)
252
+ row_nr = opt("orientationRowNr", np.int64)
253
+ if row_nr is None:
254
+ warnings.warn(
255
+ f"{path}: no 'orientationRowNr' column, so distinct-orientation "
256
+ f"counts are unavailable. Do NOT substitute "
257
+ f"'misOrientationPostRefinement' -- it is the adjacent column and "
258
+ f"reading it instead returns near-zero for every row.",
259
+ RuntimeWarning, stacklevel=2)
260
+
261
+ if check:
262
+ _check_rotations(om, path)
263
+
264
+ pos = _resolve_positions(positions, image, path)
265
+ return LaueSolutions(
266
+ image=image, grain=grain, n_matches=nmat, orient_mat=om,
267
+ lattice=lattice, row_nr=row_nr, intensity=opt("Intensity"),
268
+ misorientation=opt("misOrientationPostRefinement"), pos=pos,
269
+ columns=cols, raw=data, path=path)
270
+
271
+
272
+ def _check_rotations(om: np.ndarray, path) -> None:
273
+ """Warn if the matrices are not proper rotations.
274
+
275
+ The FF reader cross-checks Euler angles against the matrix; Laue output
276
+ carries no Euler column, so the available invariant is that a valid
277
+ orientation matrix satisfies ``R R^T = I`` and ``det R = +1``. A row read
278
+ with the wrong column offset fails both, which turns a silent
279
+ mis-slice into a message.
280
+ """
281
+ if om.size == 0:
282
+ return
283
+ eye = np.einsum("nij,nkj->nik", om, om)
284
+ off = np.abs(eye - np.eye(3)).reshape(len(om), -1).max(axis=1)
285
+ det = np.linalg.det(om)
286
+ bad = (off > ORTHONORMAL_TOL) | (np.abs(det - 1.0) > ORTHONORMAL_TOL)
287
+ if bad.any():
288
+ warnings.warn(
289
+ f"{path}: {int(bad.sum())} of {len(om)} orientation matrices are "
290
+ f"not proper rotations (max |RR^T-I| = {off.max():.2e}, det range "
291
+ f"{det.min():.4f}..{det.max():.4f}). The columns are probably being "
292
+ f"sliced wrong.", RuntimeWarning, stacklevel=3)
293
+
294
+
295
+ def _resolve_positions(positions, image, path):
296
+ if positions is None:
297
+ return None
298
+ if isinstance(positions, dict):
299
+ miss = [int(i) for i in np.unique(image) if int(i) not in positions]
300
+ if miss:
301
+ warnings.warn(f"{path}: no position for {len(miss)} image numbers "
302
+ f"(e.g. {miss[:5]}); those rows get NaN",
303
+ RuntimeWarning, stacklevel=3)
304
+ out = np.full((len(image), 2), np.nan)
305
+ for k, im in enumerate(image):
306
+ p = positions.get(int(im))
307
+ if p is not None:
308
+ out[k] = p
309
+ return out
310
+ arr = np.asarray(positions, dtype=float).reshape(-1, 2)
311
+ j = image - 1
312
+ if j.min() < 0 or j.max() >= len(arr):
313
+ raise IndexError(
314
+ f"{path}: image numbers run {image.min()}..{image.max()} but "
315
+ f"positions has {len(arr)} rows. Image numbers are 1-based and "
316
+ f"per-shard; a whole-scan position table does not line up with a "
317
+ f"single shard's solutions.txt.")
318
+ return arr[j]
319
+
320
+
321
+ def read_spots(path) -> LaueSpots:
322
+ """Parse a ``spots.txt``.
323
+
324
+ ``X`` and ``Y`` are detector pixels. These are the indexer's own spot
325
+ positions -- **not** interchangeable with those from an analysis-side peak
326
+ finder run over the same frame. The two detect differently and their
327
+ coordinates do not coincide; a spot list built from the wrong one silently
328
+ selects nothing.
329
+ """
330
+ path = Path(path)
331
+ idx, cols, data = _read_named(path, "spots.txt")
332
+ if data.size == 0:
333
+ return LaueSpots(image=np.zeros(0, int), grain=np.zeros(0, int),
334
+ hkl=np.zeros((0, 3)), xy=np.zeros((0, 2)),
335
+ columns=cols, path=path)
336
+ q = [f"Qhat[{i}]" for i in range(3)]
337
+ return LaueSpots(
338
+ image=data[:, _need(idx, "ImageNr", path)].astype(int),
339
+ grain=data[:, _need(idx, "GrainNr", path)].astype(int),
340
+ hkl=data[:, [_need(idx, k, path) for k in ("h", "k", "l")]],
341
+ xy=data[:, [_need(idx, k, path) for k in ("X", "Y")]],
342
+ qhat=data[:, [idx[k] for k in q]] if all(k in idx for k in q) else None,
343
+ intensity=data[:, idx["Intensity"]] if "Intensity" in idx else None,
344
+ columns=cols, path=path)
345
+
346
+
347
+ def read_validated(path, *, raw_z: bool = True) -> LaueSolutions:
348
+ """Load validated instances from an analysis ``.npz``.
349
+
350
+ Accepts the arrays the MIDAS Laue analysis pipeline writes after per-frame
351
+ validation: ``oms`` ``(N, 3, 3)``, ``X`` and ``Z`` (stage micrometres),
352
+ ``nhit``, and optionally ``frames`` and ``labels``. Several shards can be
353
+ concatenated by passing a sequence of paths.
354
+
355
+ Parameters
356
+ ----------
357
+ raw_z : bool
358
+ ``Z`` in these files is the **stage** coordinate, so it is divided by
359
+ :data:`COS45` to give true in-sample distance. Pass ``False`` only if
360
+ the file already holds corrected values.
361
+ """
362
+ paths = [Path(path)] if isinstance(path, (str, Path)) else [Path(p) for p in path]
363
+ oms, X, Z, nh, fr = [], [], [], [], []
364
+ for p in paths:
365
+ d = np.load(p, allow_pickle=True)
366
+ for key in ("oms", "X", "Z", "nhit"):
367
+ if key not in d:
368
+ raise KeyError(f"{p}: expected array {key!r}; found {list(d)}")
369
+ oms.append(np.asarray(d["oms"]).reshape(-1, 3, 3))
370
+ X.append(np.asarray(d["X"], float).ravel())
371
+ Z.append(np.asarray(d["Z"], float).ravel())
372
+ nh.append(np.asarray(d["nhit"]).ravel())
373
+ fr.append(np.asarray(d["frames"]).ravel() if "frames" in d
374
+ else np.arange(len(oms[-1])))
375
+ om = np.concatenate(oms)
376
+ x = np.concatenate(X)
377
+ z = np.concatenate(Z)
378
+ if raw_z:
379
+ z = z / COS45
380
+ n = np.concatenate(nh).astype(int)
381
+ _check_rotations(om, paths[0])
382
+
383
+ # ``frames`` is written as frame FILENAMES by some versions of the analysis
384
+ # and as integers by others, so it cannot be cast blindly. Names are kept as
385
+ # ``frame_name`` and ``image`` falls back to a running index -- silently
386
+ # int()-ing 'scan100Cu_1.h5' is a crash at best and a wrong join at worst.
387
+ raw_fr = np.concatenate(fr)
388
+ try:
389
+ image = raw_fr.astype(int)
390
+ names = None
391
+ except (ValueError, TypeError):
392
+ names = raw_fr.astype(str)
393
+ _, image = np.unique(names, return_inverse=True)
394
+ image = image.astype(int) + 1
395
+
396
+ return LaueSolutions(
397
+ image=image, grain=np.zeros(len(om), int), n_matches=n, orient_mat=om,
398
+ pos=np.stack([x, z], axis=1), path=paths[0], frame_name=names)