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,301 @@
1
+ """Reading MIDAS far-field ``Grains.csv``.
2
+
3
+ The FF analogue of :mod:`midas_plotting.mic`: one place that knows the column
4
+ layout so analysis scripts stop re-deriving it.
5
+
6
+ Columns are looked up **by name** from the ``%ID ...`` header line, never by
7
+ position. That matters more here than it looks: ``Grains.csv`` has grown to 47
8
+ columns, and `midas-fit-grain` 0.5.6 shipped a cyclic rotation of the
9
+ ``DiffPos`` / ``DiffOme`` / ``DiffAngle`` columns (fixed in 0.5.7) that a
10
+ positional reader would silently inherit -- one grain's ω residual read 223.87°
11
+ where the true value was 0.054°.
12
+
13
+ As a further guard, :func:`read_grains` recomputes the orientation matrix from
14
+ the Euler angles and compares it against the ``O11..O33`` columns. Both describe
15
+ the same orientation, so any disagreement means the row is being sliced wrong
16
+ (or the file was written by a broken version), and it is far better to hear
17
+ about that than to plot it.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import warnings
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ import numpy as np
27
+
28
+ __all__ = ["GrainList", "read_grains"]
29
+
30
+ _OM_NAMES = [f"O{i}{j}" for i in (1, 2, 3) for j in (1, 2, 3)]
31
+ _EUL_NAMES = ["Eul0", "Eul1", "Eul2"]
32
+ _LATTICE_NAMES = ["a", "b", "c", "alpha", "beta", "gamma"]
33
+ _FAB = [f"eFab{i}{j}" for i in (1, 2, 3) for j in (1, 2, 3)]
34
+ _KEN = [f"eKen{i}{j}" for i in (1, 2, 3) for j in (1, 2, 3)]
35
+
36
+ #: Euler/orientation-matrix agreement above this is treated as a real
37
+ #: inconsistency. ``Grains.csv`` is written at ~6 significant figures, so
38
+ #: round-trip differences of ~1e-6 are expected and harmless.
39
+ OM_EULER_TOL = 1e-3
40
+
41
+
42
+ @dataclass
43
+ class GrainList:
44
+ """A parsed FF ``Grains.csv``.
45
+
46
+ Attributes
47
+ ----------
48
+ ids : (N,) int
49
+ pos : (N, 3) float
50
+ Grain centre-of-mass X, Y, Z in **micrometres**. Trustworthy to about
51
+ ~100 µm on a typical reconstruction -- do not read the six decimals the
52
+ file prints (``FF_HEDM_Lab_Notebook.md`` §2d).
53
+ euler : (N, 3) float
54
+ Bunge ZXZ Euler angles in **radians**, matching the ``.mic`` convention
55
+ so :func:`midas_plotting.ipf_rgb` accepts them directly.
56
+ orient_mat : (N, 3, 3) float
57
+ lattice : (N, 6) float
58
+ a, b, c (Å) and alpha, beta, gamma (degrees).
59
+ radius : (N,) float
60
+ ``GrainRadius`` in µm. Correct only with ``midas-process-grains``
61
+ >= 0.6.1; older versions report ~the sample-wide mean for every grain.
62
+ completeness : (N,) float
63
+ diff_pos, diff_ome, diff_angle : (N,) float
64
+ Residuals. Cyclically mislabeled by `midas-fit-grain` 0.5.6.
65
+ strain_fab, strain_ken : (N, 3, 3) float or None
66
+ rmse_strain : (N,) float or None
67
+ phase : (N,) int or None
68
+ header : dict
69
+ The ``%key value`` preamble (NumGrains, BeamCenter, ...).
70
+ columns : list of str
71
+ raw : (N, C) float
72
+ path : Path
73
+ """
74
+
75
+ ids: np.ndarray
76
+ pos: np.ndarray
77
+ euler: np.ndarray
78
+ orient_mat: np.ndarray
79
+ lattice: np.ndarray
80
+ radius: np.ndarray
81
+ completeness: np.ndarray
82
+ diff_pos: np.ndarray
83
+ diff_ome: np.ndarray
84
+ diff_angle: np.ndarray
85
+ strain_fab: Optional[np.ndarray]
86
+ strain_ken: Optional[np.ndarray]
87
+ rmse_strain: Optional[np.ndarray]
88
+ phase: Optional[np.ndarray]
89
+ header: dict
90
+ columns: list
91
+ raw: np.ndarray
92
+ path: Path
93
+
94
+ def __len__(self) -> int:
95
+ return int(self.ids.size)
96
+
97
+ @property
98
+ def space_group(self) -> Optional[int]:
99
+ """Space group from the file's own ``%\tSpaceGroup:`` line, if present.
100
+
101
+ Plot functions default to this rather than to a hard-coded 225, so a
102
+ hexagonal or tetragonal sample is not silently coloured with the cubic
103
+ IPF triangle -- which produces a plausible-looking figure that is
104
+ simply wrong.
105
+ """
106
+ v = self.header.get("SpaceGroup")
107
+ try:
108
+ return int(str(v).strip())
109
+ except (TypeError, ValueError):
110
+ return None
111
+
112
+ @property
113
+ def lattice_parameter(self) -> Optional[np.ndarray]:
114
+ """The header's reference lattice parameter (a, b, c, al, be, ga)."""
115
+ v = self.header.get("Lattice Parameter")
116
+ if not v:
117
+ return None
118
+ try:
119
+ arr = np.array([float(x) for x in str(v).split()], dtype=float)
120
+ except ValueError:
121
+ return None
122
+ return arr if arr.size == 6 else None
123
+
124
+ @property
125
+ def n_grains(self) -> int:
126
+ return len(self)
127
+
128
+ def strain(self, convention: str = "fab") -> np.ndarray:
129
+ """``(N, 3, 3)`` strain tensor in the requested convention."""
130
+ c = convention.lower()
131
+ if c in ("fab", "fable", "efab"):
132
+ s = self.strain_fab
133
+ elif c in ("ken", "kenesei", "eken"):
134
+ s = self.strain_ken
135
+ else:
136
+ raise ValueError(
137
+ f"unknown strain convention {convention!r}; use 'fab' or 'ken'")
138
+ if s is None:
139
+ raise ValueError(
140
+ f"{self.path.name} has no {c} strain columns "
141
+ "(ProcessGrains may have been run without strain output)")
142
+ return s
143
+
144
+
145
+ def _parse(path: Path):
146
+ """Split a Grains.csv into (header dict, column names, data rows).
147
+
148
+ The preamble is not uniform. Real files contain all of:
149
+
150
+ %NumGrains 2
151
+ %PhaseInfo <- key with no value
152
+ %\tSpaceGroup:225 <- TAB-indented `key:value` continuation
153
+ %\tLattice Parameter:4.0782 ... <- key with spaces, colon-separated
154
+ %ID\tO11\t... <- the column header
155
+
156
+ so a naive `body.split()[0]` raises on the indented lines. The column
157
+ header is identified by content (it names the orientation-matrix columns)
158
+ rather than by position, since the number of preamble lines varies with
159
+ the number of phases.
160
+ """
161
+ header, columns, rows = {}, None, []
162
+ for line in Path(path).read_text().splitlines():
163
+ if not line.strip():
164
+ continue
165
+ if not line.startswith("%"):
166
+ rows.append(line.split("\t"))
167
+ continue
168
+
169
+ body = line[1:]
170
+ fields = [f.strip() for f in body.split("\t")]
171
+ # Column header: names O11 (and therefore the whole grain record).
172
+ if "O11" in fields or (fields and fields[0] in ("ID", "GrainID")
173
+ and len(fields) > 5):
174
+ columns = fields
175
+ continue
176
+
177
+ stripped = body.strip()
178
+ if not stripped:
179
+ continue
180
+ if ":" in stripped: # `SpaceGroup:225`
181
+ k, _, v = stripped.partition(":")
182
+ header[k.strip()] = v.strip()
183
+ else: # `NumGrains 2` / `PhaseInfo`
184
+ parts = stripped.split()
185
+ header[parts[0]] = " ".join(parts[1:])
186
+ return header, columns, rows
187
+
188
+
189
+ def read_grains(path, *, check_orientation: bool = True) -> GrainList:
190
+ """Parse an FF ``Grains.csv``.
191
+
192
+ Parameters
193
+ ----------
194
+ path : str or Path
195
+ check_orientation : bool
196
+ Recompute the orientation matrix from the Euler columns and compare
197
+ against ``O11..O33``. A mismatch means the columns are being read
198
+ wrong; a warning is emitted rather than an exception, so a file written
199
+ by an old or unusual version can still be inspected -- but do not
200
+ trust orientation-derived output (including IPF colour) when it fires.
201
+ """
202
+ path = Path(path)
203
+ header, columns, rows = _parse(path)
204
+ if columns is None:
205
+ raise ValueError(
206
+ f"{path}: no column header found. Expected a line beginning "
207
+ "'%ID' or '%GrainID' listing tab-separated column names.")
208
+ if not rows:
209
+ raise ValueError(f"{path}: header present but no grain rows.")
210
+
211
+ idx = {name: i for i, name in enumerate(columns)}
212
+ arr = np.array([[float(v) for v in r] for r in rows], dtype=float)
213
+ if arr.shape[1] != len(columns):
214
+ raise ValueError(
215
+ f"{path}: {arr.shape[1]} data columns but {len(columns)} header "
216
+ "names -- the file is malformed or tab/space separated "
217
+ "inconsistently.")
218
+
219
+ def col(name, required=True):
220
+ if name not in idx:
221
+ if required:
222
+ raise ValueError(
223
+ f"{path}: required column {name!r} not found. "
224
+ f"Columns present: {columns}")
225
+ return None
226
+ return arr[:, idx[name]]
227
+
228
+ def block(names):
229
+ if not all(n in idx for n in names):
230
+ return None
231
+ return np.stack([arr[:, idx[n]] for n in names], axis=1)
232
+
233
+ id_name = "ID" if "ID" in idx else "GrainID"
234
+ ids = col(id_name).astype(int)
235
+ pos = np.stack([col("X"), col("Y"), col("Z")], axis=1)
236
+
237
+ om_flat = block(_OM_NAMES)
238
+ if om_flat is None:
239
+ raise ValueError(f"{path}: orientation matrix columns O11..O33 missing.")
240
+ orient_mat = om_flat.reshape(-1, 3, 3)
241
+
242
+ eul = block(_EUL_NAMES)
243
+ if eul is None:
244
+ # Older writers omitted the Euler columns; derive them so downstream
245
+ # (and ipf_rgb) has a single, consistent source.
246
+ from midas_stress.orientation import orient_mat_to_euler
247
+
248
+ eul = np.array([np.asarray(orient_mat_to_euler(m.reshape(-1).tolist()),
249
+ dtype=float).reshape(3)
250
+ for m in orient_mat])
251
+
252
+ if check_orientation:
253
+ _check_orientation(path, eul, orient_mat)
254
+
255
+ lattice = block(_LATTICE_NAMES)
256
+ if lattice is None:
257
+ lattice = np.full((len(ids), 6), np.nan)
258
+
259
+ fab = block(_FAB)
260
+ ken = block(_KEN)
261
+ return GrainList(
262
+ ids=ids,
263
+ pos=pos,
264
+ euler=eul,
265
+ orient_mat=orient_mat,
266
+ lattice=lattice,
267
+ radius=col("GrainRadius", required=False),
268
+ completeness=col("Confidence", required=False),
269
+ diff_pos=col("DiffPos", required=False),
270
+ diff_ome=col("DiffOme", required=False),
271
+ diff_angle=col("DiffAngle", required=False),
272
+ strain_fab=None if fab is None else fab.reshape(-1, 3, 3),
273
+ strain_ken=None if ken is None else ken.reshape(-1, 3, 3),
274
+ rmse_strain=col("RMSErrorStrain", required=False),
275
+ phase=None if "PhaseNr" not in idx else col("PhaseNr").astype(int),
276
+ header=header,
277
+ columns=columns,
278
+ raw=arr,
279
+ path=path,
280
+ )
281
+
282
+
283
+ def _check_orientation(path, euler, orient_mat) -> None:
284
+ """Euler and O11..O33 must describe the same orientation."""
285
+ try:
286
+ from midas_stress.orientation import euler_to_orient_mat_batch
287
+ except Exception: # noqa: BLE001
288
+ return
289
+ rec = np.asarray(euler_to_orient_mat_batch(euler)).reshape(-1, 3, 3)
290
+ dev = float(np.abs(rec - orient_mat).max())
291
+ if dev > OM_EULER_TOL:
292
+ warnings.warn(
293
+ f"{path.name}: Euler angles and the O11..O33 matrix disagree by "
294
+ f"{dev:.3g} (tolerance {OM_EULER_TOL:g}). The columns are probably "
295
+ "being read wrong, or the file was written by a version with a "
296
+ "column-ordering bug -- `midas-fit-grain` 0.5.6 shipped one. Do "
297
+ "NOT trust orientation-derived output (IPF colour, pole figures, "
298
+ "misorientation) from this file until it is resolved.",
299
+ RuntimeWarning,
300
+ stacklevel=3,
301
+ )
midas_plotting/ipf.py ADDED
@@ -0,0 +1,184 @@
1
+ """Inverse-pole-figure colouring.
2
+
3
+ Colour encodes which crystal direction is parallel to a chosen sample axis, so
4
+ one grain is one colour and boundaries appear as colour discontinuities. That is
5
+ the property an Euler-to-RGB dump does NOT have: two orientations a fraction of
6
+ a degree apart can land on very different Euler triplets (and hence very
7
+ different colours) near the gimbal-lock line, which makes a single grain look
8
+ like several.
9
+
10
+ Symmetry operators come from :mod:`midas_stress`, never hand-listed here.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Sequence
15
+
16
+ import numpy as np
17
+
18
+ __all__ = ["ipf_rgb", "sym_matrices", "CUBIC", "HEXAGONAL"]
19
+
20
+ CUBIC = "cubic"
21
+ HEXAGONAL = "hexagonal"
22
+
23
+ # Laue class per space-group range, for the ones MIDAS actually reconstructs.
24
+ # Deliberately explicit rather than clever: a wrong guess here silently
25
+ # recolours a map without any other symptom.
26
+ _SG_LAUE = [
27
+ (195, 230, CUBIC),
28
+ (168, 194, HEXAGONAL),
29
+ ]
30
+
31
+
32
+ def laue_class(space_group: int) -> str:
33
+ """Laue family used for the IPF triangle.
34
+
35
+ Raises for space groups whose triangle is not implemented, rather than
36
+ falling back to cubic -- a silent fallback would produce a plausible-looking
37
+ but meaningless map.
38
+ """
39
+ for lo, hi, name in _SG_LAUE:
40
+ if lo <= int(space_group) <= hi:
41
+ return name
42
+ raise NotImplementedError(
43
+ f"IPF colouring for space group {space_group} is not implemented "
44
+ f"(have: cubic 195-230, hexagonal 168-194). Refusing to guess."
45
+ )
46
+
47
+
48
+ def sym_matrices(space_group: int) -> np.ndarray:
49
+ """``(n_sym, 3, 3)`` proper-rotation operators from midas_stress."""
50
+ from midas_stress.orientation import make_symmetries, quat_to_orient_mat
51
+
52
+ n, quats = make_symmetries(int(space_group))
53
+ q = np.asarray(quats)[: int(n)]
54
+ return np.stack([np.asarray(quat_to_orient_mat(qi)).reshape(3, 3) for qi in q])
55
+
56
+
57
+ def _reduce_cubic(d: np.ndarray) -> np.ndarray:
58
+ """Fold directions into the standard [001]-[101]-[111] triangle."""
59
+ d = np.abs(d)
60
+ d = np.sort(d, axis=-1) # u <= v <= w
61
+ return d
62
+
63
+
64
+ def _rgb_cubic(d: np.ndarray) -> np.ndarray:
65
+ u, v, w = d[:, 0], d[:, 1], d[:, 2]
66
+ rgb = np.stack([w - v, (v - u) * np.sqrt(2.0), u * np.sqrt(3.0)], axis=1)
67
+ return rgb
68
+
69
+
70
+ def _rgb_hexagonal(d: np.ndarray) -> np.ndarray:
71
+ """Standard [0001]-[10-10]-[2-1-10] triangle.
72
+
73
+ ``d`` is Cartesian with c along +z. After symmetry reduction the
74
+ representative has ``dz >= 0`` and azimuth in ``[0, 30]`` degrees.
75
+ """
76
+ dz = np.abs(d[:, 2])
77
+ planar = np.hypot(d[:, 0], d[:, 1])
78
+ phi = np.degrees(np.arctan2(np.abs(d[:, 1]), np.abs(d[:, 0])))
79
+ phi = np.minimum(phi % 60.0, 60.0 - (phi % 60.0)) # fold to [0, 30]
80
+ t = np.clip(phi / 30.0, 0.0, 1.0)
81
+ return np.stack([dz, planar * (1.0 - t), planar * t], axis=1)
82
+
83
+
84
+ def ipf_rgb(
85
+ euler: np.ndarray,
86
+ space_group: int = 225,
87
+ axis: Sequence[float] = (0.0, 0.0, 1.0),
88
+ *,
89
+ gamma: float = 0.5,
90
+ ) -> np.ndarray:
91
+ """RGB per orientation for the crystal direction parallel to ``axis``.
92
+
93
+ Parameters
94
+ ----------
95
+ euler : (N, 3) array
96
+ Bunge ZXZ Euler angles in **radians** -- the MIDAS ``.mic`` convention.
97
+ space_group : int
98
+ Used for the symmetry operators and to pick the triangle.
99
+ axis : length-3
100
+ Sample-frame direction. ``(0,0,1)`` gives the usual IPF-Z.
101
+ gamma : float
102
+ Perceptual lift applied as ``rgb ** gamma``. 0.5 (sqrt) matches the
103
+ common convention; 1.0 disables it.
104
+
105
+ Returns
106
+ -------
107
+ (N, 3) float array in [0, 1].
108
+ """
109
+ from midas_stress.orientation import euler_to_orient_mat_batch
110
+
111
+ euler = np.asarray(euler, dtype=float).reshape(-1, 3)
112
+ if euler.size == 0:
113
+ return np.zeros((0, 3))
114
+ g = np.asarray(euler_to_orient_mat_batch(euler)).reshape(-1, 3, 3)
115
+ return ipf_rgb_from_matrix(g, space_group, axis, gamma=gamma)
116
+
117
+
118
+ def ipf_rgb_from_matrix(
119
+ orient_mat: np.ndarray,
120
+ space_group: int = 225,
121
+ axis: Sequence[float] = (0.0, 0.0, 1.0),
122
+ *,
123
+ gamma: float = 0.5,
124
+ ) -> np.ndarray:
125
+ """RGB per orientation, from ``(N, 3, 3)`` orientation matrices.
126
+
127
+ The same colouring as :func:`ipf_rgb`, entered from the matrix rather than
128
+ from Euler angles. Far-field ``Grains.csv`` carries both (``O11..O33`` and
129
+ ``Eul0..2``); this avoids a needless matrix -> Euler -> matrix round trip,
130
+ which is lossy near the gimbal-lock configurations of the ZXZ convention.
131
+ """
132
+ g = np.asarray(orient_mat, dtype=float).reshape(-1, 3, 3)
133
+ if g.size == 0:
134
+ return np.zeros((0, 3))
135
+
136
+ a = np.asarray(axis, dtype=float)
137
+ n = np.linalg.norm(a)
138
+ if n == 0:
139
+ raise ValueError("axis must be non-zero")
140
+ a = a / n
141
+
142
+ d = np.einsum("nij,j->ni", g, a) # crystal dir of the axis
143
+ return direction_rgb(d, space_group, gamma=gamma)
144
+
145
+
146
+ def direction_rgb(
147
+ dirs: np.ndarray, space_group: int = 225, *, gamma: float = 0.5,
148
+ ) -> np.ndarray:
149
+ """RGB for **crystal directions** -- the colouring core.
150
+
151
+ ``dirs`` is ``(N, 3)`` in crystal coordinates; it is normalised here.
152
+ Both :func:`ipf_rgb` and the legend drawn by
153
+ ``midas_plotting.ff.ipf_legend`` go through this, so the key on a figure
154
+ is guaranteed to match the colours in the map beside it. A legend computed
155
+ by a separate copy of the triangle maths is a legend that eventually lies.
156
+ """
157
+ d = np.asarray(dirs, dtype=float).reshape(-1, 3)
158
+ if d.size == 0:
159
+ return np.zeros((0, 3))
160
+ nrm = np.linalg.norm(d, axis=1, keepdims=True)
161
+ d = np.divide(d, nrm, out=np.zeros_like(d), where=nrm > 0)
162
+
163
+ fam = laue_class(space_group)
164
+ sym = sym_matrices(space_group)
165
+ d = np.einsum("sij,nj->nsi", sym, d) # every equivalent
166
+
167
+ if fam == CUBIC:
168
+ red = _reduce_cubic(d)
169
+ pick = np.argmax(red[:, :, 2], axis=1) # closest to [001]
170
+ red = red[np.arange(red.shape[0]), pick]
171
+ red /= np.linalg.norm(red, axis=1, keepdims=True)
172
+ rgb = _rgb_cubic(red)
173
+ else:
174
+ dd = d.copy()
175
+ dd[:, :, 2] = np.abs(dd[:, :, 2])
176
+ pick = np.argmax(dd[:, :, 2], axis=1) # closest to [0001]
177
+ red = dd[np.arange(dd.shape[0]), pick]
178
+ red /= np.linalg.norm(red, axis=1, keepdims=True)
179
+ rgb = _rgb_hexagonal(red)
180
+
181
+ rgb = np.clip(rgb, 0.0, None)
182
+ mx = rgb.max(axis=1, keepdims=True)
183
+ rgb = np.where(mx > 0, rgb / mx, rgb)
184
+ return np.clip(rgb ** float(gamma), 0.0, 1.0)