Coverage for /builds/ase/ase/ase/pourbaix.py: 94.24%
399 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-08-02 00:12 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-08-02 00:12 +0000
1# fmt: off
3import functools
4import re
5from collections import Counter
6from dataclasses import dataclass
7from fractions import Fraction
8from itertools import chain, combinations, product
9from typing import List, Tuple, Union
11import numpy as np
12from scipy.linalg import null_space
14from ase.formula import Formula
15from ase.units import kB
17CONST = kB * np.log(10) # Nernst constant
19PREDEF_ENERGIES = { # Default chemical potentials
20 'H+': 0.0, # for water, protons and electrons
21 'e-': 0.0,
22 'H2O': -2.4583
23}
25U_STD_AGCL = 0.222 # Standard redox potential of AgCl electrode
26U_STD_SCE = 0.244 # Standard redox potential of SCE electrode
29def parse_formula(formula: str, fmt: str = 'metal'):
30 aq = formula.endswith('(aq)')
31 charge = formula.count('+') - formula.count('-')
32 formula_strip = formula.replace('(aq)', '').rstrip('+-')
33 formula_obj = Formula(formula_strip, format=fmt)
34 return formula_obj, charge, aq
37def initialize_refs(refs_dct, reduce=False, fmt='metal'):
38 """Convert dictionary entries to Species instances"""
39 refs = {}
40 for label, energy in refs_dct.items():
41 spec = Species.from_string(label, energy, reduce, fmt)
42 refs[label] = spec
43 return refs
46def get_product_combos(reactant, refs):
47 """Obtain all possible combinations of products.
49 Obtain - from the available references and based
50 on the stoichiometry of the target material (reactant) -
51 different combinations of products to be inserted
52 in (electro)chemical reactions.
53 """
54 ref_elem = reactant._main_elements
55 allcombos = [[] for _ in range(len(ref_elem))]
56 for ref in refs.values():
57 contained = ref.contains(ref_elem)
58 for w in np.argwhere(contained).flatten():
59 allcombos[w].append(ref)
60 return [np.unique(combo) for combo in product(*allcombos)]
63def get_phases(reactant, refs, T, conc, reference, tol=1e-3):
64 """Obtain all the possible decomposition pathways
65 for a given reactant as a collection of RedOx objects.
66 """
67 phases = []
68 phase_matrix = []
70 for products in get_product_combos(reactant, refs):
71 phase = RedOx.from_species(
72 reactant, products, T, conc, reference, tol
73 )
74 if phase is not None:
75 phases.append(phase)
76 phase_matrix.append(phase._vector)
78 if len(phase_matrix) == 0:
79 raise ValueError(
80 "No valid decomposition pathways have been found" +
81 " given this set of references."
82 )
84 return phases, np.array(phase_matrix).astype('float64')
87def get_main_products(count):
88 """Obtain the reaction products excluded protons,
89 water and electrons.
90 """
91 return [spec for spec, coef in count.items()
92 if coef > 0 and spec not in ['H+', 'H2O', 'e-']]
95def format_label(count) -> str:
96 """Obtain phase labels formatted in LaTeX math style."""
97 formatted = []
98 for prod in get_main_products(count):
99 label = re.sub(r'(\S)([+-]+)', r'\1$^{\2}$', prod)
100 label = re.sub(r'(\d+)', r'$_{\1}$', label)
101 for symbol in ['+', '-']:
102 count = label.count(symbol)
103 if count > 1:
104 label = label.replace(count * symbol, f'{count}{symbol}')
105 if count == 1:
106 label = label.replace(count * symbol, symbol)
107 formatted.append(label)
108 return ', '.join(f for f in formatted)
111def make_coeff_nice(coeff, max_denom) -> str:
112 """Convert a fraction into a string while limiting the denominator"""
113 frac = abs(Fraction(coeff).limit_denominator(max_denom))
114 if frac.numerator == frac.denominator:
115 return ''
116 return str(frac)
119def add_numbers(ax, text) -> None:
120 """Add number identifiers to the different domains of a Pourbaix diagram."""
121 import matplotlib.patheffects as pfx
122 for i, (x, y, _) in enumerate(text):
123 txt = ax.text(
124 y, x, f'{i}',
125 fontsize=20,
126 horizontalalignment='center'
127 )
128 txt.set_path_effects([pfx.withStroke(linewidth=2.0, foreground='w')])
131def add_labels(ax, text) -> None:
132 """Add phase labels to the different domains of a Pourbaix diagram."""
133 import matplotlib.patheffects as pfx
134 for i, (x, y, species) in enumerate(text):
135 label = format_label(species)
136 annotation = ax.annotate(
137 label, xy=(y, x), color='w',
138 fontsize=16, horizontalalignment='center'
139 )
140 annotation.set_path_effects(
141 [pfx.withStroke(linewidth=2.0, foreground='k')]
142 )
143 annotation.draggable()
144 ax.add_artist(annotation)
147def wrap_text(text) -> str:
148 import textwrap
150 textlines = []
151 for i, (_, _, species) in enumerate(text):
152 label = format_label(species)
153 textlines.append(
154 textwrap.fill(
155 f'({i}) {label}',
156 width=40,
157 subsequent_indent=' '
158 )
159 )
160 return '\n'.join(textlines)
163def add_phase_labels(fig, text, offset=0.0):
164 """Add phase labels to the right of the diagram"""
166 fig.text(
167 0.75 + offset, 0.5,
168 wrap_text(text),
169 fontsize=16,
170 va='center',
171 ha='left')
174def add_redox_lines(axes, pH, reference, color='k') -> None:
175 """Add water redox potentials to a Pourbaix diagram"""
176 const = -0.5 * PREDEF_ENERGIES['H2O']
177 corr = {
178 'SHE': 0,
179 'RHE': 0,
180 'Pt': 0,
181 'AgCl': -U_STD_AGCL,
182 'SCE': -U_STD_SCE,
183 }
184 kwargs = {
185 'c': color,
186 'ls': '--',
187 'zorder': 2
188 }
189 if reference in ['SHE', 'AgCl', 'SCE']:
190 slope = -59.2e-3
191 axes.plot(pH, slope * pH + corr[reference], **kwargs)
192 axes.plot(pH, slope * pH + const + corr[reference], **kwargs)
193 elif reference in ['Pt', 'RHE']:
194 axes.axhline(0 + corr[reference], **kwargs)
195 axes.axhline(const + corr[reference], **kwargs)
196 else:
197 raise ValueError('The specified reference electrode doesnt exist')
200@functools.total_ordering
201class Species:
202 """Class representing an individual chemical species,
203 grouping relevant properties.
205 Initialization
206 --------------
207 name: str
208 A label representing the species
210 formula: Formula
212 charge: int
213 the electric charge of the species, if ionic
215 aq: bool
216 whether the species is solid (False)
217 or acqueous (True)
219 energy: float
220 the chemical potential of the species
221 """
222 def __init__(self,
223 name: str,
224 formula: Formula,
225 charge: int,
226 aq: bool,
227 energy: float):
229 self.name = name
230 self.formula = formula
231 self.energy = energy
232 self.charge = charge
233 self.aq = aq
235 self.count = formula.count()
236 self.natoms = sum(self.count.values())
237 self._main_elements = [
238 e for e in self.count.keys() if e not in ['H', 'O']
239 ]
241 def __eq__(self, other):
242 if not isinstance(other, Species):
243 return NotImplemented
244 return self.name == other.name
246 def __lt__(self, other):
247 if not isinstance(other, Species):
248 return NotImplemented
249 return self.name < other.name
251 @classmethod
252 def from_string(cls, string: str, energy: float,
253 reduce: bool = True, fmt: str = 'metal'):
254 """Initialize the class provided a formula and an energy.
256 string: str
257 The chemical formula of the species (e.g. ``ZnO``).
258 For solid species, the formula is automatically reduced to the
259 unit formula, and the chemical potential normalized accordingly.
260 Acqueous species are specified by expliciting
261 all the positive or negative charges and by appending ``(aq)``.
262 Parentheses for grouping functional groups are acceptable.
263 e.g.
264 Be3(OH)3[3+] ➜ wrong
265 Be3(OH)3+++ ➜ wrong
266 Be3(OH)3+++(aq) ➜ correct
267 Be3O3H3+++(aq) ➜ correct
269 energy: float
270 the energy (chemical potential) associated with the species.
272 reduce: bool
273 reduce to the unit formula and normalize the energy accordingly.
274 Formulae and energies of acqueous species are never reduced.
276 fmt: str
277 Formula formatting according to the available options in
278 ase.formula.Formula
279 """
280 formula, charge, aq = parse_formula(string, fmt=fmt)
281 if not aq:
282 if reduce:
283 formula, n_fu = formula.reduce()
284 energy /= n_fu
285 name = str(formula)
286 else:
287 name = string
288 return cls(name, formula, charge, aq, energy)
290 def get_chemsys(self):
291 """Get the possible combinations of elements based
292 on the stoichiometry. Useful for database queries.
293 """
294 elements = set(self.count.keys())
295 elements.update(['H', 'O'])
296 chemsys = list(
297 chain.from_iterable(
298 [combinations(elements, i + 1)
299 for i in range(len(elements))]
300 )
301 )
302 return chemsys
304 def balance_electrochemistry(self):
305 """Obtain number of H2O, H+, e- "carried" by the species
306 in electrochemical reactions.
307 """
308 n_H2O = -self.count.get('O', 0)
309 n_H = -2 * n_H2O - self.count.get('H', 0)
310 n_e = n_H + self.charge
311 return n_H2O, n_H, n_e
313 def _count_array(self, elements):
314 return np.array([self.count.get(e, 0) for e in elements])
316 def contains(self, elements):
317 return [elem in self._main_elements for elem in elements]
319 def get_fractional_composition(self, elements):
320 """Obtain the fractional content of each element."""
321 N_all = sum(self.count.values())
322 N_elem = sum([self.count.get(e, 0) for e in elements])
323 return N_elem / N_all
325 def __repr__(self):
326 return f'Species({self.name})'
329class RedOx:
330 def __init__(self, species, coeffs,
331 T=298.15, conc=1e-6,
332 reference='SHE'):
333 """RedOx class representing an (electro)chemical reaction.
335 Initialization
336 --------------
338 species: list[Species]
339 The reactant and products excluded H2O, protons and electrons.
341 coeffs: list[float]
342 The stoichiometric coefficients of the above species.
343 Positive coefficients are associated with the products,
344 negative coefficients with the reactants.
346 T: float
347 The temperature in Kelvin. Default: 298.15 K.
349 conc: float
350 The concentration of ionic species. Default: 1.0e-6 M.
352 reference: str
353 The reference electrode. Default: SHE.
354 available options: SHE, RHE, AgCl, Pt.
357 Relevant methods
358 ----------------
360 from_species(reactant, products, T, conc, reference)
361 Initialize the class from the reactant as a Species object
362 and the product(s) a list of Species objects.
364 equation()
365 Print the chemical equation of the reaction.
367 get_free_energy(U, pH):
368 Obtain the reaction free energy at a given
369 applied potential (U) and pH.
371 """
372 self.species = species
373 self.coeffs = coeffs
374 self.T = T
375 self.conc = conc
376 self.reference = reference
377 self.count = Counter()
379 alpha = CONST * T # 0.059 eV @ T=298.15K
380 const_term = 0
381 pH_term = 0
382 U_term = 0
384 for spec, coef in zip(species, coeffs):
385 self.count[spec.name] = coef
386 amounts = spec.balance_electrochemistry()
388 const_term += coef * (
389 spec.energy + alpha * (spec.aq * np.log10(conc))
390 )
391 pH_term += - coef * alpha * amounts[1]
392 U_term += - coef * amounts[2]
394 for name, n in zip(['H2O', 'H+', 'e-'], amounts):
395 const_term += coef * n * PREDEF_ENERGIES[name]
396 self.count[name] += coef * n
398 const_corr, pH_corr = self.get_ref_correction(reference, alpha)
399 self._vector = [
400 float(const_term + const_corr),
401 float(U_term),
402 float(pH_term + pH_corr)
403 ]
405 @classmethod
406 def from_species(cls, reactant, products,
407 T: float = 298.15, conc: float = 1e-6,
408 reference: str = 'SHE', tol: float = 1e-3):
409 """Initialize the class from a combination of
410 reactant and products. The stoichiometric
411 coefficients are automatically determined.
413 reactant: Species
414 products: list[Species]
415 """
416 reac_elem = reactant._main_elements
417 reac_count = [-reactant._count_array(reac_elem)]
418 prod_count = [p._count_array(reac_elem) for p in products]
419 elem_matrix = np.array(reac_count + prod_count).T
420 coeffs = null_space(elem_matrix).flatten()
422 if len(coeffs) > 0 and all(coeffs > tol):
423 coeffs /= coeffs[0]
424 coeffs[0] = -coeffs[0]
425 species = (reactant, *products)
426 phase = cls(species, coeffs, T, conc, reference)
427 return phase
429 return None
431 def get_ref_correction(self, reference: str,
432 alpha: float) -> Tuple[float, float]:
433 """Correct the constant and pH contributions to the reaction free energy
434 based on the reference electrode of choice and the temperature
435 (alpha=k_B*T*ln(10))
436 """
437 n_e = self.count['e-']
438 gibbs_corr = 0.0
439 pH_corr = 0.0
440 if reference in ['RHE', 'Pt']:
441 pH_corr += n_e * alpha
442 if reference == 'Pt' and n_e < 0:
443 gibbs_corr += n_e * 0.5 * PREDEF_ENERGIES['H2O']
444 if reference == 'AgCl':
445 gibbs_corr -= n_e * U_STD_AGCL
446 if reference == 'SCE':
447 gibbs_corr -= n_e * U_STD_SCE
449 return gibbs_corr, pH_corr
451 def equation(self, max_denom: int = 50) -> str:
452 """Print the chemical reaction."""
454 reactants = []
455 products = []
456 for s, n in self.count.items():
457 if abs(n) <= 1e-6:
458 continue
459 nice_coeff = make_coeff_nice(n, max_denom)
460 substr = f'{nice_coeff}{s}'
461 if n > 0:
462 products.append(substr)
463 else:
464 reactants.append(substr)
466 return " ➜ ".join([" + ".join(reactants), " + ".join(products)])
468 def get_free_energy(self, U: float, pH: float) -> float:
469 """Evaluate the reaction free energy
470 at a given applied potential U and pH"""
471 return self._vector[0] + self._vector[1] * U + self._vector[2] * pH
474class Pourbaix:
475 """Pourbaix class for acqueous stability evaluations.
477 Allows to determine the most stable phase in a given set
478 of pH and potential conditions and to evaluate a complete diagram.
480 Initialization
481 --------------
483 material_name: str
484 The formula of the target material. It is preferrable
485 to provide the reduced formula (e.g. RuO2 instad of Ru2O4).
487 refs_dct: dict
488 A dictionary containing the formulae of the target material
489 and its competing phases (solid and/or ionic) as keys,
490 and their formation energies as values.
492 T: float
493 Temperature in Kelvin. Default: 298.15 K.
495 conc: float
496 Concentration of the ionic species. Default: 1e-6 mol/L.
498 reference: str
499 The reference electrode. Default: SHE.
500 available options: SHE, RHE, AgCl, Pt.
503 Relevant methods
504 ----------------
506 get_pourbaix_energy(U, pH)
507 obtain the energy of the target material
508 relative to the most stable phase at a given potential U and pH.
509 If negative, the target material can be regarded as stable.
510 plot(...)
511 plot a complete Pourbaix diagram in a given pH and potential window.
514 Relevant attributes
515 -------------------
517 material: Species
518 the target material as a Species object
520 phases: list[RedOx]
521 the available decomposition reactions of the target material
522 into its competing phases as a list of RedOx objects.
524 """
525 def __init__(self,
526 material_name: str,
527 refs_dct: dict,
528 T: float = 298.15,
529 conc: float = 1.0e-6,
530 reference: str = 'SHE'):
532 self.material_name = material_name
533 self.refs = refs_dct
534 self.T = T
535 self.conc = conc
536 self.reference = reference
538 refs = initialize_refs(refs_dct)
539 self.material = refs.pop(material_name)
540 self.phases, phase_matrix = get_phases(
541 self.material, refs, T, conc, reference
542 )
543 self._const = phase_matrix[:, 0]
544 self._var = phase_matrix[:, 1:]
546 def _decompose(self, U, pH):
547 """Evaluate the reaction energy for decomposing
548 the target material into each of the available products
549 at a given pH and applied potential.
550 """
551 return self._const + np.dot(self._var, [U, pH])
553 def _get_pourbaix_energy(self, U, pH):
554 """Evaluate the Pourbaix energy"""
555 energies = self._decompose(U, pH)
556 i_min = np.argmin(energies)
557 return -energies[i_min], i_min
559 def get_pourbaix_energy(self, U, pH, verbose=False):
560 """Evaluate the Pourbaix energy, print info about
561 the most stable phase and the corresponding energy at
562 a given potential U and pH.
564 The Pourbaix energy represents the energy of the target material
565 relative to the most stable competing phase. If negative,
566 the target material can be considered as stable.
567 """
568 energy, index = self._get_pourbaix_energy(U, pH)
569 phase = self.phases[index]
570 if verbose:
571 if energy <= 0.0:
572 print(f'{self.material.name} is stable.')
573 else:
574 print(f'Stable phase: \n{phase.equation()}')
575 print(f'Energy: {energy:.3f} eV')
576 return energy, phase
578 def get_equations(self, contains: Union[str, None] = None):
579 """Print the chemical reactions of the available phases.
581 the argument `contains' allows to filter for phases containing a
582 particular species
583 e.g. get_equations(contains='ZnO')
584 """
585 equations = []
586 for i, p in enumerate(self.phases):
587 if contains is not None and contains not in p.count:
588 continue
589 equations.append(f'{i}) {p.equation()}')
590 return equations
592 def diagram(self, U=None, pH=None):
593 """Actual evaluation of the complete diagram
595 Returns
596 -------
598 pour:
599 The stability domains of the diagram on the pH vs. U grid.
600 domains are represented by indexes (as integers)
601 that map to Pourbaix.phases
602 the target material is stable (index=-1)
604 meta:
605 The Pourbaix energy on the pH vs. U grid.
607 text:
608 The coordinates and phases information for
609 text placement on the diagram.
611 domains:
612 ...
614 """
615 pour = np.zeros((len(U), len(pH)))
616 meta = pour.copy()
618 for i, u in enumerate(U):
619 for j, p in enumerate(pH):
620 meta[i, j], pour[i, j] = self._get_pourbaix_energy(u, p)
622 # Identifying the region where the target material
623 # is stable and updating the diagram accordingly
624 where_stable = (meta <= 0)
625 pour[where_stable] = -1
627 text = []
628 domains = [int(i) for i in np.unique(pour)]
629 for phase_id in domains:
630 if phase_id == -1:
631 where = where_stable
632 txt = {self.material.name: 1}
633 else:
634 where = (pour == phase_id)
635 phase = self.phases[int(phase_id)]
636 txt = phase.count
637 x = np.dot(where.sum(1), U) / where.sum()
638 y = np.dot(where.sum(0), pH) / where.sum()
639 text.append((x, y, txt))
641 return PourbaixDiagram(self, U, pH, pour, meta, text, domains)
643 def get_phase_boundaries(self, phmin, phmax, umin, umax, domains, tol=1e-6):
644 """Plane intersection method for finding
645 the boundaries between phases seen in the final plot.
647 Returns a list of tuples, each representing a single boundary,
648 of the form ([[x1, x2], [y1, y2]], [id1, id2]), namely the
649 x and y coordinates of the simplices connected by the boundary
650 and the id's of the phases at each side of the boundary.
651 """
652 from collections import defaultdict
653 from itertools import combinations
655 # Planes identifying the diagram frame
656 planes = [(np.array([0.0, 1.0, 0.0]), umin, 'bottom'),
657 (np.array([0.0, 1.0, 0.0]), umax, 'top'),
658 (np.array([1.0, 0.0, 0.0]), phmin, 'left'),
659 (np.array([1.0, 0.0, 0.0]), phmax, 'right')]
661 # Planes associated with the stable domains of the diagram.
662 # Given x=pH, y=U, z=E_pbx=-DeltaG, each plane has expression:
663 # _vector[2]*x + _vector[1]*y + z = -_vector[0]
664 # The region where the target material is stable
665 # (id=-1, if present) is delimited by the xy plane (Epbx=0)
666 for d in domains:
667 if d == -1:
668 plane = np.array([0.0, 0.0, 1.0])
669 const = 0.0
670 else:
671 pvec = self.phases[d]._vector
672 plane = np.array([pvec[2], pvec[1], 1])
673 const = -pvec[0]
674 planes.append((plane, const, d))
676 # The simplices are found from the intersection points between
677 # all possible plane triplets. If the z coordinate of the point
678 # matches the corresponding pourbaix energy,
679 # then the point is a simplex.
680 simplices = []
681 for (p1, c1, id1), (p2, c2, id2), (p3, c3, id3) in \
682 combinations(planes, 3):
683 A = np.vstack((p1, p2, p3))
684 c = np.array([c1, c2, c3])
685 ids = (id1, id2, id3)
687 try:
688 # triplets containing parallel planes raise a LinAlgError
689 # and are automatically excluded.
690 invA = np.linalg.inv(A)
691 except np.linalg.LinAlgError:
692 continue
694 pt = np.dot(invA, c)
695 Epbx = self._get_pourbaix_energy(pt[1], pt[0])[0]
696 if pt[2] >= -tol and \
697 np.isclose(Epbx, pt[2], rtol=0, atol=tol) and \
698 phmin - tol <= pt[0] <= phmax + tol and \
699 umin - tol <= pt[1] <= umax + tol:
700 simplex = np.round(pt[:2], 3)
701 simplices.append((simplex, ids))
703 # The final segments to plot on the diagram are found from
704 # the pairs of unique simplices that have two neighboring phases
705 # in common, diagram frame excluded.
706 duplicate_filter = defaultdict(int)
708 def are_boundaries_there(comm):
709 bounds = ['bottom', 'top', 'left', 'right']
710 for c in comm:
711 if c in bounds:
712 return True
713 return False
715 for (s1, id1), (s2, id2) in combinations(simplices, 2):
717 # common neighboring phases, diagram frame excluded
718 common = {*id1} & {*id2}
720 simplices_are_distinct = not np.allclose(s1, s2, rtol=0, atol=tol)
721 only_two_phases_in_common = (len(common) == 2)
722 diagram_frame_excluded = not are_boundaries_there(common)
724 # Filtering out duplicates
725 if all([simplices_are_distinct, only_two_phases_in_common,
726 diagram_frame_excluded]):
727 testarray = sorted([s1, s2], key=lambda s: (s[0], s[1]))
728 testarray.append(sorted(common))
729 testarray = np.array(testarray).flatten()
730 duplicate_filter[tuple(testarray)] += 1
732 segments = []
733 for segment in duplicate_filter:
734 coords, phases = np.split(np.array(segment), [4])
735 segments.append((
736 coords.reshape(2, 2).T,
737 list(phases.astype(int))
738 ))
739 return segments
742@dataclass
743class PourbaixDiagram:
744 pbx: Pourbaix
745 U: np.ndarray
746 pH: np.ndarray
747 pour: np.ndarray
748 meta: np.ndarray
749 text: List[Tuple[float, float, str]]
750 domains: List[int]
752 def __post_init__(self):
753 def _issorted(array):
754 return all(np.diff(array) > 0)
756 # We might as well require the input domains to be sorted:
757 if not _issorted(self.U):
758 raise ValueError('U must be sorted')
760 if not _issorted(self.pH):
761 raise ValueError('pH must be sorted')
763 @property
764 def pHrange(self):
765 return (self.pH[0], self.pH[-1])
767 @property
768 def Urange(self):
769 return (self.U[0], self.U[-1])
771 def _draw_diagram_axes(
772 self,
773 cap,
774 normalize,
775 include_text,
776 include_water,
777 labeltype, cmap, *,
778 ax):
779 """Backend for drawing Pourbaix diagrams."""
781 meta = self.meta.copy()
782 if normalize:
783 meta /= self.pbx.material.natoms
784 cbarlabel = r'$\Delta G_{pbx}$ (eV/atom)'
785 else:
786 cbarlabel = r'$\Delta G_{pbx}$ (eV)'
788 fig = ax.get_figure()
789 extent = [*self.pHrange, *self.Urange]
791 fig.subplots_adjust(
792 left=0.1, right=0.97,
793 top=0.97, bottom=0.14
794 )
796 if isinstance(cap, list):
797 vmin = cap[0]
798 vmax = cap[1]
799 else:
800 vmin = -cap
801 vmax = cap
803 colorplot = ax.imshow(
804 meta, cmap=cmap,
805 extent=extent,
806 vmin=vmin, vmax=vmax,
807 origin='lower', aspect='auto',
808 interpolation='gaussian'
809 )
811 cbar = fig.colorbar(
812 colorplot,
813 ax=ax,
814 pad=0.02
815 )
817 bounds = self.pbx.get_phase_boundaries(
818 *self.pHrange, *self.Urange, self.domains
819 )
820 for coords, _ in bounds:
821 ax.plot(coords[0], coords[1], '-', c='k', lw=1.0)
823 if labeltype == 'numbers':
824 add_numbers(ax, self.text)
825 elif labeltype == 'phases':
826 add_labels(ax, self.text)
827 elif labeltype is None:
828 pass
829 else:
830 raise ValueError("The provided label type doesn't exist")
832 if include_water:
833 add_redox_lines(ax, self.pH, self.pbx.reference, 'w')
835 ax.set_xlim(*self.pHrange)
836 ax.set_ylim(*self.Urange)
837 ax.set_xlabel('pH', fontsize=22)
838 ax.set_ylabel(r'$\it{U}$' + f' vs. {self.pbx.reference} (V)',
839 fontsize=22)
840 ax.set_xticks(np.arange(self.pHrange[0], self.pHrange[1] + 1, 2))
841 ax.set_yticks(np.arange(self.Urange[0], self.Urange[1] + 1, 1))
842 ax.xaxis.set_tick_params(width=1.5, length=5)
843 ax.yaxis.set_tick_params(width=1.5, length=5)
844 ax.tick_params(axis='both', labelsize=22)
846 ticks = np.linspace(vmin, vmax, num=5)
847 cbar.set_ticks(ticks)
848 cbar.set_ticklabels(ticks)
849 cbar.outline.set_linewidth(1.5)
850 cbar.ax.tick_params(labelsize=20, width=1.5, length=5)
851 cbar.ax.set_ylabel(cbarlabel, fontsize=20)
853 for axis in ['top', 'bottom', 'left', 'right']:
854 ax.spines[axis].set_linewidth(1.5)
856 if include_text:
857 fig.subplots_adjust(right=0.75)
858 add_phase_labels(fig, self.text, offset=0.05)
859 return ax, cbar
861 fig.tight_layout()
862 return cbar
864 def plot(self,
865 cap=1.0,
866 # figsize=[12, 6],
867 normalize=True,
868 include_text=True,
869 include_water=False,
870 labeltype='numbers',
871 cmap="RdYlGn_r",
872 filename=None,
873 ax=None,
874 show=False):
875 """Plot a complete Pourbaix diagram.
877 Keyword arguments
878 -----------------
880 Urange: list
881 The potential range onto which to draw the diagram.
883 pHrange: list
884 The pH range onto which to draw the diagram.
886 npoints: int
887 The resolution of the diagram. Higher values
888 mean higher resolution and thus higher compute times.
890 cap: float/list
891 If float, the limit (in both the positive and negative direction)
892 of the Pourbaix energy colormap.
893 If list, the first and second value determine the colormap limits.
895 figsize: list
896 The horizontal and vertical size of the graph.
898 normalize: bool
899 Normalize energies by the number of
900 atoms in the target material unit formula.
902 include_text: bool
903 Report to the right of the diagram the main products
904 associated with the stability domains.
906 include_water: bool
907 Include in the diagram the stability domain of water.
909 labeltype: str/None
910 The labeling style of the diagram domains. Options:
911 'numbers': just add numbers associated with the
912 different phases, the latter shown
913 on the right if include_text=True.
914 'phases': Write the main products directly on the diagram.
915 These labels can be dragged around if the placement
916 is unsatisfactory. Redundant if include_text=True.
917 None: Don't draw any labels.
919 filename: str/None
920 If passed as a string, the figure will be saved with that name.
922 show: bool
923 Spawn a window showing the diagram.
925 """
926 import matplotlib.pyplot as plt
928 if ax is None:
929 ax = plt.gca()
931 fig = ax.get_figure()
933 self._draw_diagram_axes(
934 cap,
935 normalize,
936 include_text,
937 include_water,
938 labeltype, cmap,
939 ax=ax)
941 if filename is not None:
942 fig.savefig(filename)
944 if show:
945 plt.show()