• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

maurergroup / dfttoolkit / 15077298886

16 May 2025 08:57PM UTC coverage: 28.848% (+7.1%) from 21.747%
15077298886

Pull #59

github

b0d5e4
web-flow
Merge 473bfe91e into e895278a4
Pull Request #59: Vibrations refactor

1162 of 4028 relevant lines covered (28.85%)

0.29 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

20.97
dfttoolkit/geometry.py
1
import ast
1✔
2
import colorsys
1✔
3
import copy
1✔
4
import itertools
1✔
5
import re
1✔
6
import warnings
1✔
7
from collections import defaultdict
1✔
8
from collections.abc import Iterable
1✔
9
from copy import deepcopy
1✔
10
from fractions import Fraction
1✔
11
from typing import Any, List, Tuple, Union
1✔
12

13
import ase
1✔
14
import matplotlib as mpl
1✔
15
import matplotlib.cm as cmx
1✔
16
import matplotlib.colors
1✔
17
import matplotlib.pyplot as plt
1✔
18
import networkx as nx
1✔
19
import numpy as np
1✔
20
import numpy.typing as npt
1✔
21
import scipy.linalg
1✔
22
import scipy.spatial
1✔
23
import scipy.spatial.distance
1✔
24
import spglib
1✔
25
from ase.constraints import FixAtoms
1✔
26
from scipy.spatial import distance_matrix
1✔
27

28
import dfttoolkit.utils.math_utils as utils
1✔
29
from dfttoolkit.utils import units
1✔
30
from dfttoolkit.utils.periodic_table import PeriodicTable
1✔
31

32

33
class Geometry:
1✔
34
    """
35
    This class represents a geometry file for (in principle) any DFT code.
36
    In practice it has only been fully implemented for FHI-aims geometry.in
37
    files.
38

39
    """
40

41
    def __init__(self, filename: Union[str, None] = None):
1✔
42
        """
43
        Filename : str
44
            Path to text file of geometry.
45

46
        center: dict
47
            atom indices and linear combination of them to define the center of
48
            a molecule. Used only for mapping to first unit cell.
49
            Example: if the center should be the middle between the first three
50
            atoms, center would be {1:1/3,2:1/3,3:1/3}
51
        """
52
        self.species = []
1✔
53
        self.lattice_vectors = np.zeros([3, 3])
1✔
54
        self.comment_lines = []
1✔
55
        self.constrain_relax = np.zeros([0, 3], bool)
1✔
56
        self.external_force = np.zeros([0, 3], np.float64)
1✔
57
        self.calculate_friction = np.zeros([0], np.float64)
1✔
58
        self.initial_moment = []
1✔
59
        self.initial_charge = []
1✔
60
        self.name = filename
1✔
61
        self.center = None
1✔
62
        self.energy = None
1✔
63
        self.forces = None
1✔
64
        self.hessian = None
1✔
65
        self.geometry_parts = []  # list of lists: indices of each geometry part
1✔
66
        self.geometry_part_descriptions = []  # list of strings:  name of each geometry part
1✔
67
        self.symmetry_axes = None
1✔
68
        self.inversion_index = None
1✔
69
        self.vacuum_level = None
1✔
70
        self.multipoles = []
1✔
71
        self.homogeneous_field = None
1✔
72
        self.read_as_fractional_coords = False
1✔
73
        self.symmetry_params = None
1✔
74
        self.n_symmetry_params = None
1✔
75
        self.symmetry_LVs = None
1✔
76
        self.symmetry_frac_coords = None  # symmetry_frac_coords should have str values, not float, to include the parameters
1✔
77
        self.original_lattice_vectors = None  # Save the original lattice vectors if the geometry is periodically replicated
1✔
78

79
        if filename is None:
1✔
80
            self.n_atoms = 0
1✔
81
            self.coords = np.zeros([self.n_atoms, 3])
1✔
82
        else:
83
            self.read_from_file(filename)
1✔
84

85
    def __eq__(self, other):
1✔
86
        if len(self) != len(other):
1✔
87
            equal = False
×
88
        else:
89
            equal = np.allclose(self.coords, other.coords)
1✔
90
            equal = equal and np.allclose(self.lattice_vectors, other.lattice_vectors)
1✔
91
            equal = equal and self.species == other.species
1✔
92
        return equal
1✔
93

94
    def __len__(self):
1✔
95
        return self.n_atoms
1✔
96

97
    def __add__(self, other_geometry):
1✔
98
        geom = copy.deepcopy(self)
×
99
        geom += other_geometry
×
100
        return geom
×
101

102
    def __iadd__(self, other_geometry):
1✔
103
        self.add_geometry(other_geometry)
×
104
        return self
×
105

106
    def get_instance_of_other_type(self, geometry_type):
1✔
107
        if geometry_type == "aims":
1✔
108
            new_geometry = AimsGeometry()
1✔
109
        elif geometry_type == "vasp":
×
110
            new_geometry = VaspGeometry()
×
111
        elif geometry_type == "xyz":
×
112
            new_geometry = XYZGeometry()
×
113
        elif geometry_type == "xsf":
×
114
            new_geometry = XSFGeometry()
×
115
        else:
116
            raise ValueError(f'Type "{geometry_type}" is not availlable.')
×
117

118
        new_geometry.__dict__ = self.__dict__
1✔
119
        return new_geometry
1✔
120

121
    ###########################################################################
122
    #                             INPUT PARSER                                #
123
    ###########################################################################
124
    def read_from_file(self, filename: str) -> None:
1✔
125
        """
126
        Parses a geometry file
127

128
        Parameters
129
        ----------
130
        filename : str
131
            Path to input file.
132

133
        Returns
134
        -------
135
        None.
136

137
        """
138
        with open(filename) as f:
1✔
139
            text = f.read()
1✔
140

141
        self.parse(text)
1✔
142

143
    def parse(self, text):
1✔
144
        raise NotImplementedError
×
145

146
    def add_top_comment(self, comment_string: str) -> None:
1✔
147
        """
148
        Add comments that are saved at the top of the geometry file.
149

150
        Parameters
151
        ----------
152
        comment_string : str
153
            Comment string.
154

155
        Returns
156
        -------
157
        None.
158
        """
159
        lines = comment_string.split("\n")
×
160
        for line in lines:
×
161
            commented_line = "# " + line if not line.startswith("#") else line
×
162
            self.comment_lines.append(commented_line)
×
163

164
    ###########################################################################
165
    #                             OUTPUT PARSER                               #
166
    ###########################################################################
167
    def save_to_file(self, filename, **kwargs):
1✔
168
        geometry_type = get_file_format_from_ending(filename)
1✔
169

170
        new_geometry = self.get_instance_of_other_type(geometry_type)
1✔
171

172
        text = new_geometry.get_text(**kwargs)
1✔
173

174
        # Enforce linux file ending, even if running on windows machine by
175
        # using binary mode
176
        with open(filename, "w", newline="\n") as f:
1✔
177
            f.write(text)
1✔
178

179
    def get_text(self, **kwargs):
1✔
180
        raise NotImplementedError
×
181

182
    ###########################################################################
183
    #                          Data exchange with ASE                         #
184
    ###########################################################################
185
    def get_from_ase_atoms_object(self, atoms: ase.Atoms) -> None:
1✔
186
        """
187
        Read an ASE.Atoms object.
188

189
        Taken from ase.io.aims and adapted. Only basic features are implemented.
190

191
        Parameters
192
        ----------
193
        atoms : ASE atoms object
194
            Atoms object from ASE that should be converted into geometry.
195

196
        Raises
197
        ------
198
        RuntimeError
199
            If atoms object is erroneous.
200

201
        Returns
202
        -------
203
        None
204
        """
205
        if isinstance(atoms, (list, tuple)):
×
206
            if len(atoms) > 1:
×
207
                raise RuntimeError(
×
208
                    "Don't know how to save more than one image to FHI-aims input"
209
                )
210
            atoms = atoms[0]  # pyright:ignore
×
211

212
        if atoms.get_pbc().any():
×
213
            self.lattice_vectors = np.array(atoms.get_cell())
×
214

215
        fix_cart = np.zeros([len(atoms), 3])
×
216
        if atoms.constraints:
×
217
            for constr in atoms.constraints:
×
218
                if isinstance(constr, FixAtoms):
×
219
                    fix_cart[constr.index] = [True, True, True]
×
220
        constrain_relax = fix_cart
×
221

222
        coords = []
×
223
        species_list = []
×
224
        for atom in atoms:
×
225
            species = atom.symbol
×
226
            if isinstance(species, int):
×
227
                species = PeriodicTable.get_symbol(species)
×
228
            species_list.append(species)
×
229
            coords.append(atom.position)
×
230
        coords = np.array(coords)
×
231
        self.add_atoms(coords, species_list, constrain_relax)
×
232

233
    def get_as_ase(self) -> None:
1✔
234
        """
235
        Convert geometry file to ASE object.
236

237
        Returns
238
        -------
239
        None.
240

241
        """
242
        # atoms_string = ""
243
        atom_coords = []
×
244
        atom_numbers = []
×
245
        atom_constraints = []
×
246
        for i in range(self.n_atoms):
×
247
            # Do not export 'emptium" atoms
248
            if self.species[i] != "Em":
×
249
                atom_coords.append(self.coords[i, :])
×
250
                atom_numbers.append(PeriodicTable.get_atomic_number(self.species[i]))
×
251

252
                if np.any(self.constrain_relax[i]):
×
253
                    atom_constraints.append(i)
×
254

255
        ase_system = ase.Atoms(numbers=atom_numbers, positions=atom_coords)
×
256
        ase_system.cell = self.lattice_vectors
×
257

258
        c = FixAtoms(indices=atom_constraints)
×
259
        ase_system.set_constraint(c)
×
260

261
        if not np.sum(self.lattice_vectors) == 0.0:
×
262
            ase_system.pbc = [1, 1, 1]
×
263

264
        if self.energy is not None:
×
265
            ase_system.info["energy"] = self.energy
×
266

267
        if self.forces is not None:
×
268
            ase_system.arrays["forces"] = self.forces
×
269

270
        return ase_system
×
271

272
    ###########################################################################
273
    #                    Adding and removing atoms (in place)                 #
274
    ###########################################################################
275
    def add_atoms(
1✔
276
        self,
277
        cartesian_coords,
278
        species,
279
        constrain_relax=None,
280
        initial_moment=None,
281
        initial_charge=None,
282
        external_force=None,
283
        calculate_friction=None,
284
    ) -> None:
285
        """
286
        Add additional atoms to the current geometry file.
287

288
        Parameters
289
        ----------
290
        cartesion_coords : List of numpy arrays of shape [nx3]
291
            coordinates of new atoms
292
        species : list of strings
293
            element symbol for each atom
294
        constrain_relax : list of lists of bools (optional)
295
            [bool,bool,bool] (for [x,y,z] axis) for all atoms that should be
296
            constrained during a geometry relaxation
297

298
        Retruns
299
        -------
300
        None.
301

302
        """
303
        if constrain_relax is None or len(constrain_relax) == 0:
1✔
304
            constrain_relax = np.zeros([len(species), 3], bool)
×
305
        if external_force is None or len(external_force) == 0:
1✔
306
            external_force = np.zeros([len(species), 3], np.float64)
1✔
307
        if calculate_friction is None:
1✔
308
            calculate_friction = np.array([False] * len(species))
1✔
309
        if initial_moment is None:
1✔
310
            initial_moment = [0.0] * len(species)
1✔
311
        if initial_charge is None:
1✔
312
            initial_charge = [0.0] * len(species)
1✔
313
        # TODO: this should not be necessary as self.coords should always be a np.array
314
        if not hasattr(self, "coords") or self.coords is None:
1✔
315
            assert isinstance(cartesian_coords, np.ndarray)
×
316
            self.coords = cartesian_coords
×
317
        else:
318
            # make sure that coords are 2D
319
            cartesian_coords = np.atleast_2d(cartesian_coords)
1✔
320
            self.coords = np.concatenate((self.coords, cartesian_coords), axis=0)
1✔
321
        self.species += species
1✔
322
        self.n_atoms = self.coords.shape[0]
1✔
323

324
        self.constrain_relax = np.concatenate(
1✔
325
            (self.constrain_relax, constrain_relax), axis=0
326
        )
327
        self.external_force = np.concatenate(
1✔
328
            (self.external_force, external_force), axis=0
329
        )
330
        self.calculate_friction = np.concatenate(
1✔
331
            (self.calculate_friction, calculate_friction)
332
        )
333
        self.initial_moment += initial_moment
1✔
334
        self.initial_charge += initial_charge
1✔
335

336
    def add_geometry(self, geometry) -> None:
1✔
337
        """
338
        Adds full geometry to initial geometry.
339

340
        Parameters
341
        ----------
342
        geometry : Instance of geometry
343
            New geometry to be added to current geometry.
344

345
        Returns
346
        -------
347
        None.
348

349
        """
350
        # check parts: (needs to be done before adding atoms to self)
351
        if hasattr(self, "geometry_parts") and hasattr(geometry, "geometry_parts"):
×
352
            for part, name in zip(
×
353
                geometry.geometry_parts, geometry.geometry_part_descriptions
354
            ):
355
                if len(part) > 0:
×
356
                    self.geometry_parts.append([i + self.n_atoms for i in part])
×
357
                    self.geometry_part_descriptions.append(name)
×
358

359
        # some lines of code in order to preserve backwards compatibility
360
        if not hasattr(geometry, "external_force"):
×
361
            geometry.external_force = np.zeros([0, 3], np.float64)
×
362
        self.add_atoms(
×
363
            geometry.coords,
364
            geometry.species,
365
            constrain_relax=geometry.constrain_relax,
366
            initial_moment=geometry.initial_moment,
367
            initial_charge=geometry.initial_charge,
368
            external_force=geometry.external_force,
369
            calculate_friction=geometry.calculate_friction,
370
        )
371

372
        # check lattice vectors:
373
        # g has lattice and self not:
374
        if not np.any(self.lattice_vectors) and np.any(geometry.lattice_vectors):
×
375
            self.lattice_vectors = np.copy(geometry.lattice_vectors)
×
376

377
        # both have lattice vectors:
378
        elif np.any(self.lattice_vectors) and np.any(geometry.lattice_vectors):
×
379
            warnings.warn(
×
380
                "Caution: The lattice vectors of the first file will be used!"
381
            )
382

383
        # add multipoles
384
        self.add_multipoles(geometry.multipoles)
×
385

386
        # check center:
387
        # g has center and self not:
388
        if hasattr(self, "center") and hasattr(geometry, "center"):
×
389
            if self.center is None and geometry.center is not None:
×
390
                self.center = geometry.center.copy()
×
391
            # both have a center:
392
            elif self.center is not None and geometry.center is not None:
×
393
                warnings.warn("Caution: The center of the first file will be used!")
×
394

395
    def add_multipoles(self, multipoles: Union[List[float], List[list]]) -> None:
1✔
396
        """
397
        Adds multipoles to the the geometry.
398

399
        Parameters
400
        ----------
401
        multipoles: Union[List[float], List[list]]
402
            Each multipole is defined as a list -> [x, y, z, order, charge]
403
            With x,y,z cartesian coordinates
404
            order: 0 for monopoles, 1 for dipoles
405
            charge: charge
406

407
        Returns
408
        -------
409
        None
410

411
        """
412
        # if multiple multipoles are given: indented lists
413
        if len(multipoles) == 0:
×
414
            return
×
415
        if isinstance(multipoles[0], list):
×
416
            for x in multipoles:
×
417
                self.multipoles.append(x)
×
418
        # else: one list
419
        else:
420
            self.multipoles.append(multipoles)
×
421

422
    def remove_atoms(self, atom_inds: npt.NDArray[np.int64]) -> None:
1✔
423
        """
424
        Remove atoms with indices atom_inds. If no indices are specified, all
425
        atoms are removed.
426

427
        Parameters
428
        ----------
429
        atom_inds : np.array
430
            Indices of atoms to be removed.
431

432
        Returns
433
        -------
434
        None
435

436
        """
437
        if hasattr(self, "geometry_parts") and len(self.geometry_parts) > 0:
×
438
            warnings.warn(
×
439
                "CAUTION: geometry_parts indices are not updated after atom"
440
                "deletion!!\n You are welcome to implement this!!"
441
            )
442
        mask = np.ones(len(self.species), dtype=bool)
×
443
        mask[atom_inds] = False
×
444

445
        self.species = list(np.array(self.species)[mask])
×
446
        self.constrain_relax = self.constrain_relax[mask, :]
×
447
        self.external_force = self.external_force[mask, :]
×
448
        self.calculate_friction = self.calculate_friction[mask]
×
449
        self.coords = self.coords[mask, :]
×
450
        self.n_atoms = len(self.constrain_relax)
×
451

452
        if hasattr(self, "hessian") and self.hessian is not None:
×
453
            flat_mask = np.kron(mask, np.ones(3, dtype=bool))
×
454
            new_dim = np.sum(flat_mask)
×
455
            a, b = np.meshgrid(flat_mask, flat_mask)
×
456
            hess_mask = np.logical_and(a, b)
×
457
            new_hessian = self.hessian[hess_mask].reshape(new_dim, new_dim)
×
458
            self.hessian = new_hessian
×
459

460
    def remove_atoms_by_species(self, species: str) -> None:
1✔
461
        """
462
        Removes all atoms of a given species.
463

464
        Parameters
465
        ----------
466
        species : str
467
            Atom species to be removed.
468

469
        Returns
470
        -------
471
        None
472

473
        """
474
        L = np.array(self.species) == species
×
475
        atom_inds = np.where(L)[0]
×
476
        self.remove_atoms(atom_inds)
×
477

478
    def remove_constrained_atoms(self) -> None:
1✔
479
        """
480
        Remove all atoms where all coordinates are constrained.
481

482
        Returns
483
        -------
484
        None
485

486
        """
487
        remove_inds = self.get_constrained_atoms()
×
488
        self.remove_atoms(remove_inds)
×
489

490
    def remove_unconstrained_atoms(self):
1✔
491
        """
492
        Remove all atoms where all coordinates are constrained.
493

494
        Returns
495
        -------
496
        None
497

498
        """
499
        remove_inds = self.get_unconstrained_atoms()
×
500
        self.remove_atoms(remove_inds)
×
501

502
    def truncate(self, n_atoms: int) -> None:
1✔
503
        """
504
        Keep only the first n_atoms atoms
505

506
        Parameters
507
        ----------
508
        n_atoms : int
509
            Number of atoms to be kept.
510

511
        Returns
512
        -------
513
        None
514

515
        """
516
        self.species = self.species[:n_atoms]
×
517
        self.constrain_relax = self.constrain_relax[:n_atoms]
×
518
        self.external_force = self.external_force[:n_atoms]
×
519
        self.calculate_friction = self.calculate_friction[:n_atoms]
×
520
        self.coords = self.coords[:n_atoms, :]
×
521
        self.n_atoms = n_atoms
×
522

523
    def crop(
1✔
524
        self,
525
        xlim=(-np.inf, np.inf),
526
        ylim=(-np.inf, np.inf),
527
        zlim=(-np.inf, np.inf),
528
        auto_margin: bool = False,
529
    ) -> None:
530
        """
531
        Removes all atoms that are outside specified bounds.
532

533
        Parameters
534
        ----------
535
        xlim : tuple, optional
536
            Limit in x-direction. The default is (-np.inf, np.inf).
537
        ylim : tuple, optional
538
            Limit in y-direction. The default is (-np.inf, np.inf).
539
        zlim : tuple, optional
540
            Limit in z-direction. The default is (-np.inf, np.inf).
541
        auto_margin : TYPE, optional
542
            If auto_margin == True then an additional margin of the maximum
543
            covalent radius is added to all borders. The default is False.
544

545
        Returns
546
        -------
547
        None.
548

549
        """
550
        indices_to_remove = self.get_cropping_indices(xlim, ylim, zlim, auto_margin)
×
551
        self.remove_atoms(indices_to_remove)
×
552

553
    def crop_inverse(
1✔
554
        self,
555
        xlim=(-np.inf, np.inf),
556
        ylim=(-np.inf, np.inf),
557
        zlim=(-np.inf, np.inf),
558
        auto_margin=False,
559
    ) -> None:
560
        """
561
        Removes all atoms that are inside specified bounds.
562

563
        Parameters
564
        ----------
565
        xlim : tuple, optional
566
            Limit in x-direction. The default is (-np.inf, np.inf).
567
        ylim : tuple, optional
568
            Limit in y-direction. The default is (-np.inf, np.inf).
569
        zlim : tuple, optional
570
            Limit in z-direction. The default is (-np.inf, np.inf).
571
        auto_margin : TYPE, optional
572
            If auto_margin == True then an additional margin of the maximum
573
            covalent radius is added to all borders. The default is False.
574

575
        Returns
576
        -------
577
        None.
578

579
        """
580
        indices_to_keep = self.get_cropping_indices(xlim, ylim, zlim, auto_margin)
×
581
        indices_to_remove = np.array(
×
582
            [i for i in range(self.n_atoms) if i not in indices_to_keep]
583
        )
584
        self.remove_atoms(indices_to_remove)
×
585

586
    def crop_to_unit_cell(  # noqa: PLR0912
1✔
587
        self, lattice=None, frac_coord_factors: list[int] | None = None
588
    ) -> None:
589
        """
590
        Remove all atoms that are outside the given unit cell.
591

592
        Similar to `self.crop()` but allows for arbitrary unit cells
593

594
        Parameters
595
        ----------
596
        lattice : array like, optional
597
            Lattice vectors. The default is None.
598
        frac_coord_factors : list, optional
599
            The default is [0, 1].
600

601
        Returns
602
        -------
603
        None.
604

605
        """
606
        # Atoms that have fractional coordinates outside the defined frac_coord
607
        # factors are removed. Per default frac_coord_factors=[0,1]
608

609
        if frac_coord_factors is None:
×
610
            frac_coord_factors = [0, 1]
×
611
        if lattice is None:
×
612
            lattice = self.lattice_vectors
×
613
        frac_coords = utils.get_fractional_coords(self.coords, lattice)
×
614

615
        remove_inds = []
×
616
        remove_inds += list(np.where(frac_coords[:, 0] >= frac_coord_factors[1])[0])
×
617
        remove_inds += list(np.where(frac_coords[:, 1] >= frac_coord_factors[1])[0])
×
618
        remove_inds += list(np.where(frac_coords[:, 0] < frac_coord_factors[0])[0])
×
619
        remove_inds += list(np.where(frac_coords[:, 1] < frac_coord_factors[0])[0])
×
620
        remove_inds += list(np.where(frac_coords[:, 2] > frac_coord_factors[1])[0])
×
621
        remove_inds += list(np.where(frac_coords[:, 2] < frac_coord_factors[0])[0])
×
622

623
        remove_inds = np.array(set(remove_inds))
×
624

625
        self.remove_atoms(remove_inds)
×
626
        self.lattice_vectors = lattice
×
627

628
        # In the following all redundant atoms, i.e. atoms that are multiplied
629
        # at the same position when the unitcell is repeated periodically, are
630
        # removed from the new unit cell
631
        epsilon = 0.1
×
632
        # Distance in Angstrom for which two atoms are assumed to be in the
633
        # same position
634
        init_geom = self
×
635
        allcoords = init_geom.coords
×
636

637
        # generate all possible translation vectors that could map an atom of
638
        # the unit cell into itsself
639
        prim_lat_vec = [
×
640
            [init_geom.lattice_vectors[i], -init_geom.lattice_vectors[i]]
641
            for i in range(3)
642
        ]
643
        self_mapping_translation_vectors = []
×
644

645
        self_mapping_translation_vectors.extend(
×
646
            i[sign] for i in prim_lat_vec for sign in range(2)
647
        )
648

649
        for i in range(3):
×
650
            for sign0 in range(2):
×
651
                for j in range(3):
×
652
                    for sign1 in range(2):
×
653
                        if i != j:
×
654
                            single_addition_vector = (
×
655
                                prim_lat_vec[i][sign0] + prim_lat_vec[j][sign1]
656
                            )
657
                            self_mapping_translation_vectors.append(
×
658
                                single_addition_vector
659
                            )
660

661
        for i in range(3):
×
662
            for sign0 in range(2):
×
663
                for j in range(3):
×
664
                    for sign1 in range(2):
×
665
                        for k in range(3):
×
666
                            for sign2 in range(2):
×
667
                                if i not in (j, k) and j != k:
×
668
                                    single_addition_vector = (
×
669
                                        prim_lat_vec[i][sign0]
670
                                        + prim_lat_vec[j][sign1]
671
                                        + prim_lat_vec[k][sign2]
672
                                    )
673
                                    self_mapping_translation_vectors.append(
×
674
                                        single_addition_vector
675
                                    )
676

677
        # Find the indices of those atoms that are equivalent, i.e. atoms that
678
        # are doubled when the unit cell is repeated periodically
679

680
        doubleindices = []  # list of pairs of atom indices that are equivalent
×
681
        for i, coords_i in enumerate(allcoords):
×
682
            for trans_l in self_mapping_translation_vectors:
×
683
                coords_i_shift_l = copy.deepcopy(coords_i)
×
684
                coords_i_shift_l += trans_l
×
685
                for j, coords_j in enumerate(allcoords):
×
686
                    if j != i:
×
687
                        distance_i_shift_l_j = np.linalg.norm(
×
688
                            coords_i_shift_l - coords_j
689
                        )
690
                        if distance_i_shift_l_j < epsilon:
×
691
                            doubleindices.append([i, j])
×
692

693
        for i in range(len(doubleindices)):
×
694
            doubleindices[i].sort()
×
695

696
        ###################################################################
697
        ##Create a list of redundant atoms according to the atoms that are equivalent
698
        # according to all the pairs in doubleindices
699

700
        liste = doubleindices
×
701
        to_be_killed = []  # List of all atom indicess that are redundant
×
702
        for liste_i in liste:
×
703
            replacer = liste_i[0]
×
704
            to_be_replaced = liste_i[1]
×
705
            to_be_killed.append(to_be_replaced)
×
706
            for j, liste_j in enumerate(liste):
×
707
                for k in range(2):
×
708
                    if liste_j[k] == to_be_replaced:
×
709
                        liste[j][k] = replacer
×
710
        remainers = [j[0] for j in liste]
×
711
        for r in remainers:
×
712
            for k in to_be_killed:
×
713
                if k == r:
×
714
                    to_be_killed.remove(k)
×
715

716
        self.remove_atoms(np.array(to_be_killed))
×
717

718
    def remove_metal_atoms(self) -> None:
1✔
719
        """
720
        Removes all atoms with atomic number > 18 and atomic numbers
721
        3,4,11,12,13,14
722

723
        Returns
724
        -------
725
        None.
726

727
        """
728
        metal_atoms = self.get_indices_of_metal()
×
729

730
        print(type(metal_atoms))
×
731

732
        self.remove_atoms(metal_atoms)
×
733

734
    def remove_non_metallic_atoms(self) -> None:
1✔
735
        """
736
        Removes all atoms that are not metal
737

738
        Returns
739
        -------
740
        None.
741

742
        """
743
        mol_inds = self.get_indices_of_molecules()
×
744
        self.remove_atoms(mol_inds)
×
745

746
    def remove_substrate(self, primitive_substrate) -> None:
1✔
747
        """
748
        Removes all substrate atoms given the primitive substrate by
749
        identifying species and height
750

751
        Parameters
752
        ----------
753
        primitive_substrate: Geometry
754
            primitive substrate file of system
755

756
        dimension: int
757
            dimension to use as z-axis
758

759
        threshold: float
760
            height threshold in A
761
        """
762
        substrate_indices = self.get_substrate_indices(
×
763
            primitive_substrate=primitive_substrate
764
        )
765
        self.remove_atoms(substrate_indices)
×
766

767
    def remove_adsorbates(self, primitive_substrate=None) -> None:
1✔
768
        """
769
        Removes all atoms that are not part of the substrate
770

771
        Returns
772
        -------
773
        None.
774

775
        """
776
        adsorbate_indices = self.get_adsorbate_indices(
×
777
            primitive_substrate=primitive_substrate
778
        )
779
        self.remove_atoms(adsorbate_indices)
×
780

781
    def remove_collisions(self, keep_latest: Union[bool, slice] = True) -> None:
1✔
782
        """
783
        Removes all atoms that are in a collision group as given by
784
        GeometryFile.getCollidingGroups.
785

786
        Parameters
787
        ----------
788
        keep_latest : Union[bool, slice], optional
789
            Whether to keep the earliest or latest added. If a slice object is
790
            given, the selection is used to determine which atoms to keep.
791
            Defaults to True.
792

793
        Returns
794
        -------
795
        None.
796

797
        """
798
        indices = []
×
799
        if isinstance(keep_latest, bool):
×
800
            if keep_latest:
×
801
                selection = slice(None, -1, None)
×
802
            else:
803
                selection = slice(1, None, None)
×
804
        elif isinstance(keep_latest, slice):
×
805
            selection = keep_latest
×
806
        collisions = self.get_colliding_groups()
×
807
        for group in collisions:
×
808
            indices += group[selection]
×
809
        self.remove_atoms(np.array(indices))
×
810

811
    ###########################################################################
812
    #                         Transformations (in place)                      #
813
    ###########################################################################
814
    def map_to_first_unit_cell(
1✔
815
        self, lattice_vectors=None, dimensions=np.array(range(3))
816
    ) -> None:
817
        """
818
        Aps the coordinate of a geometry in multiples of the substrate lattice
819
        vectors to a point that is closest to the origin
820

821
        Parameters
822
        ----------
823
        lattice_vectors : float-array, optional
824
            Lattice vectors of the substrate. The default is None.
825
        dimensions : float-array, optional
826
            Dimensions (x, y, z) where the mapping should be done. The default
827
            is np.array(range(3)).
828

829
        Returns
830
        -------
831
        None.
832

833
        """
834
        if lattice_vectors is None:
×
835
            lattice_vectors = self.lattice_vectors
×
836

837
        assert not np.allclose(lattice_vectors, np.zeros([3, 3])), (
×
838
            "Lattice vector must be defined in Geometry or given as function parameter"
839
        )
840

841
        frac_coords = utils.get_fractional_coords(self.coords, lattice_vectors)
×
842
        # modulo 1 maps all coordinates to first unit cell
843
        frac_coords[:, dimensions] = frac_coords[:, dimensions] % 1
×
844
        new_coords = utils.get_cartesian_coords(frac_coords, lattice_vectors)
×
845
        self.coords = new_coords
×
846

847
    def map_center_of_atoms_to_first_unit_cell(self, lattice_vectors=None):
1✔
848
        if lattice_vectors is None:
×
849
            lattice_vectors = self.lattice_vectors
×
850

851
        assert not np.allclose(lattice_vectors, np.zeros([3, 3])), (
×
852
            "Lattice vector must be defined in Geometry or given as function parameter"
853
        )
854

855
        offset = self.get_geometric_center()
×
856
        frac_offset = utils.get_fractional_coords(offset, lattice_vectors)
×
857
        frac_offset = np.floor(frac_offset)
×
858
        self.move_all_atoms_by_fractional_coords(-frac_offset, lattice_vectors)
×
859

860
    def center_coordinates(
1✔
861
        self,
862
        ignore_center_attribute: bool = False,
863
        dimensions=np.array(range(3)),
864
    ):
865
        """
866
        Shift the coordinates of a geometry such that the "center of mass" or
867
        specified center lies at (0,0,0)
868

869
        Parameters
870
        ----------
871
        ignore_center_attribute : bool
872
            Switch usage of *center* attribute off/on. The default is False.
873

874
        dimensions: np.array
875
            Dimensions that should be cnetered. The default is False [0, 1, 2].
876

877
        Returns
878
        -------
879
        None.
880

881
        """
882
        offset = self.get_geometric_center(
×
883
            ignore_center_attribute=ignore_center_attribute
884
        )[dimensions]
885
        self.coords[:, dimensions] -= offset
×
886
        return offset
×
887

888
    def move_all_atoms(self, shift):
1✔
889
        """
890
        Translates the whole geometry by vector 'shift'
891

892
        """
893
        self.coords += shift
×
894

895
    def move_all_atoms_by_fractional_coords(self, frac_shift, lattice_vectors=None):
1✔
896
        if lattice_vectors is None:
×
897
            lattice_vectors = self.lattice_vectors
×
898

899
        self.coords += utils.get_cartesian_coords(frac_shift, lattice_vectors)
×
900

901
    def move_adsorbates(self, shift, primitive_substrate=None):
1✔
902
        """
903
        Shifts the adsorbates in Cartesian coordinates
904
        """
905
        adsorbates = self.get_adsorbates(primitive_substrate=primitive_substrate)
×
906
        adsorbates.coords += shift
×
907

908
        self.remove_adsorbates(primitive_substrate=primitive_substrate)
×
909
        self += adsorbates
×
910

911
    def rotate_lattice_around_axis(
1✔
912
        self,
913
        angle_in_degree: float,
914
        axis: npt.NDArray[np.float64] = np.array([0.0, 0.0, 1.0]),
915
    ) -> None:
916
        """
917
        Rotates lattice around a given axis.
918

919
        Parameters
920
        ----------
921
        angle_in_degree : float
922
            angle of rotation
923

924
        axis: np.array
925
            Axis around which to rotate. The default is
926

927
        Returns
928
        -------
929
        None.
930

931
        """
932
        R = utils.get_rotation_matrix_around_axis(axis, angle_in_degree * np.pi / 180)
×
933
        self.lattice_vectors = np.dot(self.lattice_vectors, R)
×
934

935
    def rotate_coords_around_axis(
1✔
936
        self,
937
        angle_in_degree: float,
938
        axis: npt.NDArray[np.float64] = np.array([0.0, 0.0, 1.0]),
939
        center=None,
940
        indices=None,
941
    ) -> None:
942
        """
943
        Rotates structure COUNTERCLOCKWISE around a point defined by <center>.
944

945
        Parameters
946
        ----------
947
        angle_in_degree : float
948
            Angle of rotation.
949
        axis : npt.NDArray[np.float64], optional
950
            Axis of rotation. The default is np.array([0.0, 0.0, 1.0]).
951
        center : npt.NDArray[np.float64], optional
952
            If center == None, the geometric center of the structure (as
953
            defined by self.get_geometric_center()). The default is None.
954
        indices : list, optional
955
            Indices of atoms to be manipulated. If indices == None, all atoms
956
            will be used. The default is None.
957

958
        Returns
959
        -------
960
        None.
961

962
        """
963
        if indices is None:
×
964
            indices = np.arange(self.n_atoms)
×
965
        if center is None:
×
966
            center = self.get_geometric_center(indices=indices)
×
967

968
        R = utils.get_rotation_matrix_around_axis(axis, angle_in_degree * np.pi / 180)
×
969
        temp_coords = copy.deepcopy(self.coords[indices])
×
970
        temp_coords -= center
×
971
        temp_coords = np.dot(temp_coords, R)
×
972
        temp_coords += center
×
973
        self.coords[indices] = temp_coords
×
974

975
    def mirror_through_plane(self, normal_vector: npt.NDArray[np.float64]) -> None:
1✔
976
        """
977
        Mirrors the geometry through the plane defined by the normal vector.
978

979
        Parameters
980
        ----------
981
        normal_vector : npt.NDArray[np.float64]
982
            Normal vector of mirror plane.
983

984
        Returns
985
        -------
986
        None.
987

988
        """
989
        mirror_matrix = utils.get_mirror_matrix(normal_vector=normal_vector)
×
990
        self.transform(mirror_matrix)
×
991

992
    def align_into_xy_plane(self, atom_indices):
1✔
993
        """
994
        Rotates a planar molecule (defined by 3 atom indices) into the XY plane.
995

996
        Double check results, use with caution
997

998
        Parameters
999
        ----------
1000
        atom_indices
1001
            Indices of atoms that should be aligned.
1002
        """
1003
        p1 = self.coords[atom_indices[0]]
×
1004
        p2 = self.coords[atom_indices[1]]
×
1005
        p3 = self.coords[atom_indices[2]]
×
1006

1007
        X = np.zeros([3, 3])
×
1008
        X[0, :] = p2 - p1
×
1009
        X[1, :] = p3 - p1
×
1010
        X[2, :] = np.cross(X[0], X[1])
×
1011
        for i in range(3):
×
1012
            X[i] /= np.linalg.norm(X[i])
×
1013
        X[1, :] = np.cross(X[2], X[0])
×
1014

1015
        U = np.linalg.inv(X)
×
1016
        self.transform(U)
×
1017

1018
    def align_with_z_vector(self, new_z_vec: npt.NDArray[np.float64]) -> None:
1✔
1019
        """
1020
        Transforms the coordinate system of the geometry file to a new z-vector
1021
        calculates rotation martrix for coordinate transformation to new z-vector
1022
        and uses it to transform coordinates of geometry object
1023

1024
        Parameters
1025
        ----------
1026
        new_z_vec : npt.NDArray[np.float64]
1027
            The vector to align with the z-axis.
1028

1029
        Returns
1030
        -------
1031
        None
1032

1033
        """
1034
        # get old_positions
1035
        old_positions = self.coords
×
1036

1037
        # normalize new_z_vec
1038
        new_z_vec = new_z_vec / np.linalg.norm(new_z_vec)
×
1039

1040
        # Check if the desired vector is antiparallel to the z-axis
1041
        if np.allclose(new_z_vec, -np.array([0, 0, 1])):
×
1042
            rotation_matrix = np.diag([-1, -1, 1])  # Antiparallel case
×
1043
        else:
1044
            # Calculate the rotation matrix
1045
            z_axis = np.array([0, 0, 1])
×
1046
            cross_product = np.cross(new_z_vec, z_axis)
×
1047
            dot_product = np.dot(new_z_vec, z_axis)
×
1048
            skew_symmetric_matrix = np.array(
×
1049
                [
1050
                    [0, -cross_product[2], cross_product[1]],
1051
                    [cross_product[2], 0, -cross_product[0]],
1052
                    [-cross_product[1], cross_product[0], 0],
1053
                ]
1054
            )
1055
            rotation_matrix = (
×
1056
                np.eye(3)
1057
                + skew_symmetric_matrix
1058
                + np.dot(skew_symmetric_matrix, skew_symmetric_matrix)
1059
                * (1 - dot_product)
1060
                / (np.linalg.norm(cross_product) ** 2)
1061
            )
1062

1063
        # Apply the rotation to all positions
1064
        rotated_positions = np.dot(old_positions, rotation_matrix.T)
×
1065

1066
        self.coords = rotated_positions
×
1067

1068
    def align_vector_to_vector(
1✔
1069
        self,
1070
        vector: npt.NDArray[np.float64],
1071
        vector_to_align: npt.NDArray[np.float64],
1072
    ):
1073
        """
1074
        Aligns a vector and the atomic coordiantes to a given vector.
1075

1076
        Parameters
1077
        ----------
1078
        vector : npt.NDArray[np.float64]
1079
            vector for alignment
1080

1081
        vector_to_align : npt.NDArray[np.float64]
1082
            index of the lattice vector that should be aligned
1083

1084
        Returns
1085
        -------
1086
        None
1087

1088
        """
1089
        vector_to_align_normed = vector_to_align / np.linalg.norm(vector_to_align)
×
1090

1091
        vector_normed = vector / np.linalg.norm(vector)
×
1092

1093
        R = utils.get_rotation_matrix(vector_normed, vector_to_align_normed)
×
1094

1095
        self.lattice_vectors = np.dot(self.lattice_vectors, R)
×
1096
        self.coords = np.dot(self.coords, R)
×
1097

1098
    def align_lattice_vector_to_vector(self, vector, lattice_vector_index):
1✔
1099
        """
1100
        Aligns a lattice vector and the atomic coordiantes to a given axis.
1101

1102
        Parameters
1103
        ----------
1104
        vector : array
1105
            vector for alignment
1106

1107
        lattice_vector_index : int
1108
            index of the lattice vector that should be aligned
1109

1110
        Returns
1111
        -------
1112
        None
1113

1114
        """
1115
        lattice_vector_normed = self.lattice_vectors[
×
1116
            lattice_vector_index
1117
        ] / np.linalg.norm(self.lattice_vectors[lattice_vector_index])
1118

1119
        self.align_vector_to_vector(vector, lattice_vector_normed)
×
1120

1121
    def align_cartiesian_axis_to_vector(self, vector, axis_index):
1✔
1122
        """
1123
        Aligns a lattice vector and the atomic coordiantes to a given axis.
1124

1125
        Parameters
1126
        ----------
1127
        vector : array
1128
            vector for alignment
1129

1130
        axis_index : int
1131
            index of the axis that should be aligned
1132

1133
        """
1134
        axis = np.zeros(3, dtype=np.float64)
×
1135
        axis[axis_index] = 1.0
×
1136

1137
        self.align_vector_to_vector(vector, axis)
×
1138

1139
    def align_with_view_direction(
1✔
1140
        self, view_direction: npt.NDArray[np.float64]
1141
    ) -> None:
1142
        view_direction /= np.linalg.norm(view_direction)
×
1143

1144
        vec_z = np.array([0.0, 0.0, -1.0])
×
1145

1146
        view_direction_y = np.cross(vec_z, view_direction)
×
1147
        norm_y = np.linalg.norm(view_direction_y)
×
1148

1149
        if norm_y == 0.0:
×
1150
            sign = np.dot(vec_z, view_direction)
×
1151
            self.lattice_vectors[2] *= sign
×
1152
            self.coords[:, 2] *= sign
×
1153

1154
        else:
1155
            # Orient z-axis in view direction
1156
            self.align_cartiesian_axis_to_vector(-view_direction, 2)
×
1157

1158
    def align_main_axis_along_xyz(self) -> None:
1✔
1159
        """Align coordinates of rodlike molecules along specified axis."""
1160
        _, vecs = self.get_main_axes()
×
1161
        R = np.linalg.inv(vecs.T)  # noqa: N806
×
1162
        self.coords = np.dot(self.coords, R)
×
1163

1164
    def transform(
1✔
1165
        self,
1166
        R: npt.NDArray[np.floating[Any]],
1167
        t: npt.NDArray[np.float64] = np.array([0, 0, 0]),
1168
        rotation_center: Union[npt.NDArray[np.float64], None] = None,
1169
        atom_indices: Union[npt.NDArray[np.int64], None] = None,
1170
    ) -> None:
1171
        """
1172
        Performs a symmetry transformation of the coordinates by rotation and
1173
        translation. The transformation is applied as
1174
        x_new[3x1] = x_old[3x1] x R[3x3] + t[3x1]
1175

1176
        Parameters
1177
        ----------
1178
        R : np.array
1179
            Rotation matrix in Catesian coordinates.
1180
        t : np.array, optional
1181
            Translation vector in Catesian coordinates. The default is np.array([0,0,0]).
1182
        rotation_center : np.array | None, optional
1183
            Centre of rotation. The default is None.
1184
        atom_indices : np.array | None, optional
1185
            List of indexes of atoms that should be transformed. The default is
1186
            None.
1187

1188
        Returns
1189
        -------
1190
        None
1191

1192
        """
1193
        if atom_indices is None:
×
1194
            atom_indices = np.arange(self.n_atoms)
×
1195
        if rotation_center is None:
×
1196
            temp_coords = np.dot(self.coords[atom_indices, :], R) + t
×
1197
            self.coords[atom_indices, :] = temp_coords
×
1198
        else:
1199
            temp_coords = copy.deepcopy(self.coords[atom_indices, :])
×
1200
            temp_coords -= rotation_center
×
1201
            temp_coords = np.dot(temp_coords, R) + t
×
1202
            temp_coords += rotation_center
×
1203
            self.coords[atom_indices, :] = temp_coords
×
1204

1205
    def transform_fractional(
1✔
1206
        self,
1207
        R: npt.NDArray[np.float64],
1208
        t: npt.NDArray[np.float64],
1209
        lattice=None,
1210
    ):
1211
        """
1212
        Transforms the coordinates by rotation and translation, where R,t are
1213
        given in fractional coordinates
1214
        The transformation is applied as c_new[3x1] = R[3x3] * c_old[3x1] + t[3x1]
1215

1216
        """
1217
        if lattice is None:
×
1218
            lattice = self.lattice_vectors
×
1219
        coords_frac = utils.get_fractional_coords(self.coords, lattice)
×
1220
        coords_frac = np.dot(coords_frac, R.T) + t.reshape([1, 3])
×
1221
        self.coords = utils.get_cartesian_coords(coords_frac, lattice)
×
1222

1223
    def transform_lattice(
1✔
1224
        self,
1225
        R: npt.NDArray[np.float64],
1226
        t: npt.NDArray[np.float64] = np.array([0, 0, 0]),
1227
    ) -> None:
1228
        """
1229
        Transforms the lattice vectors by rotation and translation.
1230
        The transformation is applied as x_new[3x1] = x_old[3x1] x R[3x3] + t[3x1]
1231
        Notice that this works in cartesian coordinates.
1232
        Use transform_fractional if you got your R and t from get_symmetries
1233

1234
        Parameters
1235
        ----------
1236
        R : np.array
1237
            Rotation matrix in Catesian coordinates.
1238
        t : np.array, optional
1239
            Translation vector in Catesian coordinates. The default is np.array([0,0,0]).
1240

1241
        Returns
1242
        -------
1243
        None
1244

1245
        """
1246
        new_lattice_vectors = np.dot(self.lattice_vectors, R) + t
×
1247
        self.lattice_vectors = new_lattice_vectors
×
1248

1249
    def transform_lattice_fractional(self, R, t, lattice):
1✔
1250
        """Transforms the lattice vectors by rotation and translation.
1251
        The transformation is applied as x_new[3x1] = x_old[3x1] x R[3x3] + t[3x1]
1252
        """
1253
        coords_frac = utils.get_fractional_coords(self.lattice_vectors, lattice)
×
1254
        coords_frac = np.dot(coords_frac, R.T) + t.reshape([1, 3])
×
1255
        self.lattice_vectors = utils.get_cartesian_coords(coords_frac, lattice)
×
1256

1257
    def swap_lattice_vectors(self, axis_1=0, axis_2=1):
1✔
1258
        """
1259
        Can be used to interchange two lattice vectors
1260
        Attention! Other values - for instance k_grid - will stay unchanged!!
1261
        :param axis_1 integer [0,1,2]
1262
        :param axis_2 integer [0,1,2]     axis_1 !=axis_2
1263
        :return:
1264

1265
        """
1266
        self.lattice_vectors[[axis_1, axis_2], :] = self.lattice_vectors[
×
1267
            [axis_2, axis_1], :
1268
        ]
1269
        self.coords[[axis_1, axis_2], :] = self.coords[[axis_2, axis_1], :]
×
1270

1271
    def symmetrize(self, symmetry_operations, center=None):
1✔
1272
        """
1273
        Symmetrizes Geometry with given list of symmetry operation matrices
1274
        after transferring it to the origin.
1275
        Do not include the unity matrix for symmetrizing, as it is already the
1276
        first geometry!
1277
        ATTENTION: use symmetrize_periodic to reliably symmetrize periodic
1278
        structures
1279

1280
        """
1281
        if center is not None:
×
1282
            offset = center
×
1283
            self.coords -= center
×
1284
        else:
1285
            offset = np.mean(self.coords, axis=0)
×
1286
            self.center_coordinates()
×
1287
        temp_coords = copy.deepcopy(
×
1288
            self.coords
1289
        )  # this corresponds to the unity matrix symmetry operation
1290
        for R in symmetry_operations:
×
1291
            new_geom = copy.deepcopy(self)
×
1292
            new_geom.transform(R)
×
1293
            new_geom.reorder_atoms(self.get_transformation_indices(new_geom))
×
1294
            temp_coords += new_geom.coords
×
1295
        self.coords = temp_coords / (len(symmetry_operations) + 1) + offset
×
1296

1297
    def average_with(self, other_geometries) -> None:
1✔
1298
        """
1299
        Average self.coords with those of other_geometries and apply on self.
1300

1301
        ATTENTION: this can change bond lengths etc.!Ok n
1302

1303
        Parameters
1304
        ----------
1305
        other_geometries: List of Geometrys ... might be nice to accept list of
1306
        coords too
1307
        """
1308
        if len(other_geometries) > 0:
×
1309
            offset = (
×
1310
                self.get_geometric_center()
1311
            )  # Attribute center should be used if it exists
1312
            self.coords -= offset
×
1313

1314
            for other_geom in other_geometries:
×
1315
                geom = copy.deepcopy(other_geom)
×
1316
                # center all other geometries to remove offset
1317
                geom.center_coordinates()
×
1318
                # all geometries have to be ordered like first geometry in
1319
                # order to sum them
1320
                geom.reorder_atoms(self.get_transformation_indices(geom))
×
1321
                self.coords += geom.coords
×
1322
            self.coords /= len(other_geometries) + 1  # +1 for this geometry itself
×
1323
            self.coords += offset
×
1324

1325
    def reorder_atoms(self, inds: npt.NDArray[np.int64]) -> None:
1✔
1326
        """
1327
        Reorders Atoms with index list.
1328

1329
        Parameters
1330
        ----------
1331
        inds : npt.NDArray[np.int64]
1332
            Array of indices with new order of atoms.
1333
        """
1334
        self.coords = self.coords[inds, :]
×
1335
        self.species = [self.species[i] for i in inds]
×
1336
        self.constrain_relax = self.constrain_relax[inds, :]
×
1337
        self.initial_charge = [self.initial_charge[i] for i in inds]
×
1338
        self.initial_moment = [self.initial_moment[i] for i in inds]
×
1339

1340
        inds_hessian = []
×
1341
        for ind in inds:
×
1342
            inds_new = np.array([0, 1, 2]) + 3 * ind
×
1343
            inds_hessian.append(inds_new)
×
1344

1345
        inds_hessian = np.array(inds_hessian).flatten()
×
1346

1347
        if self.hessian is not None:
×
1348
            self.hessian = self.hessian[inds_hessian, :]
×
1349
            self.hessian = self.hessian[:, inds_hessian]
×
1350

1351
    def shift_to_bottom(self) -> None:
1✔
1352
        """Shift coordinates so the one with smallest z sits at (x, y, 0)."""
1353
        min_z = np.min(self.coords[:, -1])
×
1354
        self.coords[:, -1] -= min_z
×
1355

1356
    def displace_atoms(
1✔
1357
        self,
1358
        displacement_strength: float,
1359
        displacement_indices: npt.NDArray[np.int64],
1360
    ) -> None:
1361
        """
1362
        Displaces atoms randomly.
1363

1364
        Parameters
1365
        ----------
1366
        displacement_strength : float
1367
            Scaling factor for the strenght of the dispacements.
1368
        displacement_indices : npt.NDArray[np.int64]
1369
            Indices where atoms should be dispaced.
1370

1371
        Returns
1372
        -------
1373
        None
1374

1375
        """
1376
        displacements = np.random.rand(len(displacement_indices), 3) - 0.5
1✔
1377
        displacements *= displacement_strength
1✔
1378

1379
        self.coords[displacement_indices, :] += displacements
1✔
1380

1381
    def move_multipoles(self, shift: npt.NDArray[np.float64]) -> None:
1✔
1382
        """
1383
        Moves all the multipoles by a shift vector
1384
        :param shift: list or array, len==3
1385
        :return:
1386
        """
1387
        assert len(shift) == 3
×
1388
        for m in self.multipoles:
×
1389
            m[0] += shift[0]
×
1390
            m[1] += shift[1]
×
1391
            m[2] += shift[2]
×
1392

1393
    ###########################################################################
1394
    #                      Set Properties of the Geometry                     #
1395
    ###########################################################################
1396
    def set_vacuum_height(self, vac_height, bool_shift_to_bottom=False) -> None:
1✔
1397
        if bool_shift_to_bottom:
×
1398
            self.shift_to_bottom()
×
1399
        min_z = np.min(self.coords[:, -1])
×
1400
        max_z = np.max(self.coords[:, -1])
×
1401
        self.lattice_vectors[-1, -1] = max_z + vac_height - min_z
×
1402

1403
        if vac_height < min_z:
×
1404
            raise Exception(
×
1405
                """set_vacuum_height: the defined vacuum height is smaller than
1406
                height of the lowest atom. Shift unit cell either manually or
1407
                by the keyword bool_shift_to_bottom towards the bottom
1408
                of the unit cell."""
1409
            )
1410
        self.lattice_vectors[-1, -1] = max_z + vac_height - min_z
×
1411

1412
    def set_vacuum_level(self, vacuum_level: float) -> None:
1✔
1413
        """
1414
        Sets vacuum level of geometry calculation
1415

1416
        Parameters
1417
        ----------
1418
        vacuum_level : float
1419
            Height of the vacuum level.
1420

1421
        Returns
1422
        -------
1423
        None
1424

1425
        """
1426
        self.vacuum_level = vacuum_level
×
1427

1428
    def set_multipoles_charge(self, charge: npt.NDArray[np.float64]) -> None:
1✔
1429
        """
1430
        Sets the charge of all multipoles
1431

1432
        Parameters
1433
        ----------
1434
        charge : list or float or int
1435

1436
        Returns
1437
        -------
1438
        None
1439

1440
        """
1441
        if isinstance(charge, list):
×
1442
            assert len(charge) == len(self.multipoles)
×
1443
            for i, m in enumerate(self.multipoles):
×
1444
                m[4] = charge[i]
×
1445
        else:
1446
            for i, m in enumerate(self.multipoles):
×
1447
                m[4] = charge
×
1448

1449
    def set_constraints(
1✔
1450
        self,
1451
        indices_of_atoms_to_constrain: list,
1452
        constrain_dim_flags=None,
1453
    ):
1454
        """
1455
        Sets a constraint for a few atoms in the system (identified by
1456
        'indices_of_atoms_to_constrain') for a geometry relaxation.
1457
        Since the relaxation constraint can be in any and/or all dimensions
1458
        the second parameter, 'constraint_dim_flags', makes it possible to
1459
        set which dimension(s) should be constrained for which molecule.
1460
        By default all dimensions are to be constrained for all atoms are
1461
        constrained. If the dimension to constrain should be set individually
1462
        for different atoms, you need to provide a list of booleans of the
1463
        shap len(indices_of_atoms_to_constrain) x 3, which contains the
1464
        constrain flags for each dimension for each atom.
1465

1466
        Parameters
1467
        ----------
1468
        indices_of_atoms_to_constrain : list
1469
            List of atoms to constrain.
1470
        constrain_dim_flags : list[boolean]
1471
            The default is: [True, True, True].
1472

1473
        Returns
1474
        -------
1475
        None
1476

1477
        """
1478
        if constrain_dim_flags is None:
×
1479
            constrain_dim_flags = [True, True, True]
×
1480

1481
        self.constrain_relax[indices_of_atoms_to_constrain, :] = constrain_dim_flags
×
1482

1483
    def set_constraints_based_on_space(
1✔
1484
        self,
1485
        xlim=(-np.inf, np.inf),
1486
        ylim=(-np.inf, np.inf),
1487
        zlim=(-np.inf, np.inf),
1488
        constrain_dim_flags=None,
1489
    ):
1490
        """
1491
        Constrain all atoms that are within a cuboid (defined by
1492
        limits in all dimensions: xlim, etc.) for a geometry relaxation.
1493

1494
        It is possible to define which dimension will be constrained, but since
1495
        the number of atoms in the cuboid is only calculated at runtime
1496
        the dimensions may only be set for all atoms at once. If you need to
1497
        set them individually please use set_constraints.
1498

1499
        Parameters
1500
        ----------
1501
        zlim
1502
        xlim
1503
        ylim
1504
        constrain_dim_flags : list[boolean]
1505
            The default is: [True, True, True].
1506

1507
        Returns
1508
        -------
1509
        None
1510

1511
        """
1512
        if constrain_dim_flags is None:
×
1513
            constrain_dim_flags = [True, True, True]
×
1514

1515
        # --- get indices of all atoms outside the required interval ---
1516
        indices_outside = self.get_cropping_indices(
×
1517
            xlim=xlim, ylim=ylim, zlim=zlim, auto_margin=False
1518
        )
1519
        # ---
1520

1521
        # --- Filter all that are outside ---
1522
        # The indices of the atoms of relevance to us are all that are NOT
1523
        # outside of the cuboid
1524
        indices_inside = [i for i in range(len(self)) if i not in indices_outside]
×
1525
        # ---
1526

1527
        self.set_constraints(indices_inside, constrain_dim_flags)
×
1528

1529
    def free_all_constraints(self) -> None:
1✔
1530
        """
1531
        Frees all constraints.
1532

1533
        Returns
1534
        -------
1535
        None
1536

1537
        """
1538
        self.constrain_relax = np.zeros([len(self.species), 3], bool)
×
1539

1540
    def set_calculate_friction(
1✔
1541
        self, indices_of_atoms: list, calculate_friction: bool = True
1542
    ) -> None:
1543
        """
1544
        Sets to calculate electronic friction for atoms specified by the given
1545
        list of indices.
1546

1547
        Parameters
1548
        ----------
1549
        indices_of_atoms : list
1550
            List of atoms from which electronic friction should be calculated.
1551
        calculate_friction : bool, optional
1552
            Calculate friction for these atims. The default is True.
1553

1554
        Returns
1555
        -------
1556
        None
1557

1558
        """
1559
        self.calculate_friction[indices_of_atoms] = calculate_friction
×
1560

1561
    def free_all_calculate_friction(self) -> None:
1✔
1562
        """
1563
        Set calculate electronic friction to false on all atoms.
1564

1565
        Returns
1566
        -------
1567
        None
1568

1569
        """
1570
        self.calculate_friction = np.array([False] * len(self))
×
1571

1572
    def set_external_forces(
1✔
1573
        self,
1574
        indices_of_atoms: Union[int, npt.NDArray[np.int64]],
1575
        external_force: npt.NDArray[np.float64],
1576
    ) -> None:
1577
        """
1578
        Sets a constraint for a few atoms in the system (identified by
1579
        'indices_of_atoms_to_constrain') for a geometry relaxation.
1580
        Since the relaxation constraint can be in any and/or all dimensions
1581
        the second parameter, 'constraint_dim_flags', makes it possible to
1582
        set which dimension(s) should be constrained for which molecule.
1583
        By default all dimensions are to be constrained for all atoms are
1584
        constrained. If the dimension to constrain should be set individually
1585
        for different atoms, you need to provide a list of booleans of the
1586
        shape len(indices_of_atoms_to_constrain) x 3, which contains the
1587
        constrain flags for each dimension for each atom.
1588

1589
        Parameters
1590
        ----------
1591
        indices_of_atoms_to_constrain : int or npt.NDArray[np.int64]
1592
            Index of atoms to which atoms should should be applied.
1593
        constrain_dim_flags : npt.NDArray[np.float64]
1594
            Force that should act on a given atom.
1595
        """
1596
        self.external_force[indices_of_atoms, :] = external_force
×
1597

1598
    def free_all_external_forces(self) -> None:
1✔
1599
        """
1600
        Set calculate electronic friction to false on all atoms.
1601

1602
        Returns
1603
        -------
1604
        None
1605

1606
        """
1607
        self.external_force = np.zeros((len(self), 3))
×
1608

1609
    def remove_periodicity(self):
1✔
1610
        """
1611
        Makes geometry non-periodic by setting its lattice vectors to zero
1612

1613
        """
1614
        self.lattice_vectors = np.zeros((3, 3), dtype=float)
×
1615

1616
    def set_homogeneous_field(self, E):
1✔
1617
        """Field should be a numpy array (Ex, Ey, Ez) with the Field in V/A"""
1618
        assert len(E) == 3, "Expected E-field components [Ex, Ey, Ez], but got " + str(
×
1619
            E
1620
        )
1621
        self.homogeneous_field = np.asarray(E)
×
1622

1623
    def free_homogeneous_field(self):
1✔
1624
        self.homogeneous_field = np.array([0.0, 0.0, 0.0])
×
1625

1626
    def set_initial_magnetic_moments(self, moments: List[float]) -> None:
1✔
1627
        self.initial_moment = moments
×
1628

1629
    ###############################################################################
1630
    #                      Get Properties of the Geometry                         #
1631
    ###############################################################################
1632
    def get_is_periodic(self) -> bool:
1✔
1633
        """
1634
        Checks if the geometry is periodic.
1635

1636
        Returns
1637
        -------
1638
        bool
1639
            Ture if geometry is periodic.
1640

1641
        """
1642
        return not np.allclose(self.lattice_vectors, np.zeros([3, 3]))
1✔
1643

1644
    def get_reassembled_molecule(self, threshold: float = 2.0):
1✔
1645
        geom_replica = self.get_periodic_replica(
×
1646
            (1, 1, 1),
1647
            explicit_replications=[[-1, 0, 1], [-1, 0, 1], [-1, 0, 1]],
1648
        )
1649

1650
        tree = scipy.spatial.KDTree(geom_replica.coords)
×
1651
        pairs = tree.query_pairs(threshold)
×
1652

1653
        new_cluster = True
×
1654

1655
        while new_cluster:
×
1656
            clusters = []
×
1657
            new_cluster = False
×
1658

1659
            for pair in pairs:
×
1660
                in_culster = False
×
1661
                for ind, indices in enumerate(clusters):
×
1662
                    for p in pair:
×
1663
                        if p in indices:
×
1664
                            clusters[ind] = set(list(indices) + list(pair))
×
1665
                            new_cluster = True
×
1666
                            in_culster = True
×
1667
                            break
×
1668

1669
                if not in_culster:
×
1670
                    clusters.append(set(pair))
×
1671

1672
            pairs = copy.deepcopy(clusters)
×
1673

1674
        for index_array in pairs:
×
1675
            if len(index_array) == len(self):
×
1676
                final_geom = geom_replica.get_atoms_by_indices(
×
1677
                    np.sort(np.array(list(index_array), dtype=np.int32))
1678
                )
1679
                final_geom.lattice_vectors = self.lattice_vectors
×
1680
                final_geom.map_center_of_atoms_to_first_unit_cell()
×
1681

1682
                return final_geom
×
1683

1684
        warnings.warn(
×
1685
            "Geometry.getReassembledMolecule could not reassemble \
1686
                      molecule. Returning original Geometry."
1687
        )
1688
        return self
×
1689

1690
    def get_scaled_copy(self, scaling_factor: Union[float, List]) -> object:
1✔
1691
        """
1692
        Returns a copy of the geometry, scaled by scaling_factor.
1693

1694
        Both the coordinates of the atoms and the length of the lattice vectors are
1695
        affected
1696

1697
        Parameters
1698
        ----------
1699
        scaling_factor : Union[float, Iterator]
1700
            Scaling factor for the geometry. If float, the volume of the geometry
1701
            will be scaled accordingly. If iterable, the length of the lattice vectors
1702
            will be scaled accordingly.
1703

1704
        Returns
1705
        -------
1706
        scaled_geometry : Geometry
1707
            Geometry object with scaled coordinates and lattice vectors
1708
        """
1709
        assert hasattr(self, "lattice_vectors"), (
×
1710
            "This function only works for geometries with a Unit Cell"
1711
        )
1712

1713
        if isinstance(scaling_factor, float):
×
1714
            scaling_factors = [
×
1715
                scaling_factor ** (1 / 3),
1716
            ] * 3
1717

1718
        else:
1719
            assert len(scaling_factor) == 3
×
1720
            scaling_factors = scaling_factor
×
1721

1722
        scaled_geom = deepcopy(self)
×
1723
        lattice_vectors = deepcopy(self.lattice_vectors)
×
1724
        lattice_vectors[0] *= scaling_factors[0]
×
1725
        lattice_vectors[1] *= scaling_factors[1]
×
1726
        lattice_vectors[2] *= scaling_factors[2]
×
1727

1728
        new_coords = utils.get_cartesian_coords(
×
1729
            self.get_fractional_coords(), lattice_vectors
1730
        )
1731
        scaled_geom.lattice_vectors = lattice_vectors
×
1732
        scaled_geom.coords = new_coords
×
1733

1734
        return scaled_geom
×
1735

1736
    def get_displaced_atoms(
1✔
1737
        self,
1738
        displacement_strength: float,
1739
        displace_only_unconstrained: bool = True,
1740
    ):
1741
        """
1742
        Returns a copy of the geometry, where the atoms have been displaced
1743
        randomly.
1744

1745
        Parameters
1746
        ----------
1747
        displacement_strength : float
1748
            Scaling factor for the strenght of the dispacements.
1749
        displace_only_unconstrained : bool
1750
            Indices where atoms should be dispaced. The default is True.
1751

1752
        Returns
1753
        -------
1754
        geometry.
1755

1756
        """
1757
        if displace_only_unconstrained:
1✔
1758
            displacement_indices = self.get_unconstrained_atoms()
1✔
1759
        else:
1760
            displacement_indices = np.array(range(len(self)))
×
1761

1762
        new_geometry = deepcopy(self)
1✔
1763
        new_geometry.displace_atoms(displacement_strength, displacement_indices)
1✔
1764

1765
        return new_geometry
1✔
1766

1767
    def get_fractional_coords(self, lattice_vectors=None):
1✔
1768
        if lattice_vectors is None:
×
1769
            lattice_vectors = self.lattice_vectors
×
1770

1771
        assert not np.allclose(lattice_vectors, np.zeros([3, 3])), (
×
1772
            "Lattice vector must be defined in Geometry or given as function parameter"
1773
        )
1774

1775
        fractional_coords = np.linalg.solve(lattice_vectors.T, self.coords.T)
×
1776
        return fractional_coords.T
×
1777

1778
    def get_fractional_lattice_vectors(self, lattice_vectors=None):
1✔
1779
        """
1780
        Calculate the fractional representation of lattice vectors of the
1781
        geometry file in another basis.
1782
        Useful to calculate epitaxy matrices
1783

1784
        """
1785
        fractional_coords = np.linalg.solve(lattice_vectors.T, self.lattice_vectors.T)
×
1786
        return fractional_coords.T
×
1787

1788
    def get_reciprocal_lattice_vectors(self) -> npt.NDArray[np.float64]:
1✔
1789
        """
1790
        Calculate the reciprocal lattice of the Geometry lattice_vectors in standard form
1791
        For convention see en.wikipedia.org/wiki/Reciprocal_lattice
1792

1793
        Returns
1794
        -------
1795
        recip_lattice : npt.NDArray[np.float64]
1796
            Row-wise reciprocal lattice vectors (3x3)
1797

1798
        """
1799
        a1 = self.lattice_vectors[0]
×
1800
        a2 = self.lattice_vectors[1]
×
1801
        a3 = self.lattice_vectors[2]
×
1802

1803
        volume = np.cross(a1, a2).dot(a3)
×
1804

1805
        b1 = np.cross(a2, a3)
×
1806
        b2 = np.cross(a3, a1)
×
1807
        b3 = np.cross(a1, a2)
×
1808

1809
        recip_lattice = np.array([b1, b2, b3]) * 2 * np.pi / volume
×
1810
        return recip_lattice
×
1811

1812
    def get_main_axes(
1✔
1813
        self, weights: Union[str, npt.NDArray[np.float64]] = "unity"
1814
    ) -> Tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
1815
        """
1816
        Get main axes and eigenvalues of a molecule
1817
        https://de.wikipedia.org/wiki/Tr%C3%A4gheitstensor
1818

1819
        Parameters
1820
        ----------
1821
        weights : Union[str, npt.NDArray[np.float64]] = "unity"
1822
            Specifies how the atoms are weighted.
1823
            "unity": all same weight
1824
            "Z": weighted by atomic number.
1825
            The default is 'unity'.
1826

1827
        Returns
1828
        -------
1829
        vals : npt.NDArray[np.float64]
1830
            TODO DESCRIPTION.
1831
        vecs : npt.NDArray[np.float64]
1832
            TODO DESCRIPTION.
1833

1834
        """
1835
        if weights == "unity":
×
1836
            weights = np.ones(self.n_atoms)
×
1837
        elif weights == "Z":
×
1838
            weights = np.array([PeriodicTable.get_atomic_mass(s) for s in self.species])
×
1839

1840
        coords = self.coords - np.mean(self.coords, axis=0)
×
1841
        diag_entry = np.sum(np.sum(coords**2, axis=1) * weights)
×
1842
        ident = np.eye(3) * diag_entry
×
1843
        for i in range(self.n_atoms):
×
1844
            ident -= weights[i] * np.outer(coords[i, :], coords[i, :])
×
1845

1846
        vals, vecs = scipy.linalg.eigh(ident)
×
1847
        sort_ind = np.argsort(vals)
×
1848

1849
        return vals[sort_ind], vecs[:, sort_ind]
×
1850

1851
    def get_distance_between_all_atoms(self) -> npt.NDArray[np.float64]:
1✔
1852
        """
1853
        Get the distance between all atoms in the current Geometry
1854
        object. Gives an symmetric array where distances between atom i and j
1855
        are denoted in the array elements (ij) and (ji).
1856

1857
        """
1858
        distances = scipy.spatial.distance.cdist(self.coords, self.coords)
×
1859
        return distances
×
1860

1861
    def get_closest_atoms(self, indices, species=None, n_closest=1):
1✔
1862
        """
1863
        Get the indices of the closest atom(s) for the given index or list of
1864
        indices
1865

1866
        Parameters
1867
        ----------
1868
        index: int or iterable
1869
            atoms for which the closest indices are  to be found
1870
        species: None or list of species identifiers
1871
            species to consider for closest atoms. This allows to get only the
1872
            closest atoms of the same or another species
1873
        n_closest: int
1874
            number of closest atoms to return
1875

1876
        Returns
1877
        -------
1878
        closest_atoms_list: list or list of lists
1879
            closest atoms for each entry in index
1880
        """
1881
        all_distances = self.get_distance_between_all_atoms()
×
1882

1883
        if species is None:
×
1884
            species_to_consider = list(set(self.species))
×
1885
        else:
1886
            assert isinstance(species, list), (
×
1887
                "species must be a list of species identifiers or None if all atoms should be probed"
1888
            )
1889
            species_to_consider = species
×
1890

1891
        return_single_list = False
×
1892
        if not isinstance(indices, Iterable):
×
1893
            return_single_list = True
×
1894
            indices = [indices]
×
1895

1896
        indices_to_consider = []
×
1897
        for i, s in enumerate(self.species):
×
1898
            if (s in species_to_consider) and (i not in indices):
×
1899
                indices_to_consider.append(i)
×
1900
        indices_to_consider = np.array(indices_to_consider)
×
1901

1902
        closest_atoms_list = []
×
1903
        for index in indices:
×
1904
            distances = all_distances[index, indices_to_consider]
×
1905
            distance_indices = np.argsort(distances)
×
1906
            closest_atoms = indices_to_consider[distance_indices]
×
1907
            if len(closest_atoms) > n_closest:
×
1908
                closest_atoms = closest_atoms[:n_closest]
×
1909

1910
            closest_atoms_list.append(closest_atoms.tolist())
×
1911

1912
        if return_single_list:  # can only be true if only a single index was specified
×
1913
            return closest_atoms_list[0]
×
1914
        return closest_atoms_list
×
1915

1916
    def get_distance_between_two_atoms(self, atom_indices: list) -> np.float64:
1✔
1917
        """Get the distance between two atoms in the current Geometry
1918
        object.
1919
        """
1920
        atom1 = self.coords[atom_indices[0], :]
×
1921
        atom2 = self.coords[atom_indices[1], :]
×
1922
        vec = atom2 - atom1
×
1923
        dist = np.linalg.norm(vec)
×
1924

1925
        return dist
×
1926

1927
    def get_volume_of_unit_cell(self) -> np.float64:
1✔
1928
        """
1929
        Calcualtes the volume of the unit cell.
1930

1931
        Returns
1932
        -------
1933
        volume : float
1934
            Volume of the unit cell.
1935

1936
        """
1937
        a1 = self.lattice_vectors[0]
×
1938
        a2 = self.lattice_vectors[1]
×
1939
        a3 = self.lattice_vectors[2]
×
1940
        volume = np.cross(a1, a2).dot(a3)
×
1941
        return volume
×
1942

1943
    def get_geometric_center(
1✔
1944
        self, ignore_center_attribute=False, indices=None
1945
    ) -> npt.NDArray[np.float64]:
1946
        """
1947
        Returns the center of the geometry. If the attribute *center* is set,
1948
        it is used as the definition for the center of the geometry.
1949

1950
        Parameters
1951
        ----------
1952
        ignore_center_attribute : Bool
1953
            If True, the attribute self.center is used
1954
            Otherwise, the function returns the geometric center of the structure,
1955
            i.e. the average over the position of all atoms.
1956

1957
        indices: iterable of indices or None
1958
            indices of all atoms to consider when calculating the center. Useful to calculate centers of adsorbates only
1959
            if None, all atoms are used
1960

1961
        Returns
1962
        -------
1963
        center : npt.NDArray[np.float64]
1964
            Center of the geometry
1965
        """
1966
        if (
×
1967
            not hasattr(self, "center")
1968
            or self.center is None
1969
            or ignore_center_attribute
1970
            or indices is not None
1971
        ):
1972
            if indices is None:
×
1973
                indices = np.arange(self.n_atoms)
×
1974
            center = np.mean(self.coords[indices], axis=0)
×
1975
        else:
1976
            center = np.zeros([3])
×
1977
            for i, weight in self.center.items():
×
1978
                center += self.coords[i, :] * weight
×
1979
        return center
×
1980

1981
    def get_center_of_mass(self) -> npt.NDArray[np.float64]:
1✔
1982
        """
1983
        Mind the difference to self.get_geometric_center
1984

1985
        Returns
1986
        -------
1987
        center_of_mass: npt.NDArray[np.float64]
1988
            The 3D-coordinate of the center of mass
1989

1990
        """
1991
        species_helper = []
×
1992
        for si in self.species:
×
1993
            si_new = si.split("_")[0]
×
1994
            species_helper.append(si_new)
×
1995

1996
        masses_np = np.array(
×
1997
            [PeriodicTable.get_atomic_mass(s) for s in species_helper],
1998
            dtype=np.float64,
1999
        )
2000
        center_of_mass = self.coords.T.dot(masses_np) / masses_np.sum()
×
2001
        return center_of_mass
×
2002

2003
    def get_symmetries(
1✔
2004
        self,
2005
        symmetry_precision: float = 1e-05,
2006
        remove_refelction_in_z: bool = False,
2007
    ):
2008
        """
2009
        Returns symmetries (rotation and translation matrices) from spglig.
2010
        works only for unitcell and supercell geometries (lattice vecotrs must
2011
        not be 0)
2012

2013
        Beware: The returned symmetry matrices are given with respect to
2014
        fractional coordinates, not Cartesian ones!
2015

2016
        See https://atztogo.github.io/spglib/python-spglib.html#get-symmetry
2017
        for details
2018

2019
        Parameters
2020
        ----------
2021
        save_directory : str
2022
            save directory in string format, file will be name symmetry.pickle
2023
            (default = None --> symmetry is not saved)
2024

2025
        Raises
2026
        ------
2027
        ValueError: If lattice vectors are 0
2028
        """
2029
        if np.count_nonzero(self.lattice_vectors) == 0:
1✔
2030
            print(
×
2031
                "Lattice vectors must not be 0! getSymmetry requires a unitcell-like"
2032
                "geometry file!"
2033
            )
2034
            raise ValueError(self.lattice_vectors)
×
2035

2036
        unit_cell = self.get_spglib_cell()
1✔
2037
        symmetry = spglib.get_symmetry(unit_cell, symprec=symmetry_precision)
1✔
2038

2039
        rotations = symmetry["rotations"]
1✔
2040
        translations = symmetry["translations"]
1✔
2041

2042
        if remove_refelction_in_z:
1✔
2043
            upside = rotations[:, 2, 2] == 1
×
2044
            rotations = rotations[upside, :, :]
×
2045
            translations = translations[upside, :]
×
2046

2047
        return rotations, translations
1✔
2048

2049
    def get_atomic_numbers_of_atoms(self) -> npt.NDArray[np.float64]:
1✔
2050
        """Get the atomic numbers of all atoms in the geometry file."""
2051
        species = [PeriodicTable.get_atomic_number(s) for s in self.species]
×
2052
        return np.array(species)
×
2053

2054
    def get_number_of_electrons(self) -> float:
1✔
2055
        """
2056
        Determines the number of electrons.
2057

2058
        Returns
2059
        -------
2060
        float
2061
            Number of electrons.
2062

2063
        """
2064
        electrons = []
1✔
2065
        for s in self.species:
1✔
2066
            try:
1✔
2067
                curr_species = s.split("_")[0] if "_" in s else s
1✔
2068
                electrons.append(PeriodicTable.get_atomic_number(curr_species))
1✔
2069

2070
            except KeyError:
×
2071
                msg = f"Species {s} is not known"
×
2072
                raise KeyError(msg)
×
2073

2074
        return np.sum(electrons)
1✔
2075

2076
    def get_atomic_masses(self) -> npt.NDArray[np.float64]:
1✔
2077
        """
2078
        Determines the atomic mass for all atoms.
2079

2080
        Returns
2081
        -------
2082
        masses : np.array
2083
            List of atomic masses for all atoms in the same order as
2084
            the atoms.
2085

2086
        """
2087
        masses = []
×
2088
        for s in self.species:
×
2089
            try:
×
2090
                curr_species = s.split("_")[0] if "_" in s else s
×
2091

2092
                mass = PeriodicTable.get_atomic_mass(curr_species)
×
2093
                masses.append(mass)
×
2094

2095
            except KeyError:
×
2096
                msg = f"Atomic mass for species {s} is not known"
×
2097
                raise KeyError(msg)
×
2098

2099
        return np.array(masses)
×
2100

2101
    def get_total_mass(self) -> float:
1✔
2102
        """
2103
        Determines the atomic mass of the entrie geometry.
2104

2105
        Returns
2106
        -------
2107
        atomic_mass : float
2108
            Atomic mass of the entrie geometry.
2109

2110
        """
2111
        atomic_mass = 0
×
2112

2113
        for s in self.species:
×
2114
            atomic_mass += PeriodicTable.get_atomic_mass(s)
×
2115

2116
        return atomic_mass
×
2117

2118
    def get_largest_atom_distance(self, dims_to_consider=(0, 1, 2)) -> float:
1✔
2119
        """
2120
        Find largest distance between atoms in geometry.
2121
        #search tags; molecule length, maximum size
2122

2123
        Parameters
2124
        ----------
2125
        dims_to_consider : list
2126
            Dimensions along which largest distance should be calculated.
2127

2128
        Returns
2129
        -------
2130
        geometry_size : float
2131
            Largest distance between two atoms in the unit cell.
2132

2133
        """
2134
        mask = np.array([i in dims_to_consider for i in range(3)], dtype=bool)
×
2135
        geometry_size = 0.0
×
2136
        for ind1 in range(self.n_atoms):
×
2137
            for ind2 in range(ind1, self.n_atoms):
×
2138
                geometry_size_test = np.linalg.norm(
×
2139
                    self.coords[ind1][mask] - self.coords[ind2][mask]
2140
                )
2141

2142
                if geometry_size_test > geometry_size:
×
2143
                    geometry_size = float(geometry_size_test)
×
2144

2145
        return geometry_size
×
2146

2147
    def get_area(self) -> np.float64:
1✔
2148
        """
2149
        Returns the area of the surface described by lattice_vectors 0 and 1 of
2150
        the geometry, assuming that the lattice_vector 2 is orthogonal to both.
2151

2152
        Returns
2153
        -------
2154
        area : float
2155
            Area of the unit cell.
2156

2157
        """
2158
        a = deepcopy(self.lattice_vectors[0, :])
×
2159
        b = deepcopy(self.lattice_vectors[1, :])
×
2160
        area = np.abs(np.cross(a, b)[-1])
×
2161

2162
        return area
×
2163

2164
    def get_area_in_atom_numbers(
1✔
2165
        self,
2166
        substrate_indices: Union[npt.NDArray[np.float64], None] = None,
2167
        substrate=None,
2168
    ) -> float:
2169
        """
2170
        Returns the area of the unit cell in terms of substrate atoms in the
2171
        topmost substrate layer. The substrate is determined by
2172
        default by the function self.getSubstrate(). For avoiding a faulty
2173
        substrate determination it can be either given through indices or
2174
        through the substrate geometry itself
2175

2176
        Parameters
2177
        ----------
2178
        substrate_indices : Union[np.array, None], optional
2179
            List of indices of substrate atoms. The default is None.
2180
        substrate : TYPE, optional
2181
            Geometry of substrate. The default is None.
2182

2183
        Returns
2184
        -------
2185
        float
2186
            Area of the unit cell in units of the area of the substrate.
2187

2188
        """
2189
        topmost_sub_layer = self.get_substrate_layers(
×
2190
            layer_indices=[0],
2191
            substrate_indices=substrate_indices,
2192
            primitive_substrate=substrate,
2193
        )
2194
        return topmost_sub_layer.n_atoms
×
2195

2196
    def get_bond_lengths(self, bond_factor: float = 1.5) -> npt.NDArray[np.float64]:
1✔
2197
        """
2198
        Parameters
2199
        ----------
2200
        Parameter for bond detection based on atom-distance : float, optional
2201
            DESCRIPTION. The default is 1.5.
2202

2203
        Returns
2204
        -------
2205
        bond_lengths : npt.NDArray[np.float64]
2206
            List of bond lengths for neighbouring atoms.
2207

2208
        """
2209
        raise NotImplementedError
×
2210

2211
        # TODO write the below function
2212
        neighbouring_atoms = self.get_all_neighbouring_atoms(bond_factor=bond_factor)
2213

2214
        bond_lengths = []
2215
        for v in neighbouring_atoms.values():
2216
            bond_lengths.append(v[1])
2217

2218
        bond_lengths = np.array(bond_lengths)
2219
        return bond_lengths
2220

2221
    def get_number_of_atom_layers(self, threshold: float = 1e-2) -> Tuple[dict, float]:
1✔
2222
        """
2223
        Return the number of atom layers.
2224

2225
        Parameters
2226
        ----------
2227
        threshold : float, optional
2228
            Threshold to determine the number of layers. The default is 1e-2.
2229

2230
        Returns
2231
        -------
2232
        dict
2233
            Number of layers per atom species.
2234
        float
2235
            Total number of layers.
2236

2237
        """
2238
        layers = self.get_atom_layers_indices(threshold=threshold)
×
2239

2240
        total_number_layers = 0
×
2241
        for atom_species in layers:
×
2242
            layers[atom_species] = len(layers[atom_species])
×
2243
            total_number_layers += layers[atom_species]
×
2244

2245
        return layers, total_number_layers
×
2246

2247
    def get_unit_cell_parameters(
1✔
2248
        self,
2249
    ) -> Tuple[np.float64, np.float64, np.float64, float, float, float]:
2250
        """
2251
        Determines unit cell parameters.
2252

2253
        Returns
2254
        -------
2255
        a : float
2256
            Length of lattice vector 1.
2257
        b : float
2258
            Length of lattice vector 2.
2259
        c : float
2260
            Length of lattice vector 3.
2261
        alpha : float
2262
            Angle between lattice vectors 1 and 2.
2263
        beta : float
2264
            Angle between lattice vectors 2 and 3.
2265
        gamma : float
2266
            Angle between lattice vectors 1 and 3.
2267

2268
        """
2269
        cell = self.lattice_vectors
×
2270

2271
        a = np.linalg.norm(cell[0])
×
2272
        b = np.linalg.norm(cell[1])
×
2273
        c = np.linalg.norm(cell[2])
×
2274

2275
        alpha = np.arccos(np.dot(cell[1], cell[2]) / (c * b))
×
2276
        gamma = np.arccos(np.dot(cell[1], cell[0]) / (a * b))
×
2277
        beta = np.arccos(np.dot(cell[2], cell[0]) / (a * c))
×
2278

2279
        return a, b, c, alpha, beta, gamma
×
2280

2281
    def get_spglib_cell(
1✔
2282
        self,
2283
    ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.int64], list]:
2284
        """
2285
        Returns the unit cell in a format that can be used in spglib.
2286

2287
        Used to find symmetries
2288

2289
        return : tuple
2290
            (lattice vectors, frac coordinates of atoms, atomic numbers)
2291
        """
2292
        coordinates = utils.get_fractional_coords(self.coords, self.lattice_vectors)
1✔
2293

2294
        atom_number = [
1✔
2295
            PeriodicTable.get_atomic_number(atom_name) for atom_name in self.species
2296
        ]
2297

2298
        return (self.lattice_vectors, coordinates, atom_number)
1✔
2299

2300
    def get_orientation_of_main_axis(self) -> float:
1✔
2301
        """
2302
        Get the orientation of the main axis relative to the x axis
2303

2304
        This is transformed such that it always points in the upper half of cartesian
2305
        space
2306

2307
        Returns
2308
        -------
2309
        float
2310
            angle between main axis and x axis in degree
2311
        """
2312
        main_ax = self.get_main_axes()[1][:, 0]
×
2313

2314
        if main_ax[1] < 0:
×
2315
            main_ax *= -1
×
2316

2317
        return np.arctan2(main_ax[1], main_ax[0]) * 180 / np.pi
×
2318

2319
    def get_constrained_atoms(self) -> npt.NDArray[np.int64]:
1✔
2320
        """
2321
        Returns indice of constrained atoms.
2322

2323
        Returns
2324
        -------
2325
        inds : npt.NDArray[np.int64]
2326
            Indice of constrained atoms.
2327

2328
        """
2329
        constrain = np.any(self.constrain_relax, axis=1)
1✔
2330
        inds = [i for i, c in enumerate(constrain) if c]
1✔
2331
        inds = np.array(inds)
1✔
2332
        return inds
1✔
2333

2334
    def get_unconstrained_atoms(self) -> npt.NDArray[np.int64]:
1✔
2335
        """
2336
        Returns indice of unconstrained atoms.
2337

2338
        Returns
2339
        -------
2340
        inds : npt.NDArray[np.int64]
2341
            Indice of unconstrained atoms.
2342

2343
        """
2344
        all_inds = list(range(len(self)))
1✔
2345
        keep_inds = self.get_constrained_atoms()
1✔
2346
        inds = np.array(list(set(all_inds) - set(keep_inds)))
1✔
2347
        return inds
1✔
2348

2349
    def get_cropping_indices(
1✔
2350
        self,
2351
        xlim: tuple[float, float] = (-np.inf, np.inf),
2352
        ylim: tuple[float, float] = (-np.inf, np.inf),
2353
        zlim: tuple[float, float] = (-np.inf, np.inf),
2354
        auto_margin: bool = False,
2355
        inverse: bool = False,
2356
    ) -> npt.NDArray[np.int64]:
2357
        """
2358
        Gets indices of all atoms that are outside specified bounds.
2359
        If auto_margin == True then an additional margin of the maximum covalent radius
2360
        is added to all borders
2361
        :param inverse : if True, gets indices of all atoms INSIDE specified bounds
2362

2363
        """
2364
        if auto_margin:
×
2365
            margin = max([PeriodicTable.get_covalent_radius(s) for s in self.species])
×
2366
            xlim = xlim[0] - margin, xlim[1] + margin
×
2367
            ylim = ylim[0] - margin, ylim[1] + margin
×
2368
            zlim = zlim[0] - margin, zlim[1] + margin
×
2369

2370
        remove = np.zeros(len(self), bool)
×
2371
        remove = remove | (self.coords[:, 0] < xlim[0])
×
2372
        remove = remove | (self.coords[:, 0] > xlim[1])
×
2373
        remove = remove | (self.coords[:, 1] < ylim[0])
×
2374
        remove = remove | (self.coords[:, 1] > ylim[1])
×
2375
        remove = remove | (self.coords[:, 2] < zlim[0])
×
2376
        remove = remove | (self.coords[:, 2] > zlim[1])
×
2377
        indices_to_remove = np.arange(len(self))[remove]
×
2378
        if inverse:
×
2379
            indices_to_remove = [
×
2380
                i for i in range(self.n_atoms) if i not in indices_to_remove
2381
            ]
2382

2383
        return np.array(indices_to_remove)
×
2384

2385
    def get_substrate_indices_from_parts(
1✔
2386
        self, do_warn: bool = True
2387
    ) -> Union[None, npt.NDArray[np.int64]]:
2388
        """
2389
        Returns indices of those atoms that a part of the substrate. The
2390
        definition of the substrate does NOT rely of it being a metal or the
2391
        height or something like that. Instead a geometry part named
2392
        'substrate' must be defined.
2393

2394
        Parameters
2395
        ----------
2396
        do_warn : boolean
2397
            Can be set to False to suppress warnings
2398

2399
        Returns
2400
        -------
2401
        substrate_indices : npt.NDArray[np.int64]
2402

2403
        """
2404
        substrate_key = "substrate"  # should probably moved to a class variable, but first finished folder-read-write
×
2405

2406
        if not hasattr(self, "geometry_parts") or not hasattr(
×
2407
            self, "geometry_part_descriptions"
2408
        ):
2409
            if do_warn:
×
2410
                print("getSubstrate: geometry parts not defined")
×
2411
            return None
×
2412

2413
        if substrate_key not in self.geometry_part_descriptions:
×
2414
            if do_warn:
×
2415
                print(
×
2416
                    "getSubstrate: geometry parts are defined, "
2417
                    f"but part '{substrate_key}' not found"
2418
                )
2419
            return None
×
2420

2421
        index_of_geometry_parts_substrate = self.geometry_part_descriptions.index(
×
2422
            substrate_key
2423
        )
2424
        substrate_indices = self.geometry_parts[index_of_geometry_parts_substrate]
×
2425
        substrate_indices = np.array(substrate_indices)
×
2426
        return substrate_indices
×
2427

2428
    def get_indices_of_metal(self) -> npt.NDArray[np.int64]:
1✔
2429
        """
2430
        Get indices of all metals.
2431

2432
        These are atoms with atomic number > 18 and atomic numbers = 3,4,11,12,13,14
2433

2434
        Returns
2435
        -------
2436
        metal_atoms : npt.NDArray[np.int64]
2437
            Inidices of metallic atoms.
2438
        """
2439
        atom_inds = [PeriodicTable.get_atomic_number(s) for s in self.species]
×
2440
        metal_atoms = []
×
2441
        for i, ind in enumerate(atom_inds):
×
2442
            if (ind > 18) or (ind in [3, 4, 11, 12, 13, 14]):
×
2443
                metal_atoms.append(i)
×
2444

2445
        return np.array(metal_atoms, dtype=np.int64)
×
2446

2447
    def get_indices_of_molecules(self, substrate_species=None) -> npt.NDArray[np.int64]:
1✔
2448
        """
2449
        Fetches the indices of the substrate atoms, but it defaults to
2450
        just returning all non-metal atom's indices!
2451

2452
        If substrate_species is given indices of all atoms that are not of the
2453
        substrate species are returned.
2454
        """
2455
        if substrate_species:
×
2456
            substrate_indices = self.get_indices_of_species(substrate_species)
×
2457
        else:
2458
            substrate_indices = self.get_indices_of_metal()
×
2459

2460
        molecules = [i for i in range(self.n_atoms) if i not in substrate_indices]
×
2461
        return np.array(molecules)
×
2462

2463
    def get_indices_of_species(self, species) -> npt.NDArray[np.int64]:
1✔
2464
        """
2465
        Returns all indices of atoms the are of species defined in the input.
2466
        species can be a string of a list
2467

2468
        """
2469
        # make sure species is a list
2470
        if isinstance(species, str):
×
2471
            species = [species]
×
2472

2473
        species_indices = [i for i in range(self.n_atoms) if self.species[i] in species]
×
2474
        species_indices = np.array(species_indices)
×
2475
        return species_indices
×
2476

2477
    def get_substrate_indices(
1✔
2478
        self, primitive_substrate=None, dimension=2, threshold=0.3
2479
    ) -> npt.NDArray[np.int64]:
2480
        """
2481
        This method returns the indices of all atoms that are part of the substrate.
2482
        Often these are simply all metal atoms of the geometry.
2483
        But the substrate can also be organic in which case it can't be picked by being a metal.
2484
        And there might be multiple adsorbate layers in which case only the molecules of the highest layer
2485
        shall be counted as adsorbates and the others are part of the substrate.
2486

2487
        dimension: int      only used if primitive_substrate is not None
2488
        threshold: float    only used if primitive_substrate is not None
2489

2490
        Returns
2491
        -------
2492
        indices of all substrate atoms: npt.NDArray[np.int64]
2493

2494
        """
2495
        # case 1: if a primitive_substrate was passed, use that one for the decision
2496
        # (copied from self.removeSubstrate)
2497

2498
        substrate_indices = []
×
2499

2500
        # case 2: if no substrate was passed but a geometry_parts "substrate" is defined in geometry_parts, use that one
2501
        substrate_indices_from_parts = self.get_substrate_indices_from_parts(
×
2502
            do_warn=False
2503
        )
2504

2505
        if primitive_substrate is not None:
×
2506
            substrate_species = set(primitive_substrate.species)
×
2507
            substrate_heights = primitive_substrate.coords[:, dimension]
×
2508
            substrate_candidate_indices = [
×
2509
                i for i, s in enumerate(self.species) if s in substrate_species
2510
            ]
2511
            for c in substrate_candidate_indices:
×
2512
                if np.any(
×
2513
                    np.absolute(substrate_heights - self.coords[c, dimension])
2514
                    < threshold
2515
                ):
2516
                    substrate_indices.append(c)
×
2517

2518
        elif substrate_indices_from_parts is not None:
×
2519
            substrate_indices = substrate_indices_from_parts
×
2520

2521
        else:
2522
            # case 3: if neither a substrate was passed, nor a geometry_parts "substrate" is defined,
2523
            #            use a fallback solution: assume that the substrate (and nothing else) is a metal
2524
            warnings.warn(
×
2525
                "Geometry.getIndicesOfAdsorbates: Substrate is not explicitly defined. "
2526
                "Using fallback solution of counting all metal atoms as substrate."
2527
            )
2528
            substrate_indices = self.get_indices_of_metal()
×
2529

2530
        return np.array(substrate_indices)
×
2531

2532
    def get_adsorbate_indices(self, primitive_substrate=None) -> npt.NDArray[np.int64]:
1✔
2533
        """
2534
        This method returns the indices of all atoms that are NOT part of the substrate.
2535
        In a classical organic monolayer on a metal substrate these are simply all molecules.
2536
        But the substrate can also be organic in which case it can't be picked by being a metal.
2537
        And there might be multiple adsorbate layers in which case only the molecules of the highest layer
2538
        shall be counted as adsorbates.
2539

2540
        Returns
2541
        -------
2542
        indices of all adsorbate atoms: npt.NDArray[np.int64]
2543
        """
2544
        substrate_indices = self.get_substrate_indices(
×
2545
            primitive_substrate=primitive_substrate
2546
        )
2547
        # invert:
2548
        return np.array(
×
2549
            [i for i in self.get_indices_of_all_atoms() if i not in substrate_indices]
2550
        )
2551

2552
    def get_indices_of_all_atoms(self, species=None):
1✔
2553
        if species is None:
×
2554
            return [i for i in range(self.n_atoms)]
×
2555
        return [i for i in range(self.n_atoms) if self.species[i] == species]
×
2556

2557
    def get_atom_layers_indices(self, threshold: float = 1e-2) -> dict:
1✔
2558
        """
2559
        Returns a dict of the following form.
2560

2561
        Parameters
2562
        ----------
2563
        threshold : float, optional
2564
            Treshold within which atoms are considered to be in the same layer.
2565
            The default is 1e-2.
2566

2567
        Returns
2568
        -------
2569
        layers : dict
2570
            {<Element symbol>: {height: [indices of atoms of element at height]}}.
2571

2572
        """
2573
        layers = {}
×
2574

2575
        for ind, atom_coord in enumerate(self.coords):
×
2576
            atom_species = self.species[ind]
×
2577
            if atom_species not in layers:
×
2578
                layers[atom_species] = {}
×
2579

2580
            add_new_z_coord = True
×
2581
            for z_coord in layers[atom_species].keys():
×
2582
                if abs(atom_coord[2] - z_coord) < threshold:
×
2583
                    layers[atom_species][z_coord].append(ind)
×
2584
                    add_new_z_coord = False
×
2585

2586
            if add_new_z_coord:
×
2587
                layers[atom_species][atom_coord[2]] = [ind]
×
2588

2589
        return layers
×
2590

2591
    def get_atom_layers_indices_by_height(self, threshold: float = 1e-2) -> dict:
1✔
2592
        """
2593
        Similarly to get_atom_layers_indices this function returns a dict
2594
        continaing info about height and the indices of atoms at that height.
2595

2596
        Parameters
2597
        ----------
2598
        threshold : float, optional
2599
            reshold within which atoms are considered to be in the same layer.
2600
            The default is 1e-2.
2601

2602
        Returns
2603
        -------
2604
        layers_by_height : dict
2605
            {height: [indices of atoms at that height]}.
2606

2607
        """
2608
        layers_by_species = self.get_atom_layers_indices(threshold=threshold)
×
2609

2610
        layers_by_height = defaultdict(list)
×
2611

2612
        # --- merge height-indices dicts ---
2613
        for data in layers_by_species.values():
×
2614
            for height, indices in data.items():
×
2615
                new = True
×
2616
                for new_height in layers_by_height.keys():
×
2617
                    if abs(height - new_height) < threshold:
×
2618
                        layers_by_height[new_height] += indices
×
2619
                        new = False
×
2620
                if new:
×
2621
                    layers_by_height[height] += indices
×
2622

2623
        # sort dictionary by descending height
2624
        layers_by_height = dict(sorted(layers_by_height.items(), reverse=True))
×
2625
        return layers_by_height
×
2626

2627
    def get_list_of_neighbouring_atoms(self, bond_factor: float = 1.5) -> dict:
1✔
2628
        """
2629
        Get a dictionary of neighbouring atoms.
2630

2631
        Parameters
2632
        ----------
2633
        bond_factor : float, optional
2634
            Multiply for covelent radius. If the distance between two atoms is
2635
            smaller thand (r_0+r_1)*bond_factor, the atoms are considered
2636
            neighbours. The default is 1.5.
2637

2638
        Returns
2639
        -------
2640
        neighbouring_atoms : dict
2641
            {(index_0, index_1) : [(element_0, element_1), atom distance]}.
2642

2643
        """
2644
        coords = self.coords
×
2645
        species = self.species
×
2646

2647
        all_species = set(species)
×
2648
        all_species_pairs = itertools.product(all_species, repeat=2)
×
2649

2650
        bond_thresholds = {}
×
2651
        for pair in all_species_pairs:
×
2652
            bond_thresholds[pair] = (
×
2653
                PeriodicTable.get_covalent_radius(pair[0])
2654
                + PeriodicTable.get_covalent_radius(pair[1])
2655
            ) * bond_factor
2656

2657
        neighbouring_atoms = {}
×
2658

2659
        for i, coord in enumerate(coords):
×
2660
            for j, coord_test in enumerate(coords):
×
2661
                if i >= j:
×
2662
                    continue
×
2663

2664
                pair_index = (i, j)
×
2665

2666
                if pair_index not in neighbouring_atoms:
×
2667
                    dist = np.linalg.norm(coord - coord_test)
×
2668

2669
                    pair_species = (species[i], species[j])
×
2670

2671
                    if dist < bond_thresholds[pair_species]:
×
2672
                        neighbouring_atoms[pair_index] = [pair_species, dist]
×
2673

2674
        return neighbouring_atoms
×
2675

2676
    def get_principal_moments_of_inertia(self) -> npt.NDArray[np.float64]:
1✔
2677
        """
2678
        Calculates the eigenvalues of the moments of inertia matrix
2679

2680
        Returns
2681
        -------
2682
        moments : np.array, shape=(3,), dtype=np.float64
2683
            principal moments of inertia in kg * m**2
2684

2685
        """
2686
        masses_kg = [
×
2687
            units.ATOMIC_MASS_IN_KG * PeriodicTable.get_atomic_mass(s)
2688
            for s in self.species
2689
        ]
2690

2691
        center_of_mass = self.get_center_of_mass()
×
2692
        r_to_center_in_m = units.ANGSTROM_IN_METER * (self.coords - center_of_mass)
×
2693

2694
        ###########
2695
        # begin: code based on ase/atoms.py: get_moments_of_inertia
2696
        # (GNU Lesser General Public License)
2697
        # Initialize elements of the inertial tensor
2698
        I11 = I22 = I33 = I12 = I13 = I23 = 0.0
×
2699
        for i in range(len(self)):
×
2700
            x, y, z = r_to_center_in_m[i]
×
2701
            m = masses_kg[i]
×
2702

2703
            I11 += m * (y**2 + z**2)
×
2704
            I22 += m * (x**2 + z**2)
×
2705
            I33 += m * (x**2 + y**2)
×
2706
            I12 += -m * x * y
×
2707
            I13 += -m * x * z
×
2708
            I23 += -m * y * z
×
2709

2710
        ident = np.array(
×
2711
            [[I11, I12, I13], [I12, I22, I23], [I13, I23, I33]],
2712
            dtype=np.float64,
2713
        )
2714

2715
        moments, _ = np.linalg.eigh(ident)
×
2716
        return moments
×
2717

2718
    def get_homogeneous_field(self) -> npt.NDArray[Any] | None:
1✔
2719
        """Field is a numpy array (Ex, Ey, Ez) with the Field in V/A."""
2720
        if not hasattr(self, "_homogeneous_field"):
×
2721
            self.homogeneous_field = None
×
2722

2723
        return self.homogeneous_field
×
2724

2725
    ###############################################################################
2726
    #               Get Properties in comparison to other Geometry                #
2727
    ###############################################################################
2728
    def get_distance_to_equivalent_atoms(self, other_geometry) -> np.float64:
1✔
2729
        """
2730
        Calculate the maximum distance that atoms of geom would have to be moved,
2731
        to coincide with the atoms of self.
2732

2733
        """
2734
        _, dist = self.get_transformation_indices(other_geometry, get_distances=True)
×
2735
        return np.max(dist)
×
2736

2737
    def get_transformation_indices(
1✔
2738
        self,
2739
        other_geometry,
2740
        get_distances=False,
2741
        periodic_2D=False,
2742
    ):
2743
        """
2744
        Associates every atom in self to the closest atom of the same specie in
2745
        other_geometry.
2746

2747
        If self should be orderd like other_geometry then this is done in the
2748
        following way:
2749
        >>> transformation_indices = other_geometry.get_transformation_indices(self)
2750
        >>> self.reorder_atoms(transformation_indices)
2751

2752
        Parameters
2753
        ----------
2754
        other_geometry: geometry
2755
        norm_threshold : float
2756
        get_distances : bool
2757
            The default is False.
2758
        periodic_2D : bool
2759
            The default is False.
2760

2761
        Returns
2762
        -------
2763
        transformation_indices : np.array.
2764
            The positions on the array correspond to the atoms in self;
2765
            the values of the array correspond to the atoms in other_geometry
2766

2767
        """
2768
        assert len(self) == len(other_geometry), (
×
2769
            f"Geometries have different number of atoms {len(self)} != {len(other_geometry)}"
2770
        )
2771

2772
        # Replicate other geometry to also search in neighbouring cells
2773
        if periodic_2D:
×
2774
            other_geometry = other_geometry.get_periodic_replica((3, 3, 1))
×
2775
            other_geometry.move_all_atoms_by_fractional_coords([-1 / 3.0, -1 / 3.0, 0])
×
2776

2777
        # Get the atomic numbers of each geometry file: Later only compare matching atom types
2778
        Z_values_1 = np.array(
×
2779
            [PeriodicTable.get_atomic_number(s) for s in self.species],
2780
            np.int64,
2781
        )
2782
        Z_values_2 = np.array(
×
2783
            [PeriodicTable.get_atomic_number(s) for s in other_geometry.species],
2784
            np.int64,
2785
        )
2786
        unique_Z = set(Z_values_1)
×
2787

2788
        # Allocate output arrays
2789
        transformation_indices = np.zeros(len(self), np.int64)
×
2790
        distances = np.zeros(len(self))
×
2791
        atom2_indices = np.arange(len(other_geometry)) % len(self)
×
2792

2793
        # Loop over all types of atoms
2794
        for Z in unique_Z:
×
2795
            # Select all the coordinates that belong to that species
2796
            select1 = Z_values_1 == Z
×
2797
            select2 = Z_values_2 == Z
×
2798
            # Calculate all distances between the geometries
2799
            dist_matrix = scipy.spatial.distance_matrix(
×
2800
                self.coords[select1, :], other_geometry.coords[select2, :]
2801
            )
2802
            # For each row (= atom in self with species Z) find the index of the other_geometry (with species Z) that is closest
2803
            index_of_smallest_mismatch = np.argmin(dist_matrix, axis=1)
×
2804
            transformation_indices[select1] = atom2_indices[select2][
×
2805
                index_of_smallest_mismatch
2806
            ]
2807
            if get_distances:
×
2808
                distances[select1] = [
×
2809
                    dist_matrix[i, index_of_smallest_mismatch[i]]
2810
                    for i in range(len(dist_matrix))
2811
                ]
2812

2813
        if get_distances:
×
2814
            return transformation_indices, distances
×
2815

2816
        return transformation_indices
×
2817

2818
    def is_equivalent(
1✔
2819
        self, geom, tolerance=0.01, check_neightbouring_cells=False
2820
    ) -> bool:
2821
        """
2822
        Check if this geometry is equivalent to another given geometry.
2823
        The function checks that the same atoms sit on the same positions
2824
        (but possibly in some permutation)
2825

2826
        :param check_neightbouring_cells: for periodic structures, recognizes two structures as equivalent, even if one\
2827
         of them has its atoms distributed in different unit cells compared to the other. More complete, but slower.
2828
        """
2829
        # Check that both geometries have same number of atoms
2830
        # If not, they cannot be equivalent
2831
        if geom.n_atoms != self.n_atoms:
×
2832
            return False
×
2833

2834
        # check in neighbouring cells, to account for geometries 'broken' around the cell border
2835
        if check_neightbouring_cells:
×
2836
            if self.lattice_vectors is not None:
×
2837
                geom = geom.get_periodic_replica(
×
2838
                    (3, 3), explicit_replications=[[-1, 0, 1], [-1, 0, 1]]
2839
                )
2840
            else:
2841
                print("Non periodic structure. Ignoring check_neighbouring_cells")
×
2842

2843
        n_atoms = self.n_atoms
×
2844
        n_atoms_geom = geom.n_atoms
×
2845
        # Check for each atom in coords1 that is has a matching atom in coords2
2846
        for n1 in range(n_atoms):
×
2847
            is_ok = False
×
2848
            for n2 in range(n_atoms_geom):
×
2849
                if self.species[n1] == geom.species[n2]:
×
2850
                    d = np.linalg.norm(self.coords[n1, :] - geom.coords[n2, :])
×
2851
                    if d < tolerance:
×
2852
                        # Same atom and same position
2853
                        is_ok = True
×
2854
                        break
×
2855

2856
            if not is_ok:
×
2857
                return False
×
2858
        return True
×
2859

2860
    def is_equivalent_up_to_translation(
1✔
2861
        self,
2862
        geom,
2863
        get_translation=False,
2864
        tolerance=0.01,
2865
        check_neighbouring_cells=False,
2866
    ):
2867
        """
2868
            Returns True if self can be transformed into geom by a translation
2869
                (= without changing the geometry itself).
2870

2871
        Parameters
2872
        ----------
2873
        geom : geometry
2874
            Geometry to compare to.
2875
        get_translation : bool
2876
            Additionally return the found translation
2877
        tolerance : float
2878
            Tolerance threshold for larget mismatch between two atoms, below
2879
            which they are still considered to be at the sameposition.
2880
        check_neightbouring_cells : bool
2881
            For periodic structures, recognizes two structures as equivalent,
2882
            even if one of them has its atoms distributed in different
2883
            unit cells compared to the other. More complete, but slower.
2884

2885
        Returns
2886
        -------
2887
            is_equivalent : bool
2888
                True if equivalent.
2889
            translation : np.array
2890
                Translation vetror between equivalent geometries
2891
        """
2892
        # shift both geometries to origin, get their relative translation.
2893
        # Ignore center attribute (GeometryFile.center), if defined
2894
        meanA = self.get_geometric_center(ignore_center_attribute=True)
×
2895
        meanB = geom.get_geometric_center(ignore_center_attribute=True)
×
2896
        translation = meanA - meanB
×
2897
        self.center_coordinates(ignore_center_attribute=True)
×
2898
        geom.center_coordinates(ignore_center_attribute=True)
×
2899

2900
        # check if they are equivalent (up to permutation)
2901
        is_equivalent = self.is_equivalent(
×
2902
            geom, tolerance, check_neightbouring_cells=check_neighbouring_cells
2903
        )
2904

2905
        # undo shifting to origin
2906
        self.coords += meanA
×
2907
        geom.coords += meanB
×
2908

2909
        if get_translation:
×
2910
            return is_equivalent, translation
×
2911
        return is_equivalent
×
2912

2913
    ###########################################################################
2914
    #                          Get Part of a Geometry                         #
2915
    ###########################################################################
2916
    def get_atoms_by_indices(self, atom_indices: npt.NDArray[np.int64]) -> "Geometry":
1✔
2917
        """
2918
        Return a geometry instance with the atoms listed in atom_indices
2919

2920
        Parameters
2921
        ----------
2922
        atom_indices : Union[int, np.array]
2923
            List of integers, indices of those atoms which should be copied to
2924
            new geometry
2925

2926
        Returns
2927
        -------
2928
        new_geom : Geometry
2929

2930
        """
2931
        new_geom = self.__class__()
×
2932
        new_geom.add_atoms(
×
2933
            self.coords[atom_indices, :],
2934
            [self.species[i] for i in atom_indices],
2935
            constrain_relax=self.constrain_relax[atom_indices],
2936
            initial_moment=[self.initial_moment[i] for i in atom_indices],
2937
            initial_charge=[self.initial_charge[i] for i in atom_indices],
2938
        )
2939
        new_geom.lattice_vectors = self.lattice_vectors
×
2940
        return new_geom
×
2941

2942
    def get_atoms_by_species(self, species) -> "Geometry":
1✔
2943
        """
2944
        Get new geometry file with specific atom species
2945
        """
2946
        L = np.array(self.species) == species
×
2947
        atom_indices = np.where(L)[0]
×
2948
        return self.get_atoms_by_indices(atom_indices)
×
2949

2950
    def get_primitive_slab(self, surface, threshold=1e-6):
1✔
2951
        """
2952
        Generates a primitive slab unit cell with the z-direction perpendicular
2953
        to the surface.
2954

2955
        Arguments:
2956
        ----------
2957
        surface : array_like
2958
            miller indices, eg. (1,1,1)
2959

2960
        threshold : float
2961
            numerical threshold for symmetry operations
2962

2963
        Returns
2964
        -------
2965
        primitive_slab : Geometry
2966
        """
2967
        lattice, scaled_positions, atomic_numbers = spglib.standardize_cell(
×
2968
            self.get_spglib_cell()
2969
        )
2970

2971
        surface_vector = (
×
2972
            surface[0] * lattice[0, :]
2973
            + surface[1] * lattice[1, :]
2974
            + surface[2] * lattice[2, :]
2975
        )
2976

2977
        # TODO: this way of building lattice vectors parallel to the surface
2978
        # is not ideal for certain surfaces
2979
        dot_0 = surface_vector.dot(surface_vector)
×
2980
        dot_1 = surface_vector.dot(lattice[0, :])
×
2981
        dot_2 = surface_vector.dot(lattice[1, :])
×
2982
        dot_3 = surface_vector.dot(lattice[2, :])
×
2983

2984
        if abs(dot_1) > threshold:
×
2985
            frac = Fraction(dot_0 / dot_1).limit_denominator(1000)
×
2986
            n, m = frac.numerator, frac.denominator
×
2987
            v1 = m * surface_vector - n * lattice[0, :]
×
2988
        else:
2989
            v1 = lattice[0, :]
×
2990

2991
        if abs(dot_2) > threshold:
×
2992
            frac = Fraction(dot_0 / dot_2).limit_denominator(1000)
×
2993
            n, m = frac.numerator, frac.denominator
×
2994
            v2 = m * surface_vector - n * lattice[1, :]
×
2995
        else:
2996
            v2 = lattice[1, :]
×
2997

2998
        if abs(dot_3) > threshold:
×
2999
            frac = Fraction(dot_0 / dot_3).limit_denominator(1000)
×
3000
            n, m = frac.numerator, frac.denominator
×
3001
            v3 = m * surface_vector - n * lattice[2, :]
×
3002
        else:
3003
            v3 = lattice[2, :]
×
3004

3005
        surface_lattice = np.zeros((3, 3))
×
3006
        surface_lattice[0, :] = surface_vector
×
3007

3008
        ind = 1
×
3009
        for v in [v1, v2, v3]:
×
3010
            if not np.linalg.norm(v) == 0:
×
3011
                surface_lattice[ind, :] = v
×
3012
                rank = np.linalg.matrix_rank(surface_lattice)
×
3013

3014
                if rank == ind + 1:
×
3015
                    ind += 1
×
3016
                    if ind == 3:
×
3017
                        break
×
3018

3019
        # flip surface lattice such that surface normal becomes the z-axis
3020
        surface_lattice = np.flip(surface_lattice, 0)
×
3021

3022
        slab = self.__class__()
×
3023
        slab.lattice_vectors = surface_lattice
×
3024

3025
        # shellsize 100 such that the code does not run infinitely
3026
        shellsize = 100
×
3027
        for shell in range(shellsize):
×
3028
            add_next_shell = False
×
3029
            for h in range(-shell, shell + 1):
×
3030
                for j in range(-shell, shell + 1):
×
3031
                    for k in range(-shell, shell + 1):
×
3032
                        if (abs(h) < shell) and (abs(j) < shell) and (abs(k) < shell):
×
3033
                            continue
×
3034

3035
                        for new_species, coord in zip(atomic_numbers, scaled_positions):
×
3036
                            new_coord = coord.dot(lattice) + np.array([h, j, k]).dot(
×
3037
                                lattice
3038
                            )
3039
                            frac_new_coord = utils.get_fractional_coords(
×
3040
                                new_coord, surface_lattice
3041
                            )
3042

3043
                            L1 = np.sum(frac_new_coord >= 1 - threshold)
×
3044
                            L2 = np.sum(frac_new_coord < -threshold)
×
3045

3046
                            if not L1 and not L2:
×
3047
                                slab.add_atoms(
×
3048
                                    [new_coord],
3049
                                    [PeriodicTable.get_symbol(new_species)],
3050
                                )
3051
                                add_next_shell = True
×
3052

3053
            if shell != 0 and not add_next_shell:
×
3054
                break
×
3055

3056
            if shell == 100:
×
3057
                warnings.warn(
×
3058
                    "<Geometry.get_primitive_slab> could not build a correct slab.",
3059
                    stacklevel=2,
3060
                )
3061

3062
        slab.align_lattice_vector_to_vector(np.array([0, 0, 1]), 2)
×
3063
        slab.align_lattice_vector_to_vector(np.array([1, 0, 0]), 0)
×
3064

3065
        scaled_slab_lattice = np.array(slab.lattice_vectors)
×
3066
        # break symmetry in z-direction
3067
        scaled_slab_lattice[2, :] *= 2
×
3068
        frac_coords = utils.get_fractional_coords(slab.coords, scaled_slab_lattice)
×
3069
        species = [PeriodicTable.get_atomic_number(s) for s in slab.species]
×
3070

3071
        (
×
3072
            primitive_slab_lattice,
3073
            primitive_slab_scaled_positions,
3074
            primitive_slab_atomic_numbers,
3075
        ) = spglib.find_primitive(
3076
            (scaled_slab_lattice, frac_coords, species), symprec=1e-5
3077
        )
3078

3079
        primitive_slab_species = [
×
3080
            PeriodicTable.get_symbol(s) for s in primitive_slab_atomic_numbers
3081
        ]
3082
        primitive_slab_coords = primitive_slab_scaled_positions.dot(
×
3083
            primitive_slab_lattice
3084
        )
3085
        # replace lattice vector in z-direction
3086
        primitive_slab_lattice[2, :] = slab.lattice_vectors[2, :]
×
3087

3088
        primitive_slab = self.__class__()
×
3089
        primitive_slab.lattice_vectors = primitive_slab_lattice
×
3090
        primitive_slab.add_atoms(primitive_slab_coords, primitive_slab_species)
×
3091
        primitive_slab.map_to_first_unit_cell()
×
3092

3093
        # Sanity check: primitive_slab must be reducable to the standard unit cell
3094
        check_lattice, _, _ = spglib.standardize_cell(primitive_slab.get_spglib_cell())
×
3095

3096
        assert np.allclose(check_lattice, lattice), (
×
3097
            "<Geometry.get_primitive_slab> the slab that was constructed \
3098
        could not be reduced to the original bulk unit cell. Something \
3099
        must have gone wrong."
3100
        )
3101

3102
        return primitive_slab
×
3103

3104
    def get_slab(
1✔
3105
        self,
3106
        layers: int,
3107
        surface: Union[npt.NDArray[np.int64], None] = None,
3108
        threshold: float = 1e-6,
3109
        surface_replica: Tuple[int, int] = (1, 1),
3110
        vacuum_height: Union[float, None] = None,
3111
        bool_shift_slab_to_bottom: bool = False,
3112
    ) -> "Geometry":
3113
        """
3114
        Generates a slab.
3115

3116
        Parameters
3117
        ----------
3118
        layers : int
3119
            Number of layers of the slab.
3120
        surface : npt.NDArray[np.int64] | None, optional
3121
            miller indices, eg. (1,1,1)
3122
        threshold : float, optional
3123
            numerical threshold for symmetry operations
3124
        surface_replica : Tuple[int, int], optional
3125
            Replications of surface. The default is (1,1).
3126
        vacuum_height : float | None, optional
3127
            DESCRIPTION. The default is None.
3128
        bool_shift_slab_to_bottom : bool, optional
3129
            DESCRIPTION. The default is False.
3130

3131
        Returns
3132
        -------
3133
        slab_new : Geometry
3134
            New Geometry
3135

3136
        """
3137
        if surface is not None:
×
3138
            primitive_slab = self.get_primitive_slab(surface, threshold=threshold)
×
3139
        else:
3140
            primitive_slab = self
×
3141

3142
        slab_layers = primitive_slab.get_number_of_atom_layers()[1]
×
3143

3144
        replica = np.array([1, 1, int(np.ceil(layers / slab_layers))], dtype=np.int32)
×
3145
        replica[:2] = surface_replica
×
3146
        slab_new = primitive_slab.get_periodic_replica(tuple(replica))
×
3147

3148
        slab_new_layers = slab_new.get_atom_layers_indices()
×
3149

3150
        for atom_species in slab_new_layers:
×
3151
            z_coords = list(slab_new_layers[atom_species])
×
3152
            z_coords = sorted(z_coords)
×
3153

3154
            n_layers_to_remove = len(z_coords) - layers
×
3155

3156
            atom_indices_to_remove = []
×
3157
            for ind in range(n_layers_to_remove):
×
3158
                atom_indices_to_remove += slab_new_layers[atom_species][z_coords[ind]]
×
3159

3160
            slab_new.remove_atoms(np.array(atom_indices_to_remove, dtype=np.int32))
×
3161

3162
            if vacuum_height is not None:
×
3163
                slab_new.set_vacuum_height(
×
3164
                    vac_height=vacuum_height,
3165
                    bool_shift_to_bottom=bool_shift_slab_to_bottom,
3166
                )
3167
            elif bool_shift_slab_to_bottom:
×
3168
                self.shift_to_bottom()
×
3169

3170
        return slab_new
×
3171

3172
    def get_colliding_groups(self, distance_threshold=1e-2, check_3D=False):
1✔
3173
        """
3174
        Remove atoms that are too close too each other from the geometry file.
3175
        This approach is useful if one maps back atoms into a different cell
3176
        and then needs to get rid of overlapping atoms
3177

3178
        Parameters
3179
        ----------
3180
        distance_threshold: float
3181
            maximum distance between atoms below which they are counted as
3182
            duplicates
3183

3184
        Returns
3185
        -------
3186
        """
3187
        # get all distances between all atoms
3188

3189
        z_period = [-1, 0, 1] if check_3D else [0]
×
3190
        index_tuples = []
×
3191
        for i in [-1, 0, 1]:
×
3192
            for j in [-1, 0, 1]:
×
3193
                for k in z_period:
×
3194
                    curr_shift = (
×
3195
                        i * self.lattice_vectors[0, :]
3196
                        + j * self.lattice_vectors[1, :]
3197
                        + k * self.lattice_vectors[2, :]
3198
                    )
3199

3200
                    atom_distances = scipy.spatial.distance.cdist(
×
3201
                        self.coords, self.coords + curr_shift
3202
                    )
3203
                    index_tuples += self._get_collision_indices(
×
3204
                        atom_distances, distance_threshold
3205
                    )
3206
        if len(index_tuples) > 0:
×
3207
            G = nx.Graph()
×
3208
            G = nx.from_edgelist(
×
3209
                itertools.chain.from_iterable(
3210
                    itertools.pairwise(e) for e in index_tuples
3211
                )
3212
            )
3213
            G.add_nodes_from(set.union(*map(set, index_tuples)))  # adding single items
×
3214
            atoms_to_remove = list(nx.connected_components(G))
×
3215
            return [sorted(list(s)) for s in atoms_to_remove]
×
3216
        return []
×
3217

3218
    def get_cropped_geometry(
1✔
3219
        self,
3220
        xlim=(-np.inf, np.inf),
3221
        ylim=(-np.inf, np.inf),
3222
        zlim=(-np.inf, np.inf),
3223
        auto_margin=False,
3224
    ):
3225
        """
3226
        Returns a copy of the object to which self.crop has been applied
3227

3228
        """
3229
        newgeom = deepcopy(self)
×
3230
        newgeom.crop(xlim=xlim, ylim=ylim, zlim=zlim, auto_margin=auto_margin)
×
3231
        return newgeom
×
3232

3233
    def get_substrate(self, primitive_substrate=None):
1✔
3234
        substrate_indices = self.get_substrate_indices(
×
3235
            primitive_substrate=primitive_substrate
3236
        )
3237
        return self.get_atoms_by_indices(substrate_indices)
×
3238

3239
    def get_adsorbates(self, primitive_substrate=None):
1✔
3240
        adsorbate_indices = self.get_adsorbate_indices(
×
3241
            primitive_substrate=primitive_substrate
3242
        )
3243
        return self.get_atoms_by_indices(adsorbate_indices)
×
3244

3245
    def get_periodic_replica(
1✔
3246
        self,
3247
        replications: tuple,
3248
        lattice: Union[npt.NDArray[np.float64], None] = None,
3249
        explicit_replications: Union[list, None] = None,
3250
    ):
3251
        """
3252
        Return a new geometry file that is a periodic replica of the original
3253
        file. Repeats the geometry N-1 times in all given directions:
3254
        (1,1,1) returns the original file
3255

3256
        Parameters
3257
        ----------
3258
        TODO Fix types
3259
        replications : tuple or list
3260
            number of replications for each dimension
3261
        lattice : numpy array of shape [3x3]
3262
            super-lattice vectors to use for periodic replication
3263
            if lattice is None (default) the lattice vectors from the current
3264
            geometry file are used.
3265
        explicit_replications : iterable of iterables
3266
             a way to explicitly define which replicas should be made.
3267
             example: [[-1, 0, 1], [0, 1, 2, 3], [0]] will repeat 3 times in x
3268
             (centered) and 4 times in y (not centered)
3269

3270
        Returns
3271
        -------
3272
        New geometry
3273
        """
3274
        # TODO implement geometry_parts the right way (whatever this is)
3275
        if lattice is None:
×
3276
            lattice = np.array(self.lattice_vectors)
×
3277

3278
        if explicit_replications:
×
3279
            rep = explicit_replications
×
3280
            lattice_multipliers = [np.max(t) - np.min(t) for t in explicit_replications]
×
3281
        else:
3282
            rep = [list(range(r)) for r in replications]
×
3283
            lattice_multipliers = replications
×
3284

3285
        # old: n_replicas = np.abs(np.prod(replications))
3286
        n_replicas = np.prod([len(i) for i in rep])
×
3287
        n_atoms_new = n_replicas * self.n_atoms
×
3288
        new_coords = np.zeros([n_atoms_new, 3])
×
3289
        new_species = list(self.species) * n_replicas
×
3290
        new_constrain = list(self.constrain_relax) * n_replicas
×
3291

3292
        insert_pos = 0
×
3293
        # itertools.product = nested for loop
3294
        for frac_offset in itertools.product(*rep):
×
3295
            frac_shift = np.zeros([1, 3])
×
3296
            frac_shift[0, : len(frac_offset)] = frac_offset
×
3297
            offset = utils.get_cartesian_coords(frac_shift, lattice)
×
3298
            new_coords[insert_pos : insert_pos + self.n_atoms, :] = self.coords + offset
×
3299
            insert_pos += self.n_atoms
×
3300

3301
        new_geom = self.__class__()
×
3302

3303
        new_geom.add_atoms(new_coords, new_species, new_constrain)
×
3304
        new_geom.lattice_vectors = lattice
×
3305

3306
        # save original lattice vector for visualization
3307
        if hasattr(self, "original_lattice_vectors"):
×
3308
            new_geom.original_lattice_vectors = copy.deepcopy(
×
3309
                self.original_lattice_vectors
3310
            )
3311
        else:
3312
            new_geom.original_lattice_vectors = copy.deepcopy(self.lattice_vectors)
×
3313

3314
        for i, r in enumerate(lattice_multipliers):
×
3315
            new_geom.lattice_vectors[i, :] *= r
×
3316
        return new_geom
×
3317

3318
    def get_split_into_molecules(self, threshold) -> list:
1✔
3319
        """
3320
        Splits a structure into individual molecules. Two distinct molecules
3321
        A and B are defined as two sets of atoms, such that no atom in A is
3322
        closer than the selected thresold to any atom of B
3323

3324
        """
3325
        coords = deepcopy(self.coords)
×
3326
        distances = distance_matrix(coords, coords)
×
3327
        distances[distances <= threshold] = 1
×
3328
        distances[distances > threshold] = 0
×
3329

3330
        def scan_line(line_index, matrix, already_scanned_lines_indices):
×
3331
            already_scanned_lines_indices.append(line_index)
×
3332
            line = matrix[line_index]
×
3333
            links = np.nonzero(line)[0]
×
3334
            links = [
×
3335
                link for link in links if link not in already_scanned_lines_indices
3336
            ]
3337
            return links, already_scanned_lines_indices
×
3338

3339
        molecules_indices_sets = []
×
3340
        scanned_lines_indices = []
×
3341
        indices_set = []
×
3342
        # scan lines one by one, but skips those that have already been examined
3343
        for i in range(len(distances)):
×
3344
            if i in scanned_lines_indices:
×
3345
                continue
×
3346
            # add line to the present set
3347
            indices_set.append(i)
×
3348
            # get indices of the lines connected to the examined one
3349
            links, scanned_lines_indices = scan_line(
×
3350
                i, distances, scanned_lines_indices
3351
            )
3352
            indices_set += links
×
3353
            # as long as new links are found, adds the new lines to the present set
3354
            while len(links) > 0:
×
3355
                new_links = []
×
3356
                for link in links:
×
3357
                    if link not in scanned_lines_indices:
×
3358
                        new_links_part, scanned_lines_indices = scan_line(
×
3359
                            link, distances, scanned_lines_indices
3360
                        )
3361
                        new_links += new_links_part
×
3362
                links = set(new_links)
×
3363
                indices_set += links
×
3364
            # once no more links are found, stores the present set and starts a new one
3365
            molecules_indices_sets.append(indices_set)
×
3366
            indices_set = []
×
3367

3368
        molecules = []
×
3369
        for molecule_indices in molecules_indices_sets:
×
3370
            complementary_indices = [
×
3371
                x for x in self.get_indices_of_all_atoms() if x not in molecule_indices
3372
            ]
3373
            g = deepcopy(self)
×
3374
            g.remove_atoms(np.array(complementary_indices))
×
3375
            molecules.append(g)
×
3376

3377
        return molecules
×
3378

3379
    def get_layers(self, layer_indices: list, threshold: float = 1e-2):
1✔
3380
        """
3381
        Get substrate layer by indices. The substrate is determined by
3382
        default by the function self.get_substrate. For avoiding a faulty
3383
        substrate determination it can be either given through indices or
3384
        through the substrate geometry itself.
3385

3386
        Parameters
3387
        ----------
3388
        layer_indices : list
3389
            List of indices of layers that shall be returned.
3390
        substrate_indices : Union[None, list], optional
3391
            List of indices of substrate atoms. The default is None.
3392
        substrate : Union[None, Geometry()], optional
3393
            Geometry of substrate. The default is None.
3394
        threshold : float, optional
3395
            DESCRIPTION. The default is 1e-2.
3396
        primitive_substrate : Geometry, optional
3397
            DESCRIPTION. The default is None.
3398

3399
        Returns
3400
        -------
3401
        geometry_of_layer : Geometry
3402
            Geometry of layer.
3403

3404
        """
3405
        layers = self.get_atom_layers_indices_by_height(threshold=threshold)
×
3406

3407
        heights = list(layers.keys())
×
3408
        heights = np.sort(heights)
×
3409
        heights = heights[::-1]
×
3410

3411
        # if layer_indices is an empty list, keeps the substrate as a whole
3412
        if not layer_indices:
×
3413
            geometry_of_layer = self
×
3414
        else:
3415
            geometry_of_layer = self.__class__()
×
3416
            geometry_of_layer.lattice_vectors = self.lattice_vectors
×
3417
            for layer_ind in layer_indices:
×
3418
                geometry_of_layer += self.get_atoms_by_indices(
×
3419
                    layers[heights[layer_ind]]
3420
                )
3421

3422
        return geometry_of_layer
×
3423

3424
    def get_substrate_layers(
1✔
3425
        self,
3426
        layer_indices: list,
3427
        threshold: float = 1e-2,
3428
        primitive_substrate=None,
3429
        substrate_indices: Union[None, npt.NDArray[np.int64]] = None,
3430
    ):
3431
        """
3432
        Get substrate layer by indices. The substrate is determined by
3433
        default by the function self.get_substrate. For avoiding a faulty
3434
        substrate determination it can be either given through indices or
3435
        through the substrate geometry itself.
3436

3437
        Parameters
3438
        ----------
3439
        layer_indices : list
3440
            List of indices of layers that shall be returned.
3441
        threshold : float, optional
3442
            DESCRIPTION. The default is 1e-2.
3443
        primitive_substrate : TYPE, optional
3444
            DESCRIPTION. The default is None.
3445
        substrate_indices : Union[None, list], optional
3446
            List of indices of substrate atoms. The default is None.
3447

3448
        Returns
3449
        -------
3450
        geometry_of_layer : TYPE
3451
            Geometry of substrate layer.
3452

3453
        """
3454
        if substrate_indices is not None:
×
3455
            sub = self.get_atoms_by_indices(substrate_indices)
×
3456
        else:
3457
            sub = self.get_substrate(primitive_substrate=primitive_substrate)
×
3458

3459
        return sub.get_layers(layer_indices=layer_indices, threshold=threshold)
×
3460

3461
    ###########################################################################
3462
    #                           Evaluation Functions                          #
3463
    ###########################################################################
3464
    def check_symmetry(
1✔
3465
        self,
3466
        tolerance: float,
3467
        R: npt.NDArray[np.float64],
3468
        t: npt.NDArray[np.float64] = np.array([0.0, 0.0, 0.0]),
3469
        return_symmetrical: bool = False,
3470
    ):
3471
        """
3472
        Returns True if the geometry is symmetric with respect to the
3473
        transformation, and False if it is not. If the geometry is periodic,
3474
        transformation can be tuple (rotation, translation) or np.array
3475
        (only rotation), otherwise it can only be np.array
3476

3477
        Parameters
3478
        ----------
3479
        tolerance ; float
3480
            Tolerance for checking symmetry.
3481

3482
        R : npt.NDArray[np.float64]
3483
            Symmetry transformation agaist which geometry should be checked.
3484

3485
        t: npt.NDArray[np.float64]
3486
            Translation vector
3487

3488
        return_symmetrical : bool
3489
            Return the corresponding transformed geometry together with the
3490
            result.
3491

3492
        Returns
3493
        -------
3494
        is_symmetric : bool
3495
        symm_geometry : Geometry
3496

3497
        or
3498

3499
        is_symmetric : bool
3500

3501
        """
3502
        # original structure with all atoms in the first unit cell
3503
        self_1UC = copy.deepcopy(self)
×
3504
        self_1UC.map_to_first_unit_cell()
×
3505

3506
        # original structure centered for reordering
3507
        centered_geometry = copy.deepcopy(self)
×
3508
        centered_geometry.center_coordinates()
×
3509

3510
        # apply transformation
3511
        symm_geometry = copy.deepcopy(self)
×
3512
        symm_geometry.transform_fractional(R, np.array([0, 0, 0]), self.lattice_vectors)
×
3513
        symm_geometry.move_all_atoms_by_fractional_coords(t)
×
3514

3515
        # prevent problems if the center is very close to the edge
3516
        center = utils.get_fractional_coords(
×
3517
            symm_geometry.get_geometric_center(), symm_geometry.lattice_vectors
3518
        )
3519
        center[:2] %= 1.0
×
3520
        if 1 - center[0] < 0.001:
×
3521
            adjust = -(center[0] - 0.0001)
×
3522
            symm_geometry.move_all_atoms_by_fractional_coords([adjust, 0, 0])
×
3523
        if 1 - center[1] < 0.001:
×
3524
            adjust = -(center[1] - 0.0001)
×
3525
            symm_geometry.move_all_atoms_by_fractional_coords([0, adjust, 0])
×
3526

3527
        symm_geometry.map_center_of_atoms_to_first_unit_cell(
×
3528
            lattice_vectors=self.lattice_vectors
3529
        )
3530

3531
        # reorder atoms
3532
        offset_symm = np.mean(symm_geometry.coords, axis=0)
×
3533
        symm_geometry.center_coordinates()
×
3534
        indices = centered_geometry.get_transformation_indices(symm_geometry)
×
3535
        symm_geometry.reorder_atoms(np.array(indices))
×
3536
        symm_geometry.move_all_atoms(offset_symm)
×
3537

3538
        # compare in first unit cell
3539
        symm_geometry_1UC = copy.deepcopy(symm_geometry)
×
3540
        symm_geometry_1UC.map_to_first_unit_cell()
×
3541
        is_symmetric = symm_geometry_1UC.is_equivalent(
×
3542
            self_1UC, tolerance=tolerance, check_neightbouring_cells=True
3543
        )
3544

3545
        if return_symmetrical:
×
3546
            return is_symmetric, symm_geometry
×
3547
        return is_symmetric
×
3548

3549
    ###############################################################################
3550
    #                                 Visualisation                               #
3551
    ###############################################################################
3552
    def visualise(
1✔
3553
        self,
3554
        axes=[0, 1],
3555
        min_zorder=0,
3556
        value_list=None,
3557
        maxvalue=None,
3558
        minvalue=None,
3559
        cbar_label="",
3560
        hide_axes=False,
3561
        axis_labels=True,
3562
        auto_limits=True,
3563
        crop_ratio=None,
3564
        brightness_modifier=None,
3565
        print_lattice_vectors=False,
3566
        alpha=1.0,
3567
        linewidth=1,
3568
        lattice_linewidth=None,
3569
        lattice_color="k",
3570
        atom_scale=1,
3571
        highlight_inds=[],
3572
        highlight_color="C2",
3573
        color_list=None,
3574
        cmap=None,
3575
        ax=None,
3576
        xlim=None,
3577
        ylim=None,
3578
        zlim=None,
3579
        plot_method="circles",
3580
        invert_colormap=False,
3581
        edge_color=None,
3582
        show_colorbar=True,
3583
        reverse_sort_inds=False,
3584
        axis_labels_format="/",
3585
    ) -> None:
3586
        """
3587
        Generates at plt-plot of the current geometry. This function has a
3588
        large number of options. In most cases the following examples will
3589
        work well:
3590

3591
        - Visualise the geometry:
3592
        geometry.visualise()
3593

3594
        - Turn aff axis:
3595
        geometry.visualise(hide_axes=True)
3596

3597
        - Turn off axis and set limits:
3598
        geometry.visualise(hide_axes=True, xlim=(-10, 10))
3599

3600
        - If you want to look at the geoemtry in the xz-plane:
3601
        geometry.visualise(axes=[0,2], hide_axes=True, xlim=(-10, 10))
3602

3603
        Visualise is one of the most useful things about geometry. Reading
3604
        through this code you may think that it is very ugly and on to of that
3605
        if has it's own imports. Still it is a great function and it must be
3606
        part of geometry. If you think otherwise you are wrong.
3607

3608
        Note from Dylan: I completely agree if you think it's ugly and should be killed
3609
        with fire. Do not listen to Lukas. He is wrong. Fortunately I managed to
3610
        convince him to get rid of the function-scoped imports so you're welcome.
3611

3612
        Parameter:
3613
        ----------
3614
        axes : list of 2 int elements
3615
            axis that should be visualized, x=0, y=1, z=2
3616
            By default, we look at the geometry from:
3617
            the "top" (our viewpoint is at z = +infinity) when we visualize the xy plane;
3618
            the "right" (our viewpoint is at x = +infinity) when we visualize the yz plane;
3619
            the "front" (our viewpoint is at y = -infinity) when we visualize the xz plane.
3620
            In order to visualize the geometry from the opposite viewpoints, one needs
3621
            to use the reverse_sort_inds flag, and invert the axis when necessary
3622
            (= set axis limits so that the first value is larger than the second value)
3623

3624
        min_zorder : int
3625
            plotting layer
3626

3627
        value_list : None or list of length nr. atoms
3628

3629
        maxvalue : None
3630

3631
        cbar_label : str
3632

3633
        hide_axes : bool
3634
            hide axis
3635

3636
        axis_labels : bool
3637
            generates automatic axis labels
3638

3639
        auto_limits : bool
3640
            set xlim, ylim automatically
3641

3642
        crop_ratio: float
3643
            defines the ratio between xlim and ylim if auto_limits is enabled
3644

3645
        brightness_modifier : float or list/array with length equal to the number of atoms
3646
            modifies the brightness of selected atoms. If brightness_modifier is a
3647
            list/array, then brightness_modifier[i] sets the brightness for atom i,
3648
            otherwise all atoms are set to the same brightness value. This is done by
3649
            tweaking the 'lightness' value of said atoms' color in the HSL
3650
            (hue-saturation-lightness) colorspace. Effect of brightness_modifier in
3651
            detail:
3652
            -1.0 <= brightness_modifier < 0.0  : darker color
3653
            brightness_modifier == 0.0 or None : original color
3654
            0.0 < brightness_modifier <= 1.0   :  brighter color
3655

3656
        print_lattice_vectors : bool
3657
            display lattice vectors
3658

3659
        print_unit_cell : bool
3660
            display original unit cell
3661

3662
        alpha : float between 0 and 1
3663

3664
        color_list : list or string
3665
            choose colors for visualizing each atom. If only one color is passed, all
3666
            atoms will have that color.
3667

3668
        plot_method: str
3669
            circles: show filled circles for each atom
3670
            wireframe: show molecular wireframe, standard settings: don't show H,
3671

3672
        reverse_sort_inds: bool
3673
            if set to True, inverts the order at which atoms are visualized, allowing
3674
            to visualise the geometry from the "bottom", from the "left" or from the
3675
            "back".
3676
            Example: if one wants to visualise the geometry from the "left"
3677
            (= viewpoint at x=-infinity), atoms at lower x values should be
3678
            visualised after atoms at high x values, and hide them. This is the
3679
            opposite of the default behavior of this function, and can be achieved with
3680
            reverse_sort_inds=True
3681
            NOTE: in order to correctly visualize the structure from these non-default
3682
            points of view, setting this flag to True is not sufficient: one must also
3683
            invert the XY axes of the plot where needed.
3684
            Example: when visualising from the "left", atoms with negative y values
3685
            should appear on the right side of the plot, and atoms with positive y
3686
            values should appear on the left side of the plot. But if one simply sets
3687
            reverse_sort_inds=True, atoms with negative y values will appear on the left
3688
            side of the plot (because the x axis of the plot, the horizontal axis, goes
3689
            from left to right!) and vice-versa. This is equivalent to visualising a
3690
            mirrored image of the structure. To visualise the structure correctly, one
3691
            should then set the x_limits of the plot with a first value smaller than the
3692
            second value, so the x axis is inverted, and shows y-negative values on the
3693
            left and viceversa.
3694

3695
        Returns
3696
        -------
3697
        None
3698

3699
        """
3700
        # default for lattice_linewidth (which is used to draw the lattice)
3701
        if lattice_linewidth is None:
×
3702
            lattice_linewidth = 2 * linewidth
×
3703

3704
        orig_inds = np.arange(self.n_atoms)
×
3705
        remove_inds = []
×
3706
        if xlim is not None:
×
3707
            remove_x = self.get_cropping_indices(xlim=xlim, auto_margin=True)
×
3708
            remove_inds += list(remove_x)
×
3709
        if ylim is not None:
×
3710
            remove_y = self.get_cropping_indices(ylim=ylim, auto_margin=True)
×
3711
            remove_inds += list(remove_y)
×
3712
        if zlim is not None:
×
3713
            remove_z = self.get_cropping_indices(zlim=zlim, auto_margin=True)
×
3714
            remove_inds += list(remove_z)
×
3715

3716
        crop_inds = list(set(remove_inds))
×
3717

3718
        if len(crop_inds) > 0:
×
3719
            orig_inds = [orig_inds[i] for i in orig_inds if i not in crop_inds]
×
3720
            cropped_geom = copy.deepcopy(self)
×
3721
            cropped_geom.remove_atoms(np.array(crop_inds))
×
3722
        else:
3723
            cropped_geom = self
×
3724

3725
        if ax is None:
×
3726
            ax = plt.gca()
×
3727

3728
        axnames = ["x", "y", "z"]
×
3729
        orig_coords = cropped_geom.coords
×
3730
        orig_species = cropped_geom.species
×
3731
        #        orig_constrain = cropped_geom.constrain_relax
3732

3733
        # sorting along projecting dimension.
3734
        # If sort_ind == 1, which means that we look at XZ, along the Y axis, in order to enforce our default behaviour
3735
        # of looking at the XZ from "under" (== from the negative side of the Y axis), we need to flip the order
3736
        # at which we see atoms, so we reverse the order of sort inds.
3737
        # If the flat reverse_sort_inds is set to True, the order will be flipped again, to bring us out of our default.
3738
        for i in range(3):
×
3739
            if i not in axes:
×
3740
                sort_ind = i
×
3741

3742
        inds = np.argsort(orig_coords[:, sort_ind])
×
3743

3744
        if sort_ind == 1:
×
3745
            inds = inds[::-1]
×
3746
        if reverse_sort_inds:
×
3747
            inds = inds[::-1]
×
3748

3749
        orig_inds = [orig_inds[i] for i in inds]
×
3750
        coords = orig_coords[inds]
×
3751
        species = [orig_species[i] for i in inds]
×
3752
        n_atoms = len(species)
×
3753
        circlesize = [
×
3754
            PeriodicTable.get_covalent_radius(s) * atom_scale for s in species
3755
        ]
3756

3757
        # Specify atom colors by value list or default atom colors
3758
        if value_list is None and color_list is None:
×
3759
            colors = [PeriodicTable.get_species_colours(s) for s in species]
×
3760
            colors = np.array(colors)
×
3761
        elif color_list is not None:
×
3762
            if len(color_list) == 1:
×
3763
                colors = list(color_list) * len(self.species)
×
3764
                colors = [mpl.colors.to_rgb(colors[i]) for i in inds]
×
3765
            else:
3766
                assert len(species) == len(color_list), (
×
3767
                    "Color must be specified for all atoms or none!"
3768
                    + f" Expected {len(species)}, but got {len(color_list)} values"
3769
                )
3770
                colors = [
×
3771
                    mpl.colors.to_rgb(color_list[i]) for i in inds
3772
                ]  # converting all types of color inputs to rgba here
3773
            colors = np.array(colors)
×
3774
        elif value_list is not None:
×
3775
            assert len(value_list) == self.n_atoms, (
×
3776
                "Number of Values does not match number of atoms in geometry"
3777
            )
3778
            values = [value_list[i] for i in orig_inds]
×
3779

3780
            if minvalue is not None:
×
3781
                assert maxvalue is not None, (
×
3782
                    "Error! If minvalue is defined also maxvalue must be defined"
3783
                )
3784

3785
            if maxvalue is None and minvalue is None:
×
3786
                maxvalue = np.max(np.abs(value_list))
×
3787
                minvalue = -maxvalue
×
3788

3789
                if maxvalue < 1e-5:
×
3790
                    maxvalue = 1e-5
×
3791
                    print(
×
3792
                        "Maxvalue for colormap not specified and smaller 1E-5, \nsetting it automatically to: ",
3793
                        maxvalue,
3794
                    )
3795
                else:
3796
                    print(
×
3797
                        "Maxvalue for colormap not specified, \nsetting it automatically to: ",
3798
                        maxvalue,
3799
                    )
3800

3801
            if maxvalue is not None and minvalue is None:
×
3802
                minvalue = -maxvalue
×
3803

3804
            if cmap is None:
×
3805
                if invert_colormap:
×
3806
                    cw = plt.get_cmap("coolwarm_r")
×
3807
                else:
3808
                    cw = plt.get_cmap("coolwarm")
×
3809
            else:
3810
                cw = plt.get_cmap(cmap)
×
3811

3812
            cNorm = matplotlib.colors.Normalize(vmin=minvalue, vmax=maxvalue)
×
3813
            scalarMap = cmx.ScalarMappable(norm=cNorm, cmap=cw)
×
3814

3815
            a = np.array([[minvalue, maxvalue]])
×
3816
            img = plt.imshow(a, cmap=cw)
×
3817
            img.set_visible(False)
×
3818

3819
            colors = []
×
3820
            for v in values:
×
3821
                colors.append(scalarMap.to_rgba(v)[:3])  # reomve alpha channel
×
3822
            colors = np.array(colors)
×
3823

3824
        # make specified atoms brighter by adding color_offset to all rgb values
3825

3826
        if brightness_modifier is not None:
×
3827
            # Check if brightness modifier is flat (i.e. a single value) or per atom (list of length n_atoms)
3828
            if isinstance(brightness_modifier, float) or isinstance(
×
3829
                brightness_modifier, int
3830
            ):
3831
                brightness_modifier = brightness_modifier * np.ones(n_atoms)
×
3832

3833
            else:
3834
                # Sort list according to orig_inds (which is already cropped if necessary!)
3835
                assert len(brightness_modifier) == self.n_atoms, (
×
3836
                    "Argument 'brightness_modifier' must either be a "
3837
                    "scalar (float or int) or a list with length equal "
3838
                    "to the number of atoms"
3839
                )
3840
                brightness_modifier = [brightness_modifier[i] for i in orig_inds]
×
3841

3842
            assert len(brightness_modifier) == n_atoms, (
×
3843
                "Something went wrong while reformatting brightness_modifier!"
3844
            )
3845
            for i in range(n_atoms):
×
3846
                # TODO fix the pyright errors
3847
                hls_color = np.array(
×
3848
                    colorsys.rgb_to_hls(*colors[i, :])  # pyright:ignore
3849
                )
3850
                hls_color[1] += brightness_modifier[i] * (1 - hls_color[1])
×
3851
                hls_color = np.clip(hls_color, 0, 1)
×
3852
                colors[i, :] = colorsys.hls_to_rgb(*hls_color)  # pyright:ignore
×
3853
        else:
3854
            brightness_modifier = np.zeros(n_atoms)
×
3855

3856
        zorder = min_zorder
×
3857

3858
        if plot_method == "circles":
×
3859
            for i in range(len(species)):
×
3860
                if plot_method == "circles":
×
3861
                    x1 = coords[i, axes[0]]
×
3862
                    x2 = coords[i, axes[1]]
×
3863
                    if orig_inds[i] not in highlight_inds:
×
3864
                        if edge_color is None:
×
3865
                            curr_edge_color = (
×
3866
                                np.zeros(3) + brightness_modifier[i]
3867
                                if brightness_modifier[i] > 0
3868
                                else np.zeros(3)
3869
                            )
3870
                        else:
3871
                            curr_edge_color = edge_color
×
3872

3873
                        ax.add_artist(
×
3874
                            mpl.patches.Circle(
3875
                                (x1, x2),
3876
                                circlesize[i],
3877
                                color=colors[i],
3878
                                zorder=zorder,
3879
                                linewidth=linewidth,
3880
                                alpha=alpha,
3881
                                ec=curr_edge_color,
3882
                            )
3883
                        )
3884
                    else:
3885
                        if edge_color is None:
×
3886
                            curr_edge_color = highlight_color
×
3887
                        else:
3888
                            curr_edge_color = edge_color
×
3889
                        ax.add_artist(
×
3890
                            mpl.patches.Circle(
3891
                                [x1, x2],
3892
                                circlesize[i],
3893
                                color=colors[i],
3894
                                zorder=zorder,
3895
                                linewidth=linewidth,
3896
                                alpha=alpha,
3897
                                ec=curr_edge_color,
3898
                            )
3899
                        )
3900
                    zorder += 2
×
3901

3902
        elif plot_method == "wireframe":
×
3903
            raise NotImplementedError("self.visualize_wireframe is not implemented")
×
3904
            # self.visualizeWireframe(coords=coords, species=species,
3905
            #                         linewidth=linewidth, min_zorder=min_zorder,
3906
            #                         axes=axes, alpha=alpha, **kwargs)
3907

3908
        if print_lattice_vectors:
×
3909
            ax.add_artist(
×
3910
                plt.arrow(
3911
                    0,
3912
                    0,
3913
                    *cropped_geom.lattice_vectors[0, axes],
3914
                    zorder=zorder,
3915
                    fc=lattice_color,
3916
                    ec=lattice_color,
3917
                    head_width=0.5,
3918
                    head_length=1,
3919
                )
3920
            )
3921
            ax.add_artist(
×
3922
                plt.arrow(
3923
                    0,
3924
                    0,
3925
                    *cropped_geom.lattice_vectors[1, axes],
3926
                    zorder=zorder,
3927
                    fc=lattice_color,
3928
                    ec=lattice_color,
3929
                    head_width=0.5,
3930
                    head_length=1,
3931
                )
3932
            )
3933

3934
        # scale:
3935
        xmax = np.max(coords[:, axes[0]]) + 2
×
3936
        xmin = np.min(coords[:, axes[0]]) - 2
×
3937
        ymax = np.max(coords[:, axes[1]]) + 2
×
3938
        ymin = np.min(coords[:, axes[1]]) - 2
×
3939

3940
        if auto_limits:
×
3941
            if print_lattice_vectors:
×
3942
                xmin_lattice = np.min(cropped_geom.lattice_vectors[:, axes[0]]) - 1
×
3943
                xmax_lattice = np.max(cropped_geom.lattice_vectors[:, axes[0]]) + 1
×
3944
                ymin_lattice = np.min(cropped_geom.lattice_vectors[:, axes[1]]) - 1
×
3945
                ymax_lattice = np.max(cropped_geom.lattice_vectors[:, axes[1]]) + 1
×
3946

3947
                ax_xmin = min(xmin, xmin_lattice)
×
3948
                ax_xmax = max(xmax, xmax_lattice)
×
3949
                ax_ymin = min(ymin, ymin_lattice)
×
3950
                ax_ymax = max(ymax, ymax_lattice)
×
3951

3952
            else:
3953
                ax_xmin, ax_xmax, ax_ymin, ax_ymax = xmin, xmax, ymin, ymax
×
3954
                # allow for a fixed ratio when defining the limits
3955
                # For this calculate the lengths and make the smaller limit longer so that the ratio fits
3956

3957
            if crop_ratio is not None:
×
3958
                len_xlim = ax_xmax - ax_xmin
×
3959
                len_ylim = ax_ymax - ax_ymin
×
3960
                curr_crop_ratio = len_xlim / len_ylim
×
3961

3962
                if curr_crop_ratio > crop_ratio:
×
3963
                    # make y limits larger
3964
                    y_padding_fac = len_xlim / (crop_ratio * len_ylim)
×
3965
                    y_padding = len_ylim * (y_padding_fac - 1)
×
3966
                    ax_ymin -= y_padding / 2
×
3967
                    ax_ymax += y_padding / 2
×
3968

3969
                else:
3970
                    # make x limits larger
3971
                    x_padding_fac = (crop_ratio * len_ylim) / len_xlim
×
3972
                    x_padding = len_xlim * (x_padding_fac - 1)
×
3973
                    ax_xmin -= x_padding / 2
×
3974
                    ax_xmax += x_padding / 2
×
3975

3976
            # TODO: fix pyright linting errors
3977
            ax.set_xlim([ax_xmin, ax_xmax])  # pyright:ignore
×
3978
            ax.set_ylim([ax_ymin, ax_ymax])  # pyright:ignore
×
3979

3980
        # If limits are given, set them
3981
        limits = [xlim, ylim, zlim]
×
3982
        x1lim = limits[axes[0]]
×
3983
        x2lim = limits[axes[1]]
×
3984
        if x1lim is not None:
×
3985
            ax.set_xlim(x1lim)
×
3986
        if x2lim is not None:
×
3987
            ax.set_ylim(x2lim)
×
3988

3989
        if axis_labels:
×
3990
            if axis_labels_format == "/":
×
3991
                ax.set_xlabel(rf"{axnames[axes[0]]} / $\AA$")
×
3992
                ax.set_ylabel(rf"{axnames[axes[1]]} / $\AA$")
×
3993
            elif axis_labels_format == "[]":
×
3994
                ax.set_xlabel(rf"{axnames[axes[0]]} [$\AA$]")
×
3995
                ax.set_ylabel(rf"{axnames[axes[1]]} [$\AA$]")
×
3996

3997
        if show_colorbar and (value_list is not None):
×
3998
            cbar = plt.colorbar(ax=ax)
×
3999
            cbar.ax.set_ylabel(cbar_label)
×
4000

4001
        ax.set_aspect("equal")
×
4002
        plt.grid(False)
×
4003
        if hide_axes:
×
4004
            ax.set_axis_off()
×
4005
            ax.get_xaxis().set_visible(False)
×
4006
            ax.get_yaxis().set_visible(False)
×
4007

4008
    ###############################################################################
4009
    #                            Protected Functions                              #
4010
    ###############################################################################
4011
    def _get_collision_indices(self, atom_distances, distance_threshold=1e-2):
1✔
4012
        """Helper function for removeDuplicateAtoms
4013

4014
        Parameters
4015
        ----------
4016
        distance_threshold: float
4017
            maximum distance between atoms below which they are counted as duplicates
4018

4019
        Returns
4020
        -------
4021
        atoms_to_remove : list
4022
            indices of atoms that can be removed due to collision
4023
        """
4024
        # get all distances between all atoms
4025
        is_collision = atom_distances < distance_threshold
×
4026

4027
        colliding_atoms_dict = {}
×
4028
        colliding_atoms_list = []
×
4029

4030
        # loop over all atoms
4031
        for i in range(self.n_atoms):
×
4032
            # evaluate only if atom is not already on the black list
4033
            if i not in colliding_atoms_list:
×
4034
                colliding_atoms_dict[i] = []
×
4035
                # loop over all distances to other atoms, neglecting the diagonal (thus i+1)
4036
                for j in range(i + 1, self.n_atoms):
×
4037
                    if is_collision[i, j]:
×
4038
                        colliding_atoms_dict[i].append(j)
×
4039
                        colliding_atoms_list.append(j)
×
4040

4041
        return [
×
4042
            (k, ind) for k, value in colliding_atoms_dict.items() for ind in list(value)
4043
        ]
4044

4045

4046
class AimsGeometry(Geometry):
1✔
4047
    def parse(self, text):
1✔
4048
        """
4049
        Parses text from AIMS geometry file and sets all necessary parameters
4050
        in AimsGeometry.
4051

4052
        Parameters
4053
        ----------
4054
        text : str
4055
            line wise text file of AIMS geometry.
4056
        """
4057
        atom_lines = []
1✔
4058
        is_fractional = False
1✔
4059
        is_own_hessian = False
1✔
4060
        self.trust_radius = False
1✔
4061
        self.vacuum_level = None
1✔
4062
        self.constrain_relax = []
1✔
4063
        self.external_force = []
1✔
4064
        self.calculate_friction = []
1✔
4065
        self.multipoles = []
1✔
4066
        self.homogeneous_field = None
1✔
4067
        self.symmetry_params = None
1✔
4068
        self.n_symmetry_params = None
1✔
4069
        self.symmetry_LVs = None  # symmetry_LVs should have str values, not float, to allow for the inclusion of the parameters
1✔
4070
        symmetry_LVs_lines = []
1✔
4071
        self.symmetry_frac_coords = None  # symmetry_frac_coords should have str values, not float, to allow for the inclusion of the parameters
1✔
4072
        symmetry_frac_lines = []
1✔
4073
        lattice_vector_lines = []
1✔
4074
        atom_line_ind = []
1✔
4075
        hessian_lines = []
1✔
4076
        text_lines = text.split("\n")
1✔
4077

4078
        for ind_line, line in enumerate(text_lines):
1✔
4079
            line = line.strip()  # Remove leading and trailing space in line
1✔
4080
            # Comment in input file
4081
            if line.startswith("#"):
1✔
4082
                if "DFT_ENERGY " in line:
×
4083
                    self.DFT_energy = float(line.split()[2])
×
4084
                elif "ADSORPTION_ENERGY " in line:
×
4085
                    self.E_ads = float(line.split()[2])
×
4086
                elif "ADSORPTION_ENERGY_UNRELAXED " in line:
×
4087
                    self.E_ads_sp = float(line.split()[2])
×
4088
                elif "CENTER" in line:
×
4089
                    self.center = ast.literal_eval(" ".join(line.split()[2:]))
×
4090
                # check if it is an own Hessian and not from a geometry optimization
4091
                elif "own_hessian" in line:
×
4092
                    is_own_hessian = True
×
4093

4094
                # PARTS defines parts of the geometry that can later on be treated separately.
4095
                # intended for distinction between different molecules and substrate
4096
                elif "PARTS" in line:
×
4097
                    part_definition = ast.literal_eval(" ".join(line.split()[2:]))
×
4098
                    if isinstance(part_definition, dict):
×
4099
                        for k, v in part_definition.items():
×
4100
                            self.geometry_part_descriptions.append(k)
×
4101
                            self.geometry_parts.append(v)
×
4102
                    elif isinstance(part_definition, list):
×
4103
                        if isinstance(part_definition[0], list):
×
4104
                            for part in part_definition:
×
4105
                                self.geometry_part_descriptions.append("")
×
4106
                                self.geometry_parts.append(part)
×
4107
                        else:
4108
                            self.geometry_parts.append(part)
×
4109
                            self.geometry_part_descriptions.append("")
×
4110

4111
                else:
4112
                    # Remove '#' at beginning of line, then remove any leading whitespace
4113
                    line_comment = line[1:].lstrip()
×
4114
                    # Finally add line comment to self.comment_lines
4115
                    self.comment_lines.append(line_comment)
×
4116

4117
            else:
4118
                # Extract all lines that define atoms, lattice vectors, multipoles or the Hessian matrix
4119
                if "atom" in line:
1✔
4120
                    atom_lines.append(line)
1✔
4121
                    atom_line_ind.append(ind_line)
1✔
4122
                if "lattice_vector" in line:
1✔
4123
                    lattice_vector_lines.append(line)
1✔
4124
                # c Check for fractional coordinates
4125
                if "_frac" in line:
1✔
4126
                    is_fractional = True
1✔
4127
                if "hessian_block" in line:
1✔
4128
                    hessian_lines.append(line)
×
4129
                if "trust_radius" in line:
1✔
4130
                    self.trust_radius = float(line.split()[-1])
×
4131
                if "set_vacuum_level" in line:
1✔
4132
                    self.vacuum_level = float(line.split()[1])
×
4133
                if "multipole" in line:
1✔
4134
                    multipole = [float(x) for x in list(line.split())[1:]]
×
4135
                    assert len(multipole) == 5
×
4136
                    self.multipoles.append(multipole)
×
4137
                # extract lines concerning symmetry params
4138
                if "symmetry_n_params" in line:
1✔
4139
                    self.n_symmetry_params = [int(x) for x in list(line.split())[1:]]
×
4140
                if "symmetry_params" in line:
1✔
4141
                    self.symmetry_params = list(line.split())[1:]
×
4142
                if "symmetry_lv" in line:
1✔
4143
                    symmetry_LVs_lines.append(line)
×
4144
                if "symmetry_frac" in line:
1✔
4145
                    symmetry_frac_lines.append(line)
×
4146
                if "homogeneous_field" in line:
1✔
4147
                    self.homogeneous_field = np.asarray(
×
4148
                        list(map(float, line.split()[1:4]))
4149
                    )
4150

4151
        # c Read all constraints/ moments and spins
4152
        for i, j in enumerate(atom_line_ind):
1✔
4153
            constraints = [False, False, False]
1✔
4154
            external_force = np.zeros(3)
1✔
4155
            calculate_friction = False
1✔
4156
            charge = 0.0
1✔
4157
            moment = 0.0
1✔
4158
            if i < len(atom_line_ind) - 1:
1✔
4159
                last_line = atom_line_ind[i + 1]
1✔
4160
            else:
4161
                last_line = len(text_lines)
1✔
4162
            for k in range(j, last_line):
1✔
4163
                line = text_lines[k]
1✔
4164
                if not line.startswith("#"):
1✔
4165
                    if "initial_moment" in line:
1✔
4166
                        moment = float(line.split()[1])
×
4167
                    elif "initial_charge" in line:
1✔
4168
                        charge = float(line.split()[1])
×
4169
                    elif "constrain_relaxation" in line:
1✔
4170
                        directions = line.split("constrain_relaxation")[1].lower()
1✔
4171
                        if ".true." in directions:
1✔
4172
                            constraints = [True, True, True]
1✔
4173
                        if "x" in directions:
1✔
4174
                            constraints[0] = True
×
4175
                        if "y" in directions:
1✔
4176
                            constraints[1] = True
×
4177
                        if "z" in directions:
1✔
4178
                            constraints[2] = True
×
4179
                    elif "external_force" in line:
1✔
4180
                        external_force[0] = float(line.split()[1])
×
4181
                        external_force[1] = float(line.split()[2])
×
4182
                        external_force[2] = float(line.split()[3])
×
4183
                    elif "calculate_friction" in line and ".true." in line:
1✔
4184
                        calculate_friction = True
×
4185

4186
            self.constrain_relax.append(constraints)
1✔
4187
            self.external_force.append(external_force)
1✔
4188
            self.calculate_friction.append(calculate_friction)
1✔
4189
            self.initial_charge.append(charge)
1✔
4190
            self.initial_moment.append(moment)
1✔
4191

4192
        # read the atom species and coordinates
4193
        self.n_atoms = len(atom_lines)
1✔
4194
        self.coords = np.zeros([self.n_atoms, 3])
1✔
4195
        for i, line in enumerate(atom_lines):
1✔
4196
            tokens = line.split()
1✔
4197
            self.species.append(tokens[4])
1✔
4198
            self.coords[i, :] = [float(x) for x in tokens[1:4]]
1✔
4199

4200
        # store symmetry_lv and symmetry_frac
4201
        if len(symmetry_LVs_lines) != 0:
1✔
4202
            self.symmetry_LVs = []
×
4203
            if len(symmetry_LVs_lines) != 3:
×
4204
                print(
×
4205
                    "Warning: Number of symmetry_LVs is: "
4206
                    + str(len(symmetry_LVs_lines))
4207
                )
4208
            for j in symmetry_LVs_lines:
×
4209
                line = j[11:]
×
4210
                terms = [t.strip() for t in line.split(",")]
×
4211
                self.symmetry_LVs.append(terms)
×
4212
        if len(symmetry_frac_lines) != 0:
1✔
4213
            self.symmetry_frac_coords = []
×
4214
            for i in symmetry_frac_lines:
×
4215
                line = i[13:]
×
4216
                terms = [t.strip() for t in line.split(",")]
×
4217
                self.symmetry_frac_coords.append(terms)
×
4218

4219
        # read the hessian matrix if it is an own Hessian
4220
        if is_own_hessian:
1✔
4221
            # hessian has three coordinates for every atom
4222
            self.hessian = np.zeros([self.n_atoms * 3, self.n_atoms * 3])
×
4223
            for line in hessian_lines:
×
4224
                tokens = line.split()
×
4225
                ind_1 = int(tokens[1])
×
4226
                ind_2 = int(tokens[2])
×
4227
                value_line = np.array([float(x) for x in tokens[3:12]])
×
4228
                self.hessian[
×
4229
                    (ind_1 - 1) * 3 : ind_1 * 3, (ind_2 - 1) * 3 : ind_2 * 3
4230
                ] = value_line.reshape((3, 3))
4231

4232
        if len(lattice_vector_lines) != 3 and len(lattice_vector_lines) != 0:
1✔
4233
            print(
×
4234
                "Warning: Number of lattice vectors is: "
4235
                + str(len(lattice_vector_lines))
4236
            )
4237
        for i, line in enumerate(lattice_vector_lines):
1✔
4238
            tokens = line.split()
1✔
4239
            self.lattice_vectors[i, :] = [float(x) for x in tokens[1:4]]
1✔
4240

4241
        # convert to cartesian coordinates
4242
        if is_fractional:
1✔
4243
            self.coords = utils.get_cartesian_coords(self.coords, self.lattice_vectors)
1✔
4244
            self.read_as_fractional_coords = True
1✔
4245

4246
        self.constrain_relax = np.array(self.constrain_relax)
1✔
4247
        self.external_force = np.array(self.external_force)
1✔
4248
        self.calculate_friction = np.array(self.calculate_friction)
1✔
4249

4250
        # update Part list and add all atoms that are not yet in the list
4251
        if len(self.geometry_parts) > 0:
1✔
4252
            already_indexed = list(itertools.chain.from_iterable(self.geometry_parts))
×
4253
            if len(already_indexed) < self.n_atoms:
×
4254
                additional_indices = [
×
4255
                    i for i in range(self.n_atoms) if i not in already_indexed
4256
                ]
4257
                self.geometry_parts.append(additional_indices)
×
4258
                self.geometry_part_descriptions.append("rest")
×
4259

4260
    def get_text(self, is_fractional=None):
1✔
4261
        """
4262
        If symmetry_params are to be used, the coordinates need to be fractional.
4263
        So, if symmetry_params are found, is_fractional is overridden to true.
4264
        """
4265
        if is_fractional is None:
1✔
4266
            if hasattr(self, "symmetry_params") and self.symmetry_params is not None:
1✔
4267
                is_fractional = True
×
4268
            else:
4269
                is_fractional = False
1✔
4270
        elif (
×
4271
            is_fractional is False
4272
            and hasattr(self, "symmetry_params")
4273
            and self.symmetry_params is not None
4274
        ):
4275
            warnings.warn(
×
4276
                "The symmetry parameters of your geometry will be lost. "
4277
                "To keep them set is_fractional to True",
4278
                stacklevel=2,
4279
            )
4280

4281
        text = ""
1✔
4282
        for line in self.comment_lines:
1✔
4283
            if line.startswith("#"):
×
4284
                text += line + "\n"
×
4285
            else:
4286
                text += (
×
4287
                    "# " + line.lstrip() + "\n"
4288
                )  # str.lstrip() removes leading whitespace in comment line 'l'
4289

4290
        # If set, write 'center' dict ( see docstring of Geometry.__init__ ) to file
4291
        if hasattr(self, "center") and isinstance(self.center, dict):
1✔
4292
            center_string = "# CENTER " + str(self.center)
×
4293
            text += center_string + "\n"
×
4294

4295
        if hasattr(self, "geometry_parts") and (len(self.geometry_parts) > 0):
1✔
4296
            part_string = "# PARTS "
×
4297
            part_dict = {}
×
4298
            for part, name in zip(self.geometry_parts, self.geometry_part_descriptions):
×
4299
                if name != "rest":
×
4300
                    if name not in part_dict:
×
4301
                        part_dict[name] = part
×
4302
                    else:
4303
                        warnings.warn(
×
4304
                            "Multiple equally named parts in file, renaming "
4305
                            "automatically!",
4306
                            stacklevel=2,
4307
                        )
4308
                        part_dict[name + "_1"] = part
×
4309
            part_string += str(part_dict) + "\n"
×
4310
            text += part_string
×
4311

4312
        if hasattr(self, "vacuum_level") and (self.vacuum_level is not None):
1✔
4313
            text += f"set_vacuum_level {self.vacuum_level: 15.10f}" + "\n"
×
4314

4315
        # Lattice vector relaxation constraints
4316
        constrain_vectors = np.zeros([3, 3], dtype=bool)
1✔
4317
        # if is_2D:
4318
        #    constrain_vectors[0, 2], constrain_vectors[1, 2], constrain_vectors[2] = True, True, 3*[True]
4319

4320
        # TODO: Some sort of custom lattice vector relaxation constraints parser
4321

4322
        if (self.lattice_vectors != 0).any():
1✔
4323
            for i in range(3):
×
4324
                line = "lattice_vector"
×
4325
                for j in range(3):
×
4326
                    line += f"     {self.lattice_vectors[i, j]:.8f}"
×
4327
                text += line + "\n"
×
4328
                cr = "\tconstrain_relaxation "
×
4329
                if constrain_vectors.any():
×
4330
                    if constrain_vectors[i].all():
×
4331
                        text += f"{cr}.true.\n"
×
4332
                    else:
4333
                        if constrain_vectors[i, 0]:
×
4334
                            text += f"{cr}x\n"
×
4335
                        if constrain_vectors[i, 1]:
×
4336
                            text += f"{cr}y\n"
×
4337
                        if constrain_vectors[i, 2]:
×
4338
                            text += f"{cr}z\n"
×
4339

4340
        # write down the homogeneous field if any is present
4341
        if self.homogeneous_field is not None:
1✔
4342
            text += "homogeneous_field {} {} {}\n".format(*self.homogeneous_field)
×
4343

4344
        if is_fractional:
1✔
4345
            coords = utils.get_fractional_coords(self.coords, self.lattice_vectors)
×
4346
            line_start = "atom_frac"
×
4347
        else:
4348
            coords = self.coords
1✔
4349
            line_start = "atom"
1✔
4350

4351
        for n in range(self.n_atoms):
1✔
4352
            if self.species[n] == "Em":  # do not save "Emptium" atoms
1✔
4353
                warnings.warn("Emptium atom was removed!!")
×
4354
                continue
×
4355
            line = line_start
1✔
4356
            for j in range(3):
1✔
4357
                line += f"     {coords[n, j]:.8f}"
1✔
4358
            line += " " + self.species[n]
1✔
4359
            text += line + "\n"
1✔
4360
            # backwards compatibilty for old-style constrain_relax
4361
            if isinstance(self.constrain_relax[0], bool):
1✔
4362
                if self.constrain_relax[n]:
×
4363
                    text += "constrain_relaxation .true.\n"
×
4364
            elif all(self.constrain_relax[n]):
1✔
4365
                text += "constrain_relaxation .true.\n"
1✔
4366
            else:
4367
                if self.constrain_relax[n][0]:
1✔
4368
                    text += "constrain_relaxation x\n"
×
4369
                if self.constrain_relax[n][1]:
1✔
4370
                    text += "constrain_relaxation y\n"
×
4371
                if self.constrain_relax[n][2]:
1✔
4372
                    text += "constrain_relaxation z\n"
×
4373
            if not len(self.initial_charge) == 0 and self.initial_charge[n] != 0.0:
1✔
4374
                text += f"initial_charge {self.initial_charge[n]: .6f}\n"
×
4375
            if not len(self.initial_moment) == 0 and self.initial_moment[n] != 0.0:
1✔
4376
                text += f"initial_moment {self.initial_moment[n]: .6f}\n"
×
4377
            if (
1✔
4378
                hasattr(self, "external_force")
4379
                and np.linalg.norm(self.external_force[n]) != 0.0
4380
            ):
4381
                text += f"external_force {self.external_force[n][0]: .6f} {self.external_force[n][1]: .6f} {self.external_force[n][2]: .6f}\n"
×
4382
            if hasattr(self, "calculate_friction") and self.calculate_friction[n]:
1✔
4383
                text += "calculate_friction .true.\n"
×
4384

4385
        if hasattr(self, "hessian") and self.hessian is not None:
1✔
4386
            text += "# own_hessian\n# This is a self calculated Hessian, not from a geometry optimization!\n"
×
4387
            for i in range(self.n_atoms):
×
4388
                for j in range(self.n_atoms):
×
4389
                    s = f"hessian_block  {i + 1} {j + 1}"
×
4390
                    H_block = self.hessian[3 * i : 3 * (i + 1), 3 * j : 3 * (j + 1)]
×
4391
                    # max_diff = np.max(np.abs(H_block-H_block.T))
4392
                    # print("Max diff in H: {:.3f}".format(max_diff))
4393
                    for h in H_block.flatten():
×
4394
                        s += f"  {h:.6f}"
×
4395
                    text += s + "\n"
×
4396

4397
        # write down symmetry_params and related data
4398
        if is_fractional:
1✔
4399
            if self.symmetry_params is not None:
×
4400
                l = "symmetry_params "
×
4401
                for p in self.symmetry_params:
×
4402
                    l += f"{p} "
×
4403
                l += "\n"
×
4404
                text += "\n" + l
×
4405
            if self.n_symmetry_params is not None:
×
4406
                l = "symmetry_n_params "
×
4407
                for n in self.n_symmetry_params:
×
4408
                    l += f"{n} "
×
4409
                text += l + "\n"
×
4410
                text += "\n"
×
4411
            if self.symmetry_LVs is not None:
×
4412
                for i in range(3):
×
4413
                    line = "symmetry_lv     {}  ,  {}  ,  {}".format(
×
4414
                        *self.symmetry_LVs[i]
4415
                    )
4416
                    text += line + "\n"
×
4417
                text += "\n"
×
4418
            if self.symmetry_frac_coords is not None:
×
4419
                for c in self.symmetry_frac_coords:
×
4420
                    line = "symmetry_frac     {}  ,  {}  ,  {}".format(*c)
×
4421
                    text += line + "\n"
×
4422
                text += "\n"
×
4423

4424
        # write down multipoles
4425
        for m in self.multipoles:
1✔
4426
            text += "multipole {}   {}   {}   {}   {}\n".format(*m)
×
4427
        return text
1✔
4428

4429

4430
class VaspGeometry(Geometry):
1✔
4431
    def parse(self, text):
1✔
4432
        """
4433
        Read the VASP structure definition in the typical POSCAR format
4434
        (also used by CONTCAR files, for example) from the file with the given filename
4435

4436
        Returns
4437
        -------
4438
        dic:
4439
            The dictionary holds the following keys:
4440
            systemname:
4441
            The name of the system as given in the first line of the POSCAR file.
4442
            vecs:
4443
            The unit cell vector as a 3x3 numpy.array. vecs[0,:] is the first unit
4444
            cell vector, vecs[:,0] are the x-coordinates of the three unit cell cevtors.
4445
            scaling:
4446
            The scaling factor of the POSCAR as given in the second line. However, this
4447
            information is not processed, it is up to the user to use this information
4448
            to scale whatever needs to be scaled.
4449
            coordinates:
4450
            The coordinates of all the atoms. Q[k,:] are the coordinates of the k-th atom
4451
            (the index starts with 0, as usual). Q[:,0] are the x-coordinates of all the atoms. These coordinates are always given in Cartesian coordinates.
4452
            elementtypes:
4453
            A list of as many entries as there are atoms. Gives the type specification
4454
            for every atom (typically the atom name). elementtypes[k] is the species of
4455
            the k-th atom.
4456
            typenames:
4457
            The names of all the species. This list contains as many elements as there are species.
4458
            numberofelements:
4459
            Gives the number of atoms per species. This list contains as many elements as there are species.
4460
            elementid:
4461
            Gives the index (from 0 to the number of atoms-1) of the first atom of a
4462
            certain species. This list contains as many elements as there are species.
4463
            cartesian:
4464
            A logical value whether the coordinates were given in Cartesian form (True)
4465
            or as direct coordinates (False).
4466
            originalcoordinates:
4467
            The original coordinates as read from the POSCAR file. It has the same
4468
            format as coordinates. For Cartesian coordinates (cartesian == True) this
4469
            is identical to coordinates, for direct coordinates (cartesian == False)
4470
            this contains the direct coordinates.
4471
            selective:
4472
            True or False: whether selective dynamics is on.
4473
            selectivevals:
4474
            Consists of as many rows as there are atoms, three colums: True if
4475
            selective dynamics is on for this coordinate for the atom, else False.
4476
            Only if selective is True.
4477
        """
4478
        lino = 0
×
4479
        vecs = []
×
4480
        scaling = 1.0
×
4481
        typenames = []
×
4482
        nelements = []
×
4483
        cartesian = False
×
4484
        selective = False
×
4485
        selectivevals = []
×
4486
        P = []
×
4487
        fi = text.split("\n")
×
4488

4489
        for line in fi:
×
4490
            lino += 1
×
4491
            line = line.strip()
×
4492

4493
            if lino == 1:
×
4494
                self.add_top_comment(line)
×
4495
            if lino == 2:
×
4496
                scaling = float(line)
×
4497
                # RB: now the scaling should be taken account for below when the lattice vectors and coordinates
4498
                # if scaling != 1.0:
4499
                #    print("WARNING (readin_struct): universal scaling factor is not one. This is ignored.")
4500

4501
            if lino in (3, 4, 5):
×
4502
                vecs.append(list(map(float, line.split())))
×
4503
            if lino == 6:
×
4504
                if line[0] in [
×
4505
                    "0",
4506
                    "1",
4507
                    "2",
4508
                    "3",
4509
                    "4",
4510
                    "5",
4511
                    "6",
4512
                    "7",
4513
                    "8",
4514
                    "9",
4515
                ]:
4516
                    lino += 1
×
4517
                else:
4518
                    typenames = line.split()
×
4519
            if lino == 7:
×
4520
                splitline = line.split()
×
4521
                nelements = list(map(int, splitline))
×
4522
                elementid = np.cumsum(np.array(nelements))
×
4523
                self.n_atoms = elementid[-1]
×
4524
            if lino == 8:
×
4525
                if line[0] in ("S", "s"):
×
4526
                    selective = True
×
4527
                else:
4528
                    lino += 1
×
4529
            if lino == 9:
×
4530
                if line[0] in ("K", "k", "C", "c"):  # cartesian coordinates
×
4531
                    cartesian = True
×
4532
            if lino >= 10:
×
4533
                if lino >= 10 + self.n_atoms:
×
4534
                    break
×
4535
                P.append(list(map(float, line.split()[0:3])))
×
4536

4537
                if selective:
×
4538
                    # TODO: experimental...
4539
                    constraints = list(
×
4540
                        map(lambda x: x in ("F", "f"), line.split()[3:6])
4541
                    )
4542
                    if len(constraints) != 3:
×
4543
                        self.constrain_relax.append([False, False, False])
×
4544
                    else:
4545
                        self.constrain_relax.append(constraints)
×
4546
                    selectivevals.append(constraints)
×
4547
                else:
4548
                    self.constrain_relax.append([False, False, False])
×
4549
                    # TODO: write true value
4550
                    self.initial_charge.append(0)
×
4551
                    self.initial_moment.append(0)
×
4552

4553
                self.external_force = np.append(
×
4554
                    self.external_force, np.atleast_2d(np.zeros(3)), axis=0
4555
                )
4556
                self.calculate_friction = np.append(
×
4557
                    self.calculate_friction, np.array([False])
4558
                )
4559

4560
        vecs = np.array(vecs)
×
4561
        P = np.array(P)
×
4562
        if not cartesian:
×
4563
            Q = np.dot(P, vecs)
×
4564
        else:
4565
            Q = P
×
4566
        if len(typenames) > 0:
×
4567
            for k in range(Q.shape[0]):
×
4568
                self.species.append(typenames[np.min(np.where(elementid > k)[0])])
×
4569

4570
        self.lattice_vectors = vecs
×
4571
        self.coords = Q
×
4572
        self.constrain_relax = np.array(self.constrain_relax)
×
4573

4574
        # RB: include the scaling. should work for both direct and cartesian settings
4575
        self.lattice_vectors = vecs * scaling
×
4576
        self.coords = Q * scaling
×
4577

4578
    def get_text(self, comment="POSCAR file written by Geometry.py"):
1✔
4579
        comment = comment.replace("\n", " ")
×
4580
        text = comment + "\n"
×
4581
        text += "1\n"
×
4582
        if (self.lattice_vectors != 0).any():
×
4583
            for i in range(3):
×
4584
                line = ""
×
4585
                for j in range(3):
×
4586
                    line += f"     {self.lattice_vectors[i, j]:-4.8f}"
×
4587
                text += line.strip() + "\n"
×
4588

4589
        all_species = sorted(
×
4590
            list(set(self.species))
4591
        )  # get unique species and sort alphabetically
4592
        text += " ".join(all_species) + "\n"
×
4593
        species_coords = {}
×
4594
        n_of_species = {}
×
4595
        # R.B. relax constraints
4596
        relax_constraints = {}
×
4597
        ## R.B. relax constraints end
4598

4599
        for species in all_species:
×
4600
            is_right_species = np.array(
×
4601
                [s == species for s in self.species], dtype=bool
4602
            )
4603
            curr_species_coords = self.coords[is_right_species, :]
×
4604
            species_coords[species] = curr_species_coords
×
4605
            n_of_species[species] = curr_species_coords.shape[0]
×
4606

4607
            # R.B. relax constraints
4608
            curr_species_constrain_relax = self.constrain_relax[is_right_species, :]
×
4609
            relax_constraints[species] = curr_species_constrain_relax
×
4610
            ## R.B. relax constraints end
4611

4612
        # add number of atoms per species
4613
        text += " ".join([str(n_of_species[s]) for s in all_species]) + "\n"
×
4614

4615
        # R.B. Write out selective dynamics so that the relaxation constraints are read
4616
        text += "Selective dynamics" + "\n"
×
4617

4618
        text += "Cartesian" + "\n"
×
4619

4620
        for species in all_species:
×
4621
            curr_coords = species_coords[species]
×
4622
            n_atoms = n_of_species[species]
×
4623

4624
            ## R.B. relax constraints
4625
            curr_relax_constr = relax_constraints[species]
×
4626
            ## R.B. relax constraints end
4627

4628
            for n in range(n_atoms):
×
4629
                line = ""
×
4630
                for j in range(3):
×
4631
                    if j > 0:
×
4632
                        line += "    "
×
4633
                    line += f"{curr_coords[n, j]: 2.8f}"
×
4634

4635
                ## R.B. relax constraints
4636
                for j in range(3):
×
4637
                    if curr_relax_constr[n, j] is True:
×
4638
                        line += "  " + "F"
×
4639
                    elif curr_relax_constr[n, j] is False:
×
4640
                        line += "  " + "T"
×
4641
                ## R.B. relax constraints end
4642

4643
                text += line + "\n"
×
4644

4645
        return text
×
4646

4647

4648
class XYZGeometry(Geometry):
1✔
4649
    def parse(self, text):
1✔
4650
        """
4651
        Reads a .xyz file. Designed to work with .xyz files produced by Avogadro
4652

4653
        """
4654
        # to use add_atoms we need to initialize coords the same as for Geometry
4655
        self.n_atoms = 0
×
4656
        self.coords = np.zeros([self.n_atoms, 3])
×
4657

4658
        read_natoms = None
×
4659
        count_natoms = 0
×
4660
        coords = []
×
4661
        forces = []
×
4662
        species = []
×
4663
        fi = text.split("\n")
×
4664

4665
        # parse will assume first few lines are comments
4666
        started_parsing_atoms = False
×
4667

4668
        for ind, line in enumerate(fi):
×
4669
            if ind == 0 and len(line.split()) == 1:
×
4670
                read_natoms = int(line.split()[0])
×
4671
                continue
×
4672

4673
            # look for lattice vectors
4674
            if "Lattice" in line:
×
4675
                split_line = line.split('"')[1]
×
4676

4677
                lattice_parameters = re.findall(r"\d+\.\d+", split_line)
×
4678

4679
                if len(lattice_parameters) == 9:
×
4680
                    lattice_parameters = np.array(lattice_parameters, dtype=np.float64)
×
4681
                    self.lattice_vectors = np.reshape(lattice_parameters, (3, 3))
×
4682

4683
            if "energy" in line:
×
4684
                split_line = line.split("energy")[1]
×
4685

4686
                energy = re.findall(r"-?[\d.]+(?:e-?\d+)?", split_line)
×
4687

4688
                if len(energy) > 0:
×
4689
                    self.energy = np.float64(energy[0])
×
4690

4691
            split_line = line.split()
×
4692

4693
            n_words = 0
×
4694
            n_floats = 0
×
4695

4696
            for l in split_line:
×
4697
                n_words_new = len(re.findall("[a-zA-Z]+", l))
×
4698
                n_floats_new = len(re.findall(r"-?[\d.]+(?:e-?\d+)?", l))
×
4699

4700
                if n_words_new == 1 and n_floats_new == 1:
×
4701
                    n_floats += 1
×
4702
                else:
4703
                    n_words += n_words_new
×
4704
                    n_floats += n_floats_new
×
4705

4706
            # first few lines may be comments or properties
4707
            if not started_parsing_atoms:
×
4708
                if n_words == 1 and (n_floats in {3, 6}):
×
4709
                    continue
×
4710
                started_parsing_atoms = True
×
4711

4712
            else:
4713
                if split_line == []:
×
4714
                    break
×
4715
                assert n_words == 1 and (n_floats in {3, 6}), (
×
4716
                    "Bad atoms specification: "
4717
                    + str(split_line)
4718
                    + f"{n_words} {n_floats}"
4719
                )
4720

4721
                # write atoms
4722
                species.append(str(split_line[0]))
×
4723
                coords.append(np.array(split_line[1:4], dtype=np.float64))
×
4724

4725
                if n_floats == 6:
×
4726
                    forces.append(np.array(split_line[4:], dtype=np.float64))
×
4727

4728
                count_natoms += 1
×
4729

4730
        if not started_parsing_atoms:
×
4731
            raise RuntimeError("Not atoms found in xyz file!")
×
4732

4733
        if read_natoms is not None:
×
4734
            assert read_natoms == count_natoms, "Not all atoms found!"
×
4735

4736
        coords = np.asarray(coords)
×
4737
        self.add_atoms(coords, species)
×
4738

4739
        if forces:
×
4740
            forces = np.asarray(forces)
×
4741
            self.forces = forces
×
4742

4743
    def get_text(self, comment="XYZ file written by Geometry.py"):
1✔
4744
        text = str(self.n_atoms) + "\n"
×
4745
        comment = comment.replace("\n", " ")
×
4746
        text += comment + "\n"
×
4747
        for index in range(self.n_atoms):
×
4748
            element = self.species[index]
×
4749
            x, y, z = self.coords[index]
×
4750
            text += f"{element}    {x:-4.8f}    {y:-4.8f}    {z:-4.8f}" + "\n"
×
4751
        return text
×
4752

4753

4754
class XSFGeometry(Geometry):
1✔
4755
    def get_text(self):
1✔
4756
        text = ""
×
4757
        text += "CRYSTAL\n"
×
4758
        text += "PRIMVEC\n"
×
4759
        for i in range(3):
×
4760
            line = ""
×
4761
            for j in range(3):
×
4762
                line += f"    {self.lattice_vectors[i, j]:.8f}"
×
4763
            text += line + "\n"
×
4764
        text += "PRIMCOORD\n"
×
4765
        # the 1 is mysterious but is needed for primcoord according to XSF docu
4766
        text += str(self.n_atoms) + " 1\n"
×
4767
        for i in range(self.n_atoms):
×
4768
            if self.constrain_relax[i]:
×
4769
                raise NotImplementedError(
×
4770
                    "Constrained relaxation not supported for XSF output file"
4771
                )
4772
            line = str(PeriodicTable.get_atomic_number(self.species[i]))
×
4773
            for j in range(3):
×
4774
                line += f"    {self.coords[i, j]:.8f}"
×
4775
            text += line + "\n"
×
4776
        return text
×
4777

4778

4779
###############################################################################
4780
#                            Auxiliary Functions                              #
4781
###############################################################################
4782
def get_file_format_from_ending(filename):
1✔
4783
    if filename.endswith(".in") or filename.endswith(".next_step"):
1✔
4784
        return "aims"
1✔
4785
    if filename.endswith(".xsf"):
×
4786
        return "xsf"
×
4787
    if filename.endswith(".molden"):
×
4788
        return "molden"
×
4789
    if filename.endswith("POSCAR") or filename.endswith("CONTCAR"):
×
4790
        return "vasp"
×
4791
    if filename.endswith(".xyz"):
×
4792
        return "xyz"
×
4793
    return None
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc