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

materialsproject / pymatgen / 4075885785

pending completion
4075885785

push

github

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

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

81013 of 102710 relevant lines covered (78.88%)

0.79 hits per line

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

90.18
/pymatgen/analysis/pourbaix_diagram.py
1
# Copyright (c) Pymatgen Development Team.
2
# Distributed under the terms of the MIT License.
3
"""
1✔
4
This module is intended to be used to compute Pourbaix diagrams
5
of arbitrary compositions and formation energies. If you use
6
this module in your work, please consider citing the following:
7

8
General formalism for solid-aqueous equilibria from DFT:
9
    Persson et al., DOI: 10.1103/PhysRevB.85.235438
10
Decomposition maps, or Pourbaix hull diagrams
11
    Singh et al., DOI: 10.1021/acs.chemmater.7b03980
12
Fast computation of many-element Pourbaix diagrams:
13
    Patel et al., https://arxiv.org/abs/1909.00035 (submitted)
14
"""
15

16
from __future__ import annotations
1✔
17

18
import itertools
1✔
19
import logging
1✔
20
import re
1✔
21
import warnings
1✔
22
from copy import deepcopy
1✔
23
from functools import cmp_to_key, lru_cache, partial
1✔
24
from multiprocessing import Pool
1✔
25

26
import numpy as np
1✔
27
from monty.json import MontyDecoder, MSONable
1✔
28
from scipy.spatial import ConvexHull, HalfspaceIntersection
1✔
29

30
try:
1✔
31
    from scipy.special import comb
1✔
32
except ImportError:
×
33
    from scipy.misc import comb
×
34

35
from pymatgen.analysis.phase_diagram import PDEntry, PhaseDiagram
1✔
36
from pymatgen.analysis.reaction_calculator import Reaction, ReactionError
1✔
37
from pymatgen.core.composition import Composition
1✔
38
from pymatgen.core.ion import Ion
1✔
39
from pymatgen.core.periodic_table import Element
1✔
40
from pymatgen.entries.compatibility import MU_H2O
1✔
41
from pymatgen.entries.computed_entries import ComputedEntry
1✔
42
from pymatgen.util.coord import Simplex
1✔
43
from pymatgen.util.plotting import pretty_plot
1✔
44
from pymatgen.util.string import Stringify
1✔
45

46
__author__ = "Sai Jayaraman"
1✔
47
__copyright__ = "Copyright 2012, The Materials Project"
1✔
48
__version__ = "0.4"
1✔
49
__maintainer__ = "Joseph Montoya"
1✔
50
__credits__ = "Arunima Singh, Joseph Montoya, Anjli Patel"
1✔
51
__email__ = "joseph.montoya@tri.global"
1✔
52
__status__ = "Production"
1✔
53
__date__ = "Nov 1, 2012"
1✔
54

55
logger = logging.getLogger(__name__)
1✔
56

57
PREFAC = 0.0591
1✔
58

59

60
# TODO: Revise to more closely reflect PDEntry, invoke from energy/composition
61
# TODO: PourbaixEntries depend implicitly on having entry energies be
62
#       formation energies, should be a better way to get from raw energies
63
# TODO: uncorrected_energy is a bit of a misnomer, but not sure what to rename
64
class PourbaixEntry(MSONable, Stringify):
1✔
65
    """
66
    An object encompassing all data relevant to a solid or ion
67
    in a Pourbaix diagram. Each bulk solid/ion has an energy
68
    g of the form: e = e0 + 0.0591 log10(conc) - nO mu_H2O
69
    + (nH - 2nO) pH + phi (-nH + 2nO + q)
70

71
    Note that the energies corresponding to the input entries
72
    should be formation energies with respect to hydrogen and
73
    oxygen gas in order for the Pourbaix diagram formalism to
74
    work. This may be changed to be more flexible in the future.
75
    """
76

77
    def __init__(self, entry, entry_id=None, concentration=1e-6):
1✔
78
        """
79
        Args:
80
            entry (ComputedEntry/ComputedStructureEntry/PDEntry/IonEntry): An
81
                entry object
82
            entry_id ():
83
            concentration ():
84
        """
85
        self.entry = entry
1✔
86
        if isinstance(entry, IonEntry):
1✔
87
            self.concentration = concentration
1✔
88
            self.phase_type = "Ion"
1✔
89
            self.charge = entry.ion.charge
1✔
90
        else:
91
            self.concentration = 1.0
1✔
92
            self.phase_type = "Solid"
1✔
93
            self.charge = 0.0
1✔
94
        self.uncorrected_energy = entry.energy
1✔
95
        if entry_id is not None:
1✔
96
            self.entry_id = entry_id
1✔
97
        elif hasattr(entry, "entry_id") and entry.entry_id:
1✔
98
            self.entry_id = entry.entry_id
1✔
99
        else:
100
            self.entry_id = None
1✔
101

102
    @property
1✔
103
    def npH(self):
1✔
104
        """
105
        Returns:
106
        """
107
        return self.entry.composition.get("H", 0.0) - 2 * self.entry.composition.get("O", 0.0)
1✔
108

109
    @property
1✔
110
    def nH2O(self):
1✔
111
        """
112
        Returns: Number of H2O.
113
        """
114
        return self.entry.composition.get("O", 0.0)
1✔
115

116
    @property
1✔
117
    def nPhi(self):
1✔
118
        """
119
        Returns: Number of H2O.
120
        """
121
        return self.npH - self.charge
1✔
122

123
    @property
1✔
124
    def name(self):
1✔
125
        """
126
        Returns: Name for entry
127
        """
128
        if self.phase_type == "Solid":
1✔
129
            return self.entry.composition.reduced_formula + "(s)"
1✔
130

131
        return self.entry.name
1✔
132

133
    @property
1✔
134
    def energy(self):
1✔
135
        """
136
        Returns (float): total energy of the Pourbaix
137
            entry (at pH, V = 0 vs. SHE)
138
        """
139
        # Note: this implicitly depends on formation energies as input
140
        return self.uncorrected_energy + self.conc_term - (MU_H2O * self.nH2O)
1✔
141

142
    @property
1✔
143
    def energy_per_atom(self):
1✔
144
        """
145
        energy per atom of the Pourbaix entry
146

147
        Returns (float): energy per atom
148
        """
149
        return self.energy / self.composition.num_atoms
1✔
150

151
    def energy_at_conditions(self, pH, V):
1✔
152
        """
153
        Get free energy for a given pH and V
154

155
        Args:
156
            pH (float): pH at which to evaluate free energy
157
            V (float): voltage at which to evaluate free energy
158

159
        Returns:
160
            free energy at conditions
161
        """
162
        return self.energy + self.npH * PREFAC * pH + self.nPhi * V
1✔
163

164
    def get_element_fraction(self, element):
1✔
165
        """
166
        Gets the elemental fraction of a given non-OH element
167

168
        Args:
169
            element (Element or str): string or element corresponding
170
                to element to get from composition
171

172
        Returns:
173
            fraction of element / sum(all non-OH elements)
174
        """
175
        return self.composition.get(element) * self.normalization_factor
1✔
176

177
    @property
1✔
178
    def normalized_energy(self):
1✔
179
        """
180
        Returns:
181
             energy normalized by number of non H or O atoms, e. g.
182
             for Zn2O6, energy / 2 or for AgTe3(OH)3, energy / 4
183
        """
184
        return self.energy * self.normalization_factor
×
185

186
    def normalized_energy_at_conditions(self, pH, V):
1✔
187
        """
188
        Energy at an electrochemical condition, compatible with
189
        numpy arrays for pH/V input
190

191
        Args:
192
            pH (float): pH at condition
193
            V (float): applied potential at condition
194

195
        Returns:
196
            energy normalized by number of non-O/H atoms at condition
197
        """
198
        return self.energy_at_conditions(pH, V) * self.normalization_factor
1✔
199

200
    @property
1✔
201
    def conc_term(self):
1✔
202
        """
203
        Returns the concentration contribution to the free energy,
204
        and should only be present when there are ions in the entry
205
        """
206
        return PREFAC * np.log10(self.concentration)
1✔
207

208
    # TODO: not sure if these are strictly necessary with refactor
209
    def as_dict(self):
1✔
210
        """
211
        Returns dict which contains Pourbaix Entry data.
212
        Note that the pH, voltage, H2O factors are always calculated when
213
        constructing a PourbaixEntry object.
214
        """
215
        d = {"@module": type(self).__module__, "@class": type(self).__name__}
1✔
216
        if isinstance(self.entry, IonEntry):
1✔
217
            d["entry_type"] = "Ion"
1✔
218
        else:
219
            d["entry_type"] = "Solid"
1✔
220
        d["entry"] = self.entry.as_dict()
1✔
221
        d["concentration"] = self.concentration
1✔
222
        d["entry_id"] = self.entry_id
1✔
223
        return d
1✔
224

225
    @classmethod
1✔
226
    def from_dict(cls, d):
1✔
227
        """
228
        Invokes a PourbaixEntry from a dictionary
229
        """
230
        entry_type = d["entry_type"]
1✔
231
        if entry_type == "Ion":
1✔
232
            entry = IonEntry.from_dict(d["entry"])
1✔
233
        else:
234
            entry = MontyDecoder().process_decoded(d["entry"])
1✔
235
        entry_id = d["entry_id"]
1✔
236
        concentration = d["concentration"]
1✔
237
        return cls(entry, entry_id, concentration)
1✔
238

239
    @property
1✔
240
    def normalization_factor(self):
1✔
241
        """
242
        Sum of number of atoms minus the number of H and O in composition
243
        """
244
        return 1.0 / (self.num_atoms - self.composition.get("H", 0) - self.composition.get("O", 0))
1✔
245

246
    @property
1✔
247
    def composition(self):
1✔
248
        """
249
        Returns composition
250
        """
251
        return self.entry.composition
1✔
252

253
    @property
1✔
254
    def num_atoms(self):
1✔
255
        """
256
        Return number of atoms in current formula. Useful for normalization
257
        """
258
        return self.composition.num_atoms
1✔
259

260
    def to_pretty_string(self) -> str:
1✔
261
        """
262
        :return: A pretty string representation.
263
        """
264
        if self.phase_type == "Solid":
1✔
265
            return self.entry.composition.reduced_formula + "(s)"
1✔
266

267
        return self.entry.name
1✔
268

269
    def __repr__(self):
1✔
270
        return (
×
271
            f"Pourbaix Entry : {self.entry.composition} with energy = {self.energy:.4f}, npH = {self.npH}, "
272
            f"nPhi = {self.nPhi}, nH2O = {self.nH2O}, entry_id = {self.entry_id} "
273
        )
274

275
    def __str__(self):
1✔
276
        return self.__repr__()
×
277

278

279
class MultiEntry(PourbaixEntry):
1✔
280
    """
281
    PourbaixEntry-like object for constructing multi-elemental Pourbaix
282
    diagrams.
283
    """
284

285
    def __init__(self, entry_list, weights=None):
1✔
286
        """
287
        Initializes a MultiEntry.
288

289
        Args:
290
            entry_list ([PourbaixEntry]): List of component PourbaixEntries
291
            weights ([float]): Weights associated with each entry. Default is None
292
        """
293
        if weights is None:
1✔
294
            self.weights = [1.0] * len(entry_list)
1✔
295
        else:
296
            self.weights = weights
1✔
297
        self.entry_list = entry_list
1✔
298

299
    @lru_cache
1✔
300
    def __getattr__(self, item):
1✔
301
        """
302
        Because most of the attributes here are just weighted
303
        averages of the entry_list, we save some space by
304
        having a set of conditionals to define the attributes
305
        """
306
        # Attributes that are weighted averages of entry attributes
307
        if item in [
1✔
308
            "energy",
309
            "npH",
310
            "nH2O",
311
            "nPhi",
312
            "conc_term",
313
            "composition",
314
            "uncorrected_energy",
315
        ]:
316
            # TODO: Composition could be changed for compat with sum
317
            if item == "composition":
1✔
318
                start = Composition({})
1✔
319
            else:
320
                start = 0
1✔
321
            return sum(
1✔
322
                (getattr(e, item) * w for e, w in zip(self.entry_list, self.weights)),
323
                start,
324
            )
325

326
        # Attributes that are just lists of entry attributes
327
        if item in ["entry_id", "phase_type"]:
1✔
328
            return [getattr(e, item) for e in self.entry_list]
1✔
329

330
        # normalization_factor, num_atoms should work from superclass
331
        return self.__getattribute__(item)
1✔
332

333
    @property
1✔
334
    def name(self):
1✔
335
        """
336
        MultiEntry name, i. e. the name of each entry joined by ' + '
337
        """
338
        return " + ".join(e.name for e in self.entry_list)
×
339

340
    def __repr__(self):
1✔
341
        return (
×
342
            f"Multiple Pourbaix Entry: energy = {self.energy:.4f}, npH = {self.npH}, nPhi = {self.nPhi}, "
343
            f"nH2O = {self.nH2O}, entry_id = {self.entry_id}, species: {self.name}"
344
        )
345

346
    def __str__(self):
1✔
347
        return self.__repr__()
×
348

349
    def as_dict(self):
1✔
350
        """
351
        Returns: MSONable dict
352
        """
353
        return {
1✔
354
            "@module": type(self).__module__,
355
            "@class": type(self).__name__,
356
            "entry_list": [e.as_dict() for e in self.entry_list],
357
            "weights": self.weights,
358
        }
359

360
    @classmethod
1✔
361
    def from_dict(cls, d):
1✔
362
        """
363
        Args:
364
            d (): Dict representation
365

366
        Returns:
367
            MultiEntry
368
        """
369
        entry_list = [PourbaixEntry.from_dict(e) for e in d.get("entry_list")]
1✔
370
        return cls(entry_list, d.get("weights"))
1✔
371

372

373
# TODO: this class isn't particularly useful in its current form, could be
374
#       refactored to include information about the reference solid
375
class IonEntry(PDEntry):
1✔
376
    """
377
    Object similar to PDEntry, but contains an Ion object instead of a
378
    Composition object.
379

380
    .. attribute:: name
381

382
        A name for the entry. This is the string shown in the phase diagrams.
383
        By default, this is the reduced formula for the composition, but can be
384
        set to some other string for display purposes.
385
    """
386

387
    def __init__(self, ion, energy, name=None, attribute=None):
1✔
388
        """
389
        Args:
390
            ion: Ion object
391
            energy: Energy for composition.
392
            name: Optional parameter to name the entry. Defaults to the
393
                chemical formula.
394
        """
395
        self.ion = ion
1✔
396
        # Auto-assign name
397
        name = name or self.ion.reduced_formula
1✔
398
        super().__init__(composition=ion.composition, energy=energy, name=name, attribute=attribute)
1✔
399

400
    @classmethod
1✔
401
    def from_dict(cls, d):
1✔
402
        """
403
        Returns an IonEntry object from a dict.
404
        """
405
        return cls(Ion.from_dict(d["ion"]), d["energy"], d.get("name"), d.get("attribute"))
1✔
406

407
    def as_dict(self):
1✔
408
        """
409
        Creates a dict of composition, energy, and ion name
410
        """
411
        d = {"ion": self.ion.as_dict(), "energy": self.energy, "name": self.name}
1✔
412
        return d
1✔
413

414
    def __repr__(self):
1✔
415
        return f"IonEntry : {self.composition} with energy = {self.energy:.4f}"
×
416

417
    def __str__(self):
1✔
418
        return self.__repr__()
×
419

420

421
def ion_or_solid_comp_object(formula):
1✔
422
    """
423
    Returns either an ion object or composition object given
424
    a formula.
425

426
    Args:
427
        formula: String formula. Eg. of ion: NaOH(aq), Na[+];
428
            Eg. of solid: Fe2O3(s), Fe(s), Na2O
429

430
    Returns:
431
        Composition/Ion object
432
    """
433
    m = re.search(r"\[([^\[\]]+)\]|\(aq\)", formula)
×
434
    if m:
×
435
        comp_obj = Ion.from_formula(formula)
×
436
    elif re.search(r"\(s\)", formula):
×
437
        comp_obj = Composition(formula[:-3])
×
438
    else:
439
        comp_obj = Composition(formula)
×
440
    return comp_obj
×
441

442

443
ELEMENTS_HO = {Element("H"), Element("O")}
1✔
444

445

446
# TODO: the solids filter breaks some of the functionality of the
447
#       heatmap plotter, because the reference states for decomposition
448
#       don't include oxygen/hydrogen in the OER/HER regions
449

450

451
# TODO: create a from_phase_diagram class method for non-formation energy
452
#       invocation
453
# TODO: invocation from a MultiEntry entry list could be a bit more robust
454
# TODO: serialization is still a bit rough around the edges
455
class PourbaixDiagram(MSONable):
1✔
456
    """
457
    Class to create a Pourbaix diagram from entries
458
    """
459

460
    def __init__(
1✔
461
        self,
462
        entries: list[PourbaixEntry] | list[MultiEntry],
463
        comp_dict: dict[str, float] | None = None,
464
        conc_dict: dict[str, float] | None = None,
465
        filter_solids: bool = True,
466
        nproc: int | None = None,
467
    ):
468
        """
469
        Args:
470
            entries ([PourbaixEntry] or [MultiEntry]): Entries list
471
                containing Solids and Ions or a list of MultiEntries
472
            comp_dict (dict[str, float]): Dictionary of compositions,
473
                defaults to equal parts of each elements
474
            conc_dict (dict[str, float]): Dictionary of ion concentrations,
475
                defaults to 1e-6 for each element
476
            filter_solids (bool): applying this filter to a Pourbaix
477
                diagram ensures all included solid phases are filtered by
478
                stability on the compositional phase diagram. Defaults to True.
479
                The practical consequence of this is that highly oxidized or reduced
480
                phases that might show up in experiments due to kinetic limitations
481
                on oxygen/hydrogen evolution won't appear in the diagram, but they are
482
                not actually "stable" (and are frequently overstabilized from DFT errors).
483
                Hence, including only the stable solid phases generally leads to the
484
                most accurate Pourbaix diagrams.
485
            nproc (int): number of processes to generate multientries with
486
                in parallel. Defaults to None (serial processing)
487
        """
488
        entries = deepcopy(entries)
1✔
489
        self.filter_solids = filter_solids
1✔
490

491
        # Get non-OH elements
492
        self.pbx_elts = list(
1✔
493
            set(itertools.chain.from_iterable([entry.composition.elements for entry in entries])) - ELEMENTS_HO
494
        )
495
        self.dim = len(self.pbx_elts) - 1
1✔
496

497
        # Process multientry inputs
498
        if isinstance(entries[0], MultiEntry):
1✔
499
            self._processed_entries = entries
1✔
500
            # Extract individual entries
501
            single_entries = list(set(itertools.chain.from_iterable([e.entry_list for e in entries])))
1✔
502
            self._unprocessed_entries = single_entries
1✔
503
            self._filtered_entries = single_entries
1✔
504
            self._conc_dict = None
1✔
505
            self._elt_comp = {k: v for k, v in entries[0].composition.items() if k not in ELEMENTS_HO}
1✔
506
            self._multielement = True
1✔
507

508
        # Process single entry inputs
509
        else:
510
            # Set default conc/comp dicts
511
            if not comp_dict:
1✔
512
                comp_dict = {elt.symbol: 1.0 / len(self.pbx_elts) for elt in self.pbx_elts}
1✔
513
            if not conc_dict:
1✔
514
                conc_dict = {elt.symbol: 1e-6 for elt in self.pbx_elts}
1✔
515
            self._conc_dict = conc_dict
1✔
516

517
            self._elt_comp = comp_dict
1✔
518
            self.pourbaix_elements = self.pbx_elts
1✔
519

520
            solid_entries = [entry for entry in entries if entry.phase_type == "Solid"]
1✔
521
            ion_entries = [entry for entry in entries if entry.phase_type == "Ion"]
1✔
522

523
            # If a conc_dict is specified, override individual entry concentrations
524
            for entry in ion_entries:
1✔
525
                ion_elts = list(set(entry.composition.elements) - ELEMENTS_HO)
1✔
526
                # TODO: the logic here for ion concentration setting is in two
527
                #       places, in PourbaixEntry and here, should be consolidated
528
                if len(ion_elts) == 1:
1✔
529
                    entry.concentration = conc_dict[ion_elts[0].symbol] * entry.normalization_factor
1✔
530
                elif len(ion_elts) > 1 and not entry.concentration:
1✔
531
                    raise ValueError("Elemental concentration not compatible with multi-element ions")
×
532

533
            self._unprocessed_entries = solid_entries + ion_entries
1✔
534

535
            if not len(solid_entries + ion_entries) == len(entries):
1✔
536
                raise ValueError("All supplied entries must have a phase type of " 'either "Solid" or "Ion"')
×
537

538
            if self.filter_solids:
1✔
539
                # O is 2.46 b/c pbx entry finds energies referenced to H2O
540
                entries_HO = [ComputedEntry("H", 0), ComputedEntry("O", 2.46)]
1✔
541
                solid_pd = PhaseDiagram(solid_entries + entries_HO)
1✔
542
                solid_entries = list(set(solid_pd.stable_entries) - set(entries_HO))
1✔
543

544
            self._filtered_entries = solid_entries + ion_entries
1✔
545
            if len(comp_dict) > 1:
1✔
546
                self._multielement = True
1✔
547
                self._processed_entries = self._preprocess_pourbaix_entries(self._filtered_entries, nproc=nproc)
1✔
548
            else:
549
                self._processed_entries = self._filtered_entries
1✔
550
                self._multielement = False
1✔
551

552
        self._stable_domains, self._stable_domain_vertices = self.get_pourbaix_domains(self._processed_entries)
1✔
553

554
    def _convert_entries_to_points(self, pourbaix_entries):
1✔
555
        """
556
        Args:
557
            pourbaix_entries ([PourbaixEntry]): list of Pourbaix entries
558
                to process into vectors in nph-nphi-composition space
559

560
        Returns:
561
            list of vectors, [[nph, nphi, e0, x1, x2, ..., xn-1]]
562
            corresponding to each entry in nph-nphi-composition space
563
        """
564
        vecs = [
1✔
565
            [entry.npH, entry.nPhi, entry.energy] + [entry.composition.get(elt) for elt in self.pbx_elts[:-1]]
566
            for entry in pourbaix_entries
567
        ]
568
        vecs = np.array(vecs)
1✔
569
        norms = np.transpose([[entry.normalization_factor for entry in pourbaix_entries]])
1✔
570
        vecs *= norms
1✔
571
        return vecs
1✔
572

573
    def _get_hull_in_nph_nphi_space(self, entries):
1✔
574
        """
575
        Generates convex hull of Pourbaix diagram entries in composition,
576
        npH, and nphi space. This enables filtering of multi-entries
577
        such that only compositionally stable combinations of entries
578
        are included.
579

580
        Args:
581
            entries ([PourbaixEntry]): list of PourbaixEntries to construct
582
                the convex hull
583

584
        Returns: list of entries and stable facets corresponding to that
585
            list of entries
586
        """
587
        ion_entries = [entry for entry in entries if entry.phase_type == "Ion"]
1✔
588
        solid_entries = [entry for entry in entries if entry.phase_type == "Solid"]
1✔
589

590
        # Pre-filter solids based on min at each composition
591
        logger.debug("Pre-filtering solids by min energy at each composition")
1✔
592
        sorted_entries = sorted(
1✔
593
            solid_entries,
594
            key=lambda x: (x.composition.reduced_composition, x.entry.energy_per_atom),
595
        )
596
        grouped_by_composition = itertools.groupby(sorted_entries, key=lambda x: x.composition.reduced_composition)
1✔
597
        min_entries = [list(grouped_entries)[0] for comp, grouped_entries in grouped_by_composition]
1✔
598
        min_entries += ion_entries
1✔
599

600
        logger.debug("Constructing nph-nphi-composition points for qhull")
1✔
601

602
        vecs = self._convert_entries_to_points(min_entries)
1✔
603
        maxes = np.max(vecs[:, :3], axis=0)
1✔
604
        extra_point = np.concatenate([maxes, np.ones(self.dim) / self.dim], axis=0)
1✔
605

606
        # Add padding for extra point
607
        pad = 1000
1✔
608
        extra_point[2] += pad
1✔
609
        points = np.concatenate([vecs, np.array([extra_point])], axis=0)
1✔
610
        logger.debug("Constructing convex hull in nph-nphi-composition space")
1✔
611
        hull = ConvexHull(points, qhull_options="QJ i")
1✔
612

613
        # Create facets and remove top
614
        facets = [facet for facet in hull.simplices if not len(points) - 1 in facet]
1✔
615

616
        if self.dim > 1:
1✔
617
            logger.debug("Filtering facets by Pourbaix composition")
1✔
618
            valid_facets = []
1✔
619
            for facet in facets:
1✔
620
                comps = vecs[facet][:, 3:]
1✔
621
                full_comps = np.concatenate([comps, 1 - np.sum(comps, axis=1).reshape(len(comps), 1)], axis=1)
1✔
622
                # Ensure an compositional interior point exists in the simplex
623
                if np.linalg.matrix_rank(full_comps) > self.dim:
1✔
624
                    valid_facets.append(facet)
1✔
625
        else:
626
            valid_facets = facets
1✔
627

628
        return min_entries, valid_facets
1✔
629

630
    def _preprocess_pourbaix_entries(self, entries, nproc=None):
1✔
631
        """
632
        Generates multi-entries for Pourbaix diagram
633

634
        Args:
635
            entries ([PourbaixEntry]): list of PourbaixEntries to preprocess
636
                into MultiEntries
637
            nproc (int): number of processes to be used in parallel
638
                treatment of entry combos
639

640
        Returns:
641
            ([MultiEntry]) list of stable MultiEntry candidates
642
        """
643
        # Get composition
644
        tot_comp = Composition(self._elt_comp)
1✔
645

646
        min_entries, valid_facets = self._get_hull_in_nph_nphi_space(entries)
1✔
647

648
        combos = []
1✔
649
        for facet in valid_facets:
1✔
650
            for i in range(1, self.dim + 2):
1✔
651
                these_combos = []
1✔
652
                for combo in itertools.combinations(facet, i):
1✔
653
                    these_entries = [min_entries[i] for i in combo]
1✔
654
                    these_combos.append(frozenset(these_entries))
1✔
655
                combos.append(these_combos)
1✔
656

657
        all_combos = set(itertools.chain.from_iterable(combos))
1✔
658

659
        list_combos = []
1✔
660
        for i in all_combos:
1✔
661
            list_combos.append(list(i))
1✔
662
        all_combos = list_combos
1✔
663

664
        multi_entries = []
1✔
665

666
        # Parallel processing of multi-entry generation
667
        if nproc is not None:
1✔
668
            f = partial(self.process_multientry, prod_comp=tot_comp)
1✔
669
            with Pool(nproc) as p:
1✔
670
                multi_entries = list(p.imap(f, all_combos))
1✔
671
            multi_entries = list(filter(bool, multi_entries))
1✔
672
        else:
673
            # Serial processing of multi-entry generation
674
            for combo in all_combos:
1✔
675
                multi_entry = self.process_multientry(combo, prod_comp=tot_comp)
1✔
676
                if multi_entry:
1✔
677
                    multi_entries.append(multi_entry)
1✔
678

679
        return multi_entries
1✔
680

681
    def _generate_multielement_entries(self, entries, nproc=None):
1✔
682
        """
683
        Create entries for multi-element Pourbaix construction.
684

685
        This works by finding all possible linear combinations
686
        of entries that can result in the specified composition
687
        from the initialized comp_dict.
688

689
        Args:
690
            entries ([PourbaixEntries]): list of Pourbaix entries
691
                to process into MultiEntries
692
            nproc (int): number of processes to be used in parallel
693
                treatment of entry combos
694
        """
695
        N = len(self._elt_comp)  # No. of elements
×
696
        total_comp = Composition(self._elt_comp)
×
697

698
        # generate all combinations of compounds that have all elements
699
        entry_combos = [itertools.combinations(entries, j + 1) for j in range(N)]
×
700
        entry_combos = itertools.chain.from_iterable(entry_combos)
×
701

702
        entry_combos = filter(lambda x: total_comp < MultiEntry(x).composition, entry_combos)
×
703

704
        # Generate and filter entries
705
        processed_entries = []
×
706
        total = sum(comb(len(entries), j + 1) for j in range(N))
×
707
        if total > 1e6:
×
708
            warnings.warn(f"Your Pourbaix diagram includes {total} entries and may take a long time to generate.")
×
709

710
        # Parallel processing of multi-entry generation
711
        if nproc is not None:
×
712
            f = partial(self.process_multientry, prod_comp=total_comp)
×
713
            with Pool(nproc) as p:
×
714
                processed_entries = list(p.imap(f, entry_combos))
×
715
            processed_entries = list(filter(bool, processed_entries))
×
716
        # Serial processing of multi-entry generation
717
        else:
718
            for entry_combo in entry_combos:
×
719
                processed_entry = self.process_multientry(entry_combo, total_comp)
×
720
                if processed_entry is not None:
×
721
                    processed_entries.append(processed_entry)
×
722

723
        return processed_entries
×
724

725
    @staticmethod
1✔
726
    def process_multientry(entry_list, prod_comp, coeff_threshold=1e-4):
1✔
727
        """
728
        Static method for finding a multientry based on
729
        a list of entries and a product composition.
730
        Essentially checks to see if a valid aqueous
731
        reaction exists between the entries and the
732
        product composition and returns a MultiEntry
733
        with weights according to the coefficients if so.
734

735
        Args:
736
            entry_list ([Entry]): list of entries from which to
737
                create a MultiEntry
738
            prod_comp (Composition): composition constraint for setting
739
                weights of MultiEntry
740
            coeff_threshold (float): threshold of stoichiometric
741
                coefficients to filter, if weights are lower than
742
                this value, the entry is not returned
743
        """
744
        dummy_oh = [Composition("H"), Composition("O")]
1✔
745
        try:
1✔
746
            # Get balanced reaction coeffs, ensuring all < 0 or conc thresh
747
            # Note that we get reduced compositions for solids and non-reduced
748
            # compositions for ions because ions aren't normalized due to
749
            # their charge state.
750
            entry_comps = [e.composition for e in entry_list]
1✔
751
            rxn = Reaction(entry_comps + dummy_oh, [prod_comp])
1✔
752
            react_coeffs = [-rxn.get_coeff(comp) for comp in entry_comps]
1✔
753
            all_coeffs = react_coeffs + [rxn.get_coeff(prod_comp)]
1✔
754

755
            # Check if reaction coeff threshold met for Pourbaix compounds
756
            # All reactant/product coefficients must be positive nonzero
757
            if all(coeff > coeff_threshold for coeff in all_coeffs):
1✔
758
                return MultiEntry(entry_list, weights=react_coeffs)
1✔
759

760
            return None
1✔
761
        except ReactionError:
1✔
762
            return None
1✔
763

764
    @staticmethod
1✔
765
    def get_pourbaix_domains(pourbaix_entries, limits=None):
1✔
766
        """
767
        Returns a set of Pourbaix stable domains (i. e. polygons) in
768
        pH-V space from a list of pourbaix_entries
769

770
        This function works by using scipy's HalfspaceIntersection
771
        function to construct all of the 2-D polygons that form the
772
        boundaries of the planes corresponding to individual entry
773
        gibbs free energies as a function of pH and V. Hyperplanes
774
        of the form a*pH + b*V + 1 - g(0, 0) are constructed and
775
        supplied to HalfspaceIntersection, which then finds the
776
        boundaries of each Pourbaix region using the intersection
777
        points.
778

779
        Args:
780
            pourbaix_entries ([PourbaixEntry]): Pourbaix entries
781
                with which to construct stable Pourbaix domains
782
            limits ([[float]]): limits in which to do the pourbaix
783
                analysis
784

785
        Returns:
786
            Returns a dict of the form {entry: [boundary_points]}.
787
            The list of boundary points are the sides of the N-1
788
            dim polytope bounding the allowable ph-V range of each entry.
789
        """
790
        if limits is None:
1✔
791
            limits = [[-2, 16], [-4, 4]]
1✔
792

793
        # Get hyperplanes
794
        hyperplanes = [
1✔
795
            np.array([-PREFAC * entry.npH, -entry.nPhi, 0, -entry.energy]) * entry.normalization_factor
796
            for entry in pourbaix_entries
797
        ]
798
        hyperplanes = np.array(hyperplanes)
1✔
799
        hyperplanes[:, 2] = 1
1✔
800

801
        max_contribs = np.max(np.abs(hyperplanes), axis=0)
1✔
802
        g_max = np.dot(-max_contribs, [limits[0][1], limits[1][1], 0, 1])
1✔
803

804
        # Add border hyperplanes and generate HalfspaceIntersection
805
        border_hyperplanes = [
1✔
806
            [-1, 0, 0, limits[0][0]],
807
            [1, 0, 0, -limits[0][1]],
808
            [0, -1, 0, limits[1][0]],
809
            [0, 1, 0, -limits[1][1]],
810
            [0, 0, -1, 2 * g_max],
811
        ]
812
        hs_hyperplanes = np.vstack([hyperplanes, border_hyperplanes])
1✔
813
        interior_point = np.average(limits, axis=1).tolist() + [g_max]
1✔
814
        hs_int = HalfspaceIntersection(hs_hyperplanes, np.array(interior_point))
1✔
815

816
        # organize the boundary points by entry
817
        pourbaix_domains = {entry: [] for entry in pourbaix_entries}
1✔
818
        for intersection, facet in zip(hs_int.intersections, hs_int.dual_facets):
1✔
819
            for v in facet:
1✔
820
                if v < len(pourbaix_entries):
1✔
821
                    this_entry = pourbaix_entries[v]
1✔
822
                    pourbaix_domains[this_entry].append(intersection)
1✔
823

824
        # Remove entries with no Pourbaix region
825
        pourbaix_domains = {k: v for k, v in pourbaix_domains.items() if v}
1✔
826
        pourbaix_domain_vertices = {}
1✔
827

828
        for entry, points in pourbaix_domains.items():
1✔
829
            points = np.array(points)[:, :2]
1✔
830
            # Initial sort to ensure consistency
831
            points = points[np.lexsort(np.transpose(points))]
1✔
832
            center = np.average(points, axis=0)
1✔
833
            points_centered = points - center
1✔
834

835
            # Sort points by cross product of centered points,
836
            # isn't strictly necessary but useful for plotting tools
837
            points_centered = sorted(points_centered, key=cmp_to_key(lambda x, y: x[0] * y[1] - x[1] * y[0]))
1✔
838
            points = points_centered + center
1✔
839

840
            # Create simplices corresponding to Pourbaix boundary
841
            simplices = [Simplex(points[indices]) for indices in ConvexHull(points).simplices]
1✔
842
            pourbaix_domains[entry] = simplices
1✔
843
            pourbaix_domain_vertices[entry] = points
1✔
844

845
        return pourbaix_domains, pourbaix_domain_vertices
1✔
846

847
    def find_stable_entry(self, pH, V):
1✔
848
        """
849
        Finds stable entry at a pH,V condition
850
        Args:
851
            pH (float): pH to find stable entry
852
            V (float): V to find stable entry
853

854
        Returns:
855
        """
856
        energies_at_conditions = [e.normalized_energy_at_conditions(pH, V) for e in self.stable_entries]
1✔
857
        return self.stable_entries[np.argmin(energies_at_conditions)]
1✔
858

859
    def get_decomposition_energy(self, entry, pH, V):
1✔
860
        """
861
        Finds decomposition to most stable entries in eV/atom,
862
        supports vectorized inputs for pH and V
863

864
        Args:
865
            entry (PourbaixEntry): PourbaixEntry corresponding to
866
                compound to find the decomposition for
867
            pH (float, [float]): pH at which to find the decomposition
868
            V (float, [float]): voltage at which to find the decomposition
869

870
        Returns:
871
            Decomposition energy for the entry, i. e. the energy above
872
                the "Pourbaix hull" in eV/atom at the given conditions
873
        """
874
        # Check composition consistency between entry and Pourbaix diagram:
875
        pbx_comp = Composition(self._elt_comp).fractional_composition
1✔
876
        entry_pbx_comp = Composition(
1✔
877
            {elt: coeff for elt, coeff in entry.composition.items() if elt not in ELEMENTS_HO}
878
        ).fractional_composition
879
        if entry_pbx_comp != pbx_comp:
1✔
880
            raise ValueError("Composition of stability entry does not match Pourbaix Diagram")
×
881
        entry_normalized_energy = entry.normalized_energy_at_conditions(pH, V)
1✔
882
        hull_energy = self.get_hull_energy(pH, V)
1✔
883
        decomposition_energy = entry_normalized_energy - hull_energy
1✔
884

885
        # Convert to eV/atom instead of eV/normalized formula unit
886
        decomposition_energy /= entry.normalization_factor
1✔
887
        decomposition_energy /= entry.composition.num_atoms
1✔
888
        return decomposition_energy
1✔
889

890
    def get_hull_energy(self, pH, V):
1✔
891
        """
892
        Gets the minimum energy of the Pourbaix "basin" that is formed
893
        from the stable Pourbaix planes. Vectorized.
894

895
        Args:
896
            pH (float or [float]): pH at which to find the hull energy
897
            V (float or [float]): V at which to find the hull energy
898

899
        Returns:
900
            (float or [float]) minimum Pourbaix energy at conditions
901
        """
902
        all_gs = np.array([e.normalized_energy_at_conditions(pH, V) for e in self.stable_entries])
1✔
903
        base = np.min(all_gs, axis=0)
1✔
904
        return base
1✔
905

906
    def get_stable_entry(self, pH, V):
1✔
907
        """
908
        Gets the stable entry at a given pH, V condition
909

910
        Args:
911
            pH (float): pH at a given condition
912
            V (float): V at a given condition
913

914
        Returns:
915
            (PourbaixEntry or MultiEntry): Pourbaix or multi-entry
916
                corresponding ot the minimum energy entry at a given
917
                pH, V condition
918
        """
919
        all_gs = np.array([e.normalized_energy_at_conditions(pH, V) for e in self.stable_entries])
1✔
920
        return self.stable_entries[np.argmin(all_gs)]
1✔
921

922
    @property
1✔
923
    def stable_entries(self):
1✔
924
        """
925
        Returns the stable entries in the Pourbaix diagram.
926
        """
927
        return list(self._stable_domains)
1✔
928

929
    @property
1✔
930
    def unstable_entries(self):
1✔
931
        """
932
        Returns all unstable entries in the Pourbaix diagram
933
        """
934
        return [e for e in self.all_entries if e not in self.stable_entries]
1✔
935

936
    @property
1✔
937
    def all_entries(self):
1✔
938
        """
939
        Return all entries used to generate the Pourbaix diagram
940
        """
941
        return self._processed_entries
1✔
942

943
    @property
1✔
944
    def unprocessed_entries(self):
1✔
945
        """
946
        Return unprocessed entries
947
        """
948
        return self._unprocessed_entries
×
949

950
    def as_dict(self, include_unprocessed_entries=None):
1✔
951
        """
952
        Args:
953
            include_unprocessed_entries (): DEPRECATED. Whether to include unprocessed
954
                entries (equivalent to filter_solids=False). Serialization now includes
955
                all unprocessed entries by default. Set filter_solids=False before
956
                serializing to include unstable solids from the generated Pourbaix Diagram.
957

958
        Returns:
959
            MSONable dict.
960
        """
961
        if include_unprocessed_entries:
1✔
962
            warnings.warn(
1✔
963
                DeprecationWarning(
964
                    "The include_unprocessed_entries kwarg is deprecated! "
965
                    "Set filter_solids=True / False before serializing instead."
966
                )
967
            )
968
        d = {
1✔
969
            "@module": type(self).__module__,
970
            "@class": type(self).__name__,
971
            "entries": [e.as_dict() for e in self._unprocessed_entries],
972
            "comp_dict": self._elt_comp,
973
            "conc_dict": self._conc_dict,
974
            "filter_solids": self.filter_solids,
975
        }
976
        return d
1✔
977

978
    @classmethod
1✔
979
    def from_dict(cls, d):
1✔
980
        """
981
        Args:
982
            d (): Dict representation.
983

984
        Returns:
985
            PourbaixDiagram
986
        """
987
        decoded_entries = MontyDecoder().process_decoded(d["entries"])
1✔
988
        return cls(decoded_entries, d.get("comp_dict"), d.get("conc_dict"), d.get("filter_solids"))
1✔
989

990

991
class PourbaixPlotter:
1✔
992
    """
993
    A plotter class for phase diagrams.
994
    """
995

996
    def __init__(self, pourbaix_diagram):
1✔
997
        """
998
        Args:
999
            pourbaix_diagram (PourbaixDiagram): A PourbaixDiagram object.
1000
        """
1001
        self._pbx = pourbaix_diagram
1✔
1002

1003
    def show(self, *args, **kwargs):
1✔
1004
        """
1005
        Shows the Pourbaix plot
1006

1007
        Args:
1008
            *args: args to get_pourbaix_plot
1009
            **kwargs: kwargs to get_pourbaix_plot
1010

1011
        Returns:
1012
            None
1013
        """
1014
        plt = self.get_pourbaix_plot(*args, **kwargs)
×
1015
        plt.show()
×
1016

1017
    def get_pourbaix_plot(
1✔
1018
        self,
1019
        limits=None,
1020
        title="",
1021
        label_domains=True,
1022
        label_fontsize=20,
1023
        show_water_lines=True,
1024
        show_neutral_axes=True,
1025
        plt=None,
1026
    ):
1027
        """
1028
        Plot Pourbaix diagram.
1029

1030
        Args:
1031
            limits: 2D list containing limits of the Pourbaix diagram
1032
                of the form [[xlo, xhi], [ylo, yhi]]
1033
            title (str): Title to display on plot
1034
            label_domains (bool): whether to label Pourbaix domains
1035
            label_fontsize: font size for domain labels
1036
            show_water_lines: whether to show dashed lines indicating the region
1037
                of water stability.
1038
            show_neutral_axes; whether to show dashed horizontal and vertical lines
1039
                at 0 V and pH 7, respectively.
1040
            plt (pyplot): Pyplot instance for plotting
1041

1042
        Returns:
1043
            plt (pyplot) - matplotlib plot object with Pourbaix diagram
1044
        """
1045
        if limits is None:
1✔
1046
            limits = [[-2, 16], [-3, 3]]
1✔
1047

1048
        plt = plt or pretty_plot(16)
1✔
1049

1050
        xlim = limits[0]
1✔
1051
        ylim = limits[1]
1✔
1052

1053
        ax = plt.gca()
1✔
1054
        ax.set_xlim(xlim)
1✔
1055
        ax.set_ylim(ylim)
1✔
1056
        lw = 3
1✔
1057

1058
        if show_water_lines:
1✔
1059
            h_line = np.transpose([[xlim[0], -xlim[0] * PREFAC], [xlim[1], -xlim[1] * PREFAC]])
1✔
1060
            o_line = np.transpose([[xlim[0], -xlim[0] * PREFAC + 1.23], [xlim[1], -xlim[1] * PREFAC + 1.23]])
1✔
1061
            plt.plot(h_line[0], h_line[1], "r--", linewidth=lw)
1✔
1062
            plt.plot(o_line[0], o_line[1], "r--", linewidth=lw)
1✔
1063

1064
        if show_neutral_axes:
1✔
1065
            neutral_line = np.transpose([[7, ylim[0]], [7, ylim[1]]])
1✔
1066
            V0_line = np.transpose([[xlim[0], 0], [xlim[1], 0]])
1✔
1067
            plt.plot(neutral_line[0], neutral_line[1], "k-.", linewidth=lw)
1✔
1068
            plt.plot(V0_line[0], V0_line[1], "k-.", linewidth=lw)
1✔
1069

1070
        for entry, vertices in self._pbx._stable_domain_vertices.items():
1✔
1071
            center = np.average(vertices, axis=0)
1✔
1072
            x, y = np.transpose(np.vstack([vertices, vertices[0]]))
1✔
1073
            plt.plot(x, y, "k-", linewidth=lw)
1✔
1074

1075
            if label_domains:
1✔
1076
                plt.annotate(
1✔
1077
                    generate_entry_label(entry),
1078
                    center,
1079
                    ha="center",
1080
                    va="center",
1081
                    fontsize=label_fontsize,
1082
                    color="b",
1083
                ).draggable()
1084

1085
        plt.xlabel("pH")
1✔
1086
        plt.ylabel("E (V)")
1✔
1087
        plt.title(title, fontsize=20, fontweight="bold")
1✔
1088
        return plt
1✔
1089

1090
    def plot_entry_stability(
1✔
1091
        self,
1092
        entry,
1093
        pH_range=None,
1094
        pH_resolution=100,
1095
        V_range=None,
1096
        V_resolution=100,
1097
        e_hull_max=1,
1098
        cmap="RdYlBu_r",
1099
        **kwargs,
1100
    ):
1101
        """
1102
        Args:
1103
            entry ():
1104
            pH_range ():
1105
            pH_resolution ():
1106
            V_range ():
1107
            V_resolution ():
1108
            e_hull_max ():
1109
            cmap ():
1110
            **kwargs ():
1111

1112
        Returns:
1113
        """
1114
        if pH_range is None:
1✔
1115
            pH_range = [-2, 16]
1✔
1116
        if V_range is None:
1✔
1117
            V_range = [-3, 3]
1✔
1118

1119
        # plot the Pourbaix diagram
1120
        plt = self.get_pourbaix_plot(**kwargs)
1✔
1121
        pH, V = np.mgrid[
1✔
1122
            pH_range[0] : pH_range[1] : pH_resolution * 1j,
1123
            V_range[0] : V_range[1] : V_resolution * 1j,
1124
        ]
1125

1126
        stability = self._pbx.get_decomposition_energy(entry, pH, V)
1✔
1127

1128
        # Plot stability map
1129
        plt.pcolor(pH, V, stability, cmap=cmap, vmin=0, vmax=e_hull_max)
1✔
1130
        cbar = plt.colorbar()
1✔
1131
        cbar.set_label(f"Stability of {generate_entry_label(entry)} (eV/atom)")
1✔
1132

1133
        # Set ticklabels
1134
        # ticklabels = [t.get_text() for t in cbar.ax.get_yticklabels()]
1135
        # ticklabels[-1] = '>={}'.format(ticklabels[-1])
1136
        # cbar.ax.set_yticklabels(ticklabels)
1137

1138
        return plt
1✔
1139

1140
    def domain_vertices(self, entry):
1✔
1141
        """
1142
        Returns the vertices of the Pourbaix domain.
1143

1144
        Args:
1145
            entry: Entry for which domain vertices are desired
1146

1147
        Returns:
1148
            list of vertices
1149
        """
1150
        return self._pbx._stable_domain_vertices[entry]
×
1151

1152

1153
def generate_entry_label(entry):
1✔
1154
    """
1155
    Generates a label for the Pourbaix plotter
1156

1157
    Args:
1158
        entry (PourbaixEntry or MultiEntry): entry to get a label for
1159
    """
1160
    if isinstance(entry, MultiEntry):
1✔
1161
        return " + ".join(e.name for e in entry.entry_list)
1✔
1162

1163
    # TODO - a more elegant solution could be added later to Stringify
1164
    # for example, the pattern re.sub(r"([-+][\d\.]*)", r"$^{\1}$", )
1165
    # will convert B(OH)4- to B(OH)$_4^-$.
1166
    # for this to work, the ion's charge always must be written AFTER
1167
    # the sign (e.g., Fe+2 not Fe2+)
1168
    string = entry.to_latex_string()
1✔
1169
    return re.sub(r"()\[([^)]*)\]", r"\1$^{\2}$", string)
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