1 | """Implements the :class:`PeriodicSet` class representing a periodic |
||
2 | set defined by a motif (collection of points) and unit cell (basis) |
||
3 | modelling a crystal with a point at the center of each atom. |
||
4 | |||
5 | :class:`PeriodicSet` s are yielded by the children of |
||
6 | :class:`amd.io._Reader <.io._Reader>` and are passed to |
||
7 | :func:`PDD() <.calculate.PDD>`, :func:`AMD() <.calculate.AMD>` etc to |
||
8 | calculate the invariants of crystals. |
||
9 | """ |
||
10 | |||
11 | from __future__ import annotations |
||
12 | from typing import Optional, List, NamedTuple |
||
13 | import math |
||
14 | |||
15 | import numpy as np |
||
16 | from scipy.special import factorial |
||
17 | |||
18 | from .utils import ( |
||
19 | cellpar_to_cell, |
||
20 | cellpar_to_cell_2D, |
||
21 | random_cell, |
||
22 | cell_to_cellpar, |
||
23 | ) |
||
24 | from ._types import FloatArray, UIntArray, AtomTypeArray |
||
25 | from .globals_ import ATOMIC_MASSES, ATOMIC_NUMBERS_TO_SYMS |
||
26 | |||
27 | |||
28 | __all__ = ["PeriodicSet", "DisorderGroup", "DisorderAssembly"] |
||
29 | |||
30 | |||
31 | class DisorderGroup(NamedTuple): |
||
32 | indices: list[int] |
||
33 | occupancy: float |
||
34 | |||
35 | |||
36 | class DisorderAssembly(NamedTuple): |
||
37 | groups: list[DisorderGroup] |
||
38 | is_substitutional: bool |
||
39 | |||
40 | |||
41 | class PeriodicSet: |
||
42 | """A periodic set is a collection of points (motif) which |
||
43 | periodically repeats according to a lattice (unit cell), which can |
||
44 | model a crystal with a point in the centre of each atom. |
||
45 | |||
46 | :class:`PeriodicSet` s are yielded by the children of |
||
47 | :class:`amd.io._Reader <.io._Reader>` and are passed to |
||
48 | :func:`PDD() <.calculate.PDD>`, :func:`AMD() <.calculate.AMD>` etc to |
||
49 | calculate the invariants of crystals. |
||
50 | |||
51 | Parameters |
||
52 | ---------- |
||
53 | motif : :class:`numpy.ndarray` |
||
54 | Collection of motif points in Cartesian (orthogonal) |
||
55 | coordinates, shape ``(m, dims)``. |
||
56 | cell : :class:`numpy.ndarray` |
||
57 | Square array representing the unit cell in Cartesian |
||
58 | (orthogonal) coordinates, shape ``(dims, dims)``. Use |
||
59 | :func:`amd.cellpar_to_cell <.utils.cellpar_to_cell>` to convert |
||
60 | a vector of 6 conventional cell parameters a,b,c,α,β,γ to a |
||
61 | square matrix. |
||
62 | name : str, optional |
||
63 | Name of the periodic set. |
||
64 | asym_unit : :class:`numpy.ndarray`, optional |
||
65 | Indices of motif points constituting an asymmetric unit, so that |
||
66 | ``motif[asym_unit]`` is an asymmetric unit. If given, the motif |
||
67 | should be such that ``motif[asym_unit[i]:asym_unit[i+1]]`` are |
||
68 | points corresponding to ``motif[asym_unit[i]``. |
||
69 | multiplicities : :class:`numpy.ndarray`, optional |
||
70 | Wyckoff multiplicities of points in the asymmetric unit (the |
||
71 | number of unique sites generated under symmetries). |
||
72 | types : list, optional |
||
73 | Atomic numbers of atoms in the asymmetric unit. |
||
74 | labels : list, optional |
||
75 | Labels of atoms in the asymmetric unit. |
||
76 | disorder : list, optional |
||
77 | Contains disorder assemblies of the crystal. |
||
78 | """ |
||
79 | |||
80 | def __init__( |
||
81 | self, |
||
82 | motif: FloatArray, |
||
83 | cell: FloatArray, |
||
84 | name: Optional[str] = None, |
||
85 | asym_unit: Optional[UIntArray] = None, |
||
86 | multiplicities: Optional[UIntArray] = None, |
||
87 | types: Optional[AtomTypeArray] = None, |
||
88 | labels: Optional[List[str]] = None, |
||
89 | disorder: Optional[list[DisorderAssembly]] = None, |
||
90 | ): |
||
91 | self.motif = motif |
||
92 | self.cell = cell |
||
93 | self.name = name |
||
94 | self._asym_unit = asym_unit |
||
95 | self._multiplicities = multiplicities |
||
96 | self.types = types |
||
97 | self.labels = labels |
||
98 | self.disorder = disorder |
||
99 | |||
100 | @property |
||
101 | def ndim(self) -> int: |
||
102 | """Dimension of the ambient space.""" |
||
103 | return self.cell.shape[0] |
||
104 | |||
105 | @property |
||
106 | def m(self) -> int: |
||
107 | """Number of motif points.""" |
||
108 | return self.motif.shape[0] |
||
109 | |||
110 | @property |
||
111 | def n_asym(self) -> int: |
||
112 | """Number of points in the asymmetric unit.""" |
||
113 | if self.asym_unit is None: |
||
114 | return self.m |
||
115 | else: |
||
116 | return self.asym_unit.shape[0] |
||
117 | |||
118 | @property |
||
119 | def asym_unit(self) -> np.ndarray: |
||
120 | if self._asym_unit is None: |
||
121 | return np.arange(self.m) |
||
122 | else: |
||
123 | return self._asym_unit |
||
124 | |||
125 | @property |
||
126 | def multiplicities(self) -> np.ndarray: |
||
127 | if self._multiplicities is None: |
||
128 | return np.ones((self.m,), dtype=np.uint64) |
||
129 | else: |
||
130 | return self._multiplicities |
||
131 | |||
132 | @property |
||
133 | def occupancies(self) -> np.ndarray: |
||
134 | occs = np.ones((self.n_asym, ), dtype=np.float64) |
||
135 | if self.disorder: |
||
136 | for asm in self.disorder: |
||
137 | for grp in asm.groups: |
||
138 | occs[grp.indices] = grp.occupancy |
||
139 | return occs |
||
140 | |||
141 | def __str__(self) -> str: |
||
142 | m, n = self.motif.shape |
||
143 | m_pl = "" if m == 1 else "s" |
||
144 | n_pl = "" if n == 1 else "s" |
||
145 | name_str = f"{self.name}: " if self.name is not None else "" |
||
146 | return f"PeriodicSet({name_str}{m} point{m_pl} in {n} dimension{n_pl})" |
||
147 | |||
148 | def __repr__(self) -> str: |
||
149 | motif_str = str(self.motif).replace("\n ", "\n" + " " * 11) |
||
150 | cell_str = str(self.cell).replace("\n ", "\n" + " " * 10) |
||
151 | |||
152 | optional_attrs = [] |
||
153 | for attr in ("_asym_unit", "_multiplicities"): |
||
154 | val = getattr(self, attr) |
||
155 | if val is not None: |
||
156 | st = str(val).replace("\n ", "\n" + " " * (len(attr) + 6)) |
||
157 | optional_attrs.append(f"{attr[1:]}={st}") |
||
158 | |||
159 | for attr in ("types", "labels"): |
||
160 | val = getattr(self, attr) |
||
161 | if val is not None: |
||
162 | st = str(val).replace("\n ", "\n" + " " * (len(attr) + 6)) |
||
163 | optional_attrs.append(f"{attr}={st}") |
||
164 | |||
165 | optional_attrs_str = ",\n " if optional_attrs else "" |
||
166 | optional_attrs_str += ",\n ".join(optional_attrs) |
||
167 | |||
168 | return ( |
||
169 | f"PeriodicSet(name={self.name},\n" |
||
170 | f" motif={motif_str},\n" |
||
171 | f" cell={cell_str}{optional_attrs_str})" |
||
172 | ) |
||
173 | |||
174 | def PPC(self): |
||
175 | r"""Return the point packing coefficient (PPC) of the |
||
176 | PeriodicSet. |
||
177 | |||
178 | The PPC is a constant of any periodic set determining the |
||
179 | asymptotic behaviour of its isometry invariants. As |
||
180 | :math:`k \rightarrow \infty`, the ratio |
||
181 | :math:`\text{AMD}_k / \sqrt[n]{k}` converges to the PPC, as does |
||
182 | any row of its PDD. |
||
183 | |||
184 | For a unit cell :math:`U` and :math:`m` motif points in |
||
185 | :math:`n` dimensions, |
||
186 | |||
187 | .. math:: |
||
188 | |||
189 | \text{PPC} = \sqrt[n]{\frac{\text{Vol}[U]}{m V_n}} |
||
190 | |||
191 | where :math:`V_n` is the volume of a unit sphere in :math:`n` |
||
192 | dimensions. |
||
193 | |||
194 | Returns |
||
195 | ------- |
||
196 | ppc : float |
||
197 | The PPC of ``self``. |
||
198 | """ |
||
199 | m, n = self.motif.shape |
||
200 | t = int(n // 2) |
||
201 | if n % 2 == 0: |
||
202 | ball_vol = (np.pi ** t) / factorial(t) |
||
203 | else: |
||
204 | ball_vol = (2 * factorial(t) * (4 * np.pi) ** t) / factorial(n) |
||
205 | return (np.abs(np.linalg.det(self.cell)) / (m * ball_vol)) ** (1.0 / n) |
||
206 | |||
207 | def has_positional_disorder(self): |
||
208 | if self.disorder is not None: |
||
209 | return any(not asm.is_substitutional for asm in self.disorder) |
||
210 | return False |
||
211 | |||
212 | def has_substitutional_disorder(self): |
||
213 | if self.disorder is not None: |
||
214 | return any(asm.is_substitutional for asm in self.disorder) |
||
215 | return False |
||
216 | |||
217 | def density(self): |
||
218 | """Return the physical density of the PeriodicSet representing a |
||
219 | crystal in g/cm3. |
||
220 | |||
221 | Returns |
||
222 | ------- |
||
223 | density : float |
||
224 | The physical density of ``self`` in g/cm3. |
||
225 | """ |
||
226 | if self.types is None: |
||
227 | raise ValueError(f"PeriodicSet(name={self.name}) has no types") |
||
228 | cell_mass = sum( |
||
229 | ATOMIC_MASSES[num] * tot_occ |
||
230 | for num, tot_occ in zip(self.types, self.multiplicities * self.occupancies) |
||
231 | ) |
||
232 | cell_vol = np.linalg.det(self.cell) |
||
233 | return (cell_mass / cell_vol) * 1.66053907 |
||
234 | |||
235 | def formula(self): |
||
236 | """Return the reduced formula of the PeriodicSet representing a |
||
237 | crystal. |
||
238 | |||
239 | Returns |
||
240 | ------- |
||
241 | formula : str |
||
242 | The reduced formula of ``self``. |
||
243 | """ |
||
244 | counter = self._formula_dict() |
||
245 | counter = {ATOMIC_NUMBERS_TO_SYMS[n]: count for n, count in counter.items()} |
||
246 | return "".join( |
||
247 | f"{lab}{num:.3g}" if num != 1 else lab for lab, num in counter.items() |
||
248 | ) |
||
249 | |||
250 | def _formula_dict(self): |
||
251 | """Return a dictionary of atomic types in the form |
||
252 | {atomic_num: count}. |
||
253 | """ |
||
254 | |||
255 | if self.types is None: |
||
256 | raise ValueError(f"PeriodicSet(name={self.name}) has no types") |
||
257 | |||
258 | counter = {} |
||
259 | for num, mul, occ in zip(self.types, self.multiplicities, self.occupancies): |
||
260 | if num not in counter: |
||
261 | counter[num] = 0 |
||
262 | counter[num] += occ * mul |
||
263 | |||
264 | if 0 in counter: |
||
265 | del counter[0] |
||
266 | |||
267 | gcd = math.gcd(*(int(v) for v in counter.values() if np.mod(v, 1.0) == 0.0)) |
||
0 ignored issues
–
show
Comprehensibility
Best Practice
introduced
by
![]() Comprehensibility
Best Practice
introduced
by
|
|||
268 | if gcd == 0: |
||
269 | gcd = 1 |
||
270 | |||
271 | counter_ = {} |
||
272 | if 6 in counter: |
||
273 | counter_[6] = counter[6] / gcd |
||
274 | del counter[6] |
||
275 | if 1 in counter: |
||
276 | counter_[1] = counter[1] / gcd |
||
277 | del counter[1] |
||
278 | |||
279 | for n, count in sorted(counter.items(), key=lambda x: x[0]): |
||
280 | counter_[int(n)] = count / gcd |
||
281 | |||
282 | return counter_ |
||
283 | |||
284 | def _equal_cell_and_motif(self, other: PeriodicSet, tol: float = 1e-8) -> bool: |
||
285 | """Used for debugging. True if both 1) unit cells are (close to) |
||
286 | identical, and 2) the motifs are the same shape, and |
||
287 | every point in one motif has a (close to) identical point |
||
288 | somewhere in the other, accounting for pbc. |
||
289 | """ |
||
290 | |||
291 | if ( |
||
292 | self.cell.shape != other.cell.shape |
||
293 | or self.motif.shape != other.motif.shape |
||
294 | or not np.allclose(self.cell, other.cell) |
||
295 | ): |
||
296 | return False |
||
297 | |||
298 | cell_inv = np.linalg.inv(self.cell) |
||
299 | fm1 = np.mod(self.motif @ cell_inv, 1) |
||
300 | fm2 = np.mod(other.motif @ cell_inv, 1) |
||
301 | d1 = np.abs(fm2[:, None] - fm1) |
||
302 | d2 = np.abs(d1 - 1) |
||
303 | diffs = np.amax(np.minimum(d1, d2), axis=-1) |
||
304 | |||
305 | if not np.all( |
||
306 | (np.amin(diffs, axis=0) <= tol) | (np.amin(diffs, axis=-1) <= tol) |
||
307 | ): |
||
308 | return False |
||
309 | return True |
||
310 | |||
311 | @staticmethod |
||
312 | def cubic(scale: float = 1.0, dims: int = 3) -> PeriodicSet: |
||
313 | """Return a :class:`PeriodicSet` representing a cubic lattice.""" |
||
314 | return PeriodicSet(np.zeros((1, dims)), np.identity(dims) * scale) |
||
315 | |||
316 | @staticmethod |
||
317 | def hexagonal(scale: float = 1.0, dims: int = 3) -> PeriodicSet: |
||
318 | """Return a :class:`PeriodicSet` representing a hexagonal |
||
319 | lattice. Implemented for dimensions 2 and 3 only. |
||
320 | """ |
||
321 | |||
322 | if dims == 3: |
||
323 | cellpar = np.array([scale, scale, scale, 90.0, 90.0, 120.0]) |
||
324 | cell = cellpar_to_cell(cellpar) |
||
325 | elif dims == 2: |
||
326 | cell = cellpar_to_cell_2D(np.array([scale, scale, 60.0])) |
||
327 | else: |
||
328 | raise NotImplementedError( |
||
329 | "amd.PeriodicSet.hexagonal() only implemented for dimensions " |
||
330 | f"2 and 3, passed {dims}" |
||
331 | ) |
||
332 | return PeriodicSet(np.zeros((1, dims)), cell) |
||
333 | |||
334 | @staticmethod |
||
335 | def random( |
||
336 | n_points: int, |
||
337 | length_bounds: tuple = (1.0, 2.0), |
||
338 | angle_bounds: tuple = (60.0, 120.0), |
||
339 | dims: int = 3, |
||
340 | ) -> PeriodicSet: |
||
341 | """Return a :class:`PeriodicSet` with a chosen number of |
||
342 | randomly placed points in a random cell with edges between |
||
343 | ``length_bounds`` and angles between ``angle_bounds``. |
||
344 | Implemented for dimensions 2 and 3 only. |
||
345 | """ |
||
346 | |||
347 | if dims not in (2, 3): |
||
348 | raise NotImplementedError( |
||
349 | "amd.PeriodicSet._random only implemented for dimensions 2 " |
||
350 | f"and 3, passed {dims}" |
||
351 | ) |
||
352 | |||
353 | cell = random_cell( |
||
354 | length_bounds=length_bounds, angle_bounds=angle_bounds, dims=dims |
||
355 | ) |
||
356 | frac_motif = np.random.uniform(size=(n_points, dims)) |
||
357 | return PeriodicSet(frac_motif @ cell, cell) |
||
358 | |||
359 | def to_cif_str(self) -> str: |
||
360 | """Return a CIF string representing the PeriodicSet. Does not |
||
361 | write disorder data, atom labels or symmetries.""" |
||
362 | |||
363 | a, b, c, al, be, ga = cell_to_cellpar(self.cell) |
||
364 | out = [ |
||
365 | f"data_{self.name}", |
||
366 | "_symmetry_space_group_name_H-M 'P 1'", |
||
367 | f"_cell_length_a {a}", |
||
368 | f"_cell_length_b {b}", |
||
369 | f"_cell_length_c {c}", |
||
370 | f"_cell_angle_alpha {al}", |
||
371 | f"_cell_angle_beta {be}", |
||
372 | f"_cell_angle_gamma {ga}", |
||
373 | "loop_", |
||
374 | " _symmetry_equiv_pos_site_id", |
||
375 | " _symmetry_equiv_pos_as_xyz", |
||
376 | " 1 'x, y, z'", |
||
377 | "loop_", |
||
378 | " _atom_site_label", |
||
379 | " _atom_site_type_symbol", |
||
380 | " _atom_site_fract_x", |
||
381 | " _atom_site_fract_y", |
||
382 | " _atom_site_fract_z", |
||
383 | ] |
||
384 | |||
385 | frac_motif = self.motif @ np.linalg.inv(self.cell) |
||
386 | ind = 0 |
||
387 | label_nums = {} |
||
388 | for asym_ind, mul in enumerate(self.multiplicities): |
||
389 | for _ in range(mul): |
||
390 | |||
391 | sym = ATOMIC_NUMBERS_TO_SYMS[self.types[asym_ind]] |
||
392 | if sym in label_nums: |
||
393 | label_nums[sym] += 1 |
||
394 | else: |
||
395 | label_nums[sym] = 1 |
||
396 | |||
397 | out.append( |
||
398 | " ".join( |
||
399 | [ |
||
400 | f"{sym}{label_nums[sym]}", |
||
401 | sym, |
||
402 | f"{frac_motif[ind, 0]:.4f}", |
||
403 | f"{frac_motif[ind, 1]:.4f}", |
||
404 | f"{frac_motif[ind, 2]:.4f}", |
||
405 | ] |
||
406 | ) |
||
407 | ) |
||
408 | ind += 1 |
||
409 | |||
410 | return "\n".join(out) |
||
411 | |||
412 | def to_pymatgen_structure(self): |
||
413 | |||
414 | if self.types is None: |
||
415 | raise ValueError( |
||
416 | "Cannot convert PeriodicSet to pymatgen Structure without " |
||
417 | "atomic types." |
||
418 | ) |
||
419 | |||
420 | from pymatgen.core import Structure |
||
421 | |||
422 | species = [ |
||
423 | ATOMIC_NUMBERS_TO_SYMS[t] |
||
424 | for m, t in zip(self.multiplicities, self.types) |
||
425 | for _ in range(m) |
||
426 | ] |
||
427 | |||
428 | labels = None |
||
429 | if self.labels is not None: |
||
430 | labels = [ |
||
431 | f"{t}_{i+1}" |
||
432 | for m, t in zip(self.multiplicities, self.labels) |
||
433 | for i in range(m) |
||
434 | ] |
||
435 | |||
436 | return Structure( |
||
437 | self.cell, species, self.motif, coords_are_cartesian=True, labels=labels |
||
438 | ) |
||
439 | |||
440 | def majority_occupancy_config(self) -> PeriodicSet: |
||
441 | |||
442 | if not self.disorder: |
||
443 | return self |
||
444 | |||
445 | asym_mask = np.full((self.n_asym, ), fill_value=True) |
||
446 | motif_mask = np.full((self.m, ), fill_value=True) |
||
447 | |||
448 | for asm in self.disorder: |
||
449 | |||
450 | # find majority occupancy group |
||
451 | majority_occ, majority_ind = 0, 0 |
||
452 | for i, grp in enumerate(asm.groups): |
||
453 | if grp.occupancy > majority_occ: |
||
454 | majority_ind = i |
||
455 | majority_occ = grp.occupancy |
||
456 | |||
457 | # mask all other groups |
||
458 | for i, grp in enumerate(asm.groups): |
||
459 | |||
460 | if i == majority_ind: |
||
461 | continue |
||
462 | |||
463 | for j in grp.indices: |
||
464 | asym_mask[j] = False |
||
465 | m_i = self.asym_unit[j] |
||
466 | mul = self.multiplicities[j] |
||
467 | motif_mask[m_i : m_i + mul] = False |
||
468 | |||
469 | muls = self.multiplicities[asym_mask] |
||
470 | asym_unit = np.zeros_like(muls, dtype=np.int64) |
||
471 | asym_unit[1:] = np.cumsum(muls)[:-1] |
||
472 | masked_labels = [l for l, flag in zip(self.labels, asym_mask) if flag] |
||
473 | |||
474 | return PeriodicSet( |
||
475 | self.motif[motif_mask], |
||
476 | self.cell, |
||
477 | self.name, |
||
478 | asym_unit, |
||
479 | self.multiplicities[asym_mask], |
||
480 | self.types[asym_mask], |
||
481 | masked_labels |
||
482 | ) |
||
483 | |||
484 | def compressed_dtypes(self) -> PeriodicSet: |
||
485 | """Return a PeriodicSet using float32, uint32 and uint8 for |
||
486 | atomic types.""" |
||
487 | |||
488 | if self._asym_unit is not None: |
||
489 | asym_unit = np.array(self._asym_unit, dtype=np.uint32) |
||
490 | else: |
||
491 | asym_unit = None |
||
492 | |||
493 | if self._multiplicities is not None: |
||
494 | multiplicities = np.array(self._multiplicities, dtype=np.uint32) |
||
495 | else: |
||
496 | multiplicities = None |
||
497 | |||
498 | if self.types is not None: |
||
499 | types = np.array(self.types, dtype=np.uint8) |
||
500 | else: |
||
501 | types = None |
||
502 | |||
503 | return PeriodicSet( |
||
504 | np.array(self.motif, dtype=np.float32), |
||
505 | np.array(self.cell, dtype=np.float32), |
||
506 | name=self.name, |
||
507 | asym_unit=asym_unit, |
||
508 | multiplicities=multiplicities, |
||
509 | types=types, |
||
510 | labels=self.labels, |
||
511 | disorder=self.disorder |
||
512 | ) |
||
513 |