• 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

55.49
/pymatgen/command_line/critic2_caller.py
1
"""
2
This module implements an interface to the critic2 Bader analysis code.
3

4
For most Bader analysis purposes, users are referred to
5
pymatgen.command_line.bader_caller instead, this module is for advanced
6
usage requiring identification of critical points in the charge density.
7

8
This module depends on a compiled critic2 executable available in the path.
9
Please follow the instructions at https://github.com/aoterodelaroza/critic2
10
to compile.
11

12
New users are *strongly* encouraged to read the critic2 manual first.
13

14
In brief,
15
* critic2 searches for critical points in charge density
16
* a critical point can be one of four types: nucleus, bond, ring
17
or cage
18
* it does this by seeding locations for likely critical points
19
and then searching in these regions
20
* there are two lists of critical points in the output, a list
21
of non-equivalent points (with in-depth information about the
22
field at those points), and a full list of points generated
23
by the appropriate symmetry operations
24
* connectivity between these points is also provided when
25
appropriate (e.g. the two nucleus critical points linked to
26
 a bond critical point)
27
* critic2 can do many other things besides
28

29
If you use this module, please cite the following:
30

31
A. Otero-de-la-Roza, E. R. Johnson and V. Luaña,
32
Comput. Phys. Communications 185, 1007-1018 (2014)
33
(https://doi.org/10.1016/j.cpc.2013.10.026)
34

35
A. Otero-de-la-Roza, M. A. Blanco, A. Martín Pendás and
36
V. Luaña, Comput. Phys. Communications 180, 157-166 (2009)
37
(https://doi.org/10.1016/j.cpc.2008.07.018)
38
"""
39

40
from __future__ import annotations
1✔
41

42
import glob
1✔
43
import logging
1✔
44
import os
1✔
45
import subprocess
1✔
46
import warnings
1✔
47
from enum import Enum
1✔
48
from shutil import which
1✔
49

50
import numpy as np
1✔
51
from monty.dev import requires
1✔
52
from monty.json import MSONable
1✔
53
from monty.serialization import loadfn
1✔
54
from monty.tempfile import ScratchDir
1✔
55
from scipy.spatial import KDTree
1✔
56

57
from pymatgen.analysis.graphs import StructureGraph
1✔
58
from pymatgen.core.periodic_table import DummySpecies
1✔
59
from pymatgen.core.structure import Structure
1✔
60
from pymatgen.io.vasp.inputs import Potcar
1✔
61
from pymatgen.io.vasp.outputs import Chgcar, VolumetricData
1✔
62

63
logging.basicConfig(level=logging.INFO)
1✔
64
logger = logging.getLogger(__name__)
1✔
65

66

67
class Critic2Caller:
1✔
68
    """
69
    Class to call critic2 and store standard output for further processing.
70
    """
71

72
    @requires(
1✔
73
        which("critic2"),
74
        "Critic2Caller requires the executable critic to be in the path. "
75
        "Please follow the instructions at https://github.com/aoterodelaroza/critic2.",
76
    )
77
    def __init__(self, input_script):
1✔
78
        """
79
        Run Critic2 on a given input script
80

81
        :param input_script: string defining the critic2 input
82
        """
83
        # store if examining the input script is useful,
84
        # not otherwise used
85
        self._input_script = input_script
×
86

87
        with open("input_script.cri", "w") as f:
×
88
            f.write(input_script)
×
89

90
        args = ["critic2", "input_script.cri"]
×
91
        with subprocess.Popen(args, stdout=subprocess.PIPE, stdin=subprocess.PIPE, close_fds=True) as rs:
×
92
            stdout, stderr = rs.communicate()
×
93
        stdout = stdout.decode()
×
94

95
        if stderr:
×
96
            stderr = stderr.decode()
×
97
            warnings.warn(stderr)
×
98

99
        if rs.returncode != 0:
×
100
            raise RuntimeError(f"critic2 exited with return code {rs.returncode}: {stdout}")
×
101

102
        self._stdout = stdout
×
103
        self._stderr = stderr
×
104

105
        if os.path.exists("cpreport.json"):
×
106
            cpreport = loadfn("cpreport.json")
×
107
        else:
108
            cpreport = None
×
109
        self._cpreport = cpreport
×
110

111
        if os.path.exists("yt.json"):
×
112
            yt = loadfn("yt.json")
×
113
        else:
114
            yt = None
×
115
        self._yt = yt
×
116

117
    @classmethod
1✔
118
    def from_chgcar(
1✔
119
        cls,
120
        structure,
121
        chgcar=None,
122
        chgcar_ref=None,
123
        user_input_settings=None,
124
        write_cml=False,
125
        write_json=True,
126
        zpsp=None,
127
    ):
128
        """
129
        Run Critic2 in automatic mode on a supplied structure, charge
130
        density (chgcar) and reference charge density (chgcar_ref).
131

132
        The reason for a separate reference field is that in
133
        VASP, the CHGCAR charge density only contains valence
134
        electrons and may be missing substantial charge at
135
        nuclei leading to misleading results. Thus, a reference
136
        field is commonly constructed from the sum of AECCAR0
137
        and AECCAR2 which is the total charge density, but then
138
        the valence charge density is used for the final analysis.
139

140
        If chgcar_ref is not supplied, chgcar will be used as the
141
        reference field. If chgcar is not supplied, the promolecular
142
        charge density will be used as the reference field -- this can
143
        often still give useful results if only topological information
144
        is wanted.
145

146
        User settings is a dictionary that can contain:
147
        * GRADEPS, float (field units), gradient norm threshold
148
        * CPEPS, float (Bohr units in crystals), minimum distance between
149
          critical points for them to be equivalent
150
        * NUCEPS, same as CPEPS but specifically for nucleus critical
151
          points (critic2 default is dependent on grid dimensions)
152
        * NUCEPSH, same as NUCEPS but specifically for hydrogen nuclei
153
          since associated charge density can be significantly displaced
154
          from hydrogen nucleus
155
        * EPSDEGEN, float (field units), discard critical point if any
156
          element of the diagonal of the Hessian is below this value,
157
          useful for discarding points in vacuum regions
158
        * DISCARD, float (field units), discard critical points with field
159
          value below this value, useful for discarding points in vacuum
160
          regions
161
        * SEED, list of strings, strategies for seeding points, default
162
          is ['WS 1', 'PAIR 10'] which seeds critical points by
163
          sub-dividing the Wigner-Seitz cell and between every atom pair
164
          closer than 10 Bohr, see critic2 manual for more options
165

166
        :param structure: Structure to analyze
167
        :param chgcar: Charge density to use for analysis. If None, will
168
            use promolecular density. Should be a Chgcar object or path (string).
169
        :param chgcar_ref: Reference charge density. If None, will use
170
            chgcar as reference. Should be a Chgcar object or path (string).
171
        :param user_input_settings (dict): as explained above
172
        :param write_cml (bool): Useful for debug, if True will write all
173
            critical points to a file 'table.cml' in the working directory
174
            useful for visualization
175
        :param write_json (bool): Whether to write out critical points
176
        and YT json. YT integration will be performed with this setting.
177
        :param zpsp (dict): Dict of element/symbol name to number of electrons
178
        (ZVAL in VASP pseudopotential), with which to properly augment core regions
179
        and calculate charge transfer. Optional.
180
        """
181
        settings = {"CPEPS": 0.1, "SEED": ["WS", "PAIR DIST 10"]}
×
182
        if user_input_settings:
×
183
            settings.update(user_input_settings)
×
184

185
        # Load crystal structure
186
        input_script = ["crystal POSCAR"]
×
187

188
        # Load data to use as reference field
189
        if chgcar_ref:
×
190
            input_script += ["load ref.CHGCAR id chg_ref", "reference chg_ref"]
×
191

192
        # Load data to use for analysis
193
        if chgcar:
×
194
            input_script += ["load int.CHGCAR id chg_int", "integrable chg_int"]
×
195
            if zpsp:
×
196
                zpsp_str = " zpsp " + " ".join(f"{symbol} {zval}" for symbol, zval in zpsp.items())
×
197
                input_script[-2] += zpsp_str
×
198

199
        # Command to run automatic analysis
200
        auto = "auto "
×
201
        for k, v in settings.items():
×
202
            if isinstance(v, list):
×
203
                for item in v:
×
204
                    auto += f"{k} {item} "
×
205
            else:
206
                auto += f"{k} {v} "
×
207
        input_script += [auto]
×
208

209
        if write_cml:
×
210
            input_script += ["cpreport ../table.cml cell border graph"]
×
211

212
        if write_json:
×
213
            input_script += ["cpreport cpreport.json"]
×
214

215
        if write_json and chgcar:
×
216
            # requires gridded data to work
217
            input_script += ["yt"]
×
218
            input_script += ["yt JSON yt.json"]
×
219

220
        input_script = "\n".join(input_script)
×
221

222
        with ScratchDir(".") as temp_dir:
×
223
            os.chdir(temp_dir)
×
224

225
            structure.to(filename="POSCAR")
×
226

227
            if chgcar and isinstance(chgcar, VolumetricData):
×
228
                chgcar.write_file("int.CHGCAR")
×
229
            elif chgcar:
×
230
                os.symlink(chgcar, "int.CHGCAR")
×
231

232
            if chgcar_ref and isinstance(chgcar_ref, VolumetricData):
×
233
                chgcar_ref.write_file("ref.CHGCAR")
×
234
            elif chgcar_ref:
×
235
                os.symlink(chgcar_ref, "ref.CHGCAR")
×
236

237
            caller = cls(input_script)
×
238

239
            caller.output = Critic2Analysis(
×
240
                structure,
241
                stdout=caller._stdout,
242
                stderr=caller._stderr,
243
                cpreport=caller._cpreport,
244
                yt=caller._yt,
245
                zpsp=zpsp,
246
            )
247

248
            return caller
×
249

250
    @classmethod
1✔
251
    def from_path(cls, path, suffix="", zpsp=None):
1✔
252
        """
253
        Convenience method to run critic2 analysis on a folder containing
254
        typical VASP output files.
255
        This method will:
256

257
        1. Look for files CHGCAR, AECAR0, AECAR2, POTCAR or their gzipped
258
        counterparts.
259

260
        2. If AECCAR* files are present, constructs a temporary reference
261
        file as AECCAR0 + AECCAR2.
262

263
        3. Runs critic2 analysis twice: once for charge, and a second time
264
        for the charge difference (magnetization density).
265

266
        :param path: path to folder to search in
267
        :param suffix: specific suffix to look for (e.g. '.relax1' for
268
            'CHGCAR.relax1.gz')
269
        :param zpsp: manually specify ZPSP if POTCAR not present
270
        :return:
271
        """
272
        chgcar_path = get_filepath("CHGCAR", "Could not find CHGCAR!", path, suffix)
×
273
        chgcar = Chgcar.from_file(chgcar_path)
×
274
        chgcar_ref = None
×
275

276
        if not zpsp:
×
277
            potcar_path = get_filepath(
×
278
                "POTCAR",
279
                "Could not find POTCAR, will not be able to calculate charge transfer.",
280
                path,
281
                suffix,
282
            )
283

284
            if potcar_path:
×
285
                potcar = Potcar.from_file(potcar_path)
×
286
                zpsp = {p.element: p.zval for p in potcar}
×
287

288
        if not zpsp:
×
289
            # try and get reference "all-electron-like" charge density if zpsp not present
290
            aeccar0_path = get_filepath(
×
291
                "AECCAR0",
292
                "Could not find AECCAR0, interpret Bader results with caution.",
293
                path,
294
                suffix,
295
            )
296
            aeccar0 = Chgcar.from_file(aeccar0_path) if aeccar0_path else None
×
297

298
            aeccar2_path = get_filepath(
×
299
                "AECCAR2",
300
                "Could not find AECCAR2, interpret Bader results with caution.",
301
                path,
302
                suffix,
303
            )
304
            aeccar2 = Chgcar.from_file(aeccar2_path) if aeccar2_path else None
×
305

306
            chgcar_ref = aeccar0.linear_add(aeccar2) if (aeccar0 and aeccar2) else None
×
307

308
        return cls.from_chgcar(chgcar.structure, chgcar, chgcar_ref, zpsp=zpsp)
×
309

310

311
class CriticalPointType(Enum):
1✔
312
    """
313
    Enum type for the different varieties of critical point.
314
    """
315

316
    nucleus = "nucleus"  # (3, -3)
1✔
317
    bond = "bond"  # (3, -1)
1✔
318
    ring = "ring"  # (3, 1)
1✔
319
    cage = "cage"  # (3, 3)
1✔
320
    nnattr = "nnattr"  # (3, -3), non-nuclear attractor
1✔
321

322

323
def get_filepath(filename, warning, path, suffix):
1✔
324
    """
325
    Args:
326
        filename: Filename
327
        warning: Warning message
328
        path: Path to search
329
        suffix: Suffixes to search.
330
    """
331
    paths = glob.glob(os.path.join(path, filename + suffix + "*"))
×
332
    if not paths:
×
333
        warnings.warn(warning)
×
334
        return None
×
335
    if len(paths) > 1:
×
336
        # using reverse=True because, if multiple files are present,
337
        # they likely have suffixes 'static', 'relax', 'relax2', etc.
338
        # and this would give 'static' over 'relax2' over 'relax'
339
        # however, better to use 'suffix' kwarg to avoid this!
340
        paths.sort(reverse=True)
×
341
        warnings.warn(f"Multiple files detected, using {os.path.basename(path)}")
×
342
    path = paths[0]
×
343
    return path
×
344

345

346
class CriticalPoint(MSONable):
1✔
347
    """
348
    Access information about a critical point and the field values at that point.
349
    """
350

351
    def __init__(
1✔
352
        self,
353
        index,
354
        type,
355
        frac_coords,
356
        point_group,
357
        multiplicity,
358
        field,
359
        field_gradient,
360
        coords=None,
361
        field_hessian=None,
362
    ):
363
        """
364
        Class to characterise a critical point from a topological
365
        analysis of electron charge density.
366

367
        Note this class is usually associated with a Structure, so
368
        has information on multiplicity/point group symmetry.
369

370
        :param index: index of point
371
        :param type: type of point, given as a string
372
        :param coords: Cartesian coordinates in Angstroms
373
        :param frac_coords: fractional coordinates
374
        :param point_group: point group associated with critical point
375
        :param multiplicity: number of equivalent critical points
376
        :param field: value of field at point (f)
377
        :param field_gradient: gradient of field at point (grad f)
378
        :param field_hessian: hessian of field at point (del^2 f)
379
        """
380
        self.index = index
1✔
381
        self._type = type
1✔
382
        self.coords = coords
1✔
383
        self.frac_coords = frac_coords
1✔
384
        self.point_group = point_group
1✔
385
        self.multiplicity = multiplicity
1✔
386
        self.field = field
1✔
387
        self.field_gradient = field_gradient
1✔
388
        self.field_hessian = field_hessian
1✔
389

390
    @property
1✔
391
    def type(self):
1✔
392
        """
393
        Returns: Instance of CriticalPointType
394
        """
395
        return CriticalPointType(self._type)
1✔
396

397
    def __str__(self):
1✔
398
        return f"Critical Point: {self.type.name} ({self.frac_coords})"
×
399

400
    @property
1✔
401
    def laplacian(self):
1✔
402
        """
403
        Returns: The Laplacian of the field at the critical point
404
        """
405
        return np.trace(self.field_hessian)
1✔
406

407
    @property
1✔
408
    def ellipticity(self):
1✔
409
        """
410
        Most meaningful for bond critical points,
411
        can be physically interpreted as e.g. degree
412
        of pi-bonding in organic molecules. Consult
413
        literature for more information.
414
        Returns: The ellpiticity of the field at the critical point
415
        """
416
        eig, _ = np.linalg.eig(self.field_hessian)
1✔
417
        eig.sort()
1✔
418
        return eig[0] / eig[1] - 1
1✔
419

420

421
class Critic2Analysis(MSONable):
1✔
422
    """
423
    Class to process the standard output from critic2 into pymatgen-compatible objects.
424
    """
425

426
    def __init__(self, structure: Structure, stdout=None, stderr=None, cpreport=None, yt=None, zpsp=None):
1✔
427
        """
428
        This class is used to store results from the Critic2Caller.
429

430
        To explore the bond graph, use the "structure_graph"
431
        method, which returns a user-friendly StructureGraph
432
        class with bonding information. By default, this returns
433
        a StructureGraph with edge weights as bond lengths, but
434
        can optionally return a graph with edge weights as any
435
        property supported by the `CriticalPoint` class, such as
436
        bond ellipticity.
437

438
        This class also provides an interface to explore just the
439
        non-symmetrically-equivalent critical points via the
440
        `critical_points` attribute, and also all critical
441
        points (via nodes dict) and connections between them
442
        (via edges dict). The user should be familiar with critic2
443
        before trying to understand these.
444

445
        Indexes of nucleus critical points in the nodes dict are the
446
        same as the corresponding sites in structure, with indices of
447
        other critical points arbitrarily assigned.
448

449
        Only one of (stdout, cpreport) required, with cpreport preferred
450
        since this is a new, native JSON output from critic2.
451

452
        :param structure: associated Structure
453
        :param stdout: stdout from running critic2 in automatic
454
            mode
455
        :param stderr: stderr from running critic2 in automatic
456
            mode
457
        :param cpreport: json output from CPREPORT command
458
        :param yt: json output from YT command
459
        :param zpsp (dict): Dict of element/symbol name to number of electrons
460
        (ZVAL in VASP pseudopotential), with which to calculate charge transfer.
461
        Optional.
462
        """
463
        self.structure = structure
1✔
464

465
        self._stdout = stdout
1✔
466
        self._stderr = stderr
1✔
467
        self._cpreport = cpreport
1✔
468
        self._yt = yt
1✔
469
        self._zpsp = zpsp
1✔
470

471
        self.nodes: dict[int, dict] = {}
1✔
472
        self.edges: dict[int, dict] = {}
1✔
473

474
        if yt:
1✔
475
            self.structure = self._annotate_structure_with_yt(yt, structure, zpsp)
×
476

477
        if cpreport:
1✔
478
            self._parse_cpreport(cpreport)
×
479
        elif stdout:
1✔
480
            self._parse_stdout(stdout)
1✔
481
        else:
482
            raise ValueError("One of cpreport or stdout required.")
×
483

484
        self._remap_indices()
1✔
485

486
    def structure_graph(self, include_critical_points=("bond", "ring", "cage")):
1✔
487
        """
488
        A StructureGraph object describing bonding information
489
        in the crystal.
490
        Args:
491
            include_critical_points: add DummySpecies for
492
            the critical points themselves, a list of
493
            "nucleus", "bond", "ring", "cage", set to None
494
            to disable
495

496
        Returns: a StructureGraph
497
        """
498
        structure = self.structure.copy()
1✔
499

500
        point_idx_to_struct_idx = {}
1✔
501
        if include_critical_points:
1✔
502
            # atoms themselves don't have field information
503
            # so set to 0
504
            for prop in ("ellipticity", "laplacian", "field"):
1✔
505
                structure.add_site_property(prop, [0] * len(structure))
1✔
506
            for idx, node in self.nodes.items():
1✔
507
                cp = self.critical_points[node["unique_idx"]]
1✔
508
                if cp.type.value in include_critical_points:
1✔
509
                    specie = DummySpecies(f"X{cp.type.value[0]}cp", oxidation_state=None)
1✔
510
                    structure.append(
1✔
511
                        specie,
512
                        node["frac_coords"],
513
                        properties={
514
                            "ellipticity": cp.ellipticity,
515
                            "laplacian": cp.laplacian,
516
                            "field": cp.field,
517
                        },
518
                    )
519
                    point_idx_to_struct_idx[idx] = len(structure) - 1
1✔
520

521
        edge_weight = "bond_length"
1✔
522
        edge_weight_units = "Ã…"
1✔
523

524
        sg = StructureGraph.with_empty_graph(
1✔
525
            structure,
526
            name="bonds",
527
            edge_weight_name=edge_weight,
528
            edge_weight_units=edge_weight_units,
529
        )
530

531
        edges = self.edges.copy()
1✔
532
        idx_to_delete = []
1✔
533
        # check for duplicate bonds
534
        for idx, edge in edges.items():
1✔
535
            unique_idx = self.nodes[idx]["unique_idx"]
1✔
536
            # only check edges representing bonds, not rings
537
            if self.critical_points[unique_idx].type == CriticalPointType.bond:
1✔
538
                if idx not in idx_to_delete:
1✔
539
                    for idx2, edge2 in edges.items():
1✔
540
                        if idx != idx2 and edge == edge2:
1✔
541
                            idx_to_delete.append(idx2)
×
542
                            warnings.warn(
×
543
                                "Duplicate edge detected, try re-running "
544
                                "critic2 with custom parameters to fix this. "
545
                                "Mostly harmless unless user is also "
546
                                "interested in rings/cages."
547
                            )
548
                            logger.debug(
×
549
                                f"Duplicate edge between points {idx} (unique point {self.nodes[idx]['unique_idx']})"
550
                                f"and {idx2} ({self.nodes[idx2]['unique_idx']})."
551
                            )
552
        # and remove any duplicate bonds present
553
        for idx in idx_to_delete:
1✔
554
            del edges[idx]
×
555

556
        for idx, edge in edges.items():
1✔
557
            unique_idx = self.nodes[idx]["unique_idx"]
1✔
558
            # only add edges representing bonds, not rings
559
            if self.critical_points[unique_idx].type == CriticalPointType.bond:
1✔
560
                from_idx = edge["from_idx"]
1✔
561
                to_idx = edge["to_idx"]
1✔
562

563
                # have to also check bond is between nuclei if non-nuclear
564
                # attractors not in structure
565
                skip_bond = False
1✔
566
                if include_critical_points and "nnattr" not in include_critical_points:
1✔
567
                    from_type = self.critical_points[self.nodes[from_idx]["unique_idx"]].type
1✔
568
                    to_type = self.critical_points[self.nodes[from_idx]["unique_idx"]].type
1✔
569
                    skip_bond = (from_type != CriticalPointType.nucleus) or (to_type != CriticalPointType.nucleus)
1✔
570

571
                if not skip_bond:
1✔
572
                    from_lvec = edge["from_lvec"]
1✔
573
                    to_lvec = edge["to_lvec"]
1✔
574

575
                    relative_lvec = np.subtract(to_lvec, from_lvec)
1✔
576

577
                    # for edge case of including nnattrs in bonding graph when other critical
578
                    # points also included, indices may get mixed
579
                    struct_from_idx = point_idx_to_struct_idx.get(from_idx, from_idx)
1✔
580
                    struct_to_idx = point_idx_to_struct_idx.get(to_idx, to_idx)
1✔
581

582
                    weight = self.structure.get_distance(struct_from_idx, struct_to_idx, jimage=relative_lvec)
1✔
583

584
                    crit_point = self.critical_points[unique_idx]
1✔
585

586
                    edge_properties = {
1✔
587
                        "field": crit_point.field,
588
                        "laplacian": crit_point.laplacian,
589
                        "ellipticity": crit_point.ellipticity,
590
                        "frac_coords": self.nodes[idx]["frac_coords"],
591
                    }
592

593
                    sg.add_edge(
1✔
594
                        struct_from_idx,
595
                        struct_to_idx,
596
                        from_jimage=from_lvec,
597
                        to_jimage=to_lvec,
598
                        weight=weight,
599
                        edge_properties=edge_properties,
600
                    )
601

602
        return sg
1✔
603

604
    def get_critical_point_for_site(self, n: int):
1✔
605
        """
606
        Args:
607
            n (int): Site index
608

609
        Returns: A CriticalPoint instance
610
        """
611
        return self.critical_points[self.nodes[n]["unique_idx"]]
×
612

613
    def get_volume_and_charge_for_site(self, n):
1✔
614
        """
615
        Args:
616
            n: Site index n
617

618
        Returns: A dict containing "volume" and "charge" keys,
619
        or None if YT integration not performed
620
        """
621
        # pylint: disable=E1101
622
        if not self._node_values:
×
623
            return None
×
624
        return self._node_values[n]
×
625

626
    def _parse_cpreport(self, cpreport):
1✔
627
        def get_type(signature: int, is_nucleus: bool):
×
628
            if signature == 3:
×
629
                return "cage"
×
630
            if signature == 1:
×
631
                return "ring"
×
632
            if signature == -1:
×
633
                return "bond"
×
634
            if signature == -3:
×
635
                if is_nucleus:
×
636
                    return "nucleus"
×
637
                return "nnattr"
×
638
            return None
×
639

640
        bohr_to_angstrom = 0.529177
×
641

642
        self.critical_points = [
×
643
            CriticalPoint(
644
                p["id"] - 1,
645
                get_type(p["signature"], p["is_nucleus"]),
646
                p["fractional_coordinates"],
647
                p["point_group"],
648
                p["multiplicity"],
649
                p["field"],
650
                p["gradient"],
651
                coords=[x * bohr_to_angstrom for x in p["cartesian_coordinates"]]
652
                if cpreport["units"] == "bohr"
653
                else None,
654
                field_hessian=p["hessian"],
655
            )
656
            for p in cpreport["critical_points"]["nonequivalent_cps"]
657
        ]
658

659
        for point in cpreport["critical_points"]["cell_cps"]:
×
660
            self._add_node(
×
661
                idx=point["id"] - 1,
662
                unique_idx=point["nonequivalent_id"] - 1,
663
                frac_coords=point["fractional_coordinates"],
664
            )
665
            if "attractors" in point:
×
666
                self._add_edge(
×
667
                    idx=point["id"] - 1,
668
                    from_idx=int(point["attractors"][0]["cell_id"]) - 1,
669
                    from_lvec=point["attractors"][0]["lvec"],
670
                    to_idx=int(point["attractors"][1]["cell_id"]) - 1,
671
                    to_lvec=point["attractors"][1]["lvec"],
672
                )
673

674
    def _remap_indices(self):
1✔
675
        """
676
        Re-maps indices on self.nodes and self.edges such that node indices match
677
        that of structure, and then sorts self.nodes by index.
678
        """
679
        # Order of nuclei provided by critic2 doesn't
680
        # necessarily match order of sites in Structure.
681
        # This is because critic2 performs a symmetrization step.
682
        # We perform a mapping from one to the other,
683
        # and re-index all nodes accordingly.
684
        node_mapping = {}  # critic2_index:structure_index
1✔
685
        # ensure frac coords are in [0,1] range
686
        frac_coords = np.array(self.structure.frac_coords) % 1
1✔
687
        kd = KDTree(frac_coords)
1✔
688

689
        node_mapping = {}
1✔
690
        for idx, node in self.nodes.items():
1✔
691
            if self.critical_points[node["unique_idx"]].type == CriticalPointType.nucleus:
1✔
692
                node_mapping[idx] = kd.query(node["frac_coords"])[1]
1✔
693

694
        if len(node_mapping) != len(self.structure):
1✔
695
            warnings.warn(
×
696
                f"Check that all sites in input structure ({len(self.structure)}) have "
697
                f"been detected by critic2 ({ len(node_mapping)})."
698
            )
699

700
        self.nodes = {node_mapping.get(idx, idx): node for idx, node in self.nodes.items()}
1✔
701

702
        for edge in self.edges.values():
1✔
703
            edge["from_idx"] = node_mapping.get(edge["from_idx"], edge["from_idx"])
1✔
704
            edge["to_idx"] = node_mapping.get(edge["to_idx"], edge["to_idx"])
1✔
705

706
    @staticmethod
1✔
707
    def _annotate_structure_with_yt(yt, structure: Structure, zpsp):
1✔
708
        volume_idx = None
×
709
        charge_idx = None
×
710

711
        for prop in yt["integration"]["properties"]:
×
712
            if prop["label"] == "Volume":
×
713
                volume_idx = prop["id"] - 1  # 1-indexed, change to 0
×
714
            elif prop["label"] == "$chg_int":
×
715
                charge_idx = prop["id"] - 1
×
716

717
        def get_volume_and_charge(nonequiv_idx):
×
718
            attractor = yt["integration"]["attractors"][nonequiv_idx - 1]
×
719
            if attractor["id"] != nonequiv_idx:
×
720
                raise ValueError(f"List of attractors may be un-ordered (wanted id={nonequiv_idx}): {attractor}")
×
721
            return (
×
722
                attractor["integrals"][volume_idx],
723
                attractor["integrals"][charge_idx],
724
            )
725

726
        volumes = []
×
727
        charges = []
×
728
        charge_transfer = []
×
729

730
        for idx, site in enumerate(yt["structure"]["cell_atoms"]):
×
731
            if not np.allclose(structure[idx].frac_coords, site["fractional_coordinates"]):
×
732
                raise IndexError(
×
733
                    f"Site in structure doesn't seem to match site in YT integration:\n{structure[idx]}\n{site}"
734
                )
735
            volume, charge = get_volume_and_charge(site["nonequivalent_id"])
×
736
            volumes.append(volume)
×
737
            charges.append(charge)
×
738
            if zpsp:
×
739
                if structure[idx].species_string in zpsp:
×
740
                    charge_transfer.append(charge - zpsp[structure[idx].species_string])
×
741
                else:
742
                    raise ValueError(
×
743
                        f"ZPSP argument does not seem compatible with species in structure "
744
                        f"({structure[idx].species_string}): {zpsp}"
745
                    )
746

747
        structure = structure.copy()
×
748
        structure.add_site_property("bader_volume", volumes)
×
749
        structure.add_site_property("bader_charge", charges)
×
750

751
        if zpsp:
×
752
            if len(charge_transfer) != len(charges):
×
753
                warnings.warn(f"Something went wrong calculating charge transfer: {charge_transfer}")
×
754
            else:
755
                structure.add_site_property("bader_charge_transfer", charge_transfer)
×
756

757
        return structure
×
758

759
    def _parse_stdout(self, stdout):
1✔
760
        warnings.warn(
1✔
761
            "Parsing critic2 standard output is deprecated and will not be maintained, "
762
            "please use the native JSON output in future."
763
        )
764

765
        stdout = stdout.split("\n")
1✔
766

767
        # NOTE WE ARE USING 0-BASED INDEXING:
768
        # This is different from critic2 which
769
        # uses 1-based indexing, so all parsed
770
        # indices have 1 subtracted.
771

772
        # Parsing happens in two stages:
773

774
        # 1. We construct a list of unique critical points
775
        #    (i.e. non-equivalent by the symmetry of the crystal)
776
        #   and the properties of the field at those points
777

778
        # 2. We construct a list of nodes and edges describing
779
        #    all critical points in the crystal
780

781
        # Steps 1. and 2. are essentially independent, except
782
        # that the critical points in 2. have a pointer to their
783
        # associated unique critical point in 1. so that more
784
        # information on that point can be retrieved if necessary.
785

786
        unique_critical_points = []
1✔
787

788
        # parse unique critical points
789
        for i, line in enumerate(stdout):
1✔
790
            if "mult  name            f             |grad|           lap" in line:
1✔
791
                start_i = i + 1
1✔
792
            elif "* Analysis of system bonds" in line:
1✔
793
                end_i = i - 2
1✔
794
        # if start_i and end_i haven't been found, we
795
        # need to re-evaluate assumptions in this parser!
796

797
        for i, line in enumerate(stdout):
1✔
798
            if start_i <= i <= end_i:
1✔
799
                l = line.replace("(", "").replace(")", "").split()
1✔
800

801
                unique_idx = int(l[0]) - 1
1✔
802
                point_group = l[1]
1✔
803
                # type = l[2]  # type from definition of critical point e.g. (3, -3)
804
                critical_point_type = l[3]  # type from name, e.g. nucleus
1✔
805
                frac_coords = [float(l[4]), float(l[5]), float(l[6])]
1✔
806
                multiplicity = float(l[7])
1✔
807
                # name = float(l[8])
808
                field = float(l[9])
1✔
809
                field_gradient = float(l[10])
1✔
810
                # laplacian = float(l[11])
811

812
                point = CriticalPoint(
1✔
813
                    unique_idx,
814
                    critical_point_type,
815
                    frac_coords,
816
                    point_group,
817
                    multiplicity,
818
                    field,
819
                    field_gradient,
820
                )
821
                unique_critical_points.append(point)
1✔
822

823
        for i, line in enumerate(stdout):
1✔
824
            if "+ Critical point no." in line:
1✔
825
                unique_idx = int(line.split()[4]) - 1
1✔
826
            elif "Hessian:" in line:
1✔
827
                l1 = list(map(float, stdout[i + 1].split()))
1✔
828
                l2 = list(map(float, stdout[i + 2].split()))
1✔
829
                l3 = list(map(float, stdout[i + 3].split()))
1✔
830
                hessian = [
1✔
831
                    [l1[0], l1[1], l1[2]],
832
                    [l2[0], l2[1], l2[2]],
833
                    [l3[0], l3[1], l3[2]],
834
                ]
835
                unique_critical_points[unique_idx].field_hessian = hessian
1✔
836

837
        self.critical_points = unique_critical_points
1✔
838

839
        # parse graph connecting critical points
840
        for i, line in enumerate(stdout):
1✔
841
            if "#cp  ncp   typ        position " in line:
1✔
842
                start_i = i + 1
1✔
843
            elif "* Attractor connectivity matrix" in line:
1✔
844
                end_i = i - 2
1✔
845
        # if start_i and end_i haven't been found, we
846
        # need to re-evaluate assumptions in this parser!
847

848
        for i, line in enumerate(stdout):
1✔
849
            if start_i <= i <= end_i:
1✔
850
                l = line.replace("(", "").replace(")", "").split()
1✔
851

852
                idx = int(l[0]) - 1
1✔
853
                unique_idx = int(l[1]) - 1
1✔
854
                frac_coords = [float(l[3]), float(l[4]), float(l[5])]
1✔
855

856
                self._add_node(idx, unique_idx, frac_coords)
1✔
857
                if len(l) > 6:
1✔
858
                    from_idx = int(l[6]) - 1
1✔
859
                    to_idx = int(l[10]) - 1
1✔
860
                    self._add_edge(
1✔
861
                        idx,
862
                        from_idx=from_idx,
863
                        from_lvec=(int(l[7]), int(l[8]), int(l[9])),
864
                        to_idx=to_idx,
865
                        to_lvec=(int(l[11]), int(l[12]), int(l[13])),
866
                    )
867

868
    def _add_node(self, idx, unique_idx, frac_coords):
1✔
869
        """
870
        Add information about a node describing a critical point.
871

872
        :param idx: index
873
        :param unique_idx: index of unique CriticalPoint,
874
            used to look up more information of point (field etc.)
875
        :param frac_coord: fractional coordinates of point
876
        :return:
877
        """
878
        self.nodes[idx] = {"unique_idx": unique_idx, "frac_coords": frac_coords}
1✔
879

880
    def _add_edge(self, idx, from_idx, from_lvec, to_idx, to_lvec):
1✔
881
        """
882
        Add information about an edge linking two critical points.
883

884
        This actually describes two edges:
885

886
        from_idx ------ idx ------ to_idx
887

888
        However, in practice, from_idx and to_idx will typically be
889
        atom nuclei, with the center node (idx) referring to a bond
890
        critical point. Thus, it will be more convenient to model
891
        this as a single edge linking nuclei with the properties
892
        of the bond critical point stored as an edge attribute.
893

894
        :param idx: index of node
895
        :param from_idx: from index of node
896
        :param from_lvec: vector of lattice image the from node is in
897
            as tuple of ints
898
        :param to_idx: to index of node
899
        :param to_lvec:  vector of lattice image the to node is in as
900
            tuple of ints
901
        :return:
902
        """
903
        self.edges[idx] = {
1✔
904
            "from_idx": from_idx,
905
            "from_lvec": from_lvec,
906
            "to_idx": to_idx,
907
            "to_lvec": to_lvec,
908
        }
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