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.
Files changed (69) hide show
  1. de_ebsd/__init__.py +76 -0
  2. de_ebsd/core/__init__.py +23 -0
  3. de_ebsd/core/constants.py +81 -0
  4. de_ebsd/core/device.py +12 -0
  5. de_ebsd/core/geometry.py +67 -0
  6. de_ebsd/core/lambert.py +148 -0
  7. de_ebsd/core/rotation.py +139 -0
  8. de_ebsd/crystal/__init__.py +22 -0
  9. de_ebsd/crystal/data/__init__.py +0 -0
  10. de_ebsd/crystal/data/element_properties.py +29 -0
  11. de_ebsd/crystal/data/scattering_tables.py +414 -0
  12. de_ebsd/crystal/lattice.py +517 -0
  13. de_ebsd/crystal/scattering.py +339 -0
  14. de_ebsd/crystal/scattering_torch.py +233 -0
  15. de_ebsd/crystal/structure_factors.py +291 -0
  16. de_ebsd/diffraction/__init__.py +45 -0
  17. de_ebsd/diffraction/backends.py +318 -0
  18. de_ebsd/diffraction/bethe.py +28 -0
  19. de_ebsd/diffraction/kinematic.py +389 -0
  20. de_ebsd/diffraction/kvectors.py +120 -0
  21. de_ebsd/diffraction/prism.py +204 -0
  22. de_ebsd/diffraction/reflections.py +234 -0
  23. de_ebsd/index/__init__.py +61 -0
  24. de_ebsd/index/_kernels.py +222 -0
  25. de_ebsd/index/backproject.py +171 -0
  26. de_ebsd/index/crosscorr.py +654 -0
  27. de_ebsd/index/dictionary.py +362 -0
  28. de_ebsd/index/io.py +324 -0
  29. de_ebsd/index/live/__init__.py +5 -0
  30. de_ebsd/index/live/background.py +103 -0
  31. de_ebsd/index/live/calibration.py +70 -0
  32. de_ebsd/index/live/common.py +44 -0
  33. de_ebsd/index/live/deformation.py +289 -0
  34. de_ebsd/index/live/indexer.py +386 -0
  35. de_ebsd/index/preprocess.py +195 -0
  36. de_ebsd/index/refine/__init__.py +7 -0
  37. de_ebsd/index/refine/highres.py +479 -0
  38. de_ebsd/index/refine/levenberg.py +33 -0
  39. de_ebsd/index/refine/orientation.py +367 -0
  40. de_ebsd/index/refine/pattern_center.py +356 -0
  41. de_ebsd/index/spherical/__init__.py +41 -0
  42. de_ebsd/index/spherical/correlation.py +106 -0
  43. de_ebsd/index/spherical/harmonics.py +311 -0
  44. de_ebsd/index/spherical/indexer.py +345 -0
  45. de_ebsd/index/spherical/wigner.py +190 -0
  46. de_ebsd/sim/__init__.py +20 -0
  47. de_ebsd/sim/detector.py +145 -0
  48. de_ebsd/sim/masterpattern.py +231 -0
  49. de_ebsd/sim/microscope.py +93 -0
  50. de_ebsd/sim/montecarlo/__init__.py +19 -0
  51. de_ebsd/sim/montecarlo/depth.py +167 -0
  52. de_ebsd/sim/montecarlo/transport.py +532 -0
  53. de_ebsd/sim/simulator.py +291 -0
  54. de_ebsd/viz/__init__.py +53 -0
  55. de_ebsd/viz/camera.py +56 -0
  56. de_ebsd/viz/depth_widget.js +76 -0
  57. de_ebsd/viz/explorer.py +647 -0
  58. de_ebsd/viz/figures.py +67 -0
  59. de_ebsd/viz/maps.py +277 -0
  60. de_ebsd/viz/patterns.py +164 -0
  61. de_ebsd/viz/setup_widget.js +171 -0
  62. de_ebsd/viz/views.py +125 -0
  63. de_ebsd/viz/widgets.py +191 -0
  64. de_ebsd-0.2.0.dist-info/METADATA +251 -0
  65. de_ebsd-0.2.0.dist-info/RECORD +69 -0
  66. de_ebsd-0.2.0.dist-info/WHEEL +5 -0
  67. de_ebsd-0.2.0.dist-info/licenses/LICENSE +674 -0
  68. de_ebsd-0.2.0.dist-info/licenses/NOTICE +44 -0
  69. 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")