de-ebsd 0.2.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.
- de_ebsd/__init__.py +76 -0
- de_ebsd/core/__init__.py +23 -0
- de_ebsd/core/constants.py +81 -0
- de_ebsd/core/device.py +12 -0
- de_ebsd/core/geometry.py +67 -0
- de_ebsd/core/lambert.py +148 -0
- de_ebsd/core/rotation.py +139 -0
- de_ebsd/crystal/__init__.py +22 -0
- de_ebsd/crystal/data/__init__.py +0 -0
- de_ebsd/crystal/data/element_properties.py +29 -0
- de_ebsd/crystal/data/scattering_tables.py +414 -0
- de_ebsd/crystal/lattice.py +517 -0
- de_ebsd/crystal/scattering.py +339 -0
- de_ebsd/crystal/scattering_torch.py +233 -0
- de_ebsd/crystal/structure_factors.py +291 -0
- de_ebsd/diffraction/__init__.py +45 -0
- de_ebsd/diffraction/backends.py +318 -0
- de_ebsd/diffraction/bethe.py +28 -0
- de_ebsd/diffraction/kinematic.py +389 -0
- de_ebsd/diffraction/kvectors.py +120 -0
- de_ebsd/diffraction/prism.py +204 -0
- de_ebsd/diffraction/reflections.py +234 -0
- de_ebsd/index/__init__.py +61 -0
- de_ebsd/index/_kernels.py +222 -0
- de_ebsd/index/backproject.py +171 -0
- de_ebsd/index/crosscorr.py +654 -0
- de_ebsd/index/dictionary.py +362 -0
- de_ebsd/index/io.py +324 -0
- de_ebsd/index/live/__init__.py +5 -0
- de_ebsd/index/live/background.py +103 -0
- de_ebsd/index/live/calibration.py +70 -0
- de_ebsd/index/live/common.py +44 -0
- de_ebsd/index/live/deformation.py +289 -0
- de_ebsd/index/live/indexer.py +386 -0
- de_ebsd/index/preprocess.py +195 -0
- de_ebsd/index/refine/__init__.py +7 -0
- de_ebsd/index/refine/highres.py +479 -0
- de_ebsd/index/refine/levenberg.py +33 -0
- de_ebsd/index/refine/orientation.py +367 -0
- de_ebsd/index/refine/pattern_center.py +356 -0
- de_ebsd/index/spherical/__init__.py +41 -0
- de_ebsd/index/spherical/correlation.py +106 -0
- de_ebsd/index/spherical/harmonics.py +311 -0
- de_ebsd/index/spherical/indexer.py +345 -0
- de_ebsd/index/spherical/wigner.py +190 -0
- de_ebsd/sim/__init__.py +20 -0
- de_ebsd/sim/detector.py +145 -0
- de_ebsd/sim/masterpattern.py +231 -0
- de_ebsd/sim/microscope.py +93 -0
- de_ebsd/sim/montecarlo/__init__.py +19 -0
- de_ebsd/sim/montecarlo/depth.py +167 -0
- de_ebsd/sim/montecarlo/transport.py +532 -0
- de_ebsd/sim/simulator.py +291 -0
- de_ebsd/viz/__init__.py +53 -0
- de_ebsd/viz/camera.py +56 -0
- de_ebsd/viz/depth_widget.js +76 -0
- de_ebsd/viz/explorer.py +647 -0
- de_ebsd/viz/figures.py +67 -0
- de_ebsd/viz/maps.py +277 -0
- de_ebsd/viz/patterns.py +164 -0
- de_ebsd/viz/setup_widget.js +171 -0
- de_ebsd/viz/views.py +125 -0
- de_ebsd/viz/widgets.py +191 -0
- de_ebsd-0.2.0.dist-info/METADATA +251 -0
- de_ebsd-0.2.0.dist-info/RECORD +69 -0
- de_ebsd-0.2.0.dist-info/WHEEL +5 -0
- de_ebsd-0.2.0.dist-info/licenses/LICENSE +674 -0
- de_ebsd-0.2.0.dist-info/licenses/NOTICE +44 -0
- de_ebsd-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""Unit cells: lattice parameters, symmetry expansion, and metric tensors.
|
|
2
|
+
|
|
3
|
+
Space-group symmetry operations come from :mod:`diffpy.structure`. The Cartesian
|
|
4
|
+
crystal frame and the direct and reciprocal structure matrices follow EMsoft's
|
|
5
|
+
``mod_crystallography.f90``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from diffpy.structure.spacegroups import GetSpaceGroup
|
|
16
|
+
|
|
17
|
+
from de_ebsd.core.constants import C_AVOGADRO
|
|
18
|
+
from de_ebsd.crystal.scattering import atomic_number, atomic_weight
|
|
19
|
+
|
|
20
|
+
__all__ = ["AtomSite", "Crystal"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class AtomSite:
|
|
25
|
+
"""One site of the asymmetric unit.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
element
|
|
30
|
+
Element symbol, e.g. ``"Si"``.
|
|
31
|
+
position
|
|
32
|
+
Fractional coordinates (x, y, z) in the unit cell.
|
|
33
|
+
occupancy
|
|
34
|
+
Site occupancy in [0, 1].
|
|
35
|
+
debye_waller
|
|
36
|
+
Isotropic Debye-Waller factor B in nm^2 (EMsoft convention;
|
|
37
|
+
0.005 nm^2 = 0.5 A^2 is a reasonable room-temperature default).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
element: str
|
|
41
|
+
position: tuple[float, float, float]
|
|
42
|
+
occupancy: float = 1.0
|
|
43
|
+
debye_waller: float = 0.005
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Crystal:
|
|
47
|
+
"""A crystal defined by its lattice, space group, and asymmetric unit.
|
|
48
|
+
|
|
49
|
+
Prefer the :meth:`from_parameters` constructor::
|
|
50
|
+
|
|
51
|
+
crystal = Crystal.from_parameters(
|
|
52
|
+
a=0.5431, space_group=227, atoms=[("Si", (0.125, 0.125, 0.125))]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
a, b, c
|
|
58
|
+
Lattice parameters in nm.
|
|
59
|
+
alpha, beta, gamma
|
|
60
|
+
Lattice angles in degrees.
|
|
61
|
+
space_group
|
|
62
|
+
International space-group number (1-230).
|
|
63
|
+
sites
|
|
64
|
+
The asymmetric unit as a sequence of :class:`AtomSite`.
|
|
65
|
+
origin
|
|
66
|
+
Origin choice for the 24 centrosymmetric space groups that the
|
|
67
|
+
International Tables list with two origins (e.g. Fd-3m). The default,
|
|
68
|
+
2, places the origin on an inversion center (the modern standard used
|
|
69
|
+
by EMsoft and kikuchipy reference data); pass 1 for the tabulated
|
|
70
|
+
diffpy setting. The choice-2 operators are derived by conjugating the
|
|
71
|
+
tabulated operators with the smallest inversion-center shift; this is
|
|
72
|
+
verified for the cubic two-origin groups -- for other two-origin
|
|
73
|
+
groups, confirm the convention against the International Tables.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
a: float,
|
|
79
|
+
b: float,
|
|
80
|
+
c: float,
|
|
81
|
+
alpha: float,
|
|
82
|
+
beta: float,
|
|
83
|
+
gamma: float,
|
|
84
|
+
space_group: int,
|
|
85
|
+
sites: Sequence[AtomSite],
|
|
86
|
+
origin: int = 2,
|
|
87
|
+
) -> None:
|
|
88
|
+
if not 1 <= int(space_group) <= 230:
|
|
89
|
+
raise ValueError(f"space_group must be in 1..230, got {space_group}")
|
|
90
|
+
if origin not in (1, 2):
|
|
91
|
+
raise ValueError("origin must be 1 or 2")
|
|
92
|
+
if len(sites) == 0:
|
|
93
|
+
raise ValueError("at least one atom site is required")
|
|
94
|
+
self.a, self.b, self.c = float(a), float(b), float(c)
|
|
95
|
+
self.alpha, self.beta, self.gamma = float(alpha), float(beta), float(gamma)
|
|
96
|
+
self.space_group = int(space_group)
|
|
97
|
+
self.origin = int(origin)
|
|
98
|
+
self.sites = tuple(sites)
|
|
99
|
+
self._spacegroup = GetSpaceGroup(self.space_group)
|
|
100
|
+
self._symops = _symmetry_operations(self._spacegroup, self.origin)
|
|
101
|
+
self._positions = tuple(
|
|
102
|
+
_expand_orbit(self._symops, np.asarray(s.position, dtype=np.float64))
|
|
103
|
+
for s in self.sites
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_parameters(
|
|
108
|
+
cls,
|
|
109
|
+
a: float,
|
|
110
|
+
b: float | None = None,
|
|
111
|
+
c: float | None = None,
|
|
112
|
+
alpha: float = 90.0,
|
|
113
|
+
beta: float = 90.0,
|
|
114
|
+
gamma: float = 90.0,
|
|
115
|
+
space_group: int = 1,
|
|
116
|
+
atoms: Sequence[AtomSite | tuple] = (),
|
|
117
|
+
debye_waller: float = 0.005,
|
|
118
|
+
) -> Crystal:
|
|
119
|
+
"""Create a crystal from lattice parameters and a simple atom list.
|
|
120
|
+
|
|
121
|
+
``atoms`` entries may be :class:`AtomSite` objects or plain tuples
|
|
122
|
+
``(element, (x, y, z))`` / ``(element, (x, y, z), occupancy)``;
|
|
123
|
+
tuples get the shared ``debye_waller`` default.
|
|
124
|
+
"""
|
|
125
|
+
sites: list[AtomSite] = []
|
|
126
|
+
for atom in atoms:
|
|
127
|
+
if isinstance(atom, AtomSite):
|
|
128
|
+
sites.append(atom)
|
|
129
|
+
else:
|
|
130
|
+
element, position, *rest = atom
|
|
131
|
+
occupancy = rest[0] if rest else 1.0
|
|
132
|
+
sites.append(
|
|
133
|
+
AtomSite(
|
|
134
|
+
element=element,
|
|
135
|
+
position=tuple(float(p) for p in position),
|
|
136
|
+
occupancy=float(occupancy),
|
|
137
|
+
debye_waller=float(debye_waller),
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
return cls(
|
|
141
|
+
a=a,
|
|
142
|
+
b=a if b is None else b,
|
|
143
|
+
c=a if c is None else c,
|
|
144
|
+
alpha=alpha,
|
|
145
|
+
beta=beta,
|
|
146
|
+
gamma=gamma,
|
|
147
|
+
space_group=space_group,
|
|
148
|
+
sites=sites,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def from_cif(
|
|
153
|
+
cls,
|
|
154
|
+
path: str,
|
|
155
|
+
origin: int | None = None,
|
|
156
|
+
debye_waller: float = 0.005,
|
|
157
|
+
) -> Crystal:
|
|
158
|
+
"""Create a crystal from a CIF file.
|
|
159
|
+
|
|
160
|
+
Reads only the **asymmetric unit**; the symmetry expansion is this class's
|
|
161
|
+
own job, so passing the expanded cell that ``diffpy.structure.loadStructure``
|
|
162
|
+
returns would double-count every atom.
|
|
163
|
+
|
|
164
|
+
CIF lengths are Angstrom and are converted to nm. An ``U_iso`` column becomes
|
|
165
|
+
the Debye-Waller factor as ``B = 8 pi^2 U_iso``, converted to nm^2; sites
|
|
166
|
+
without one (or with ``U_iso <= 0``) fall back to ``debye_waller``. Ionic
|
|
167
|
+
``_atom_site_type_symbol`` entries are reduced to the element (``"Si4+"`` ->
|
|
168
|
+
``"Si"``), since the scattering tables are per element.
|
|
169
|
+
|
|
170
|
+
Parameters
|
|
171
|
+
----------
|
|
172
|
+
path
|
|
173
|
+
Path to the CIF file. Only its first data block is read.
|
|
174
|
+
origin
|
|
175
|
+
Origin choice for the two-origin space groups. ``None`` (default) reads
|
|
176
|
+
``_space_group_IT_coordinate_system_code`` from the file and falls back to
|
|
177
|
+
2 -- the modern standard used by EMsoft and kikuchipy. **Getting this
|
|
178
|
+
wrong silently produces a plausible but incorrect master pattern**, so
|
|
179
|
+
pass it explicitly when the file does not record it and the group has two
|
|
180
|
+
origins (:attr:`has_two_origins`).
|
|
181
|
+
debye_waller
|
|
182
|
+
Fallback isotropic B in nm^2 for sites with no usable ``U_iso``.
|
|
183
|
+
|
|
184
|
+
Returns
|
|
185
|
+
-------
|
|
186
|
+
Crystal
|
|
187
|
+
"""
|
|
188
|
+
from diffpy.structure.parsers import getParser
|
|
189
|
+
|
|
190
|
+
parser = getParser("cif")
|
|
191
|
+
parser.parseFile(str(path))
|
|
192
|
+
lattice = parser.asymmetric_unit[0].lattice if parser.asymmetric_unit else None
|
|
193
|
+
if lattice is None:
|
|
194
|
+
raise ValueError(f"{path}: no atom sites found in the CIF")
|
|
195
|
+
|
|
196
|
+
if origin is None:
|
|
197
|
+
origin = _cif_origin_choice(parser)
|
|
198
|
+
|
|
199
|
+
sites: list[AtomSite] = []
|
|
200
|
+
for atom in parser.asymmetric_unit:
|
|
201
|
+
b_nm2 = debye_waller
|
|
202
|
+
u_iso = float(getattr(atom, "Uisoequiv", 0.0) or 0.0)
|
|
203
|
+
if u_iso > 0.0:
|
|
204
|
+
b_nm2 = 8.0 * np.pi**2 * u_iso / 100.0 # 8 pi^2 U [A^2] -> B [nm^2]
|
|
205
|
+
sites.append(
|
|
206
|
+
AtomSite(
|
|
207
|
+
element=_element_symbol(atom.element),
|
|
208
|
+
position=tuple(float(x) for x in atom.xyz),
|
|
209
|
+
occupancy=float(atom.occupancy),
|
|
210
|
+
debye_waller=b_nm2,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
return cls(
|
|
215
|
+
a=lattice.a / 10.0,
|
|
216
|
+
b=lattice.b / 10.0,
|
|
217
|
+
c=lattice.c / 10.0,
|
|
218
|
+
alpha=lattice.alpha,
|
|
219
|
+
beta=lattice.beta,
|
|
220
|
+
gamma=lattice.gamma,
|
|
221
|
+
space_group=parser.spacegroup.number,
|
|
222
|
+
sites=sites,
|
|
223
|
+
origin=origin,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def has_two_origins(self) -> bool:
|
|
228
|
+
"""Whether this space group is one the Tables list with two origin choices.
|
|
229
|
+
|
|
230
|
+
When ``True``, :attr:`origin` changes the atom positions and therefore the
|
|
231
|
+
master pattern, so a CIF that does not record its choice is ambiguous.
|
|
232
|
+
"""
|
|
233
|
+
return self.space_group in _TWO_ORIGIN_GROUPS
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def direct_metric(self) -> np.ndarray:
|
|
237
|
+
"""Direct metric tensor g_ij in nm^2."""
|
|
238
|
+
a, b, c = self.a, self.b, self.c
|
|
239
|
+
ca, cb, cg = (np.cos(np.deg2rad(x)) for x in (self.alpha, self.beta, self.gamma))
|
|
240
|
+
return np.array(
|
|
241
|
+
[
|
|
242
|
+
[a * a, a * b * cg, a * c * cb],
|
|
243
|
+
[a * b * cg, b * b, b * c * ca],
|
|
244
|
+
[a * c * cb, b * c * ca, c * c],
|
|
245
|
+
]
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def reciprocal_metric(self) -> np.ndarray:
|
|
250
|
+
"""Reciprocal metric tensor g*_ij in nm^-2."""
|
|
251
|
+
return np.linalg.inv(self.direct_metric)
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def volume(self) -> float:
|
|
255
|
+
"""Unit-cell volume in nm^3."""
|
|
256
|
+
return float(np.sqrt(np.linalg.det(self.direct_metric)))
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def direct_structure_matrix(self) -> np.ndarray:
|
|
260
|
+
"""Columns are the basis vectors a, b, c in the Cartesian crystal frame (nm)."""
|
|
261
|
+
a, b, c = self.a, self.b, self.c
|
|
262
|
+
ca, cb, cg = (np.cos(np.deg2rad(x)) for x in (self.alpha, self.beta, self.gamma))
|
|
263
|
+
sg = np.sin(np.deg2rad(self.gamma))
|
|
264
|
+
vol = self.volume
|
|
265
|
+
return np.array(
|
|
266
|
+
[
|
|
267
|
+
[a, b * cg, c * cb],
|
|
268
|
+
[0.0, b * sg, -c * (cb * cg - ca) / sg],
|
|
269
|
+
[0.0, 0.0, vol / (a * b * sg)],
|
|
270
|
+
]
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def reciprocal_structure_matrix(self) -> np.ndarray:
|
|
275
|
+
"""Columns are the reciprocal basis vectors a*, b*, c* in the Cartesian frame (nm^-1)."""
|
|
276
|
+
return np.linalg.inv(self.direct_structure_matrix).T
|
|
277
|
+
|
|
278
|
+
def g_length(self, hkl: np.ndarray) -> np.ndarray:
|
|
279
|
+
"""Length ``|g|`` in nm^-1 for Miller indices ``hkl`` of shape (..., 3)."""
|
|
280
|
+
hkl = np.asarray(hkl, dtype=np.float64)
|
|
281
|
+
gstar = self.reciprocal_metric
|
|
282
|
+
return np.sqrt(np.einsum("...i,ij,...j->...", hkl, gstar, hkl))
|
|
283
|
+
|
|
284
|
+
def d_spacing(self, hkl: np.ndarray) -> np.ndarray:
|
|
285
|
+
"""Interplanar spacing d in nm for Miller indices ``hkl``."""
|
|
286
|
+
return 1.0 / self.g_length(hkl)
|
|
287
|
+
|
|
288
|
+
def g_cartesian(self, hkl: np.ndarray) -> np.ndarray:
|
|
289
|
+
"""Reciprocal-lattice vectors in the Cartesian crystal frame (nm^-1)."""
|
|
290
|
+
hkl = np.asarray(hkl, dtype=np.float64)
|
|
291
|
+
return hkl @ self.reciprocal_structure_matrix.T
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def positions(self) -> tuple[np.ndarray, ...]:
|
|
295
|
+
"""Expanded orbit (n_m, 3) of fractional positions, one array per site."""
|
|
296
|
+
return self._positions
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def multiplicities(self) -> tuple[int, ...]:
|
|
300
|
+
"""Number of equivalent atoms per asymmetric-unit site."""
|
|
301
|
+
return tuple(len(p) for p in self._positions)
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def _site_counts(self) -> np.ndarray:
|
|
305
|
+
return np.array(
|
|
306
|
+
[s.occupancy * m for s, m in zip(self.sites, self.multiplicities, strict=True)]
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def mean_atomic_number(self) -> float:
|
|
311
|
+
"""Composition- (number-) averaged atomic number, sum(n_i Z_i) / sum(n_i)."""
|
|
312
|
+
counts = self._site_counts
|
|
313
|
+
z = np.array([atomic_number(s.element) for s in self.sites], dtype=np.float64)
|
|
314
|
+
return float((counts * z).sum() / counts.sum())
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def mean_atomic_weight(self) -> float:
|
|
318
|
+
"""Composition- (number-) averaged atomic weight in g/mol."""
|
|
319
|
+
counts = self._site_counts
|
|
320
|
+
a = np.array([atomic_weight(s.element) for s in self.sites])
|
|
321
|
+
return float((counts * a).sum() / counts.sum())
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def density(self) -> float:
|
|
325
|
+
"""Mass density in g/cm^3 from the unit-cell contents and volume."""
|
|
326
|
+
counts = self._site_counts
|
|
327
|
+
a = np.array([atomic_weight(s.element) for s in self.sites])
|
|
328
|
+
cell_mass_g = (counts * a).sum() / C_AVOGADRO
|
|
329
|
+
volume_cm3 = self.volume * 1e-21 # nm^3 -> cm^3
|
|
330
|
+
return float(cell_mass_g / volume_cm3)
|
|
331
|
+
|
|
332
|
+
@property
|
|
333
|
+
def centering(self) -> str:
|
|
334
|
+
"""Lattice centering symbol (first letter of the space-group symbol)."""
|
|
335
|
+
return self._spacegroup.short_name[0]
|
|
336
|
+
|
|
337
|
+
def allowed_by_centering(self, hkl: np.ndarray) -> np.ndarray:
|
|
338
|
+
"""Boolean mask of reflections allowed by the lattice centering.
|
|
339
|
+
|
|
340
|
+
Mirrors EMsoft ``IsGAllowed``: only centering absences are tested;
|
|
341
|
+
glide/screw absences follow automatically from ``|U_g| = 0``.
|
|
342
|
+
"""
|
|
343
|
+
hkl = np.asarray(hkl, dtype=np.int64)
|
|
344
|
+
h, k, l = hkl[..., 0], hkl[..., 1], hkl[..., 2] # noqa: E741
|
|
345
|
+
symbol = self.centering
|
|
346
|
+
if symbol == "P":
|
|
347
|
+
return np.ones(h.shape, dtype=bool)
|
|
348
|
+
if symbol == "F":
|
|
349
|
+
return (h % 2 == k % 2) & (k % 2 == l % 2)
|
|
350
|
+
if symbol == "I":
|
|
351
|
+
return (h + k + l) % 2 == 0
|
|
352
|
+
if symbol == "A":
|
|
353
|
+
return (k + l) % 2 == 0
|
|
354
|
+
if symbol == "B":
|
|
355
|
+
return (h + l) % 2 == 0
|
|
356
|
+
if symbol == "C":
|
|
357
|
+
return (h + k) % 2 == 0
|
|
358
|
+
if symbol == "R": # obverse hexagonal setting
|
|
359
|
+
return (-h + k + l) % 3 == 0
|
|
360
|
+
raise ValueError(f"Unknown centering symbol {symbol!r}")
|
|
361
|
+
|
|
362
|
+
def show(self, title: str | None = None):
|
|
363
|
+
"""Display the expanded crystal structure as a native 3-D figure."""
|
|
364
|
+
from de_ebsd.viz.patterns import crystal_structure_figure
|
|
365
|
+
|
|
366
|
+
return crystal_structure_figure(self, title=title or "Crystal structure")
|
|
367
|
+
|
|
368
|
+
def explore(self, *, energy_kev: float = 200.0, dmin: float = 0.1, **kwargs):
|
|
369
|
+
"""Explore STEM orientations using geometric lines and kinematic spots.
|
|
370
|
+
|
|
371
|
+
Returns a :class:`~de_ebsd.viz.explorer.CrystalExplorer` with a shared
|
|
372
|
+
projection, band sphere, and crystal view. Beam energy is in keV and
|
|
373
|
+
the minimum lattice spacing ``dmin`` in nm. Further keyword arguments
|
|
374
|
+
are passed to the explorer. No master-pattern simulation is run.
|
|
375
|
+
|
|
376
|
+
Display the result in Jupyter for interactive controls, or mount its
|
|
377
|
+
native ``figure`` in an application with a live Python bridge.
|
|
378
|
+
"""
|
|
379
|
+
from de_ebsd.viz.explorer import CrystalExplorer
|
|
380
|
+
|
|
381
|
+
return CrystalExplorer(self, energy_kev=energy_kev, dmin=dmin, **kwargs)
|
|
382
|
+
|
|
383
|
+
def overview(
|
|
384
|
+
self,
|
|
385
|
+
master_pattern=None,
|
|
386
|
+
name: str | None = None,
|
|
387
|
+
*,
|
|
388
|
+
texture_height: int = 128,
|
|
389
|
+
n_grid: int = 96,
|
|
390
|
+
):
|
|
391
|
+
"""Compose phase metadata with native crystal and master figures.
|
|
392
|
+
|
|
393
|
+
The returned :class:`~de_ebsd.viz.views.PhaseOverview` displays in a
|
|
394
|
+
notebook and gives SpyDE named figures to mount with its existing
|
|
395
|
+
anyplotlib bridge. It never serializes a figure into another widget.
|
|
396
|
+
"""
|
|
397
|
+
from de_ebsd.viz.views import PhaseOverview
|
|
398
|
+
|
|
399
|
+
return PhaseOverview(
|
|
400
|
+
crystal=self,
|
|
401
|
+
master_pattern=master_pattern,
|
|
402
|
+
name=name,
|
|
403
|
+
texture_height=texture_height,
|
|
404
|
+
n_grid=n_grid,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def __repr__(self) -> str:
|
|
408
|
+
atoms = ", ".join(
|
|
409
|
+
f"{s.element}({len(p)}x)" for s, p in zip(self.sites, self._positions, strict=True)
|
|
410
|
+
)
|
|
411
|
+
return (
|
|
412
|
+
f"Crystal(a={self.a:.4f} nm, b={self.b:.4f}, c={self.c:.4f}, "
|
|
413
|
+
f"alpha={self.alpha:g}, beta={self.beta:g}, gamma={self.gamma:g}, "
|
|
414
|
+
f"space_group={self.space_group}, atoms=[{atoms}])"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _symmetry_operations(spacegroup, origin: int) -> list[tuple[np.ndarray, np.ndarray]]:
|
|
419
|
+
ops = [
|
|
420
|
+
(np.asarray(op.R, dtype=np.float64), np.asarray(op.t, dtype=np.float64))
|
|
421
|
+
for op in spacegroup.iter_symops()
|
|
422
|
+
]
|
|
423
|
+
if origin == 1:
|
|
424
|
+
return ops
|
|
425
|
+
|
|
426
|
+
inversions = [t for r, t in ops if np.allclose(r, -np.eye(3))]
|
|
427
|
+
if not inversions: # non-centrosymmetric: no origin ambiguity
|
|
428
|
+
return ops
|
|
429
|
+
# Inversion centers sit at (t + m) / 2 for integer vectors m; pick the one
|
|
430
|
+
# closest to the tabulated origin. If it is the origin itself, nothing to do.
|
|
431
|
+
candidates = []
|
|
432
|
+
for t in inversions:
|
|
433
|
+
for m in np.ndindex(2, 2, 2):
|
|
434
|
+
s = np.mod((t + np.asarray(m, dtype=np.float64)) / 2.0, 1.0)
|
|
435
|
+
dist = np.linalg.norm(np.minimum(s, 1.0 - s))
|
|
436
|
+
candidates.append((dist, s))
|
|
437
|
+
dist, shift = min(candidates, key=lambda c: c[0])
|
|
438
|
+
if dist < 1e-9:
|
|
439
|
+
return ops
|
|
440
|
+
return [(r, np.mod(t + r @ shift - shift, 1.0)) for r, t in ops]
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _expand_orbit(
|
|
444
|
+
symops: list[tuple[np.ndarray, np.ndarray]], position: np.ndarray, decimals: int = 6
|
|
445
|
+
) -> np.ndarray:
|
|
446
|
+
seen: dict[tuple, np.ndarray] = {}
|
|
447
|
+
for r, t in symops:
|
|
448
|
+
new = r @ position + t
|
|
449
|
+
new = np.mod(np.round(new, decimals), 1.0)
|
|
450
|
+
new = np.mod(new, 1.0) # fold 0.999999... rounded to 1.0 back to 0
|
|
451
|
+
seen.setdefault(tuple(new), new)
|
|
452
|
+
return np.array(list(seen.values()))
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# The space groups the International Tables list with two origin choices. For these
|
|
456
|
+
# `Crystal.origin` moves the atoms, so a CIF that does not record its choice is
|
|
457
|
+
# ambiguous -- see `Crystal.from_cif`.
|
|
458
|
+
_TWO_ORIGIN_GROUPS = frozenset(
|
|
459
|
+
{
|
|
460
|
+
48,
|
|
461
|
+
50,
|
|
462
|
+
59,
|
|
463
|
+
68,
|
|
464
|
+
70,
|
|
465
|
+
85,
|
|
466
|
+
86,
|
|
467
|
+
88,
|
|
468
|
+
125,
|
|
469
|
+
126,
|
|
470
|
+
129,
|
|
471
|
+
130,
|
|
472
|
+
133,
|
|
473
|
+
134,
|
|
474
|
+
137,
|
|
475
|
+
138,
|
|
476
|
+
141,
|
|
477
|
+
142,
|
|
478
|
+
201,
|
|
479
|
+
203,
|
|
480
|
+
222,
|
|
481
|
+
224,
|
|
482
|
+
227,
|
|
483
|
+
228,
|
|
484
|
+
}
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _cif_origin_choice(parser, default: int = 2) -> int:
|
|
489
|
+
"""Origin choice recorded in a CIF, or ``default`` when it says nothing."""
|
|
490
|
+
try:
|
|
491
|
+
block = parser.ciffile[next(iter(parser.ciffile.keys()))]
|
|
492
|
+
except Exception: # noqa: BLE001 - an unreadable block just means "unspecified"
|
|
493
|
+
return default
|
|
494
|
+
for key in (
|
|
495
|
+
"_space_group_it_coordinate_system_code",
|
|
496
|
+
"_symmetry_space_group_setting",
|
|
497
|
+
):
|
|
498
|
+
value = str(block.get(key, "")).strip()
|
|
499
|
+
if value in ("1", "2"):
|
|
500
|
+
return int(value)
|
|
501
|
+
return default
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _element_symbol(raw: str) -> str:
|
|
505
|
+
"""Element symbol from a CIF ``type_symbol``/label (``"Si4+"``, ``"Si1"`` -> ``"Si"``)."""
|
|
506
|
+
letters = re.match(r"[A-Za-z]+", str(raw).strip())
|
|
507
|
+
if letters is None:
|
|
508
|
+
raise ValueError(f"cannot read an element symbol from {raw!r}")
|
|
509
|
+
text = letters.group(0)
|
|
510
|
+
for n in (len(text), 2, 1):
|
|
511
|
+
candidate = text[:n].capitalize()
|
|
512
|
+
try:
|
|
513
|
+
atomic_number(candidate)
|
|
514
|
+
except ValueError:
|
|
515
|
+
continue
|
|
516
|
+
return candidate
|
|
517
|
+
raise ValueError(f"{raw!r} is not a known element symbol")
|