midas-plotting 0.3.0__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.0/PKG-INFO +161 -0
- midas_plotting-0.3.0/README.md +141 -0
- midas_plotting-0.3.0/midas_plotting/__init__.py +59 -0
- midas_plotting-0.3.0/midas_plotting/cli.py +264 -0
- midas_plotting-0.3.0/midas_plotting/ff.py +522 -0
- midas_plotting-0.3.0/midas_plotting/grains.py +301 -0
- midas_plotting-0.3.0/midas_plotting/ipf.py +184 -0
- midas_plotting-0.3.0/midas_plotting/laue.py +671 -0
- midas_plotting-0.3.0/midas_plotting/maps.py +197 -0
- midas_plotting-0.3.0/midas_plotting/mic.py +87 -0
- midas_plotting-0.3.0/midas_plotting/solutions.py +398 -0
- midas_plotting-0.3.0/midas_plotting.egg-info/PKG-INFO +161 -0
- midas_plotting-0.3.0/midas_plotting.egg-info/SOURCES.txt +21 -0
- midas_plotting-0.3.0/midas_plotting.egg-info/dependency_links.txt +1 -0
- midas_plotting-0.3.0/midas_plotting.egg-info/entry_points.txt +2 -0
- midas_plotting-0.3.0/midas_plotting.egg-info/requires.txt +7 -0
- midas_plotting-0.3.0/midas_plotting.egg-info/top_level.txt +1 -0
- midas_plotting-0.3.0/pyproject.toml +38 -0
- midas_plotting-0.3.0/setup.cfg +4 -0
- midas_plotting-0.3.0/tests/test_ff.py +248 -0
- midas_plotting-0.3.0/tests/test_ipf.py +100 -0
- midas_plotting-0.3.0/tests/test_laue.py +393 -0
- midas_plotting-0.3.0/tests/test_mic.py +56 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: midas-plotting
|
|
3
|
+
Version: 0.3.0
|
|
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
|
+
Author-email: Hemant Sharma <hsharma@anl.gov>
|
|
6
|
+
License: BSD-3-Clause
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Science/Research
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: numpy>=1.22
|
|
15
|
+
Requires-Dist: matplotlib>=3.5
|
|
16
|
+
Requires-Dist: midas-stress>=0.1
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
19
|
+
Requires-Dist: scipy>=1.9; extra == "dev"
|
|
20
|
+
|
|
21
|
+
# midas-plotting
|
|
22
|
+
|
|
23
|
+
Standard plots for MIDAS reconstructions.
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from midas_plotting import read_mic, orientation_map, compare_maps
|
|
27
|
+
|
|
28
|
+
m = read_mic("Ce5Y_mr.2.mic")
|
|
29
|
+
print(m.summary())
|
|
30
|
+
orientation_map(m, space_group=225, cmin=0.3)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
midas-plot Ce5Y.0.mic Ce5Y_sum3thr2.0.mic --kind orientation --cmin 0.3 \
|
|
35
|
+
--titles "baseline|sum3+thr2" -o compare.png
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Why
|
|
39
|
+
|
|
40
|
+
IPF colouring, `.mic` parsing and map plotting had been re-implemented in
|
|
41
|
+
several one-off analysis scripts, each with its own conventions. Two things that
|
|
42
|
+
kept going wrong and are now handled in one place:
|
|
43
|
+
|
|
44
|
+
- **Euler→RGB is not an orientation map.** Two orientations a fraction of a
|
|
45
|
+
degree apart can produce very different Euler triplets near gimbal lock, so a
|
|
46
|
+
single grain renders as several colours. `ipf_rgb` colours by the crystal
|
|
47
|
+
direction along a sample axis instead.
|
|
48
|
+
- **A permissive confidence cut fills the whole grid.** The fit returns *an*
|
|
49
|
+
orientation for every voxel it evaluates, so plotting at C ≥ 0.1 shows
|
|
50
|
+
plausible microstructure whether or not material is there. `orientation_map`
|
|
51
|
+
annotates the figure when asked to plot below `TRUST_FLOOR` (0.3).
|
|
52
|
+
|
|
53
|
+
Symmetry operators come from `midas_stress`; nothing is hand-listed here.
|
|
54
|
+
|
|
55
|
+
Implemented Laue families: cubic (SG 195–230) and hexagonal (168–194).
|
|
56
|
+
Anything else raises rather than silently falling back to cubic.
|
|
57
|
+
|
|
58
|
+
## Far-field (`Grains.csv`)
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from midas_plotting import ff, read_grains
|
|
62
|
+
|
|
63
|
+
g = read_grains("Grains.csv")
|
|
64
|
+
print(len(g), g.space_group) # symmetry is read from the file's header
|
|
65
|
+
|
|
66
|
+
ff.summary(g) # one-page overview
|
|
67
|
+
ff.grain_map(g, color="ipf") # IPF-coloured grain centres
|
|
68
|
+
ff.ipf_legend(g.space_group) # the colour key
|
|
69
|
+
ff.pole_figure(g, hkl=(1, 1, 1))
|
|
70
|
+
ff.strain_map(g, kind="vonmises")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
midas-plot Grains.csv --kind summary -o overview.png
|
|
75
|
+
midas-plot Grains.csv --kind pole --hkl 1,1,1
|
|
76
|
+
midas-plot Grains.csv --kind strain --strain-kind hydrostatic
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
FF output is a **grain list**, not a voxel grid, so these are scatter and
|
|
80
|
+
distribution plots. They are namespaced under `ff` rather than exported flat
|
|
81
|
+
because both modalities have a `grain_map` and they mean different things:
|
|
82
|
+
`maps.grain_map` labels a near-field voxel grid, `ff.grain_map` scatters
|
|
83
|
+
far-field grain centres.
|
|
84
|
+
|
|
85
|
+
Things the module will not let you get wrong:
|
|
86
|
+
|
|
87
|
+
* **Symmetry comes from the file.** `Grains.csv` states its space group in the
|
|
88
|
+
preamble; the plots use it. Defaulting to cubic would colour a hexagonal
|
|
89
|
+
sample with the wrong IPF triangle and produce a plausible, wrong figure.
|
|
90
|
+
* **Columns are read by name.** `Grains.csv` has 47 columns and
|
|
91
|
+
`midas-fit-grain` 0.5.6 shipped a cyclic rotation of three of them; a
|
|
92
|
+
positional reader inherits that silently.
|
|
93
|
+
* **Euler angles are cross-checked against `O11..O33`.** They describe the same
|
|
94
|
+
orientation, so disagreement means the row is being sliced wrong — you get a
|
|
95
|
+
warning instead of a wrong colour.
|
|
96
|
+
* **Strain is already microstrain.** The `eFab`/`eKen` columns are not
|
|
97
|
+
dimensionless; they are not rescaled.
|
|
98
|
+
|
|
99
|
+
Two caveats the plots cannot fix: FF grain positions are good to ~100 µm (not
|
|
100
|
+
the six decimals the file prints), and `GrainRadius` is only correct with
|
|
101
|
+
`midas-process-grains >= 0.6.1`.
|
|
102
|
+
|
|
103
|
+
## Laue (`solutions.txt`, `spots.txt`)
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from midas_plotting import laue, read_solutions, read_spots
|
|
107
|
+
|
|
108
|
+
sol = read_solutions("solutions.txt") # one row per orientation PER FRAME
|
|
109
|
+
print(sol.summary()) # ... 4,746 distinct orientations ...
|
|
110
|
+
sol = sol.gate(11) # the measured null for THAT scan
|
|
111
|
+
|
|
112
|
+
c = laue.cluster(sol, 1.0, space_group=194)
|
|
113
|
+
print(c) # <GrainClusters 631 grains at 1.0deg (of 636 clusters,
|
|
114
|
+
# 5 spanning >half the map), n_eff 309.5>
|
|
115
|
+
|
|
116
|
+
reps = c.representatives(sol.orient_mat) # one orientation per grain
|
|
117
|
+
laue.tilt_histogram(reps) # against the random reference
|
|
118
|
+
laue.texture_strength(reps) # (peak, chance, peak/chance)
|
|
119
|
+
laue.summary(sol)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
midas-plot solutions.txt --kind tilt --gate 11 --sg 194
|
|
124
|
+
midas-plot validated.npz --kind summary --sg 194 --tol 1.0
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Laue output is neither a voxel grid nor a grain list: it is one row per
|
|
128
|
+
*orientation per frame*, so a crystal seen at twenty positions appears twenty
|
|
129
|
+
times. Nothing is a grain until it has been clustered, and every grain count
|
|
130
|
+
here carries the tolerance that produced it.
|
|
131
|
+
|
|
132
|
+
Four things the module will not let you get wrong:
|
|
133
|
+
|
|
134
|
+
* **Half of a random population lies more than 60° from any fixed direction.**
|
|
135
|
+
That is solid angle, not texture. `tilt_histogram` draws
|
|
136
|
+
`random_tilt_fractions()` beside the data by default, because "70% of grains
|
|
137
|
+
lie near the surface plane" reads as a strong texture and is very nearly
|
|
138
|
+
random — and 30% there is a *depletion*.
|
|
139
|
+
* **A raw pole density is not comparable between datasets.** A small grain
|
|
140
|
+
population peaks higher by chance alone, and its chance level rises to match.
|
|
141
|
+
`texture_strength` returns the ratio to its own measured null, which is what
|
|
142
|
+
makes 85 grains and 631 grains commensurable.
|
|
143
|
+
* **An orientation present at every raster position is not a grain.** The beam
|
|
144
|
+
moves a micron or two between frames. `cluster` flags anything spanning more
|
|
145
|
+
than half the map; on one dataset a single such object held 59% of all
|
|
146
|
+
measurements and dragged the effective sample size from 29 to 2.5. The Kish
|
|
147
|
+
effective n sits next to every grain count for the same reason.
|
|
148
|
+
* **`orientationRowNr` is column 34 and `misOrientationPostRefinement` is 33.**
|
|
149
|
+
Reading 33 for 34 does not raise — it returns a near-zero float for every
|
|
150
|
+
row, so distinct-orientation counts collapse to single digits and the scan
|
|
151
|
+
looks like it found one crystal. Columns are read by name.
|
|
152
|
+
|
|
153
|
+
Geometry is explicit, never assumed: `SURFACE_NORMAL_34IDE` and the `COS45`
|
|
154
|
+
stage correction are module constants with 34-ID-E defaults, and every function
|
|
155
|
+
takes `normal=`. The out-of-plane stage axis sits at 45°, so quoting its raw
|
|
156
|
+
extent as a map size understates it by 1.41× — a 200 × 100 µm map reads as
|
|
157
|
+
200 × 71.
|
|
158
|
+
|
|
159
|
+
The acceptance gate has **no default**. It is the largest number of reflections
|
|
160
|
+
a randomly oriented crystal achieves on those frames, it is a property of the
|
|
161
|
+
scan, and `midas-plot` says so when you omit `--gate` rather than picking one.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# midas-plotting
|
|
2
|
+
|
|
3
|
+
Standard plots for MIDAS reconstructions.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from midas_plotting import read_mic, orientation_map, compare_maps
|
|
7
|
+
|
|
8
|
+
m = read_mic("Ce5Y_mr.2.mic")
|
|
9
|
+
print(m.summary())
|
|
10
|
+
orientation_map(m, space_group=225, cmin=0.3)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
midas-plot Ce5Y.0.mic Ce5Y_sum3thr2.0.mic --kind orientation --cmin 0.3 \
|
|
15
|
+
--titles "baseline|sum3+thr2" -o compare.png
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
IPF colouring, `.mic` parsing and map plotting had been re-implemented in
|
|
21
|
+
several one-off analysis scripts, each with its own conventions. Two things that
|
|
22
|
+
kept going wrong and are now handled in one place:
|
|
23
|
+
|
|
24
|
+
- **Euler→RGB is not an orientation map.** Two orientations a fraction of a
|
|
25
|
+
degree apart can produce very different Euler triplets near gimbal lock, so a
|
|
26
|
+
single grain renders as several colours. `ipf_rgb` colours by the crystal
|
|
27
|
+
direction along a sample axis instead.
|
|
28
|
+
- **A permissive confidence cut fills the whole grid.** The fit returns *an*
|
|
29
|
+
orientation for every voxel it evaluates, so plotting at C ≥ 0.1 shows
|
|
30
|
+
plausible microstructure whether or not material is there. `orientation_map`
|
|
31
|
+
annotates the figure when asked to plot below `TRUST_FLOOR` (0.3).
|
|
32
|
+
|
|
33
|
+
Symmetry operators come from `midas_stress`; nothing is hand-listed here.
|
|
34
|
+
|
|
35
|
+
Implemented Laue families: cubic (SG 195–230) and hexagonal (168–194).
|
|
36
|
+
Anything else raises rather than silently falling back to cubic.
|
|
37
|
+
|
|
38
|
+
## Far-field (`Grains.csv`)
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from midas_plotting import ff, read_grains
|
|
42
|
+
|
|
43
|
+
g = read_grains("Grains.csv")
|
|
44
|
+
print(len(g), g.space_group) # symmetry is read from the file's header
|
|
45
|
+
|
|
46
|
+
ff.summary(g) # one-page overview
|
|
47
|
+
ff.grain_map(g, color="ipf") # IPF-coloured grain centres
|
|
48
|
+
ff.ipf_legend(g.space_group) # the colour key
|
|
49
|
+
ff.pole_figure(g, hkl=(1, 1, 1))
|
|
50
|
+
ff.strain_map(g, kind="vonmises")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
midas-plot Grains.csv --kind summary -o overview.png
|
|
55
|
+
midas-plot Grains.csv --kind pole --hkl 1,1,1
|
|
56
|
+
midas-plot Grains.csv --kind strain --strain-kind hydrostatic
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
FF output is a **grain list**, not a voxel grid, so these are scatter and
|
|
60
|
+
distribution plots. They are namespaced under `ff` rather than exported flat
|
|
61
|
+
because both modalities have a `grain_map` and they mean different things:
|
|
62
|
+
`maps.grain_map` labels a near-field voxel grid, `ff.grain_map` scatters
|
|
63
|
+
far-field grain centres.
|
|
64
|
+
|
|
65
|
+
Things the module will not let you get wrong:
|
|
66
|
+
|
|
67
|
+
* **Symmetry comes from the file.** `Grains.csv` states its space group in the
|
|
68
|
+
preamble; the plots use it. Defaulting to cubic would colour a hexagonal
|
|
69
|
+
sample with the wrong IPF triangle and produce a plausible, wrong figure.
|
|
70
|
+
* **Columns are read by name.** `Grains.csv` has 47 columns and
|
|
71
|
+
`midas-fit-grain` 0.5.6 shipped a cyclic rotation of three of them; a
|
|
72
|
+
positional reader inherits that silently.
|
|
73
|
+
* **Euler angles are cross-checked against `O11..O33`.** They describe the same
|
|
74
|
+
orientation, so disagreement means the row is being sliced wrong — you get a
|
|
75
|
+
warning instead of a wrong colour.
|
|
76
|
+
* **Strain is already microstrain.** The `eFab`/`eKen` columns are not
|
|
77
|
+
dimensionless; they are not rescaled.
|
|
78
|
+
|
|
79
|
+
Two caveats the plots cannot fix: FF grain positions are good to ~100 µm (not
|
|
80
|
+
the six decimals the file prints), and `GrainRadius` is only correct with
|
|
81
|
+
`midas-process-grains >= 0.6.1`.
|
|
82
|
+
|
|
83
|
+
## Laue (`solutions.txt`, `spots.txt`)
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from midas_plotting import laue, read_solutions, read_spots
|
|
87
|
+
|
|
88
|
+
sol = read_solutions("solutions.txt") # one row per orientation PER FRAME
|
|
89
|
+
print(sol.summary()) # ... 4,746 distinct orientations ...
|
|
90
|
+
sol = sol.gate(11) # the measured null for THAT scan
|
|
91
|
+
|
|
92
|
+
c = laue.cluster(sol, 1.0, space_group=194)
|
|
93
|
+
print(c) # <GrainClusters 631 grains at 1.0deg (of 636 clusters,
|
|
94
|
+
# 5 spanning >half the map), n_eff 309.5>
|
|
95
|
+
|
|
96
|
+
reps = c.representatives(sol.orient_mat) # one orientation per grain
|
|
97
|
+
laue.tilt_histogram(reps) # against the random reference
|
|
98
|
+
laue.texture_strength(reps) # (peak, chance, peak/chance)
|
|
99
|
+
laue.summary(sol)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
midas-plot solutions.txt --kind tilt --gate 11 --sg 194
|
|
104
|
+
midas-plot validated.npz --kind summary --sg 194 --tol 1.0
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Laue output is neither a voxel grid nor a grain list: it is one row per
|
|
108
|
+
*orientation per frame*, so a crystal seen at twenty positions appears twenty
|
|
109
|
+
times. Nothing is a grain until it has been clustered, and every grain count
|
|
110
|
+
here carries the tolerance that produced it.
|
|
111
|
+
|
|
112
|
+
Four things the module will not let you get wrong:
|
|
113
|
+
|
|
114
|
+
* **Half of a random population lies more than 60° from any fixed direction.**
|
|
115
|
+
That is solid angle, not texture. `tilt_histogram` draws
|
|
116
|
+
`random_tilt_fractions()` beside the data by default, because "70% of grains
|
|
117
|
+
lie near the surface plane" reads as a strong texture and is very nearly
|
|
118
|
+
random — and 30% there is a *depletion*.
|
|
119
|
+
* **A raw pole density is not comparable between datasets.** A small grain
|
|
120
|
+
population peaks higher by chance alone, and its chance level rises to match.
|
|
121
|
+
`texture_strength` returns the ratio to its own measured null, which is what
|
|
122
|
+
makes 85 grains and 631 grains commensurable.
|
|
123
|
+
* **An orientation present at every raster position is not a grain.** The beam
|
|
124
|
+
moves a micron or two between frames. `cluster` flags anything spanning more
|
|
125
|
+
than half the map; on one dataset a single such object held 59% of all
|
|
126
|
+
measurements and dragged the effective sample size from 29 to 2.5. The Kish
|
|
127
|
+
effective n sits next to every grain count for the same reason.
|
|
128
|
+
* **`orientationRowNr` is column 34 and `misOrientationPostRefinement` is 33.**
|
|
129
|
+
Reading 33 for 34 does not raise — it returns a near-zero float for every
|
|
130
|
+
row, so distinct-orientation counts collapse to single digits and the scan
|
|
131
|
+
looks like it found one crystal. Columns are read by name.
|
|
132
|
+
|
|
133
|
+
Geometry is explicit, never assumed: `SURFACE_NORMAL_34IDE` and the `COS45`
|
|
134
|
+
stage correction are module constants with 34-ID-E defaults, and every function
|
|
135
|
+
takes `normal=`. The out-of-plane stage axis sits at 45°, so quoting its raw
|
|
136
|
+
extent as a map size understates it by 1.41× — a 200 × 100 µm map reads as
|
|
137
|
+
200 × 71.
|
|
138
|
+
|
|
139
|
+
The acceptance gate has **no default**. It is the largest number of reflections
|
|
140
|
+
a randomly oriented crystal achieves on those frames, it is a property of the
|
|
141
|
+
scan, and `midas-plot` says so when you omit `--gate` rather than picking one.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Standard plots for MIDAS reconstructions.
|
|
2
|
+
|
|
3
|
+
from midas_plotting import read_mic, orientation_map
|
|
4
|
+
orientation_map("Ce5Y.0.mic", space_group=225, cmin=0.3)
|
|
5
|
+
|
|
6
|
+
Far-field ``Grains.csv`` lives in the ``ff`` submodule::
|
|
7
|
+
|
|
8
|
+
from midas_plotting import ff, read_grains
|
|
9
|
+
g = read_grains("Grains.csv")
|
|
10
|
+
ff.summary(g) # one-page overview
|
|
11
|
+
ff.grain_map(g, color="ipf") # IPF-coloured grain scatter
|
|
12
|
+
ff.ipf_legend(g.space_group) # the colour key
|
|
13
|
+
|
|
14
|
+
FF plots are namespaced rather than exported flat because both modalities have
|
|
15
|
+
a ``grain_map`` and they mean different things: ``maps.grain_map`` labels a
|
|
16
|
+
near-field voxel grid, ``ff.grain_map`` scatters far-field grain centres.
|
|
17
|
+
|
|
18
|
+
Laue microdiffraction lives in ``laue``, and reads the indexer's text output::
|
|
19
|
+
|
|
20
|
+
from midas_plotting import laue, read_solutions
|
|
21
|
+
sol = read_solutions("solutions.txt") # one row per frame, not per grain
|
|
22
|
+
sol = sol.gate(11) # the measured random-orientation null
|
|
23
|
+
c = laue.cluster(sol, 1.0, space_group=194) # grains, with full-field objects flagged
|
|
24
|
+
laue.tilt_histogram(c.representatives(sol.orient_mat)) # vs the random reference
|
|
25
|
+
laue.summary(sol)
|
|
26
|
+
|
|
27
|
+
or from the shell::
|
|
28
|
+
|
|
29
|
+
midas-plot Ce5Y.0.mic --kind orientation --cmin 0.3 --sg 225
|
|
30
|
+
midas-plot Grains.csv --kind summary
|
|
31
|
+
|
|
32
|
+
Written after the same IPF colouring, .mic parsing and map plotting were
|
|
33
|
+
re-implemented several times in one-off analysis scripts, each time with its own
|
|
34
|
+
conventions.
|
|
35
|
+
"""
|
|
36
|
+
from .ipf import (
|
|
37
|
+
CUBIC, HEXAGONAL, direction_rgb, ipf_rgb, ipf_rgb_from_matrix,
|
|
38
|
+
laue_class, sym_matrices,
|
|
39
|
+
)
|
|
40
|
+
from .maps import (
|
|
41
|
+
TRUST_FLOOR, compare_maps, confidence_map, grain_labels, grain_map,
|
|
42
|
+
orientation_map,
|
|
43
|
+
)
|
|
44
|
+
from . import ff, laue
|
|
45
|
+
from .grains import GrainList, read_grains
|
|
46
|
+
from .solutions import (
|
|
47
|
+
LaueSolutions, LaueSpots, read_solutions, read_spots, read_validated,
|
|
48
|
+
)
|
|
49
|
+
from .mic import MicMap, read_mic
|
|
50
|
+
|
|
51
|
+
__version__ = "0.3.0"
|
|
52
|
+
__all__ = [
|
|
53
|
+
"MicMap", "read_mic", "GrainList", "read_grains", "ff", "laue",
|
|
54
|
+
"LaueSolutions", "LaueSpots", "read_solutions", "read_spots",
|
|
55
|
+
"read_validated", "ipf_rgb", "ipf_rgb_from_matrix", "direction_rgb",
|
|
56
|
+
"sym_matrices", "laue_class",
|
|
57
|
+
"CUBIC", "HEXAGONAL", "orientation_map", "confidence_map", "grain_map",
|
|
58
|
+
"grain_labels", "compare_maps", "TRUST_FLOOR", "__version__",
|
|
59
|
+
]
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""``midas-plot`` — one-shot reconstruction figures from the shell."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv=None) -> int:
|
|
9
|
+
ap = argparse.ArgumentParser(
|
|
10
|
+
prog="midas-plot",
|
|
11
|
+
description="Standard MIDAS reconstruction maps (orientation, "
|
|
12
|
+
"confidence, grains).")
|
|
13
|
+
ap.add_argument("mics", nargs="+",
|
|
14
|
+
help="near-field .mic file(s), a far-field Grains.csv, or a "
|
|
15
|
+
"Laue solutions.txt / validated .npz")
|
|
16
|
+
ap.add_argument("--kind", default=None,
|
|
17
|
+
help="NF: orientation | confidence | grain. "
|
|
18
|
+
"FF: summary | orientation | pole | strain | size | "
|
|
19
|
+
"completeness | 3d. "
|
|
20
|
+
"Laue: summary | orientation | pole | tilt | size | "
|
|
21
|
+
"sweep. "
|
|
22
|
+
"Default: orientation for NF, summary for FF/Laue.")
|
|
23
|
+
ap.add_argument("--plane", default="xy",
|
|
24
|
+
help="FF only: projection plane (xy, xz, yz)")
|
|
25
|
+
ap.add_argument("--hkl", default="0,0,1",
|
|
26
|
+
help="FF pole figure: crystal direction")
|
|
27
|
+
ap.add_argument("--strain-kind", default="hydrostatic",
|
|
28
|
+
help="FF strain: hydrostatic | vonmises | 11 | 33 | ...")
|
|
29
|
+
ap.add_argument("--sg", default=None,
|
|
30
|
+
help="space group; a single value, or one per .mic "
|
|
31
|
+
"comma-separated when comparing PHASES (colouring a "
|
|
32
|
+
"cubic map with hexagonal symmetry silently produces "
|
|
33
|
+
"a meaningless figure). Unset: 225 for near-field, "
|
|
34
|
+
"the file's own header for far-field, 194 for Laue.")
|
|
35
|
+
ap.add_argument("--cmin", type=float, default=0.3,
|
|
36
|
+
help="confidence cut (default 0.3, the trust floor)")
|
|
37
|
+
ap.add_argument("--axis", default="0,0,1", help="IPF sample axis")
|
|
38
|
+
ap.add_argument("--titles", default=None, help="'|'-separated")
|
|
39
|
+
ap.add_argument("--suptitle", default=None)
|
|
40
|
+
ap.add_argument("-o", "--out", default="midas_plot.png")
|
|
41
|
+
ap.add_argument("--dpi", type=int, default=145)
|
|
42
|
+
ap.add_argument("--gate", type=int, default=None,
|
|
43
|
+
help="Laue: keep solutions matching MORE than this many "
|
|
44
|
+
"reflections. No default -- it is the measured "
|
|
45
|
+
"random-orientation null for that scan, not a "
|
|
46
|
+
"universal constant.")
|
|
47
|
+
ap.add_argument("--tol", type=float, default=1.0,
|
|
48
|
+
help="Laue: grain clustering tolerance in degrees")
|
|
49
|
+
a = ap.parse_args(argv)
|
|
50
|
+
|
|
51
|
+
import matplotlib
|
|
52
|
+
matplotlib.use("Agg")
|
|
53
|
+
|
|
54
|
+
if all(_looks_like_laue(m) for m in a.mics):
|
|
55
|
+
return _run_laue(a, ap)
|
|
56
|
+
if all(_looks_like_ff(m) for m in a.mics):
|
|
57
|
+
return _run_ff(a, ap)
|
|
58
|
+
if a.kind is None:
|
|
59
|
+
a.kind = "orientation"
|
|
60
|
+
if a.kind not in ("orientation", "confidence", "grain"):
|
|
61
|
+
ap.error(f"--kind {a.kind!r} is not valid for near-field .mic input")
|
|
62
|
+
from .maps import compare_maps
|
|
63
|
+
from .mic import read_mic
|
|
64
|
+
|
|
65
|
+
mics = [read_mic(m) for m in a.mics]
|
|
66
|
+
for m in mics:
|
|
67
|
+
print(f"{m.path.name}: {m.summary()}")
|
|
68
|
+
|
|
69
|
+
sgs = [int(v) for v in str(a.sg if a.sg is not None else "225").split(",")]
|
|
70
|
+
if len(sgs) == 1:
|
|
71
|
+
sgs *= len(mics)
|
|
72
|
+
elif len(sgs) != len(mics):
|
|
73
|
+
ap.error(f"--sg has {len(sgs)} values for {len(mics)} .mic files; "
|
|
74
|
+
"give one value or one per file")
|
|
75
|
+
|
|
76
|
+
titles = a.titles.split("|") if a.titles else [None] * len(mics)
|
|
77
|
+
axis = tuple(float(v) for v in a.axis.split(","))
|
|
78
|
+
|
|
79
|
+
import matplotlib.pyplot as plt
|
|
80
|
+
from .maps import confidence_map, grain_map, orientation_map
|
|
81
|
+
fn = {"orientation": orientation_map, "confidence": confidence_map,
|
|
82
|
+
"grain": grain_map}[a.kind]
|
|
83
|
+
fig, axes = plt.subplots(1, len(mics), figsize=(6.2 * len(mics), 6.4),
|
|
84
|
+
squeeze=False)
|
|
85
|
+
for ax, m, t, sg in zip(axes[0], mics, titles, sgs):
|
|
86
|
+
kw = {}
|
|
87
|
+
if a.kind == "orientation":
|
|
88
|
+
kw = dict(space_group=sg, cmin=a.cmin, axis=axis)
|
|
89
|
+
elif a.kind == "grain":
|
|
90
|
+
kw = dict(space_group=sg, cmin=a.cmin)
|
|
91
|
+
fn(m, ax=ax, title=t, **kw)
|
|
92
|
+
if a.suptitle:
|
|
93
|
+
fig.suptitle(a.suptitle, fontsize=12)
|
|
94
|
+
# `kind` (bare) used to be referenced here; it is only ever bound in the FF
|
|
95
|
+
# branch, so every near-field CLI run raised NameError after doing all the
|
|
96
|
+
# work and before writing the file. No test covered the CLI path.
|
|
97
|
+
fig.tight_layout()
|
|
98
|
+
fig.savefig(a.out, dpi=a.dpi, bbox_inches="tight")
|
|
99
|
+
print(f"wrote {Path(a.out).resolve()}")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _looks_like_ff(path) -> bool:
|
|
104
|
+
"""Far-field Grains.csv, by content not by filename.
|
|
105
|
+
|
|
106
|
+
Users rename these constantly (Grains_layer1.csv, au3_grains.csv), so sniff
|
|
107
|
+
for the header MIDAS actually writes instead of matching a name.
|
|
108
|
+
"""
|
|
109
|
+
p = Path(path)
|
|
110
|
+
if not p.is_file():
|
|
111
|
+
return False
|
|
112
|
+
try:
|
|
113
|
+
with p.open() as fh:
|
|
114
|
+
for _ in range(40):
|
|
115
|
+
line = fh.readline()
|
|
116
|
+
if not line:
|
|
117
|
+
break
|
|
118
|
+
if line.startswith("%NumGrains") or "\tO11\t" in line:
|
|
119
|
+
return True
|
|
120
|
+
except OSError:
|
|
121
|
+
return False
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _looks_like_laue(path) -> bool:
|
|
126
|
+
"""Laue solutions.txt or a validated .npz, by content not by filename."""
|
|
127
|
+
p = Path(path)
|
|
128
|
+
if not p.is_file():
|
|
129
|
+
return False
|
|
130
|
+
if p.suffix == ".npz":
|
|
131
|
+
try:
|
|
132
|
+
import numpy as np
|
|
133
|
+
with np.load(p, allow_pickle=True) as d:
|
|
134
|
+
return {"oms", "X", "Z", "nhit"} <= set(d.files)
|
|
135
|
+
except Exception:
|
|
136
|
+
return False
|
|
137
|
+
try:
|
|
138
|
+
with p.open() as fh:
|
|
139
|
+
head = fh.readline()
|
|
140
|
+
except OSError:
|
|
141
|
+
return False
|
|
142
|
+
return head.startswith("%ImageNr") and "OrientMatrix0" in head
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _run_laue(a, ap) -> int:
|
|
146
|
+
"""Laue plotting branch."""
|
|
147
|
+
import matplotlib.pyplot as plt
|
|
148
|
+
|
|
149
|
+
from . import laue
|
|
150
|
+
from .solutions import read_solutions, read_validated
|
|
151
|
+
|
|
152
|
+
kind = a.kind or "summary"
|
|
153
|
+
if a.sg is None:
|
|
154
|
+
sg = 194
|
|
155
|
+
print("note: --sg not given, using 194 (hexagonal). The wrong symmetry "
|
|
156
|
+
"silently changes grain counts, so pass it for another phase.")
|
|
157
|
+
else:
|
|
158
|
+
sg = int(str(a.sg).split(",")[0])
|
|
159
|
+
hkl = tuple(float(v) for v in a.hkl.split(","))
|
|
160
|
+
|
|
161
|
+
sols = [read_validated(m) if str(m).endswith(".npz") else read_solutions(m)
|
|
162
|
+
for m in a.mics]
|
|
163
|
+
for s in sols:
|
|
164
|
+
print(f"{Path(s.path).name}: {s.summary()}")
|
|
165
|
+
if a.gate is not None:
|
|
166
|
+
sols = [s.gate(a.gate) for s in sols]
|
|
167
|
+
for s in sols:
|
|
168
|
+
print(f" after gate >{a.gate}: {len(s)} solutions")
|
|
169
|
+
else:
|
|
170
|
+
print("note: no --gate given, so every solution is plotted including "
|
|
171
|
+
"ones a randomly oriented crystal could produce.")
|
|
172
|
+
|
|
173
|
+
if kind == "summary":
|
|
174
|
+
if len(sols) != 1:
|
|
175
|
+
ap.error("--kind summary takes exactly one Laue input")
|
|
176
|
+
fig = laue.summary(sols[0], tolerance=a.tol, space_group=sg, hkl=hkl)
|
|
177
|
+
else:
|
|
178
|
+
fig, axes = plt.subplots(1, len(sols),
|
|
179
|
+
figsize=(6.2 * len(sols), 5.4), squeeze=False)
|
|
180
|
+
for ax, s in zip(axes[0], sols):
|
|
181
|
+
if kind == "orientation":
|
|
182
|
+
laue.orientation_map(s, ax, hkl=hkl)
|
|
183
|
+
elif kind in ("pole", "tilt", "size"):
|
|
184
|
+
c = laue.cluster(s, a.tol, space_group=sg)
|
|
185
|
+
reps = c.representatives(s.orient_mat)
|
|
186
|
+
if kind == "pole":
|
|
187
|
+
laue.pole_figure(reps, ax, hkl=hkl)
|
|
188
|
+
elif kind == "tilt":
|
|
189
|
+
laue.tilt_histogram(reps, ax, hkl=hkl)
|
|
190
|
+
else:
|
|
191
|
+
laue.grain_size_distribution(c, ax)
|
|
192
|
+
elif kind == "sweep":
|
|
193
|
+
laue.tolerance_sweep(s, ax, space_group=sg)
|
|
194
|
+
else:
|
|
195
|
+
ap.error(f"--kind {kind!r} is not valid for Laue input; use "
|
|
196
|
+
"summary, orientation, pole, tilt, size or sweep")
|
|
197
|
+
fig.tight_layout()
|
|
198
|
+
if a.suptitle:
|
|
199
|
+
fig.suptitle(a.suptitle, fontsize=12)
|
|
200
|
+
fig.savefig(a.out, dpi=a.dpi, bbox_inches="tight")
|
|
201
|
+
print(f"wrote {Path(a.out).resolve()}")
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _run_ff(a, ap) -> int:
|
|
206
|
+
"""Far-field plotting branch."""
|
|
207
|
+
import matplotlib.pyplot as plt
|
|
208
|
+
|
|
209
|
+
from . import ff
|
|
210
|
+
from .grains import read_grains
|
|
211
|
+
|
|
212
|
+
kind = a.kind or "summary"
|
|
213
|
+
axis = tuple(float(v) for v in a.axis.split(","))
|
|
214
|
+
# Unset means "use the file's own header" -- Grains.csv states its space
|
|
215
|
+
# group, and overriding it with a default would colour a hexagonal sample
|
|
216
|
+
# through the cubic triangle and produce a plausible, wrong figure.
|
|
217
|
+
sg = None if a.sg in (None, "", "auto") else int(str(a.sg).split(",")[0])
|
|
218
|
+
|
|
219
|
+
grains = [read_grains(m) for m in a.mics]
|
|
220
|
+
for g in grains:
|
|
221
|
+
print(f"{g.path.name}: {len(g)} grains, space group "
|
|
222
|
+
f"{g.space_group if sg is None else sg}")
|
|
223
|
+
|
|
224
|
+
if kind == "summary":
|
|
225
|
+
if len(grains) != 1:
|
|
226
|
+
ap.error("--kind summary takes exactly one Grains.csv")
|
|
227
|
+
fig = ff.summary(grains[0], space_group=sg, cmin=a.cmin, axis=axis)
|
|
228
|
+
else:
|
|
229
|
+
fns = {
|
|
230
|
+
"orientation": lambda g, ax: ff.grain_map(
|
|
231
|
+
g, ax, plane=a.plane, space_group=sg, axis=axis, cmin=a.cmin),
|
|
232
|
+
"pole": lambda g, ax: ff.pole_figure(
|
|
233
|
+
g, ax, hkl=tuple(float(v) for v in a.hkl.split(",")),
|
|
234
|
+
space_group=sg, cmin=a.cmin, axis=axis),
|
|
235
|
+
"strain": lambda g, ax: ff.strain_map(
|
|
236
|
+
g, ax, kind=a.strain_kind, plane=a.plane, cmin=a.cmin),
|
|
237
|
+
"size": lambda g, ax: ff.grain_size_distribution(g, ax, cmin=a.cmin),
|
|
238
|
+
"completeness": lambda g, ax: ff.completeness_hist(g, ax),
|
|
239
|
+
}
|
|
240
|
+
if kind == "3d":
|
|
241
|
+
fig = plt.figure(figsize=(6.6 * len(grains), 6.0))
|
|
242
|
+
for k, g in enumerate(grains):
|
|
243
|
+
ax = fig.add_subplot(1, len(grains), k + 1, projection="3d")
|
|
244
|
+
ff.grain_map_3d(g, ax, space_group=sg, axis=axis, cmin=a.cmin)
|
|
245
|
+
elif kind in fns:
|
|
246
|
+
fig, axes = plt.subplots(1, len(grains),
|
|
247
|
+
figsize=(6.2 * len(grains), 5.6),
|
|
248
|
+
squeeze=False)
|
|
249
|
+
for ax, g in zip(axes[0], grains):
|
|
250
|
+
fns[kind](g, ax)
|
|
251
|
+
else:
|
|
252
|
+
ap.error(f"--kind {kind!r} is not valid for far-field input; use "
|
|
253
|
+
"summary, orientation, pole, strain, size, completeness "
|
|
254
|
+
"or 3d")
|
|
255
|
+
if a.suptitle:
|
|
256
|
+
fig.suptitle(a.suptitle, fontsize=12)
|
|
257
|
+
fig.tight_layout()
|
|
258
|
+
fig.savefig(a.out, dpi=a.dpi, bbox_inches="tight")
|
|
259
|
+
print(f"wrote {Path(a.out).resolve()}")
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
raise SystemExit(main())
|