midas-plotting 0.3.2__tar.gz → 0.4.1__tar.gz
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-0.3.2 → midas_plotting-0.4.1}/PKG-INFO +2 -2
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/README.md +1 -1
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting/__init__.py +1 -1
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting/ff.py +11 -3
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting/grains.py +40 -12
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting/ipf.py +66 -3
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting.egg-info/PKG-INFO +2 -2
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/pyproject.toml +1 -1
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/tests/test_ff.py +119 -13
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/tests/test_ipf.py +63 -1
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting/cli.py +0 -0
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting/laue.py +0 -0
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting/maps.py +0 -0
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting/mic.py +0 -0
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting/solutions.py +0 -0
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting.egg-info/SOURCES.txt +0 -0
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting.egg-info/dependency_links.txt +0 -0
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting.egg-info/entry_points.txt +0 -0
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting.egg-info/requires.txt +0 -0
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/midas_plotting.egg-info/top_level.txt +0 -0
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/setup.cfg +0 -0
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/tests/test_laue.py +0 -0
- {midas_plotting-0.3.2 → midas_plotting-0.4.1}/tests/test_mic.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: midas-plotting
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Standard plots for MIDAS reconstructions - near-field, far-field and Laue: IPF maps and legends, grain maps, pole figures, strain and size distributions, and Laue texture diagnostics against their chance levels.
|
|
5
5
|
Author-email: Hemant Sharma <hsharma@anl.gov>
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -88,7 +88,7 @@ Things the module will not let you get wrong:
|
|
|
88
88
|
* **Symmetry comes from the file.** `Grains.csv` states its space group in the
|
|
89
89
|
preamble; the plots use it. Defaulting to cubic would colour a hexagonal
|
|
90
90
|
sample with the wrong IPF triangle and produce a plausible, wrong figure.
|
|
91
|
-
* **Columns are read by name.** `Grains.csv` has
|
|
91
|
+
* **Columns are read by name.** `Grains.csv` has 53 columns (47 before 2026-08-21) and
|
|
92
92
|
`midas-fit-grain` 0.5.6 shipped a cyclic rotation of three of them; a
|
|
93
93
|
positional reader inherits that silently.
|
|
94
94
|
* **Euler angles are cross-checked against `O11..O33`.** They describe the same
|
|
@@ -67,7 +67,7 @@ Things the module will not let you get wrong:
|
|
|
67
67
|
* **Symmetry comes from the file.** `Grains.csv` states its space group in the
|
|
68
68
|
preamble; the plots use it. Defaulting to cubic would colour a hexagonal
|
|
69
69
|
sample with the wrong IPF triangle and produce a plausible, wrong figure.
|
|
70
|
-
* **Columns are read by name.** `Grains.csv` has
|
|
70
|
+
* **Columns are read by name.** `Grains.csv` has 53 columns (47 before 2026-08-21) and
|
|
71
71
|
`midas-fit-grain` 0.5.6 shipped a cyclic rotation of three of them; a
|
|
72
72
|
positional reader inherits that silently.
|
|
73
73
|
* **Euler angles are cross-checked against `O11..O33`.** They describe the same
|
|
@@ -48,7 +48,7 @@ from .solutions import (
|
|
|
48
48
|
)
|
|
49
49
|
from .mic import MicMap, read_mic
|
|
50
50
|
|
|
51
|
-
__version__ = "0.
|
|
51
|
+
__version__ = "0.4.1"
|
|
52
52
|
__all__ = [
|
|
53
53
|
"MicMap", "read_mic", "GrainList", "read_grains", "ff", "laue",
|
|
54
54
|
"LaueSolutions", "LaueSpots", "read_solutions", "read_spots",
|
|
@@ -318,9 +318,17 @@ def completeness_hist(grains, ax=None, *, bins: int = 30,
|
|
|
318
318
|
|
|
319
319
|
# ─── strain ─────────────────────────────────────────────────────────────────
|
|
320
320
|
def strain_scalar(grains, kind: str = "hydrostatic", *,
|
|
321
|
-
convention: str = "
|
|
321
|
+
convention: str = "ken") -> np.ndarray:
|
|
322
322
|
"""Reduce the per-grain strain tensor to one number per grain.
|
|
323
323
|
|
|
324
|
+
``convention`` defaults to **eKen**, matching
|
|
325
|
+
``midas_stress.io.read_grains_csv``'s ``strain`` key and its documented
|
|
326
|
+
recommendation, so a strain map from this module and a stress computed by
|
|
327
|
+
``midas_stress`` from the same Grains.csv are the same tensor. It used to
|
|
328
|
+
default to ``"fab"``; both sites labelled their choice, so nothing was
|
|
329
|
+
mis-read, but the two answers silently differed. Pass ``convention="fab"``
|
|
330
|
+
for the lattice-parameter form.
|
|
331
|
+
|
|
324
332
|
``kind``:
|
|
325
333
|
``hydrostatic`` trace/3
|
|
326
334
|
``vonmises`` von Mises equivalent of the deviatoric part
|
|
@@ -352,7 +360,7 @@ def strain_scalar(grains, kind: str = "hydrostatic", *,
|
|
|
352
360
|
|
|
353
361
|
|
|
354
362
|
def strain_map(grains, ax=None, *, kind: str = "hydrostatic",
|
|
355
|
-
convention: str = "
|
|
363
|
+
convention: str = "ken", plane: str = "xy", cmin: float = 0.0,
|
|
356
364
|
cmap: str = "coolwarm", vmin=None, vmax=None,
|
|
357
365
|
symmetric: bool = True, title: Optional[str] = None):
|
|
358
366
|
"""Grain map coloured by a strain scalar (µε).
|
|
@@ -392,7 +400,7 @@ def strain_map(grains, ax=None, *, kind: str = "hydrostatic",
|
|
|
392
400
|
return ax
|
|
393
401
|
|
|
394
402
|
|
|
395
|
-
def strain_distribution(grains, ax=None, *, convention: str = "
|
|
403
|
+
def strain_distribution(grains, ax=None, *, convention: str = "ken",
|
|
396
404
|
bins: int = 30, title: Optional[str] = None):
|
|
397
405
|
"""Histograms of the three normal strain components (µε)."""
|
|
398
406
|
import matplotlib.pyplot as plt
|
|
@@ -4,11 +4,16 @@ The FF analogue of :mod:`midas_plotting.mic`: one place that knows the column
|
|
|
4
4
|
layout so analysis scripts stop re-deriving it.
|
|
5
5
|
|
|
6
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
|
|
8
|
-
columns, and `midas-fit-grain` 0.5.6
|
|
9
|
-
``DiffPos`` / ``DiffOme`` / ``DiffAngle``
|
|
10
|
-
positional reader would silently inherit --
|
|
11
|
-
where the true value was 0.054°.
|
|
7
|
+
position. That matters more here than it looks: ``Grains.csv`` has been widened
|
|
8
|
+
repeatedly (19 -> 21 -> 23 -> 47 -> 53 columns), and `midas-fit-grain` 0.5.6
|
|
9
|
+
shipped a cyclic rotation of the ``DiffPos`` / ``DiffOme`` / ``DiffAngle``
|
|
10
|
+
columns (fixed in 0.5.7) that a positional reader would silently inherit --
|
|
11
|
+
one grain's ω residual read 223.87° where the true value was 0.054°.
|
|
12
|
+
|
|
13
|
+
Rows are split on **whitespace**, not on tabs: the C ``ProcessGrains`` writer
|
|
14
|
+
ends every data row with a tab, so a bare ``split("\t")`` produces a trailing
|
|
15
|
+
empty field and ``float("")`` raises on files that are otherwise perfectly
|
|
16
|
+
valid.
|
|
12
17
|
|
|
13
18
|
As a further guard, :func:`read_grains` recomputes the orientation matrix from
|
|
14
19
|
the Euler angles and compares it against the ``O11..O33`` columns. Both describe
|
|
@@ -125,8 +130,20 @@ class GrainList:
|
|
|
125
130
|
def n_grains(self) -> int:
|
|
126
131
|
return len(self)
|
|
127
132
|
|
|
128
|
-
def strain(self, convention: str = "
|
|
129
|
-
"""``(N, 3, 3)`` strain tensor in the requested convention.
|
|
133
|
+
def strain(self, convention: str = "ken") -> np.ndarray:
|
|
134
|
+
"""``(N, 3, 3)`` strain tensor in the requested convention.
|
|
135
|
+
|
|
136
|
+
Defaults to **eKen** to agree with ``midas_stress.io.read_grains_csv``,
|
|
137
|
+
whose plain ``strain`` key is eKen and which documents it as the
|
|
138
|
+
recommended form (it is fit directly from the per-reflection
|
|
139
|
+
``eps_ij g_i g_j = (d_obs - d_0)/d_0`` gauge equation, so it is tied to
|
|
140
|
+
the raw observables and has better noise properties than the
|
|
141
|
+
lattice-parameter form). These two packages are routinely pointed at
|
|
142
|
+
the same Grains.csv -- a strain map here and a stress calculation
|
|
143
|
+
there -- and while both label their choice, disagreeing on the default
|
|
144
|
+
meant the map and the stress were not the same tensor. Pass
|
|
145
|
+
``convention="fab"`` for the lattice-parameter (Fable-Beaudoin) form.
|
|
146
|
+
"""
|
|
130
147
|
c = convention.lower()
|
|
131
148
|
if c in ("fab", "fable", "efab"):
|
|
132
149
|
s = self.strain_fab
|
|
@@ -163,11 +180,22 @@ def _parse(path: Path):
|
|
|
163
180
|
if not line.strip():
|
|
164
181
|
continue
|
|
165
182
|
if not line.startswith("%"):
|
|
166
|
-
|
|
183
|
+
# Whitespace-split, NOT `split("\t")`. The C `ProcessGrains`
|
|
184
|
+
# terminates every data row with a tab, so a bare tab-split yields
|
|
185
|
+
# a spurious empty final field and `float("")` raises -- which is
|
|
186
|
+
# exactly what every file under midas_stress/dev/paper3/runs/ and
|
|
187
|
+
# the bundled GrainsSim.csv did. Splitting on whitespace also
|
|
188
|
+
# accepts the space-separated flavours (NF mic2grains seed files).
|
|
189
|
+
# No value in this format ever contains an internal space, and a
|
|
190
|
+
# collapsed empty field would be caught by the column-count check
|
|
191
|
+
# in read_grains() below.
|
|
192
|
+
rows.append(line.split())
|
|
167
193
|
continue
|
|
168
194
|
|
|
169
195
|
body = line[1:]
|
|
170
|
-
|
|
196
|
+
# Same rule for the header, so a space-separated file's column names
|
|
197
|
+
# are found too. Column names never contain spaces.
|
|
198
|
+
fields = body.split()
|
|
171
199
|
# Column header: names O11 (and therefore the whole grain record).
|
|
172
200
|
if "O11" in fields or (fields and fields[0] in ("ID", "GrainID")
|
|
173
201
|
and len(fields) > 5):
|
|
@@ -204,7 +232,7 @@ def read_grains(path, *, check_orientation: bool = True) -> GrainList:
|
|
|
204
232
|
if columns is None:
|
|
205
233
|
raise ValueError(
|
|
206
234
|
f"{path}: no column header found. Expected a line beginning "
|
|
207
|
-
"'%ID' or '%GrainID' listing
|
|
235
|
+
"'%ID' or '%GrainID' listing the column names.")
|
|
208
236
|
if not rows:
|
|
209
237
|
raise ValueError(f"{path}: header present but no grain rows.")
|
|
210
238
|
|
|
@@ -213,8 +241,8 @@ def read_grains(path, *, check_orientation: bool = True) -> GrainList:
|
|
|
213
241
|
if arr.shape[1] != len(columns):
|
|
214
242
|
raise ValueError(
|
|
215
243
|
f"{path}: {arr.shape[1]} data columns but {len(columns)} header "
|
|
216
|
-
"names -- the file is malformed or
|
|
217
|
-
"
|
|
244
|
+
"names -- the file is malformed (an empty field mid-row, or a "
|
|
245
|
+
"header that does not match the rows).")
|
|
218
246
|
|
|
219
247
|
def col(name, required=True):
|
|
220
248
|
if name not in idx:
|
|
@@ -15,10 +15,11 @@ from typing import Sequence
|
|
|
15
15
|
|
|
16
16
|
import numpy as np
|
|
17
17
|
|
|
18
|
-
__all__ = ["ipf_rgb", "sym_matrices", "CUBIC", "HEXAGONAL"]
|
|
18
|
+
__all__ = ["ipf_rgb", "sym_matrices", "CUBIC", "HEXAGONAL", "TRIGONAL"]
|
|
19
19
|
|
|
20
20
|
CUBIC = "cubic"
|
|
21
21
|
HEXAGONAL = "hexagonal"
|
|
22
|
+
TRIGONAL = "trigonal"
|
|
22
23
|
|
|
23
24
|
# Laue class per space-group range, for the ones MIDAS actually reconstructs.
|
|
24
25
|
# Deliberately explicit rather than clever: a wrong guess here silently
|
|
@@ -26,6 +27,7 @@ HEXAGONAL = "hexagonal"
|
|
|
26
27
|
_SG_LAUE = [
|
|
27
28
|
(195, 230, CUBIC),
|
|
28
29
|
(168, 194, HEXAGONAL),
|
|
30
|
+
(143, 167, TRIGONAL),
|
|
29
31
|
]
|
|
30
32
|
|
|
31
33
|
|
|
@@ -41,7 +43,7 @@ def laue_class(space_group: int) -> str:
|
|
|
41
43
|
return name
|
|
42
44
|
raise NotImplementedError(
|
|
43
45
|
f"IPF colouring for space group {space_group} is not implemented "
|
|
44
|
-
f"(have: cubic 195-230, hexagonal 168-194). Refusing to guess."
|
|
46
|
+
f"(have: cubic 195-230, hexagonal 168-194, trigonal 143-167). Refusing to guess."
|
|
45
47
|
)
|
|
46
48
|
|
|
47
49
|
|
|
@@ -81,6 +83,58 @@ def _rgb_hexagonal(d: np.ndarray) -> np.ndarray:
|
|
|
81
83
|
return np.stack([dz, planar * (1.0 - t), planar * t], axis=1)
|
|
82
84
|
|
|
83
85
|
|
|
86
|
+
def _trigonal_sector_deg(sym: np.ndarray) -> float:
|
|
87
|
+
"""Azimuthal fundamental-sector width, measured from the operators.
|
|
88
|
+
|
|
89
|
+
Counts the distinct upper-hemisphere azimuths a generic direction is sent
|
|
90
|
+
to by the group plus the Laue centre. For -3m that is 6 images, so 60 deg
|
|
91
|
+
-- twice the hexagonal 30. Measured rather than hard-coded so a change in
|
|
92
|
+
the operator set cannot silently mis-scale the colour ramp.
|
|
93
|
+
"""
|
|
94
|
+
g = np.array([0.3411, 0.1297, 0.4271]) # generic, no special azimuth
|
|
95
|
+
g = g / np.linalg.norm(g)
|
|
96
|
+
both = np.concatenate([sym @ g, -(sym @ g)], axis=0)
|
|
97
|
+
up = both[both[:, 2] >= -1e-12]
|
|
98
|
+
phi = np.sort(np.degrees(np.arctan2(up[:, 1], up[:, 0])) % 360.0)
|
|
99
|
+
keep = [phi[0]]
|
|
100
|
+
for a in phi[1:]:
|
|
101
|
+
if a - keep[-1] > 1e-6:
|
|
102
|
+
keep.append(a)
|
|
103
|
+
return 360.0 / len(keep)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _rgb_trigonal(d_all: np.ndarray, sym: np.ndarray) -> np.ndarray:
|
|
107
|
+
"""Standard [0001]-[10-10]-[01-10] triangle for Laue class -3m.
|
|
108
|
+
|
|
109
|
+
D3 has 6 proper rotations (3-fold about c, three 2-fold in the basal
|
|
110
|
+
plane) against 6/mmm's 12, so the sector spans 60 deg, not 30. Colouring
|
|
111
|
+
R-3m with the hexagonal triangle folds by a 6-fold axis the crystal does
|
|
112
|
+
not have and silently gives distinct orientations the same colour.
|
|
113
|
+
|
|
114
|
+
The azimuth is NOT hand-folded. Where MIDAS puts the 2-fold axes is a
|
|
115
|
+
convention, and assuming one sits at azimuth 0 gives a colouring that is
|
|
116
|
+
not symmetry invariant (measured: colour moved by up to 0.99 under
|
|
117
|
+
``g -> S.g``). Instead take the whole orbit, add ``-d`` for the Laue
|
|
118
|
+
centre, keep the upper hemisphere and pick the smallest azimuth --
|
|
119
|
+
canonical whatever the operator convention.
|
|
120
|
+
|
|
121
|
+
``d_all`` is ``(n, n_sym, 3)``, every symmetry image of each direction.
|
|
122
|
+
"""
|
|
123
|
+
both = np.concatenate([d_all, -d_all], axis=1) # (n, 2s, 3)
|
|
124
|
+
phi = np.degrees(np.arctan2(both[:, :, 1], both[:, :, 0])) % 360.0
|
|
125
|
+
phi = np.where(both[:, :, 2] >= -1e-12, phi, np.inf) # upper only
|
|
126
|
+
k = np.argmin(phi, axis=1)
|
|
127
|
+
idx = np.arange(both.shape[0])
|
|
128
|
+
rep, phi_c = both[idx, k], phi[idx, k]
|
|
129
|
+
phi_c = np.where(np.isfinite(phi_c), phi_c, 0.0)
|
|
130
|
+
rep = rep / np.linalg.norm(rep, axis=1, keepdims=True)
|
|
131
|
+
|
|
132
|
+
dz = np.abs(rep[:, 2])
|
|
133
|
+
planar = np.hypot(rep[:, 0], rep[:, 1])
|
|
134
|
+
t = np.clip(phi_c / _trigonal_sector_deg(sym), 0.0, 1.0)
|
|
135
|
+
return np.stack([dz, planar * (1.0 - t), planar * t], axis=1)
|
|
136
|
+
|
|
137
|
+
|
|
84
138
|
def ipf_rgb(
|
|
85
139
|
euler: np.ndarray,
|
|
86
140
|
space_group: int = 225,
|
|
@@ -139,7 +193,14 @@ def ipf_rgb_from_matrix(
|
|
|
139
193
|
raise ValueError("axis must be non-zero")
|
|
140
194
|
a = a / n
|
|
141
195
|
|
|
142
|
-
|
|
196
|
+
# TRANSPOSE. MIDAS orientation matrices map CRYSTAL -> LAB (v_lab = g v_crystal),
|
|
197
|
+
# so the crystal direction parallel to the sample axis `a` is g^T a, not g a.
|
|
198
|
+
# This is not cosmetic: with g a, the colour is NOT symmetry-invariant for this
|
|
199
|
+
# convention -- the 24 equally valid representations of one grain spread over
|
|
200
|
+
# 0.96 in RGB, so a map's colour depended on which variant the indexer happened
|
|
201
|
+
# to store. Two grains agreeing to 0.5 deg came out 0.42 apart in RGB.
|
|
202
|
+
# Fixed 2026-09-03; see tests::test_ipf_colour_is_symmetry_invariant.
|
|
203
|
+
d = np.einsum("nji,j->ni", g, a) # crystal dir of the axis = g^T a
|
|
143
204
|
return direction_rgb(d, space_group, gamma=gamma)
|
|
144
205
|
|
|
145
206
|
|
|
@@ -170,6 +231,8 @@ def direction_rgb(
|
|
|
170
231
|
red = red[np.arange(red.shape[0]), pick]
|
|
171
232
|
red /= np.linalg.norm(red, axis=1, keepdims=True)
|
|
172
233
|
rgb = _rgb_cubic(red)
|
|
234
|
+
elif fam == TRIGONAL:
|
|
235
|
+
rgb = _rgb_trigonal(d, sym)
|
|
173
236
|
else:
|
|
174
237
|
dd = d.copy()
|
|
175
238
|
dd[:, :, 2] = np.abs(dd[:, :, 2])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: midas-plotting
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Standard plots for MIDAS reconstructions - near-field, far-field and Laue: IPF maps and legends, grain maps, pole figures, strain and size distributions, and Laue texture diagnostics against their chance levels.
|
|
5
5
|
Author-email: Hemant Sharma <hsharma@anl.gov>
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -88,7 +88,7 @@ Things the module will not let you get wrong:
|
|
|
88
88
|
* **Symmetry comes from the file.** `Grains.csv` states its space group in the
|
|
89
89
|
preamble; the plots use it. Defaulting to cubic would colour a hexagonal
|
|
90
90
|
sample with the wrong IPF triangle and produce a plausible, wrong figure.
|
|
91
|
-
* **Columns are read by name.** `Grains.csv` has
|
|
91
|
+
* **Columns are read by name.** `Grains.csv` has 53 columns (47 before 2026-08-21) and
|
|
92
92
|
`midas-fit-grain` 0.5.6 shipped a cyclic rotation of three of them; a
|
|
93
93
|
positional reader inherits that silently.
|
|
94
94
|
* **Euler angles are cross-checked against `O11..O33`.** They describe the same
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "midas-plotting"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.1"
|
|
8
8
|
description = "Standard plots for MIDAS reconstructions - near-field, far-field and Laue: IPF maps and legends, grain maps, pole figures, strain and size distributions, and Laue texture diagnostics against their chance levels."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "BSD-3-Clause" }
|
|
@@ -13,16 +13,38 @@ from midas_plotting.ipf import ( # noqa: E402
|
|
|
13
13
|
direction_rgb, ipf_rgb, ipf_rgb_from_matrix,
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
# The CURRENT width is 53: the six DiffPos/Ome/Angle Pre/Post diagnostics were
|
|
17
|
+
# appended on 2026-08-21. The fixture used to stop at 47, which is why nothing
|
|
18
|
+
# here noticed the widening.
|
|
19
|
+
_DATA_COLS = ([f"O{i}{j}" for i in (1, 2, 3) for j in (1, 2, 3)]
|
|
20
|
+
+ ["X", "Y", "Z", "a", "b", "c", "alpha", "beta", "gamma",
|
|
21
|
+
"DiffPos", "DiffOme", "DiffAngle",
|
|
22
|
+
"GrainRadius", "Confidence"]
|
|
23
|
+
+ [f"eFab{i}{j}" for i in (1, 2, 3) for j in (1, 2, 3)]
|
|
24
|
+
+ [f"eKen{i}{j}" for i in (1, 2, 3) for j in (1, 2, 3)]
|
|
25
|
+
+ ["RMSErrorStrain", "PhaseNr", "Eul0", "Eul1", "Eul2",
|
|
26
|
+
"DiffPosPre", "DiffOmePre", "DiffAnglePre",
|
|
27
|
+
"DiffPosPost", "DiffOmePost", "DiffAnglePost"])
|
|
28
|
+
_COLS = ["ID"] + _DATA_COLS
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _write_grains(tmp_path, n=4, sg=225, break_orientation=False,
|
|
32
|
+
*, trailing_tab=False, id_token="ID", sep="\t"):
|
|
33
|
+
"""A Grains.csv with the real preamble shape, incl. tab-indented phase info.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
trailing_tab : bool
|
|
38
|
+
Terminate every data row with a tab, which is what the C
|
|
39
|
+
``ProcessGrains`` actually writes. A reader that does ``split("\t")``
|
|
40
|
+
then sees a spurious empty final field and ``float("")`` raises. Every
|
|
41
|
+
real file under ``midas_stress/dev/paper3/runs/*/data/*MPa/`` and the
|
|
42
|
+
bundled ``GrainsSim.csv`` is in this form, and this fixture's omission
|
|
43
|
+
of it is why the crash shipped.
|
|
44
|
+
id_token : "ID" or "GrainID"
|
|
45
|
+
Both spellings are written by live MIDAS writers.
|
|
46
|
+
sep : column separator for the header and data rows.
|
|
47
|
+
"""
|
|
26
48
|
from midas_stress.orientation import euler_to_orient_mat_batch
|
|
27
49
|
|
|
28
50
|
rng = np.random.default_rng(0)
|
|
@@ -38,7 +60,7 @@ def _write_grains(tmp_path, n=4, sg=225, break_orientation=False):
|
|
|
38
60
|
f"%\tSpaceGroup:{sg}",
|
|
39
61
|
"%\tLattice Parameter:4.078200\t4.078200\t4.078200\t"
|
|
40
62
|
"90.000000\t90.000000\t90.000000",
|
|
41
|
-
"%" +
|
|
63
|
+
"%" + sep.join([id_token] + _DATA_COLS),
|
|
42
64
|
]
|
|
43
65
|
for k in range(n):
|
|
44
66
|
row = [str(k + 1)] + [f"{v:.6f}" for v in om[k].reshape(-1)]
|
|
@@ -50,7 +72,9 @@ def _write_grains(tmp_path, n=4, sg=225, break_orientation=False):
|
|
|
50
72
|
row += [f"{v:.4f}" for v in rng.normal(200, 50, 9)] # eKen
|
|
51
73
|
row += ["400.0", "1"]
|
|
52
74
|
row += [f"{v:.9f}" for v in eul[k]]
|
|
53
|
-
|
|
75
|
+
row += ["151.0", "0.06", "0.09", "150.0", "0.05", "0.08"] # pre/post
|
|
76
|
+
assert len(row) == len(_COLS)
|
|
77
|
+
lines.append(sep.join(row) + ("\t" if trailing_tab else ""))
|
|
54
78
|
p = tmp_path / "Grains.csv"
|
|
55
79
|
p.write_text("\n".join(lines) + "\n")
|
|
56
80
|
return p, eul, om
|
|
@@ -111,6 +135,51 @@ def test_orientation_check_can_be_disabled(tmp_path):
|
|
|
111
135
|
read_grains(p, check_orientation=False)
|
|
112
136
|
|
|
113
137
|
|
|
138
|
+
@pytest.mark.parametrize("trailing_tab", [False, True])
|
|
139
|
+
@pytest.mark.parametrize("id_token", ["ID", "GrainID"])
|
|
140
|
+
def test_trailing_tab_and_both_id_spellings(tmp_path, trailing_tab, id_token):
|
|
141
|
+
"""The shipped crash: `line.split("\t")` on a tab-terminated row.
|
|
142
|
+
|
|
143
|
+
The C ``ProcessGrains`` ends every data row with a tab, so a tab-split
|
|
144
|
+
yields one extra empty field and ``float("")`` raised
|
|
145
|
+
``could not convert string to float: ''`` on every real file under
|
|
146
|
+
``midas_stress/dev/paper3/runs/*/data/*MPa/Grains.csv``.
|
|
147
|
+
"""
|
|
148
|
+
p, eul, om = _write_grains(tmp_path, n=4, trailing_tab=trailing_tab,
|
|
149
|
+
id_token=id_token)
|
|
150
|
+
g = read_grains(p)
|
|
151
|
+
assert len(g) == 4
|
|
152
|
+
assert len(g.columns) == 53
|
|
153
|
+
np.testing.assert_array_equal(g.ids, [1, 2, 3, 4])
|
|
154
|
+
assert np.allclose(g.euler, eul, atol=1e-6)
|
|
155
|
+
assert np.allclose(g.orient_mat, om, atol=1e-5)
|
|
156
|
+
assert np.allclose(g.radius, [40.0, 50.0, 60.0, 70.0])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_space_separated_file_is_readable(tmp_path):
|
|
160
|
+
"""NF ``mic2grains`` seed files are space-separated, not tab-separated."""
|
|
161
|
+
p, eul, _ = _write_grains(tmp_path, n=3, sep=" ")
|
|
162
|
+
g = read_grains(p)
|
|
163
|
+
assert len(g) == 3 and len(g.columns) == 53
|
|
164
|
+
assert np.allclose(g.euler, eul, atol=1e-6)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_current_width_exposes_pre_post_residual_columns(tmp_path):
|
|
168
|
+
"""The 2026-08-21 widening: 47 -> 53. The extra columns must be readable
|
|
169
|
+
by name, and must not displace anything that was already there."""
|
|
170
|
+
p, _, _ = _write_grains(tmp_path, n=2)
|
|
171
|
+
g = read_grains(p)
|
|
172
|
+
idx = {c: i for i, c in enumerate(g.columns)}
|
|
173
|
+
for c in ("DiffPosPre", "DiffOmePre", "DiffAnglePre",
|
|
174
|
+
"DiffPosPost", "DiffOmePost", "DiffAnglePost"):
|
|
175
|
+
assert c in idx
|
|
176
|
+
np.testing.assert_allclose(g.raw[:, idx["DiffPosPre"]], 151.0)
|
|
177
|
+
np.testing.assert_allclose(g.raw[:, idx["DiffPosPost"]], 150.0)
|
|
178
|
+
# ...and the pre-existing block is untouched.
|
|
179
|
+
np.testing.assert_allclose(g.diff_pos, 150.0)
|
|
180
|
+
np.testing.assert_allclose(g.diff_ome, 0.05)
|
|
181
|
+
|
|
182
|
+
|
|
114
183
|
def test_missing_header_is_an_error(tmp_path):
|
|
115
184
|
p = tmp_path / "bad.csv"
|
|
116
185
|
p.write_text("1\t2\t3\n")
|
|
@@ -127,7 +196,7 @@ def test_strain_is_not_rescaled(tmp_path):
|
|
|
127
196
|
p, _, _ = _write_grains(tmp_path, n=4)
|
|
128
197
|
g = read_grains(p)
|
|
129
198
|
raw11 = g.strain_fab[:, 0, 0]
|
|
130
|
-
assert np.allclose(ff.strain_scalar(g, "11"), raw11)
|
|
199
|
+
assert np.allclose(ff.strain_scalar(g, "11", convention="fab"), raw11)
|
|
131
200
|
assert np.abs(ff.strain_scalar(g, "hydrostatic")).max() < 1e4
|
|
132
201
|
|
|
133
202
|
|
|
@@ -135,6 +204,9 @@ def test_strain_scalars(tmp_path):
|
|
|
135
204
|
p, _, _ = _write_grains(tmp_path, n=4)
|
|
136
205
|
g = read_grains(p)
|
|
137
206
|
e = g.strain("fab")
|
|
207
|
+
assert np.allclose(ff.strain_scalar(g, "hydrostatic", convention="fab"),
|
|
208
|
+
np.trace(e, axis1=1, axis2=2) / 3.0)
|
|
209
|
+
e = g.strain() # default convention, checked below
|
|
138
210
|
assert np.allclose(ff.strain_scalar(g, "hydrostatic"),
|
|
139
211
|
np.trace(e, axis1=1, axis2=2) / 3.0)
|
|
140
212
|
assert (ff.strain_scalar(g, "vonmises") >= 0).all()
|
|
@@ -246,3 +318,37 @@ def test_ff_detected_by_content_not_filename(tmp_path):
|
|
|
246
318
|
other.write_text("1 2 3 4 5\n")
|
|
247
319
|
assert not _looks_like_ff(other)
|
|
248
320
|
assert not _looks_like_ff(tmp_path / "missing.csv")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ── strain convention default (regression) ──────────────────────────────────
|
|
324
|
+
def test_strain_default_convention_is_eken(tmp_path):
|
|
325
|
+
"""The default must be eKen, matching ``midas_stress.io``'s ``strain`` key.
|
|
326
|
+
|
|
327
|
+
midas_plotting defaulted to eFab while midas_stress.io mapped its plain
|
|
328
|
+
``strain`` key to eKen and documented eKen as the recommendation. Both
|
|
329
|
+
sites labelled their choice, so nothing raised and nothing was mislabelled
|
|
330
|
+
-- but a strain map from here and a stress from there, on the SAME
|
|
331
|
+
Grains.csv, were different tensors.
|
|
332
|
+
"""
|
|
333
|
+
p, _, _ = _write_grains(tmp_path, n=6)
|
|
334
|
+
g = read_grains(p)
|
|
335
|
+
# The fixture draws eFab and eKen independently, so a wrong default shows.
|
|
336
|
+
assert not np.allclose(g.strain_fab, g.strain_ken)
|
|
337
|
+
np.testing.assert_allclose(g.strain(), g.strain_ken)
|
|
338
|
+
for kind in ("hydrostatic", "vonmises", "11", "23"):
|
|
339
|
+
np.testing.assert_allclose(ff.strain_scalar(g, kind),
|
|
340
|
+
ff.strain_scalar(g, kind, convention="ken"))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_strain_default_agrees_with_midas_stress(tmp_path):
|
|
344
|
+
"""Same file, same tensor, across the two packages.
|
|
345
|
+
|
|
346
|
+
midas_stress returns dimensionless strain (it divides the stored
|
|
347
|
+
microstrain by 1e6); midas_plotting keeps microstrain. That factor is the
|
|
348
|
+
ONLY difference the two defaults may have.
|
|
349
|
+
"""
|
|
350
|
+
ms_io = pytest.importorskip("midas_stress.io")
|
|
351
|
+
p, _, _ = _write_grains(tmp_path, n=6)
|
|
352
|
+
g = read_grains(p)
|
|
353
|
+
ms = ms_io.read_grains_csv(str(p))
|
|
354
|
+
np.testing.assert_allclose(g.strain(), ms["strain"] * 1e6, rtol=1e-9)
|
|
@@ -72,7 +72,11 @@ def test_symmetry_equivalent_orientations_get_the_same_colour():
|
|
|
72
72
|
|
|
73
73
|
base = ipf_rgb(e0, 225)
|
|
74
74
|
for op in S[1:6]:
|
|
75
|
-
|
|
75
|
+
# symmetry acts on the RIGHT for crystal->lab matrices (MIDAS convention):
|
|
76
|
+
# midas_stress.misorientation_om_batch calls g.S equivalent and S.g a
|
|
77
|
+
# DIFFERENT orientation (61 deg). Asserting the wrong side is what let the
|
|
78
|
+
# g-vs-g^T bug survive until 2026-09-03.
|
|
79
|
+
g1 = np.einsum("nij,jk->nik", g0, op)
|
|
76
80
|
e1 = np.array([np.asarray(orient_mat_to_euler(m.ravel().tolist())).ravel()
|
|
77
81
|
for m in g1])
|
|
78
82
|
np.testing.assert_allclose(ipf_rgb(e1, 225), base, atol=1e-6)
|
|
@@ -98,3 +102,61 @@ def test_hexagonal_c_axis_along_z_is_red():
|
|
|
98
102
|
"""[0001] || Z is the first corner of the hexagonal triangle."""
|
|
99
103
|
rgb = ipf_rgb(np.zeros((1, 3)), 194)[0]
|
|
100
104
|
assert rgb[0] > 0.9
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- trigonal (Laue -3m), added for NMC811 R-3m / SG 166 -------------------
|
|
108
|
+
|
|
109
|
+
def _random_oms(n, seed=0):
|
|
110
|
+
rng = np.random.default_rng(seed)
|
|
111
|
+
q = rng.normal(size=(n, 4))
|
|
112
|
+
q /= np.linalg.norm(q, axis=1, keepdims=True)
|
|
113
|
+
w, x, y, z = q.T
|
|
114
|
+
return np.stack([
|
|
115
|
+
1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w),
|
|
116
|
+
2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w),
|
|
117
|
+
2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y),
|
|
118
|
+
], axis=1).reshape(-1, 3, 3)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.mark.parametrize("sg", [166, 194, 225])
|
|
122
|
+
def test_ipf_colour_is_symmetry_invariant(sg):
|
|
123
|
+
"""colour(S.g) == colour(g) for every proper rotation S.
|
|
124
|
+
|
|
125
|
+
This is the property that makes an IPF map mean anything: one grain, one
|
|
126
|
+
colour, whichever symmetrically equivalent orientation the indexer
|
|
127
|
+
happened to report. It is also what caught the first -3m triangle, which
|
|
128
|
+
hand-folded the azimuth assuming a 2-fold axis at 0 deg and moved the
|
|
129
|
+
colour by up to 0.99 under this test.
|
|
130
|
+
"""
|
|
131
|
+
from midas_plotting.ipf import ipf_rgb_from_matrix, sym_matrices
|
|
132
|
+
g = _random_oms(2000, seed=sg)
|
|
133
|
+
base = ipf_rgb_from_matrix(g, sg)
|
|
134
|
+
for s in sym_matrices(sg):
|
|
135
|
+
got = ipf_rgb_from_matrix(np.einsum("nij,jk->nik", g, s), sg)
|
|
136
|
+
np.testing.assert_allclose(got, base, atol=1e-9)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_trigonal_sector_is_twice_the_hexagonal_one():
|
|
140
|
+
"""-3m has 6 proper rotations to 6/mmm's 12, so a 60 deg sector, not 30.
|
|
141
|
+
|
|
142
|
+
Measured from the operator set, so a change there cannot silently
|
|
143
|
+
mis-scale the ramp.
|
|
144
|
+
"""
|
|
145
|
+
from midas_plotting.ipf import _trigonal_sector_deg, sym_matrices
|
|
146
|
+
assert _trigonal_sector_deg(sym_matrices(166)) == pytest.approx(60.0)
|
|
147
|
+
assert _trigonal_sector_deg(sym_matrices(194)) == pytest.approx(30.0)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_trigonal_is_not_the_hexagonal_colouring():
|
|
151
|
+
"""R-3m must not be quietly coloured as 6/mmm -- that merges orientations."""
|
|
152
|
+
from midas_plotting.ipf import ipf_rgb_from_matrix
|
|
153
|
+
g = _random_oms(3000, seed=7)
|
|
154
|
+
assert np.abs(ipf_rgb_from_matrix(g, 166)
|
|
155
|
+
- ipf_rgb_from_matrix(g, 194)).mean() > 0.05
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_laue_class_still_refuses_what_it_cannot_draw():
|
|
159
|
+
from midas_plotting.ipf import laue_class
|
|
160
|
+
assert laue_class(166) == "trigonal"
|
|
161
|
+
with pytest.raises(NotImplementedError):
|
|
162
|
+
laue_class(2) # triclinic: no triangle implemented
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|