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