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

materialsproject / pymatgen / 4075885785

pending completion
4075885785

push

github

Shyue Ping Ong
Merge branch 'master' of github.com:materialsproject/pymatgen

96 of 96 new or added lines in 27 files covered. (100.0%)

81013 of 102710 relevant lines covered (78.88%)

0.79 hits per line

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

90.32
/pymatgen/core/structure.py
1
# Copyright (c) Pymatgen Development Team.
2
# Distributed under the terms of the MIT License.
3

4
"""
1✔
5
This module provides classes used to define a non-periodic molecule and a
6
periodic structure.
7
"""
8

9
from __future__ import annotations
1✔
10

11
import collections
1✔
12
import functools
1✔
13
import itertools
1✔
14
import json
1✔
15
import math
1✔
16
import os
1✔
17
import random
1✔
18
import re
1✔
19
import warnings
1✔
20
from abc import ABCMeta, abstractmethod
1✔
21
from fnmatch import fnmatch
1✔
22
from io import StringIO
1✔
23
from typing import (
1✔
24
    Any,
25
    Callable,
26
    Iterable,
27
    Iterator,
28
    Literal,
29
    Sequence,
30
    SupportsIndex,
31
    cast,
32
)
33

34
import numpy as np
1✔
35
from monty.dev import deprecated
1✔
36
from monty.io import zopen
1✔
37
from monty.json import MSONable
1✔
38
from ruamel.yaml import YAML
1✔
39
from tabulate import tabulate
1✔
40

41
from pymatgen.core.bonds import CovalentBond, get_bond_length
1✔
42
from pymatgen.core.composition import Composition
1✔
43
from pymatgen.core.lattice import Lattice, get_points_in_spheres
1✔
44
from pymatgen.core.operations import SymmOp
1✔
45
from pymatgen.core.periodic_table import DummySpecies, Element, Species, get_el_sp
1✔
46
from pymatgen.core.sites import PeriodicSite, Site
1✔
47
from pymatgen.core.units import Length, Mass
1✔
48
from pymatgen.electronic_structure.core import Magmom
1✔
49
from pymatgen.symmetry.maggroups import MagneticSpaceGroup
1✔
50
from pymatgen.util.coord import all_distances, get_angle, lattice_points_in_supercell
1✔
51
from pymatgen.util.typing import ArrayLike, CompositionLike, SpeciesLike
1✔
52

53

54
class Neighbor(Site):
1✔
55
    """
56
    Simple Site subclass to contain a neighboring atom that skips all the unnecessary checks for speed. Can be
57
    used as a fixed-length tuple of size 3 to retain backwards compatibility with past use cases.
58

59
        (site, nn_distance, index).
60

61
    In future, usage should be to call attributes, e.g., Neighbor.index, Neighbor.distance, etc.
62
    """
63

64
    def __init__(
1✔
65
        self,
66
        species: Composition,
67
        coords: np.ndarray,
68
        properties: dict | None = None,
69
        nn_distance: float = 0.0,
70
        index: int = 0,
71
    ):
72
        """
73
        :param species: Same as Site
74
        :param coords: Same as Site, but must be fractional.
75
        :param properties: Same as Site
76
        :param nn_distance: Distance to some other Site.
77
        :param index: Index within structure.
78
        """
79
        self.coords = coords
1✔
80
        self._species = species
1✔
81
        self.properties = properties or {}
1✔
82
        self.nn_distance = nn_distance
1✔
83
        self.index = index
1✔
84

85
    def __len__(self) -> Literal[3]:
1✔
86
        """
87
        Make neighbor Tuple-like to retain backwards compatibility.
88
        """
89
        return 3
×
90

91
    def __getitem__(self, idx: int):
1✔
92
        """Make neighbor Tuple-like to retain backwards compatibility."""
93
        return (self, self.nn_distance, self.index)[idx]
1✔
94

95
    def as_dict(self) -> dict:  # type: ignore
1✔
96
        """
97
        Note that method calls the super of Site, which is MSONable itself.
98

99
        Returns: dict
100
        """
101
        return super(Site, self).as_dict()  # pylint: disable=E1003
×
102

103
    @classmethod
1✔
104
    def from_dict(cls, d: dict) -> Neighbor:  # type: ignore
1✔
105
        """
106
        Returns a Neighbor from a dict.
107

108
        Args:
109
            d: MSONable dict format.
110

111
        Returns:
112
            Neighbor
113
        """
114
        return super(Site, cls).from_dict(d)  # pylint: disable=E1003
×
115

116

117
class PeriodicNeighbor(PeriodicSite):
1✔
118
    """
119
    Simple PeriodicSite subclass to contain a neighboring atom that skips all
120
    the unnecessary checks for speed. Can be used as a fixed-length tuple of
121
    size 4 to retain backwards compatibility with past use cases.
122

123
        (site, distance, index, image).
124

125
    In future, usage should be to call attributes, e.g., PeriodicNeighbor.index,
126
    PeriodicNeighbor.distance, etc.
127
    """
128

129
    def __init__(
1✔
130
        self,
131
        species: Composition,
132
        coords: np.ndarray,
133
        lattice: Lattice,
134
        properties: dict | None = None,
135
        nn_distance: float = 0.0,
136
        index: int = 0,
137
        image: tuple = (0, 0, 0),
138
    ):
139
        """
140
        Args:
141
            species (Composition): Same as PeriodicSite
142
            coords (np.ndarray): Same as PeriodicSite, but must be fractional.
143
            lattice (Lattice): Same as PeriodicSite
144
            properties (dict, optional): Same as PeriodicSite. Defaults to None.
145
            nn_distance (float, optional): Distance to some other Site.. Defaults to 0.0.
146
            index (int, optional): Index within structure.. Defaults to 0.
147
            image (tuple, optional): PeriodicImage. Defaults to (0, 0, 0).
148
        """
149
        self._lattice = lattice
1✔
150
        self._frac_coords = coords
1✔
151
        self._species = species
1✔
152
        self.properties = properties or {}
1✔
153
        self.nn_distance = nn_distance
1✔
154
        self.index = index
1✔
155
        self.image = image
1✔
156

157
    @property  # type: ignore
1✔
158
    def coords(self) -> np.ndarray:  # type: ignore
1✔
159
        """
160
        :return: Cartesian coords.
161
        """
162
        return self._lattice.get_cartesian_coords(self._frac_coords)
1✔
163

164
    def __len__(self):
1✔
165
        """
166
        Make neighbor Tuple-like to retain backwards compatibility.
167
        """
168
        return 4
1✔
169

170
    def __getitem__(self, i: int):
1✔
171
        """
172
        Make neighbor Tuple-like to retain backwards compatibility.
173
        """
174
        return (self, self.nn_distance, self.index, self.image)[i]
1✔
175

176
    def as_dict(self) -> dict:  # type: ignore
1✔
177
        """
178
        Note that method calls the super of Site, which is MSONable itself.
179

180
        Returns: dict
181
        """
182
        return super(Site, self).as_dict()  # pylint: disable=E1003
1✔
183

184
    @classmethod
1✔
185
    def from_dict(cls, d: dict) -> PeriodicNeighbor:  # type: ignore
1✔
186
        """
187
        Returns a PeriodicNeighbor from a dict.
188

189
        Args:
190
            d: MSONable dict format.
191

192
        Returns:
193
            PeriodicNeighbor
194
        """
195
        return super(Site, cls).from_dict(d)  # pylint: disable=E1003
1✔
196

197

198
class SiteCollection(collections.abc.Sequence, metaclass=ABCMeta):
1✔
199
    """
200
    Basic SiteCollection. Essentially a sequence of Sites or PeriodicSites.
201
    This serves as a base class for Molecule (a collection of Site, i.e., no
202
    periodicity) and Structure (a collection of PeriodicSites, i.e.,
203
    periodicity). Not meant to be instantiated directly.
204
    """
205

206
    # Tolerance in Angstrom for determining if sites are too close.
207
    DISTANCE_TOLERANCE = 0.5
1✔
208

209
    @property
1✔
210
    @abstractmethod
1✔
211
    def sites(self) -> tuple[Site, ...]:
1✔
212
        """
213
        Returns a tuple of sites.
214
        """
215

216
    @abstractmethod
1✔
217
    def get_distance(self, i: int, j: int) -> float:
1✔
218
        """
219
        Returns distance between sites at index i and j.
220

221
        Args:
222
            i: Index of first site
223
            j: Index of second site
224

225
        Returns:
226
            Distance between sites at index i and index j.
227
        """
228

229
    @property
1✔
230
    def distance_matrix(self) -> np.ndarray:
1✔
231
        """
232
        Returns the distance matrix between all sites in the structure. For
233
        periodic structures, this is overwritten to return the nearest image
234
        distance.
235
        """
236
        return all_distances(self.cart_coords, self.cart_coords)
1✔
237

238
    @property
1✔
239
    def species(self) -> list[Element | Species]:
1✔
240
        """
241
        Only works for ordered structures.
242
        Disordered structures will raise an AttributeError.
243

244
        Returns:
245
            ([Species]) List of species at each site of the structure.
246
        """
247
        return [site.specie for site in self]
1✔
248

249
    @property
1✔
250
    def species_and_occu(self) -> list[Composition]:
1✔
251
        """
252
        List of species and occupancies at each site of the structure.
253
        """
254
        return [site.species for site in self]
1✔
255

256
    @property
1✔
257
    def ntypesp(self) -> int:
1✔
258
        """Number of types of atoms."""
259
        return len(self.types_of_species)
1✔
260

261
    @property
1✔
262
    def types_of_species(self) -> tuple[Element | Species | DummySpecies]:
1✔
263
        """
264
        List of types of specie.
265
        """
266
        # Cannot use set since we want a deterministic algorithm.
267
        types: list[Element | Species | DummySpecies] = []
1✔
268
        for site in self:
1✔
269
            for sp, v in site.species.items():
1✔
270
                if v != 0:
1✔
271
                    types.append(sp)
1✔
272
        return tuple(sorted(set(types)))  # type: ignore
1✔
273

274
    @property
1✔
275
    def types_of_specie(self) -> tuple[Element | Species | DummySpecies]:
1✔
276
        """
277
        Specie->Species rename. Maintained for backwards compatibility.
278
        """
279
        return self.types_of_species
×
280

281
    def group_by_types(self) -> Iterator[Site | PeriodicSite]:
1✔
282
        """Iterate over species grouped by type"""
283
        for t in self.types_of_species:
1✔
284
            for site in self:
1✔
285
                if site.specie == t:
1✔
286
                    yield site
1✔
287

288
    def indices_from_symbol(self, symbol: str) -> tuple[int, ...]:
1✔
289
        """
290
        Returns a tuple with the sequential indices of the sites
291
        that contain an element with the given chemical symbol.
292
        """
293
        return tuple((i for i, specie in enumerate(self.species) if specie.symbol == symbol))
1✔
294

295
    @property
1✔
296
    def symbol_set(self) -> tuple[str, ...]:
1✔
297
        """
298
        Tuple with the set of chemical symbols.
299
        Note that len(symbol_set) == len(types_of_specie)
300
        """
301
        return tuple(sorted(specie.symbol for specie in self.types_of_species))
1✔
302

303
    @property
1✔
304
    def atomic_numbers(self) -> tuple[int, ...]:
1✔
305
        """List of atomic numbers."""
306
        try:
1✔
307
            return tuple(site.specie.Z for site in self)
1✔
308
        except AttributeError:
×
309
            raise AttributeError("atomic_numbers available only for ordered Structures")
×
310

311
    @property
1✔
312
    def site_properties(self) -> dict[str, Sequence]:
1✔
313
        """
314
        Returns the site properties as a dict of sequences. E.g.,
315
        {"magmom": (5,-5), "charge": (-4,4)}.
316
        """
317
        props: dict[str, Sequence] = {}
1✔
318
        prop_keys: set[str] = set()
1✔
319
        for site in self:
1✔
320
            prop_keys.update(site.properties)
1✔
321

322
        for key in prop_keys:
1✔
323
            props[key] = [site.properties.get(key) for site in self]
1✔
324
        return props
1✔
325

326
    def __contains__(self, site: object) -> bool:
1✔
327
        return site in self.sites
1✔
328

329
    def __iter__(self) -> Iterator[Site]:
1✔
330
        return self.sites.__iter__()
1✔
331

332
    def __getitem__(self, ind):
1✔
333
        return self.sites[ind]
1✔
334

335
    def __len__(self) -> int:
1✔
336
        return len(self.sites)
1✔
337

338
    def __hash__(self) -> int:
1✔
339
        # for now, just use the composition hash code.
340
        return hash(self.composition)
×
341

342
    @property
1✔
343
    def num_sites(self) -> int:
1✔
344
        """
345
        Number of sites.
346
        """
347
        return len(self)
1✔
348

349
    @property
1✔
350
    def cart_coords(self) -> np.ndarray:
1✔
351
        """
352
        Returns an np.array of the Cartesian coordinates of sites in the
353
        structure.
354
        """
355
        return np.array([site.coords for site in self])
1✔
356

357
    @property
1✔
358
    def formula(self) -> str:
1✔
359
        """
360
        (str) Returns the formula.
361
        """
362
        return self.composition.formula
1✔
363

364
    @property
1✔
365
    def composition(self) -> Composition:
1✔
366
        """
367
        (Composition) Returns the composition
368
        """
369
        elem_map: dict[Species, float] = collections.defaultdict(float)
1✔
370
        for site in self:
1✔
371
            for species, occu in site.species.items():
1✔
372
                elem_map[species] += occu
1✔
373
        return Composition(elem_map)
1✔
374

375
    @property
1✔
376
    def charge(self) -> float:
1✔
377
        """
378
        Returns the net charge of the structure based on oxidation states. If
379
        Elements are found, a charge of 0 is assumed.
380
        """
381
        charge = 0
1✔
382
        for site in self:
1✔
383
            for specie, amt in site.species.items():
1✔
384
                charge += (getattr(specie, "oxi_state", 0) or 0) * amt
1✔
385

386
        return charge
1✔
387

388
    @property
1✔
389
    def is_ordered(self) -> bool:
1✔
390
        """
391
        Checks if structure is ordered, meaning no partial occupancies in any
392
        of the sites.
393
        """
394
        return all(site.is_ordered for site in self)
1✔
395

396
    def get_angle(self, i: int, j: int, k: int) -> float:
1✔
397
        """
398
        Returns angle specified by three sites.
399

400
        Args:
401
            i: Index of first site.
402
            j: Index of second site.
403
            k: Index of third site.
404

405
        Returns:
406
            Angle in degrees.
407
        """
408
        v1 = self[i].coords - self[j].coords
1✔
409
        v2 = self[k].coords - self[j].coords
1✔
410
        return get_angle(v1, v2, units="degrees")
1✔
411

412
    def get_dihedral(self, i: int, j: int, k: int, l: int) -> float:
1✔
413
        """
414
        Returns dihedral angle specified by four sites.
415

416
        Args:
417
            i: Index of first site
418
            j: Index of second site
419
            k: Index of third site
420
            l: Index of fourth site
421

422
        Returns:
423
            Dihedral angle in degrees.
424
        """
425
        v1 = self[k].coords - self[l].coords
1✔
426
        v2 = self[j].coords - self[k].coords
1✔
427
        v3 = self[i].coords - self[j].coords
1✔
428
        v23 = np.cross(v2, v3)
1✔
429
        v12 = np.cross(v1, v2)
1✔
430
        return math.degrees(math.atan2(np.linalg.norm(v2) * np.dot(v1, v23), np.dot(v12, v23)))
1✔
431

432
    def is_valid(self, tol: float = DISTANCE_TOLERANCE) -> bool:
1✔
433
        """
434
        True if SiteCollection does not contain atoms that are too close
435
        together. Note that the distance definition is based on type of
436
        SiteCollection. Cartesian distances are used for non-periodic
437
        Molecules, while PBC is taken into account for periodic structures.
438

439
        Args:
440
            tol (float): Distance tolerance. Default is 0.5A.
441

442
        Returns:
443
            (bool) True if SiteCollection does not contain atoms that are too
444
            close together.
445
        """
446
        if len(self.sites) == 1:
1✔
447
            return True
×
448
        all_dists = self.distance_matrix[np.triu_indices(len(self), 1)]
1✔
449
        return bool(np.min(all_dists) > tol)
1✔
450

451
    @abstractmethod
1✔
452
    def to(self, filename: str = "", fmt: str = "") -> str | None:
1✔
453
        """
454
        Generates string representations (cif, json, poscar, ....) of SiteCollections (e.g.,
455
        molecules / structures). Should return str or None if written to a file.
456
        """
457
        raise NotImplementedError
×
458

459
    @classmethod
1✔
460
    @abstractmethod
1✔
461
    def from_str(cls, input_string: str, fmt: Any):
1✔
462
        """
463
        Reads in SiteCollection from a string.
464
        """
465
        raise NotImplementedError
×
466

467
    @classmethod
1✔
468
    @abstractmethod
1✔
469
    def from_file(cls, filename: str):
1✔
470
        """
471
        Reads in SiteCollection from a filename.
472
        """
473
        raise NotImplementedError
×
474

475
    def add_site_property(self, property_name: str, values: list):
1✔
476
        """
477
        Adds a property to a site. Note: This is the preferred method
478
        for adding magnetic moments, selective dynamics, and related
479
        site-specific properties to a structure/molecule object.
480

481
        Examples:
482
            structure.add_site_property("magmom", [1.0, 0.0])
483
            structure.add_site_property("selective_dynamics", [[True, True, True], [False, False, False]])
484

485
        Args:
486
            property_name (str): The name of the property to add.
487
            values (list): A sequence of values. Must be same length as
488
                number of sites.
489
        """
490
        if len(values) != len(self.sites):
1✔
491
            raise ValueError("Values must be same length as sites.")
×
492
        for site, val in zip(self.sites, values):
1✔
493
            site.properties[property_name] = val
1✔
494

495
    def remove_site_property(self, property_name: str):
1✔
496
        """
497
        Removes a property to a site.
498

499
        Args:
500
            property_name (str): The name of the property to remove.
501
        """
502
        for site in self.sites:
1✔
503
            del site.properties[property_name]
1✔
504

505
    def replace_species(self, species_mapping: dict[SpeciesLike, SpeciesLike | dict[SpeciesLike, float]]) -> None:
1✔
506
        """
507
        Swap species. Note that this method modifies the structure in place.
508

509
        Args:
510
            species_mapping (dict): dict of species to swap. Species can be elements too. E.g.,
511
                {Element("Li"): Element("Na")} performs a Li for Na substitution. The second species can
512
                be a sp_and_occu dict. For example, a site with 0.5 Si that is passed the mapping
513
                {Element('Si): {Element('Ge'): 0.75, Element('C'): 0.25} } will have .375 Ge and .125 C.
514
        """
515
        sp_mapping = {get_el_sp(k): v for k, v in species_mapping.items()}
1✔
516
        sp_to_replace = set(sp_mapping)
1✔
517
        sp_in_structure = set(self.composition)
1✔
518
        if not sp_in_structure.issuperset(sp_to_replace):
1✔
519
            warnings.warn(
1✔
520
                "Some species to be substituted are not present in structure. Pls check your input. Species to be "
521
                f"substituted = {sp_to_replace}; Species in structure = {sp_in_structure}"
522
            )
523

524
        for site in self.sites:
1✔
525
            if sp_to_replace.intersection(site.species):
1✔
526
                c = Composition()
1✔
527
                for sp, amt in site.species.items():
1✔
528
                    new_sp = sp_mapping.get(sp, sp)
1✔
529
                    try:
1✔
530
                        c += Composition(new_sp) * amt
1✔
531
                    except Exception:
1✔
532
                        c += {new_sp: amt}
1✔
533
                site.species = c
1✔
534

535
    def add_oxidation_state_by_element(self, oxidation_states: dict[str, float]):
1✔
536
        """
537
        Add oxidation states.
538

539
        Args:
540
            oxidation_states (dict): Dict of oxidation states.
541
                E.g., {"Li":1, "Fe":2, "P":5, "O":-2}
542
        """
543
        try:
1✔
544
            for site in self.sites:
1✔
545
                new_sp = {}
1✔
546
                for el, occu in site.species.items():
1✔
547
                    sym = el.symbol
1✔
548
                    new_sp[Species(sym, oxidation_states[sym])] = occu
1✔
549
                site.species = Composition(new_sp)
1✔
550
        except KeyError:
1✔
551
            raise ValueError("Oxidation state of all elements must be specified in the dictionary.")
1✔
552

553
    def add_oxidation_state_by_site(self, oxidation_states: list[float]):
1✔
554
        """
555
        Add oxidation states to a structure by site.
556

557
        Args:
558
            oxidation_states (list): List of oxidation states.
559
                E.g., [1, 1, 1, 1, 2, 2, 2, 2, 5, 5, 5, 5, -2, -2, -2, -2]
560
        """
561
        if len(oxidation_states) != len(self.sites):
1✔
562
            raise ValueError("Oxidation states of all sites must be specified.")
1✔
563
        for site, ox in zip(self.sites, oxidation_states):
1✔
564
            new_sp = {}
1✔
565
            for el, occu in site.species.items():
1✔
566
                sym = el.symbol
1✔
567
                new_sp[Species(sym, ox)] = occu
1✔
568
            site.species = Composition(new_sp)
1✔
569

570
    def remove_oxidation_states(self):
1✔
571
        """
572
        Removes oxidation states from a structure.
573
        """
574
        for site in self.sites:
1✔
575
            new_sp = collections.defaultdict(float)
1✔
576
            for el, occu in site.species.items():
1✔
577
                sym = el.symbol
1✔
578
                new_sp[Element(sym)] += occu
1✔
579
            site.species = Composition(new_sp)
1✔
580

581
    def add_oxidation_state_by_guess(self, **kwargs):
1✔
582
        """
583
        Decorates the structure with oxidation state, guessing
584
        using Composition.oxi_state_guesses()
585

586
        Args:
587
            **kwargs: parameters to pass into oxi_state_guesses()
588
        """
589
        oxid_guess = self.composition.oxi_state_guesses(**kwargs)
1✔
590
        oxid_guess = oxid_guess or [{e.symbol: 0 for e in self.composition}]
1✔
591
        self.add_oxidation_state_by_element(oxid_guess[0])
1✔
592

593
    def add_spin_by_element(self, spins: dict[str, float]):
1✔
594
        """
595
        Add spin states to a structure.
596

597
        Args:
598
            spins (dict): Dict of spins associated with elements or species,
599
                e.g. {"Ni":+5} or {"Ni2+":5}
600
        """
601
        for site in self.sites:
1✔
602
            new_sp = {}
1✔
603
            for sp, occu in site.species.items():
1✔
604
                sym = sp.symbol
1✔
605
                oxi_state = getattr(sp, "oxi_state", None)
1✔
606
                new_sp[
1✔
607
                    Species(
608
                        sym,
609
                        oxidation_state=oxi_state,
610
                        properties={"spin": spins.get(str(sp), spins.get(sym, None))},
611
                    )
612
                ] = occu
613
            site.species = Composition(new_sp)
1✔
614

615
    def add_spin_by_site(self, spins: list[float]):
1✔
616
        """
617
        Add spin states to a structure by site.
618

619
        Args:
620
            spins (list): List of spins
621
                E.g., [+5, -5, 0, 0]
622
        """
623
        if len(spins) != len(self.sites):
1✔
624
            raise ValueError("Spin of all sites must be specified in the dictionary.")
×
625

626
        for site, spin in zip(self.sites, spins):
1✔
627
            new_sp = {}
1✔
628
            for sp, occu in site.species.items():
1✔
629
                sym = sp.symbol
1✔
630
                oxi_state = getattr(sp, "oxi_state", None)
1✔
631
                new_sp[Species(sym, oxidation_state=oxi_state, properties={"spin": spin})] = occu
1✔
632
            site.species = Composition(new_sp)
1✔
633

634
    def remove_spin(self):
1✔
635
        """
636
        Removes spin states from a structure.
637
        """
638
        for site in self.sites:
1✔
639
            new_sp = collections.defaultdict(float)
1✔
640
            for sp, occu in site.species.items():
1✔
641
                oxi_state = getattr(sp, "oxi_state", None)
1✔
642
                new_sp[Species(sp.symbol, oxidation_state=oxi_state)] += occu
1✔
643
            site.species = new_sp
1✔
644

645
    def extract_cluster(self, target_sites: list[Site], **kwargs):
1✔
646
        """
647
        Extracts a cluster of atoms based on bond lengths
648

649
        Args:
650
            target_sites ([Site]): List of initial sites to nucleate cluster.
651
            **kwargs: kwargs passed through to CovalentBond.is_bonded.
652

653
        Returns:
654
            [Site/PeriodicSite] Cluster of atoms.
655
        """
656
        cluster = list(target_sites)
1✔
657
        others = [site for site in self if site not in cluster]
1✔
658
        size = 0
1✔
659
        while len(cluster) > size:
1✔
660
            size = len(cluster)
1✔
661
            new_others = []
1✔
662
            for site in others:
1✔
663
                for site2 in cluster:
1✔
664
                    if CovalentBond.is_bonded(site, site2, **kwargs):
1✔
665
                        cluster.append(site)
1✔
666
                        break
1✔
667
                else:
668
                    new_others.append(site)
1✔
669
            others = new_others
1✔
670
        return cluster
1✔
671

672

673
class IStructure(SiteCollection, MSONable):
1✔
674
    """
675
    Basic immutable Structure object with periodicity. Essentially a sequence
676
    of PeriodicSites having a common lattice. IStructure is made to be
677
    (somewhat) immutable so that they can function as keys in a dict. To make
678
    modifications, use the standard Structure object instead. Structure
679
    extends Sequence and Hashable, which means that in many cases,
680
    it can be used like any Python sequence. Iterating through a
681
    structure is equivalent to going through the sites in sequence.
682
    """
683

684
    def __init__(
1✔
685
        self,
686
        lattice: ArrayLike | Lattice,
687
        species: Sequence[CompositionLike],
688
        coords: Sequence[ArrayLike],
689
        charge: float | None = None,
690
        validate_proximity: bool = False,
691
        to_unit_cell: bool = False,
692
        coords_are_cartesian: bool = False,
693
        site_properties: dict | None = None,
694
    ) -> None:
695
        """
696
        Create a periodic structure.
697

698
        Args:
699
            lattice (Lattice/3x3 array): The lattice, either as a
700
                :class:`pymatgen.core.lattice.Lattice` or
701
                simply as any 2D array. Each row should correspond to a lattice
702
                vector. E.g., [[10,0,0], [20,10,0], [0,0,30]] specifies a
703
                lattice with lattice vectors [10,0,0], [20,10,0] and [0,0,30].
704
            species ([Species]): Sequence of species on each site. Can take in
705
                flexible input, including:
706

707
                i.  A sequence of element / species specified either as string
708
                    symbols, e.g. ["Li", "Fe2+", "P", ...] or atomic numbers,
709
                    e.g., (3, 56, ...) or actual Element or Species objects.
710

711
                ii. List of dict of elements/species and occupancies, e.g.,
712
                    [{"Fe" : 0.5, "Mn":0.5}, ...]. This allows the setup of
713
                    disordered structures.
714
            coords (Nx3 array): list of fractional/Cartesian coordinates of
715
                each species.
716
            charge (int): overall charge of the structure. Defaults to behavior
717
                in SiteCollection where total charge is the sum of the oxidation
718
                states.
719
            validate_proximity (bool): Whether to check if there are sites
720
                that are less than 0.01 Ang apart. Defaults to False.
721
            to_unit_cell (bool): Whether to map all sites into the unit cell,
722
                i.e., fractional coords between 0 and 1. Defaults to False.
723
            coords_are_cartesian (bool): Set to True if you are providing
724
                coordinates in Cartesian coordinates. Defaults to False.
725
            site_properties (dict): Properties associated with the sites as a
726
                dict of sequences, e.g., {"magmom":[5,5,5,5]}. The sequences
727
                have to be the same length as the atomic species and
728
                fractional_coords. Defaults to None for no properties.
729
        """
730
        if len(species) != len(coords):
1✔
731
            raise StructureError(
1✔
732
                "The list of atomic species must be of the same length as the list of fractional coordinates."
733
            )
734

735
        if isinstance(lattice, Lattice):
1✔
736
            self._lattice = lattice
1✔
737
        else:
738
            self._lattice = Lattice(lattice)
1✔
739

740
        sites = []
1✔
741
        for i, sp in enumerate(species):
1✔
742
            prop = None
1✔
743
            if site_properties:
1✔
744
                prop = {k: v[i] for k, v in site_properties.items()}
1✔
745

746
            sites.append(
1✔
747
                PeriodicSite(
748
                    sp,
749
                    coords[i],
750
                    self._lattice,
751
                    to_unit_cell,
752
                    coords_are_cartesian=coords_are_cartesian,
753
                    properties=prop,  # type: ignore
754
                )
755
            )
756
        self._sites: tuple[PeriodicSite, ...] = tuple(sites)
1✔
757
        if validate_proximity and not self.is_valid():
1✔
758
            raise StructureError(("Structure contains sites that are ", "less than 0.01 Angstrom apart!"))
1✔
759
        self._charge = charge
1✔
760

761
    @classmethod
1✔
762
    def from_sites(
1✔
763
        cls,
764
        sites: list[PeriodicSite],
765
        charge: float | None = None,
766
        validate_proximity: bool = False,
767
        to_unit_cell: bool = False,
768
    ) -> IStructure:
769
        """
770
        Convenience constructor to make a Structure from a list of sites.
771

772
        Args:
773
            sites: Sequence of PeriodicSites. Sites must have the same
774
                lattice.
775
            charge: Charge of structure.
776
            validate_proximity (bool): Whether to check if there are sites
777
                that are less than 0.01 Ang apart. Defaults to False.
778
            to_unit_cell (bool): Whether to translate sites into the unit
779
                cell.
780

781
        Returns:
782
            (Structure) Note that missing properties are set as None.
783
        """
784
        if len(sites) < 1:
1✔
785
            raise ValueError(f"You need at least one site to construct a {cls}")
×
786
        prop_keys: list[str] = []
1✔
787
        props = {}
1✔
788
        lattice = sites[0].lattice
1✔
789
        for i, site in enumerate(sites):
1✔
790
            if site.lattice != lattice:
1✔
791
                raise ValueError("Sites must belong to the same lattice")
×
792
            for k, v in site.properties.items():
1✔
793
                if k not in prop_keys:
1✔
794
                    prop_keys.append(k)
1✔
795
                    props[k] = [None] * len(sites)
1✔
796
                props[k][i] = v
1✔
797
        for k, v in props.items():
1✔
798
            if any(vv is None for vv in v):
1✔
799
                warnings.warn(f"Not all sites have property {k}. Missing values are set to None.")
1✔
800
        return cls(
1✔
801
            lattice,
802
            [site.species for site in sites],
803
            [site.frac_coords for site in sites],
804
            charge=charge,
805
            site_properties=props,
806
            validate_proximity=validate_proximity,
807
            to_unit_cell=to_unit_cell,
808
        )
809

810
    @classmethod
1✔
811
    def from_spacegroup(
1✔
812
        cls,
813
        sg: str,
814
        lattice: list | np.ndarray | Lattice,
815
        species: Sequence[str | Element | Species | DummySpecies | Composition],
816
        coords: Sequence[Sequence[float]],
817
        site_properties: dict[str, Sequence] | None = None,
818
        coords_are_cartesian: bool = False,
819
        tol: float = 1e-5,
820
    ) -> IStructure | Structure:
821
        """
822
        Generate a structure using a spacegroup. Note that only symmetrically
823
        distinct species and coords should be provided. All equivalent sites
824
        are generated from the spacegroup operations.
825

826
        Args:
827
            sg (str/int): The spacegroup. If a string, it will be interpreted
828
                as one of the notations supported by
829
                pymatgen.symmetry.groups.Spacegroup. E.g., "R-3c" or "Fm-3m".
830
                If an int, it will be interpreted as an international number.
831
            lattice (Lattice/3x3 array): The lattice, either as a
832
                :class:`pymatgen.core.lattice.Lattice` or
833
                simply as any 2D array. Each row should correspond to a lattice
834
                vector. E.g., [[10,0,0], [20,10,0], [0,0,30]] specifies a
835
                lattice with lattice vectors [10,0,0], [20,10,0] and [0,0,30].
836
                Note that no attempt is made to check that the lattice is
837
                compatible with the spacegroup specified. This may be
838
                introduced in a future version.
839
            species ([Species]): Sequence of species on each site. Can take in
840
                flexible input, including:
841

842
                i.  A sequence of element / species specified either as string
843
                    symbols, e.g. ["Li", "Fe2+", "P", ...] or atomic numbers,
844
                    e.g., (3, 56, ...) or actual Element or Species objects.
845

846
                ii. List of dict of elements/species and occupancies, e.g.,
847
                    [{"Fe" : 0.5, "Mn":0.5}, ...]. This allows the setup of
848
                    disordered structures.
849
            coords (Nx3 array): list of fractional/cartesian coordinates of
850
                each species.
851
            coords_are_cartesian (bool): Set to True if you are providing
852
                coordinates in Cartesian coordinates. Defaults to False.
853
            site_properties (dict): Properties associated with the sites as a
854
                dict of sequences, e.g., {"magmom":[5,5,5,5]}. The sequences
855
                have to be the same length as the atomic species and
856
                fractional_coords. Defaults to None for no properties.
857
            tol (float): A fractional tolerance to deal with numerical
858
               precision issues in determining if orbits are the same.
859
        """
860
        from pymatgen.symmetry.groups import SpaceGroup
1✔
861

862
        try:
1✔
863
            i = int(sg)
1✔
864
            spg = SpaceGroup.from_int_number(i)
1✔
865
        except ValueError:
1✔
866
            spg = SpaceGroup(sg)
1✔
867

868
        if isinstance(lattice, Lattice):
1✔
869
            latt = lattice
1✔
870
        else:
871
            latt = Lattice(lattice)
1✔
872

873
        if not spg.is_compatible(latt):
1✔
874
            raise ValueError(
1✔
875
                f"Supplied lattice with parameters {latt.parameters} is incompatible with supplied spacegroup "
876
                f"{spg.symbol}!"
877
            )
878

879
        if len(species) != len(coords):
1✔
880
            raise ValueError(f"Supplied species and coords lengths ({len(species)} vs {len(coords)}) are different!")
1✔
881

882
        frac_coords = (
1✔
883
            np.array(coords, dtype=np.float_) if not coords_are_cartesian else latt.get_fractional_coords(coords)
884
        )
885

886
        props = {} if site_properties is None else site_properties
1✔
887

888
        all_sp: list[str | Element | Species | DummySpecies | Composition] = []
1✔
889
        all_coords: list[list[float]] = []
1✔
890
        all_site_properties: dict[str, list] = collections.defaultdict(list)
1✔
891
        for i, (sp, c) in enumerate(zip(species, frac_coords)):
1✔
892
            cc = spg.get_orbit(c, tol=tol)
1✔
893
            all_sp.extend([sp] * len(cc))
1✔
894
            all_coords.extend(cc)  # type: ignore
1✔
895
            for k, v in props.items():
1✔
896
                all_site_properties[k].extend([v[i]] * len(cc))
1✔
897

898
        return cls(latt, all_sp, all_coords, site_properties=all_site_properties)
1✔
899

900
    @classmethod
1✔
901
    def from_magnetic_spacegroup(
1✔
902
        cls,
903
        msg: str | MagneticSpaceGroup,
904
        lattice: list | np.ndarray | Lattice,
905
        species: Sequence[str | Element | Species | DummySpecies | Composition],
906
        coords: Sequence[Sequence[float]],
907
        site_properties: dict[str, Sequence],
908
        coords_are_cartesian: bool = False,
909
        tol: float = 1e-5,
910
    ) -> IStructure | Structure:
911
        """
912
        Generate a structure using a magnetic spacegroup. Note that only
913
        symmetrically distinct species, coords and magmoms should be provided.]
914
        All equivalent sites are generated from the spacegroup operations.
915

916
        Args:
917
            msg (str/list/:class:`pymatgen.symmetry.maggroups.MagneticSpaceGroup`):
918
                The magnetic spacegroup.
919
                If a string, it will be interpreted as one of the notations
920
                supported by MagneticSymmetryGroup, e.g., "R-3'c" or "Fm'-3'm".
921
                If a list of two ints, it will be interpreted as the number of
922
                the spacegroup in its Belov, Neronova and Smirnova (BNS) setting.
923
            lattice (Lattice/3x3 array): The lattice, either as a
924
                :class:`pymatgen.core.lattice.Lattice` or
925
                simply as any 2D array. Each row should correspond to a lattice
926
                vector. E.g., [[10,0,0], [20,10,0], [0,0,30]] specifies a
927
                lattice with lattice vectors [10,0,0], [20,10,0] and [0,0,30].
928
                Note that no attempt is made to check that the lattice is
929
                compatible with the spacegroup specified. This may be
930
                introduced in a future version.
931
            species ([Species]): Sequence of species on each site. Can take in
932
                flexible input, including:
933
                i.  A sequence of element / species specified either as string
934
                symbols, e.g. ["Li", "Fe2+", "P", ...] or atomic numbers,
935
                e.g., (3, 56, ...) or actual Element or Species objects.
936

937
                ii. List of dict of elements/species and occupancies, e.g.,
938
                    [{"Fe" : 0.5, "Mn":0.5}, ...]. This allows the setup of
939
                    disordered structures.
940
            coords (Nx3 array): list of fractional/cartesian coordinates of
941
                each species.
942
            site_properties (dict): Properties associated with the sites as a
943
                dict of sequences, e.g., {"magmom":[5,5,5,5]}. The sequences
944
                have to be the same length as the atomic species and
945
                fractional_coords. Unlike Structure.from_spacegroup(),
946
                this argument is mandatory, since magnetic moment information
947
                has to be included. Note that the *direction* of the supplied
948
                magnetic moment relative to the crystal is important, even if
949
                the resulting structure is used for collinear calculations.
950
            coords_are_cartesian (bool): Set to True if you are providing
951
                coordinates in Cartesian coordinates. Defaults to False.
952
            tol (float): A fractional tolerance to deal with numerical
953
                precision issues in determining if orbits are the same.
954

955
        Returns:
956
            Structure | IStructure
957
        """
958
        if "magmom" not in site_properties:
1✔
959
            raise ValueError("Magnetic moments have to be defined.")
×
960

961
        magmoms = [Magmom(m) for m in site_properties["magmom"]]
1✔
962

963
        if not isinstance(msg, MagneticSpaceGroup):
1✔
964
            msg = MagneticSpaceGroup(msg)
1✔
965

966
        if isinstance(lattice, Lattice):
1✔
967
            latt = lattice
1✔
968
        else:
969
            latt = Lattice(lattice)
×
970

971
        if not msg.is_compatible(latt):
1✔
972
            raise ValueError(
×
973
                f"Supplied lattice with parameters {latt.parameters} is incompatible with supplied spacegroup "
974
                f"{msg.sg_symbol}!"
975
            )
976

977
        if len(species) != len(coords):
1✔
978
            raise ValueError(f"Supplied species and coords lengths ({len(species)} vs {len(coords)}) are different!")
×
979

980
        if len(species) != len(magmoms):
1✔
981
            raise ValueError(f"Supplied species and magmom lengths ({len(species)} vs {len(magmoms)}) are different!")
×
982

983
        frac_coords = coords if not coords_are_cartesian else latt.get_fractional_coords(coords)
1✔
984

985
        all_sp: list[str | Element | Species | DummySpecies | Composition] = []
1✔
986
        all_coords: list[list[float]] = []
1✔
987
        all_magmoms: list[float] = []
1✔
988
        all_site_properties: dict[str, list] = collections.defaultdict(list)
1✔
989
        for i, (sp, c, m) in enumerate(zip(species, frac_coords, magmoms)):  # type: ignore
1✔
990
            cc, mm = msg.get_orbit(c, m, tol=tol)
1✔
991
            all_sp.extend([sp] * len(cc))
1✔
992
            all_coords.extend(cc)
1✔
993
            all_magmoms.extend(mm)
1✔
994
            for k, v in site_properties.items():
1✔
995
                if k != "magmom":
1✔
996
                    all_site_properties[k].extend([v[i]] * len(cc))
×
997

998
        all_site_properties["magmom"] = all_magmoms
1✔
999

1000
        return cls(latt, all_sp, all_coords, site_properties=all_site_properties)
1✔
1001

1002
    @property
1✔
1003
    def charge(self) -> float:
1✔
1004
        """
1005
        Overall charge of the structure
1006
        """
1007
        if self._charge is None:
1✔
1008
            return super().charge
1✔
1009
        return self._charge
1✔
1010

1011
    @property
1✔
1012
    def distance_matrix(self) -> np.ndarray:
1✔
1013
        """
1014
        Returns the distance matrix between all sites in the structure. For
1015
        periodic structures, this should return the nearest image distance.
1016
        """
1017
        return self.lattice.get_all_distances(self.frac_coords, self.frac_coords)
1✔
1018

1019
    @property
1✔
1020
    def sites(self) -> tuple[PeriodicSite, ...]:
1✔
1021
        """
1022
        Returns an iterator for the sites in the Structure.
1023
        """
1024
        return self._sites
1✔
1025

1026
    @property
1✔
1027
    def lattice(self) -> Lattice:
1✔
1028
        """
1029
        Lattice of the structure.
1030
        """
1031
        return self._lattice
1✔
1032

1033
    @property
1✔
1034
    def density(self) -> float:
1✔
1035
        """
1036
        Returns the density in units of g/cc
1037
        """
1038
        m = Mass(self.composition.weight, "amu")
1✔
1039
        return m.to("g") / (self.volume * Length(1, "ang").to("cm") ** 3)
1✔
1040

1041
    @property
1✔
1042
    def pbc(self) -> tuple[bool, bool, bool]:
1✔
1043
        """
1044
        Returns the periodicity of the structure.
1045
        """
1046
        return self._lattice.pbc
1✔
1047

1048
    @property
1✔
1049
    def is_3d_periodic(self) -> bool:
1✔
1050
        """True if the Lattice is periodic in all directions."""
1051
        return self._lattice.is_3d_periodic
1✔
1052

1053
    def get_space_group_info(self, symprec=1e-2, angle_tolerance=5.0) -> tuple[str, int]:
1✔
1054
        """
1055
        Convenience method to quickly get the spacegroup of a structure.
1056

1057
        Args:
1058
            symprec (float): Same definition as in SpacegroupAnalyzer.
1059
                Defaults to 1e-2.
1060
            angle_tolerance (float): Same definition as in SpacegroupAnalyzer.
1061
                Defaults to 5 degrees.
1062

1063
        Returns:
1064
            spacegroup_symbol, international_number
1065
        """
1066
        # Import within method needed to avoid cyclic dependency.
1067
        from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
1✔
1068

1069
        a = SpacegroupAnalyzer(self, symprec=symprec, angle_tolerance=angle_tolerance)
1✔
1070
        return a.get_space_group_symbol(), a.get_space_group_number()
1✔
1071

1072
    def matches(self, other: IStructure | Structure, anonymous: bool = False, **kwargs) -> bool:
1✔
1073
        """
1074
        Check whether this structure is similar to another structure.
1075
        Basically a convenience method to call structure matching.
1076

1077
        Args:
1078
            other (IStructure/Structure): Another structure.
1079
            **kwargs: Same **kwargs as in
1080
                :class:`pymatgen.analysis.structure_matcher.StructureMatcher`.
1081

1082
        Returns:
1083
            (bool) True is the structures are similar under some affine
1084
            transformation.
1085
        """
1086
        from pymatgen.analysis.structure_matcher import StructureMatcher
1✔
1087

1088
        m = StructureMatcher(**kwargs)
1✔
1089
        if not anonymous:
1✔
1090
            return m.fit(self, other)
1✔
1091
        return m.fit_anonymous(self, other)
×
1092

1093
    def __eq__(self, other: object) -> bool:
1✔
1094
        # check for valid operand following class Student example from official functools docs
1095
        # https://docs.python.org/3/library/functools.html#functools.total_ordering
1096
        if not isinstance(other, IStructure):
1✔
1097
            return NotImplemented
1✔
1098

1099
        if other is self:
1✔
1100
            return True
1✔
1101
        if len(self) != len(other):
1✔
1102
            return False
1✔
1103
        if self.lattice != other.lattice:
1✔
1104
            return False
1✔
1105
        for site in self:
1✔
1106
            if site not in other:
1✔
1107
                return False
1✔
1108
        return True
1✔
1109

1110
    def __hash__(self) -> int:
1✔
1111
        # For now, just use the composition hash code.
1112
        return hash(self.composition)
1✔
1113

1114
    def __mul__(self, scaling_matrix: int | Sequence[int] | Sequence[Sequence[int]]) -> Structure:
1✔
1115
        """
1116
        Makes a supercell. Allowing to have sites outside the unit cell
1117

1118
        Args:
1119
            scaling_matrix: A scaling matrix for transforming the lattice
1120
                vectors. Has to be all integers. Several options are possible:
1121

1122
                a. A full 3x3 scaling matrix defining the linear combination
1123
                   of the old lattice vectors. E.g., [[2,1,0],[0,3,0],[0,0,
1124
                   1]] generates a new structure with lattice vectors a' =
1125
                   2a + b, b' = 3b, c' = c where a, b, and c are the lattice
1126
                   vectors of the original structure.
1127
                b. A sequence of three scaling factors. E.g., [2, 1, 1]
1128
                   specifies that the supercell should have dimensions 2a x b x
1129
                   c.
1130
                c. A number, which simply scales all lattice vectors by the
1131
                   same factor.
1132

1133
        Returns:
1134
            Supercell structure. Note that a Structure is always returned,
1135
            even if the input structure is a subclass of Structure. This is
1136
            to avoid different arguments signatures from causing problems. If
1137
            you prefer a subclass to return its own type, you need to override
1138
            this method in the subclass.
1139
        """
1140
        scale_matrix = np.array(scaling_matrix, int)
1✔
1141
        if scale_matrix.shape != (3, 3):
1✔
1142
            scale_matrix = np.array(scale_matrix * np.eye(3), int)
1✔
1143
        new_lattice = Lattice(np.dot(scale_matrix, self._lattice.matrix))
1✔
1144

1145
        f_lat = lattice_points_in_supercell(scale_matrix)
1✔
1146
        c_lat = new_lattice.get_cartesian_coords(f_lat)
1✔
1147

1148
        new_sites = []
1✔
1149
        for site in self:
1✔
1150
            for v in c_lat:
1✔
1151
                s = PeriodicSite(
1✔
1152
                    site.species,
1153
                    site.coords + v,
1154
                    new_lattice,
1155
                    properties=site.properties,
1156
                    coords_are_cartesian=True,
1157
                    to_unit_cell=False,
1158
                    skip_checks=True,
1159
                )
1160
                new_sites.append(s)
1✔
1161

1162
        new_charge = self._charge * np.linalg.det(scale_matrix) if self._charge else None
1✔
1163
        return Structure.from_sites(new_sites, charge=new_charge, to_unit_cell=True)
1✔
1164

1165
    def __rmul__(self, scaling_matrix):
1✔
1166
        """
1167
        Similar to __mul__ to preserve commutativeness.
1168
        """
1169
        return self.__mul__(scaling_matrix)
1✔
1170

1171
    @property
1✔
1172
    def frac_coords(self):
1✔
1173
        """
1174
        Fractional coordinates as a Nx3 numpy array.
1175
        """
1176
        return np.array([site.frac_coords for site in self._sites])
1✔
1177

1178
    @property
1✔
1179
    def volume(self) -> float:
1✔
1180
        """
1181
        Returns the volume of the structure.
1182
        """
1183
        return self._lattice.volume
1✔
1184

1185
    def get_distance(self, i: int, j: int, jimage=None) -> float:
1✔
1186
        """
1187
        Get distance between site i and j assuming periodic boundary
1188
        conditions. If the index jimage of two sites atom j is not specified it
1189
        selects the jimage nearest to the i atom and returns the distance and
1190
        jimage indices in terms of lattice vector translations if the index
1191
        jimage of atom j is specified it returns the distance between the i
1192
        atom and the specified jimage atom.
1193

1194
        Args:
1195
            i (int): Index of first site
1196
            j (int): Index of second site
1197
            jimage: Number of lattice translations in each lattice direction.
1198
                Default is None for nearest image.
1199

1200
        Returns:
1201
            distance
1202
        """
1203
        return self[i].distance(self[j], jimage)
1✔
1204

1205
    def get_sites_in_sphere(
1✔
1206
        self,
1207
        pt: ArrayLike,
1208
        r: float,
1209
        include_index: bool = False,
1210
        include_image: bool = False,
1211
    ) -> list[PeriodicNeighbor]:
1212
        """
1213
        Find all sites within a sphere from the point, including a site (if any)
1214
        sitting on the point itself. This includes sites in other periodic
1215
        images.
1216

1217
        Algorithm:
1218

1219
        1. place sphere of radius r in crystal and determine minimum supercell
1220
           (parallelpiped) which would contain a sphere of radius r. for this
1221
           we need the projection of a_1 on a unit vector perpendicular
1222
           to a_2 & a_3 (i.e. the unit vector in the direction b_1) to
1223
           determine how many a_1"s it will take to contain the sphere.
1224

1225
           Nxmax = r * length_of_b_1 / (2 Pi)
1226

1227
        2. keep points falling within r.
1228

1229
        Args:
1230
            pt (3x1 array): Cartesian coordinates of center of sphere.
1231
            r (float): Radius of sphere.
1232
            include_index (bool): Whether the non-supercell site index
1233
                is included in the returned data
1234
            include_image (bool): Whether to include the supercell image
1235
                is included in the returned data
1236

1237
        Returns:
1238
            [:class:`pymatgen.core.structure.PeriodicNeighbor`]
1239
        """
1240
        site_fcoords = np.mod(self.frac_coords, 1)
1✔
1241
        neighbors: list[PeriodicNeighbor] = []
1✔
1242
        for fcoord, dist, i, img in self._lattice.get_points_in_sphere(site_fcoords, pt, r):
1✔
1243
            nnsite = PeriodicNeighbor(
1✔
1244
                self[i].species,
1245
                fcoord,
1246
                self._lattice,
1247
                properties=self[i].properties,
1248
                nn_distance=dist,
1249
                image=img,  # type: ignore
1250
                index=i,
1251
            )
1252
            neighbors.append(nnsite)
1✔
1253
        return neighbors
1✔
1254

1255
    def get_neighbors(
1✔
1256
        self,
1257
        site: PeriodicSite,
1258
        r: float,
1259
        include_index: bool = False,
1260
        include_image: bool = False,
1261
    ) -> list[PeriodicNeighbor]:
1262
        """
1263
        Get all neighbors to a site within a sphere of radius r. Excludes the
1264
        site itself.
1265

1266
        Args:
1267
            site (Site): Which is the center of the sphere.
1268
            r (float): Radius of sphere.
1269
            include_index (bool): Deprecated. Now, the non-supercell site index
1270
                is always included in the returned data.
1271
            include_image (bool): Deprecated. Now the supercell image
1272
                is always included in the returned data.
1273

1274
        Returns:
1275
            [:class:`pymatgen.core.structure.PeriodicNeighbor`]
1276
        """
1277
        return self.get_all_neighbors(r, include_index=include_index, include_image=include_image, sites=[site])[0]
1✔
1278

1279
    @deprecated(get_neighbors, "This is retained purely for checking purposes.")
1✔
1280
    def get_neighbors_old(self, site, r, include_index=False, include_image=False):
1✔
1281
        """
1282
        Get all neighbors to a site within a sphere of radius r. Excludes the
1283
        site itself.
1284

1285
        Args:
1286
            site (Site): Which is the center of the sphere.
1287
            r (float): Radius of sphere.
1288
            include_index (bool): Whether the non-supercell site index
1289
                is included in the returned data
1290
            include_image (bool): Whether to include the supercell image
1291
                is included in the returned data
1292

1293
        Returns:
1294
            [:class:`pymatgen.core.structure.PeriodicNeighbor`]
1295
        """
1296
        nn = self.get_sites_in_sphere(site.coords, r, include_index=include_index, include_image=include_image)
×
1297
        return [d for d in nn if site != d[0]]
×
1298

1299
    def _get_neighbor_list_py(
1✔
1300
        self,
1301
        r: float,
1302
        sites: list[PeriodicSite] | None = None,
1303
        numerical_tol: float = 1e-8,
1304
        exclude_self: bool = True,
1305
    ) -> tuple[np.ndarray, ...]:
1306
        """
1307
        A python version of getting neighbor_list. The returned values are a tuple of
1308
        numpy arrays (center_indices, points_indices, offset_vectors, distances).
1309
        Atom `center_indices[i]` has neighbor atom `points_indices[i]` that is
1310
        translated by `offset_vectors[i]` lattice vectors, and the distance is
1311
        `distances[i]`.
1312

1313
        Args:
1314
            r (float): Radius of sphere
1315
            sites (list of Sites or None): sites for getting all neighbors,
1316
                default is None, which means neighbors will be obtained for all
1317
                sites. This is useful in the situation where you are interested
1318
                only in one subspecies type, and makes it a lot faster.
1319
            numerical_tol (float): This is a numerical tolerance for distances.
1320
                Sites which are < numerical_tol are determined to be coincident
1321
                with the site. Sites which are r + numerical_tol away is deemed
1322
                to be within r from the site. The default of 1e-8 should be
1323
                ok in most instances.
1324
            exclude_self (bool): whether to exclude atom neighboring with itself within
1325
                numerical tolerance distance, default to True
1326
        Returns: (center_indices, points_indices, offset_vectors, distances)
1327
        """
1328
        neighbors = self.get_all_neighbors_py(
1✔
1329
            r=r, include_index=True, include_image=True, sites=sites, numerical_tol=1e-8
1330
        )
1331
        center_indices = []
1✔
1332
        points_indices = []
1✔
1333
        offsets = []
1✔
1334
        distances = []
1✔
1335
        for i, nns in enumerate(neighbors):
1✔
1336
            if len(nns) > 0:
1✔
1337
                for n in nns:
1✔
1338
                    if exclude_self and (i == n.index) and (n.nn_distance <= numerical_tol):
1✔
1339
                        continue
×
1340
                    center_indices.append(i)
1✔
1341
                    points_indices.append(n.index)
1✔
1342
                    offsets.append(n.image)
1✔
1343
                    distances.append(n.nn_distance)
1✔
1344
        return tuple(
1✔
1345
            (
1346
                np.array(center_indices),
1347
                np.array(points_indices),
1348
                np.array(offsets),
1349
                np.array(distances),
1350
            )
1351
        )
1352

1353
    def get_neighbor_list(
1✔
1354
        self,
1355
        r: float,
1356
        sites: Sequence[PeriodicSite] | None = None,
1357
        numerical_tol: float = 1e-8,
1358
        exclude_self: bool = True,
1359
    ) -> tuple[np.ndarray, ...]:
1360
        """
1361
        Get neighbor lists using numpy array representations without constructing
1362
        Neighbor objects. If the cython extension is installed, this method will
1363
        be orders of magnitude faster than `get_all_neighbors_old` and 2-3x faster
1364
        than `get_all_neighbors`.
1365
        The returned values are a tuple of numpy arrays
1366
        (center_indices, points_indices, offset_vectors, distances).
1367
        Atom `center_indices[i]` has neighbor atom `points_indices[i]` that is
1368
        translated by `offset_vectors[i]` lattice vectors, and the distance is
1369
        `distances[i]`.
1370

1371
        Args:
1372
            r (float): Radius of sphere
1373
            sites (list of Sites or None): sites for getting all neighbors,
1374
                default is None, which means neighbors will be obtained for all
1375
                sites. This is useful in the situation where you are interested
1376
                only in one subspecies type, and makes it a lot faster.
1377
            numerical_tol (float): This is a numerical tolerance for distances.
1378
                Sites which are < numerical_tol are determined to be coincident
1379
                with the site. Sites which are r + numerical_tol away is deemed
1380
                to be within r from the site. The default of 1e-8 should be
1381
                ok in most instances.
1382
            exclude_self (bool): whether to exclude atom neighboring with itself within
1383
                numerical tolerance distance, default to True
1384
        Returns: (center_indices, points_indices, offset_vectors, distances)
1385
        """
1386
        try:
1✔
1387
            from pymatgen.optimization.neighbors import find_points_in_spheres
1✔
1388
        except ImportError:
×
1389
            return self._get_neighbor_list_py(r, sites, exclude_self=exclude_self)  # type: ignore
×
1390
        else:
1391
            if sites is None:
1✔
1392
                sites = self.sites
1✔
1393
            site_coords = np.array([site.coords for site in sites], dtype=float)
1✔
1394
            cart_coords = np.ascontiguousarray(np.array(self.cart_coords), dtype=float)
1✔
1395
            lattice_matrix = np.ascontiguousarray(np.array(self.lattice.matrix), dtype=float)
1✔
1396
            r = float(r)
1✔
1397
            center_indices, points_indices, images, distances = find_points_in_spheres(
1✔
1398
                cart_coords,
1399
                site_coords,
1400
                r=r,
1401
                pbc=np.array(self.pbc, dtype=int),
1402
                lattice=lattice_matrix,
1403
                tol=numerical_tol,
1404
            )
1405
            cond = np.array([True] * len(center_indices))
1✔
1406
            if exclude_self:
1✔
1407
                self_pair = (center_indices == points_indices) & (distances <= numerical_tol)
1✔
1408
                cond = ~self_pair
1✔
1409
            return tuple(
1✔
1410
                (
1411
                    center_indices[cond],
1412
                    points_indices[cond],
1413
                    images[cond],
1414
                    distances[cond],
1415
                )
1416
            )
1417

1418
    def get_symmetric_neighbor_list(
1✔
1419
        self,
1420
        r: float,
1421
        sg: str,
1422
        unique: bool = False,
1423
        numerical_tol: float = 1e-8,
1424
        exclude_self: bool = True,
1425
    ) -> tuple[np.ndarray, ...]:
1426
        """
1427
        Similar to 'get_neighbor_list' with sites=None, but the neighbors are
1428
        grouped by symmetry. The returned values are a tuple of numpy arrays
1429
        (center_indices, points_indices, offset_vectors, distances,
1430
         symmetry_indices). Atom `center_indices[i]` has neighbor atom
1431
        `points_indices[i]` that is translated by `offset_vectors[i]` lattice
1432
        vectors, and the distance is `distances[i]`. Symmetry_idx groups the bonds
1433
        that are related by a symmetry of the provided space group and symmetry_op
1434
        is the operation that relates the first bond of the same symmetry_idx to
1435
        the respective atom. The first bond maps onto itself via the Identity. The
1436
        output is sorted w.r.t. to symmetry_indices. If unique is True only one of the
1437
        two bonds connecting two points is given. Out of the two, the bond that does not
1438
        reverse the sites is chosen.
1439

1440
        Args:
1441
            r (float): Radius of sphere
1442
            sg (str/int): The spacegroup the symmetry operations of which will be
1443
                used to classify the neighbors. If a string, it will be interpreted
1444
                as one of the notations supported by
1445
                pymatgen.symmetry.groups.Spacegroup. E.g., "R-3c" or "Fm-3m".
1446
                If an int, it will be interpreted as an international number.
1447
                If None, 'get_space_group_info' will be used to determine the
1448
                space group, default to None.
1449
            unique (bool): Whether a bond is given for both, or only a single
1450
                direction is given. The default is False.
1451
            numerical_tol (float): This is a numerical tolerance for distances.
1452
                Sites which are < numerical_tol are determined to be coincident
1453
                with the site. Sites which are r + numerical_tol away is deemed
1454
                to be within r from the site. The default of 1e-8 should be
1455
                ok in most instances.
1456
            exclude_self (bool): whether to exclude atom neighboring with itself within
1457
                numerical tolerance distance, default to True
1458
        Returns: (center_indices, points_indices, offset_vectors, distances,
1459
                  symmetry_indices, symmetry_ops)
1460
        """
1461
        from pymatgen.symmetry.groups import SpaceGroup
1✔
1462

1463
        if sg is None:
1✔
1464
            ops = SpaceGroup(self.get_space_group_info()[0]).symmetry_ops
×
1465
        else:
1466
            try:
1✔
1467
                i = int(sg)
1✔
1468
                sgp = SpaceGroup.from_int_number(i)
1✔
1469
            except ValueError:
×
1470
                sgp = SpaceGroup(sg)
×
1471
            ops = sgp.symmetry_ops
1✔
1472

1473
        latt = self.lattice
1✔
1474

1475
        if not sgp.is_compatible(latt):
1✔
1476
            raise ValueError(
×
1477
                f"Supplied lattice with parameters {latt.parameters} is incompatible with "
1478
                f"supplied spacegroup {sgp.symbol}!"
1479
            )
1480

1481
        # get a list of neighbors up to distance r
1482
        bonds = self.get_neighbor_list(r)
1✔
1483

1484
        if unique:
1✔
1485
            redundant = []
1✔
1486
            # compare all neighbors pairwise to find the pairs that connect the same
1487
            # two sites, but with an inverted vector (R=-R) that connects the two and add
1488
            # one of each pair to the redundant list.
1489
            for it, (i, j, R, d) in enumerate(zip(*bonds)):
1✔
1490
                if it in redundant:
1✔
1491
                    pass
1✔
1492
                else:
1493
                    for it2, (i2, j2, R2, d2) in enumerate(zip(*bonds)):
1✔
1494
                        bool1 = i == j2
1✔
1495
                        bool2 = j == i2
1✔
1496
                        bool3 = (R == -R2).all()
1✔
1497
                        bool4 = np.isclose(d, d2, atol=numerical_tol)
1✔
1498
                        if bool1 and bool2 and bool3 and bool4:
1✔
1499
                            redundant.append(it2)
1✔
1500

1501
            # delete the redundant neighbors
1502
            m = ~np.in1d(np.arange(len(bonds[0])), redundant)
1✔
1503
            idcs_dist = np.argsort(bonds[3][m])
1✔
1504
            bonds = (bonds[0][m][idcs_dist], bonds[1][m][idcs_dist], bonds[2][m][idcs_dist], bonds[3][m][idcs_dist])
1✔
1505

1506
        # expand the output tuple by symmetry_indices and symmetry_ops.
1507
        nbonds = len(bonds[0])
1✔
1508
        symmetry_indices = np.empty(nbonds)
1✔
1509
        symmetry_indices[:] = np.NaN
1✔
1510
        symmetry_ops = np.empty(len(symmetry_indices), dtype=object)
1✔
1511
        symmetry_identity = SymmOp.from_rotation_and_translation(np.eye(3), np.zeros(3))
1✔
1512
        symmetry_index = 0
1✔
1513

1514
        # Again, compare all neighbors pairwise. For each pair of neighbors, all the symmetry operations of the provided
1515
        # space group are iterated over. If an operation is found that connects the two bonds, it is assigned the same
1516
        # symmetry index it is compared to, and the symmetry operation that connets the two is saved. To compare two
1517
        # neighbors 'SymmOp.are_symmetrically_related_vectors' is used. It is also checked whether applying the
1518
        # connecting symmetry operation generates the neighbor-pair itself, or the equivalent version with the
1519
        # sites exchanged and R reversed. The output is always reordered such that the former case is true.
1520
        for it in range(nbonds):
1✔
1521
            if np.isnan(symmetry_indices[it]):
1✔
1522
                symmetry_indices[it] = symmetry_index
1✔
1523
                symmetry_ops[it] = symmetry_identity
1✔
1524
                for it2 in np.arange(nbonds)[np.isnan(symmetry_indices)]:
1✔
1525
                    equal_distance = np.isclose(bonds[3][it], bonds[3][it2], atol=numerical_tol)
1✔
1526
                    if equal_distance:
1✔
1527
                        from_a = self[bonds[0][it]].frac_coords
1✔
1528
                        to_a = self[bonds[1][it]].frac_coords
1✔
1529
                        r_a = bonds[2][it]
1✔
1530
                        from_b = self[bonds[0][it2]].frac_coords
1✔
1531
                        to_b = self[bonds[1][it2]].frac_coords
1✔
1532
                        r_b = bonds[2][it2]
1✔
1533
                        for op in ops:
1✔
1534
                            are_related, is_reversed = op.are_symmetrically_related_vectors(
1✔
1535
                                from_a, to_a, r_a, from_b, to_b, r_b
1536
                            )
1537
                            if are_related and not is_reversed:
1✔
1538
                                symmetry_indices[it2] = symmetry_index
1✔
1539
                                symmetry_ops[it2] = op
1✔
1540
                            elif are_related and is_reversed:
1✔
1541
                                symmetry_indices[it2] = symmetry_index
1✔
1542
                                symmetry_ops[it2] = op
1✔
1543
                                bonds[0][it2], bonds[1][it2] = bonds[1][it2], bonds[0][it2]
1✔
1544
                                bonds[2][it2] = -bonds[2][it2]
1✔
1545

1546
                symmetry_index += 1
1✔
1547

1548
        # the bonds are ordered by their symmetry index
1549
        idcs_symid = np.argsort(symmetry_indices)
1✔
1550
        bonds = (
1✔
1551
            bonds[0][idcs_symid],
1552
            bonds[1][idcs_symid],
1553
            bonds[2][idcs_symid],
1554
            bonds[3][idcs_symid],
1555
        )
1556
        symmetry_indices = symmetry_indices[idcs_symid]
1✔
1557
        symmetry_ops = symmetry_ops[idcs_symid]
1✔
1558

1559
        # the groups of neighbors with the same symmetry index are ordered such that neighbors
1560
        # that are the first occurrence of a new symmetry index in the ordered output are the ones
1561
        # that are assigned the Identity as a symmetry operation.
1562
        idcs_symop = np.arange(nbonds)
1✔
1563
        identity_idcs = np.where(symmetry_ops == symmetry_identity)[0]
1✔
1564
        for symmetry_idx in np.unique(symmetry_indices):
1✔
1565
            first_idx = np.argmax(symmetry_indices == symmetry_idx)
1✔
1566
            for second_idx in identity_idcs:
1✔
1567
                if symmetry_indices[second_idx] == symmetry_idx:
1✔
1568
                    idcs_symop[first_idx], idcs_symop[second_idx] = idcs_symop[second_idx], idcs_symop[first_idx]
1✔
1569

1570
        return (
1✔
1571
            bonds[0][idcs_symop],
1572
            bonds[1][idcs_symop],
1573
            bonds[2][idcs_symop],
1574
            bonds[3][idcs_symop],
1575
            symmetry_indices[idcs_symop],
1576
            symmetry_ops[idcs_symop],
1577
        )
1578

1579
    def get_all_neighbors(
1✔
1580
        self,
1581
        r: float,
1582
        include_index: bool = False,
1583
        include_image: bool = False,
1584
        sites: Sequence[PeriodicSite] | None = None,
1585
        numerical_tol: float = 1e-8,
1586
    ) -> list[list[PeriodicNeighbor]]:
1587
        """
1588
        Get neighbors for each atom in the unit cell, out to a distance r
1589
        Returns a list of list of neighbors for each site in structure.
1590
        Use this method if you are planning on looping over all sites in the
1591
        crystal. If you only want neighbors for a particular site, use the
1592
        method get_neighbors as it may not have to build such a large supercell
1593
        However if you are looping over all sites in the crystal, this method
1594
        is more efficient since it only performs one pass over a large enough
1595
        supercell to contain all possible atoms out to a distance r.
1596
        The return type is a [(site, dist) ...] since most of the time,
1597
        subsequent processing requires the distance.
1598

1599
        A note about periodic images: Before computing the neighbors, this
1600
        operation translates all atoms to within the unit cell (having
1601
        fractional coordinates within [0,1)). This means that the "image" of a
1602
        site does not correspond to how much it has been translates from its
1603
        current position, but which image of the unit cell it resides.
1604

1605
        Args:
1606
            r (float): Radius of sphere.
1607
            include_index (bool): Deprecated. Now, the non-supercell site index
1608
                is always included in the returned data.
1609
            include_image (bool): Deprecated. Now the supercell image
1610
                is always included in the returned data.
1611
            sites (list of Sites or None): sites for getting all neighbors,
1612
                default is None, which means neighbors will be obtained for all
1613
                sites. This is useful in the situation where you are interested
1614
                only in one subspecies type, and makes it a lot faster.
1615
            numerical_tol (float): This is a numerical tolerance for distances.
1616
                Sites which are < numerical_tol are determined to be coincident
1617
                with the site. Sites which are r + numerical_tol away is deemed
1618
                to be within r from the site. The default of 1e-8 should be
1619
                ok in most instances.
1620

1621
        Returns:
1622
            [[:class:`pymatgen.core.structure.PeriodicNeighbor`], ..]
1623
        """
1624
        if sites is None:
1✔
1625
            sites = self.sites
1✔
1626
        center_indices, points_indices, images, distances = self.get_neighbor_list(
1✔
1627
            r=r, sites=sites, numerical_tol=numerical_tol
1628
        )
1629
        if len(points_indices) < 1:
1✔
1630
            return [[]] * len(sites)
1✔
1631
        f_coords = self.frac_coords[points_indices] + images
1✔
1632
        neighbor_dict: dict[int, list] = collections.defaultdict(list)
1✔
1633
        lattice = self.lattice
1✔
1634
        atol = Site.position_atol
1✔
1635
        all_sites = self.sites
1✔
1636
        for cindex, pindex, image, f_coord, d in zip(center_indices, points_indices, images, f_coords, distances):
1✔
1637
            psite = all_sites[pindex]
1✔
1638
            csite = sites[cindex]
1✔
1639
            if (
1✔
1640
                d > numerical_tol
1641
                or
1642
                # This simply compares the psite and csite. The reason why manual comparison is done is
1643
                # for speed. This does not check the lattice since they are always equal. Also, the or construct
1644
                # returns True immediately once one of the conditions are satisfied.
1645
                psite.species != csite.species
1646
                or (not np.allclose(psite.coords, csite.coords, atol=atol))
1647
                or (not psite.properties == csite.properties)
1648
            ):
1649
                neighbor_dict[cindex].append(
1✔
1650
                    PeriodicNeighbor(
1651
                        species=psite.species,
1652
                        coords=f_coord,
1653
                        lattice=lattice,
1654
                        properties=psite.properties,
1655
                        nn_distance=d,
1656
                        index=pindex,
1657
                        image=tuple(image),
1658
                    )
1659
                )
1660

1661
        neighbors: list[list[PeriodicNeighbor]] = []
1✔
1662

1663
        for i in range(len(sites)):
1✔
1664
            neighbors.append(neighbor_dict[i])
1✔
1665
        return neighbors
1✔
1666

1667
    def get_all_neighbors_py(
1✔
1668
        self,
1669
        r: float,
1670
        include_index: bool = False,
1671
        include_image: bool = False,
1672
        sites: Sequence[PeriodicSite] | None = None,
1673
        numerical_tol: float = 1e-8,
1674
    ) -> list[list[PeriodicNeighbor]]:
1675
        """
1676
        Get neighbors for each atom in the unit cell, out to a distance r
1677
        Returns a list of list of neighbors for each site in structure.
1678
        Use this method if you are planning on looping over all sites in the
1679
        crystal. If you only want neighbors for a particular site, use the
1680
        method get_neighbors as it may not have to build such a large supercell
1681
        However if you are looping over all sites in the crystal, this method
1682
        is more efficient since it only performs one pass over a large enough
1683
        supercell to contain all possible atoms out to a distance r.
1684
        The return type is a [(site, dist) ...] since most of the time,
1685
        subsequent processing requires the distance.
1686

1687
        A note about periodic images: Before computing the neighbors, this
1688
        operation translates all atoms to within the unit cell (having
1689
        fractional coordinates within [0,1)). This means that the "image" of a
1690
        site does not correspond to how much it has been translates from its
1691
        current position, but which image of the unit cell it resides.
1692

1693
        Args:
1694
            r (float): Radius of sphere.
1695
            include_index (bool): Deprecated. Now, the non-supercell site index
1696
                is always included in the returned data.
1697
            include_image (bool): Deprecated. Now the supercell image
1698
                is always included in the returned data.
1699
            sites (list of Sites or None): sites for getting all neighbors,
1700
                default is None, which means neighbors will be obtained for all
1701
                sites. This is useful in the situation where you are interested
1702
                only in one subspecies type, and makes it a lot faster.
1703
            numerical_tol (float): This is a numerical tolerance for distances.
1704
                Sites which are < numerical_tol are determined to be coincident
1705
                with the site. Sites which are r + numerical_tol away is deemed
1706
                to be within r from the site. The default of 1e-8 should be
1707
                ok in most instances.
1708

1709
        Returns:
1710
            list[list[PeriodicNeighbor]]
1711
        """
1712
        if sites is None:
1✔
1713
            sites = self.sites
1✔
1714
        site_coords = np.array([site.coords for site in sites])
1✔
1715
        point_neighbors = get_points_in_spheres(
1✔
1716
            self.cart_coords,
1717
            site_coords,
1718
            r=r,
1719
            pbc=self.pbc,
1720
            numerical_tol=numerical_tol,
1721
            lattice=self.lattice,
1722
        )
1723
        neighbors: list[list[PeriodicNeighbor]] = []
1✔
1724
        for point_neighbor, site in zip(point_neighbors, sites):
1✔
1725
            nns: list[PeriodicNeighbor] = []
1✔
1726
            if len(point_neighbor) < 1:
1✔
1727
                neighbors.append([])
×
1728
                continue
×
1729
            for n in point_neighbor:
1✔
1730
                coord, d, index, image = n
1✔
1731
                if (d > numerical_tol) or (self[index] != site):
1✔
1732
                    neighbor = PeriodicNeighbor(
1✔
1733
                        species=self[index].species,
1734
                        coords=coord,
1735
                        lattice=self.lattice,
1736
                        properties=self[index].properties,
1737
                        nn_distance=d,
1738
                        index=index,
1739
                        image=tuple(image),
1740
                    )
1741
                    nns.append(neighbor)
1✔
1742
            neighbors.append(nns)
1✔
1743
        return neighbors
1✔
1744

1745
    @deprecated(get_all_neighbors, "This is retained purely for checking purposes.")
1✔
1746
    def get_all_neighbors_old(self, r, include_index=False, include_image=False, include_site=True):
1✔
1747
        """
1748
        Get neighbors for each atom in the unit cell, out to a distance r
1749
        Returns a list of list of neighbors for each site in structure.
1750
        Use this method if you are planning on looping over all sites in the
1751
        crystal. If you only want neighbors for a particular site, use the
1752
        method get_neighbors as it may not have to build such a large supercell
1753
        However if you are looping over all sites in the crystal, this method
1754
        is more efficient since it only performs one pass over a large enough
1755
        supercell to contain all possible atoms out to a distance r.
1756
        The return type is a [(site, dist) ...] since most of the time,
1757
        subsequent processing requires the distance.
1758

1759
        A note about periodic images: Before computing the neighbors, this
1760
        operation translates all atoms to within the unit cell (having
1761
        fractional coordinates within [0,1)). This means that the "image" of a
1762
        site does not correspond to how much it has been translates from its
1763
        current position, but which image of the unit cell it resides.
1764

1765
        Args:
1766
            r (float): Radius of sphere.
1767
            include_index (bool): Whether to include the non-supercell site
1768
                in the returned data
1769
            include_image (bool): Whether to include the supercell image
1770
                in the returned data
1771
            include_site (bool): Whether to include the site in the returned
1772
                data. Defaults to True.
1773

1774
        Returns:
1775
            [:class:`pymatgen.core.structure.PeriodicNeighbor`]
1776
        """
1777
        # Use same algorithm as get_sites_in_sphere to determine supercell but
1778
        # loop over all atoms in crystal
1779
        recp_len = np.array(self.lattice.reciprocal_lattice.abc)
1✔
1780
        maxr = np.ceil((r + 0.15) * recp_len / (2 * math.pi))
1✔
1781
        nmin = np.floor(np.min(self.frac_coords, axis=0)) - maxr
1✔
1782
        nmax = np.ceil(np.max(self.frac_coords, axis=0)) + maxr
1✔
1783

1784
        all_ranges = [np.arange(x, y) for x, y in zip(nmin, nmax)]
1✔
1785
        latt = self._lattice
1✔
1786
        matrix = latt.matrix
1✔
1787
        neighbors = [[] for _ in range(len(self._sites))]
1✔
1788
        all_fcoords = np.mod(self.frac_coords, 1)
1✔
1789
        coords_in_cell = np.dot(all_fcoords, matrix)
1✔
1790
        site_coords = self.cart_coords
1✔
1791

1792
        indices = np.arange(len(self))
1✔
1793

1794
        for image in itertools.product(*all_ranges):
1✔
1795
            coords = np.dot(image, matrix) + coords_in_cell
1✔
1796
            all_dists = all_distances(coords, site_coords)
1✔
1797
            all_within_r = np.bitwise_and(all_dists <= r, all_dists > 1e-8)
1✔
1798

1799
            for j, d, within_r in zip(indices, all_dists, all_within_r):
1✔
1800
                if include_site:
1✔
1801
                    nnsite = PeriodicSite(
1✔
1802
                        self[j].species,
1803
                        coords[j],
1804
                        latt,
1805
                        properties=self[j].properties,
1806
                        coords_are_cartesian=True,
1807
                        skip_checks=True,
1808
                    )
1809

1810
                for i in indices[within_r]:
1✔
1811
                    item = []
1✔
1812
                    if include_site:
1✔
1813
                        item.append(nnsite)
1✔
1814
                    item.append(d[i])
1✔
1815
                    if include_index:
1✔
1816
                        item.append(j)
1✔
1817
                    # Add the image, if requested
1818
                    if include_image:
1✔
1819
                        item.append(image)
1✔
1820
                    neighbors[i].append(item)
1✔
1821
        return neighbors
1✔
1822

1823
    def get_neighbors_in_shell(
1✔
1824
        self, origin: ArrayLike, r: float, dr: float, include_index: bool = False, include_image: bool = False
1825
    ) -> list[PeriodicNeighbor]:
1826
        """
1827
        Returns all sites in a shell centered on origin (coords) between radii
1828
        r-dr and r+dr.
1829

1830
        Args:
1831
            origin (3x1 array): Cartesian coordinates of center of sphere.
1832
            r (float): Inner radius of shell.
1833
            dr (float): Width of shell.
1834
            include_index (bool): Deprecated. Now, the non-supercell site index
1835
                is always included in the returned data.
1836
            include_image (bool): Deprecated. Now the supercell image
1837
                is always included in the returned data.
1838

1839
        Returns:
1840
            [NearestNeighbor] where Nearest Neighbor is a named tuple containing
1841
            (site, distance, index, image).
1842
        """
1843
        outer = self.get_sites_in_sphere(origin, r + dr, include_index=include_index, include_image=include_image)
1✔
1844
        inner = r - dr
1✔
1845
        return [t for t in outer if t.nn_distance > inner]
1✔
1846

1847
    def get_sorted_structure(self, key: Callable | None = None, reverse: bool = False) -> IStructure | Structure:
1✔
1848
        """
1849
        Get a sorted copy of the structure. The parameters have the same
1850
        meaning as in list.sort. By default, sites are sorted by the
1851
        electronegativity of the species.
1852

1853
        Args:
1854
            key: Specifies a function of one argument that is used to extract
1855
                a comparison key from each list element: key=str.lower. The
1856
                default value is None (compare the elements directly).
1857
            reverse (bool): If set to True, then the list elements are sorted
1858
                as if each comparison were reversed.
1859
        """
1860
        sites = sorted(self, key=key, reverse=reverse)
1✔
1861
        return type(self).from_sites(sites, charge=self._charge)
1✔
1862

1863
    def get_reduced_structure(self, reduction_algo: Literal["niggli", "LLL"] = "niggli") -> IStructure | Structure:
1✔
1864
        """
1865
        Get a reduced structure.
1866

1867
        Args:
1868
            reduction_algo ("niggli" | "LLL"): The lattice reduction algorithm to use.
1869
                Defaults to "niggli".
1870
        """
1871
        if reduction_algo == "niggli":
1✔
1872
            reduced_latt = self._lattice.get_niggli_reduced_lattice()
1✔
1873
        elif reduction_algo == "LLL":
1✔
1874
            reduced_latt = self._lattice.get_lll_reduced_lattice()
1✔
1875
        else:
1876
            raise ValueError(f"Invalid reduction algo : {reduction_algo}")
×
1877

1878
        if reduced_latt != self.lattice:
1✔
1879
            return self.__class__(
1✔
1880
                reduced_latt,
1881
                self.species_and_occu,
1882
                self.cart_coords,  # type: ignore
1883
                coords_are_cartesian=True,
1884
                to_unit_cell=True,
1885
                site_properties=self.site_properties,
1886
                charge=self._charge,
1887
            )
1888
        return self.copy()
1✔
1889

1890
    def copy(self, site_properties=None, sanitize=False):
1✔
1891
        """
1892
        Convenience method to get a copy of the structure, with options to add
1893
        site properties.
1894

1895
        Args:
1896
            site_properties (dict): Properties to add or override. The
1897
                properties are specified in the same way as the constructor,
1898
                i.e., as a dict of the form {property: [values]}. The
1899
                properties should be in the order of the *original* structure
1900
                if you are performing sanitization.
1901
            sanitize (bool): If True, this method will return a sanitized
1902
                structure. Sanitization performs a few things: (i) The sites are
1903
                sorted by electronegativity, (ii) a LLL lattice reduction is
1904
                carried out to obtain a relatively orthogonalized cell,
1905
                (iii) all fractional coords for sites are mapped into the
1906
                unit cell.
1907

1908
        Returns:
1909
            A copy of the Structure, with optionally new site_properties and
1910
            optionally sanitized.
1911
        """
1912
        props = self.site_properties
1✔
1913
        if site_properties:
1✔
1914
            props.update(site_properties)
1✔
1915
        if not sanitize:
1✔
1916
            return self.__class__(
1✔
1917
                self._lattice,
1918
                self.species_and_occu,
1919
                self.frac_coords,
1920
                charge=self._charge,
1921
                site_properties=props,
1922
            )
1923
        reduced_latt = self._lattice.get_lll_reduced_lattice()
1✔
1924
        new_sites = []
1✔
1925
        for idx, site in enumerate(self):
1✔
1926
            frac_coords = reduced_latt.get_fractional_coords(site.coords)
1✔
1927
            site_props = {}
1✔
1928
            for prop, val in props.items():
1✔
1929
                site_props[prop] = val[idx]
1✔
1930
            new_sites.append(
1✔
1931
                PeriodicSite(
1932
                    site.species,
1933
                    frac_coords,
1934
                    reduced_latt,
1935
                    to_unit_cell=True,
1936
                    properties=site_props,
1937
                    skip_checks=True,
1938
                )
1939
            )
1940
        new_sites = sorted(new_sites)
1✔
1941
        return type(self).from_sites(new_sites, charge=self._charge)
1✔
1942

1943
    def interpolate(
1✔
1944
        self,
1945
        end_structure: IStructure | Structure,
1946
        nimages: int | Iterable = 10,
1947
        interpolate_lattices: bool = False,
1948
        pbc: bool = True,
1949
        autosort_tol: float = 0,
1950
    ) -> list[IStructure | Structure]:
1951
        """
1952
        Interpolate between this structure and end_structure. Useful for
1953
        construction of NEB inputs.
1954

1955
        Args:
1956
            end_structure (Structure): structure to interpolate between this
1957
                structure and end.
1958
            nimages (int,list): No. of interpolation images or a list of
1959
                interpolation images. Defaults to 10 images.
1960
            interpolate_lattices (bool): Whether to interpolate the lattices.
1961
                Interpolates the lengths and angles (rather than the matrix)
1962
                so orientation may be affected.
1963
            pbc (bool): Whether to use periodic boundary conditions to find
1964
                the shortest path between endpoints.
1965
            autosort_tol (float): A distance tolerance in angstrom in
1966
                which to automatically sort end_structure to match to the
1967
                closest points in this particular structure. This is usually
1968
                what you want in a NEB calculation. 0 implies no sorting.
1969
                Otherwise, a 0.5 value usually works pretty well.
1970

1971
        Returns:
1972
            List of interpolated structures. The starting and ending
1973
            structures included as the first and last structures respectively.
1974
            A total of (nimages + 1) structures are returned.
1975
        """
1976
        # Check length of structures
1977
        if len(self) != len(end_structure):
1✔
1978
            raise ValueError("Structures have different lengths!")
×
1979

1980
        if not (interpolate_lattices or self.lattice == end_structure.lattice):
1✔
1981
            raise ValueError("Structures with different lattices!")
1✔
1982

1983
        if not isinstance(nimages, collections.abc.Iterable):
1✔
1984
            images = np.arange(nimages + 1) / nimages
1✔
1985
        else:
1986
            images = nimages  # type: ignore
1✔
1987

1988
        # Check that both structures have the same species
1989
        for i, site in enumerate(self):
1✔
1990
            if site.species != end_structure[i].species:
1✔
1991
                raise ValueError(
1✔
1992
                    "Different species!\nStructure 1:\n" + str(self) + "\nStructure 2\n" + str(end_structure)
1993
                )
1994

1995
        start_coords = np.array(self.frac_coords)
1✔
1996
        end_coords = np.array(end_structure.frac_coords)
1✔
1997

1998
        if autosort_tol:
1✔
1999
            dist_matrix = self.lattice.get_all_distances(start_coords, end_coords)
1✔
2000
            site_mappings: dict[int, list[int]] = collections.defaultdict(list)
1✔
2001
            unmapped_start_ind = []
1✔
2002
            for i, row in enumerate(dist_matrix):
1✔
2003
                ind = np.where(row < autosort_tol)[0]
1✔
2004
                if len(ind) == 1:
1✔
2005
                    site_mappings[i].append(ind[0])
1✔
2006
                else:
2007
                    unmapped_start_ind.append(i)
1✔
2008

2009
            if len(unmapped_start_ind) > 1:
1✔
2010
                raise ValueError(f"Unable to reliably match structures with {autosort_tol = }, {unmapped_start_ind = }")
×
2011

2012
            sorted_end_coords = np.zeros_like(end_coords)
1✔
2013
            matched = []
1✔
2014
            for i, j in site_mappings.items():
1✔
2015
                if len(j) > 1:
1✔
2016
                    raise ValueError(
×
2017
                        f"Unable to reliably match structures with auto_sort_tol = {autosort_tol}. "
2018
                        "More than one site match!"
2019
                    )
2020
                sorted_end_coords[i] = end_coords[j[0]]
1✔
2021
                matched.append(j[0])
1✔
2022

2023
            if len(unmapped_start_ind) == 1:
1✔
2024
                i = unmapped_start_ind[0]
1✔
2025
                j = list(set(range(len(start_coords))) - set(matched))[0]  # type: ignore
1✔
2026
                sorted_end_coords[i] = end_coords[j]
1✔
2027

2028
            end_coords = sorted_end_coords
1✔
2029

2030
        vec = end_coords - start_coords
1✔
2031
        if pbc:
1✔
2032
            vec[:, self.pbc] -= np.round(vec[:, self.pbc])
1✔
2033
        sp = self.species_and_occu
1✔
2034
        structs = []
1✔
2035

2036
        if interpolate_lattices:
1✔
2037
            # interpolate lattice matrices using polar decomposition
2038
            from scipy.linalg import polar
1✔
2039

2040
            # u is a unitary rotation, p is stretch
2041
            u, p = polar(np.dot(end_structure.lattice.matrix.T, np.linalg.inv(self.lattice.matrix.T)))
1✔
2042
            lvec = p - np.identity(3)
1✔
2043
            lstart = self.lattice.matrix.T
1✔
2044

2045
        for x in images:
1✔
2046
            if interpolate_lattices:
1✔
2047
                l_a = np.dot(np.identity(3) + x * lvec, lstart).T
1✔
2048
                lat = Lattice(l_a)
1✔
2049
            else:
2050
                lat = self.lattice
1✔
2051
            fcoords = start_coords + x * vec
1✔
2052
            structs.append(self.__class__(lat, sp, fcoords, site_properties=self.site_properties))
1✔
2053
        return structs
1✔
2054

2055
    def get_miller_index_from_site_indexes(self, site_ids, round_dp=4, verbose=True):
1✔
2056
        """
2057
        Get the Miller index of a plane from a set of sites indexes.
2058

2059
        A minimum of 3 sites are required. If more than 3 sites are given
2060
        the best plane that minimises the distance to all points will be
2061
        calculated.
2062

2063
        Args:
2064
            site_ids (list of int): A list of site indexes to consider. A
2065
                minimum of three site indexes are required. If more than three
2066
                sites are provided, the best plane that minimises the distance
2067
                to all sites will be calculated.
2068
            round_dp (int, optional): The number of decimal places to round the
2069
                miller index to.
2070
            verbose (bool, optional): Whether to print warnings.
2071

2072
        Returns:
2073
            (tuple): The Miller index.
2074
        """
2075
        return self.lattice.get_miller_index_from_coords(
1✔
2076
            self.frac_coords[site_ids],
2077
            coords_are_cartesian=False,
2078
            round_dp=round_dp,
2079
            verbose=verbose,
2080
        )
2081

2082
    def get_primitive_structure(
1✔
2083
        self, tolerance: float = 0.25, use_site_props: bool = False, constrain_latt: list | dict | None = None
2084
    ):
2085
        """
2086
        This finds a smaller unit cell than the input. Sometimes it doesn"t
2087
        find the smallest possible one, so this method is recursively called
2088
        until it is unable to find a smaller cell.
2089

2090
        NOTE: if the tolerance is greater than 1/2 the minimum inter-site
2091
        distance in the primitive cell, the algorithm will reject this lattice.
2092

2093
        Args:
2094
            tolerance (float), Angstroms: Tolerance for each coordinate of a
2095
                particular site. For example, [0.1, 0, 0.1] in cartesian
2096
                coordinates will be considered to be on the same coordinates
2097
                as [0, 0, 0] for a tolerance of 0.25. Defaults to 0.25.
2098
            use_site_props (bool): Whether to account for site properties in
2099
                differentiating sites.
2100
            constrain_latt (list/dict): List of lattice parameters we want to
2101
                preserve, e.g. ["alpha", "c"] or dict with the lattice
2102
                parameter names as keys and values we want the parameters to
2103
                be e.g. {"alpha": 90, "c": 2.5}.
2104

2105
        Returns:
2106
            The most primitive structure found.
2107
        """
2108
        if constrain_latt is None:
1✔
2109
            constrain_latt = []
1✔
2110

2111
        def site_label(site):
1✔
2112
            if not use_site_props:
1✔
2113
                return site.species_string
1✔
2114
            d = [site.species_string]
1✔
2115
            for k in sorted(site.properties):
1✔
2116
                d.append(k + "=" + str(site.properties[k]))
1✔
2117
            return ", ".join(d)
1✔
2118

2119
        # group sites by species string
2120
        sites = sorted(self._sites, key=site_label)
1✔
2121

2122
        grouped_sites = [list(a[1]) for a in itertools.groupby(sites, key=site_label)]
1✔
2123
        grouped_fcoords = [np.array([s.frac_coords for s in g]) for g in grouped_sites]
1✔
2124

2125
        # min_vecs are approximate periodicities of the cell. The exact
2126
        # periodicities from the supercell matrices are checked against these
2127
        # first
2128
        min_fcoords = min(grouped_fcoords, key=lambda x: len(x))
1✔
2129
        min_vecs = min_fcoords - min_fcoords[0]
1✔
2130

2131
        # fractional tolerance in the supercell
2132
        super_ftol = np.divide(tolerance, self.lattice.abc)
1✔
2133
        super_ftol_2 = super_ftol * 2
1✔
2134

2135
        def pbc_coord_intersection(fc1, fc2, tol):
1✔
2136
            """
2137
            Returns the fractional coords in fc1 that have coordinates
2138
            within tolerance to some coordinate in fc2
2139
            """
2140
            d = fc1[:, None, :] - fc2[None, :, :]
1✔
2141
            d -= np.round(d)
1✔
2142
            np.abs(d, d)
1✔
2143
            return fc1[np.any(np.all(d < tol, axis=-1), axis=-1)]
1✔
2144

2145
        # here we reduce the number of min_vecs by enforcing that every
2146
        # vector in min_vecs approximately maps each site onto a similar site.
2147
        # The subsequent processing is O(fu^3 * min_vecs) = O(n^4) if we do no
2148
        # reduction.
2149
        # This reduction is O(n^3) so usually is an improvement. Using double
2150
        # the tolerance because both vectors are approximate
2151
        for g in sorted(grouped_fcoords, key=lambda x: len(x)):
1✔
2152
            for f in g:
1✔
2153
                min_vecs = pbc_coord_intersection(min_vecs, g - f, super_ftol_2)
1✔
2154

2155
        def get_hnf(fu):
1✔
2156
            """
2157
            Returns all possible distinct supercell matrices given a
2158
            number of formula units in the supercell. Batches the matrices
2159
            by the values in the diagonal (for less numpy overhead).
2160
            Computational complexity is O(n^3), and difficult to improve.
2161
            Might be able to do something smart with checking combinations of a
2162
            and b first, though unlikely to reduce to O(n^2).
2163
            """
2164

2165
            def factors(n):
1✔
2166
                for i in range(1, n + 1):
1✔
2167
                    if n % i == 0:
1✔
2168
                        yield i
1✔
2169

2170
            for det in factors(fu):
1✔
2171
                if det == 1:
1✔
2172
                    continue
1✔
2173
                for a in factors(det):
1✔
2174
                    for e in factors(det // a):
1✔
2175
                        g = det // a // e
1✔
2176
                        yield det, np.array(
1✔
2177
                            [
2178
                                [[a, b, c], [0, e, f], [0, 0, g]]
2179
                                for b, c, f in itertools.product(range(a), range(a), range(e))
2180
                            ]
2181
                        )
2182

2183
        # we can't let sites match to their neighbors in the supercell
2184
        grouped_non_nbrs = []
1✔
2185
        for gfcoords in grouped_fcoords:
1✔
2186
            fdist = gfcoords[None, :, :] - gfcoords[:, None, :]
1✔
2187
            fdist -= np.round(fdist)
1✔
2188
            np.abs(fdist, fdist)
1✔
2189
            non_nbrs = np.any(fdist > 2 * super_ftol[None, None, :], axis=-1)
1✔
2190
            # since we want sites to match to themselves
2191
            np.fill_diagonal(non_nbrs, True)
1✔
2192
            grouped_non_nbrs.append(non_nbrs)
1✔
2193

2194
        num_fu = functools.reduce(math.gcd, map(len, grouped_sites))
1✔
2195
        for size, ms in get_hnf(num_fu):
1✔
2196
            inv_ms = np.linalg.inv(ms)
1✔
2197

2198
            # find sets of lattice vectors that are present in min_vecs
2199
            dist = inv_ms[:, :, None, :] - min_vecs[None, None, :, :]
1✔
2200
            dist -= np.round(dist)
1✔
2201
            np.abs(dist, dist)
1✔
2202
            is_close = np.all(dist < super_ftol, axis=-1)
1✔
2203
            any_close = np.any(is_close, axis=-1)
1✔
2204
            inds = np.all(any_close, axis=-1)
1✔
2205

2206
            for inv_m, m in zip(inv_ms[inds], ms[inds]):
1✔
2207
                new_m = np.dot(inv_m, self.lattice.matrix)
1✔
2208
                ftol = np.divide(tolerance, np.sqrt(np.sum(new_m**2, axis=1)))
1✔
2209

2210
                valid = True
1✔
2211
                new_coords = []
1✔
2212
                new_sp = []
1✔
2213
                new_props = collections.defaultdict(list)
1✔
2214
                for gsites, gfcoords, non_nbrs in zip(grouped_sites, grouped_fcoords, grouped_non_nbrs):
1✔
2215
                    all_frac = np.dot(gfcoords, m)
1✔
2216

2217
                    # calculate grouping of equivalent sites, represented by
2218
                    # adjacency matrix
2219
                    fdist = all_frac[None, :, :] - all_frac[:, None, :]
1✔
2220
                    fdist = np.abs(fdist - np.round(fdist))
1✔
2221
                    close_in_prim = np.all(fdist < ftol[None, None, :], axis=-1)
1✔
2222
                    groups = np.logical_and(close_in_prim, non_nbrs)
1✔
2223

2224
                    # check that groups are correct
2225
                    if not np.all(np.sum(groups, axis=0) == size):
1✔
2226
                        valid = False
1✔
2227
                        break
1✔
2228

2229
                    # check that groups are all cliques
2230
                    for g in groups:
1✔
2231
                        if not np.all(groups[g][:, g]):
1✔
2232
                            valid = False
×
2233
                            break
×
2234
                    if not valid:
1✔
2235
                        break
×
2236

2237
                    # add the new sites, averaging positions
2238
                    added = np.zeros(len(gsites))
1✔
2239
                    new_fcoords = all_frac % 1
1✔
2240
                    for i, group in enumerate(groups):
1✔
2241
                        if not added[i]:
1✔
2242
                            added[group] = True
1✔
2243
                            inds = np.where(group)[0]
1✔
2244
                            coords = new_fcoords[inds[0]]
1✔
2245
                            for n, j in enumerate(inds[1:]):
1✔
2246
                                offset = new_fcoords[j] - coords
1✔
2247
                                coords += (offset - np.round(offset)) / (n + 2)
1✔
2248
                            new_sp.append(gsites[inds[0]].species)
1✔
2249
                            for k in gsites[inds[0]].properties:
1✔
2250
                                new_props[k].append(gsites[inds[0]].properties[k])
1✔
2251
                            new_coords.append(coords)
1✔
2252

2253
                if valid:
1✔
2254
                    inv_m = np.linalg.inv(m)
1✔
2255
                    new_l = Lattice(np.dot(inv_m, self.lattice.matrix))
1✔
2256
                    s = Structure(
1✔
2257
                        new_l,
2258
                        new_sp,
2259
                        new_coords,
2260
                        site_properties=new_props,
2261
                        coords_are_cartesian=False,
2262
                    )
2263

2264
                    # Default behavior
2265
                    p = s.get_primitive_structure(
1✔
2266
                        tolerance=tolerance,
2267
                        use_site_props=use_site_props,
2268
                        constrain_latt=constrain_latt,
2269
                    ).get_reduced_structure()
2270
                    if not constrain_latt:
1✔
2271
                        return p
1✔
2272

2273
                    # Only return primitive structures that
2274
                    # satisfy the restriction condition
2275
                    p_latt, s_latt = p.lattice, self.lattice
1✔
2276
                    if type(constrain_latt).__name__ == "list":
1✔
2277
                        if all(getattr(p_latt, pp) == getattr(s_latt, pp) for pp in constrain_latt):
×
2278
                            return p
×
2279
                    elif type(constrain_latt).__name__ == "dict":
1✔
2280
                        if all(getattr(p_latt, pp) == constrain_latt[pp] for pp in constrain_latt):  # type: ignore
1✔
2281
                            return p
1✔
2282

2283
        return self.copy()
1✔
2284

2285
    def __repr__(self):
1✔
2286
        outs = ["Structure Summary", repr(self.lattice)]
1✔
2287
        if self._charge:
1✔
2288
            if self._charge >= 0:
×
2289
                outs.append(f"Overall Charge: +{self._charge}")
×
2290
            else:
2291
                outs.append(f"Overall Charge: -{self._charge}")
×
2292
        for s in self:
1✔
2293
            outs.append(repr(s))
1✔
2294
        return "\n".join(outs)
1✔
2295

2296
    def __str__(self):
1✔
2297
        outs = [
1✔
2298
            f"Full Formula ({self.composition.formula})",
2299
            f"Reduced Formula: {self.composition.reduced_formula}",
2300
        ]
2301

2302
        def to_s(x):
1✔
2303
            return f"{x:0.6f}"
1✔
2304

2305
        outs.append("abc   : " + " ".join(to_s(i).rjust(10) for i in self.lattice.abc))
1✔
2306
        outs.append("angles: " + " ".join(to_s(i).rjust(10) for i in self.lattice.angles))
1✔
2307
        outs.append("pbc   : " + " ".join(str(p).rjust(10) for p in self.lattice.pbc))
1✔
2308
        if self._charge:
1✔
2309
            if self._charge >= 0:
1✔
2310
                outs.append(f"Overall Charge: +{self._charge}")
1✔
2311
            else:
2312
                outs.append(f"Overall Charge: -{self._charge}")
×
2313
        outs.append(f"Sites ({len(self)})")
1✔
2314
        data = []
1✔
2315
        props = self.site_properties
1✔
2316
        keys = sorted(props)
1✔
2317
        for i, site in enumerate(self):
1✔
2318
            row = [str(i), site.species_string]
1✔
2319
            row.extend([to_s(j) for j in site.frac_coords])
1✔
2320
            for k in keys:
1✔
2321
                row.append(props[k][i])
1✔
2322
            data.append(row)
1✔
2323
        outs.append(
1✔
2324
            tabulate(
2325
                data,
2326
                headers=["#", "SP", "a", "b", "c"] + keys,
2327
            )
2328
        )
2329
        return "\n".join(outs)
1✔
2330

2331
    def get_orderings(self, mode: Literal["enum", "sqs"] = "enum", **kwargs) -> list[Structure]:
1✔
2332
        """
2333
        Returns list of orderings for a disordered structure. If structure
2334
        does not contain disorder, the default structure is returned.
2335

2336
        Args:
2337
            mode ("enum" | "sqs"): Either "enum" or "sqs". If enum,
2338
                the enumlib will be used to return all distinct
2339
                orderings. If sqs, mcsqs will be used to return
2340
                an sqs structure.
2341
            kwargs: kwargs passed to either
2342
                pymatgen.command_line..enumlib_caller.EnumlibAdaptor
2343
                or pymatgen.command_line.mcsqs_caller.run_mcsqs.
2344
                For run_mcsqs, a default cluster search of 2 cluster interactions
2345
                with 1NN distance and 3 cluster interactions with 2NN distance
2346
                is set.
2347

2348
        Returns:
2349
            List[Structure]
2350
        """
2351
        if self.is_ordered:
×
2352
            return [self]
×
2353
        if mode.startswith("enum"):
×
2354
            from pymatgen.command_line.enumlib_caller import EnumlibAdaptor
×
2355

2356
            adaptor = EnumlibAdaptor(self, **kwargs)
×
2357
            adaptor.run()
×
2358
            return adaptor.structures
×
2359
        if mode == "sqs":
×
2360
            from pymatgen.command_line.mcsqs_caller import run_mcsqs
×
2361

2362
            if "clusters" not in kwargs:
×
2363
                disordered_sites = [site for site in self if not site.is_ordered]
×
2364
                subset_structure = Structure.from_sites(disordered_sites)
×
2365
                dist_matrix = subset_structure.distance_matrix
×
2366
                dists = sorted(set(dist_matrix.ravel()))
×
2367
                unique_dists = []
×
2368
                for i in range(1, len(dists)):
×
2369
                    if dists[i] - dists[i - 1] > 0.1:
×
2370
                        unique_dists.append(dists[i])
×
2371
                clusters = {(i + 2): d + 0.01 for i, d in enumerate(unique_dists) if i < 2}
×
2372
                kwargs["clusters"] = clusters
×
2373
            return [run_mcsqs(self, **kwargs).bestsqs]
×
2374
        raise ValueError()
×
2375

2376
    def as_dict(self, verbosity=1, fmt=None, **kwargs):
1✔
2377
        """
2378
        Dict representation of Structure.
2379

2380
        Args:
2381
            verbosity (int): Verbosity level. Default of 1 includes both
2382
                direct and Cartesian coordinates for all sites, lattice
2383
                parameters, etc. Useful for reading and for insertion into a
2384
                database. Set to 0 for an extremely lightweight version
2385
                that only includes sufficient information to reconstruct the
2386
                object.
2387
            fmt (str): Specifies a format for the dict. Defaults to None,
2388
                which is the default format used in pymatgen. Other options
2389
                include "abivars".
2390
            **kwargs: Allow passing of other kwargs needed for certain
2391
            formats, e.g., "abivars".
2392

2393
        Returns:
2394
            JSON-serializable dict representation.
2395
        """
2396
        if fmt == "abivars":
1✔
2397
            """Returns a dictionary with the ABINIT variables."""
2398
            from pymatgen.io.abinit.abiobjects import structure_to_abivars
1✔
2399

2400
            return structure_to_abivars(self, **kwargs)
1✔
2401

2402
        latt_dict = self._lattice.as_dict(verbosity=verbosity)
1✔
2403
        del latt_dict["@module"]
1✔
2404
        del latt_dict["@class"]
1✔
2405

2406
        d = {
1✔
2407
            "@module": type(self).__module__,
2408
            "@class": type(self).__name__,
2409
            "charge": self.charge,
2410
            "lattice": latt_dict,
2411
            "sites": [],
2412
        }
2413
        for site in self:
1✔
2414
            site_dict = site.as_dict(verbosity=verbosity)
1✔
2415
            del site_dict["lattice"]
1✔
2416
            del site_dict["@module"]
1✔
2417
            del site_dict["@class"]
1✔
2418
            d["sites"].append(site_dict)
1✔
2419
        return d
1✔
2420

2421
    def as_dataframe(self):
1✔
2422
        """
2423
        Returns a Pandas dataframe of the sites. Structure level attributes are stored in DataFrame.attrs. Example:
2424

2425
        Species    a    b             c    x             y             z  magmom
2426
        0    (Si)  0.0  0.0  0.000000e+00  0.0  0.000000e+00  0.000000e+00       5
2427
        1    (Si)  0.0  0.0  1.000000e-07  0.0 -2.217138e-07  3.135509e-07      -5
2428
        """
2429
        data = []
1✔
2430
        site_properties = self.site_properties
1✔
2431
        prop_keys = list(site_properties)
1✔
2432
        for site in self:
1✔
2433
            row = [site.species] + list(site.frac_coords) + list(site.coords)
1✔
2434
            for k in prop_keys:
1✔
2435
                row.append(site.properties.get(k))
1✔
2436
            data.append(row)
1✔
2437
        import pandas as pd
1✔
2438

2439
        df = pd.DataFrame(data, columns=["Species", "a", "b", "c", "x", "y", "z"] + prop_keys)
1✔
2440
        df.attrs["Reduced Formula"] = self.composition.reduced_formula
1✔
2441
        df.attrs["Lattice"] = self.lattice
1✔
2442
        return df
1✔
2443

2444
    @classmethod
1✔
2445
    def from_dict(cls, d: dict[str, Any], fmt: Literal["abivars"] | None = None) -> Structure:
1✔
2446
        """
2447
        Reconstitute a Structure object from a dict representation of Structure
2448
        created using as_dict().
2449

2450
        Args:
2451
            d (dict): Dict representation of structure.
2452
            fmt ('abivars' | None): Use structure_from_abivars() to parse the dict. Defaults to None.
2453

2454
        Returns:
2455
            Structure object
2456
        """
2457
        if fmt == "abivars":
1✔
2458
            from pymatgen.io.abinit.abiobjects import structure_from_abivars
1✔
2459

2460
            return structure_from_abivars(cls=cls, **d)
1✔
2461

2462
        lattice = Lattice.from_dict(d["lattice"])
1✔
2463
        sites = [PeriodicSite.from_dict(sd, lattice) for sd in d["sites"]]
1✔
2464
        charge = d.get("charge", None)
1✔
2465
        return cls.from_sites(sites, charge=charge)
1✔
2466

2467
    def to(self, filename: str = "", fmt: str = "", **kwargs) -> str | None:
1✔
2468
        """
2469
        Outputs the structure to a file or string.
2470

2471
        Args:
2472
            filename (str): If provided, output will be written to a file. If
2473
                fmt is not specified, the format is determined from the
2474
                filename. Defaults is None, i.e. string output.
2475
            fmt (str): Format to output to. Defaults to JSON unless filename
2476
                is provided. If fmt is specifies, it overrides whatever the
2477
                filename is. Options include "cif", "poscar", "cssr", "json",
2478
                "xsf", "mcsqs", "prismatic", "yaml", "fleur-inpgen".
2479
                Non-case sensitive.
2480
            **kwargs: Kwargs passthru to relevant methods. E.g., This allows
2481
                the passing of parameters like symprec to the
2482
                CifWriter.__init__ method for generation of symmetric cifs.
2483

2484
        Returns:
2485
            (str) if filename is None. None otherwise.
2486
        """
2487
        fmt = fmt.lower()
1✔
2488

2489
        if fmt == "cif" or fnmatch(filename.lower(), "*.cif*"):
1✔
2490
            from pymatgen.io.cif import CifWriter
1✔
2491

2492
            writer = CifWriter(self, **kwargs)
1✔
2493
        elif fmt == "mcif" or fnmatch(filename.lower(), "*.mcif*"):
1✔
2494
            from pymatgen.io.cif import CifWriter
×
2495

2496
            writer = CifWriter(self, write_magmoms=True, **kwargs)
×
2497
        elif fmt == "poscar" or fnmatch(filename, "*POSCAR*"):
1✔
2498
            from pymatgen.io.vasp import Poscar
1✔
2499

2500
            writer = Poscar(self, **kwargs)
1✔
2501
        elif fmt == "cssr" or fnmatch(filename.lower(), "*.cssr*"):
1✔
2502
            from pymatgen.io.cssr import Cssr
1✔
2503

2504
            writer = Cssr(self)  # type: ignore
1✔
2505
        elif fmt == "json" or fnmatch(filename.lower(), "*.json"):
1✔
2506
            s = json.dumps(self.as_dict())
1✔
2507
            if filename:
1✔
2508
                with zopen(filename, "wt") as f:
1✔
2509
                    f.write(s)
1✔
2510
            return s
1✔
2511
        elif fmt == "xsf" or fnmatch(filename.lower(), "*.xsf*"):
1✔
2512
            from pymatgen.io.xcrysden import XSF
1✔
2513

2514
            s = XSF(self).to_string()
1✔
2515
            if filename:
1✔
2516
                with zopen(filename, "wt", encoding="utf8") as f:
×
2517
                    f.write(s)
×
2518
            return s
1✔
2519
        elif (
1✔
2520
            fmt == "mcsqs"
2521
            or fnmatch(filename, "*rndstr.in*")
2522
            or fnmatch(filename, "*lat.in*")
2523
            or fnmatch(filename, "*bestsqs*")
2524
        ):
2525
            from pymatgen.io.atat import Mcsqs
×
2526

2527
            s = Mcsqs(self).to_string()
×
2528
            if filename:
×
2529
                with zopen(filename, "wt", encoding="ascii") as f:
×
2530
                    f.write(s)
×
2531
            return s
×
2532
        elif fmt == "prismatic" or fnmatch(filename, "*prismatic*"):
1✔
2533
            from pymatgen.io.prismatic import Prismatic
×
2534

2535
            s = Prismatic(self).to_string()
×
2536
            return s
×
2537
        elif fmt == "yaml" or fnmatch(filename, "*.yaml*") or fnmatch(filename, "*.yml*"):
1✔
2538
            yaml = YAML()
1✔
2539
            if filename:
1✔
2540
                with zopen(filename, "wt") as f:
1✔
2541
                    yaml.dump(self.as_dict(), f)
1✔
2542
                return None
1✔
2543
            sio = StringIO()
1✔
2544
            yaml.dump(self.as_dict(), sio)
1✔
2545
            return sio.getvalue()
1✔
2546
        elif fmt == "fleur-inpgen" or fnmatch(filename, "*.in*"):
1✔
2547
            from pymatgen.io.fleur import FleurInput
×
2548

2549
            writer = FleurInput(self, **kwargs)
×
2550
        elif fmt == "res" or fnmatch(filename, "*.res"):
1✔
2551
            from pymatgen.io.res import ResIO
1✔
2552

2553
            s = ResIO.structure_to_str(self)
1✔
2554
            if filename:
1✔
2555
                with zopen(filename, "wt", encoding="utf8") as f:
×
2556
                    f.write(s)
×
2557
                return None
×
2558
            return s
1✔
2559
        else:
2560
            raise ValueError(f"Invalid format: `{str(fmt)}`")
1✔
2561

2562
        if filename:
1✔
2563
            writer.write_file(filename)
1✔
2564
            return None
1✔
2565
        return str(writer)
1✔
2566

2567
    @classmethod
1✔
2568
    def from_str(
1✔
2569
        cls,
2570
        input_string: str,
2571
        fmt: Literal["cif", "poscar", "cssr", "json", "yaml", "xsf", "mcsqs", "res"],
2572
        primitive: bool = False,
2573
        sort: bool = False,
2574
        merge_tol: float = 0.0,
2575
        **kwargs,
2576
    ) -> Structure | IStructure:
2577
        """
2578
        Reads a structure from a string.
2579

2580
        Args:
2581
            input_string (str): String to parse.
2582
            fmt (str): A file format specification. One of "cif", "poscar", "cssr",
2583
                "json", "yaml", "xsf", "mcsqs".
2584
            primitive (bool): Whether to find a primitive cell. Defaults to
2585
                False.
2586
            sort (bool): Whether to sort the sites in accordance to the default
2587
                ordering criteria, i.e., electronegativity.
2588
            merge_tol (float): If this is some positive number, sites that
2589
                are within merge_tol from each other will be merged. Usually
2590
                0.01 should be enough to deal with common numerical issues.
2591
            **kwargs: Passthrough to relevant parser.
2592

2593
        Returns:
2594
            IStructure | Structure
2595
        """
2596
        fmt_low = fmt.lower()
1✔
2597
        if fmt_low == "cif":
1✔
2598
            from pymatgen.io.cif import CifParser
1✔
2599

2600
            parser = CifParser.from_string(input_string, **kwargs)
1✔
2601
            s = parser.get_structures(primitive=primitive)[0]
1✔
2602
        elif fmt_low == "poscar":
1✔
2603
            from pymatgen.io.vasp import Poscar
1✔
2604

2605
            s = Poscar.from_string(input_string, False, read_velocities=False, **kwargs).structure
1✔
2606
        elif fmt_low == "cssr":
1✔
2607
            from pymatgen.io.cssr import Cssr
1✔
2608

2609
            cssr = Cssr.from_string(input_string, **kwargs)
1✔
2610
            s = cssr.structure
1✔
2611
        elif fmt_low == "json":
1✔
2612
            d = json.loads(input_string)
1✔
2613
            s = Structure.from_dict(d)
1✔
2614
        elif fmt_low == "yaml":
1✔
2615
            yaml = YAML()
1✔
2616
            d = yaml.load(input_string)
1✔
2617
            s = Structure.from_dict(d)
1✔
2618
        elif fmt_low == "xsf":
1✔
2619
            from pymatgen.io.xcrysden import XSF
1✔
2620

2621
            s = XSF.from_string(input_string, **kwargs).structure
1✔
2622
        elif fmt_low == "mcsqs":
1✔
2623
            from pymatgen.io.atat import Mcsqs
1✔
2624

2625
            s = Mcsqs.structure_from_string(input_string, **kwargs)
1✔
2626
        elif fmt == "fleur-inpgen":
1✔
2627
            from pymatgen.io.fleur import FleurInput
×
2628

2629
            s = FleurInput.from_string(input_string, inpgen_input=True, **kwargs).structure
×
2630
        elif fmt == "fleur":
1✔
2631
            from pymatgen.io.fleur import FleurInput
×
2632

2633
            s = FleurInput.from_string(input_string, inpgen_input=False).structure
×
2634
        elif fmt == "res":
1✔
2635
            from pymatgen.io.res import ResIO
1✔
2636

2637
            s = ResIO.structure_from_str(input_string, **kwargs)
1✔
2638
        else:
2639
            raise ValueError(f"Unrecognized format `{fmt}`!")
×
2640

2641
        if sort:
1✔
2642
            s = s.get_sorted_structure()
×
2643
        if merge_tol:
1✔
2644
            s.merge_sites(merge_tol)
×
2645
        return cls.from_sites(s)
1✔
2646

2647
    @classmethod
1✔
2648
    def from_file(cls, filename, primitive=False, sort=False, merge_tol=0.0, **kwargs):
1✔
2649
        """
2650
        Reads a structure from a file. For example, anything ending in
2651
        a "cif" is assumed to be a Crystallographic Information Format file.
2652
        Supported formats include CIF, POSCAR/CONTCAR, CHGCAR, LOCPOT,
2653
        vasprun.xml, CSSR, Netcdf and pymatgen's JSON-serialized structures.
2654

2655
        Args:
2656
            filename (str): The filename to read from.
2657
            primitive (bool): Whether to convert to a primitive cell. Only available for cifs. Defaults to False.
2658
            sort (bool): Whether to sort sites. Default to False.
2659
            merge_tol (float): If this is some positive number, sites that are within merge_tol from each other will be
2660
                merged. Usually 0.01 should be enough to deal with common numerical issues.
2661
            **kwargs: Passthrough to relevant reader. E.g., if it is CIF, the kwargs will be passed through to
2662
                CifParser.
2663

2664
        Returns:
2665
            Structure.
2666
        """
2667
        filename = str(filename)
1✔
2668
        if filename.endswith(".nc"):
1✔
2669
            # Read Structure from a netcdf file.
2670
            from pymatgen.io.abinit.netcdf import structure_from_ncdata
×
2671

2672
            s = structure_from_ncdata(filename, cls=cls)
×
2673
            if sort:
×
2674
                s = s.get_sorted_structure()
×
2675
            return s
×
2676

2677
        from pymatgen.io.exciting import ExcitingInput
1✔
2678
        from pymatgen.io.lmto import LMTOCtrl
1✔
2679
        from pymatgen.io.vasp import Chgcar, Vasprun
1✔
2680

2681
        fname = os.path.basename(filename)
1✔
2682
        with zopen(filename, "rt") as f:
1✔
2683
            contents = f.read()
1✔
2684
        if fnmatch(fname.lower(), "*.cif*") or fnmatch(fname.lower(), "*.mcif*"):
1✔
2685
            return cls.from_str(contents, fmt="cif", primitive=primitive, sort=sort, merge_tol=merge_tol, **kwargs)
1✔
2686
        if fnmatch(fname, "*POSCAR*") or fnmatch(fname, "*CONTCAR*") or fnmatch(fname, "*.vasp"):
1✔
2687
            s = cls.from_str(contents, fmt="poscar", primitive=primitive, sort=sort, merge_tol=merge_tol, **kwargs)
1✔
2688

2689
        elif fnmatch(fname, "CHGCAR*") or fnmatch(fname, "LOCPOT*"):
1✔
2690
            s = Chgcar.from_file(filename, **kwargs).structure
×
2691
        elif fnmatch(fname, "vasprun*.xml*"):
1✔
2692
            s = Vasprun(filename, **kwargs).final_structure
×
2693
        elif fnmatch(fname.lower(), "*.cssr*"):
1✔
2694
            return cls.from_str(contents, fmt="cssr", primitive=primitive, sort=sort, merge_tol=merge_tol, **kwargs)
1✔
2695
        elif fnmatch(fname, "*.json*") or fnmatch(fname, "*.mson*"):
1✔
2696
            return cls.from_str(contents, fmt="json", primitive=primitive, sort=sort, merge_tol=merge_tol, **kwargs)
1✔
2697
        elif fnmatch(fname, "*.yaml*") or fnmatch(fname, "*.yml*"):
1✔
2698
            return cls.from_str(contents, fmt="yaml", primitive=primitive, sort=sort, merge_tol=merge_tol, **kwargs)
1✔
2699
        elif fnmatch(fname, "*.xsf"):
1✔
2700
            return cls.from_str(contents, fmt="xsf", primitive=primitive, sort=sort, merge_tol=merge_tol, **kwargs)
×
2701
        elif fnmatch(fname, "input*.xml"):
1✔
2702
            return ExcitingInput.from_file(fname, **kwargs).structure
×
2703
        elif fnmatch(fname, "*rndstr.in*") or fnmatch(fname, "*lat.in*") or fnmatch(fname, "*bestsqs*"):
1✔
2704
            return cls.from_str(contents, fmt="mcsqs", primitive=primitive, sort=sort, merge_tol=merge_tol, **kwargs)
1✔
2705
        elif fnmatch(fname, "CTRL*"):
1✔
2706
            return LMTOCtrl.from_file(filename=filename, **kwargs).structure
1✔
2707
        elif fnmatch(fname, "inp*.xml") or fnmatch(fname, "*.in*") or fnmatch(fname, "inp_*"):
1✔
2708
            from pymatgen.io.fleur import FleurInput
×
2709

2710
            s = FleurInput.from_file(filename, **kwargs).structure
×
2711
        elif fnmatch(fname, "*.res"):
1✔
2712
            from pymatgen.io.res import ResIO
1✔
2713

2714
            s = ResIO.structure_from_file(filename, **kwargs)
1✔
2715
        else:
2716
            raise ValueError("Unrecognized file extension!")
×
2717
        if sort:
1✔
2718
            s = s.get_sorted_structure()
×
2719
        if merge_tol:
1✔
2720
            s.merge_sites(merge_tol)
×
2721

2722
        s.__class__ = cls
1✔
2723
        return s
1✔
2724

2725

2726
class IMolecule(SiteCollection, MSONable):
1✔
2727
    """
2728
    Basic immutable Molecule object without periodicity. Essentially a
2729
    sequence of sites. IMolecule is made to be immutable so that they can
2730
    function as keys in a dict. For a mutable molecule,
2731
    use the :class:Molecule.
2732

2733
    Molecule extends Sequence and Hashable, which means that in many cases,
2734
    it can be used like any Python sequence. Iterating through a molecule is
2735
    equivalent to going through the sites in sequence.
2736
    """
2737

2738
    def __init__(
1✔
2739
        self,
2740
        species: Sequence[CompositionLike],
2741
        coords: Sequence[ArrayLike],
2742
        charge: float = 0.0,
2743
        spin_multiplicity: int | None = None,
2744
        validate_proximity: bool = False,
2745
        site_properties: dict | None = None,
2746
        charge_spin_check: bool = True,
2747
    ):
2748
        """
2749
        Creates a Molecule.
2750

2751
        Args:
2752
            species: list of atomic species. Possible kinds of input include a
2753
                list of dict of elements/species and occupancies, a List of
2754
                elements/specie specified as actual Element/Species, Strings
2755
                ("Fe", "Fe2+") or atomic numbers (1,56).
2756
            coords (3x1 array): list of Cartesian coordinates of each species.
2757
            charge (float): Charge for the molecule. Defaults to 0.
2758
            spin_multiplicity (int): Spin multiplicity for molecule.
2759
                Defaults to None, which means that the spin multiplicity is
2760
                set to 1 if the molecule has no unpaired electrons and to 2
2761
                if there are unpaired electrons.
2762
            validate_proximity (bool): Whether to check if there are sites
2763
                that are less than 1 Ang apart. Defaults to False.
2764
            site_properties (dict): Properties associated with the sites as
2765
                a dict of sequences, e.g., {"magmom":[5,5,5,5]}. The
2766
                sequences have to be the same length as the atomic species
2767
                and fractional_coords. Defaults to None for no properties.
2768
            charge_spin_check (bool): Whether to check that the charge and
2769
                spin multiplicity are compatible with each other. Defaults
2770
                to True.
2771
        """
2772
        if len(species) != len(coords):
1✔
2773
            raise StructureError(
×
2774
                (
2775
                    "The list of atomic species must be of the",
2776
                    " same length as the list of fractional ",
2777
                    "coordinates.",
2778
                )
2779
            )
2780

2781
        self._charge_spin_check = charge_spin_check
1✔
2782

2783
        sites = []
1✔
2784
        for i, _ in enumerate(species):
1✔
2785
            prop = None
1✔
2786
            if site_properties:
1✔
2787
                prop = {k: v[i] for k, v in site_properties.items()}
1✔
2788
            sites.append(Site(species[i], coords[i], properties=prop))  # type: ignore
1✔
2789

2790
        self._sites = tuple(sites)
1✔
2791
        if validate_proximity and not self.is_valid():
1✔
2792
            raise StructureError("Molecule contains sites that are less than 0.01 Angstrom apart!")
1✔
2793

2794
        self._charge = charge
1✔
2795
        nelectrons = 0.0
1✔
2796
        for site in sites:
1✔
2797
            for sp, amt in site.species.items():
1✔
2798
                if not isinstance(sp, DummySpecies):
1✔
2799
                    nelectrons += sp.Z * amt
1✔
2800
        nelectrons -= charge
1✔
2801
        self._nelectrons = nelectrons
1✔
2802
        if spin_multiplicity:
1✔
2803
            if charge_spin_check and (nelectrons + spin_multiplicity) % 2 != 1:
1✔
2804
                raise ValueError(
1✔
2805
                    f"Charge of {self._charge} and spin multiplicity of {spin_multiplicity} is not possible for "
2806
                    "this molecule!"
2807
                )
2808
            self._spin_multiplicity = spin_multiplicity
1✔
2809
        else:
2810
            self._spin_multiplicity = 1 if nelectrons % 2 == 0 else 2
1✔
2811

2812
    @property
1✔
2813
    def charge(self) -> float:
1✔
2814
        """
2815
        Charge of molecule
2816
        """
2817
        return self._charge
1✔
2818

2819
    @property
1✔
2820
    def spin_multiplicity(self) -> float:
1✔
2821
        """
2822
        Spin multiplicity of molecule.
2823
        """
2824
        return self._spin_multiplicity
1✔
2825

2826
    @property
1✔
2827
    def nelectrons(self) -> float:
1✔
2828
        """
2829
        Number of electrons in the molecule.
2830
        """
2831
        return self._nelectrons
1✔
2832

2833
    @property
1✔
2834
    def center_of_mass(self) -> np.ndarray:
1✔
2835
        """
2836
        Center of mass of molecule.
2837
        """
2838
        center = np.zeros(3)
1✔
2839
        total_weight: float = 0
1✔
2840
        for site in self:
1✔
2841
            wt = site.species.weight
1✔
2842
            center += site.coords * wt
1✔
2843
            total_weight += wt
1✔
2844
        return center / total_weight
1✔
2845

2846
    @property
1✔
2847
    def sites(self) -> tuple[Site, ...]:
1✔
2848
        """
2849
        Returns a tuple of sites in the Molecule.
2850
        """
2851
        return self._sites
1✔
2852

2853
    @classmethod
1✔
2854
    def from_sites(
1✔
2855
        cls,
2856
        sites: Sequence[Site],
2857
        charge: float = 0,
2858
        spin_multiplicity: int | None = None,
2859
        validate_proximity: bool = False,
2860
        charge_spin_check: bool = True,
2861
    ) -> IMolecule | Molecule:
2862
        """
2863
        Convenience constructor to make a Molecule from a list of sites.
2864

2865
        Args:
2866
            sites ([Site]): Sequence of Sites.
2867
            charge (int): Charge of molecule. Defaults to 0.
2868
            spin_multiplicity (int): Spin multicipity. Defaults to None,
2869
                in which it is determined automatically.
2870
            validate_proximity (bool): Whether to check that atoms are too
2871
                close.
2872
            charge_spin_check (bool): Whether to check that the charge and
2873
                spin multiplicity are compatible with each other. Defaults
2874
                to True.
2875
        """
2876
        props = collections.defaultdict(list)
1✔
2877
        for site in sites:
1✔
2878
            for k, v in site.properties.items():
1✔
2879
                props[k].append(v)
1✔
2880
        return cls(
1✔
2881
            [site.species for site in sites],
2882
            [site.coords for site in sites],
2883
            charge=charge,
2884
            spin_multiplicity=spin_multiplicity,
2885
            validate_proximity=validate_proximity,
2886
            site_properties=props,
2887
            charge_spin_check=charge_spin_check,
2888
        )
2889

2890
    def break_bond(self, ind1: int, ind2: int, tol: float = 0.2) -> tuple[IMolecule | Molecule, ...]:
1✔
2891
        """
2892
        Returns two molecules based on breaking the bond between atoms at index
2893
        ind1 and ind2.
2894

2895
        Args:
2896
            ind1 (int): Index of first site.
2897
            ind2 (int): Index of second site.
2898
            tol (float): Relative tolerance to test. Basically, the code
2899
                checks if the distance between the sites is less than (1 +
2900
                tol) * typical bond distances. Defaults to 0.2, i.e.,
2901
                20% longer.
2902

2903
        Returns:
2904
            Two Molecule objects representing the two clusters formed from
2905
            breaking the bond.
2906
        """
2907
        clusters = [[self._sites[ind1]], [self._sites[ind2]]]
1✔
2908

2909
        sites = [site for i, site in enumerate(self._sites) if i not in (ind1, ind2)]
1✔
2910

2911
        def belongs_to_cluster(site, cluster):
1✔
2912
            for test_site in cluster:
1✔
2913
                if CovalentBond.is_bonded(site, test_site, tol=tol):
1✔
2914
                    return True
1✔
2915
            return False
×
2916

2917
        while len(sites) > 0:
1✔
2918
            unmatched = []
1✔
2919
            for site in sites:
1✔
2920
                for cluster in clusters:
1✔
2921
                    if belongs_to_cluster(site, cluster):
1✔
2922
                        cluster.append(site)
1✔
2923
                        break
1✔
2924
                else:
2925
                    unmatched.append(site)
×
2926

2927
            if len(unmatched) == len(sites):
1✔
2928
                raise ValueError("Not all sites are matched!")
×
2929
            sites = unmatched
1✔
2930

2931
        return tuple(type(self).from_sites(cluster) for cluster in clusters)
1✔
2932

2933
    def get_covalent_bonds(self, tol: float = 0.2) -> list[CovalentBond]:
1✔
2934
        """
2935
        Determines the covalent bonds in a molecule.
2936

2937
        Args:
2938
            tol (float): The tol to determine bonds in a structure. See
2939
                CovalentBond.is_bonded.
2940

2941
        Returns:
2942
            List of bonds
2943
        """
2944
        bonds = []
1✔
2945
        for site1, site2 in itertools.combinations(self._sites, 2):
1✔
2946
            if CovalentBond.is_bonded(site1, site2, tol):
1✔
2947
                bonds.append(CovalentBond(site1, site2))
1✔
2948
        return bonds
1✔
2949

2950
    def __eq__(self, other: object) -> bool:
1✔
2951
        needed_attrs = ("charge", "spin_multiplicity", "sites")
1✔
2952

2953
        if not all(hasattr(other, attr) for attr in needed_attrs):
1✔
2954
            return NotImplemented
×
2955

2956
        other = cast(IMolecule, other)
1✔
2957

2958
        if len(self) != len(other):
1✔
2959
            return False
×
2960
        if self.charge != other.charge:
1✔
2961
            return False
1✔
2962
        if self.spin_multiplicity != other.spin_multiplicity:
1✔
2963
            return False
×
2964
        for site in self:
1✔
2965
            if site not in other:
1✔
2966
                return False
×
2967
        return True
1✔
2968

2969
    def __hash__(self):
1✔
2970
        # For now, just use the composition hash code.
2971
        return hash(self.composition)
×
2972

2973
    def __repr__(self):
1✔
2974
        outs = ["Molecule Summary"]
1✔
2975
        for s in self:
1✔
2976
            outs.append(s.__repr__())
1✔
2977
        return "\n".join(outs)
1✔
2978

2979
    def __str__(self):
1✔
2980
        outs = [
1✔
2981
            f"Full Formula ({self.composition.formula})",
2982
            "Reduced Formula: " + self.composition.reduced_formula,
2983
            f"Charge = {self._charge}, Spin Mult = {self._spin_multiplicity}",
2984
            f"Sites ({len(self)})",
2985
        ]
2986
        for idx, site in enumerate(self):
1✔
2987
            outs.append(f"{idx} {site.species_string} {' '.join([f'{j:0.6f}'.rjust(12) for j in site.coords])}")
1✔
2988
        return "\n".join(outs)
1✔
2989

2990
    def as_dict(self):
1✔
2991
        """
2992
        JSON-serializable dict representation of Molecule
2993
        """
2994
        d = {
1✔
2995
            "@module": type(self).__module__,
2996
            "@class": type(self).__name__,
2997
            "charge": self.charge,
2998
            "spin_multiplicity": self.spin_multiplicity,
2999
            "sites": [],
3000
        }
3001
        for site in self:
1✔
3002
            site_dict = site.as_dict()
1✔
3003
            del site_dict["@module"]
1✔
3004
            del site_dict["@class"]
1✔
3005
            d["sites"].append(site_dict)
1✔
3006
        return d
1✔
3007

3008
    @classmethod
1✔
3009
    def from_dict(cls, d) -> dict:
1✔
3010
        """
3011
        Reconstitute a Molecule object from a dict representation created using
3012
        as_dict().
3013

3014
        Args:
3015
            d (dict): dict representation of Molecule.
3016

3017
        Returns:
3018
            Molecule object
3019
        """
3020
        sites = [Site.from_dict(sd) for sd in d["sites"]]
1✔
3021
        charge = d.get("charge", 0)
1✔
3022
        spin_multiplicity = d.get("spin_multiplicity")
1✔
3023
        return cls.from_sites(sites, charge=charge, spin_multiplicity=spin_multiplicity)
1✔
3024

3025
    def get_distance(self, i: int, j: int) -> float:
1✔
3026
        """
3027
        Get distance between site i and j.
3028

3029
        Args:
3030
            i (int): Index of first site
3031
            j (int): Index of second site
3032

3033
        Returns:
3034
            Distance between the two sites.
3035
        """
3036
        return self[i].distance(self[j])
1✔
3037

3038
    def get_sites_in_sphere(self, pt: ArrayLike, r: float) -> list[Neighbor]:
1✔
3039
        """
3040
        Find all sites within a sphere from a point.
3041

3042
        Args:
3043
            pt (3x1 array): Cartesian coordinates of center of sphere
3044
            r (float): Radius of sphere.
3045

3046
        Returns:
3047
            [:class:`pymatgen.core.structure.Neighbor`]
3048
        """
3049
        neighbors = []
1✔
3050
        for i, site in enumerate(self._sites):
1✔
3051
            dist = site.distance_from_point(pt)
1✔
3052
            if dist <= r:
1✔
3053
                neighbors.append(Neighbor(site.species, site.coords, site.properties, dist, i))
1✔
3054
        return neighbors
1✔
3055

3056
    def get_neighbors(self, site: Site, r: float) -> list[Neighbor]:
1✔
3057
        """
3058
        Get all neighbors to a site within a sphere of radius r. Excludes the
3059
        site itself.
3060

3061
        Args:
3062
            site (Site): Site at the center of the sphere.
3063
            r (float): Radius of sphere.
3064

3065
        Returns:
3066
            [:class:`pymatgen.core.structure.Neighbor`]
3067
        """
3068
        nns = self.get_sites_in_sphere(site.coords, r)
1✔
3069
        return [nn for nn in nns if nn != site]
1✔
3070

3071
    def get_neighbors_in_shell(self, origin: ArrayLike, r: float, dr: float) -> list[Neighbor]:
1✔
3072
        """
3073
        Returns all sites in a shell centered on origin (coords) between radii
3074
        r-dr and r+dr.
3075

3076
        Args:
3077
            origin (3x1 array): Cartesian coordinates of center of sphere.
3078
            r (float): Inner radius of shell.
3079
            dr (float): Width of shell.
3080

3081
        Returns:
3082
            [:class:`pymatgen.core.structure.Neighbor`]
3083
        """
3084
        outer = self.get_sites_in_sphere(origin, r + dr)
1✔
3085
        inner = r - dr
1✔
3086
        return [nn for nn in outer if nn.nn_distance > inner]
1✔
3087

3088
    def get_boxed_structure(
1✔
3089
        self,
3090
        a: float,
3091
        b: float,
3092
        c: float,
3093
        images: ArrayLike = (1, 1, 1),
3094
        random_rotation: bool = False,
3095
        min_dist: float = 1.0,
3096
        cls=None,
3097
        offset: ArrayLike | None = None,
3098
        no_cross: bool = False,
3099
        reorder: bool = True,
3100
    ) -> IStructure | Structure:
3101
        """
3102
        Creates a Structure from a Molecule by putting the Molecule in the
3103
        center of a orthorhombic box. Useful for creating Structure for
3104
        calculating molecules using periodic codes.
3105

3106
        Args:
3107
            a (float): a-lattice parameter.
3108
            b (float): b-lattice parameter.
3109
            c (float): c-lattice parameter.
3110
            images: No. of boxed images in each direction. Defaults to
3111
                (1, 1, 1), meaning single molecule with 1 lattice parameter
3112
                in each direction.
3113
            random_rotation (bool): Whether to apply a random rotation to
3114
                each molecule. This jumbles all the molecules so that they
3115
                are not exact images of each other.
3116
            min_dist (float): The minimum distance that atoms should be from
3117
                each other. This is only used if random_rotation is True.
3118
                The randomized rotations are searched such that no two atoms
3119
                are less than min_dist from each other.
3120
            cls: The Structure class to instantiate (defaults to pymatgen
3121
                structure)
3122
            offset: Translation to offset molecule from center of mass coords
3123
            no_cross: Whether to forbid molecule coords from extending beyond
3124
                boundary of box.
3125
            reorder: Whether to reorder the sites to be in electronegativity
3126
                order.
3127

3128
        Returns:
3129
            Structure containing molecule in a box.
3130
        """
3131
        if offset is None:
1✔
3132
            offset = np.array([0, 0, 0])
1✔
3133

3134
        coords = np.array(self.cart_coords)
1✔
3135
        x_range = max(coords[:, 0]) - min(coords[:, 0])
1✔
3136
        y_range = max(coords[:, 1]) - min(coords[:, 1])
1✔
3137
        z_range = max(coords[:, 2]) - min(coords[:, 2])
1✔
3138

3139
        if a <= x_range or b <= y_range or c <= z_range:
1✔
3140
            raise ValueError("Box is not big enough to contain Molecule.")
1✔
3141
        lattice = Lattice.from_parameters(a * images[0], b * images[1], c * images[2], 90, 90, 90)  # type: ignore
1✔
3142
        nimages = images[0] * images[1] * images[2]  # type: ignore
1✔
3143
        all_coords: list[ArrayLike] = []
1✔
3144

3145
        centered_coords = self.cart_coords - self.center_of_mass + offset
1✔
3146

3147
        for i, j, k in itertools.product(
1✔
3148
            list(range(images[0])), list(range(images[1])), list(range(images[2]))  # type: ignore
3149
        ):
3150
            box_center = [(i + 0.5) * a, (j + 0.5) * b, (k + 0.5) * c]
1✔
3151
            if random_rotation:
1✔
3152
                while True:
3153
                    op = SymmOp.from_origin_axis_angle(
×
3154
                        (0, 0, 0),
3155
                        axis=np.random.rand(3),
3156
                        angle=random.uniform(-180, 180),
3157
                    )
3158
                    m = op.rotation_matrix
×
3159
                    new_coords = np.dot(m, centered_coords.T).T + box_center
×
3160
                    if no_cross:
×
3161
                        x_max, x_min = max(new_coords[:, 0]), min(new_coords[:, 0])
×
3162
                        y_max, y_min = max(new_coords[:, 1]), min(new_coords[:, 1])
×
3163
                        z_max, z_min = max(new_coords[:, 2]), min(new_coords[:, 2])
×
3164
                        if x_max > a or x_min < 0 or y_max > b or y_min < 0 or z_max > c or z_min < 0:
×
3165
                            raise ValueError("Molecule crosses boundary of box.")
×
3166
                    if len(all_coords) == 0:
×
3167
                        break
×
3168
                    distances = lattice.get_all_distances(
×
3169
                        lattice.get_fractional_coords(new_coords),
3170
                        lattice.get_fractional_coords(all_coords),
3171
                    )
3172
                    if np.amin(distances) > min_dist:
×
3173
                        break
×
3174
            else:
3175
                new_coords = centered_coords + box_center
1✔
3176
                if no_cross:
1✔
3177
                    x_max, x_min = max(new_coords[:, 0]), min(new_coords[:, 0])
1✔
3178
                    y_max, y_min = max(new_coords[:, 1]), min(new_coords[:, 1])
1✔
3179
                    z_max, z_min = max(new_coords[:, 2]), min(new_coords[:, 2])
1✔
3180
                    if x_max > a or x_min < 0 or y_max > b or y_min < 0 or z_max > c or z_min < 0:
1✔
3181
                        raise ValueError("Molecule crosses boundary of box.")
1✔
3182
            all_coords.extend(new_coords)
1✔
3183
        sprops = {k: v * nimages for k, v in self.site_properties.items()}  # type: ignore
1✔
3184

3185
        if cls is None:
1✔
3186
            cls = Structure
1✔
3187

3188
        if reorder:
1✔
3189
            return cls(
1✔
3190
                lattice,
3191
                self.species * nimages,  # type: ignore
3192
                all_coords,
3193
                coords_are_cartesian=True,
3194
                site_properties=sprops,
3195
            ).get_sorted_structure()
3196

3197
        return cls(
1✔
3198
            lattice,
3199
            self.species * nimages,  # type: ignore
3200
            coords,
3201
            coords_are_cartesian=True,
3202
            site_properties=sprops,
3203
        )
3204

3205
    def get_centered_molecule(self) -> IMolecule | Molecule:
1✔
3206
        """
3207
        Returns a Molecule centered at the center of mass.
3208

3209
        Returns:
3210
            Molecule centered with center of mass at origin.
3211
        """
3212
        center = self.center_of_mass
1✔
3213
        new_coords = np.array(self.cart_coords) - center
1✔
3214
        return self.__class__(
1✔
3215
            self.species_and_occu,
3216
            new_coords,
3217
            charge=self._charge,
3218
            spin_multiplicity=self._spin_multiplicity,
3219
            site_properties=self.site_properties,
3220
            charge_spin_check=self._charge_spin_check,
3221
        )
3222

3223
    def to(self, filename: str = "", fmt: str = "") -> str | None:
1✔
3224
        """
3225
        Outputs the molecule to a file or string.
3226

3227
        Args:
3228
            filename (str): If provided, output will be written to a file. If
3229
                fmt is not specified, the format is determined from the
3230
                filename. Defaults is None, i.e. string output.
3231
            fmt (str): Format to output to. Defaults to JSON unless filename
3232
                is provided. If fmt is specifies, it overrides whatever the
3233
                filename is. Options include "xyz", "gjf", "g03", "json". If
3234
                you have OpenBabel installed, any of the formats supported by
3235
                OpenBabel. Non-case sensitive.
3236

3237
        Returns:
3238
            (str) if filename is None. None otherwise.
3239
        """
3240
        from pymatgen.io.babel import BabelMolAdaptor
1✔
3241
        from pymatgen.io.gaussian import GaussianInput
1✔
3242
        from pymatgen.io.xyz import XYZ
1✔
3243

3244
        fmt = fmt.lower()
1✔
3245
        writer: Any
3246
        if fmt == "xyz" or fnmatch(filename.lower(), "*.xyz*"):
1✔
3247
            writer = XYZ(self)
1✔
3248
        elif any(fmt == r or fnmatch(filename.lower(), f"*.{r}*") for r in ["gjf", "g03", "g09", "com", "inp"]):
1✔
3249
            writer = GaussianInput(self)
1✔
3250
        elif fmt == "json" or fnmatch(filename, "*.json*") or fnmatch(filename, "*.mson*"):
1✔
3251
            if filename:
1✔
3252
                with zopen(filename, "wt", encoding="utf8") as f:
×
3253
                    json.dump(self.as_dict(), f)
×
3254
                    return None
×
3255
            else:
3256
                return json.dumps(self.as_dict())
1✔
3257
        elif fmt == "yaml" or fnmatch(filename, "*.yaml*"):
1✔
3258
            yaml = YAML()
1✔
3259
            if filename:
1✔
3260
                with zopen(filename, "wt", encoding="utf8") as f:
1✔
3261
                    return yaml.dump(self.as_dict(), f)
1✔
3262
            else:
3263
                sio = StringIO()
1✔
3264
                yaml.dump(self.as_dict(), sio)
1✔
3265
                return sio.getvalue()
1✔
3266
        else:
3267
            m = re.search(r"\.(pdb|mol|mdl|sdf|sd|ml2|sy2|mol2|cml|mrv)", filename.lower())
×
3268
            if (not fmt) and m:
×
3269
                fmt = m.group(1)
×
3270
            writer = BabelMolAdaptor(self)
×
3271
            return writer.write_file(filename, file_format=fmt)
×
3272

3273
        if filename:
1✔
3274
            writer.write_file(filename)
1✔
3275
        return str(writer)
1✔
3276

3277
    @classmethod
1✔
3278
    def from_str(cls, input_string: str, fmt: str):
1✔
3279
        """
3280
        Reads the molecule from a string.
3281

3282
        Args:
3283
            input_string (str): String to parse.
3284
            fmt (str): Format to output to. Defaults to JSON unless filename
3285
                is provided. If fmt is specifies, it overrides whatever the
3286
                filename is. Options include "xyz", "gjf", "g03", "json". If
3287
                you have OpenBabel installed, any of the formats supported by
3288
                OpenBabel. Non-case sensitive.
3289

3290
        Returns:
3291
            IMolecule or Molecule.
3292
        """
3293
        from pymatgen.io.gaussian import GaussianInput
1✔
3294
        from pymatgen.io.xyz import XYZ
1✔
3295

3296
        if fmt.lower() == "xyz":
1✔
3297
            m = XYZ.from_string(input_string).molecule
1✔
3298
        elif fmt in ["gjf", "g03", "g09", "com", "inp"]:
1✔
3299
            m = GaussianInput.from_string(input_string).molecule
1✔
3300
        elif fmt == "json":
1✔
3301
            d = json.loads(input_string)
1✔
3302
            return cls.from_dict(d)
1✔
3303
        elif fmt == "yaml":
1✔
3304
            yaml = YAML()
1✔
3305
            d = yaml.load(input_string)
1✔
3306
            return cls.from_dict(d)
1✔
3307
        else:
3308
            from pymatgen.io.babel import BabelMolAdaptor
×
3309

3310
            m = BabelMolAdaptor.from_string(input_string, file_format=fmt).pymatgen_mol
×
3311
        return cls.from_sites(m)
1✔
3312

3313
    @classmethod
1✔
3314
    def from_file(cls, filename):
1✔
3315
        """
3316
        Reads a molecule from a file. Supported formats include xyz,
3317
        gaussian input (gjf|g03|g09|com|inp), Gaussian output (.out|and
3318
        pymatgen's JSON-serialized molecules. Using openbabel,
3319
        many more extensions are supported but requires openbabel to be
3320
        installed.
3321

3322
        Args:
3323
            filename (str): The filename to read from.
3324

3325
        Returns:
3326
            Molecule
3327
        """
3328
        filename = str(filename)
1✔
3329
        from pymatgen.io.gaussian import GaussianOutput
1✔
3330

3331
        with zopen(filename) as f:
1✔
3332
            contents = f.read()
1✔
3333
        fname = filename.lower()
1✔
3334
        if fnmatch(fname, "*.xyz*"):
1✔
3335
            return cls.from_str(contents, fmt="xyz")
1✔
3336
        if any(fnmatch(fname.lower(), f"*.{r}*") for r in ["gjf", "g03", "g09", "com", "inp"]):
1✔
3337
            return cls.from_str(contents, fmt="g09")
×
3338
        if any(fnmatch(fname.lower(), f"*.{r}*") for r in ["out", "lis", "log"]):
1✔
3339
            return GaussianOutput(filename).final_structure
×
3340
        if fnmatch(fname, "*.json*") or fnmatch(fname, "*.mson*"):
1✔
3341
            return cls.from_str(contents, fmt="json")
×
3342
        if fnmatch(fname, "*.yaml*"):
1✔
3343
            return cls.from_str(contents, fmt="yaml")
1✔
3344
        from pymatgen.io.babel import BabelMolAdaptor
×
3345

3346
        m = re.search(r"\.(pdb|mol|mdl|sdf|sd|ml2|sy2|mol2|cml|mrv)", filename.lower())
×
3347
        if m:
×
3348
            new = BabelMolAdaptor.from_file(filename, m.group(1)).pymatgen_mol
×
3349
            new.__class__ = cls
×
3350
            return new
×
3351
        raise ValueError("Cannot determine file type.")
×
3352

3353

3354
class Structure(IStructure, collections.abc.MutableSequence):
1✔
3355
    """
3356
    Mutable version of structure.
3357
    """
3358

3359
    __hash__ = None  # type: ignore
1✔
3360

3361
    def __init__(
1✔
3362
        self,
3363
        lattice: ArrayLike | Lattice,
3364
        species: Sequence[CompositionLike],
3365
        coords: Sequence[ArrayLike],
3366
        charge: float | None = None,
3367
        validate_proximity: bool = False,
3368
        to_unit_cell: bool = False,
3369
        coords_are_cartesian: bool = False,
3370
        site_properties: dict | None = None,
3371
    ):
3372
        """
3373
        Create a periodic structure.
3374

3375
        Args:
3376
            lattice: The lattice, either as a pymatgen.core.lattice.Lattice or
3377
                simply as any 2D array. Each row should correspond to a lattice
3378
                vector. E.g., [[10,0,0], [20,10,0], [0,0,30]] specifies a
3379
                lattice with lattice vectors [10,0,0], [20,10,0] and [0,0,30].
3380
            species: List of species on each site. Can take in flexible input,
3381
                including:
3382

3383
                i.  A sequence of element / species specified either as string
3384
                    symbols, e.g. ["Li", "Fe2+", "P", ...] or atomic numbers,
3385
                    e.g., (3, 56, ...) or actual Element or Species objects.
3386

3387
                ii. List of dict of elements/species and occupancies, e.g.,
3388
                    [{"Fe" : 0.5, "Mn":0.5}, ...]. This allows the setup of
3389
                    disordered structures.
3390
            coords (Nx3 array): list of fractional/cartesian coordinates of
3391
                each species.
3392
            charge (int): overall charge of the structure. Defaults to behavior
3393
                in SiteCollection where total charge is the sum of the oxidation
3394
                states.
3395
            validate_proximity (bool): Whether to check if there are sites
3396
                that are less than 0.01 Ang apart. Defaults to False.
3397
            to_unit_cell (bool): Whether to map all sites into the unit cell,
3398
                i.e., fractional coords between 0 and 1. Defaults to False.
3399
            coords_are_cartesian (bool): Set to True if you are providing
3400
                coordinates in Cartesian coordinates. Defaults to False.
3401
            site_properties (dict): Properties associated with the sites as a
3402
                dict of sequences, e.g., {"magmom":[5,5,5,5]}. The sequences
3403
                have to be the same length as the atomic species and
3404
                fractional_coords. Defaults to None for no properties.
3405
        """
3406
        super().__init__(
1✔
3407
            lattice,
3408
            species,
3409
            coords,
3410
            charge=charge,
3411
            validate_proximity=validate_proximity,
3412
            to_unit_cell=to_unit_cell,
3413
            coords_are_cartesian=coords_are_cartesian,
3414
            site_properties=site_properties,
3415
        )
3416

3417
        self._sites: list[PeriodicSite] = list(self._sites)  # type: ignore
1✔
3418

3419
    def __setitem__(  # type: ignore
1✔
3420
        self, i: int | slice | Sequence[int] | SpeciesLike, site: SpeciesLike | PeriodicSite | Sequence
3421
    ):
3422
        """
3423
        Modify a site in the structure.
3424

3425
        Args:
3426
            i (int, [int], slice, Species-like): Indices to change. You can
3427
                specify these as an int, a list of int, or a species-like
3428
                string.
3429
            site (PeriodicSite/Species/Sequence): Three options exist. You
3430
                can provide a PeriodicSite directly (lattice will be
3431
                checked). Or more conveniently, you can provide a
3432
                specie-like object or a tuple of up to length 3.
3433

3434
        Examples:
3435
            s[0] = "Fe"
3436
            s[0] = Element("Fe")
3437
            both replaces the species only.
3438
            s[0] = "Fe", [0.5, 0.5, 0.5]
3439
            Replaces site and *fractional* coordinates. Any properties
3440
            are inherited from current site.
3441
            s[0] = "Fe", [0.5, 0.5, 0.5], {"spin": 2}
3442
            Replaces site and *fractional* coordinates and properties.
3443

3444
            s[(0, 2, 3)] = "Fe"
3445
            Replaces sites 0, 2 and 3 with Fe.
3446

3447
            s[0::2] = "Fe"
3448
            Replaces all even index sites with Fe.
3449

3450
            s["Mn"] = "Fe"
3451
            Replaces all Mn in the structure with Fe. This is
3452
            a short form for the more complex replace_species.
3453

3454
            s["Mn"] = "Fe0.5Co0.5"
3455
            Replaces all Mn in the structure with Fe: 0.5, Co: 0.5, i.e.,
3456
            creates a disordered structure!
3457
        """
3458
        if isinstance(i, int):
1✔
3459
            indices = [i]
1✔
3460
        elif isinstance(i, (str, Element, Species)):
1✔
3461
            self.replace_species({i: site})  # type: ignore
1✔
3462
            return
1✔
3463
        elif isinstance(i, slice):
1✔
3464
            to_mod = self[i]
1✔
3465
            indices = [ii for ii, s in enumerate(self._sites) if s in to_mod]
1✔
3466
        else:
3467
            indices = list(i)
1✔
3468

3469
        for ii in indices:
1✔
3470
            if isinstance(site, PeriodicSite):
1✔
3471
                if site.lattice != self._lattice:
1✔
3472
                    raise ValueError("PeriodicSite added must have same lattice as Structure!")
×
3473
                if len(indices) != 1:
1✔
3474
                    raise ValueError("Site assignments makes sense only for single int indices!")
×
3475
                self._sites[ii] = site
1✔
3476
            else:
3477
                if isinstance(site, str) or (not isinstance(site, collections.abc.Sequence)):
1✔
3478
                    self._sites[ii].species = site  # type: ignore
1✔
3479
                else:
3480
                    self._sites[ii].species = site[0]  # type: ignore
1✔
3481
                    if len(site) > 1:
1✔
3482
                        self._sites[ii].frac_coords = site[1]  # type: ignore
1✔
3483
                    if len(site) > 2:
1✔
3484
                        self._sites[ii].properties = site[2]  # type: ignore
1✔
3485

3486
    def __delitem__(self, idx: SupportsIndex | slice) -> None:
1✔
3487
        """Deletes a site from the Structure."""
3488
        self._sites.__delitem__(idx)
1✔
3489

3490
    @property
1✔
3491
    def lattice(self) -> Lattice:
1✔
3492
        """
3493
        :return: Lattice associated with structure.
3494
        """
3495
        return self._lattice
1✔
3496

3497
    @lattice.setter
1✔
3498
    def lattice(self, lattice: ArrayLike | Lattice):
1✔
3499
        if not isinstance(lattice, Lattice):
1✔
3500
            lattice = Lattice(lattice)
×
3501
        self._lattice = lattice
1✔
3502
        for site in self._sites:
1✔
3503
            site.lattice = lattice
1✔
3504

3505
    def append(  # type: ignore
1✔
3506
        self,
3507
        species: CompositionLike,
3508
        coords: ArrayLike,
3509
        coords_are_cartesian: bool = False,
3510
        validate_proximity: bool = False,
3511
        properties: dict | None = None,
3512
    ):
3513
        """
3514
        Append a site to the structure.
3515

3516
        Args:
3517
            species: Species of inserted site
3518
            coords (3x1 array): Coordinates of inserted site
3519
            coords_are_cartesian (bool): Whether coordinates are cartesian.
3520
                Defaults to False.
3521
            validate_proximity (bool): Whether to check if inserted site is
3522
                too close to an existing site. Defaults to False.
3523
            properties (dict): Properties of the site.
3524

3525
        Returns:
3526
            New structure with inserted site.
3527
        """
3528
        return self.insert(
1✔
3529
            len(self),
3530
            species,
3531
            coords,
3532
            coords_are_cartesian=coords_are_cartesian,
3533
            validate_proximity=validate_proximity,
3534
            properties=properties,
3535
        )
3536

3537
    def insert(  # type: ignore
1✔
3538
        self,
3539
        i: int,
3540
        species: CompositionLike,
3541
        coords: ArrayLike,
3542
        coords_are_cartesian: bool = False,
3543
        validate_proximity: bool = False,
3544
        properties: dict | None = None,
3545
    ):
3546
        """
3547
        Insert a site to the structure.
3548

3549
        Args:
3550
            i (int): Index to insert site
3551
            species (species-like): Species of inserted site
3552
            coords (3x1 array): Coordinates of inserted site
3553
            coords_are_cartesian (bool): Whether coordinates are cartesian.
3554
                Defaults to False.
3555
            validate_proximity (bool): Whether to check if inserted site is
3556
                too close to an existing site. Defaults to False.
3557
            properties (dict): Properties associated with the site.
3558

3559
        Returns:
3560
            New structure with inserted site.
3561
        """
3562
        if not coords_are_cartesian:
1✔
3563
            new_site = PeriodicSite(species, coords, self._lattice, properties=properties)
1✔
3564
        else:
3565
            frac_coords = self._lattice.get_fractional_coords(coords)
1✔
3566
            new_site = PeriodicSite(species, frac_coords, self._lattice, properties=properties)
1✔
3567

3568
        if validate_proximity:
1✔
3569
            for site in self:
1✔
3570
                if site.distance(new_site) < self.DISTANCE_TOLERANCE:
1✔
3571
                    raise ValueError("New site is too close to an existing site!")
1✔
3572

3573
        self._sites.insert(i, new_site)
1✔
3574

3575
    def replace(
1✔
3576
        self,
3577
        i: int,
3578
        species: CompositionLike,
3579
        coords: ArrayLike | None = None,
3580
        coords_are_cartesian: bool = False,
3581
        properties: dict | None = None,
3582
    ):
3583
        """
3584
        Replace a single site. Takes either a species or a dict of species and
3585
        occupations.
3586

3587
        Args:
3588
            i (int): Index of the site in the _sites list.
3589
            species (species-like): Species of replacement site
3590
            coords (3x1 array): Coordinates of replacement site. If None,
3591
                the current coordinates are assumed.
3592
            coords_are_cartesian (bool): Whether coordinates are cartesian.
3593
                Defaults to False.
3594
            properties (dict): Properties associated with the site.
3595
        """
3596
        if coords is None:
1✔
3597
            frac_coords = self[i].frac_coords
1✔
3598
        elif coords_are_cartesian:
1✔
3599
            frac_coords = self._lattice.get_fractional_coords(coords)
×
3600
        else:
3601
            frac_coords = coords  # type: ignore
1✔
3602

3603
        new_site = PeriodicSite(species, frac_coords, self._lattice, properties=properties)
1✔
3604
        self._sites[i] = new_site
1✔
3605

3606
    def substitute(self, index: int, func_group: IMolecule | Molecule | str, bond_order: int = 1) -> None:
1✔
3607
        """
3608
        Substitute atom at index with a functional group.
3609

3610
        Args:
3611
            index (int): Index of atom to substitute.
3612
            func_group: Substituent molecule. There are two options:
3613

3614
                1. Providing an actual Molecule as the input. The first atom
3615
                   must be a DummySpecies X, indicating the position of
3616
                   nearest neighbor. The second atom must be the next
3617
                   nearest atom. For example, for a methyl group
3618
                   substitution, func_group should be X-CH3, where X is the
3619
                   first site and C is the second site. What the code will
3620
                   do is to remove the index site, and connect the nearest
3621
                   neighbor to the C atom in CH3. The X-C bond indicates the
3622
                   directionality to connect the atoms.
3623
                2. A string name. The molecule will be obtained from the
3624
                   relevant template in func_groups.json.
3625
            bond_order (int): A specified bond order to calculate the bond
3626
                length between the attached functional group and the nearest
3627
                neighbor site. Defaults to 1.
3628
        """
3629
        # Find the nearest neighbor that is not a terminal atom.
3630
        all_non_terminal_nn = []
1✔
3631
        for nn, dist, _, _ in self.get_neighbors(self[index], 3):
1✔
3632
            # Check that the nn has neighbors within a sensible distance but
3633
            # is not the site being substituted.
3634
            for inn, dist2, _, _ in self.get_neighbors(nn, 3):
1✔
3635
                if inn != self[index] and dist2 < 1.2 * get_bond_length(nn.specie, inn.specie):
1✔
3636
                    all_non_terminal_nn.append((nn, dist))
1✔
3637
                    break
1✔
3638

3639
        if len(all_non_terminal_nn) == 0:
1✔
3640
            raise RuntimeError("Can't find a non-terminal neighbor to attach functional group to.")
×
3641

3642
        non_terminal_nn = min(all_non_terminal_nn, key=lambda d: d[1])[0]
1✔
3643

3644
        # Set the origin point to be the coordinates of the nearest
3645
        # non-terminal neighbor.
3646
        origin = non_terminal_nn.coords
1✔
3647

3648
        # Pass value of functional group--either from user-defined or from
3649
        # functional.json
3650
        if not isinstance(func_group, Molecule):
1✔
3651
            # Check to see whether the functional group is in database.
3652
            if func_group not in FunctionalGroups:
1✔
3653
                raise RuntimeError("Can't find functional group in list. Provide explicit coordinate instead")
×
3654
            fgroup = FunctionalGroups[func_group]
1✔
3655
        else:
3656
            fgroup = func_group
1✔
3657

3658
        # If a bond length can be found, modify func_grp so that the X-group
3659
        # bond length is equal to the bond length.
3660
        try:
1✔
3661
            bl = get_bond_length(non_terminal_nn.specie, fgroup[1].specie, bond_order=bond_order)
1✔
3662
        # Catches for case of incompatibility between Element(s) and Species(s)
3663
        except TypeError:
1✔
3664
            bl = None
1✔
3665

3666
        if bl is not None:
1✔
3667
            fgroup = fgroup.copy()
1✔
3668
            vec = fgroup[0].coords - fgroup[1].coords
1✔
3669
            vec /= np.linalg.norm(vec)
1✔
3670
            fgroup[0] = "X", fgroup[1].coords + float(bl) * vec
1✔
3671

3672
        # Align X to the origin.
3673
        x = fgroup[0]
1✔
3674
        fgroup.translate_sites(list(range(len(fgroup))), origin - x.coords)
1✔
3675

3676
        # Find angle between the attaching bond and the bond to be replaced.
3677
        v1 = fgroup[1].coords - origin
1✔
3678
        v2 = self[index].coords - origin
1✔
3679
        angle = get_angle(v1, v2)
1✔
3680

3681
        if 1 < abs(angle % 180) < 179:
1✔
3682
            # For angles which are not 0 or 180, we perform a rotation about
3683
            # the origin along an axis perpendicular to both bonds to align
3684
            # bonds.
3685
            axis = np.cross(v1, v2)
1✔
3686
            op = SymmOp.from_origin_axis_angle(origin, axis, angle)
1✔
3687
            fgroup.apply_operation(op)
1✔
3688
        elif abs(abs(angle) - 180) < 1:
1✔
3689
            # We have a 180 degree angle. Simply do an inversion about the
3690
            # origin
3691
            for i, fg in enumerate(fgroup):
×
3692
                fgroup[i] = (fg.species, origin - (fg.coords - origin))
×
3693

3694
        # Remove the atom to be replaced, and add the rest of the functional
3695
        # group.
3696
        del self[index]
1✔
3697
        for site in fgroup[1:]:
1✔
3698
            s_new = PeriodicSite(site.species, site.coords, self.lattice, coords_are_cartesian=True)
1✔
3699
            self._sites.append(s_new)
1✔
3700

3701
    def remove_species(self, species: Sequence[SpeciesLike]) -> None:
1✔
3702
        """
3703
        Remove all occurrences of several species from a structure.
3704

3705
        Args:
3706
            species: Sequence of species to remove, e.g., ["Li", "Na"].
3707
        """
3708
        new_sites = []
1✔
3709
        species = [get_el_sp(s) for s in species]
1✔
3710

3711
        for site in self._sites:
1✔
3712
            new_sp_occu = {sp: amt for sp, amt in site.species.items() if sp not in species}
1✔
3713
            if len(new_sp_occu) > 0:
1✔
3714
                new_sites.append(
1✔
3715
                    PeriodicSite(
3716
                        new_sp_occu,
3717
                        site.frac_coords,
3718
                        self._lattice,
3719
                        properties=site.properties,
3720
                    )
3721
                )
3722
        self._sites = new_sites
1✔
3723

3724
    def remove_sites(self, indices: Sequence[int]) -> None:
1✔
3725
        """
3726
        Delete sites with at indices.
3727

3728
        Args:
3729
            indices: Sequence of indices of sites to delete.
3730
        """
3731
        self._sites = [s for i, s in enumerate(self._sites) if i not in indices]
1✔
3732

3733
    def apply_operation(self, symmop: SymmOp, fractional: bool = False) -> Structure:
1✔
3734
        """
3735
        Apply a symmetry operation to the structure in place and return the modified
3736
        structure. The lattice is operated on by the rotation matrix only.
3737
        Coords are operated in full and then transformed to the new lattice.
3738

3739
        Args:
3740
            symmop (SymmOp): Symmetry operation to apply.
3741
            fractional (bool): Whether the symmetry operation is applied in
3742
                fractional space. Defaults to False, i.e., symmetry operation
3743
                is applied in Cartesian coordinates.
3744

3745
        Returns:
3746
            Structure: post-operation structure
3747
        """
3748
        if not fractional:
1✔
3749
            self._lattice = Lattice([symmop.apply_rotation_only(row) for row in self._lattice.matrix])
1✔
3750

3751
            def operate_site(site):
1✔
3752
                new_cart = symmop.operate(site.coords)
1✔
3753
                new_frac = self._lattice.get_fractional_coords(new_cart)
1✔
3754
                return PeriodicSite(
1✔
3755
                    site.species,
3756
                    new_frac,
3757
                    self._lattice,
3758
                    properties=site.properties,
3759
                    skip_checks=True,
3760
                )
3761

3762
        else:
3763
            new_latt = np.dot(symmop.rotation_matrix, self._lattice.matrix)
1✔
3764
            self._lattice = Lattice(new_latt)
1✔
3765

3766
            def operate_site(site):
1✔
3767
                return PeriodicSite(
1✔
3768
                    site.species,
3769
                    symmop.operate(site.frac_coords),
3770
                    self._lattice,
3771
                    properties=site.properties,
3772
                    skip_checks=True,
3773
                )
3774

3775
        self._sites = [operate_site(s) for s in self._sites]
1✔
3776

3777
        return self
1✔
3778

3779
    def apply_strain(self, strain: ArrayLike) -> None:
1✔
3780
        """
3781
        Apply a strain to the lattice.
3782

3783
        Args:
3784
            strain (float or list): Amount of strain to apply. Can be a float,
3785
                or a sequence of 3 numbers. E.g., 0.01 means all lattice
3786
                vectors are increased by 1%. This is equivalent to calling
3787
                modify_lattice with a lattice with lattice parameters that
3788
                are 1% larger.
3789
        """
3790
        s = (1 + np.array(strain)) * np.eye(3)
1✔
3791
        self.lattice = Lattice(np.dot(self._lattice.matrix.T, s).T)
1✔
3792

3793
    def sort(self, key: Callable | None = None, reverse: bool = False) -> None:
1✔
3794
        """
3795
        Sort a structure in place. The parameters have the same meaning as in
3796
        list.sort. By default, sites are sorted by the electronegativity of
3797
        the species. The difference between this method and
3798
        get_sorted_structure (which also works in IStructure) is that the
3799
        latter returns a new Structure, while this just sorts the Structure
3800
        in place.
3801

3802
        Args:
3803
            key: Specifies a function of one argument that is used to extract
3804
                a comparison key from each list element: key=str.lower. The
3805
                default value is None (compare the elements directly).
3806
            reverse (bool): If set to True, then the list elements are sorted
3807
                as if each comparison were reversed.
3808
        """
3809
        self._sites.sort(key=key, reverse=reverse)
1✔
3810

3811
    def translate_sites(
1✔
3812
        self, indices: int | Sequence[int], vector: ArrayLike, frac_coords: bool = True, to_unit_cell: bool = True
3813
    ) -> None:
3814
        """
3815
        Translate specific sites by some vector, keeping the sites within the
3816
        unit cell.
3817

3818
        Args:
3819
            indices: Integer or List of site indices on which to perform the
3820
                translation.
3821
            vector: Translation vector for sites.
3822
            frac_coords (bool): Whether the vector corresponds to fractional or
3823
                Cartesian coordinates.
3824
            to_unit_cell (bool): Whether new sites are transformed to unit
3825
                cell
3826
        """
3827
        if not isinstance(indices, collections.abc.Iterable):
1✔
3828
            indices = [indices]
1✔
3829

3830
        for i in indices:
1✔
3831
            site = self._sites[i]
1✔
3832
            if frac_coords:
1✔
3833
                fcoords = site.frac_coords + vector
1✔
3834
            else:
3835
                fcoords = self._lattice.get_fractional_coords(site.coords + vector)
1✔
3836
            if to_unit_cell:
1✔
3837
                fcoords = [np.mod(f, 1) if p else f for p, f in zip(self.lattice.pbc, fcoords)]
1✔
3838
            self._sites[i].frac_coords = fcoords
1✔
3839

3840
    def rotate_sites(
1✔
3841
        self,
3842
        indices: list[int] | None = None,
3843
        theta: float = 0.0,
3844
        axis: ArrayLike | None = None,
3845
        anchor: ArrayLike | None = None,
3846
        to_unit_cell: bool = True,
3847
    ) -> None:
3848
        """
3849
        Rotate specific sites by some angle around vector at anchor.
3850

3851
        Args:
3852
            indices (list): List of site indices on which to perform the
3853
                translation.
3854
            theta (float): Angle in radians
3855
            axis (3x1 array): Rotation axis vector.
3856
            anchor (3x1 array): Point of rotation.
3857
            to_unit_cell (bool): Whether new sites are transformed to unit
3858
                cell
3859
        """
3860
        from numpy import cross, eye
1✔
3861
        from numpy.linalg import norm
1✔
3862
        from scipy.linalg import expm
1✔
3863

3864
        if indices is None:
1✔
3865
            indices = list(range(len(self)))
×
3866

3867
        if axis is None:
1✔
3868
            axis = [0, 0, 1]
1✔
3869

3870
        if anchor is None:
1✔
3871
            anchor = [0, 0, 0]
×
3872

3873
        anchor = np.array(anchor)
1✔
3874
        axis = np.array(axis)
1✔
3875

3876
        theta %= 2 * np.pi
1✔
3877

3878
        rm = expm(cross(eye(3), axis / norm(axis)) * theta)
1✔
3879
        for i in indices:
1✔
3880
            site = self._sites[i]
1✔
3881
            coords = ((np.dot(rm, np.array(site.coords - anchor).T)).T + anchor).ravel()
1✔
3882
            new_site = PeriodicSite(
1✔
3883
                site.species,
3884
                coords,
3885
                self._lattice,
3886
                to_unit_cell=to_unit_cell,
3887
                coords_are_cartesian=True,
3888
                properties=site.properties,
3889
                skip_checks=True,
3890
            )
3891
            self._sites[i] = new_site
1✔
3892

3893
    def perturb(self, distance: float, min_distance: float | None = None) -> None:
1✔
3894
        """
3895
        Performs a random perturbation of the sites in a structure to break
3896
        symmetries.
3897

3898
        Args:
3899
            distance (float): Distance in angstroms by which to perturb each
3900
                site.
3901
            min_distance (None, int, or float): if None, all displacements will
3902
                be equal amplitude. If int or float, perturb each site a
3903
                distance drawn from the uniform distribution between
3904
                'min_distance' and 'distance'.
3905
        """
3906

3907
        def get_rand_vec():
1✔
3908
            # deals with zero vectors.
3909
            vector = np.random.randn(3)
1✔
3910
            vnorm = np.linalg.norm(vector)
1✔
3911
            dist = distance
1✔
3912
            if isinstance(min_distance, (float, int)):
1✔
3913
                dist = np.random.uniform(min_distance, dist)
1✔
3914
            return vector / vnorm * dist if vnorm != 0 else get_rand_vec()
1✔
3915

3916
        for i in range(len(self._sites)):
1✔
3917
            self.translate_sites([i], get_rand_vec(), frac_coords=False)
1✔
3918

3919
    def make_supercell(self, scaling_matrix: ArrayLike, to_unit_cell: bool = True) -> None:
1✔
3920
        """
3921
        Create a supercell.
3922

3923
        Args:
3924
            scaling_matrix: A scaling matrix for transforming the lattice
3925
                vectors. Has to be all integers. Several options are possible:
3926

3927
                a. A full 3x3 scaling matrix defining the linear combination
3928
                   the old lattice vectors. E.g., [[2,1,0],[0,3,0],[0,0,
3929
                   1]] generates a new structure with lattice vectors a' =
3930
                   2a + b, b' = 3b, c' = c where a, b, and c are the lattice
3931
                   vectors of the original structure.
3932
                b. An sequence of three scaling factors. E.g., [2, 1, 1]
3933
                   specifies that the supercell should have dimensions 2a x b x
3934
                   c.
3935
                c. A number, which simply scales all lattice vectors by the
3936
                   same factor.
3937
            to_unit_cell: Whether or not to fall back sites into the unit cell
3938
        """
3939
        s = self * scaling_matrix
1✔
3940
        if to_unit_cell:
1✔
3941
            for site in s:
1✔
3942
                site.to_unit_cell(in_place=True)
1✔
3943
        self._sites = s.sites
1✔
3944
        self._lattice = s.lattice
1✔
3945

3946
    def scale_lattice(self, volume: float) -> None:
1✔
3947
        """
3948
        Performs a scaling of the lattice vectors so that length proportions
3949
        and angles are preserved.
3950

3951
        Args:
3952
            volume (float): New volume of the unit cell in A^3.
3953
        """
3954
        self.lattice = self._lattice.scale(volume)
1✔
3955

3956
    def merge_sites(self, tol: float = 0.01, mode: Literal["sum", "delete", "average"] = "sum") -> None:
1✔
3957
        """
3958
        Merges sites (adding occupancies) within tol of each other.
3959
        Removes site properties.
3960

3961
        Args:
3962
            tol (float): Tolerance for distance to merge sites.
3963
            mode ('sum' | 'delete' | 'average'): "delete" means duplicate sites are
3964
                deleted. "sum" means the occupancies are summed for the sites.
3965
                "average" means that the site is deleted but the properties are averaged
3966
                Only first letter is considered.
3967
        """
3968
        from scipy.cluster.hierarchy import fcluster, linkage
1✔
3969
        from scipy.spatial.distance import squareform
1✔
3970

3971
        d = self.distance_matrix
1✔
3972
        np.fill_diagonal(d, 0)
1✔
3973
        clusters = fcluster(linkage(squareform((d + d.T) / 2)), tol, "distance")
1✔
3974
        sites = []
1✔
3975
        for c in np.unique(clusters):
1✔
3976
            inds = np.where(clusters == c)[0]
1✔
3977
            species = self[inds[0]].species
1✔
3978
            coords = self[inds[0]].frac_coords
1✔
3979
            props = self[inds[0]].properties
1✔
3980
            for n, i in enumerate(inds[1:]):
1✔
3981
                sp = self[i].species
1✔
3982
                if mode.lower()[0] == "s":
1✔
3983
                    species += sp
1✔
3984
                offset = self[i].frac_coords - coords
1✔
3985
                coords = coords + ((offset - np.round(offset)) / (n + 2)).astype(coords.dtype)
1✔
3986
                for key in props:
1✔
3987
                    if props[key] is not None and self[i].properties[key] != props[key]:
1✔
3988
                        if mode.lower()[0] == "a" and isinstance(props[key], float):
1✔
3989
                            # update a running total
3990
                            props[key] = props[key] * (n + 1) / (n + 2) + self[i].properties[key] / (n + 2)
1✔
3991
                        else:
3992
                            props[key] = None
1✔
3993
                            warnings.warn(
1✔
3994
                                f"Sites with different site property {key} are merged. So property is set to none"
3995
                            )
3996
            sites.append(PeriodicSite(species, coords, self.lattice, properties=props))
1✔
3997

3998
        self._sites = sites
1✔
3999

4000
    def set_charge(self, new_charge: float = 0.0) -> None:
1✔
4001
        """
4002
        Sets the overall structure charge
4003

4004
        Args:
4005
            new_charge (float): new charge to set
4006
        """
4007
        self._charge = new_charge
1✔
4008

4009
    def relax(
1✔
4010
        self,
4011
        calculator: str = "m3gnet",
4012
        relax_cell: bool = True,
4013
        stress_weight: float = 0.01,
4014
        steps: int = 500,
4015
        fmax: float = 0.1,
4016
        verbose: bool = False,
4017
    ) -> Structure:
4018
        """
4019
        Performs a crystal structure relaxation using some algorithm.
4020

4021
        Args:
4022
            calculator: A string or an ASE calculator. Defaults to 'm3gnet', i.e. the M3GNet universal potential.
4023
            relax_cell (bool): whether to relax the lattice cell. Defaults to True.
4024
            stress_weight (float): the stress weight for relaxation. Defaults to 0.01.
4025
            steps (int): max number of steps for relaxation. Defaults to 500.
4026
            fmax (float): total force tolerance for relaxation convergence.
4027
                Here fmax is a sum of force and stress forces. Defaults to 0.1.
4028
            verbose (bool): whether to print out relaxation steps. Defaults to False.
4029

4030
        Returns: Relaxed structure
4031
        """
4032
        import contextlib
1✔
4033
        import io
1✔
4034
        import sys
1✔
4035

4036
        from ase.constraints import ExpCellFilter
1✔
4037
        from ase.optimize.fire import FIRE
1✔
4038
        from m3gnet.models import M3GNet, M3GNetCalculator, Potential
1✔
4039

4040
        from pymatgen.io.ase import AseAtomsAdaptor
1✔
4041

4042
        if calculator == "m3gnet":
1✔
4043
            potential = Potential(M3GNet.load())
1✔
4044
            calculator = M3GNetCalculator(potential=potential, stress_weight=stress_weight)
1✔
4045

4046
        optimizer = FIRE
1✔
4047
        adaptor = AseAtomsAdaptor()
1✔
4048
        atoms = adaptor.get_atoms(self)
1✔
4049
        atoms.set_calculator(calculator)
1✔
4050
        stream = sys.stdout if verbose else io.StringIO()
1✔
4051
        with contextlib.redirect_stdout(stream):
1✔
4052
            if relax_cell:
1✔
4053
                atoms = ExpCellFilter(atoms)
1✔
4054
            optimizer = optimizer(atoms)
1✔
4055
            optimizer.run(fmax=fmax, steps=steps)
1✔
4056
        if isinstance(atoms, ExpCellFilter):
1✔
4057
            atoms = atoms.atoms
1✔
4058

4059
        return adaptor.get_structure(atoms)
1✔
4060

4061
    @classmethod
1✔
4062
    def from_prototype(cls, prototype: str, species: Sequence, **kwargs) -> Structure:
1✔
4063
        """
4064
        Method to rapidly construct common prototype structures.
4065

4066
        Args:
4067
            prototype: Name of prototype. E.g., cubic, rocksalt, perovksite etc.
4068
            species: List of species corresponding to symmetrically distinct sites.
4069
            **kwargs: Lattice parameters, e.g., a = 3.0, b = 4, c = 5. Only the required lattice parameters need to be
4070
                specified. For example, if it is a cubic prototype, only a needs to be specified.
4071

4072
        Returns:
4073
            Structure
4074
        """
4075
        prototype = prototype.lower()
1✔
4076
        try:
1✔
4077
            if prototype == "fcc":
1✔
4078
                return Structure.from_spacegroup("Fm-3m", Lattice.cubic(kwargs["a"]), species, [[0, 0, 0]])
1✔
4079
            if prototype == "bcc":
1✔
4080
                return Structure.from_spacegroup("Im-3m", Lattice.cubic(kwargs["a"]), species, [[0, 0, 0]])
1✔
4081
            if prototype == "hcp":
1✔
4082
                return Structure.from_spacegroup(
1✔
4083
                    "P6_3/mmc", Lattice.hexagonal(kwargs["a"], kwargs["c"]), species, [[1 / 3, 2 / 3, 1 / 4]]
4084
                )
4085
            if prototype == "diamond":
1✔
4086
                return Structure.from_spacegroup("Fd-3m", Lattice.cubic(kwargs["a"]), species, [[0, 0, 0]])
1✔
4087
            if prototype == "rocksalt":
1✔
4088
                return Structure.from_spacegroup(
1✔
4089
                    "Fm-3m", Lattice.cubic(kwargs["a"]), species, [[0, 0, 0], [0.5, 0.5, 0]]
4090
                )
4091
            if prototype == "perovskite":
1✔
4092
                return Structure.from_spacegroup(
×
4093
                    "Pm-3m", Lattice.cubic(kwargs["a"]), species, [[0, 0, 0], [0.5, 0.5, 0.5], [0.5, 0.5, 0]]
4094
                )
4095
            if prototype in ("cscl"):
1✔
4096
                return Structure.from_spacegroup(
1✔
4097
                    "Pm-3m", Lattice.cubic(kwargs["a"]), species, [[0, 0, 0], [0.5, 0.5, 0.5]]
4098
                )
4099
            if prototype in ("fluorite", "caf2"):
1✔
4100
                return Structure.from_spacegroup(
1✔
4101
                    "Fm-3m", Lattice.cubic(kwargs["a"]), species, [[0, 0, 0], [1 / 4, 1 / 4, 1 / 4]]
4102
                )
4103
            if prototype in ("antifluorite"):
1✔
4104
                return Structure.from_spacegroup(
1✔
4105
                    "Fm-3m", Lattice.cubic(kwargs["a"]), species, [[1 / 4, 1 / 4, 1 / 4], [0, 0, 0]]
4106
                )
4107
            if prototype in ("zincblende"):
1✔
4108
                return Structure.from_spacegroup(
1✔
4109
                    "F-43m", Lattice.cubic(kwargs["a"]), species, [[0, 0, 0], [1 / 4, 1 / 4, 3 / 4]]
4110
                )
4111
        except KeyError as ex:
1✔
4112
            raise ValueError(f"Required parameter {ex} not specified as a kwargs!")
1✔
4113
        raise ValueError(f"Unsupported prototype {prototype}!")
×
4114

4115

4116
class Molecule(IMolecule, collections.abc.MutableSequence):
1✔
4117
    """
4118
    Mutable Molecule. It has all the methods in IMolecule, but in addition,
4119
    it allows a user to perform edits on the molecule.
4120
    """
4121

4122
    __hash__ = None  # type: ignore
1✔
4123

4124
    def __init__(
1✔
4125
        self,
4126
        species: Sequence[SpeciesLike],
4127
        coords: Sequence[ArrayLike],
4128
        charge: float = 0.0,
4129
        spin_multiplicity: int | None = None,
4130
        validate_proximity: bool = False,
4131
        site_properties: dict | None = None,
4132
        charge_spin_check: bool = True,
4133
    ) -> None:
4134
        """
4135
        Creates a MutableMolecule.
4136

4137
        Args:
4138
            species: list of atomic species. Possible kinds of input include a
4139
                list of dict of elements/species and occupancies, a List of
4140
                elements/specie specified as actual Element/Species, Strings
4141
                ("Fe", "Fe2+") or atomic numbers (1,56).
4142
            coords (3x1 array): list of Cartesian coordinates of each species.
4143
            charge (float): Charge for the molecule. Defaults to 0.
4144
            spin_multiplicity (int): Spin multiplicity for molecule.
4145
                Defaults to None, which means that the spin multiplicity is
4146
                set to 1 if the molecule has no unpaired electrons and to 2
4147
                if there are unpaired electrons.
4148
            validate_proximity (bool): Whether to check if there are sites
4149
                that are less than 1 Ang apart. Defaults to False.
4150
            site_properties (dict): Properties associated with the sites as
4151
                a dict of sequences, e.g., {"magmom":[5,5,5,5]}. The
4152
                sequences have to be the same length as the atomic species
4153
                and fractional_coords. Defaults to None for no properties.
4154
            charge_spin_check (bool): Whether to check that the charge and
4155
                spin multiplicity are compatible with each other. Defaults
4156
                to True.
4157
        """
4158
        super().__init__(
1✔
4159
            species,
4160
            coords,
4161
            charge=charge,
4162
            spin_multiplicity=spin_multiplicity,
4163
            validate_proximity=validate_proximity,
4164
            site_properties=site_properties,
4165
            charge_spin_check=charge_spin_check,
4166
        )
4167
        self._sites: list[Site] = list(self._sites)  # type: ignore
1✔
4168

4169
    def __setitem__(  # type: ignore
1✔
4170
        self, idx: int | slice | Sequence[int] | SpeciesLike, site: SpeciesLike | Site | Sequence
4171
    ) -> None:
4172
        """
4173
        Modify a site in the molecule.
4174

4175
        Args:
4176
            idx (int, [int], slice, Species-like): Indices to change. You can
4177
                specify these as an int, a list of int, or a species-like
4178
                string.
4179
            site (PeriodicSite/Species/Sequence): Three options exist. You can
4180
                provide a Site directly, or for convenience, you can provide
4181
                simply a Species-like string/object, or finally a (Species,
4182
                coords) sequence, e.g., ("Fe", [0.5, 0.5, 0.5]).
4183
        """
4184
        if isinstance(idx, int):
1✔
4185
            indices = [idx]
1✔
4186
        elif isinstance(idx, (str, Element, Species)):
1✔
4187
            self.replace_species({idx: site})  # type: ignore
1✔
4188
            return
1✔
4189
        elif isinstance(idx, slice):
1✔
4190
            to_mod = self[idx]
1✔
4191
            indices = [ii for ii, s in enumerate(self._sites) if s in to_mod]
1✔
4192
        else:
4193
            indices = list(idx)
1✔
4194

4195
        for ii in indices:
1✔
4196
            if isinstance(site, Site):
1✔
4197
                self._sites[ii] = site
1✔
4198
            else:
4199
                if isinstance(site, str) or not isinstance(site, collections.abc.Sequence):
1✔
4200
                    self._sites[ii].species = site  # type: ignore
1✔
4201
                else:
4202
                    self._sites[ii].species = site[0]  # type: ignore
1✔
4203
                    if len(site) > 1:
1✔
4204
                        self._sites[ii].coords = site[1]  # type: ignore
1✔
4205
                    if len(site) > 2:
1✔
4206
                        self._sites[ii].properties = site[2]  # type: ignore
1✔
4207

4208
    def __delitem__(self, idx: SupportsIndex | slice) -> None:
1✔
4209
        """Deletes a site from the Structure."""
4210
        self._sites.__delitem__(idx)
1✔
4211

4212
    def append(  # type: ignore
1✔
4213
        self,
4214
        species: CompositionLike,
4215
        coords: ArrayLike,
4216
        validate_proximity: bool = False,
4217
        properties: dict | None = None,
4218
    ) -> Molecule:
4219
        """
4220
        Appends a site to the molecule.
4221

4222
        Args:
4223
            species: Species of inserted site
4224
            coords: Coordinates of inserted site
4225
            validate_proximity (bool): Whether to check if inserted site is
4226
                too close to an existing site. Defaults to False.
4227
            properties (dict): A dict of properties for the Site.
4228

4229
        Returns:
4230
            New molecule with inserted site.
4231
        """
4232
        return self.insert(
1✔
4233
            len(self),
4234
            species,
4235
            coords,
4236
            validate_proximity=validate_proximity,
4237
            properties=properties,
4238
        )
4239

4240
    def set_charge_and_spin(self, charge: float, spin_multiplicity: int | None = None):
1✔
4241
        """
4242
        Set the charge and spin multiplicity.
4243

4244
        Args:
4245
            charge (int): Charge for the molecule. Defaults to 0.
4246
            spin_multiplicity (int): Spin multiplicity for molecule.
4247
                Defaults to None, which means that the spin multiplicity is
4248
                set to 1 if the molecule has no unpaired electrons and to 2
4249
                if there are unpaired electrons.
4250
        """
4251
        self._charge = charge
1✔
4252
        nelectrons = 0.0
1✔
4253
        for site in self._sites:
1✔
4254
            for sp, amt in site.species.items():
1✔
4255
                if not isinstance(sp, DummySpecies):
1✔
4256
                    nelectrons += sp.Z * amt
1✔
4257
        nelectrons -= charge
1✔
4258
        self._nelectrons = nelectrons
1✔
4259
        if spin_multiplicity:
1✔
4260
            if self._charge_spin_check and (nelectrons + spin_multiplicity) % 2 != 1:
1✔
4261
                raise ValueError(
1✔
4262
                    f"Charge of {self._charge} and spin multiplicity of {spin_multiplicity} is"
4263
                    " not possible for this molecule"
4264
                )
4265
            self._spin_multiplicity = spin_multiplicity
1✔
4266
        else:
4267
            self._spin_multiplicity = 1 if nelectrons % 2 == 0 else 2
1✔
4268

4269
    def insert(  # type: ignore
1✔
4270
        self,
4271
        i: int,
4272
        species: CompositionLike,
4273
        coords: ArrayLike,
4274
        validate_proximity: bool = False,
4275
        properties: dict | None = None,
4276
    ) -> Molecule:
4277
        """
4278
        Insert a site to the molecule.
4279

4280
        Args:
4281
            i (int): Index to insert site
4282
            species: species of inserted site
4283
            coords (3x1 array): coordinates of inserted site
4284
            validate_proximity (bool): Whether to check if inserted site is
4285
                too close to an existing site. Defaults to True.
4286
            properties (dict): Dict of properties for the Site.
4287

4288
        Returns:
4289
            New molecule with inserted site.
4290
        """
4291
        new_site = Site(species, coords, properties=properties)
1✔
4292
        if validate_proximity:
1✔
4293
            for site in self:
×
4294
                if site.distance(new_site) < self.DISTANCE_TOLERANCE:
×
4295
                    raise ValueError("New site is too close to an existing site!")
×
4296
        self._sites.insert(i, new_site)
1✔
4297

4298
        return self
1✔
4299

4300
    def remove_species(self, species: Sequence[SpeciesLike]):
1✔
4301
        """
4302
        Remove all occurrences of a species from a molecule.
4303

4304
        Args:
4305
            species: Species to remove.
4306
        """
4307
        new_sites = []
1✔
4308
        species = [get_el_sp(sp) for sp in species]
1✔
4309
        for site in self._sites:
1✔
4310
            new_sp_occu = {sp: amt for sp, amt in site.species.items() if sp not in species}
1✔
4311
            if len(new_sp_occu) > 0:
1✔
4312
                new_sites.append(Site(new_sp_occu, site.coords, properties=site.properties))
1✔
4313
        self._sites = new_sites
1✔
4314

4315
    def remove_sites(self, indices: Sequence[int]):
1✔
4316
        """
4317
        Delete sites with at indices.
4318

4319
        Args:
4320
            indices: Sequence of indices of sites to delete.
4321
        """
4322
        self._sites = [self._sites[i] for i in range(len(self._sites)) if i not in indices]
1✔
4323

4324
    def translate_sites(self, indices: Sequence[int] | None = None, vector: ArrayLike | None = None):
1✔
4325
        """
4326
        Translate specific sites by some vector, keeping the sites within the
4327
        unit cell.
4328

4329
        Args:
4330
            indices (list): List of site indices on which to perform the
4331
                translation.
4332
            vector (3x1 array): Translation vector for sites.
4333
        """
4334
        if indices is None:
1✔
4335
            indices = range(len(self))
1✔
4336
        if vector is None:
1✔
4337
            vector = [0, 0, 0]
×
4338
        for i in indices:
1✔
4339
            site = self._sites[i]
1✔
4340
            new_site = Site(site.species, site.coords + vector, properties=site.properties)
1✔
4341
            self._sites[i] = new_site
1✔
4342

4343
    def rotate_sites(
1✔
4344
        self,
4345
        indices: Sequence[int] | None = None,
4346
        theta: float = 0.0,
4347
        axis: ArrayLike | None = None,
4348
        anchor: ArrayLike | None = None,
4349
    ):
4350
        """
4351
        Rotate specific sites by some angle around vector at anchor.
4352

4353
        Args:
4354
            indices (list): List of site indices on which to perform the
4355
                translation.
4356
            theta (float): Angle in radians
4357
            axis (3x1 array): Rotation axis vector.
4358
            anchor (3x1 array): Point of rotation.
4359
        """
4360
        from numpy import cross, eye
1✔
4361
        from numpy.linalg import norm
1✔
4362
        from scipy.linalg import expm
1✔
4363

4364
        if indices is None:
1✔
4365
            indices = range(len(self))
1✔
4366

4367
        if axis is None:
1✔
4368
            axis = [0, 0, 1]
1✔
4369

4370
        if anchor is None:
1✔
4371
            anchor = [0, 0, 0]
1✔
4372

4373
        anchor = np.array(anchor)
1✔
4374
        axis = np.array(axis)
1✔
4375

4376
        theta %= 2 * np.pi
1✔
4377

4378
        rm = expm(cross(eye(3), axis / norm(axis)) * theta)
1✔
4379

4380
        for i in indices:
1✔
4381
            site = self._sites[i]
1✔
4382
            s = ((np.dot(rm, (site.coords - anchor).T)).T + anchor).ravel()
1✔
4383
            new_site = Site(site.species, s, properties=site.properties)
1✔
4384
            self._sites[i] = new_site
1✔
4385

4386
    def perturb(self, distance: float):
1✔
4387
        """
4388
        Performs a random perturbation of the sites in a structure to break
4389
        symmetries.
4390

4391
        Args:
4392
            distance (float): Distance in angstroms by which to perturb each
4393
                site.
4394
        """
4395

4396
        def get_rand_vec():
1✔
4397
            # deals with zero vectors.
4398
            vector = np.random.randn(3)
1✔
4399
            vnorm = np.linalg.norm(vector)
1✔
4400
            return vector / vnorm * distance if vnorm != 0 else get_rand_vec()
1✔
4401

4402
        for i in range(len(self._sites)):
1✔
4403
            self.translate_sites([i], get_rand_vec())
1✔
4404

4405
    def apply_operation(self, symmop: SymmOp):
1✔
4406
        """
4407
        Apply a symmetry operation to the molecule.
4408

4409
        Args:
4410
            symmop (SymmOp): Symmetry operation to apply.
4411
        """
4412

4413
        def operate_site(site):
1✔
4414
            new_cart = symmop.operate(site.coords)
1✔
4415
            return Site(site.species, new_cart, properties=site.properties)
1✔
4416

4417
        self._sites = [operate_site(s) for s in self._sites]
1✔
4418

4419
    def copy(self):
1✔
4420
        """
4421
        Convenience method to get a copy of the molecule.
4422

4423
        Returns:
4424
            A copy of the Molecule.
4425
        """
4426
        return type(self).from_sites(self)
1✔
4427

4428
    def substitute(self, index: int, func_group: IMolecule | Molecule | str, bond_order: int = 1):
1✔
4429
        """
4430
        Substitute atom at index with a functional group.
4431

4432
        Args:
4433
            index (int): Index of atom to substitute.
4434
            func_group: Substituent molecule. There are two options:
4435

4436
                1. Providing an actual molecule as the input. The first atom
4437
                   must be a DummySpecies X, indicating the position of
4438
                   nearest neighbor. The second atom must be the next
4439
                   nearest atom. For example, for a methyl group
4440
                   substitution, func_group should be X-CH3, where X is the
4441
                   first site and C is the second site. What the code will
4442
                   do is to remove the index site, and connect the nearest
4443
                   neighbor to the C atom in CH3. The X-C bond indicates the
4444
                   directionality to connect the atoms.
4445
                2. A string name. The molecule will be obtained from the
4446
                   relevant template in func_groups.json.
4447
            bond_order (int): A specified bond order to calculate the bond
4448
                length between the attached functional group and the nearest
4449
                neighbor site. Defaults to 1.
4450
        """
4451
        # Find the nearest neighbor that is not a terminal atom.
4452
        all_non_terminal_nn = []
1✔
4453
        for nn in self.get_neighbors(self[index], 3):
1✔
4454
            # Check that the nn has neighbors within a sensible distance but
4455
            # is not the site being substituted.
4456
            for nn2 in self.get_neighbors(nn, 3):
1✔
4457
                if nn2 != self[index] and nn2.nn_distance < 1.2 * get_bond_length(nn.specie, nn2.specie):
1✔
4458
                    all_non_terminal_nn.append(nn)
1✔
4459
                    break
1✔
4460

4461
        if len(all_non_terminal_nn) == 0:
1✔
4462
            raise RuntimeError("Can't find a non-terminal neighbor to attach functional group to.")
×
4463

4464
        non_terminal_nn = min(all_non_terminal_nn, key=lambda nn: nn.nn_distance)
1✔
4465

4466
        # Set the origin point to be the coordinates of the nearest
4467
        # non-terminal neighbor.
4468
        origin = non_terminal_nn.coords
1✔
4469

4470
        # Pass value of functional group--either from user-defined or from
4471
        # functional.json
4472
        if isinstance(func_group, Molecule):
1✔
4473
            func_grp = func_group
1✔
4474
        else:
4475
            # Check to see whether the functional group is in database.
4476
            if func_group not in FunctionalGroups:
1✔
4477
                raise RuntimeError("Can't find functional group in list. Provide explicit coordinate instead")
×
4478
            func_grp = FunctionalGroups[func_group]
1✔
4479

4480
        # If a bond length can be found, modify func_grp so that the X-group
4481
        # bond length is equal to the bond length.
4482
        bl = get_bond_length(non_terminal_nn.specie, func_grp[1].specie, bond_order=bond_order)
1✔
4483
        if bl is not None:
1✔
4484
            func_grp = func_grp.copy()
1✔
4485
            vec = func_grp[0].coords - func_grp[1].coords
1✔
4486
            vec /= np.linalg.norm(vec)
1✔
4487
            func_grp[0] = "X", func_grp[1].coords + float(bl) * vec
1✔
4488

4489
        # Align X to the origin.
4490
        x = func_grp[0]
1✔
4491
        func_grp.translate_sites(list(range(len(func_grp))), origin - x.coords)
1✔
4492

4493
        # Find angle between the attaching bond and the bond to be replaced.
4494
        v1 = func_grp[1].coords - origin
1✔
4495
        v2 = self[index].coords - origin
1✔
4496
        angle = get_angle(v1, v2)
1✔
4497

4498
        if 1 < abs(angle % 180) < 179:
1✔
4499
            # For angles which are not 0 or 180, we perform a rotation about
4500
            # the origin along an axis perpendicular to both bonds to align
4501
            # bonds.
4502
            axis = np.cross(v1, v2)
1✔
4503
            op = SymmOp.from_origin_axis_angle(origin, axis, angle)
1✔
4504
            func_grp.apply_operation(op)
1✔
4505
        elif abs(abs(angle) - 180) < 1:
1✔
4506
            # We have a 180 degree angle. Simply do an inversion about the
4507
            # origin
4508
            for i, fg in enumerate(func_grp):
1✔
4509
                func_grp[i] = (fg.species, origin - (fg.coords - origin))
1✔
4510

4511
        # Remove the atom to be replaced, and add the rest of the functional
4512
        # group.
4513
        del self[index]
1✔
4514
        for site in func_grp[1:]:
1✔
4515
            self._sites.append(site)
1✔
4516

4517

4518
class StructureError(Exception):
1✔
4519
    """
4520
    Exception class for Structure.
4521
    Raised when the structure has problems, e.g., atoms that are too close.
4522
    """
4523

4524

4525
with open(os.path.join(os.path.dirname(__file__), "func_groups.json")) as f:
1✔
4526
    FunctionalGroups = {k: Molecule(v["species"], v["coords"]) for k, v in json.load(f).items()}
1✔
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

© 2025 Coveralls, Inc