• 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

88.99
/pymatgen/analysis/local_env.py
1
# Copyright (c) Pymatgen Development Team.
2
# Distributed under the terms of the MIT License.
3

4
"""
1✔
5
This module provides classes to perform analyses of
6
the local environments (e.g., finding near neighbors)
7
of single sites in molecules and structures.
8
"""
9

10
from __future__ import annotations
1✔
11

12
import json
1✔
13
import math
1✔
14
import os
1✔
15
import warnings
1✔
16
from bisect import bisect_left
1✔
17
from collections import defaultdict, namedtuple
1✔
18
from copy import deepcopy
1✔
19
from functools import lru_cache
1✔
20
from math import acos, asin, atan2, cos, exp, fabs, pi, pow, sin, sqrt
1✔
21
from typing import Any, Literal, get_args
1✔
22

23
import numpy as np
1✔
24
from monty.dev import requires
1✔
25
from monty.serialization import loadfn
1✔
26
from ruamel.yaml import YAML
1✔
27
from scipy.spatial import Voronoi
1✔
28

29
from pymatgen.analysis.bond_valence import BV_PARAMS, BVAnalyzer
1✔
30
from pymatgen.analysis.graphs import MoleculeGraph, StructureGraph
1✔
31
from pymatgen.analysis.molecule_structure_comparator import CovalentRadius
1✔
32
from pymatgen.core.composition import SpeciesLike
1✔
33
from pymatgen.core.periodic_table import Element, Species
1✔
34
from pymatgen.core.sites import PeriodicSite, Site
1✔
35
from pymatgen.core.structure import IStructure, PeriodicNeighbor, Structure
1✔
36

37
try:
1✔
38
    from openbabel import openbabel
1✔
39
except Exception:
1✔
40
    openbabel = None
1✔
41

42
__author__ = "Shyue Ping Ong, Geoffroy Hautier, Sai Jayaraman, "
1✔
43
__author__ += "Nils E. R. Zimmermann, Bharat Medasani, Evan Spotte-Smith"
1✔
44
__copyright__ = "Copyright 2011, The Materials Project"
1✔
45
__version__ = "1.0"
1✔
46
__maintainer__ = "Nils E. R. Zimmermann"
1✔
47
__email__ = "nils.e.r.zimmermann@gmail.com"
1✔
48
__status__ = "Production"
1✔
49
__date__ = "August 17, 2017"
1✔
50

51
_directory = os.path.join(os.path.dirname(__file__))
1✔
52
yaml = YAML()
1✔
53

54
with open(os.path.join(_directory, "op_params.yaml")) as f:
1✔
55
    default_op_params = yaml.load(f)
1✔
56

57
with open(os.path.join(_directory, "cn_opt_params.yaml")) as f:
1✔
58
    cn_opt_params = yaml.load(f)
1✔
59

60
with open(os.path.join(_directory, "ionic_radii.json")) as fp:
1✔
61
    _ion_radii = json.load(fp)
1✔
62

63

64
class ValenceIonicRadiusEvaluator:
1✔
65
    """
66
    Computes site valences and ionic radii for a structure using bond valence
67
    analyzer
68
    """
69

70
    def __init__(self, structure):
1✔
71
        """
72
        Args:
73
            structure: pymatgen.core.structure.Structure
74
        """
75
        self._structure = structure.copy()
1✔
76
        self._valences = self._get_valences()
1✔
77
        self._ionic_radii = self._get_ionic_radii()
1✔
78

79
    @property
1✔
80
    def radii(self):
1✔
81
        """
82
        List of ionic radii of elements in the order of sites.
83
        """
84
        el = [site.species_string for site in self._structure.sites]
1✔
85
        radii_dict = dict(zip(el, self._ionic_radii))
1✔
86
        # print radii_dict
87
        return radii_dict
1✔
88

89
    @property
1✔
90
    def valences(self):
1✔
91
        """
92
        List of oxidation states of elements in the order of sites.
93
        """
94
        el = [site.species_string for site in self._structure.sites]
1✔
95
        valence_dict = dict(zip(el, self._valences))
1✔
96
        return valence_dict
1✔
97

98
    @property
1✔
99
    def structure(self):
1✔
100
        """
101
        Returns oxidation state decorated structure.
102
        """
103
        return self._structure.copy()
1✔
104

105
    def _get_ionic_radii(self):
1✔
106
        """
107
        Computes ionic radii of elements for all sites in the structure.
108
        If valence is zero, atomic radius is used.
109
        """
110
        radii = []
1✔
111
        vnn = VoronoiNN()
1✔
112

113
        def nearest_key(sorted_vals, skey):
1✔
114
            n = bisect_left(sorted_vals, skey)
1✔
115
            if n == len(sorted_vals):
1✔
116
                return sorted_vals[-1]
1✔
117
            if n == 0:
1✔
118
                return sorted_vals[0]
1✔
119
            before = sorted_vals[n - 1]
1✔
120
            after = sorted_vals[n]
1✔
121
            if after - skey < skey - before:
1✔
122
                return after
1✔
123
            return before
×
124

125
        for i, site in enumerate(self._structure.sites):
1✔
126
            if isinstance(site.specie, Element):
1✔
127
                radius = site.specie.atomic_radius
×
128
                # Handle elements with no atomic_radius
129
                # by using calculated values instead.
130
                if radius is None:
×
131
                    radius = site.specie.atomic_radius_calculated
×
132
                if radius is None:
×
133
                    raise ValueError(f"cannot assign radius to element {site.specie}")
×
134
                radii.append(radius)
×
135
                continue
×
136

137
            el = site.specie.symbol
1✔
138
            oxi_state = int(round(site.specie.oxi_state))
1✔
139
            coord_no = int(round(vnn.get_cn(self._structure, i)))
1✔
140
            try:
1✔
141
                tab_oxi_states = sorted(map(int, _ion_radii[el]))
1✔
142
                oxi_state = nearest_key(tab_oxi_states, oxi_state)
1✔
143
                radius = _ion_radii[el][str(oxi_state)][str(coord_no)]
1✔
144
            except KeyError:
1✔
145
                if vnn.get_cn(self._structure, i) - coord_no > 0:
1✔
146
                    new_coord_no = coord_no + 1
×
147
                else:
148
                    new_coord_no = coord_no - 1
1✔
149
                try:
1✔
150
                    radius = _ion_radii[el][str(oxi_state)][str(new_coord_no)]
1✔
151
                    coord_no = new_coord_no
×
152
                except Exception:
1✔
153
                    tab_coords = sorted(map(int, _ion_radii[el][str(oxi_state)]))
1✔
154
                    new_coord_no = nearest_key(tab_coords, coord_no)
1✔
155
                    i = 0
1✔
156
                    for val in tab_coords:
1✔
157
                        if val > coord_no:
1✔
158
                            break
×
159
                        i = i + 1
1✔
160
                    if i == len(tab_coords):
1✔
161
                        key = str(tab_coords[-1])
1✔
162
                        radius = _ion_radii[el][str(oxi_state)][key]
1✔
163
                    elif i == 0:
×
164
                        key = str(tab_coords[0])
×
165
                        radius = _ion_radii[el][str(oxi_state)][key]
×
166
                    else:
167
                        key = str(tab_coords[i - 1])
×
168
                        radius1 = _ion_radii[el][str(oxi_state)][key]
×
169
                        key = str(tab_coords[i])
×
170
                        radius2 = _ion_radii[el][str(oxi_state)][key]
×
171
                        radius = (radius1 + radius2) / 2
×
172

173
            # implement complex checks later
174
            radii.append(radius)
1✔
175
        return radii
1✔
176

177
    def _get_valences(self):
1✔
178
        """
179
        Computes ionic valences of elements for all sites in the structure.
180
        """
181
        try:
1✔
182
            bv = BVAnalyzer()
1✔
183
            self._structure = bv.get_oxi_state_decorated_structure(self._structure)
1✔
184
            valences = bv.get_valences(self._structure)
1✔
185
        except Exception:
1✔
186
            try:
1✔
187
                bv = BVAnalyzer(symm_tol=0.0)
1✔
188
                self._structure = bv.get_oxi_state_decorated_structure(self._structure)
1✔
189
                valences = bv.get_valences(self._structure)
×
190
            except Exception:
1✔
191
                valences = []
1✔
192
                for site in self._structure.sites:
1✔
193
                    if len(site.specie.common_oxidation_states) > 0:
1✔
194
                        valences.append(site.specie.common_oxidation_states[0])
1✔
195
                    # Handle noble gas species
196
                    # which have no entries in common_oxidation_states.
197
                    else:
198
                        valences.append(0)
×
199
                if sum(valences):
1✔
200
                    valences = [0] * self._structure.num_sites
1✔
201
                else:
202
                    self._structure.add_oxidation_state_by_site(valences)
×
203
                # raise
204

205
        # el = [site.specie.symbol for site in self._structure.sites]
206
        # el = [site.species_string for site in self._structure.sites]
207
        # el = [site.specie for site in self._structure.sites]
208
        # valence_dict = dict(zip(el, valences))
209
        # print valence_dict
210
        return valences
1✔
211

212

213
on_disorder_options = Literal["take_majority_strict", "take_majority_drop", "take_max_species", "error"]
1✔
214

215

216
def _handle_disorder(structure: Structure, on_disorder: on_disorder_options):
1✔
217
    """What to do in bonding and coordination number analysis if a site is disordered."""
218

219
    if all(site.is_ordered for site in structure):
1✔
220
        return structure
1✔
221

222
    if on_disorder == "error":
1✔
223
        raise ValueError(
1✔
224
            f"""Generating StructureGraphs for disordered Structures is unsupported. Pass on_disorder='take
225
            majority' | 'take_max_species' | 'error'. 'take_majority_strict' considers only the majority species from
226
            each site in the bonding algorithm and raises ValueError in case there is no majority (e.g. as in {{Fe:
227
            0.4, O: 0.4, C: 0.2}}) whereas 'take_majority_drop' just ignores the site altogether when computing bonds as
228
            if it didn't exist. 'take_max_species' extracts the first max species on each site (Fe in prev.
229
            example since Fe and O have equal occupancy and Fe comes first). 'error' raises an error in case
230
            of disordered structure. Offending {structure = }
231
        """
232
        )
233
    elif on_disorder.startswith("take_"):
1✔
234
        # disordered structures raise AttributeError when passed to NearNeighbors.get_cn()
235
        # or NearNeighbors.get_bonded_structure() (and probably others too, see GH-2070).
236
        # As a workaround, we create a new structure with majority species on each site.
237
        structure = structure.copy()  # make a copy so we don't mutate the original structure
1✔
238
        for idx, site in enumerate(structure):
1✔
239
            max_specie = max(site.species, key=site.species.get)  # type: ignore
1✔
240
            max_val = site.species[max_specie]
1✔
241
            if max_val <= 0.5:
1✔
242
                if on_disorder == "take_majority_strict":
1✔
243
                    raise ValueError(
1✔
244
                        f"Site {idx} has no majority species, the max species is {max_specie} with occupancy {max_val}"
245
                    )
246
                elif on_disorder == "take_majority_drop":
1✔
247
                    continue
×
248

249
            # this is the take_max_species case
250
            site.species = max_specie  # set site species in copied structure to max specie
1✔
251
    else:
252
        raise ValueError(f"Unexpected {on_disorder = }, should be one of {get_args(on_disorder_options)}")
×
253

254
    return structure
1✔
255

256

257
class NearNeighbors:
1✔
258
    """
259
    Base class to determine near neighbors that typically include nearest
260
    neighbors and others that are within some tolerable distance.
261
    """
262

263
    def __eq__(self, other: object) -> bool:
1✔
264
        if isinstance(other, type(self)):
×
265
            return self.__dict__ == other.__dict__
×
266
        return False
×
267

268
    def __hash__(self) -> int:
1✔
269
        return len(self.__dict__.items())
×
270

271
    @property
1✔
272
    def structures_allowed(self) -> bool:
1✔
273
        """
274
        Boolean property: can this NearNeighbors class be used with Structure
275
        objects?
276
        """
277
        raise NotImplementedError("structures_allowed is not defined!")
×
278

279
    @property
1✔
280
    def molecules_allowed(self) -> bool:
1✔
281
        """
282
        Boolean property: can this NearNeighbors class be used with Molecule
283
        objects?
284
        """
285
        raise NotImplementedError("molecules_allowed is not defined!")
×
286

287
    @property
1✔
288
    def extend_structure_molecules(self) -> bool:
1✔
289
        """
290
        Boolean property: Do Molecules need to be converted to Structures to use
291
        this NearNeighbors class? Note: this property is not defined for classes
292
        for which molecules_allowed is False.
293
        """
294
        raise NotImplementedError("extend_structures_molecule is not defined!")
×
295

296
    def get_cn(
1✔
297
        self,
298
        structure: Structure,
299
        n: int,
300
        use_weights: bool = False,
301
        on_disorder: on_disorder_options = "take_majority_strict",
302
    ) -> float:
303
        """
304
        Get coordination number, CN, of site with index n in structure.
305

306
        Args:
307
            structure (Structure): input structure.
308
            n (int): index of site for which to determine CN.
309
            use_weights (bool): flag indicating whether (True) to use weights for computing the coordination
310
                number or not (False, default: each coordinated site has equal weight).
311
            on_disorder ('take_majority_strict' | 'take_majority_drop' | 'take_max_species' | 'error'):
312
                What to do when encountering a disordered structure. 'error' will raise ValueError.
313
                'take_majority_strict' will use the majority specie on each site and raise
314
                ValueError if no majority exists. 'take_max_species' will use the first max specie
315
                on each site. For {{Fe: 0.4, O: 0.4, C: 0.2}}, 'error' and 'take_majority_strict'
316
                will raise ValueError, while 'take_majority_drop' ignores this site altogether and
317
                'take_max_species' will use Fe as the site specie.
318
        Returns:
319
            cn (int or float): coordination number.
320
        """
321
        structure = _handle_disorder(structure, on_disorder)
1✔
322
        siw = self.get_nn_info(structure, n)
1✔
323
        return sum(e["weight"] for e in siw) if use_weights else len(siw)
1✔
324

325
    def get_cn_dict(self, structure: Structure, n: int, use_weights: bool = False):
1✔
326
        """
327
        Get coordination number, CN, of each element bonded to site with index n in structure
328

329
        Args:
330
            structure (Structure): input structure
331
            n (int): index of site for which to determine CN.
332
            use_weights (bool): flag indicating whether (True)
333
                to use weights for computing the coordination number
334
                or not (False, default: each coordinated site has equal
335
                weight).
336

337
        Returns:
338
            cn (dict): dictionary of CN of each element bonded to site
339
        """
340
        siw = self.get_nn_info(structure, n)
×
341

342
        cn_dict = {}
×
343
        for idx in siw:
×
344
            site_element = idx["site"].species_string
×
345
            if site_element not in cn_dict:
×
346
                if use_weights:
×
347
                    cn_dict[site_element] = idx["weight"]
×
348
                else:
349
                    cn_dict[site_element] = 1
×
350
            else:
351
                if use_weights:
×
352
                    cn_dict[site_element] += idx["weight"]
×
353
                else:
354
                    cn_dict[site_element] += 1
×
355
        return cn_dict
×
356

357
    def get_nn(self, structure: Structure, n: int):
1✔
358
        """
359
        Get near neighbors of site with index n in structure.
360

361
        Args:
362
            structure (Structure): input structure.
363
            n (int): index of site in structure for which to determine
364
                    neighbors.
365
        Returns:
366
            sites (list of Site objects): near neighbors.
367
        """
368
        return [e["site"] for e in self.get_nn_info(structure, n)]
1✔
369

370
    def get_weights_of_nn_sites(self, structure: Structure, n: int):
1✔
371
        """
372
        Get weight associated with each near neighbor of site with
373
        index n in structure.
374

375
        Args:
376
            structure (Structure): input structure.
377
            n (int): index of site for which to determine the weights.
378
        Returns:
379
            weights (list of floats): near-neighbor weights.
380
        """
381
        return [e["weight"] for e in self.get_nn_info(structure, n)]
×
382

383
    def get_nn_images(self, structure: Structure, n: int):
1✔
384
        """
385
        Get image location of all near neighbors of site with index n in
386
        structure.
387

388
        Args:
389
            structure (Structure): input structure.
390
            n (int): index of site for which to determine the image
391
                location of near neighbors.
392
        Returns:
393
            images (list of 3D integer array): image locations of
394
                near neighbors.
395
        """
396
        return [e["image"] for e in self.get_nn_info(structure, n)]
1✔
397

398
    def get_nn_info(self, structure: Structure, n: int) -> list[dict]:
1✔
399
        """
400
        Get all near-neighbor sites as well as the associated image locations
401
        and weights of the site with index n.
402

403
        Args:
404
            structure (Structure): input structure.
405
            n (int): index of site for which to determine near-neighbor
406
                information.
407

408
        Returns:
409
            siw (list[dict]): each dictionary provides information
410
                about a single near neighbor, where key 'site' gives access to the
411
                corresponding Site object, 'image' gives the image location, and
412
                'weight' provides the weight that a given near-neighbor site contributes
413
                to the coordination number (1 or smaller), 'site_index' gives index of
414
                the corresponding site in the original structure.
415
        """
416
        raise NotImplementedError("get_nn_info(structure, n) is not defined!")
×
417

418
    def get_all_nn_info(self, structure):
1✔
419
        """Get a listing of all neighbors for all sites in a structure
420

421
        Args:
422
            structure (Structure): Input structure
423
        Return:
424
            List of NN site information for each site in the structure. Each
425
                entry has the same format as `get_nn_info`
426
        """
427
        return [self.get_nn_info(structure, n) for n in range(len(structure))]
1✔
428

429
    def get_nn_shell_info(self, structure: Structure, site_idx, shell):
1✔
430
        """Get a certain nearest neighbor shell for a certain site.
431

432
        Determines all non-backtracking paths through the neighbor network
433
        computed by `get_nn_info`. The weight is determined by multiplying
434
        the weight of the neighbor at each hop through the network. For
435
        example, a 2nd-nearest-neighbor that has a weight of 1 from its
436
        1st-nearest-neighbor and weight 0.5 from the original site will
437
        be assigned a weight of 0.5.
438

439
        As this calculation may involve computing the nearest neighbors of
440
        atoms multiple times, the calculation starts by computing all of the
441
        neighbor info and then calling `_get_nn_shell_info`. If you are likely
442
        to call this method for more than one site, consider calling `get_all_nn`
443
        first and then calling this protected method yourself.
444

445
        Args:
446
            structure (Structure): Input structure
447
            site_idx (int): index of site for which to determine neighbor
448
                information.
449
            shell (int): Which neighbor shell to retrieve (1 == 1st NN shell)
450
        Returns:
451
            list of dictionaries. Each entry in the list is information about
452
                a certain neighbor in the structure, in the same format as
453
                `get_nn_info`.
454
        """
455
        all_nn_info = self.get_all_nn_info(structure)
1✔
456
        sites = self._get_nn_shell_info(structure, all_nn_info, site_idx, shell)
1✔
457

458
        # Update the site positions
459
        #   Did not do this during NN options because that can be slower
460
        output = []
1✔
461
        for info in sites:
1✔
462
            orig_site = structure[info["site_index"]]
1✔
463
            info["site"] = PeriodicSite(
1✔
464
                orig_site.species,
465
                np.add(orig_site.frac_coords, info["image"]),
466
                structure.lattice,
467
                properties=orig_site.properties,
468
            )
469
            output.append(info)
1✔
470
        return output
1✔
471

472
    def _get_nn_shell_info(
1✔
473
        self,
474
        structure,
475
        all_nn_info,
476
        site_idx,
477
        shell,
478
        _previous_steps=frozenset(),
479
        _cur_image=(0, 0, 0),
480
    ):
481
        """Private method for computing the neighbor shell information
482

483
        Args:
484
            structure (Structure) - Structure being assessed
485
            all_nn_info ([[dict]]) - Results from `get_all_nn_info`
486
            site_idx (int) - index of site for which to determine neighbor
487
                information.
488
            shell (int) - Which neighbor shell to retrieve (1 == 1st NN shell)
489
            _previous_steps ({(site_idx, image}) - Internal use only: Set of
490
                sites that have already been traversed.
491
            _cur_image (tuple) - Internal use only Image coordinates of current atom
492
        Returns:
493
            list of dictionaries. Each entry in the list is information about
494
                a certain neighbor in the structure, in the same format as
495
                `get_nn_info`. Does not update the site positions
496
        """
497
        if shell <= 0:
1✔
498
            raise ValueError("Shell must be positive")
×
499

500
        # Append this site to the list of previously-visited sites
501
        _previous_steps = _previous_steps | {(site_idx, _cur_image)}
1✔
502

503
        # Get all the neighbors of this site
504
        possible_steps = list(all_nn_info[site_idx])
1✔
505
        for i, step in enumerate(possible_steps):
1✔
506
            # Update the image information
507
            #  Note: We do not update the site position yet, as making a
508
            #    PeriodicSite for each intermediate step is too costly
509
            step = dict(step)
1✔
510
            step["image"] = tuple(np.add(step["image"], _cur_image).tolist())
1✔
511
            possible_steps[i] = step
1✔
512

513
        # Get only the non-backtracking steps
514
        allowed_steps = [x for x in possible_steps if (x["site_index"], x["image"]) not in _previous_steps]
1✔
515

516
        # If we are the last step (i.e., shell == 1), done!
517
        if shell == 1:
1✔
518
            # No further work needed, just package these results
519
            return allowed_steps
1✔
520

521
        # If not, Get the N-1 NNs of these allowed steps
522
        terminal_neighbors = [
1✔
523
            self._get_nn_shell_info(
524
                structure,
525
                all_nn_info,
526
                x["site_index"],
527
                shell - 1,
528
                _previous_steps,
529
                x["image"],
530
            )
531
            for x in allowed_steps
532
        ]
533

534
        # Each allowed step results in many terminal neighbors
535
        #  And, different first steps might results in the same neighbor
536
        #  Now, we condense those neighbors into a single entry per neighbor
537
        all_sites = {}
1✔
538
        for first_site, term_sites in zip(allowed_steps, terminal_neighbors):
1✔
539
            for term_site in term_sites:
1✔
540
                key = (term_site["site_index"], tuple(term_site["image"]))
1✔
541

542
                # The weight for this site is equal to the weight of the
543
                #  first step multiplied by the weight of the terminal neighbor
544
                term_site["weight"] *= first_site["weight"]
1✔
545

546
                # Check if this site is already known
547
                value = all_sites.get(key)
1✔
548
                if value is not None:
1✔
549
                    # If so, add to its weight
550
                    value["weight"] += term_site["weight"]
1✔
551
                else:
552
                    # If not, prepare to add it
553
                    value = term_site
1✔
554
                all_sites[key] = value
1✔
555
        return list(all_sites.values())
1✔
556

557
    @staticmethod
1✔
558
    def _get_image(structure, site):
1✔
559
        """Private convenience method for get_nn_info,
560
        gives lattice image from provided PeriodicSite and Structure.
561

562
        Image is defined as displacement from original site in structure to a given site.
563
        i.e. if structure has a site at (-0.1, 1.0, 0.3), then (0.9, 0, 2.3) -> jimage = (1, -1, 2).
564
        Note that this method takes O(number of sites) due to searching an original site.
565

566
        Args:
567
            structure: Structure Object
568
            site: PeriodicSite Object
569

570
        Returns:
571
            image: ((int)*3) Lattice image
572
        """
573
        original_site = structure[NearNeighbors._get_original_site(structure, site)]
1✔
574
        image = np.around(np.subtract(site.frac_coords, original_site.frac_coords))
1✔
575
        image = tuple(image.astype(int))
1✔
576
        return image
1✔
577

578
    @staticmethod
1✔
579
    def _get_original_site(structure, site):
1✔
580
        """Private convenience method for get_nn_info,
581
        gives original site index from ProvidedPeriodicSite."""
582
        if isinstance(structure, (IStructure, Structure)):
1✔
583
            for i, s in enumerate(structure):
1✔
584
                if site.is_periodic_image(s):
1✔
585
                    return i
1✔
586
        else:
587
            for i, s in enumerate(structure):
1✔
588
                if site == s:
1✔
589
                    return i
1✔
590
        raise Exception("Site not found!")
×
591

592
    def get_bonded_structure(
1✔
593
        self,
594
        structure: Structure,
595
        decorate: bool = False,
596
        weights: bool = True,
597
        edge_properties: bool = False,
598
        on_disorder: on_disorder_options = "take_majority_strict",
599
    ) -> StructureGraph | MoleculeGraph:
600
        """
601
        Obtain a StructureGraph object using this NearNeighbor
602
        class. Requires the optional dependency networkx
603
        (pip install networkx).
604

605
        Args:
606
            structure: Structure object.
607
            decorate (bool): whether to annotate site properties with order parameters using neighbors
608
                determined by this NearNeighbor class
609
            weights (bool): whether to include edge weights from NearNeighbor class in StructureGraph
610
            edge_properties (bool) whether to include further edge properties from NearNeighbor class in StructureGraph
611
            on_disorder ('take_majority_strict' | 'take_majority_drop' | 'take_max_species' | 'error'):
612
                What to do when encountering a disordered structure. 'error' will raise ValueError.
613
                'take_majority_strict' will use the majority specie on each site and raise
614
                ValueError if no majority exists. 'take_max_species' will use the first max specie
615
                on each site. For {{Fe: 0.4, O: 0.4, C: 0.2}}, 'error' and 'take_majority_strict'
616
                will raise ValueError, while 'take_majority_drop' ignores this site altogether and
617
                'take_max_species' will use Fe as the site specie.
618

619
        Returns: a pymatgen.analysis.graphs.StructureGraph object
620
        """
621
        structure = _handle_disorder(structure, on_disorder)
1✔
622

623
        if decorate:
1✔
624
            # Decorate all sites in the underlying structure
625
            # with site properties that provides information on the
626
            # coordination number and coordination pattern based
627
            # on the (current) structure of this graph.
628
            order_parameters = [self.get_local_order_parameters(structure, n) for n in range(len(structure))]
1✔
629
            structure.add_site_property("order_parameters", order_parameters)
1✔
630

631
        sg = StructureGraph.with_local_env_strategy(structure, self, weights=weights, edge_properties=edge_properties)
1✔
632

633
        # sets the attributes
634
        sg.set_node_attributes()
1✔
635
        return sg
1✔
636

637
    def get_local_order_parameters(self, structure: Structure, n: int):
1✔
638
        """
639
        Calculate those local structure order parameters for
640
        the given site whose ideal CN corresponds to the
641
        underlying motif (e.g., CN=4, then calculate the
642
        square planar, tetrahedral, see-saw-like,
643
        rectangular see-saw-like order parameters).
644

645
        Args:
646
            structure: Structure object
647
            n (int): site index.
648

649
        Returns (dict[str, float]):
650
            A dict of order parameters (values) and the
651
            underlying motif type (keys; for example, tetrahedral).
652
        """
653
        # code from @nisse3000, moved here from graphs to avoid circular
654
        # import, also makes sense to have this as a general NN method
655
        cn = self.get_cn(structure, n)
1✔
656
        int_cn = [int(k_cn) for k_cn in cn_opt_params]
1✔
657
        if cn in int_cn:
1✔
658
            names = list(cn_opt_params[cn])
1✔
659
            types = []
1✔
660
            params = []
1✔
661
            for name in names:
1✔
662
                types.append(cn_opt_params[cn][name][0])
1✔
663
                tmp = cn_opt_params[cn][name][1] if len(cn_opt_params[cn][name]) > 1 else None
1✔
664
                params.append(tmp)
1✔
665
            lostops = LocalStructOrderParams(types, parameters=params)
1✔
666
            sites = [structure[n]] + self.get_nn(structure, n)
1✔
667
            lostop_vals = lostops.get_order_parameters(sites, 0, indices_neighs=list(range(1, cn + 1)))  # type: ignore
1✔
668
            d = {}
1✔
669
            for i, lostop in enumerate(lostop_vals):
1✔
670
                d[names[i]] = lostop
1✔
671
            return d
1✔
672
        return None
×
673

674

675
class VoronoiNN(NearNeighbors):
1✔
676
    """
677
    Uses a Voronoi algorithm to determine near neighbors for each site in a
678
    structure.
679
    """
680

681
    def __init__(
1✔
682
        self,
683
        tol=0,
684
        targets=None,
685
        cutoff=13.0,
686
        allow_pathological=False,
687
        weight="solid_angle",
688
        extra_nn_info=True,
689
        compute_adj_neighbors=True,
690
    ):
691
        """
692
        Args:
693
            tol (float): tolerance parameter for near-neighbor finding. Faces that are
694
                smaller than `tol` fraction of the largest face are not included in the
695
                tessellation. (default: 0).
696
            targets (Element or list of Elements): target element(s).
697
            cutoff (float): cutoff radius in Angstrom to look for near-neighbor
698
                atoms. Defaults to 13.0.
699
            allow_pathological (bool): whether to allow infinite vertices in
700
                determination of Voronoi coordination.
701
            weight (string) - Statistic used to weigh neighbors (see the statistics
702
                available in get_voronoi_polyhedra)
703
            extra_nn_info (bool) - Add all polyhedron info to `get_nn_info`
704
            compute_adj_neighbors (bool) - Whether to compute which neighbors are
705
                adjacent. Turn off for faster performance
706
        """
707
        super().__init__()
1✔
708
        self.tol = tol
1✔
709
        self.cutoff = cutoff
1✔
710
        self.allow_pathological = allow_pathological
1✔
711
        self.targets = targets
1✔
712
        self.weight = weight
1✔
713
        self.extra_nn_info = extra_nn_info
1✔
714
        self.compute_adj_neighbors = compute_adj_neighbors
1✔
715

716
    @property
1✔
717
    def structures_allowed(self):
1✔
718
        """
719
        Boolean property: can this NearNeighbors class be used with Structure
720
        objects?
721
        """
722
        return True
×
723

724
    @property
1✔
725
    def molecules_allowed(self):
1✔
726
        """
727
        Boolean property: can this NearNeighbors class be used with Molecule
728
        objects?
729
        """
730
        return False
×
731

732
    def get_voronoi_polyhedra(self, structure: Structure, n: int):
1✔
733
        """
734
        Gives a weighted polyhedra around a site.
735

736
        See ref: A Proposed Rigorous Definition of Coordination Number,
737
        M. O'Keeffe, Acta Cryst. (1979). A35, 772-775
738

739
        Args:
740
            structure (Structure): structure for which to evaluate the
741
                coordination environment.
742
            n (int): site index.
743

744
        Returns:
745
            A dict of sites sharing a common Voronoi facet with the site
746
            n mapped to a directory containing statistics about the facet:
747
                - solid_angle - Solid angle subtended by face
748
                - angle_normalized - Solid angle normalized such that the
749
                    faces with the largest
750
                - area - Area of the facet
751
                - face_dist - Distance between site n and the facet
752
                - volume - Volume of Voronoi cell for this face
753
                - n_verts - Number of vertices on the facet
754
        """
755
        # Assemble the list of neighbors used in the tessellation
756
        #   Gets all atoms within a certain radius
757
        if self.targets is None:
1✔
758
            targets = structure.composition.elements
1✔
759
        else:
760
            targets = self.targets
1✔
761
        center = structure[n]
1✔
762

763
        cutoff = self.cutoff
1✔
764

765
        # max cutoff is the longest diagonal of the cell + room for noise
766
        corners = [[1, 1, 1], [-1, 1, 1], [1, -1, 1], [1, 1, -1]]
1✔
767
        d_corners = [np.linalg.norm(structure.lattice.get_cartesian_coords(c)) for c in corners]
1✔
768
        max_cutoff = max(d_corners) + 0.01
1✔
769

770
        while True:
771
            try:
1✔
772
                neighbors = structure.get_sites_in_sphere(center.coords, cutoff)
1✔
773
                neighbors = [i[0] for i in sorted(neighbors, key=lambda s: s[1])]
1✔
774

775
                # Run the Voronoi tessellation
776
                qvoronoi_input = [s.coords for s in neighbors]
1✔
777

778
                voro = Voronoi(qvoronoi_input)  # can give seg fault if cutoff is too small
1✔
779

780
                # Extract data about the site in question
781
                cell_info = self._extract_cell_info(0, neighbors, targets, voro, self.compute_adj_neighbors)
1✔
782
                break
1✔
783

784
            except RuntimeError as e:
×
785
                if cutoff >= max_cutoff:
×
786
                    if e.args and "vertex" in e.args[0]:
×
787
                        # pass through the error raised by _extract_cell_info
788
                        raise e
×
789
                    raise RuntimeError("Error in Voronoi neighbor finding; max cutoff exceeded")
×
790
                cutoff = min(cutoff * 2, max_cutoff + 0.001)
×
791
        return cell_info
1✔
792

793
    def get_all_voronoi_polyhedra(self, structure):
1✔
794
        """Get the Voronoi polyhedra for all site in a simulation cell
795

796
        Args:
797
            structure (Structure): Structure to be evaluated
798
        Returns:
799
            A dict of sites sharing a common Voronoi facet with the site
800
            n mapped to a directory containing statistics about the facet:
801
                - solid_angle - Solid angle subtended by face
802
                - angle_normalized - Solid angle normalized such that the
803
                    faces with the largest
804
                - area - Area of the facet
805
                - face_dist - Distance between site n and the facet
806
                - volume - Volume of Voronoi cell for this face
807
                - n_verts - Number of vertices on the facet
808
        """
809
        # Special case: For atoms with 1 site, the atom in the root image is not
810
        # included in the get_all_neighbors output. Rather than creating logic to add
811
        # that atom to the neighbor list, which requires detecting whether it will be
812
        # translated to reside within the unit cell before neighbor detection, it is
813
        # less complex to just call the one-by-one operation
814
        if len(structure) == 1:
1✔
815
            return [self.get_voronoi_polyhedra(structure, 0)]
1✔
816

817
        # Assemble the list of neighbors used in the tessellation
818
        if self.targets is None:
1✔
819
            targets = structure.composition.elements
1✔
820
        else:
821
            targets = self.targets
1✔
822

823
        # Initialize the list of sites with the atoms in the origin unit cell
824
        # The `get_all_neighbors` function returns neighbors for each site's image in
825
        # the original unit cell. We start off with these central atoms to ensure they
826
        # are included in the tessellation
827

828
        sites = [x.to_unit_cell() for x in structure]
1✔
829
        indices = [(i, 0, 0, 0) for i, _ in enumerate(structure)]
1✔
830

831
        # Get all neighbors within a certain cutoff
832
        #   Record both the list of these neighbors, and the site indices
833
        all_neighs = structure.get_all_neighbors(self.cutoff, include_index=True, include_image=True)
1✔
834
        for neighs in all_neighs:
1✔
835
            sites.extend([x[0] for x in neighs])
1✔
836
            indices.extend([(x[2],) + x[3] for x in neighs])
1✔
837

838
        # Get the non-duplicates (using the site indices for numerical stability)
839
        indices = np.array(indices, dtype=int)
1✔
840
        indices, uniq_inds = np.unique(indices, return_index=True, axis=0)
1✔
841
        sites = [sites[i] for i in uniq_inds]
1✔
842

843
        # Sort array such that atoms in the root image are first
844
        #   Exploit the fact that the array is sorted by the unique operation such that
845
        #   the images associated with atom 0 are first, followed by atom 1, etc.
846
        (root_images,) = np.nonzero(np.abs(indices[:, 1:]).max(axis=1) == 0)
1✔
847

848
        del indices  # Save memory (tessellations can be costly)
1✔
849

850
        # Run the tessellation
851
        qvoronoi_input = [s.coords for s in sites]
1✔
852
        voro = Voronoi(qvoronoi_input)
1✔
853

854
        # Get the information for each neighbor
855
        return [
1✔
856
            self._extract_cell_info(i, sites, targets, voro, self.compute_adj_neighbors) for i in root_images.tolist()
857
        ]
858

859
    def _extract_cell_info(self, site_idx, sites, targets, voro, compute_adj_neighbors=False):
1✔
860
        """Get the information about a certain atom from the results of a tessellation
861

862
        Args:
863
            site_idx (int) - Index of the atom in question
864
            sites ([Site]) - List of all sites in the tessellation
865
            targets ([Element]) - Target elements
866
            voro - Output of qvoronoi
867
            compute_adj_neighbors (boolean) - Whether to compute which neighbors are adjacent
868
        Returns:
869
            A dict of sites sharing a common Voronoi facet. Key is facet id
870
             (not useful) and values are dictionaries containing statistics
871
             about the facet:
872
                - site: Pymatgen site
873
                - solid_angle - Solid angle subtended by face
874
                - angle_normalized - Solid angle normalized such that the
875
                    faces with the largest
876
                - area - Area of the facet
877
                - face_dist - Distance between site n and the facet
878
                - volume - Volume of Voronoi cell for this face
879
                - n_verts - Number of vertices on the facet
880
                - adj_neighbors - Facet id's for the adjacent neighbors
881
        """
882
        # Get the coordinates of every vertex
883
        all_vertices = voro.vertices
1✔
884

885
        # Get the coordinates of the central site
886
        center_coords = sites[site_idx].coords
1✔
887

888
        # Iterate through all the faces in the tessellation
889
        results = {}
1✔
890
        for nn, vind in voro.ridge_dict.items():
1✔
891
            # Get only those that include the site in question
892
            if site_idx in nn:
1✔
893
                other_site = nn[0] if nn[1] == site_idx else nn[1]
1✔
894
                if -1 in vind:
1✔
895
                    # -1 indices correspond to the Voronoi cell
896
                    #  missing a face
897
                    if self.allow_pathological:
1✔
898
                        continue
1✔
899

900
                    raise RuntimeError("This structure is pathological, infinite vertex in the Voronoi construction")
×
901

902
                # Get the solid angle of the face
903
                facets = [all_vertices[i] for i in vind]
1✔
904
                angle = solid_angle(center_coords, facets)
1✔
905

906
                # Compute the volume of associated with this face
907
                volume = 0
1✔
908
                # qvoronoi returns vertices in CCW order, so I can break
909
                # the face up in to segments (0,1,2), (0,2,3), ... to compute
910
                # its area where each number is a vertex size
911
                for j, k in zip(vind[1:], vind[2:]):
1✔
912
                    volume += vol_tetra(
1✔
913
                        center_coords,
914
                        all_vertices[vind[0]],
915
                        all_vertices[j],
916
                        all_vertices[k],
917
                    )
918

919
                # Compute the distance of the site to the face
920
                face_dist = np.linalg.norm(center_coords - sites[other_site].coords) / 2
1✔
921

922
                # Compute the area of the face (knowing V=Ad/3)
923
                face_area = 3 * volume / face_dist
1✔
924

925
                # Compute the normal of the facet
926
                normal = np.subtract(sites[other_site].coords, center_coords)
1✔
927
                normal /= np.linalg.norm(normal)
1✔
928

929
                # Store by face index
930
                results[other_site] = {
1✔
931
                    "site": sites[other_site],
932
                    "normal": normal,
933
                    "solid_angle": angle,
934
                    "volume": volume,
935
                    "face_dist": face_dist,
936
                    "area": face_area,
937
                    "n_verts": len(vind),
938
                }
939

940
                # If we are computing which neighbors are adjacent, store the vertices
941
                if compute_adj_neighbors:
1✔
942
                    results[other_site]["verts"] = vind
1✔
943

944
        # all sites should have at least two connected ridges in periodic system
945
        if len(results) == 0:
1✔
946
            raise ValueError("No Voronoi neighbors found for site - try increasing cutoff")
×
947

948
        # Get only target elements
949
        result_weighted = {}
1✔
950
        for nn_index, nn_stats in results.items():
1✔
951
            # Check if this is a target site
952
            nn = nn_stats["site"]
1✔
953
            if nn.is_ordered:
1✔
954
                if nn.specie in targets:
1✔
955
                    result_weighted[nn_index] = nn_stats
1✔
956
            else:  # if nn site is disordered
957
                for disordered_sp in nn.species:
×
958
                    if disordered_sp in targets:
×
959
                        result_weighted[nn_index] = nn_stats
×
960

961
        # If desired, determine which neighbors are adjacent
962
        if compute_adj_neighbors:
1✔
963
            # Initialize storage for the adjacent neighbors
964
            adj_neighbors = {i: [] for i in result_weighted}
1✔
965

966
            # Find the neighbors that are adjacent by finding those
967
            #  that contain exactly two vertices
968
            for a_ind, a_nn_info in result_weighted.items():
1✔
969
                # Get the indices for this site
970
                a_verts = set(a_nn_info["verts"])
1✔
971

972
                # Loop over all neighbors that have an index lower that this one
973
                # The goal here is to exploit the fact that neighbor adjacency is
974
                # symmetric (if A is adj to B, B is adj to A)
975
                for b_ind, b_nninfo in result_weighted.items():
1✔
976
                    if b_ind > a_ind:
1✔
977
                        continue
1✔
978
                    if len(a_verts.intersection(b_nninfo["verts"])) == 2:
1✔
979
                        adj_neighbors[a_ind].append(b_ind)
1✔
980
                        adj_neighbors[b_ind].append(a_ind)
1✔
981

982
            # Store the results in the nn_info
983
            for key, neighbors in adj_neighbors.items():
1✔
984
                result_weighted[key]["adj_neighbors"] = neighbors
1✔
985

986
        return result_weighted
1✔
987

988
    def get_nn_info(self, structure: Structure, n: int):
1✔
989
        """
990
        Get all near-neighbor sites as well as the associated image locations
991
        and weights of the site with index n in structure
992
        using Voronoi decomposition.
993

994
        Args:
995
            structure (Structure): input structure.
996
            n (int): index of site for which to determine near-neighbor
997
                sites.
998

999
        Returns:
1000
            siw (list of tuples (Site, array, float)): tuples, each one
1001
                of which represents a coordinated site, its image location,
1002
                and its weight.
1003
        """
1004
        # Run the tessellation
1005
        nns = self.get_voronoi_polyhedra(structure, n)
1✔
1006

1007
        # Extract the NN info
1008
        return self._extract_nn_info(structure, nns)
1✔
1009

1010
    def get_all_nn_info(self, structure):
1✔
1011
        """
1012
        Args:
1013
            structure (Structure): input structure.
1014

1015
        Returns:
1016
            All nn info for all sites.
1017
        """
1018
        all_voro_cells = self.get_all_voronoi_polyhedra(structure)
1✔
1019
        return [self._extract_nn_info(structure, cell) for cell in all_voro_cells]
1✔
1020

1021
    def _extract_nn_info(self, structure: Structure, nns):
1✔
1022
        """Given Voronoi NNs, extract the NN info in the form needed by NearestNeighbors
1023

1024
        Args:
1025
            structure (Structure): Structure being evaluated
1026
            nns ([dicts]): Nearest neighbor information for a structure
1027
        Returns:
1028
            (list of tuples (Site, array, float)): See nn_info
1029
        """
1030
        # Get the target information
1031
        if self.targets is None:
1✔
1032
            targets = structure.composition.elements
1✔
1033
        else:
1034
            targets = self.targets
1✔
1035

1036
        # Extract the NN info
1037
        siw = []
1✔
1038
        max_weight = max(nn[self.weight] for nn in nns.values())
1✔
1039
        for nstats in nns.values():
1✔
1040
            site = nstats["site"]
1✔
1041
            if nstats[self.weight] > self.tol * max_weight and _is_in_targets(site, targets):
1✔
1042
                nn_info = {
1✔
1043
                    "site": site,
1044
                    "image": self._get_image(structure, site),
1045
                    "weight": nstats[self.weight] / max_weight,
1046
                    "site_index": self._get_original_site(structure, site),
1047
                }
1048

1049
                if self.extra_nn_info:
1✔
1050
                    # Add all the information about the site
1051
                    poly_info = nstats
1✔
1052
                    del poly_info["site"]
1✔
1053
                    nn_info["poly_info"] = poly_info
1✔
1054
                siw.append(nn_info)
1✔
1055
        return siw
1✔
1056

1057

1058
class IsayevNN(VoronoiNN):
1✔
1059
    """
1060
    Uses the algorithm defined in 10.1038/ncomms15679
1061

1062
    Sites are considered neighbors if (i) they share a Voronoi facet and (ii) the
1063
    bond distance is less than the sum of the Cordero covalent radii + 0.25 Ã….
1064
    """
1065

1066
    def __init__(
1✔
1067
        self,
1068
        tol: float = 0.25,
1069
        targets: Element | list[Element] | None = None,
1070
        cutoff: float = 13.0,
1071
        allow_pathological: bool = False,
1072
        extra_nn_info: bool = True,
1073
        compute_adj_neighbors: bool = True,
1074
    ):
1075
        """
1076
        Args:
1077
            tol: Tolerance in Ã… for bond distances that are considered coordinated.
1078
            targets: Target element(s).
1079
            cutoff: Cutoff radius in Angstrom to look for near-neighbor atoms.
1080
            allow_pathological: Whether to allow infinite vertices in Voronoi
1081
                coordination.
1082
            extra_nn_info: Add all polyhedron info to `get_nn_info`.
1083
            compute_adj_neighbors: Whether to compute which neighbors are adjacent. Turn
1084
                off for faster performance.
1085
        """
1086
        super().__init__()
1✔
1087
        self.tol = tol
1✔
1088
        self.cutoff = cutoff
1✔
1089
        self.allow_pathological = allow_pathological
1✔
1090
        self.targets = targets
1✔
1091
        self.extra_nn_info = extra_nn_info
1✔
1092
        self.compute_adj_neighbors = compute_adj_neighbors
1✔
1093

1094
    def get_nn_info(self, structure: Structure, n: int) -> list[dict[str, Any]]:
1✔
1095
        """
1096
        Get all near-neighbor site information.
1097

1098
        Gets the associated image locations and weights of the site with index n
1099
        in structure using Voronoi decomposition and distance cutoff.
1100

1101
        Args:
1102
            structure: Input structure.
1103
            n: Index of site for which to determine near-neighbor sites.
1104

1105
        Returns:
1106
            List of dicts containing the near-neighbor information. Each dict has the
1107
            keys:
1108

1109
            - "site": The near-neighbor site.
1110
            - "image": The periodic image of the near-neighbor site.
1111
            - "weight": The face weight of the Voronoi decomposition.
1112
            - "site_index": The index of the near-neighbor site in the original
1113
              structure.
1114
        """
1115
        nns = self.get_voronoi_polyhedra(structure, n)
1✔
1116
        return self._filter_nns(structure, n, nns)
1✔
1117

1118
    def get_all_nn_info(self, structure: Structure) -> list[list[dict[str, Any]]]:
1✔
1119
        """
1120
        Args:
1121
            structure (Structure): input structure.
1122

1123
        Returns:
1124
            List of near neighbor information for each site. See get_nn_info for the
1125
            format of the data for each site.
1126
        """
1127
        all_nns = self.get_all_voronoi_polyhedra(structure)
×
1128
        return [self._filter_nns(structure, n, nns) for n, nns in enumerate(all_nns)]
×
1129

1130
    def _filter_nns(self, structure: Structure, n: int, nns: dict[str, Any]) -> list[dict[str, Any]]:
1✔
1131
        """Extract and filter the NN info into the format needed by NearestNeighbors.
1132

1133
        Args:
1134
            structure: The structure.
1135
            n: The central site index.
1136
            nns: Nearest neighbor information for the structure.
1137

1138
        Returns:
1139
            See get_nn_info for the format of the returned data.
1140
        """
1141
        # Get the target information
1142
        if self.targets is None:
1✔
1143
            targets = structure.composition.elements
1✔
1144
        else:
1145
            targets = self.targets
×
1146

1147
        site = structure[n]
1✔
1148

1149
        # Extract the NN info
1150
        siw = []
1✔
1151
        max_weight = max(nn["area"] for nn in nns.values())
1✔
1152
        for nstats in nns.values():
1✔
1153
            nn = nstats.pop("site")
1✔
1154

1155
            # use the Cordero radius if it is available, otherwise the atomic radius
1156
            cov_distance = _get_default_radius(site) + _get_default_radius(nn)
1✔
1157
            nn_distance = np.linalg.norm(site.coords - nn.coords)
1✔
1158

1159
            # by default VoronoiNN only returns neighbors which share a Voronoi facet
1160
            # therefore we don't need do to additional filtering based on the weight
1161
            if _is_in_targets(nn, targets) and nn_distance <= cov_distance + self.tol:
1✔
1162
                nn_info = {
1✔
1163
                    "site": nn,
1164
                    "image": self._get_image(structure, nn),
1165
                    "weight": nstats["area"] / max_weight,
1166
                    "site_index": self._get_original_site(structure, nn),
1167
                }
1168

1169
                if self.extra_nn_info:
1✔
1170
                    nn_info["poly_info"] = nstats
1✔
1171
                siw.append(nn_info)
1✔
1172
        return siw
1✔
1173

1174

1175
def _is_in_targets(site, targets):
1✔
1176
    """
1177
    Test whether a site contains elements in the target list
1178

1179
    Args:
1180
        site (Site): Site to assess
1181
        targets ([Element]) List of elements
1182
    Returns:
1183
         (boolean) Whether this site contains a certain list of elements
1184
    """
1185
    elems = _get_elements(site)
1✔
1186
    for elem in elems:
1✔
1187
        if elem not in targets:
1✔
1188
            return False
×
1189
    return True
1✔
1190

1191

1192
def _get_elements(site):
1✔
1193
    """
1194
    Get the list of elements for a Site
1195

1196
    Args:
1197
         site (Site): Site to assess
1198
    Returns:
1199
        [Element]: List of elements
1200
    """
1201
    try:
1✔
1202
        if isinstance(site.specie, Element):
1✔
1203
            return [site.specie]
1✔
1204
        return [Element(site.specie)]
1✔
1205
    except Exception:
1✔
1206
        return site.species.elements
1✔
1207

1208

1209
class JmolNN(NearNeighbors):
1✔
1210
    """
1211
    Determine near-neighbor sites and coordination number using an emulation
1212
    of Jmol's default autoBond() algorithm. This version of the algorithm
1213
    does not take into account any information regarding known charge
1214
    states.
1215
    """
1216

1217
    def __init__(
1✔
1218
        self,
1219
        tol: float = 0.45,
1220
        min_bond_distance: float = 0.4,
1221
        el_radius_updates: dict[SpeciesLike, float] | None = None,
1222
    ):
1223
        """
1224
        Args:
1225
            tol (float): tolerance parameter for bond determination
1226
                Defaults to 0.56.
1227
            min_bond_distance (float): minimum bond distance for consideration
1228
                Defaults to 0.4.
1229
            el_radius_updates: (dict) symbol->float to override default atomic
1230
                radii table values
1231
        """
1232
        self.tol = tol
1✔
1233
        self.min_bond_distance = min_bond_distance
1✔
1234

1235
        # Load elemental radii table
1236
        bonds_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bonds_jmol_ob.yaml")
1✔
1237
        with open(bonds_file) as f:
1✔
1238
            yaml = YAML()
1✔
1239
            self.el_radius = yaml.load(f)
1✔
1240

1241
        # Update any user preference elemental radii
1242
        if el_radius_updates:
1✔
1243
            self.el_radius.update(el_radius_updates)
1✔
1244

1245
    @property
1✔
1246
    def structures_allowed(self):
1✔
1247
        """
1248
        Boolean property: can this NearNeighbors class be used with Structure
1249
        objects?
1250
        """
1251
        return True
×
1252

1253
    @property
1✔
1254
    def molecules_allowed(self):
1✔
1255
        """
1256
        Boolean property: can this NearNeighbors class be used with Molecule
1257
        objects?
1258
        """
1259
        return True
×
1260

1261
    @property
1✔
1262
    def extend_structure_molecules(self):
1✔
1263
        """
1264
        Boolean property: Do Molecules need to be converted to Structures to use
1265
        this NearNeighbors class? Note: this property is not defined for classes
1266
        for which molecules_allowed is False.
1267
        """
1268
        return True
×
1269

1270
    def get_max_bond_distance(self, el1_sym, el2_sym):
1✔
1271
        """
1272
        Use Jmol algorithm to determine bond length from atomic parameters
1273
        Args:
1274
            el1_sym: (str) symbol of atom 1
1275
            el2_sym: (str) symbol of atom 2
1276

1277
        Returns: (float) max bond length
1278
        """
1279
        return sqrt((self.el_radius[el1_sym] + self.el_radius[el2_sym] + self.tol) ** 2)
1✔
1280

1281
    def get_nn_info(self, structure: Structure, n: int):
1✔
1282
        """
1283
        Get all near-neighbor sites as well as the associated image locations
1284
        and weights of the site with index n using the bond identification
1285
        algorithm underlying Jmol.
1286

1287
        Args:
1288
            structure (Structure): input structure.
1289
            n (int): index of site for which to determine near
1290
                neighbors.
1291

1292
        Returns:
1293
            siw (list of tuples (Site, array, float)): tuples, each one
1294
                of which represents a neighbor site, its image location,
1295
                and its weight.
1296
        """
1297
        site = structure[n]
1✔
1298

1299
        # Determine relevant bond lengths based on atomic radii table
1300
        bonds = {}
1✔
1301
        for el in structure.composition.elements:
1✔
1302
            bonds[site.specie, el] = self.get_max_bond_distance(site.specie.symbol, el.symbol)
1✔
1303

1304
        # Search for neighbors up to max bond length + tolerance
1305
        max_rad = max(bonds.values()) + self.tol
1✔
1306
        min_rad = min(bonds.values())
1✔
1307

1308
        siw = []
1✔
1309
        for nn in structure.get_neighbors(site, max_rad):
1✔
1310
            dist = nn.nn_distance
1✔
1311
            # Confirm neighbor based on bond length specific to atom pair
1312
            if dist <= (bonds[(site.specie, nn.specie)]) and (nn.nn_distance > self.min_bond_distance):
1✔
1313
                weight = min_rad / dist
1✔
1314
                siw.append(
1✔
1315
                    {
1316
                        "site": nn,
1317
                        "image": self._get_image(structure, nn),
1318
                        "weight": weight,
1319
                        "site_index": self._get_original_site(structure, nn),
1320
                    }
1321
                )
1322
        return siw
1✔
1323

1324

1325
class MinimumDistanceNN(NearNeighbors):
1✔
1326
    """
1327
    Determine near-neighbor sites and coordination number using the
1328
    nearest neighbor(s) at distance, d_min, plus all neighbors
1329
    within a distance (1 + tol) * d_min, where tol is a
1330
    (relative) distance tolerance parameter.
1331
    """
1332

1333
    def __init__(self, tol: float = 0.1, cutoff=10.0, get_all_sites=False):
1✔
1334
        """
1335
        Args:
1336
            tol (float): tolerance parameter for neighbor identification
1337
                (default: 0.1).
1338
            cutoff (float): cutoff radius in Angstrom to look for trial
1339
                near-neighbor sites (default: 10.0).
1340
            get_all_sites (bool): If this is set to True then the neighbor
1341
                sites are only determined by the cutoff radius, tol is ignored
1342
        """
1343
        self.tol = tol
1✔
1344
        self.cutoff = cutoff
1✔
1345
        self.get_all_sites = get_all_sites
1✔
1346

1347
    @property
1✔
1348
    def structures_allowed(self):
1✔
1349
        """
1350
        Boolean property: can this NearNeighbors class be used with Structure
1351
        objects?
1352
        """
1353
        return True
1✔
1354

1355
    @property
1✔
1356
    def molecules_allowed(self):
1✔
1357
        """
1358
        Boolean property: can this NearNeighbors class be used with Molecule
1359
        objects?
1360
        """
1361
        return True
1✔
1362

1363
    @property
1✔
1364
    def extend_structure_molecules(self):
1✔
1365
        """
1366
        Boolean property: Do Molecules need to be converted to Structures to use
1367
        this NearNeighbors class? Note: this property is not defined for classes
1368
        for which molecules_allowed is False.
1369
        """
1370
        return True
1✔
1371

1372
    def get_nn_info(self, structure: Structure, n: int):
1✔
1373
        """
1374
        Get all near-neighbor sites as well as the associated image locations
1375
        and weights of the site with index n using the closest neighbor
1376
        distance-based method.
1377

1378
        Args:
1379
            structure (Structure): input structure.
1380
            n (int): index of site for which to determine near
1381
                neighbors.
1382

1383
        Returns:
1384
            siw (list of tuples (Site, array, float)): tuples, each one
1385
                of which represents a neighbor site, its image location,
1386
                and its weight.
1387
        """
1388
        site = structure[n]
1✔
1389
        neighs_dists = structure.get_neighbors(site, self.cutoff)
1✔
1390
        is_periodic = isinstance(structure, (Structure, IStructure))
1✔
1391
        siw = []
1✔
1392
        if self.get_all_sites:
1✔
1393
            for nn in neighs_dists:
1✔
1394
                w = nn.nn_distance
1✔
1395
                siw.append(
1✔
1396
                    {
1397
                        "site": nn,
1398
                        "image": self._get_image(structure, nn) if is_periodic else None,
1399
                        "weight": w,
1400
                        "site_index": self._get_original_site(structure, nn),
1401
                    }
1402
                )
1403
        else:
1404
            min_dist = min(nn.nn_distance for nn in neighs_dists)
1✔
1405
            for nn in neighs_dists:
1✔
1406
                dist = nn.nn_distance
1✔
1407
                if dist < (1.0 + self.tol) * min_dist:
1✔
1408
                    w = min_dist / dist
1✔
1409
                    siw.append(
1✔
1410
                        {
1411
                            "site": nn,
1412
                            "image": self._get_image(structure, nn) if is_periodic else None,
1413
                            "weight": w,
1414
                            "site_index": self._get_original_site(structure, nn),
1415
                        }
1416
                    )
1417
        return siw
1✔
1418

1419

1420
class OpenBabelNN(NearNeighbors):
1✔
1421
    """
1422
    Determine near-neighbor sites and bond orders using OpenBabel API.
1423

1424
    NOTE: This strategy is only appropriate for molecules, and not for
1425
    structures.
1426
    """
1427

1428
    @requires(
1✔
1429
        openbabel,
1430
        "BabelMolAdaptor requires openbabel to be installed with "
1431
        "Python bindings. Please get it at http://openbabel.org "
1432
        "(version >=3.0.0).",
1433
    )
1434
    def __init__(self, order=True):
1✔
1435
        """
1436
        Args:
1437
            order (bool): True if bond order should be returned as a weight, False
1438
            if bond length should be used as a weight.
1439
        """
1440
        self.order = order
×
1441

1442
    @property
1✔
1443
    def structures_allowed(self):
1✔
1444
        """
1445
        Boolean property: can this NearNeighbors class be used with Structure
1446
        objects?
1447
        """
1448
        return False
×
1449

1450
    @property
1✔
1451
    def molecules_allowed(self):
1✔
1452
        """
1453
        Boolean property: can this NearNeighbors class be used with Molecule
1454
        objects?
1455
        """
1456
        return True
×
1457

1458
    @property
1✔
1459
    def extend_structure_molecules(self):
1✔
1460
        """
1461
        Boolean property: Do Molecules need to be converted to Structures to use
1462
        this NearNeighbors class? Note: this property is not defined for classes
1463
        for which molecules_allowed is False.
1464
        """
1465
        return False
×
1466

1467
    def get_nn_info(self, structure: Structure, n: int):
1✔
1468
        """
1469
        Get all near-neighbor sites and weights (orders) of bonds for a given
1470
        atom.
1471

1472
        Args:
1473
            structure: Molecule object.
1474
            n: index of site for which to determine near neighbors.
1475

1476
        Returns:
1477
            (dict): representing a neighboring site and the type of
1478
            bond present between site n and the neighboring site.
1479
        """
1480
        from pymatgen.io.babel import BabelMolAdaptor
×
1481

1482
        obmol = BabelMolAdaptor(structure).openbabel_mol
×
1483

1484
        siw = []
×
1485

1486
        # Get only the atom of interest
1487
        site_atom = [
×
1488
            a
1489
            for i, a in enumerate(openbabel.OBMolAtomDFSIter(obmol))
1490
            if [a.GetX(), a.GetY(), a.GetZ()] == list(structure[n].coords)
1491
        ][0]
1492

1493
        for neighbor in openbabel.OBAtomAtomIter(site_atom):
×
1494
            coords = [neighbor.GetX(), neighbor.GetY(), neighbor.GetZ()]
×
1495
            site = [a for a in structure if list(a.coords) == coords][0]
×
1496
            index = structure.index(site)
×
1497

1498
            bond = site_atom.GetBond(neighbor)
×
1499

1500
            if self.order:
×
1501
                obmol.PerceiveBondOrders()
×
1502
                weight = bond.GetBondOrder()
×
1503
            else:
1504
                weight = bond.GetLength()
×
1505

1506
            siw.append(
×
1507
                {
1508
                    "site": site,
1509
                    "image": (0, 0, 0),
1510
                    "weight": weight,
1511
                    "site_index": index,
1512
                }
1513
            )
1514

1515
        return siw
×
1516

1517
    def get_bonded_structure(self, structure: Structure, decorate: bool = False) -> StructureGraph:  # type: ignore
1✔
1518
        """
1519
        Obtain a MoleculeGraph object using this NearNeighbor
1520
        class. Requires the optional dependency networkx
1521
        (pip install networkx).
1522

1523
        Args:
1524
            structure: Molecule object.
1525
            decorate (bool): whether to annotate site properties
1526
            with order parameters using neighbors determined by
1527
            this NearNeighbor class
1528

1529
        Returns: a pymatgen.analysis.graphs.MoleculeGraph object
1530
        """
1531
        if decorate:
×
1532
            # Decorate all sites in the underlying structure
1533
            # with site properties that provides information on the
1534
            # coordination number and coordination pattern based
1535
            # on the (current) structure of this graph.
1536
            order_parameters = [self.get_local_order_parameters(structure, n) for n in range(len(structure))]
×
1537
            structure.add_site_property("order_parameters", order_parameters)
×
1538

1539
        mg = MoleculeGraph.with_local_env_strategy(structure, self)
×
1540

1541
        return mg
×
1542

1543
    def get_nn_shell_info(self, structure: Structure, site_idx, shell):
1✔
1544
        """Get a certain nearest neighbor shell for a certain site.
1545

1546
        Determines all non-backtracking paths through the neighbor network
1547
        computed by `get_nn_info`. The weight is determined by multiplying
1548
        the weight of the neighbor at each hop through the network. For
1549
        example, a 2nd-nearest-neighbor that has a weight of 1 from its
1550
        1st-nearest-neighbor and weight 0.5 from the original site will
1551
        be assigned a weight of 0.5.
1552

1553
        As this calculation may involve computing the nearest neighbors of
1554
        atoms multiple times, the calculation starts by computing all of the
1555
        neighbor info and then calling `_get_nn_shell_info`. If you are likely
1556
        to call this method for more than one site, consider calling `get_all_nn`
1557
        first and then calling this protected method yourself.
1558

1559
        Args:
1560
            structure (Molecule): Input structure
1561
            site_idx (int): index of site for which to determine neighbor
1562
                information.
1563
            shell (int): Which neighbor shell to retrieve (1 == 1st NN shell)
1564
        Returns:
1565
            list of dictionaries. Each entry in the list is information about
1566
                a certain neighbor in the structure, in the same format as
1567
                `get_nn_info`.
1568
        """
1569
        all_nn_info = self.get_all_nn_info(structure)
×
1570
        sites = self._get_nn_shell_info(structure, all_nn_info, site_idx, shell)
×
1571

1572
        # Update the site positions
1573
        #   Did not do this during NN options because that can be slower
1574
        output = []
×
1575
        for info in sites:
×
1576
            orig_site = structure[info["site_index"]]
×
1577
            info["site"] = Site(orig_site.species, orig_site._coords, properties=orig_site.properties)
×
1578
            output.append(info)
×
1579
        return output
×
1580

1581

1582
class CovalentBondNN(NearNeighbors):
1✔
1583
    """
1584
    Determine near-neighbor sites and bond orders using built-in
1585
    pymatgen.Molecule CovalentBond functionality.
1586

1587
    NOTE: This strategy is only appropriate for molecules, and not for
1588
    structures.
1589
    """
1590

1591
    def __init__(self, tol: float = 0.2, order=True):
1✔
1592
        """
1593
        Args:
1594
            tol (float): Tolerance for covalent bond checking.
1595
            order (bool): If True (default), this class will compute bond
1596
                orders. If False, bond lengths will be computed
1597
        """
1598
        self.tol = tol
1✔
1599
        self.order = order
1✔
1600

1601
        self.bonds = None
1✔
1602

1603
    @property
1✔
1604
    def structures_allowed(self):
1✔
1605
        """
1606
        Boolean property: can this NearNeighbors class be used with Structure
1607
        objects?
1608
        """
1609
        return False
1✔
1610

1611
    @property
1✔
1612
    def molecules_allowed(self):
1✔
1613
        """
1614
        Boolean property: can this NearNeighbors class be used with Molecule
1615
        objects?
1616
        """
1617
        return True
1✔
1618

1619
    @property
1✔
1620
    def extend_structure_molecules(self):
1✔
1621
        """
1622
        Boolean property: Do Molecules need to be converted to Structures to use
1623
        this NearNeighbors class? Note: this property is not defined for classes
1624
        for which molecules_allowed is False.
1625
        """
1626
        return False
1✔
1627

1628
    def get_nn_info(self, structure: Structure, n: int):
1✔
1629
        """
1630
        Get all near-neighbor sites and weights (orders) of bonds for a given
1631
        atom.
1632

1633
        :param structure: input Molecule.
1634
        :param n: index of site for which to determine near neighbors.
1635
        :return: [dict] representing a neighboring site and the type of
1636
            bond present between site n and the neighboring site.
1637
        """
1638
        # This is unfortunately inefficient, but is the best way to fit the
1639
        # current NearNeighbors scheme
1640
        self.bonds = bonds = structure.get_covalent_bonds(tol=self.tol)
1✔
1641

1642
        siw = []
1✔
1643

1644
        for bond in bonds:
1✔
1645
            capture_bond = False
1✔
1646
            if bond.site1 == structure[n]:
1✔
1647
                site = bond.site2
1✔
1648
                capture_bond = True
1✔
1649
            elif bond.site2 == structure[n]:
1✔
1650
                site = bond.site1
1✔
1651
                capture_bond = True
1✔
1652

1653
            if capture_bond:
1✔
1654
                index = structure.index(site)
1✔
1655
                if self.order:
1✔
1656
                    weight = bond.get_bond_order()
1✔
1657
                else:
1658
                    weight = bond.length
1✔
1659

1660
                siw.append({"site": site, "image": (0, 0, 0), "weight": weight, "site_index": index})
1✔
1661

1662
        return siw
1✔
1663

1664
    def get_bonded_structure(self, structure: Structure, decorate: bool = False) -> MoleculeGraph:  # type: ignore
1✔
1665
        """
1666
        Obtain a MoleculeGraph object using this NearNeighbor
1667
        class.
1668

1669
        Args:
1670
            structure: Molecule object.
1671
            decorate (bool): whether to annotate site properties
1672
            with order parameters using neighbors determined by
1673
            this NearNeighbor class
1674

1675
        Returns: a pymatgen.analysis.graphs.MoleculeGraph object
1676
        """
1677
        # requires optional dependency which is why it's not a top-level import
1678
        from pymatgen.analysis.graphs import MoleculeGraph
1✔
1679

1680
        if decorate:
1✔
1681
            # Decorate all sites in the underlying structure
1682
            # with site properties that provides information on the
1683
            # coordination number and coordination pattern based
1684
            # on the (current) structure of this graph.
1685
            order_parameters = [self.get_local_order_parameters(structure, n) for n in range(len(structure))]
×
1686
            structure.add_site_property("order_parameters", order_parameters)
×
1687

1688
        mg = MoleculeGraph.with_local_env_strategy(structure, self)
1✔
1689

1690
        return mg
1✔
1691

1692
    def get_nn_shell_info(self, structure: Structure, site_idx, shell):
1✔
1693
        """Get a certain nearest neighbor shell for a certain site.
1694

1695
        Determines all non-backtracking paths through the neighbor network
1696
        computed by `get_nn_info`. The weight is determined by multiplying
1697
        the weight of the neighbor at each hop through the network. For
1698
        example, a 2nd-nearest-neighbor that has a weight of 1 from its
1699
        1st-nearest-neighbor and weight 0.5 from the original site will
1700
        be assigned a weight of 0.5.
1701

1702
        As this calculation may involve computing the nearest neighbors of
1703
        atoms multiple times, the calculation starts by computing all of the
1704
        neighbor info and then calling `_get_nn_shell_info`. If you are likely
1705
        to call this method for more than one site, consider calling `get_all_nn`
1706
        first and then calling this protected method yourself.
1707

1708
        Args:
1709
            structure (Molecule): Input structure
1710
            site_idx (int): index of site for which to determine neighbor
1711
                information.
1712
            shell (int): Which neighbor shell to retrieve (1 == 1st NN shell)
1713
        Returns:
1714
            list of dictionaries. Each entry in the list is information about
1715
                a certain neighbor in the structure, in the same format as
1716
                `get_nn_info`.
1717
        """
1718
        all_nn_info = self.get_all_nn_info(structure)
×
1719
        sites = self._get_nn_shell_info(structure, all_nn_info, site_idx, shell)
×
1720

1721
        # Update the site positions
1722
        #   Did not do this during NN options because that can be slower
1723
        output = []
×
1724
        for info in sites:
×
1725
            orig_site = structure[info["site_index"]]
×
1726
            info["site"] = Site(orig_site.species, orig_site._coords, properties=orig_site.properties)
×
1727
            output.append(info)
×
1728
        return output
×
1729

1730

1731
class MinimumOKeeffeNN(NearNeighbors):
1✔
1732
    """
1733
    Determine near-neighbor sites and coordination number using the
1734
    neighbor(s) at closest relative distance, d_min_OKeffee, plus some
1735
    relative tolerance, where bond valence parameters from O'Keeffe's
1736
    bond valence method (J. Am. Chem. Soc. 1991, 3226-3229) are used
1737
    to calculate relative distances.
1738
    """
1739

1740
    def __init__(self, tol: float = 0.1, cutoff=10.0):
1✔
1741
        """
1742
        Args:
1743
            tol (float): tolerance parameter for neighbor identification
1744
                (default: 0.1).
1745
            cutoff (float): cutoff radius in Angstrom to look for trial
1746
                near-neighbor sites (default: 10.0).
1747
        """
1748
        self.tol = tol
1✔
1749
        self.cutoff = cutoff
1✔
1750

1751
    @property
1✔
1752
    def structures_allowed(self):
1✔
1753
        """
1754
        Boolean property: can this NearNeighbors class be used with Structure
1755
        objects?
1756
        """
1757
        return True
1✔
1758

1759
    @property
1✔
1760
    def molecules_allowed(self):
1✔
1761
        """
1762
        Boolean property: can this NearNeighbors class be used with Molecule
1763
        objects?
1764
        """
1765
        return True
×
1766

1767
    @property
1✔
1768
    def extend_structure_molecules(self):
1✔
1769
        """
1770
        Boolean property: Do Molecules need to be converted to Structures to use
1771
        this NearNeighbors class? Note: this property is not defined for classes
1772
        for which molecules_allowed is False.
1773
        """
1774
        return True
×
1775

1776
    def get_nn_info(self, structure: Structure, n: int):
1✔
1777
        """
1778
        Get all near-neighbor sites as well as the associated image locations
1779
        and weights of the site with index n using the closest relative
1780
        neighbor distance-based method with O'Keeffe parameters.
1781

1782
        Args:
1783
            structure (Structure): input structure.
1784
            n (int): index of site for which to determine near
1785
                neighbors.
1786

1787
        Returns:
1788
            siw (list of tuples (Site, array, float)): tuples, each one
1789
                of which represents a neighbor site, its image location,
1790
                and its weight.
1791
        """
1792
        site = structure[n]
1✔
1793
        neighs_dists = structure.get_neighbors(site, self.cutoff)
1✔
1794
        try:
1✔
1795
            eln = site.specie.element
1✔
1796
        except Exception:
1✔
1797
            eln = site.species_string
1✔
1798

1799
        reldists_neighs = []
1✔
1800
        for nn in neighs_dists:
1✔
1801
            neigh = nn
1✔
1802
            dist = nn.nn_distance
1✔
1803
            try:
1✔
1804
                el2 = neigh.specie.element
1✔
1805
            except Exception:
1✔
1806
                el2 = neigh.species_string
1✔
1807
            reldists_neighs.append([dist / get_okeeffe_distance_prediction(eln, el2), neigh])
1✔
1808

1809
        siw = []
1✔
1810
        min_reldist = min(reldist for reldist, neigh in reldists_neighs)
1✔
1811
        for reldist, s in reldists_neighs:
1✔
1812
            if reldist < (1.0 + self.tol) * min_reldist:
1✔
1813
                w = min_reldist / reldist
1✔
1814
                siw.append(
1✔
1815
                    {
1816
                        "site": s,
1817
                        "image": self._get_image(structure, s),
1818
                        "weight": w,
1819
                        "site_index": self._get_original_site(structure, s),
1820
                    }
1821
                )
1822

1823
        return siw
1✔
1824

1825

1826
class MinimumVIRENN(NearNeighbors):
1✔
1827
    """
1828
    Determine near-neighbor sites and coordination number using the
1829
    neighbor(s) at closest relative distance, d_min_VIRE, plus some
1830
    relative tolerance, where atom radii from the
1831
    ValenceIonicRadiusEvaluator (VIRE) are used
1832
    to calculate relative distances.
1833
    """
1834

1835
    def __init__(self, tol: float = 0.1, cutoff=10.0):
1✔
1836
        """
1837
        Args:
1838
            tol (float): tolerance parameter for neighbor identification
1839
                (default: 0.1).
1840
            cutoff (float): cutoff radius in Angstrom to look for trial
1841
                near-neighbor sites (default: 10.0).
1842
        """
1843
        self.tol = tol
1✔
1844
        self.cutoff = cutoff
1✔
1845

1846
    @property
1✔
1847
    def structures_allowed(self):
1✔
1848
        """
1849
        Boolean property: can this NearNeighbors class be used with Structure
1850
        objects?
1851
        """
1852
        return True
×
1853

1854
    @property
1✔
1855
    def molecules_allowed(self):
1✔
1856
        """
1857
        Boolean property: can this NearNeighbors class be used with Molecule
1858
        objects?
1859
        """
1860
        return False
×
1861

1862
    def get_nn_info(self, structure: Structure, n: int):
1✔
1863
        """
1864
        Get all near-neighbor sites as well as the associated image locations
1865
        and weights of the site with index n using the closest relative
1866
        neighbor distance-based method with VIRE atomic/ionic radii.
1867

1868
        Args:
1869
            structure (Structure): input structure.
1870
            n (int): index of site for which to determine near
1871
                neighbors.
1872

1873
        Returns:
1874
            siw (list of tuples (Site, array, float)): tuples, each one
1875
                of which represents a neighbor site, its image location,
1876
                and its weight.
1877
        """
1878
        vire = _get_vire(structure)
1✔
1879
        site = vire.structure[n]
1✔
1880
        neighs_dists = vire.structure.get_neighbors(site, self.cutoff)
1✔
1881
        rn = vire.radii[vire.structure[n].species_string]
1✔
1882

1883
        reldists_neighs = []
1✔
1884
        for nn in neighs_dists:
1✔
1885
            reldists_neighs.append([nn.nn_distance / (vire.radii[nn.species_string] + rn), nn])
1✔
1886

1887
        siw = []
1✔
1888
        min_reldist = min(reldist for reldist, neigh in reldists_neighs)
1✔
1889
        for reldist, s in reldists_neighs:
1✔
1890
            if reldist < (1.0 + self.tol) * min_reldist:
1✔
1891
                w = min_reldist / reldist
1✔
1892
                siw.append(
1✔
1893
                    {
1894
                        "site": s,
1895
                        "image": self._get_image(vire.structure, s),
1896
                        "weight": w,
1897
                        "site_index": self._get_original_site(vire.structure, s),
1898
                    }
1899
                )
1900

1901
        return siw
1✔
1902

1903

1904
def _get_vire(structure: Structure | IStructure):
1✔
1905
    """Get the ValenceIonicRadiusEvaluator object for an structure taking
1906
    advantage of caching.
1907

1908
    Args:
1909
        structure: A structure.
1910

1911
    Returns:
1912
        Output of `ValenceIonicRadiusEvaluator(structure)`
1913
    """
1914
    # pymatgen does not hash Structure objects, so we need
1915
    # to cast from Structure to the immutable IStructure
1916
    if isinstance(structure, Structure):
1✔
1917
        structure = IStructure.from_sites(structure)
1✔
1918

1919
    return _get_vire_istructure(structure)
1✔
1920

1921

1922
@lru_cache(maxsize=1)
1✔
1923
def _get_vire_istructure(structure: IStructure):
1✔
1924
    """Get the ValenceIonicRadiusEvaluator object for an immutable structure
1925
    taking advantage of caching.
1926

1927
    Args:
1928
        structure: A structure.
1929

1930
    Returns:
1931
        Output of `ValenceIonicRadiusEvaluator(structure)`
1932
    """
1933
    return ValenceIonicRadiusEvaluator(structure)
1✔
1934

1935

1936
def solid_angle(center, coords):
1✔
1937
    """
1938
    Helper method to calculate the solid angle of a set of coords from the
1939
    center.
1940

1941
    Args:
1942
        center (3x1 array): Center to measure solid angle from.
1943
        coords (Nx3 array): List of coords to determine solid angle.
1944

1945
    Returns:
1946
        The solid angle.
1947
    """
1948

1949
    # Compute the displacement from the center
1950
    r = [np.subtract(c, center) for c in coords]
1✔
1951

1952
    # Compute the magnitude of each vector
1953
    r_norm = [np.linalg.norm(i) for i in r]
1✔
1954

1955
    # Compute the solid angle for each tetrahedron that makes up the facet
1956
    #  Following: https://en.wikipedia.org/wiki/Solid_angle#Tetrahedron
1957
    angle = 0
1✔
1958
    for i in range(1, len(r) - 1):
1✔
1959
        j = i + 1
1✔
1960
        tp = np.abs(np.dot(r[0], np.cross(r[i], r[j])))
1✔
1961
        de = (
1✔
1962
            r_norm[0] * r_norm[i] * r_norm[j]
1963
            + r_norm[j] * np.dot(r[0], r[i])
1964
            + r_norm[i] * np.dot(r[0], r[j])
1965
            + r_norm[0] * np.dot(r[i], r[j])
1966
        )
1967
        if de == 0:
1✔
1968
            my_angle = 0.5 * pi if tp > 0 else -0.5 * pi
1✔
1969
        else:
1970
            my_angle = np.arctan(tp / de)
1✔
1971
        angle += (my_angle if my_angle > 0 else my_angle + np.pi) * 2
1✔
1972

1973
    return angle
1✔
1974

1975

1976
def vol_tetra(vt1, vt2, vt3, vt4):
1✔
1977
    """
1978
    Calculate the volume of a tetrahedron, given the four vertices of vt1,
1979
    vt2, vt3 and vt4.
1980
    Args:
1981
        vt1 (array-like): coordinates of vertex 1.
1982
        vt2 (array-like): coordinates of vertex 2.
1983
        vt3 (array-like): coordinates of vertex 3.
1984
        vt4 (array-like): coordinates of vertex 4.
1985
    Returns:
1986
        (float): volume of the tetrahedron.
1987
    """
1988
    vol_tetra = np.abs(np.dot((vt1 - vt4), np.cross((vt2 - vt4), (vt3 - vt4)))) / 6
1✔
1989
    return vol_tetra
1✔
1990

1991

1992
def get_okeeffe_params(el_symbol):
1✔
1993
    """
1994
    Returns the elemental parameters related to atom size and
1995
    electronegativity which are used for estimating bond-valence
1996
    parameters (bond length) of pairs of atoms on the basis of data
1997
    provided in 'Atoms Sizes and Bond Lengths in Molecules and Crystals'
1998
    (O'Keeffe & Brese, 1991).
1999

2000
    Args:
2001
        el_symbol (str): element symbol.
2002
    Returns:
2003
        (dict): atom-size ('r') and electronegativity-related ('c')
2004
                parameter.
2005
    """
2006

2007
    el = Element(el_symbol)
1✔
2008
    if el not in list(BV_PARAMS):
1✔
2009
        raise RuntimeError(
×
2010
            "Could not find O'Keeffe parameters for element"
2011
            f' {el_symbol!r} in "BV_PARAMS"dictionary'
2012
            " provided by pymatgen"
2013
        )
2014

2015
    return BV_PARAMS[el]
1✔
2016

2017

2018
def get_okeeffe_distance_prediction(el1, el2):
1✔
2019
    """
2020
    Returns an estimate of the bond valence parameter (bond length) using
2021
    the derived parameters from 'Atoms Sizes and Bond Lengths in Molecules
2022
    and Crystals' (O'Keeffe & Brese, 1991). The estimate is based on two
2023
    experimental parameters: r and c. The value for r  is based off radius,
2024
    while c is (usually) the Allred-Rochow electronegativity. Values used
2025
    are *not* generated from pymatgen, and are found in
2026
    'okeeffe_params.json'.
2027

2028
    Args:
2029
        el1, el2 (Element): two Element objects
2030
    Returns:
2031
        a float value of the predicted bond length
2032
    """
2033
    el1_okeeffe_params = get_okeeffe_params(el1)
1✔
2034
    el2_okeeffe_params = get_okeeffe_params(el2)
1✔
2035

2036
    r1 = el1_okeeffe_params["r"]
1✔
2037
    r2 = el2_okeeffe_params["r"]
1✔
2038
    c1 = el1_okeeffe_params["c"]
1✔
2039
    c2 = el2_okeeffe_params["c"]
1✔
2040

2041
    return r1 + r2 - r1 * r2 * pow(sqrt(c1) - sqrt(c2), 2) / (c1 * r1 + c2 * r2)
1✔
2042

2043

2044
def get_neighbors_of_site_with_index(struct, n, approach="min_dist", delta=0.1, cutoff=10.0):
1✔
2045
    """
2046
    Returns the neighbors of a given site using a specific neighbor-finding
2047
    method.
2048

2049
    Args:
2050
        struct (Structure): input structure.
2051
        n (int): index of site in Structure object for which motif type
2052
            is to be determined.
2053
        approach (str): type of neighbor-finding approach, where
2054
            "min_dist" will use the MinimumDistanceNN class,
2055
            "voronoi" the VoronoiNN class, "min_OKeeffe" the
2056
            MinimumOKeeffe class, and "min_VIRE" the MinimumVIRENN class.
2057
        delta (float): tolerance involved in neighbor finding.
2058
        cutoff (float): (large) radius to find tentative neighbors.
2059

2060
    Returns: neighbor sites.
2061
    """
2062
    if approach == "min_dist":
1✔
2063
        return MinimumDistanceNN(tol=delta, cutoff=cutoff).get_nn(struct, n)
1✔
2064

2065
    if approach == "voronoi":
1✔
2066
        return VoronoiNN(tol=delta, cutoff=cutoff).get_nn(struct, n)
1✔
2067

2068
    if approach == "min_OKeeffe":
1✔
2069
        return MinimumOKeeffeNN(tol=delta, cutoff=cutoff).get_nn(struct, n)
1✔
2070

2071
    if approach == "min_VIRE":
1✔
2072
        return MinimumVIRENN(tol=delta, cutoff=cutoff).get_nn(struct, n)
1✔
2073
    raise RuntimeError(f"unsupported neighbor-finding method ({approach}).")
×
2074

2075

2076
def site_is_of_motif_type(struct, n, approach="min_dist", delta=0.1, cutoff=10.0, thresh=None):
1✔
2077
    """
2078
    Returns the motif type of the site with index n in structure struct;
2079
    currently featuring "tetrahedral", "octahedral", "bcc", and "cp"
2080
    (close-packed: fcc and hcp) as well as "square pyramidal" and
2081
    "trigonal bipyramidal". If the site is not recognized,
2082
    "unrecognized" is returned. If a site should be assigned to two
2083
    different motifs, "multiple assignments" is returned.
2084

2085
    Args:
2086
        struct (Structure): input structure.
2087
        n (int): index of site in Structure object for which motif type
2088
                is to be determined.
2089
        approach (str): type of neighbor-finding approach, where
2090
              "min_dist" will use the MinimumDistanceNN class,
2091
              "voronoi" the VoronoiNN class, "min_OKeeffe" the
2092
              MinimumOKeeffe class, and "min_VIRE" the MinimumVIRENN class.
2093
        delta (float): tolerance involved in neighbor finding.
2094
        cutoff (float): (large) radius to find tentative neighbors.
2095
        thresh (dict): thresholds for motif criteria (currently, required
2096
                keys and their default values are "qtet": 0.5,
2097
                "qoct": 0.5, "qbcc": 0.5, "q6": 0.4).
2098

2099
    Returns: motif type (str).
2100
    """
2101

2102
    if thresh is None:
1✔
2103
        thresh = {
1✔
2104
            "qtet": 0.5,
2105
            "qoct": 0.5,
2106
            "qbcc": 0.5,
2107
            "q6": 0.4,
2108
            "qtribipyr": 0.8,
2109
            "qsqpyr": 0.8,
2110
        }
2111

2112
    ops = LocalStructOrderParams(["cn", "tet", "oct", "bcc", "q6", "sq_pyr", "tri_bipyr"])
1✔
2113

2114
    neighs_cent = get_neighbors_of_site_with_index(struct, n, approach=approach, delta=delta, cutoff=cutoff)
1✔
2115
    neighs_cent.append(struct.sites[n])
1✔
2116
    opvals = ops.get_order_parameters(
1✔
2117
        neighs_cent,
2118
        len(neighs_cent) - 1,
2119
        indices_neighs=list(range(len(neighs_cent) - 1)),
2120
    )
2121
    cn = int(opvals[0] + 0.5)
1✔
2122
    motif_type = "unrecognized"
1✔
2123
    nmotif = 0
1✔
2124

2125
    if cn == 4 and opvals[1] > thresh["qtet"]:
1✔
2126
        motif_type = "tetrahedral"
1✔
2127
        nmotif += 1
1✔
2128
    if cn == 5 and opvals[5] > thresh["qsqpyr"]:
1✔
2129
        motif_type = "square pyramidal"
1✔
2130
        nmotif += 1
1✔
2131
    if cn == 5 and opvals[6] > thresh["qtribipyr"]:
1✔
2132
        motif_type = "trigonal bipyramidal"
1✔
2133
        nmotif += 1
1✔
2134
    if cn == 6 and opvals[2] > thresh["qoct"]:
1✔
2135
        motif_type = "octahedral"
1✔
2136
        nmotif += 1
1✔
2137
    if cn == 8 and (opvals[3] > thresh["qbcc"] and opvals[1] < thresh["qtet"]):
1✔
2138
        motif_type = "bcc"
1✔
2139
        nmotif += 1
1✔
2140
    if cn == 12 and (
1✔
2141
        opvals[4] > thresh["q6"] and opvals[1] < thresh["q6"] and opvals[2] < thresh["q6"] and opvals[3] < thresh["q6"]
2142
    ):
2143
        motif_type = "cp"
×
2144
        nmotif += 1
×
2145

2146
    if nmotif > 1:
1✔
2147
        motif_type = "multiple assignments"
×
2148

2149
    return motif_type
1✔
2150

2151

2152
def gramschmidt(vin, uin):
1✔
2153
    """
2154
    Returns that part of the first input vector
2155
    that is orthogonal to the second input vector.
2156
    The output vector is not normalized.
2157

2158
    Args:
2159
        vin (numpy array):
2160
            first input vector
2161
        uin (numpy array):
2162
            second input vector
2163
    """
2164

2165
    vin_uin = np.inner(vin, uin)
1✔
2166
    uin_uin = np.inner(uin, uin)
1✔
2167
    if uin_uin <= 0.0:
1✔
2168
        raise ValueError("Zero or negative inner product!")
×
2169
    return vin - (vin_uin / uin_uin) * uin
1✔
2170

2171

2172
class LocalStructOrderParams:
1✔
2173
    """
2174
    This class permits the calculation of various types of local
2175
    structure order parameters.
2176
    """
2177

2178
    __supported_types = (
1✔
2179
        "cn",
2180
        "sgl_bd",
2181
        "bent",
2182
        "tri_plan",
2183
        "tri_plan_max",
2184
        "reg_tri",
2185
        "sq_plan",
2186
        "sq_plan_max",
2187
        "pent_plan",
2188
        "pent_plan_max",
2189
        "sq",
2190
        "tet",
2191
        "tet_max",
2192
        "tri_pyr",
2193
        "sq_pyr",
2194
        "sq_pyr_legacy",
2195
        "tri_bipyr",
2196
        "sq_bipyr",
2197
        "oct",
2198
        "oct_legacy",
2199
        "pent_pyr",
2200
        "hex_pyr",
2201
        "pent_bipyr",
2202
        "hex_bipyr",
2203
        "T",
2204
        "cuboct",
2205
        "cuboct_max",
2206
        "see_saw_rect",
2207
        "bcc",
2208
        "q2",
2209
        "q4",
2210
        "q6",
2211
        "oct_max",
2212
        "hex_plan_max",
2213
        "sq_face_cap_trig_pris",
2214
    )
2215

2216
    def __init__(self, types, parameters=None, cutoff=-10.0):
1✔
2217
        """
2218
        Args:
2219
            types ([string]): list of strings representing the types of
2220
                order parameters to be calculated. Note that multiple
2221
                mentions of the same type may occur. Currently available
2222
                types recognize following environments:
2223
                  "cn": simple coordination number---normalized
2224
                        if desired;
2225
                  "sgl_bd": single bonds;
2226
                  "bent": bent (angular) coordinations
2227
                          (Zimmermann & Jain, in progress, 2017);
2228
                  "T": T-shape coordinations;
2229
                  "see_saw_rect": see saw-like coordinations;
2230
                  "tet": tetrahedra
2231
                         (Zimmermann et al., submitted, 2017);
2232
                  "oct": octahedra
2233
                         (Zimmermann et al., submitted, 2017);
2234
                  "bcc": body-centered cubic environments (Peters,
2235
                         J. Chem. Phys., 131, 244103, 2009);
2236
                  "tri_plan": trigonal planar environments;
2237
                  "sq_plan": square planar environments;
2238
                  "pent_plan": pentagonal planar environments;
2239
                  "tri_pyr": trigonal pyramids (coordinated atom is in
2240
                             the center of the basal plane);
2241
                  "sq_pyr": square pyramids;
2242
                  "pent_pyr": pentagonal pyramids;
2243
                  "hex_pyr": hexagonal pyramids;
2244
                  "tri_bipyr": trigonal bipyramids;
2245
                  "sq_bipyr": square bipyramids;
2246
                  "pent_bipyr": pentagonal bipyramids;
2247
                  "hex_bipyr": hexagonal bipyramids;
2248
                  "cuboct": cuboctahedra;
2249
                  "q2": motif-unspecific bond orientational order
2250
                        parameter (BOOP) of weight l=2 (Steinhardt
2251
                        et al., Phys. Rev. B, 28, 784-805, 1983);
2252
                  "q4": BOOP of weight l=4;
2253
                  "q6": BOOP of weight l=6.
2254
                  "reg_tri": regular triangle with varying height
2255
                             to basal plane;
2256
                  "sq": square coordination (cf., "reg_tri");
2257
                  "oct_legacy": original Peters-style OP recognizing
2258
                                octahedral coordination environments
2259
                                (Zimmermann et al., J. Am. Chem. Soc.,
2260
                                137, 13352-13361, 2015) that can, however,
2261
                                produce small negative values sometimes.
2262
                  "sq_pyr_legacy": square pyramids (legacy);
2263
            parameters ([dict]): list of dictionaries
2264
                that store float-type parameters associated with the
2265
                definitions of the different order parameters
2266
                (length of list = number of OPs). If an entry
2267
                is None, default values are used that are read from
2268
                the op_params.yaml file. With few exceptions, 9 different
2269
                parameters are used across all OPs:
2270
                  "norm": normalizing constant (used in "cn"
2271
                      (default value: 1)).
2272
                  "TA": target angle (TA) in fraction of 180 degrees
2273
                      ("bent" (1), "tet" (0.6081734479693927),
2274
                      "tri_plan" (0.66666666667), "pent_plan" (0.6),
2275
                      "sq_pyr_legacy" (0.5)).
2276
                  "IGW_TA": inverse Gaussian width (IGW) for penalizing
2277
                      angles away from the target angle in inverse
2278
                      fractions of 180 degrees to ("bent" and "tet" (15),
2279
                      "tri_plan" (13.5), "pent_plan" (18),
2280
                      "sq_pyr_legacy" (30)).
2281
                  "IGW_EP": IGW for penalizing angles away from the
2282
                      equatorial plane (EP) at 90 degrees ("T", "see_saw_rect",
2283
                      "oct", "sq_plan", "tri_pyr", "sq_pyr", "pent_pyr",
2284
                      "hex_pyr", "tri_bipyr", "sq_bipyr", "pent_bipyr",
2285
                      "hex_bipyr", and "oct_legacy" (18)).
2286
                  "fac_AA": factor applied to azimuth angle (AA) in cosine
2287
                      term ("T", "tri_plan", and "sq_plan" (1), "tet",
2288
                      "tri_pyr", and "tri_bipyr" (1.5), "oct", "sq_pyr",
2289
                      "sq_bipyr", and "oct_legacy" (2), "pent_pyr"
2290
                      and "pent_bipyr" (2.5), "hex_pyr" and
2291
                      "hex_bipyr" (3)).
2292
                  "exp_cos_AA": exponent applied to cosine term of AA
2293
                      ("T", "tet", "oct", "tri_plan", "sq_plan",
2294
                      "tri_pyr", "sq_pyr", "pent_pyr", "hex_pyr",
2295
                      "tri_bipyr", "sq_bipyr", "pent_bipyr", "hex_bipyr",
2296
                      and "oct_legacy" (2)).
2297
                  "min_SPP": smallest angle (in radians) to consider
2298
                      a neighbor to be
2299
                      at South pole position ("see_saw_rect", "oct", "bcc",
2300
                      "sq_plan", "tri_bipyr", "sq_bipyr", "pent_bipyr",
2301
                      "hex_bipyr", "cuboct", and "oct_legacy"
2302
                      (2.792526803190927)).
2303
                  "IGW_SPP": IGW for penalizing angles away from South
2304
                      pole position ("see_saw_rect", "oct", "bcc", "sq_plan",
2305
                      "tri_bipyr", "sq_bipyr", "pent_bipyr", "hex_bipyr",
2306
                      "cuboct", and "oct_legacy" (15)).
2307
                  "w_SPP": weight for South pole position relative to
2308
                      equatorial positions ("see_saw_rect" and "sq_plan" (1),
2309
                      "cuboct" (1.8), "tri_bipyr" (2), "oct",
2310
                      "sq_bipyr", and "oct_legacy" (3), "pent_bipyr" (4),
2311
                      "hex_bipyr" (5), "bcc" (6)).
2312
            cutoff (float): Cutoff radius to determine which nearest
2313
                neighbors are supposed to contribute to the order
2314
                parameters. If the value is negative the neighboring
2315
                sites found by distance and cutoff radius are further
2316
                pruned using the get_nn method from the
2317
                VoronoiNN class.
2318
        """
2319
        for t in types:
1✔
2320
            if t not in LocalStructOrderParams.__supported_types:
1✔
2321
                raise ValueError("Unknown order parameter type (" + t + ")!")
×
2322
        self._types = tuple(types)
1✔
2323

2324
        self._comp_azi = False
1✔
2325
        self._params = []
1✔
2326
        for i, t in enumerate(self._types):
1✔
2327
            d = deepcopy(default_op_params[t]) if default_op_params[t] is not None else None
1✔
2328
            if parameters is None:
1✔
2329
                self._params.append(d)
1✔
2330
            elif parameters[i] is None:
1✔
2331
                self._params.append(d)
1✔
2332
            else:
2333
                self._params.append(deepcopy(parameters[i]))
1✔
2334

2335
        self._computerijs = self._computerjks = self._geomops = False
1✔
2336
        self._geomops2 = self._boops = False
1✔
2337
        self._max_trig_order = -1
1✔
2338

2339
        # Add here any additional flags to be used during calculation.
2340
        if "sgl_bd" in self._types:
1✔
2341
            self._computerijs = True
1✔
2342
        if not set(self._types).isdisjoint(
1✔
2343
            [
2344
                "tet",
2345
                "oct",
2346
                "bcc",
2347
                "sq_pyr",
2348
                "sq_pyr_legacy",
2349
                "tri_bipyr",
2350
                "sq_bipyr",
2351
                "oct_legacy",
2352
                "tri_plan",
2353
                "sq_plan",
2354
                "pent_plan",
2355
                "tri_pyr",
2356
                "pent_pyr",
2357
                "hex_pyr",
2358
                "pent_bipyr",
2359
                "hex_bipyr",
2360
                "T",
2361
                "cuboct",
2362
                "oct_max",
2363
                "tet_max",
2364
                "tri_plan_max",
2365
                "sq_plan_max",
2366
                "pent_plan_max",
2367
                "cuboct_max",
2368
                "bent",
2369
                "see_saw_rect",
2370
                "hex_plan_max",
2371
                "sq_face_cap_trig_pris",
2372
            ]
2373
        ):
2374
            self._computerijs = self._geomops = True
1✔
2375
        if "sq_face_cap_trig_pris" in self._types:
1✔
2376
            self._comp_azi = True
1✔
2377
        if not set(self._types).isdisjoint(["reg_tri", "sq"]):
1✔
2378
            self._computerijs = self._computerjks = self._geomops2 = True
1✔
2379
        if not set(self._types).isdisjoint(["q2", "q4", "q6"]):
1✔
2380
            self._computerijs = self._boops = True
1✔
2381
        if "q2" in self._types:
1✔
2382
            self._max_trig_order = 2
1✔
2383
        if "q4" in self._types:
1✔
2384
            self._max_trig_order = 4
1✔
2385
        if "q6" in self._types:
1✔
2386
            self._max_trig_order = 6
1✔
2387

2388
        # Finish parameter treatment.
2389
        if cutoff < 0.0:
1✔
2390
            self._cutoff = -cutoff
1✔
2391
            self._voroneigh = True
1✔
2392
        elif cutoff > 0.0:
1✔
2393
            self._cutoff = cutoff
1✔
2394
            self._voroneigh = False
1✔
2395
        else:
2396
            raise ValueError("Cutoff radius is zero!")
×
2397

2398
        # Further variable definitions.
2399
        self._last_nneigh = -1
1✔
2400
        self._pow_sin_t = {}
1✔
2401
        self._pow_cos_t = {}
1✔
2402
        self._sin_n_p = {}
1✔
2403
        self._cos_n_p = {}
1✔
2404

2405
    @property
1✔
2406
    def num_ops(self):
1✔
2407
        """
2408
        Returns:
2409
            int: the number of different order parameters that are targeted
2410
                to be calculated.
2411
        """
2412
        return len(self._types)
×
2413

2414
    @property
1✔
2415
    def last_nneigh(self):
1✔
2416
        """
2417
        Returns:
2418
            int: the number of neighbors encountered during the most
2419
                recent order parameter calculation. A value of -1 indicates
2420
                that no such calculation has yet been performed for this
2421
                instance.
2422
        """
2423
        return len(self._last_nneigh)
×
2424

2425
    def compute_trigonometric_terms(self, thetas, phis):
1✔
2426
        """
2427
        Computes trigonometric terms that are required to
2428
        calculate bond orientational order parameters using
2429
        internal variables.
2430

2431
        Args:
2432
            thetas ([float]): polar angles of all neighbors in radians.
2433
            phis ([float]): azimuth angles of all neighbors in radians.
2434
                The list of
2435
                azimuth angles of all neighbors in radians. The list of
2436
                azimuth angles is expected to have the same size as the
2437
                list of polar angles; otherwise, a ValueError is raised.
2438
                Also, the two lists of angles have to be coherent in
2439
                order. That is, it is expected that the order in the list
2440
                of azimuth angles corresponds to a distinct sequence of
2441
                neighbors. And, this sequence has to equal the sequence
2442
                of neighbors in the list of polar angles.
2443
        """
2444
        if len(thetas) != len(phis):
1✔
2445
            raise ValueError("List of polar and azimuthal angles have to be equal!")
×
2446

2447
        self._pow_sin_t.clear()
1✔
2448
        self._pow_cos_t.clear()
1✔
2449
        self._sin_n_p.clear()
1✔
2450
        self._cos_n_p.clear()
1✔
2451

2452
        self._pow_sin_t[1] = [sin(float(t)) for t in thetas]
1✔
2453
        self._pow_cos_t[1] = [cos(float(t)) for t in thetas]
1✔
2454
        self._sin_n_p[1] = [sin(float(p)) for p in phis]
1✔
2455
        self._cos_n_p[1] = [cos(float(p)) for p in phis]
1✔
2456

2457
        for i in range(2, self._max_trig_order + 1):
1✔
2458
            self._pow_sin_t[i] = [e[0] * e[1] for e in zip(self._pow_sin_t[i - 1], self._pow_sin_t[1])]
1✔
2459
            self._pow_cos_t[i] = [e[0] * e[1] for e in zip(self._pow_cos_t[i - 1], self._pow_cos_t[1])]
1✔
2460
            self._sin_n_p[i] = [sin(float(i) * float(p)) for p in phis]
1✔
2461
            self._cos_n_p[i] = [cos(float(i) * float(p)) for p in phis]
1✔
2462

2463
    def get_q2(self, thetas=None, phis=None):
1✔
2464
        """
2465
        Calculates the value of the bond orientational order parameter of
2466
        weight l=2. If the function is called with non-empty lists of
2467
        polar and azimuthal angles the corresponding trigonometric terms
2468
        are computed afresh. Otherwise, it is expected that the
2469
        compute_trigonometric_terms function has been just called.
2470

2471
        Args:
2472
            thetas ([float]): polar angles of all neighbors in radians.
2473
            phis ([float]): azimuth angles of all neighbors in radians.
2474

2475
        Returns:
2476
            float: bond orientational order parameter of weight l=2
2477
                corresponding to the input angles thetas and phis.
2478
        """
2479
        if thetas is not None and phis is not None:
1✔
2480
            self.compute_trigonometric_terms(thetas, phis)
1✔
2481
        nnn = len(self._pow_sin_t[1])
1✔
2482
        nnn_range = range(nnn)
1✔
2483

2484
        sqrt_15_2pi = sqrt(15.0 / (2 * pi))
1✔
2485
        sqrt_5_pi = sqrt(5.0 / pi)
1✔
2486

2487
        pre_y_2_2 = [0.25 * sqrt_15_2pi * val for val in self._pow_sin_t[2]]
1✔
2488
        pre_y_2_1 = [0.5 * sqrt_15_2pi * val[0] * val[1] for val in zip(self._pow_sin_t[1], self._pow_cos_t[1])]
1✔
2489

2490
        acc = 0.0
1✔
2491

2492
        # Y_2_-2
2493
        real = imag = 0.0
1✔
2494
        for i in nnn_range:
1✔
2495
            real += pre_y_2_2[i] * self._cos_n_p[2][i]
1✔
2496
            imag -= pre_y_2_2[i] * self._sin_n_p[2][i]
1✔
2497
        acc += real * real + imag * imag
1✔
2498

2499
        # Y_2_-1
2500
        real = imag = 0.0
1✔
2501
        for i in nnn_range:
1✔
2502
            real += pre_y_2_1[i] * self._cos_n_p[1][i]
1✔
2503
            imag -= pre_y_2_1[i] * self._sin_n_p[1][i]
1✔
2504
        acc += real * real + imag * imag
1✔
2505

2506
        # Y_2_0
2507
        real = imag = 0.0
1✔
2508
        for i in nnn_range:
1✔
2509
            real += 0.25 * sqrt_5_pi * (3 * self._pow_cos_t[2][i] - 1.0)
1✔
2510
        acc += real * real
1✔
2511

2512
        # Y_2_1
2513
        real = imag = 0.0
1✔
2514
        for i in nnn_range:
1✔
2515
            real -= pre_y_2_1[i] * self._cos_n_p[1][i]
1✔
2516
            imag -= pre_y_2_1[i] * self._sin_n_p[1][i]
1✔
2517
        acc += real * real + imag * imag
1✔
2518

2519
        # Y_2_2
2520
        real = imag = 0.0
1✔
2521
        for i in nnn_range:
1✔
2522
            real += pre_y_2_2[i] * self._cos_n_p[2][i]
1✔
2523
            imag += pre_y_2_2[i] * self._sin_n_p[2][i]
1✔
2524
        acc += real * real + imag * imag
1✔
2525

2526
        q2 = sqrt(4 * pi * acc / (5 * float(nnn * nnn)))
1✔
2527
        return q2
1✔
2528

2529
    def get_q4(self, thetas=None, phis=None):
1✔
2530
        """
2531
        Calculates the value of the bond orientational order parameter of
2532
        weight l=4. If the function is called with non-empty lists of
2533
        polar and azimuthal angles the corresponding trigonometric terms
2534
        are computed afresh. Otherwise, it is expected that the
2535
        compute_trigonometric_terms function has been just called.
2536

2537
        Args:
2538
            thetas ([float]): polar angles of all neighbors in radians.
2539
            phis ([float]): azimuth angles of all neighbors in radians.
2540

2541
        Returns:
2542
            float: bond orientational order parameter of weight l=4
2543
                corresponding to the input angles thetas and phis.
2544
        """
2545
        if thetas is not None and phis is not None:
1✔
2546
            self.compute_trigonometric_terms(thetas, phis)
1✔
2547
        nnn = len(self._pow_sin_t[1])
1✔
2548
        nnn_range = range(nnn)
1✔
2549

2550
        i16_3 = 3.0 / 16.0
1✔
2551
        i8_3 = 3.0 / 8.0
1✔
2552

2553
        sqrt_35_pi = sqrt(35.0 / pi)
1✔
2554
        sqrt_35_2pi = sqrt(35.0 / (2 * pi))
1✔
2555
        sqrt_5_pi = sqrt(5.0 / pi)
1✔
2556
        sqrt_5_2pi = sqrt(5.0 / (2 * pi))
1✔
2557
        sqrt_1_pi = sqrt(1.0 / pi)
1✔
2558

2559
        pre_y_4_4 = [i16_3 * sqrt_35_2pi * val for val in self._pow_sin_t[4]]
1✔
2560
        pre_y_4_3 = [i8_3 * sqrt_35_pi * val[0] * val[1] for val in zip(self._pow_sin_t[3], self._pow_cos_t[1])]
1✔
2561
        pre_y_4_2 = [
1✔
2562
            i8_3 * sqrt_5_2pi * val[0] * (7 * val[1] - 1.0) for val in zip(self._pow_sin_t[2], self._pow_cos_t[2])
2563
        ]
2564
        pre_y_4_1 = [
1✔
2565
            i8_3 * sqrt_5_pi * val[0] * (7 * val[1] - 3 * val[2])
2566
            for val in zip(self._pow_sin_t[1], self._pow_cos_t[3], self._pow_cos_t[1])
2567
        ]
2568

2569
        acc = 0.0
1✔
2570

2571
        # Y_4_-4
2572
        real = imag = 0.0
1✔
2573
        for i in nnn_range:
1✔
2574
            real += pre_y_4_4[i] * self._cos_n_p[4][i]
1✔
2575
            imag -= pre_y_4_4[i] * self._sin_n_p[4][i]
1✔
2576
        acc += real * real + imag * imag
1✔
2577

2578
        # Y_4_-3
2579
        real = imag = 0.0
1✔
2580
        for i in nnn_range:
1✔
2581
            real += pre_y_4_3[i] * self._cos_n_p[3][i]
1✔
2582
            imag -= pre_y_4_3[i] * self._sin_n_p[3][i]
1✔
2583
        acc += real * real + imag * imag
1✔
2584

2585
        # Y_4_-2
2586
        real = imag = 0.0
1✔
2587
        for i in nnn_range:
1✔
2588
            real += pre_y_4_2[i] * self._cos_n_p[2][i]
1✔
2589
            imag -= pre_y_4_2[i] * self._sin_n_p[2][i]
1✔
2590
        acc += real * real + imag * imag
1✔
2591

2592
        # Y_4_-1
2593
        real = imag = 0.0
1✔
2594
        for i in nnn_range:
1✔
2595
            real += pre_y_4_1[i] * self._cos_n_p[1][i]
1✔
2596
            imag -= pre_y_4_1[i] * self._sin_n_p[1][i]
1✔
2597
        acc += real * real + imag * imag
1✔
2598

2599
        # Y_4_0
2600
        real = imag = 0.0
1✔
2601
        for i in nnn_range:
1✔
2602
            real += i16_3 * sqrt_1_pi * (35 * self._pow_cos_t[4][i] - 30 * self._pow_cos_t[2][i] + 3.0)
1✔
2603
        acc += real * real
1✔
2604

2605
        # Y_4_1
2606
        real = imag = 0.0
1✔
2607
        for i in nnn_range:
1✔
2608
            real -= pre_y_4_1[i] * self._cos_n_p[1][i]
1✔
2609
            imag -= pre_y_4_1[i] * self._sin_n_p[1][i]
1✔
2610
        acc += real * real + imag * imag
1✔
2611

2612
        # Y_4_2
2613
        real = imag = 0.0
1✔
2614
        for i in nnn_range:
1✔
2615
            real += pre_y_4_2[i] * self._cos_n_p[2][i]
1✔
2616
            imag += pre_y_4_2[i] * self._sin_n_p[2][i]
1✔
2617
        acc += real * real + imag * imag
1✔
2618

2619
        # Y_4_3
2620
        real = imag = 0.0
1✔
2621
        for i in nnn_range:
1✔
2622
            real -= pre_y_4_3[i] * self._cos_n_p[3][i]
1✔
2623
            imag -= pre_y_4_3[i] * self._sin_n_p[3][i]
1✔
2624
        acc += real * real + imag * imag
1✔
2625

2626
        # Y_4_4
2627
        real = imag = 0.0
1✔
2628
        for i in nnn_range:
1✔
2629
            real += pre_y_4_4[i] * self._cos_n_p[4][i]
1✔
2630
            imag += pre_y_4_4[i] * self._sin_n_p[4][i]
1✔
2631
        acc += real * real + imag * imag
1✔
2632

2633
        q4 = sqrt(4 * pi * acc / (9 * float(nnn * nnn)))
1✔
2634
        return q4
1✔
2635

2636
    def get_q6(self, thetas=None, phis=None):
1✔
2637
        """
2638
        Calculates the value of the bond orientational order parameter of
2639
        weight l=6. If the function is called with non-empty lists of
2640
        polar and azimuthal angles the corresponding trigonometric terms
2641
        are computed afresh. Otherwise, it is expected that the
2642
        compute_trigonometric_terms function has been just called.
2643

2644
        Args:
2645
            thetas ([float]): polar angles of all neighbors in radians.
2646
            phis ([float]): azimuth angles of all neighbors in radians.
2647

2648
        Returns:
2649
            float: bond orientational order parameter of weight l=6
2650
                corresponding to the input angles thetas and phis.
2651
        """
2652
        if thetas is not None and phis is not None:
1✔
2653
            self.compute_trigonometric_terms(thetas, phis)
1✔
2654
        nnn = len(self._pow_sin_t[1])
1✔
2655
        nnn_range = range(nnn)
1✔
2656

2657
        i64 = 1.0 / 64.0
1✔
2658
        i32 = 1.0 / 32.0
1✔
2659
        i32_3 = 3.0 / 32.0
1✔
2660
        i16 = 1.0 / 16.0
1✔
2661

2662
        sqrt_3003_pi = sqrt(3003.0 / pi)
1✔
2663
        sqrt_1001_pi = sqrt(1001.0 / pi)
1✔
2664
        sqrt_91_2pi = sqrt(91.0 / (2 * pi))
1✔
2665
        sqrt_1365_pi = sqrt(1365.0 / pi)
1✔
2666
        sqrt_273_2pi = sqrt(273.0 / (2 * pi))
1✔
2667
        sqrt_13_pi = sqrt(13.0 / pi)
1✔
2668

2669
        pre_y_6_6 = [i64 * sqrt_3003_pi * val for val in self._pow_sin_t[6]]
1✔
2670
        pre_y_6_5 = [i32_3 * sqrt_1001_pi * val[0] * val[1] for val in zip(self._pow_sin_t[5], self._pow_cos_t[1])]
1✔
2671
        pre_y_6_4 = [
1✔
2672
            i32_3 * sqrt_91_2pi * val[0] * (11 * val[1] - 1.0) for val in zip(self._pow_sin_t[4], self._pow_cos_t[2])
2673
        ]
2674
        pre_y_6_3 = [
1✔
2675
            i32 * sqrt_1365_pi * val[0] * (11 * val[1] - 3 * val[2])
2676
            for val in zip(self._pow_sin_t[3], self._pow_cos_t[3], self._pow_cos_t[1])
2677
        ]
2678
        pre_y_6_2 = [
1✔
2679
            i64 * sqrt_1365_pi * val[0] * (33 * val[1] - 18 * val[2] + 1.0)
2680
            for val in zip(self._pow_sin_t[2], self._pow_cos_t[4], self._pow_cos_t[2])
2681
        ]
2682
        pre_y_6_1 = [
1✔
2683
            i16 * sqrt_273_2pi * val[0] * (33 * val[1] - 30 * val[2] + 5 * val[3])
2684
            for val in zip(
2685
                self._pow_sin_t[1],
2686
                self._pow_cos_t[5],
2687
                self._pow_cos_t[3],
2688
                self._pow_cos_t[1],
2689
            )
2690
        ]
2691

2692
        acc = 0.0
1✔
2693

2694
        # Y_6_-6
2695
        real = 0.0
1✔
2696
        imag = 0.0
1✔
2697
        for i in nnn_range:
1✔
2698
            real += pre_y_6_6[i] * self._cos_n_p[6][i]  # cos(x) =  cos(-x)
1✔
2699
            imag -= pre_y_6_6[i] * self._sin_n_p[6][i]  # sin(x) = -sin(-x)
1✔
2700
        acc += real * real + imag * imag
1✔
2701

2702
        # Y_6_-5
2703
        real = 0.0
1✔
2704
        imag = 0.0
1✔
2705
        for i in nnn_range:
1✔
2706
            real += pre_y_6_5[i] * self._cos_n_p[5][i]
1✔
2707
            imag -= pre_y_6_5[i] * self._sin_n_p[5][i]
1✔
2708
        acc += real * real + imag * imag
1✔
2709

2710
        # Y_6_-4
2711
        real = 0.0
1✔
2712
        imag = 0.0
1✔
2713
        for i in nnn_range:
1✔
2714
            real += pre_y_6_4[i] * self._cos_n_p[4][i]
1✔
2715
            imag -= pre_y_6_4[i] * self._sin_n_p[4][i]
1✔
2716
        acc += real * real + imag * imag
1✔
2717

2718
        # Y_6_-3
2719
        real = 0.0
1✔
2720
        imag = 0.0
1✔
2721
        for i in nnn_range:
1✔
2722
            real += pre_y_6_3[i] * self._cos_n_p[3][i]
1✔
2723
            imag -= pre_y_6_3[i] * self._sin_n_p[3][i]
1✔
2724
        acc += real * real + imag * imag
1✔
2725

2726
        # Y_6_-2
2727
        real = 0.0
1✔
2728
        imag = 0.0
1✔
2729
        for i in nnn_range:
1✔
2730
            real += pre_y_6_2[i] * self._cos_n_p[2][i]
1✔
2731
            imag -= pre_y_6_2[i] * self._sin_n_p[2][i]
1✔
2732
        acc += real * real + imag * imag
1✔
2733

2734
        # Y_6_-1
2735
        real = 0.0
1✔
2736
        imag = 0.0
1✔
2737
        for i in nnn_range:
1✔
2738
            real += pre_y_6_1[i] * self._cos_n_p[1][i]
1✔
2739
            imag -= pre_y_6_1[i] * self._sin_n_p[1][i]
1✔
2740
        acc += real * real + imag * imag
1✔
2741

2742
        # Y_6_0
2743
        real = 0.0
1✔
2744
        imag = 0.0
1✔
2745
        for i in nnn_range:
1✔
2746
            real += (
1✔
2747
                i32
2748
                * sqrt_13_pi
2749
                * (231 * self._pow_cos_t[6][i] - 315 * self._pow_cos_t[4][i] + 105 * self._pow_cos_t[2][i] - 5.0)
2750
            )
2751
        acc += real * real
1✔
2752

2753
        # Y_6_1
2754
        real = 0.0
1✔
2755
        imag = 0.0
1✔
2756
        for i in nnn_range:
1✔
2757
            real -= pre_y_6_1[i] * self._cos_n_p[1][i]
1✔
2758
            imag -= pre_y_6_1[i] * self._sin_n_p[1][i]
1✔
2759
        acc += real * real + imag * imag
1✔
2760

2761
        # Y_6_2
2762
        real = 0.0
1✔
2763
        imag = 0.0
1✔
2764
        for i in nnn_range:
1✔
2765
            real += pre_y_6_2[i] * self._cos_n_p[2][i]
1✔
2766
            imag += pre_y_6_2[i] * self._sin_n_p[2][i]
1✔
2767
        acc += real * real + imag * imag
1✔
2768

2769
        # Y_6_3
2770
        real = 0.0
1✔
2771
        imag = 0.0
1✔
2772
        for i in nnn_range:
1✔
2773
            real -= pre_y_6_3[i] * self._cos_n_p[3][i]
1✔
2774
            imag -= pre_y_6_3[i] * self._sin_n_p[3][i]
1✔
2775
        acc += real * real + imag * imag
1✔
2776

2777
        # Y_6_4
2778
        real = 0.0
1✔
2779
        imag = 0.0
1✔
2780
        for i in nnn_range:
1✔
2781
            real += pre_y_6_4[i] * self._cos_n_p[4][i]
1✔
2782
            imag += pre_y_6_4[i] * self._sin_n_p[4][i]
1✔
2783
        acc += real * real + imag * imag
1✔
2784

2785
        # Y_6_5
2786
        real = 0.0
1✔
2787
        imag = 0.0
1✔
2788
        for i in nnn_range:
1✔
2789
            real -= pre_y_6_5[i] * self._cos_n_p[5][i]
1✔
2790
            imag -= pre_y_6_5[i] * self._sin_n_p[5][i]
1✔
2791
        acc += real * real + imag * imag
1✔
2792

2793
        # Y_6_6
2794
        real = 0.0
1✔
2795
        imag = 0.0
1✔
2796
        for i in nnn_range:
1✔
2797
            real += pre_y_6_6[i] * self._cos_n_p[6][i]
1✔
2798
            imag += pre_y_6_6[i] * self._sin_n_p[6][i]
1✔
2799
        acc += real * real + imag * imag
1✔
2800

2801
        q6 = sqrt(4 * pi * acc / (13 * float(nnn * nnn)))
1✔
2802
        return q6
1✔
2803

2804
    def get_type(self, index):
1✔
2805
        """
2806
        Return type of order parameter at the index provided and
2807
        represented by a short string.
2808

2809
        Args:
2810
            index (int): index of order parameter for which type is
2811
                to be returned.
2812
        Returns:
2813
            str: OP type.
2814
        """
2815
        if index < 0 or index >= len(self._types):
×
2816
            raise ValueError("Index for getting order parameter type out-of-bounds!")
×
2817
        return self._types[index]
×
2818

2819
    def get_parameters(self, index):
1✔
2820
        """
2821
        Returns list of floats that represents
2822
        the parameters associated
2823
        with calculation of the order
2824
        parameter that was defined at the index provided.
2825
        Attention: the parameters do not need to equal those originally
2826
        inputted because of processing out of efficiency reasons.
2827

2828
        Args:
2829
            index (int):
2830
                index of order parameter for which associated parameters
2831
                are to be returned.
2832
        Returns:
2833
            [float]: parameters of a given OP.
2834
        """
2835
        if index < 0 or index >= len(self._types):
1✔
2836
            raise ValueError("Index for getting parameters associated with order parameter calculation out-of-bounds!")
×
2837
        return self._params[index]
1✔
2838

2839
    def get_order_parameters(
1✔
2840
        self, structure: Structure, n: int, indices_neighs: list[int] | None = None, tol: float = 0.0, target_spec=None
2841
    ):
2842
        """
2843
        Compute all order parameters of site n.
2844

2845
        Args:
2846
            structure (Structure): input structure.
2847
            n (int): index of site in input structure,
2848
                for which OPs are to be
2849
                calculated. Note that we do not use the sites iterator
2850
                here, but directly access sites via struct[index].
2851
            indices_neighs (list[int]): list of indices of those neighbors
2852
                in Structure object
2853
                structure that are to be considered for OP computation.
2854
                This optional argument overwrites the way neighbors are
2855
                to be determined as defined in the constructor (i.e.,
2856
                Voronoi coordination finder via negative cutoff radius
2857
                vs constant cutoff radius if cutoff was positive).
2858
                We do not use information about the underlying
2859
                structure lattice if the neighbor indices are explicitly
2860
                provided. This has two important consequences. First,
2861
                the input Structure object can, in fact, be a
2862
                simple list of Site objects. Second, no nearest images
2863
                of neighbors are determined when providing an index list.
2864
                Note furthermore that this neighbor
2865
                determination type ignores the optional target_spec
2866
                argument.
2867
            tol (float): threshold of weight
2868
                (= solid angle / maximal solid angle)
2869
                to determine if a particular pair is
2870
                considered neighbors; this is relevant only in the case
2871
                when Voronoi polyhedra are used to determine coordination
2872
            target_spec (Species): target species to be considered
2873
                when calculating the order
2874
                parameters of site n; None includes all species of input
2875
                structure.
2876

2877
        Returns:
2878
            [floats]: representing order parameters. Should it not be
2879
            possible to compute a given OP for a conceptual reason, the
2880
            corresponding entry is None instead of a float. For Steinhardt
2881
            et al.'s bond orientational OPs and the other geometric OPs
2882
            ("tet", "oct", "bcc", etc.),
2883
            this can happen if there is a single
2884
            neighbor around site n in the structure because that
2885
            does not permit calculation of angles between multiple
2886
            neighbors.
2887
        """
2888
        # Do error-checking and initialization.
2889
        if n < 0:
1✔
2890
            raise ValueError("Site index smaller zero!")
×
2891
        if n >= len(structure):
1✔
2892
            raise ValueError("Site index beyond maximum!")
×
2893
        if indices_neighs is not None:
1✔
2894
            for index in indices_neighs:
1✔
2895
                if index >= len(structure):
1✔
2896
                    raise ValueError("Neighbor site index beyond maximum!")
1✔
2897
        if tol < 0.0:
1✔
2898
            raise ValueError("Negative tolerance for weighted solid angle!")
×
2899

2900
        left_of_unity = 1.0 - 1.0e-12
1✔
2901
        # The following threshold has to be adapted to non-Angstrom units.
2902
        very_small = 1.0e-12
1✔
2903
        fac_bcc = 1.0 / exp(-0.5)
1✔
2904

2905
        # Find central site and its neighbors.
2906
        # Note that we adopt the same way of accessing sites here as in
2907
        # VoronoiNN; that is, not via the sites iterator.
2908
        centsite = structure[n]
1✔
2909
        if indices_neighs is not None:
1✔
2910
            neighsites = [structure[index] for index in indices_neighs]
1✔
2911
        elif self._voroneigh:
1✔
2912
            vnn = VoronoiNN(tol=tol, targets=target_spec)
1✔
2913
            neighsites = vnn.get_nn(structure, n)
1✔
2914
        else:
2915
            # Structure.get_sites_in_sphere --> also other periodic images
2916
            neighsitestmp = [i[0] for i in structure.get_sites_in_sphere(centsite.coords, self._cutoff)]
1✔
2917
            neighsites = []
1✔
2918
            if centsite not in neighsitestmp:
1✔
2919
                raise ValueError("Could not find center site!")
×
2920
            neighsitestmp.remove(centsite)
1✔
2921

2922
            if target_spec is None:
1✔
2923
                neighsites = list(neighsitestmp)
1✔
2924
            else:
2925
                neighsites[:] = [site for site in neighsitestmp if site.specie.symbol == target_spec]
×
2926
        nneigh = len(neighsites)
1✔
2927
        self._last_nneigh = nneigh
1✔
2928

2929
        # Prepare angle calculations, if applicable.
2930
        rij: list[np.ndarray] = []
1✔
2931
        rjk: list[list[np.ndarray]] = []
1✔
2932
        rijnorm: list[list[float]] = []
1✔
2933
        rjknorm: list[list[np.ndarray]] = []
1✔
2934
        dist: list[float] = []
1✔
2935
        distjk_unique: list[float] = []
1✔
2936
        distjk: list[list[float]] = []
1✔
2937
        centvec = centsite.coords
1✔
2938
        if self._computerijs:
1✔
2939
            for j, neigh in enumerate(neighsites):
1✔
2940
                rij.append(neigh.coords - centvec)
1✔
2941
                dist.append(float(np.linalg.norm(rij[j])))
1✔
2942
                rijnorm.append(rij[j] / dist[j])  # type: ignore
1✔
2943
        if self._computerjks:
1✔
2944
            for j, neigh in enumerate(neighsites):
1✔
2945
                rjk.append([])
1✔
2946
                rjknorm.append([])
1✔
2947
                distjk.append([])
1✔
2948
                kk = 0
1✔
2949
                for k, neigh_2 in enumerate(neighsites):
1✔
2950
                    if j != k:
1✔
2951
                        rjk[j].append(neigh_2.coords - neigh.coords)
1✔
2952
                        distjk[j].append(float(np.linalg.norm(rjk[j][kk])))
1✔
2953
                        if k > j:
1✔
2954
                            distjk_unique.append(distjk[j][kk])
1✔
2955
                        rjknorm[j].append(rjk[j][kk] / distjk[j][kk])
1✔
2956
                        kk = kk + 1
1✔
2957
        # Initialize OP list and, then, calculate OPs.
2958
        ops = [0.0 for t in self._types]
1✔
2959
        # norms = [[[] for j in range(nneigh)] for t in self._types]
2960

2961
        # First, coordination number and distance-based OPs.
2962
        for i, t in enumerate(self._types):
1✔
2963
            if t == "cn":
1✔
2964
                ops[i] = nneigh / self._params[i]["norm"]
1✔
2965
            elif t == "sgl_bd":
1✔
2966
                dist_sorted = sorted(dist)
1✔
2967
                if len(dist_sorted) == 1:
1✔
2968
                    ops[i] = 1.0
1✔
2969
                elif len(dist_sorted) > 1:
1✔
2970
                    ops[i] = 1.0 - dist_sorted[0] / dist_sorted[1]
1✔
2971

2972
        # Then, bond orientational OPs based on spherical harmonics
2973
        # according to Steinhardt et al., Phys. Rev. B, 28, 784-805, 1983.
2974
        if self._boops:
1✔
2975
            thetas = []
1✔
2976
            phis = []
1✔
2977
            for vec in rijnorm:
1✔
2978
                # z is North pole --> theta between vec and (0, 0, 1)^T.
2979
                # Because vec is normalized, dot product is simply vec[2].
2980
                thetas.append(acos(max(-1.0, min(vec[2], 1.0))))
1✔
2981
                tmpphi = 0.0
1✔
2982

2983
                # Compute phi only if it is not (almost) perfectly
2984
                # aligned with z-axis.
2985
                if -left_of_unity < vec[2] < left_of_unity:
1✔
2986
                    # x is prime meridian --> phi between projection of vec
2987
                    # into x-y plane and (1, 0, 0)^T
2988
                    tmpphi = acos(
1✔
2989
                        max(
2990
                            -1.0,
2991
                            min(vec[0] / (sqrt(vec[0] * vec[0] + vec[1] * vec[1])), 1.0),
2992
                        )
2993
                    )
2994
                    if vec[1] < 0.0:
1✔
2995
                        tmpphi = -tmpphi
1✔
2996
                phis.append(tmpphi)
1✔
2997

2998
            # Note that None flags that we have too few neighbors
2999
            # for calculating BOOPS.
3000
            for i, t in enumerate(self._types):
1✔
3001
                if t == "q2":
1✔
3002
                    ops[i] = self.get_q2(thetas, phis) if len(thetas) > 0 else None
1✔
3003
                elif t == "q4":
1✔
3004
                    ops[i] = self.get_q4(thetas, phis) if len(thetas) > 0 else None
1✔
3005
                elif t == "q6":
1✔
3006
                    ops[i] = self.get_q6(thetas, phis) if len(thetas) > 0 else None
1✔
3007

3008
        # Then, deal with the Peters-style OPs that are tailor-made
3009
        # to recognize common structural motifs
3010
        # (Peters, J. Chem. Phys., 131, 244103, 2009;
3011
        #  Zimmermann et al., J. Am. Chem. Soc., under revision, 2015).
3012
        if self._geomops:
1✔
3013
            gaussthetak = [0.0 for t in self._types]  # not used by all OPs
1✔
3014
            qsptheta = [[[] for j in range(nneigh)] for t in self._types]  # type: ignore
1✔
3015
            norms = [[[] for j in range(nneigh)] for t in self._types]  # type: ignore
1✔
3016
            ipi = 1.0 / pi
1✔
3017
            piover2 = pi / 2.0
1✔
3018
            onethird = 1.0 / 3.0
1✔
3019
            twothird = 2.0 / 3.0
1✔
3020
            for j in range(nneigh):  # Neighbor j is put to the North pole.
1✔
3021
                zaxis = rijnorm[j]
1✔
3022
                kc = 0
1✔
3023
                for k in range(nneigh):  # From neighbor k, we construct
1✔
3024
                    if j != k:  # the prime meridian.
1✔
3025
                        for i in range(len(self._types)):
1✔
3026
                            qsptheta[i][j].append(0.0)
1✔
3027
                            norms[i][j].append(0)
1✔
3028
                        tmp = max(-1.0, min(np.inner(zaxis, rijnorm[k]), 1.0))
1✔
3029
                        thetak = acos(tmp)
1✔
3030
                        xaxis = gramschmidt(rijnorm[k], zaxis)
1✔
3031
                        if np.linalg.norm(xaxis) < very_small:
1✔
3032
                            flag_xaxis = True
1✔
3033
                        else:
3034
                            xaxis = xaxis / np.linalg.norm(xaxis)
1✔
3035
                            flag_xaxis = False
1✔
3036
                        if self._comp_azi:
1✔
3037
                            flag_yaxis = True
1✔
3038
                            yaxis = np.cross(zaxis, xaxis)
1✔
3039
                            if np.linalg.norm(yaxis) > very_small:
1✔
3040
                                yaxis = yaxis / np.linalg.norm(yaxis)
1✔
3041
                                flag_yaxis = False
1✔
3042

3043
                        # Contributions of j-i-k angles, where i represents the
3044
                        # central atom and j and k two of the neighbors.
3045
                        for i, t in enumerate(self._types):
1✔
3046
                            if t in ["bent", "sq_pyr_legacy"]:
1✔
3047
                                tmp = self._params[i]["IGW_TA"] * (thetak * ipi - self._params[i]["TA"])
1✔
3048
                                qsptheta[i][j][kc] += exp(-0.5 * tmp * tmp)
1✔
3049
                                norms[i][j][kc] += 1
1✔
3050
                            elif t in ["tri_plan", "tri_plan_max", "tet", "tet_max"]:
1✔
3051
                                tmp = self._params[i]["IGW_TA"] * (thetak * ipi - self._params[i]["TA"])
1✔
3052
                                gaussthetak[i] = exp(-0.5 * tmp * tmp)
1✔
3053
                                if t in ["tri_plan_max", "tet_max"]:
1✔
3054
                                    qsptheta[i][j][kc] += gaussthetak[i]
1✔
3055
                                    norms[i][j][kc] += 1
1✔
3056
                            elif t in ["T", "tri_pyr", "sq_pyr", "pent_pyr", "hex_pyr"]:
1✔
3057
                                tmp = self._params[i]["IGW_EP"] * (thetak * ipi - 0.5)
1✔
3058
                                qsptheta[i][j][kc] += exp(-0.5 * tmp * tmp)
1✔
3059
                                norms[i][j][kc] += 1
1✔
3060
                            elif t in [
1✔
3061
                                "sq_plan",
3062
                                "oct",
3063
                                "oct_legacy",
3064
                                "cuboct",
3065
                                "cuboct_max",
3066
                            ]:
3067
                                if thetak >= self._params[i]["min_SPP"]:
1✔
3068
                                    tmp = self._params[i]["IGW_SPP"] * (thetak * ipi - 1.0)
1✔
3069
                                    qsptheta[i][j][kc] += self._params[i]["w_SPP"] * exp(-0.5 * tmp * tmp)
1✔
3070
                                    norms[i][j][kc] += self._params[i]["w_SPP"]
1✔
3071
                            elif t in [
1✔
3072
                                "see_saw_rect",
3073
                                "tri_bipyr",
3074
                                "sq_bipyr",
3075
                                "pent_bipyr",
3076
                                "hex_bipyr",
3077
                                "oct_max",
3078
                                "sq_plan_max",
3079
                                "hex_plan_max",
3080
                            ]:
3081
                                if thetak < self._params[i]["min_SPP"]:
1✔
3082
                                    tmp = (
1✔
3083
                                        self._params[i]["IGW_EP"] * (thetak * ipi - 0.5)
3084
                                        if t != "hex_plan_max"
3085
                                        else self._params[i]["IGW_TA"]
3086
                                        * (fabs(thetak * ipi - 0.5) - self._params[i]["TA"])
3087
                                    )
3088
                                    qsptheta[i][j][kc] += exp(-0.5 * tmp * tmp)
1✔
3089
                                    norms[i][j][kc] += 1
1✔
3090
                            elif t in ["pent_plan", "pent_plan_max"]:
1✔
3091
                                tmp = 0.4 if thetak <= self._params[i]["TA"] * pi else 0.8
1✔
3092
                                tmp2 = self._params[i]["IGW_TA"] * (thetak * ipi - tmp)
1✔
3093
                                gaussthetak[i] = exp(-0.5 * tmp2 * tmp2)
1✔
3094
                                if t == "pent_plan_max":
1✔
3095
                                    qsptheta[i][j][kc] += gaussthetak[i]
1✔
3096
                                    norms[i][j][kc] += 1
1✔
3097
                            elif t == "bcc" and j < k:
1✔
3098
                                if thetak >= self._params[i]["min_SPP"]:
1✔
3099
                                    tmp = self._params[i]["IGW_SPP"] * (thetak * ipi - 1.0)
1✔
3100
                                    qsptheta[i][j][kc] += self._params[i]["w_SPP"] * exp(-0.5 * tmp * tmp)
1✔
3101
                                    norms[i][j][kc] += self._params[i]["w_SPP"]
1✔
3102
                            elif t == "sq_face_cap_trig_pris":
1✔
3103
                                if thetak < self._params[i]["TA3"]:
1✔
3104
                                    tmp = self._params[i]["IGW_TA1"] * (thetak * ipi - self._params[i]["TA1"])
1✔
3105
                                    qsptheta[i][j][kc] += exp(-0.5 * tmp * tmp)
1✔
3106
                                    norms[i][j][kc] += 1
1✔
3107

3108
                        for m in range(nneigh):
1✔
3109
                            if (m != j) and (m != k) and (not flag_xaxis):
1✔
3110
                                tmp = max(-1.0, min(np.inner(zaxis, rijnorm[m]), 1.0))
1✔
3111
                                thetam = acos(tmp)
1✔
3112
                                xtwoaxistmp = gramschmidt(rijnorm[m], zaxis)
1✔
3113
                                l = np.linalg.norm(xtwoaxistmp)
1✔
3114
                                if l < very_small:
1✔
3115
                                    flag_xtwoaxis = True
1✔
3116
                                else:
3117
                                    xtwoaxis = xtwoaxistmp / l
1✔
3118
                                    phi = acos(max(-1.0, min(np.inner(xtwoaxis, xaxis), 1.0)))
1✔
3119
                                    flag_xtwoaxis = False
1✔
3120
                                    if self._comp_azi:
1✔
3121
                                        phi2 = atan2(
1✔
3122
                                            np.dot(xtwoaxis, yaxis),
3123
                                            np.dot(xtwoaxis, xaxis),
3124
                                        )
3125
                                # South pole contributions of m.
3126
                                if t in [
1✔
3127
                                    "tri_bipyr",
3128
                                    "sq_bipyr",
3129
                                    "pent_bipyr",
3130
                                    "hex_bipyr",
3131
                                    "oct_max",
3132
                                    "sq_plan_max",
3133
                                    "hex_plan_max",
3134
                                    "see_saw_rect",
3135
                                ]:
3136
                                    if thetam >= self._params[i]["min_SPP"]:
1✔
3137
                                        tmp = self._params[i]["IGW_SPP"] * (thetam * ipi - 1.0)
1✔
3138
                                        qsptheta[i][j][kc] += exp(-0.5 * tmp * tmp)
1✔
3139
                                        norms[i][j][kc] += 1
1✔
3140

3141
                                # Contributions of j-i-m angle and
3142
                                # angles between plane j-i-k and i-m vector.
3143
                                if not flag_xaxis and not flag_xtwoaxis:
1✔
3144
                                    for i, t in enumerate(self._types):
1✔
3145
                                        if t in [
1✔
3146
                                            "tri_plan",
3147
                                            "tri_plan_max",
3148
                                            "tet",
3149
                                            "tet_max",
3150
                                        ]:
3151
                                            tmp = self._params[i]["IGW_TA"] * (thetam * ipi - self._params[i]["TA"])
1✔
3152
                                            tmp2 = cos(self._params[i]["fac_AA"] * phi) ** self._params[i]["exp_cos_AA"]
1✔
3153
                                            tmp3 = 1 if t in ["tri_plan_max", "tet_max"] else gaussthetak[i]
1✔
3154
                                            qsptheta[i][j][kc] += tmp3 * exp(-0.5 * tmp * tmp) * tmp2
1✔
3155
                                            norms[i][j][kc] += 1
1✔
3156
                                        elif t in ["pent_plan", "pent_plan_max"]:
1✔
3157
                                            tmp = 0.4 if thetam <= self._params[i]["TA"] * pi else 0.8
1✔
3158
                                            tmp2 = self._params[i]["IGW_TA"] * (thetam * ipi - tmp)
1✔
3159
                                            tmp3 = cos(phi)
1✔
3160
                                            tmp4 = 1 if t == "pent_plan_max" else gaussthetak[i]
1✔
3161
                                            qsptheta[i][j][kc] += tmp4 * exp(-0.5 * tmp2 * tmp2) * tmp3 * tmp3
1✔
3162
                                            norms[i][j][kc] += 1
1✔
3163
                                        elif t in [
1✔
3164
                                            "T",
3165
                                            "tri_pyr",
3166
                                            "sq_pyr",
3167
                                            "pent_pyr",
3168
                                            "hex_pyr",
3169
                                        ]:
3170
                                            tmp = cos(self._params[i]["fac_AA"] * phi) ** self._params[i]["exp_cos_AA"]
1✔
3171
                                            tmp3 = self._params[i]["IGW_EP"] * (thetam * ipi - 0.5)
1✔
3172
                                            qsptheta[i][j][kc] += tmp * exp(-0.5 * tmp3 * tmp3)
1✔
3173
                                            norms[i][j][kc] += 1
1✔
3174
                                        elif t in ["sq_plan", "oct", "oct_legacy"]:
1✔
3175
                                            if (
1✔
3176
                                                thetak < self._params[i]["min_SPP"]
3177
                                                and thetam < self._params[i]["min_SPP"]
3178
                                            ):
3179
                                                tmp = (
1✔
3180
                                                    cos(self._params[i]["fac_AA"] * phi)
3181
                                                    ** self._params[i]["exp_cos_AA"]
3182
                                                )
3183
                                                tmp2 = self._params[i]["IGW_EP"] * (thetam * ipi - 0.5)
1✔
3184
                                                qsptheta[i][j][kc] += tmp * exp(-0.5 * tmp2 * tmp2)
1✔
3185
                                                if t == "oct_legacy":
1✔
3186
                                                    qsptheta[i][j][kc] -= tmp * self._params[i][6] * self._params[i][7]
×
3187
                                                norms[i][j][kc] += 1
1✔
3188
                                        elif t in [
1✔
3189
                                            "tri_bipyr",
3190
                                            "sq_bipyr",
3191
                                            "pent_bipyr",
3192
                                            "hex_bipyr",
3193
                                            "oct_max",
3194
                                            "sq_plan_max",
3195
                                            "hex_plan_max",
3196
                                        ]:
3197
                                            if thetam < self._params[i]["min_SPP"]:
1✔
3198
                                                if thetak < self._params[i]["min_SPP"]:
1✔
3199
                                                    tmp = (
1✔
3200
                                                        cos(self._params[i]["fac_AA"] * phi)
3201
                                                        ** self._params[i]["exp_cos_AA"]
3202
                                                    )
3203
                                                    tmp2 = (
1✔
3204
                                                        self._params[i]["IGW_EP"] * (thetam * ipi - 0.5)
3205
                                                        if t != "hex_plan_max"
3206
                                                        else self._params[i]["IGW_TA"]
3207
                                                        * (fabs(thetam * ipi - 0.5) - self._params[i]["TA"])
3208
                                                    )
3209
                                                    qsptheta[i][j][kc] += tmp * exp(-0.5 * tmp2 * tmp2)
1✔
3210
                                                    norms[i][j][kc] += 1
1✔
3211
                                        elif t == "bcc" and j < k:
1✔
3212
                                            if thetak < self._params[i]["min_SPP"]:
1✔
3213
                                                if thetak > piover2:
1✔
3214
                                                    fac = 1.0
1✔
3215
                                                else:
3216
                                                    fac = -1.0
1✔
3217
                                                tmp = (thetam - piover2) / asin(1 / 3)
1✔
3218
                                                qsptheta[i][j][kc] += (
1✔
3219
                                                    fac * cos(3 * phi) * fac_bcc * tmp * exp(-0.5 * tmp * tmp)
3220
                                                )
3221
                                                norms[i][j][kc] += 1
1✔
3222
                                        elif t == "see_saw_rect":
1✔
3223
                                            if thetam < self._params[i]["min_SPP"]:
1✔
3224
                                                if thetak < self._params[i]["min_SPP"] and phi < 0.75 * pi:
1✔
3225
                                                    tmp = (
1✔
3226
                                                        cos(self._params[i]["fac_AA"] * phi)
3227
                                                        ** self._params[i]["exp_cos_AA"]
3228
                                                    )
3229
                                                    tmp2 = self._params[i]["IGW_EP"] * (thetam * ipi - 0.5)
1✔
3230
                                                    qsptheta[i][j][kc] += tmp * exp(-0.5 * tmp2 * tmp2)
1✔
3231
                                                    norms[i][j][kc] += 1.0
1✔
3232
                                        elif t in ["cuboct", "cuboct_max"]:
1✔
3233
                                            if (
1✔
3234
                                                thetam < self._params[i]["min_SPP"]
3235
                                                and self._params[i][4] < thetak < self._params[i][2]
3236
                                            ):
3237
                                                if self._params[i][4] < thetam < self._params[i][2]:
1✔
3238
                                                    tmp = cos(phi)
1✔
3239
                                                    tmp2 = self._params[i][5] * (thetam * ipi - 0.5)
1✔
3240
                                                    qsptheta[i][j][kc] += tmp * tmp * exp(-0.5 * tmp2 * tmp2)
1✔
3241
                                                    norms[i][j][kc] += 1.0
1✔
3242
                                                elif thetam < self._params[i][4]:
1✔
3243
                                                    tmp = 0.0556 * (cos(phi - 0.5 * pi) - 0.81649658)
1✔
3244
                                                    tmp2 = self._params[i][6] * (thetam * ipi - onethird)
1✔
3245
                                                    qsptheta[i][j][kc] += exp(-0.5 * tmp * tmp) * exp(
1✔
3246
                                                        -0.5 * tmp2 * tmp2
3247
                                                    )
3248
                                                    norms[i][j][kc] += 1.0
1✔
3249
                                                elif thetam > self._params[i][2]:
1✔
3250
                                                    tmp = 0.0556 * (cos(phi - 0.5 * pi) - 0.81649658)
1✔
3251
                                                    tmp2 = self._params[i][6] * (thetam * ipi - twothird)
1✔
3252
                                                    qsptheta[i][j][kc] += exp(-0.5 * tmp * tmp) * exp(
1✔
3253
                                                        -0.5 * tmp2 * tmp2
3254
                                                    )
3255
                                                    norms[i][j][kc] += 1.0
1✔
3256
                                        elif t == "sq_face_cap_trig_pris" and not flag_yaxis:
1✔
3257
                                            if thetak < self._params[i]["TA3"]:
1✔
3258
                                                if thetam < self._params[i]["TA3"]:
1✔
3259
                                                    tmp = (
1✔
3260
                                                        cos(self._params[i]["fac_AA1"] * phi2)
3261
                                                        ** self._params[i]["exp_cos_AA1"]
3262
                                                    )
3263
                                                    tmp2 = self._params[i]["IGW_TA1"] * (
1✔
3264
                                                        thetam * ipi - self._params[i]["TA1"]
3265
                                                    )
3266
                                                else:
3267
                                                    tmp = (
1✔
3268
                                                        cos(
3269
                                                            self._params[i]["fac_AA2"]
3270
                                                            * (phi2 + self._params[i]["shift_AA2"])
3271
                                                        )
3272
                                                        ** self._params[i]["exp_cos_AA2"]
3273
                                                    )
3274
                                                    tmp2 = self._params[i]["IGW_TA2"] * (
1✔
3275
                                                        thetam * ipi - self._params[i]["TA2"]
3276
                                                    )
3277

3278
                                                qsptheta[i][j][kc] += tmp * exp(-0.5 * tmp2 * tmp2)
1✔
3279
                                                norms[i][j][kc] += 1
1✔
3280

3281
                        kc += 1
1✔
3282

3283
            # Normalize Peters-style OPs.
3284
            for i, t in enumerate(self._types):
1✔
3285
                if t in [
1✔
3286
                    "tri_plan",
3287
                    "tet",
3288
                    "bent",
3289
                    "sq_plan",
3290
                    "oct",
3291
                    "oct_legacy",
3292
                    "cuboct",
3293
                    "pent_plan",
3294
                ]:
3295
                    ops[i] = tmp_norm = 0.0
1✔
3296
                    for j in range(nneigh):
1✔
3297
                        ops[i] += sum(qsptheta[i][j])
1✔
3298
                        tmp_norm += float(sum(norms[i][j]))
1✔
3299
                    ops[i] = ops[i] / tmp_norm if tmp_norm > 1.0e-12 else None  # type: ignore
1✔
3300
                elif t in [
1✔
3301
                    "T",
3302
                    "tri_pyr",
3303
                    "see_saw_rect",
3304
                    "sq_pyr",
3305
                    "tri_bipyr",
3306
                    "sq_bipyr",
3307
                    "pent_pyr",
3308
                    "hex_pyr",
3309
                    "pent_bipyr",
3310
                    "hex_bipyr",
3311
                    "oct_max",
3312
                    "tri_plan_max",
3313
                    "tet_max",
3314
                    "sq_plan_max",
3315
                    "pent_plan_max",
3316
                    "cuboct_max",
3317
                    "hex_plan_max",
3318
                    "sq_face_cap_trig_pris",
3319
                ]:
3320
                    ops[i] = None  # type: ignore
1✔
3321
                    if nneigh > 1:
1✔
3322
                        for j in range(nneigh):
1✔
3323
                            for k in range(len(qsptheta[i][j])):
1✔
3324
                                qsptheta[i][j][k] = (
1✔
3325
                                    qsptheta[i][j][k] / norms[i][j][k] if norms[i][j][k] > 1.0e-12 else 0.0
3326
                                )
3327
                            ops[i] = max(qsptheta[i][j]) if j == 0 else max(ops[i], max(qsptheta[i][j]))
1✔
3328
                elif t == "bcc":
1✔
3329
                    ops[i] = 0.0
1✔
3330
                    for j in range(nneigh):
1✔
3331
                        ops[i] += sum(qsptheta[i][j])
1✔
3332
                    if nneigh > 3:
1✔
3333
                        ops[i] = ops[i] / float(0.5 * float(nneigh * (6 + (nneigh - 2) * (nneigh - 3))))
1✔
3334
                    else:
3335
                        ops[i] = None  # type: ignore
1✔
3336
                elif t == "sq_pyr_legacy":
1✔
3337
                    if nneigh > 1:
1✔
3338
                        dmean = np.mean(dist)
1✔
3339
                        acc = 0.0
1✔
3340
                        for d in dist:
1✔
3341
                            tmp = self._params[i][2] * (d - dmean)
1✔
3342
                            acc = acc + exp(-0.5 * tmp * tmp)
1✔
3343
                        for j in range(nneigh):
1✔
3344
                            ops[i] = max(qsptheta[i][j]) if j == 0 else max(ops[i], max(qsptheta[i][j]))
1✔
3345
                        ops[i] = acc * ops[i] / float(nneigh)
1✔
3346
                        # nneigh * (nneigh - 1))
3347
                    else:
3348
                        ops[i] = None  # type: ignore
1✔
3349

3350
        # Then, deal with the new-style OPs that require vectors between
3351
        # neighbors.
3352
        if self._geomops2:
1✔
3353
            # Compute all (unique) angles and sort the resulting list.
3354
            aij = []
1✔
3355
            for ir, r in enumerate(rijnorm):
1✔
3356
                for j in range(ir + 1, len(rijnorm)):
1✔
3357
                    aij.append(acos(max(-1.0, min(np.inner(r, rijnorm[j]), 1.0))))
1✔
3358
            aijs = sorted(aij)
1✔
3359

3360
            # Compute height, side and diagonal length estimates.
3361
            neighscent = np.array([0.0, 0.0, 0.0])
1✔
3362
            for neigh in neighsites:
1✔
3363
                neighscent = neighscent + neigh.coords
1✔
3364
            if nneigh > 0:
1✔
3365
                neighscent = neighscent / float(nneigh)
1✔
3366
            h = np.linalg.norm(neighscent - centvec)
1✔
3367
            b = min(distjk_unique) if len(distjk_unique) > 0 else 0
1✔
3368
            dhalf = max(distjk_unique) / 2.0 if len(distjk_unique) > 0 else 0
1✔
3369

3370
            for i, t in enumerate(self._types):
1✔
3371
                if t in ("reg_tri", "sq"):
1✔
3372
                    if nneigh < 3:
1✔
3373
                        ops[i] = None  # type: ignore
1✔
3374
                    else:
3375
                        ops[i] = 1.0
1✔
3376
                        if t == "reg_tri":
1✔
3377
                            a = 2 * asin(b / (2 * sqrt(h * h + (b / (2 * cos(3 * pi / 18))) ** 2)))  # type: ignore
1✔
3378
                            nmax = 3
1✔
3379
                        elif t == "sq":
1✔
3380
                            a = 2 * asin(b / (2 * sqrt(h * h + dhalf * dhalf)))  # type: ignore
1✔
3381
                            nmax = 4
1✔
3382
                        for j in range(min([nneigh, nmax])):
1✔
3383
                            ops[i] = ops[i] * exp(-0.5 * ((aijs[j] - a) * self._params[i][0]) ** 2)
1✔
3384

3385
        return ops
1✔
3386

3387

3388
class BrunnerNN_reciprocal(NearNeighbors):
1✔
3389
    """
3390
    Determine coordination number using Brunner's algorithm which counts the
3391
    atoms that are within the largest gap in differences in real space
3392
    interatomic distances. This algorithm uses Brunner's method of
3393
    largest reciprocal gap in interatomic distances.
3394
    """
3395

3396
    def __init__(self, tol: float = 1.0e-4, cutoff=8.0):
1✔
3397
        """
3398
        Args:
3399
            tol (float): tolerance parameter for bond determination
3400
                (default: 1E-4).
3401
            cutoff (float): cutoff radius in Angstrom to look for near-neighbor
3402
                atoms. Defaults to 8.0.
3403
        """
3404
        self.tol = tol
1✔
3405
        self.cutoff = cutoff
1✔
3406

3407
    @property
1✔
3408
    def structures_allowed(self):
1✔
3409
        """
3410
        Boolean property: can this NearNeighbors class be used with Structure
3411
        objects?
3412
        """
3413
        return True
×
3414

3415
    @property
1✔
3416
    def molecules_allowed(self):
1✔
3417
        """
3418
        Boolean property: can this NearNeighbors class be used with Molecule
3419
        objects?
3420
        """
3421
        return False
×
3422

3423
    def get_nn_info(self, structure: Structure, n: int):
1✔
3424
        """
3425
        Get all near-neighbor sites as well as the associated image locations
3426
        and weights of the site with index n in structure.
3427

3428
        Args:
3429
            structure (Structure): input structure.
3430
            n (int): index of site for which to determine near-neighbor
3431
                sites.
3432

3433
        Returns:
3434
            siw (list of tuples (Site, array, float)): tuples, each one
3435
                of which represents a coordinated site, its image location,
3436
                and its weight.
3437
        """
3438
        site = structure[n]
1✔
3439
        neighs_dists = structure.get_neighbors(site, self.cutoff)
1✔
3440
        ds = sorted(i.nn_distance for i in neighs_dists)
1✔
3441

3442
        ns = [1.0 / ds[i] - 1.0 / ds[i + 1] for i in range(len(ds) - 1)]
1✔
3443

3444
        d_max = ds[ns.index(max(ns))]
1✔
3445
        siw = []
1✔
3446
        for nn in neighs_dists:
1✔
3447
            s, dist = nn, nn.nn_distance
1✔
3448
            if dist < d_max + self.tol:
1✔
3449
                w = ds[0] / dist
1✔
3450
                siw.append(
1✔
3451
                    {
3452
                        "site": s,
3453
                        "image": self._get_image(structure, s),
3454
                        "weight": w,
3455
                        "site_index": self._get_original_site(structure, s),
3456
                    }
3457
                )
3458
        return siw
1✔
3459

3460

3461
class BrunnerNN_relative(NearNeighbors):
1✔
3462
    """
3463
    Determine coordination number using Brunner's algorithm which counts the
3464
    atoms that are within the largest gap in differences in real space
3465
    interatomic distances. This algorithm uses Brunner's method of
3466
    of largest relative gap in interatomic distances.
3467
    """
3468

3469
    def __init__(self, tol: float = 1.0e-4, cutoff=8.0):
1✔
3470
        """
3471
        Args:
3472
            tol (float): tolerance parameter for bond determination
3473
                (default: 1E-4).
3474
            cutoff (float): cutoff radius in Angstrom to look for near-neighbor
3475
                atoms. Defaults to 8.0.
3476
        """
3477
        self.tol = tol
1✔
3478
        self.cutoff = cutoff
1✔
3479

3480
    @property
1✔
3481
    def structures_allowed(self):
1✔
3482
        """
3483
        Boolean property: can this NearNeighbors class be used with Structure
3484
        objects?
3485
        """
3486
        return True
×
3487

3488
    @property
1✔
3489
    def molecules_allowed(self):
1✔
3490
        """
3491
        Boolean property: can this NearNeighbors class be used with Molecule
3492
        objects?
3493
        """
3494
        return False
×
3495

3496
    def get_nn_info(self, structure: Structure, n: int):
1✔
3497
        """
3498
        Get all near-neighbor sites as well as the associated image locations
3499
        and weights of the site with index n in structure.
3500

3501
        Args:
3502
            structure (Structure): input structure.
3503
            n (int): index of site for which to determine near-neighbor
3504
                sites.
3505

3506
        Returns:
3507
            siw (list of tuples (Site, array, float)): tuples, each one
3508
                of which represents a coordinated site, its image location,
3509
                and its weight.
3510
        """
3511
        site = structure[n]
1✔
3512
        neighs_dists = structure.get_neighbors(site, self.cutoff)
1✔
3513
        ds = sorted(i.nn_distance for i in neighs_dists)
1✔
3514

3515
        ns = [ds[i + 1] / ds[i] for i in range(len(ds) - 1)]
1✔
3516

3517
        d_max = ds[ns.index(max(ns))]
1✔
3518
        siw = []
1✔
3519
        for nn in neighs_dists:
1✔
3520
            s, dist = nn, nn.nn_distance
1✔
3521
            if dist < d_max + self.tol:
1✔
3522
                w = ds[0] / dist
1✔
3523
                siw.append(
1✔
3524
                    {
3525
                        "site": s,
3526
                        "image": self._get_image(structure, s),
3527
                        "weight": w,
3528
                        "site_index": self._get_original_site(structure, s),
3529
                    }
3530
                )
3531
        return siw
1✔
3532

3533

3534
class BrunnerNN_real(NearNeighbors):
1✔
3535
    """
3536
    Determine coordination number using Brunner's algorithm which counts the
3537
    atoms that are within the largest gap in differences in real space
3538
    interatomic distances. This algorithm uses Brunner's method of
3539
    largest gap in interatomic distances.
3540
    """
3541

3542
    def __init__(self, tol: float = 1.0e-4, cutoff=8.0):
1✔
3543
        """
3544
        Args:
3545
            tol (float): tolerance parameter for bond determination
3546
                (default: 1E-4).
3547
            cutoff (float): cutoff radius in Angstrom to look for near-neighbor
3548
                atoms. Defaults to 8.0.
3549
        """
3550
        self.tol = tol
1✔
3551
        self.cutoff = cutoff
1✔
3552

3553
    @property
1✔
3554
    def structures_allowed(self):
1✔
3555
        """
3556
        Boolean property: can this NearNeighbors class be used with Structure
3557
        objects?
3558
        """
3559
        return True
×
3560

3561
    @property
1✔
3562
    def molecules_allowed(self):
1✔
3563
        """
3564
        Boolean property: can this NearNeighbors class be used with Molecule
3565
        objects?
3566
        """
3567
        return False
×
3568

3569
    def get_nn_info(self, structure: Structure, n: int):
1✔
3570
        """
3571
        Get all near-neighbor sites as well as the associated image locations
3572
        and weights of the site with index n in structure.
3573

3574
        Args:
3575
            structure (Structure): input structure.
3576
            n (int): index of site for which to determine near-neighbor
3577
                sites.
3578

3579
        Returns:
3580
            siw (list of tuples (Site, array, float)): tuples, each one
3581
                of which represents a coordinated site, its image location,
3582
                and its weight.
3583
        """
3584
        site = structure[n]
1✔
3585
        neighs_dists = structure.get_neighbors(site, self.cutoff)
1✔
3586
        ds = sorted(i.nn_distance for i in neighs_dists)
1✔
3587

3588
        ns = [ds[i + 1] - ds[i] for i in range(len(ds) - 1)]
1✔
3589

3590
        d_max = ds[ns.index(max(ns))]
1✔
3591
        siw = []
1✔
3592
        for nn in neighs_dists:
1✔
3593
            s, dist = nn, nn.nn_distance
1✔
3594
            if dist < d_max + self.tol:
1✔
3595
                w = ds[0] / dist
1✔
3596
                siw.append(
1✔
3597
                    {
3598
                        "site": s,
3599
                        "image": self._get_image(structure, s),
3600
                        "weight": w,
3601
                        "site_index": self._get_original_site(structure, s),
3602
                    }
3603
                )
3604
        return siw
1✔
3605

3606

3607
class EconNN(NearNeighbors):
1✔
3608
    """
3609
    Determines the average effective coordination number for each cation in a
3610
    given structure using Hoppe's algorithm.
3611

3612
    This method follows the procedure outlined in:
3613

3614
    Hoppe, Rudolf. "Effective coordination numbers (ECoN) and mean fictive ionic
3615
    radii (MEFIR)." Zeitschrift für Kristallographie-Crystalline Materials
3616
    150.1-4 (1979): 23-52.
3617
    """
3618

3619
    def __init__(
1✔
3620
        self,
3621
        tol: float = 0.2,
3622
        cutoff: float = 10.0,
3623
        cation_anion: bool = False,
3624
        use_fictive_radius: bool = False,
3625
    ):
3626
        """
3627
        Args:
3628
            tol: Tolerance parameter for bond determination.
3629
            cutoff: Cutoff radius in Angstrom to look for near-neighbor atoms.
3630
            cation_anion: If set to True, will restrict bonding targets to
3631
                sites with opposite or zero charge. Requires an oxidation states
3632
                on all sites in the structure.
3633
            use_fictive_radius: Whether to use the fictive radius in the
3634
                EcoN calculation. If False, the bond distance will be used.
3635
        """
3636
        self.tol = tol
1✔
3637
        self.cutoff = cutoff
1✔
3638
        self.cation_anion = cation_anion
1✔
3639
        self.use_fictive_radius = use_fictive_radius
1✔
3640

3641
    @property
1✔
3642
    def structures_allowed(self):
1✔
3643
        """
3644
        Boolean property: can this NearNeighbors class be used with Structure
3645
        objects?
3646
        """
3647
        return True
×
3648

3649
    @property
1✔
3650
    def molecules_allowed(self):
1✔
3651
        """
3652
        Boolean property: can this NearNeighbors class be used with Molecule
3653
        objects?
3654
        """
3655
        return True
×
3656

3657
    @property
1✔
3658
    def extend_structure_molecules(self):
1✔
3659
        """
3660
        Boolean property: Do Molecules need to be converted to Structures to use
3661
        this NearNeighbors class? Note: this property is not defined for classes
3662
        for which molecules_allowed is False.
3663
        """
3664
        return True
×
3665

3666
    def get_nn_info(self, structure: Structure, n: int):
1✔
3667
        """
3668
        Get all near-neighbor sites as well as the associated image locations
3669
        and weights of the site with index n in structure.
3670

3671
        Args:
3672
            structure (Structure): input structure.
3673
            n (int): index of site for which to determine near-neighbor
3674
                sites.
3675

3676
        Returns:
3677
            siw (list of tuples (Site, array, float)): tuples, each one
3678
                of which represents a coordinated site, its image location,
3679
                and its weight.
3680
        """
3681
        site = structure[n]
1✔
3682
        neighbors = structure.get_neighbors(site, self.cutoff)
1✔
3683

3684
        if self.cation_anion and hasattr(site.specie, "oxi_state"):
1✔
3685
            # filter out neighbor of like charge (except for neutral sites)
3686
            if site.specie.oxi_state >= 0:
×
3687
                neighbors = [n for n in neighbors if n.oxi_state <= 0]
×
3688
            elif site.specie.oxi_state <= 0:
×
3689
                neighbors = [n for n in neighbors if n.oxi_state >= 0]
×
3690

3691
        if self.use_fictive_radius:
1✔
3692
            # calculate fictive ionic radii
3693
            firs = [_get_fictive_ionic_radius(site, neighbor) for neighbor in neighbors]
×
3694
        else:
3695
            # just use the bond distance
3696
            firs = [neighbor.nn_distance for neighbor in neighbors]
1✔
3697

3698
        # calculate mean fictive ionic radius
3699
        mefir = _get_mean_fictive_ionic_radius(firs)
1✔
3700

3701
        # # iteratively solve MEFIR; follows equation 4 in Hoppe's EconN paper
3702
        prev_mefir = float("inf")
1✔
3703
        while abs(prev_mefir - mefir) > 1e-4:
1✔
3704
            # this is guaranteed to converge
3705
            prev_mefir = mefir
1✔
3706
            mefir = _get_mean_fictive_ionic_radius(firs, minimum_fir=mefir)
1✔
3707

3708
        siw = []
1✔
3709
        for nn, fir in zip(neighbors, firs):
1✔
3710
            if nn.nn_distance < self.cutoff:
1✔
3711
                w = exp(1 - (fir / mefir) ** 6)
1✔
3712
                if w > self.tol:
1✔
3713
                    bonded_site = {
1✔
3714
                        "site": nn,
3715
                        "image": self._get_image(structure, nn),
3716
                        "weight": w,
3717
                        "site_index": self._get_original_site(structure, nn),
3718
                    }
3719
                    siw.append(bonded_site)
1✔
3720
        return siw
1✔
3721

3722

3723
def _get_fictive_ionic_radius(site: Site, neighbor: PeriodicNeighbor) -> float:
1✔
3724
    """
3725
    Get fictive ionic radius.
3726

3727
    Follows equation 1 of:
3728

3729
    Hoppe, Rudolf. "Effective coordination numbers (ECoN) and mean fictive ionic
3730
    radii (MEFIR)." Zeitschrift für Kristallographie-Crystalline Materials
3731
    150.1-4 (1979): 23-52.
3732

3733
    Args:
3734
        site: The central site.
3735
        neighbor neighboring site.
3736

3737
    Returns:
3738
        Hoppe's fictive ionic radius.
3739
    """
3740
    r_h = _get_radius(site)
×
3741
    if r_h == 0:
×
3742
        r_h = _get_default_radius(site)
×
3743

3744
    r_i = _get_radius(neighbor)
×
3745
    if r_i == 0:
×
3746
        r_i = _get_default_radius(neighbor)
×
3747

3748
    return neighbor.nn_distance * (r_h / (r_h + r_i))
×
3749

3750

3751
def _get_mean_fictive_ionic_radius(
1✔
3752
    fictive_ionic_radii: list[float],
3753
    minimum_fir: float | None = None,
3754
) -> float:
3755
    """
3756
    Returns the mean fictive ionic radius.
3757

3758
    Follows equation 2:
3759

3760
    Hoppe, Rudolf. "Effective coordination numbers (ECoN) and mean fictive ionic
3761
    radii (MEFIR)." Zeitschrift für Kristallographie-Crystalline Materials
3762
    150.1-4 (1979): 23-52.
3763

3764
    Args:
3765
        fictive_ionic_radii: List of fictive ionic radii for a center site
3766
            and its neighbors.
3767
        minimum_fir: Minimum fictive ionic radius to use.
3768

3769
    Returns:
3770
        Hoppe's mean fictive ionic radius.
3771
    """
3772
    if not minimum_fir:
1✔
3773
        minimum_fir = min(fictive_ionic_radii)
1✔
3774

3775
    weighted_sum = 0.0
1✔
3776
    total_sum = 0.0
1✔
3777
    for fir in fictive_ionic_radii:
1✔
3778
        weighted_sum += fir * exp(1 - (fir / minimum_fir) ** 6)
1✔
3779
        total_sum += exp(1 - (fir / minimum_fir) ** 6)
1✔
3780

3781
    return weighted_sum / total_sum
1✔
3782

3783

3784
class CrystalNN(NearNeighbors):
1✔
3785
    """
3786
    This is a custom near-neighbor method intended for use in all kinds of periodic structures
3787
    (metals, minerals, porous structures, etc). It is based on a Voronoi algorithm and uses the
3788
    solid angle weights to determine the probability of various coordination environments. The
3789
    algorithm can also modify probability using smooth distance cutoffs as well as Pauling
3790
    electronegativity differences. The output can either be the most probable coordination
3791
    environment or a weighted list of coordination environments.
3792
    """
3793

3794
    NNData = namedtuple("NNData", ["all_nninfo", "cn_weights", "cn_nninfo"])
1✔
3795

3796
    def __init__(
1✔
3797
        self,
3798
        weighted_cn=False,
3799
        cation_anion=False,
3800
        distance_cutoffs=(0.5, 1),
3801
        x_diff_weight=3.0,
3802
        porous_adjustment=True,
3803
        search_cutoff=7,
3804
        fingerprint_length=None,
3805
    ):
3806
        """
3807
        Initialize CrystalNN with desired parameters. Default parameters assume
3808
        "chemical bond" type behavior is desired. For geometric neighbor
3809
        finding (e.g., structural framework), set (i) distance_cutoffs=None,
3810
        (ii) x_diff_weight=0.0 and (optionally) (iii) porous_adjustment=False
3811
        which will disregard the atomic identities and perform best for a purely
3812
        geometric match.
3813

3814
        Args:
3815
            weighted_cn: (bool) if set to True, will return fractional weights
3816
                for each potential near neighbor.
3817
            cation_anion: (bool) if set True, will restrict bonding targets to
3818
                sites with opposite or zero charge. Requires an oxidation states
3819
                on all sites in the structure.
3820
            distance_cutoffs: ([float, float]) - if not None, penalizes neighbor
3821
                distances greater than sum of covalent radii plus
3822
                distance_cutoffs[0]. Distances greater than covalent radii sum
3823
                plus distance_cutoffs[1] are enforced to have zero weight.
3824
            x_diff_weight: (float) - if multiple types of neighbor elements are
3825
                possible, this sets preferences for targets with higher
3826
                electronegativity difference.
3827
            porous_adjustment: (bool) - if True, readjusts Voronoi weights to
3828
                better describe layered / porous structures
3829
            search_cutoff: (float) cutoff in Angstroms for initial neighbor
3830
                search; this will be adjusted if needed internally
3831
            fingerprint_length: (int) if a fixed_length CN "fingerprint" is
3832
                desired from get_nn_data(), set this parameter
3833
        """
3834
        self.weighted_cn = weighted_cn
1✔
3835
        self.cation_anion = cation_anion
1✔
3836
        self.distance_cutoffs = distance_cutoffs
1✔
3837
        self.x_diff_weight = x_diff_weight if x_diff_weight is not None else 0
1✔
3838
        self.search_cutoff = search_cutoff
1✔
3839
        self.porous_adjustment = porous_adjustment
1✔
3840
        self.fingerprint_length = fingerprint_length
1✔
3841

3842
    @property
1✔
3843
    def structures_allowed(self):
1✔
3844
        """
3845
        Boolean property: can this NearNeighbors class be used with Structure
3846
        objects?
3847
        """
3848
        return True
1✔
3849

3850
    @property
1✔
3851
    def molecules_allowed(self):
1✔
3852
        """
3853
        Boolean property: can this NearNeighbors class be used with Molecule
3854
        objects?
3855
        """
3856
        return False
×
3857

3858
    def get_nn_info(self, structure: Structure, n: int) -> list[dict]:
1✔
3859
        """
3860
        Get all near-neighbor information.
3861
        Args:
3862
            structure: (Structure) pymatgen Structure
3863
            n: (int) index of target site
3864

3865
        Returns:
3866
            siw (list[dict]): each dictionary provides information
3867
                about a single near neighbor, where key 'site' gives access to the
3868
                corresponding Site object, 'image' gives the image location, and
3869
                'weight' provides the weight that a given near-neighbor site contributes
3870
                to the coordination number (1 or smaller), 'site_index' gives index of
3871
                the corresponding site in the original structure.
3872
        """
3873
        nndata = self.get_nn_data(structure, n)
1✔
3874

3875
        if not self.weighted_cn:
1✔
3876
            max_key = max(nndata.cn_weights, key=lambda k: nndata.cn_weights[k])
1✔
3877
            nn = nndata.cn_nninfo[max_key]
1✔
3878
            for entry in nn:
1✔
3879
                entry["weight"] = 1
1✔
3880
            return nn
1✔
3881

3882
        for entry in nndata.all_nninfo:
1✔
3883
            weight = 0
1✔
3884
            for cn in nndata.cn_nninfo:
1✔
3885
                for cn_entry in nndata.cn_nninfo[cn]:
1✔
3886
                    if entry["site"] == cn_entry["site"]:
1✔
3887
                        weight += nndata.cn_weights[cn]
1✔
3888

3889
            entry["weight"] = weight
1✔
3890

3891
        return nndata.all_nninfo
1✔
3892

3893
    def get_nn_data(self, structure: Structure, n: int, length=None):
1✔
3894
        """
3895
        The main logic of the method to compute near neighbor.
3896

3897
        Args:
3898
            structure: (Structure) enclosing structure object
3899
            n: (int) index of target site to get NN info for
3900
            length: (int) if set, will return a fixed range of CN numbers
3901

3902
        Returns:
3903
            a namedtuple (NNData) object that contains:
3904
                - all near neighbor sites with weights
3905
                - a dict of CN -> weight
3906
                - a dict of CN -> associated near neighbor sites
3907
        """
3908
        length = length or self.fingerprint_length
1✔
3909

3910
        # determine possible bond targets
3911
        target = None
1✔
3912
        if self.cation_anion:
1✔
3913
            target = []
1✔
3914
            m_oxi = structure[n].specie.oxi_state
1✔
3915
            for site in structure:
1✔
3916
                if site.specie.oxi_state * m_oxi <= 0:  # opposite charge
1✔
3917
                    target.append(site.specie)
1✔
3918
            if not target:
1✔
3919
                raise ValueError("No valid targets for site within cation_anion constraint!")
×
3920

3921
        # get base VoronoiNN targets
3922
        cutoff = self.search_cutoff
1✔
3923
        vnn = VoronoiNN(weight="solid_angle", targets=target, cutoff=cutoff)
1✔
3924
        nn = vnn.get_nn_info(structure, n)
1✔
3925

3926
        # solid angle weights can be misleading in open / porous structures
3927
        # adjust weights to correct for this behavior
3928
        if self.porous_adjustment:
1✔
3929
            for x in nn:
1✔
3930
                x["weight"] *= x["poly_info"]["solid_angle"] / x["poly_info"]["area"]
1✔
3931

3932
        # adjust solid angle weight based on electronegativity difference
3933
        if self.x_diff_weight > 0:
1✔
3934
            for entry in nn:
1✔
3935
                X1 = structure[n].specie.X
1✔
3936
                X2 = entry["site"].specie.X
1✔
3937

3938
                if math.isnan(X1) or math.isnan(X2):
1✔
3939
                    chemical_weight = 1
1✔
3940
                else:
3941
                    # note: 3.3 is max deltaX between 2 elements
3942
                    chemical_weight = 1 + self.x_diff_weight * math.sqrt(abs(X1 - X2) / 3.3)
1✔
3943

3944
                entry["weight"] = entry["weight"] * chemical_weight
1✔
3945

3946
        # sort nearest neighbors from highest to lowest weight
3947
        nn = sorted(nn, key=lambda x: x["weight"], reverse=True)
1✔
3948
        if nn[0]["weight"] == 0:
1✔
3949
            return self.transform_to_length(self.NNData([], {0: 1.0}, {0: []}), length)
×
3950

3951
        # renormalize weights so the highest weight is 1.0
3952
        highest_weight = nn[0]["weight"]
1✔
3953
        for entry in nn:
1✔
3954
            entry["weight"] = entry["weight"] / highest_weight
1✔
3955

3956
        # adjust solid angle weights based on distance
3957
        if self.distance_cutoffs:
1✔
3958
            r1 = _get_radius(structure[n])
1✔
3959
            for entry in nn:
1✔
3960
                r2 = _get_radius(entry["site"])
1✔
3961
                if r1 > 0 and r2 > 0:
1✔
3962
                    d = r1 + r2
1✔
3963
                else:
3964
                    warnings.warn(
1✔
3965
                        "CrystalNN: cannot locate an appropriate radius, "
3966
                        "covalent or atomic radii will be used, this can lead "
3967
                        "to non-optimal results."
3968
                    )
3969
                    d = _get_default_radius(structure[n]) + _get_default_radius(entry["site"])
1✔
3970

3971
                dist = np.linalg.norm(structure[n].coords - entry["site"].coords)
1✔
3972
                dist_weight: float = 0
1✔
3973

3974
                cutoff_low = d + self.distance_cutoffs[0]
1✔
3975
                cutoff_high = d + self.distance_cutoffs[1]
1✔
3976

3977
                if dist <= cutoff_low:
1✔
3978
                    dist_weight = 1
1✔
3979
                elif dist < cutoff_high:
1✔
3980
                    dist_weight = (math.cos((dist - cutoff_low) / (cutoff_high - cutoff_low) * math.pi) + 1) * 0.5
1✔
3981
                entry["weight"] = entry["weight"] * dist_weight
1✔
3982

3983
        # sort nearest neighbors from highest to lowest weight
3984
        nn = sorted(nn, key=lambda x: x["weight"], reverse=True)
1✔
3985
        if nn[0]["weight"] == 0:
1✔
3986
            return self.transform_to_length(self.NNData([], {0: 1.0}, {0: []}), length)
1✔
3987

3988
        for entry in nn:
1✔
3989
            entry["weight"] = round(entry["weight"], 3)
1✔
3990
            del entry["poly_info"]  # trim
1✔
3991

3992
        # remove entries with no weight
3993
        nn = [x for x in nn if x["weight"] > 0]
1✔
3994

3995
        # get the transition distances, i.e. all distinct weights
3996
        dist_bins: list[float] = []
1✔
3997
        for entry in nn:
1✔
3998
            if not dist_bins or dist_bins[-1] != entry["weight"]:
1✔
3999
                dist_bins.append(entry["weight"])
1✔
4000
        dist_bins.append(0)
1✔
4001

4002
        # main algorithm to determine fingerprint from bond weights
4003
        cn_weights = {}  # CN -> score for that CN
1✔
4004
        cn_nninfo = {}  # CN -> list of nearneighbor info for that CN
1✔
4005
        for idx, val in enumerate(dist_bins):
1✔
4006
            if val != 0:
1✔
4007
                nn_info = []
1✔
4008
                for entry in nn:
1✔
4009
                    if entry["weight"] >= val:
1✔
4010
                        nn_info.append(entry)
1✔
4011
                cn = len(nn_info)
1✔
4012
                cn_nninfo[cn] = nn_info
1✔
4013
                cn_weights[cn] = self._semicircle_integral(dist_bins, idx)
1✔
4014

4015
        # add zero coord
4016
        cn0_weight = 1.0 - sum(cn_weights.values())
1✔
4017
        if cn0_weight > 0:
1✔
4018
            cn_nninfo[0] = []
1✔
4019
            cn_weights[0] = cn0_weight
1✔
4020

4021
        return self.transform_to_length(self.NNData(nn, cn_weights, cn_nninfo), length)
1✔
4022

4023
    def get_cn(self, structure: Structure, n: int, **kwargs) -> float:  # type: ignore
1✔
4024
        """
4025
        Get coordination number, CN, of site with index n in structure.
4026

4027
        Args:
4028
            structure (Structure): input structure.
4029
            n (int): index of site for which to determine CN.
4030
            use_weights (bool): flag indicating whether (True)
4031
                to use weights for computing the coordination number
4032
                or not (False, default: each coordinated site has equal
4033
                weight).
4034
            on_disorder ('take_majority_strict' | 'take_majority_drop' | 'take_max_species' | 'error'):
4035
                What to do when encountering a disordered structure. 'error' will raise ValueError.
4036
                'take_majority_strict' will use the majority specie on each site and raise
4037
                ValueError if no majority exists. 'take_max_species' will use the first max specie
4038
                on each site. For {{Fe: 0.4, O: 0.4, C: 0.2}}, 'error' and 'take_majority_strict'
4039
                will raise ValueError, while 'take_majority_drop' ignores this site altogether and
4040
                'take_max_species' will use Fe as the site specie.
4041

4042
        Returns:
4043
            cn (int or float): coordination number.
4044
        """
4045
        use_weights = kwargs.get("use_weights", False)
1✔
4046
        if self.weighted_cn != use_weights:
1✔
4047
            raise ValueError("The weighted_cn parameter and use_weights parameter should match!")
1✔
4048

4049
        return super().get_cn(structure, n, **kwargs)
1✔
4050

4051
    def get_cn_dict(self, structure: Structure, n: int, use_weights: bool = False, **kwargs):
1✔
4052
        """
4053
        Get coordination number, CN, of each element bonded to site with index n in structure
4054

4055
        Args:
4056
            structure (Structure): input structure
4057
            n (int): index of site for which to determine CN.
4058
            use_weights (bool): flag indicating whether (True)
4059
                to use weights for computing the coordination number
4060
                or not (False, default: each coordinated site has equal
4061
                weight).
4062

4063
        Returns:
4064
            cn (dict): dictionary of CN of each element bonded to site
4065
        """
4066
        if self.weighted_cn != use_weights:
×
4067
            raise ValueError("The weighted_cn parameter and use_weights parameter should match!")
×
4068

4069
        return super().get_cn_dict(structure, n, use_weights)
×
4070

4071
    @staticmethod
1✔
4072
    def _semicircle_integral(dist_bins, idx):
1✔
4073
        """
4074
        An internal method to get an integral between two bounds of a unit
4075
        semicircle. Used in algorithm to determine bond probabilities.
4076
        Args:
4077
            dist_bins: (float) list of all possible bond weights
4078
            idx: (float) index of starting bond weight
4079

4080
        Returns:
4081
            (float) integral of portion of unit semicircle
4082
        """
4083
        r = 1
1✔
4084

4085
        x1 = dist_bins[idx]
1✔
4086
        x2 = dist_bins[idx + 1]
1✔
4087

4088
        if dist_bins[idx] == 1:
1✔
4089
            area1 = 0.25 * math.pi * r**2
1✔
4090
        else:
4091
            area1 = 0.5 * ((x1 * math.sqrt(r**2 - x1**2)) + (r**2 * math.atan(x1 / math.sqrt(r**2 - x1**2))))
1✔
4092

4093
        area2 = 0.5 * ((x2 * math.sqrt(r**2 - x2**2)) + (r**2 * math.atan(x2 / math.sqrt(r**2 - x2**2))))
1✔
4094

4095
        return (area1 - area2) / (0.25 * math.pi * r**2)
1✔
4096

4097
    @staticmethod
1✔
4098
    def transform_to_length(nndata, length):
1✔
4099
        """
4100
        Given NNData, transforms data to the specified fingerprint length
4101
        Args:
4102
            nndata: (NNData)
4103
            length: (int) desired length of NNData
4104
        """
4105
        if length is None:
1✔
4106
            return nndata
1✔
4107

4108
        if length:
1✔
4109
            for cn in range(length):
1✔
4110
                if cn not in nndata.cn_weights:
1✔
4111
                    nndata.cn_weights[cn] = 0
1✔
4112
                    nndata.cn_nninfo[cn] = []
1✔
4113

4114
        return nndata
1✔
4115

4116

4117
def _get_default_radius(site):
1✔
4118
    """
4119
    An internal method to get a "default" covalent/element radius
4120

4121
    Args:
4122
        site: (Site)
4123

4124
    Returns:
4125
        Covalent radius of element on site, or Atomic radius if unavailable
4126
    """
4127
    try:
1✔
4128
        return CovalentRadius.radius[site.specie.symbol]
1✔
4129
    except Exception:
×
4130
        return site.specie.atomic_radius
×
4131

4132

4133
def _get_radius(site):
1✔
4134
    """
4135
    An internal method to get the expected radius for a site with
4136
    oxidation state.
4137
    Args:
4138
        site: (Site)
4139

4140
    Returns:
4141
        Oxidation-state dependent radius: ionic, covalent, or atomic.
4142
        Returns 0 if no oxidation state or appropriate radius is found.
4143
    """
4144
    if hasattr(site.specie, "oxi_state"):
1✔
4145
        el = site.specie.element
1✔
4146
        oxi = site.specie.oxi_state
1✔
4147

4148
        if oxi == 0:
1✔
4149
            return _get_default_radius(site)
1✔
4150

4151
        if oxi in el.ionic_radii:
1✔
4152
            return el.ionic_radii[oxi]
1✔
4153

4154
        # e.g., oxi = 2.667, average together 2+ and 3+ radii
4155
        if int(math.floor(oxi)) in el.ionic_radii and int(math.ceil(oxi)) in el.ionic_radii:
1✔
4156
            oxi_low = el.ionic_radii[int(math.floor(oxi))]
×
4157
            oxi_high = el.ionic_radii[int(math.ceil(oxi))]
×
4158
            x = oxi - int(math.floor(oxi))
×
4159
            return (1 - x) * oxi_low + x * oxi_high
×
4160

4161
        if oxi > 0 and el.average_cationic_radius > 0:
1✔
4162
            return el.average_cationic_radius
×
4163

4164
        if el.average_anionic_radius > 0 > oxi:
1✔
4165
            return el.average_anionic_radius
×
4166

4167
    else:
4168
        warnings.warn(
1✔
4169
            "No oxidation states specified on sites! For better results, set "
4170
            "the site oxidation states in the structure."
4171
        )
4172
    return 0
1✔
4173

4174

4175
class CutOffDictNN(NearNeighbors):
1✔
4176
    """
4177
    A basic NN class using a dictionary of fixed cut-off distances.
4178
    Only pairs of elements listed in the cut-off dictionary are considered
4179
    during construction of the neighbor lists.
4180

4181
    Omit passing a dictionary for a Null/Empty NN class.
4182
    """
4183

4184
    def __init__(self, cut_off_dict=None):
1✔
4185
        """
4186
        Args:
4187
            cut_off_dict (dict[str, float]): a dictionary
4188
            of cut-off distances, e.g. {('Fe','O'): 2.0} for
4189
            a maximum Fe-O bond length of 2.0 Angstroms.
4190
            Bonds will only be created between pairs listed
4191
            in the cut-off dictionary.
4192
            If your structure is oxidation state decorated,
4193
            the cut-off distances will have to explicitly include
4194
            the oxidation state, e.g. {('Fe2+', 'O2-'): 2.0}
4195
        """
4196
        self.cut_off_dict = cut_off_dict or {}
1✔
4197

4198
        # for convenience
4199
        self._max_dist = 0.0
1✔
4200
        lookup_dict = defaultdict(dict)
1✔
4201
        for (sp1, sp2), dist in self.cut_off_dict.items():
1✔
4202
            lookup_dict[sp1][sp2] = dist
1✔
4203
            lookup_dict[sp2][sp1] = dist
1✔
4204
            if dist > self._max_dist:
1✔
4205
                self._max_dist = dist
1✔
4206
        self._lookup_dict = lookup_dict
1✔
4207

4208
    @property
1✔
4209
    def structures_allowed(self):
1✔
4210
        """
4211
        Boolean property: can this NearNeighbors class be used with Structure
4212
        objects?
4213
        """
4214
        return True
1✔
4215

4216
    @property
1✔
4217
    def molecules_allowed(self):
1✔
4218
        """
4219
        Boolean property: can this NearNeighbors class be used with Molecule
4220
        objects?
4221
        """
4222
        return True
×
4223

4224
    @property
1✔
4225
    def extend_structure_molecules(self):
1✔
4226
        """
4227
        Boolean property: Do Molecules need to be converted to Structures to use
4228
        this NearNeighbors class? Note: this property is not defined for classes
4229
        for which molecules_allowed is False.
4230
        """
4231
        return True
×
4232

4233
    @staticmethod
1✔
4234
    def from_preset(preset):
1✔
4235
        """
4236
        Initialise a CutOffDictNN according to a preset set of cut-offs.
4237

4238
        Args:
4239
            preset (str): A preset name. The list of supported presets are:
4240

4241
                - "vesta_2019": The distance cut-offs used by the VESTA
4242
                  visualisation program.
4243

4244
        Returns:
4245
            A CutOffDictNN using the preset cut-off dictionary.
4246
        """
4247
        if preset == "vesta_2019":
1✔
4248
            cut_offs = loadfn(os.path.join(_directory, "vesta_cutoffs.yaml"))
1✔
4249
            return CutOffDictNN(cut_off_dict=cut_offs)
1✔
4250

4251
        raise ValueError(f"Unrecognised preset: {preset}")
1✔
4252

4253
    def get_nn_info(self, structure: Structure, n: int):
1✔
4254
        """
4255
        Get all near-neighbor sites as well as the associated image locations
4256
        and weights of the site with index n in structure.
4257

4258
        Args:
4259
            structure (Structure): input structure.
4260
            n (int): index of site for which to determine near-neighbor
4261
                sites.
4262

4263
        Returns:
4264
            siw (list of tuples (Site, array, float)): tuples, each one
4265
                of which represents a coordinated site, its image location,
4266
                and its weight.
4267
        """
4268
        site = structure[n]
1✔
4269

4270
        neighs_dists = structure.get_neighbors(site, self._max_dist)
1✔
4271

4272
        nn_info = []
1✔
4273
        for nn in neighs_dists:
1✔
4274
            n_site = nn
1✔
4275
            dist = nn.nn_distance
1✔
4276
            neigh_cut_off_dist = self._lookup_dict.get(site.species_string, {}).get(n_site.species_string, 0.0)
1✔
4277

4278
            if dist < neigh_cut_off_dist:
1✔
4279
                nn_info.append(
1✔
4280
                    {
4281
                        "site": n_site,
4282
                        "image": self._get_image(structure, n_site),
4283
                        "weight": dist,
4284
                        "site_index": self._get_original_site(structure, n_site),
4285
                    }
4286
                )
4287

4288
        return nn_info
1✔
4289

4290

4291
class Critic2NN(NearNeighbors):
1✔
4292
    """
4293
    Performs a topological analysis using critic2 to obtain
4294
    neighbor information, using a sum of atomic charge
4295
    densities. If an actual charge density is available
4296
    (e.g. from a VASP CHGCAR), see Critic2Caller directly
4297
    instead.
4298
    """
4299

4300
    def __init__(self):
1✔
4301
        """
4302
        Init for Critic2NN.
4303
        """
4304
        # we cache the last-used structure, in case user
4305
        # calls get_nn_info() repeatedly for different
4306
        # sites in the same structure to save redundant
4307
        # computations
4308
        self.__last_structure = None
×
4309
        self.__last_bonded_structure = None
×
4310

4311
    @property
1✔
4312
    def structures_allowed(self):
1✔
4313
        """
4314
        Boolean property: can this NearNeighbors class be used with Structure
4315
        objects?
4316
        """
4317
        return True
×
4318

4319
    @property
1✔
4320
    def molecules_allowed(self):
1✔
4321
        """
4322
        Boolean property: can this NearNeighbors class be used with Molecule
4323
        objects?
4324
        """
4325
        return True
×
4326

4327
    @property
1✔
4328
    def extend_structure_molecules(self):
1✔
4329
        """
4330
        Boolean property: Do Molecules need to be converted to Structures to use
4331
        this NearNeighbors class? Note: this property is not defined for classes
4332
        for which molecules_allowed is False.
4333
        """
4334
        return True
×
4335

4336
    def get_bonded_structure(self, structure: Structure, decorate: bool = False) -> StructureGraph:  # type: ignore
1✔
4337
        """
4338
        Args:
4339
            structure (Structure): Input structure
4340
            decorate (bool, optional): Whether to decorate the structure. Defaults to False.
4341

4342
        Returns:
4343
            StructureGraph: Bonded structure
4344
        """
4345
        # not a top-level import because critic2 is an optional
4346
        # dependency, only want to raise an import error if
4347
        # Critic2NN() is used
4348
        from pymatgen.command_line.critic2_caller import Critic2Caller
×
4349

4350
        if structure == self.__last_structure:
×
4351
            sg = self.__last_bonded_structure
×
4352
        else:
4353
            c2_output = Critic2Caller(structure).output
×
4354
            sg = c2_output.structure_graph()
×
4355

4356
            self.__last_structure = structure
×
4357
            self.__last_bonded_structure = sg
×
4358

4359
        if decorate:
×
4360
            order_parameters = [self.get_local_order_parameters(structure, n) for n in range(len(structure))]
×
4361
            sg.structure.add_site_property("order_parameters", order_parameters)
×
4362

4363
        return sg
×
4364

4365
    def get_nn_info(self, structure: Structure, n: int):
1✔
4366
        """
4367
        Get all near-neighbor sites as well as the associated image locations
4368
        and weights of the site with index n in structure.
4369

4370
        Args:
4371
            structure (Structure): input structure.
4372
            n (int): index of site for which to determine near-neighbor
4373
                sites.
4374

4375
        Returns:
4376
            siw (list of tuples (Site, array, float)): tuples, each one
4377
                of which represents a coordinated site, its image location,
4378
                and its weight.
4379
        """
4380
        sg = self.get_bonded_structure(structure)
×
4381

4382
        return [
×
4383
            {
4384
                "site": connected_site.site,
4385
                "image": connected_site.jimage,
4386
                "weight": connected_site.weight,
4387
                "site_index": connected_site.index,
4388
            }
4389
            for connected_site in sg.get_connected_sites(n)
4390
        ]
4391

4392

4393
def metal_edge_extender(
1✔
4394
    mol_graph,
4395
    cutoff: float = 2.5,
4396
    metals: list | tuple | None = ("Li", "Mg", "Ca", "Zn", "B", "Al"),
4397
    coordinators: list | tuple = ("O", "N", "F", "S", "Cl"),
4398
):
4399
    """
4400
    Function to identify and add missed coordinate bond edges for metals
4401

4402
    Args:
4403
        mol_graph: pymatgen.analysis.graphs.MoleculeGraph object
4404
        cutoff: cutoff in Angstrom. Metal-coordinator sites that are closer
4405
            together than this value will be considered coordination bonds.
4406
            If the MoleculeGraph contains a metal, but no coordination bonds are found
4407
            with the chosen cutoff, the cutoff will be increased by 1 Angstrom
4408
            and another attempt will be made to identify coordination bonds.
4409
        metals: Species considered metals for the purpose of identifying
4410
            missed coordinate bond edges. The set {"Li", "Mg", "Ca", "Zn", "B", "Al"}
4411
            (default) corresponds to the settings used in the LIBE dataset.
4412
            Alternatively, set to None to cause any Species classified as a metal
4413
            by Specie.is_metal to be considered a metal.
4414
        coordinators: Possible coordinating species to consider when identifying
4415
            missed coordinate bonds. The default set {"O", "N", "F", "S", "Cl"} was
4416
            used in the LIBE dataset.
4417

4418
    Returns:
4419
        mol_graph: pymatgen.analysis.graphs.MoleculeGraph object with additional
4420
            metal bonds (if any found) added
4421

4422
    """
4423
    if metals is None:
1✔
4424
        metals = []
1✔
4425
        for idx in mol_graph.graph.nodes():
1✔
4426
            if Species(mol_graph.graph.nodes()[idx]["specie"]).is_metal:
1✔
4427
                metals.append(mol_graph.graph.nodes()[idx]["specie"])
1✔
4428
    metal_sites: dict = {k: {} for k in metals}
1✔
4429

4430
    num_new_edges = 0
1✔
4431
    for idx in mol_graph.graph.nodes():
1✔
4432
        if mol_graph.graph.nodes()[idx]["specie"] in metals:
1✔
4433
            metal_sites[mol_graph.graph.nodes()[idx]["specie"]][idx] = [
1✔
4434
                site[2] for site in mol_graph.get_connected_sites(idx)
4435
            ]
4436
    for sites in metal_sites.values():
1✔
4437
        for idx, indices in sites.items():
1✔
4438
            for ii, site in enumerate(mol_graph.molecule):
1✔
4439
                if ii != idx and ii not in indices:
1✔
4440
                    if str(site.specie) in coordinators:
1✔
4441
                        if site.distance(mol_graph.molecule[idx]) < cutoff:
1✔
4442
                            mol_graph.add_edge(idx, ii)
1✔
4443
                            num_new_edges += 1
1✔
4444
                            indices.append(ii)
1✔
4445
    # If no metal edges are found, increase cutoff by 1 Ang and repeat analysis
4446
    total_metal_edges = 0
1✔
4447
    for sites in metal_sites.values():
1✔
4448
        for indices in sites.values():
1✔
4449
            total_metal_edges += len(indices)
1✔
4450
    if total_metal_edges == 0:
1✔
4451
        for sites in metal_sites.values():
1✔
4452
            for idx, indices in sites.items():
1✔
4453
                for ii, site in enumerate(mol_graph.molecule):
1✔
4454
                    if ii != idx and ii not in indices:
1✔
4455
                        if str(site.specie) in coordinators:
1✔
4456
                            if site.distance(mol_graph.molecule[idx]) < (cutoff + 1):
1✔
4457
                                mol_graph.add_edge(idx, ii)
1✔
4458
                                num_new_edges += 1
1✔
4459
                                indices.append(ii)
1✔
4460

4461
    return mol_graph
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