amd.periodicset.PeriodicSet.__repr__()   B
last analyzed

Complexity

Conditions 6

Size

Total Lines 22
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 18
dl 0
loc 22
rs 8.5666
c 0
b 0
f 0
cc 6
nop 1
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
The variable int does not seem to be defined.
Loading history...
Comprehensibility Best Practice introduced by
The variable v does not seem to be defined.
Loading history...
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