• 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

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

4
"""
1✔
5
This module defines tools to analyze surface and adsorption related
6
quantities as well as related plots. If you use this module, please
7
consider citing the following works::
8

9
    R. Tran, Z. Xu, B. Radhakrishnan, D. Winston, W. Sun, K. A. Persson,
10
    S. P. Ong, "Surface Energies of Elemental Crystals", Scientific
11
    Data, 2016, 3:160080, doi: 10.1038/sdata.2016.80.
12

13
    and
14

15
    Kang, S., Mo, Y., Ong, S. P., & Ceder, G. (2014). Nanoscale
16
    stabilization of sodium oxides: Implications for Na-O2 batteries.
17
    Nano Letters, 14(2), 1016-1020. https://doi.org/10.1021/nl404557w
18

19
    and
20

21
    Montoya, J. H., & Persson, K. A. (2017). A high-throughput framework
22
        for determining adsorption energies on solid surfaces. Npj
23
        Computational Materials, 3(1), 14.
24
        https://doi.org/10.1038/s41524-017-0017-z
25

26
TODO:
27
    -Still assumes individual elements have their own chempots
28
        in a molecular adsorbate instead of considering a single
29
        chempot for a single molecular adsorbate. E.g. for an OH
30
        adsorbate, the surface energy is a function of delu_O and
31
        delu_H instead of delu_OH
32
    -Need a method to automatically get chempot range when
33
        dealing with non-stoichiometric slabs
34
    -Simplify the input for SurfaceEnergyPlotter such that the
35
        user does not need to generate a dict
36
"""
37

38
from __future__ import annotations
1✔
39

40
import copy
1✔
41
import itertools
1✔
42
import random
1✔
43
import warnings
1✔
44

45
import numpy as np
1✔
46
from sympy import Symbol
1✔
47
from sympy.solvers import linsolve, solve
1✔
48

49
from pymatgen.analysis.wulff import WulffShape
1✔
50
from pymatgen.core.composition import Composition
1✔
51
from pymatgen.core.structure import Structure
1✔
52
from pymatgen.core.surface import get_slab_regions
1✔
53
from pymatgen.entries.computed_entries import ComputedStructureEntry
1✔
54
from pymatgen.io.vasp.outputs import Locpot, Outcar, Poscar
1✔
55
from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
1✔
56
from pymatgen.util.plotting import pretty_plot
1✔
57

58
EV_PER_ANG2_TO_JOULES_PER_M2 = 16.0217656
1✔
59

60
__author__ = "Richard Tran"
1✔
61
__credits__ = "Joseph Montoya, Xianguo Li"
1✔
62

63

64
class SlabEntry(ComputedStructureEntry):
1✔
65
    """
66
    A ComputedStructureEntry object encompassing all data relevant to a
67
        slab for analyzing surface thermodynamics.
68

69
    .. attribute:: miller_index
70

71
        Miller index of plane parallel to surface.
72

73
    .. attribute:: label
74

75
        Brief description for this slab.
76

77
    .. attribute:: adsorbates
78

79
        List of ComputedStructureEntry for the types of adsorbates
80

81
    ..attribute:: clean_entry
82

83
        SlabEntry for the corresponding clean slab for an adsorbed slab
84

85
    ..attribute:: ads_entries_dict
86

87
        Dictionary where the key is the reduced composition of the
88
            adsorbate entry and value is the entry itself
89
    """
90

91
    def __init__(
1✔
92
        self,
93
        structure,
94
        energy,
95
        miller_index,
96
        correction=0.0,
97
        parameters=None,
98
        data=None,
99
        entry_id=None,
100
        label=None,
101
        adsorbates=None,
102
        clean_entry=None,
103
        marker=None,
104
        color=None,
105
    ):
106
        """
107
        Make a SlabEntry containing all relevant surface thermodynamics data.
108

109
        Args:
110
            structure (Slab): The primary slab associated with this entry.
111
            energy (float): Energy from total energy calculation
112
            miller_index (tuple(h, k, l)): Miller index of plane parallel
113
                to surface
114
            correction (float): See ComputedSlabEntry
115
            parameters (dict): See ComputedSlabEntry
116
            data (dict): See ComputedSlabEntry
117
            entry_id (obj): See ComputedSlabEntry
118
            data (dict): See ComputedSlabEntry
119
            entry_id (str): See ComputedSlabEntry
120
            label (str): Any particular label for this slab, e.g. "Tasker 2",
121
                "non-stoichiometric", "reconstructed"
122
            adsorbates ([ComputedStructureEntry]): List of reference entries
123
                for the adsorbates on the slab, can be an isolated molecule
124
                (e.g. O2 for O or O2 adsorption), a bulk structure (eg. fcc
125
                Cu for Cu adsorption) or anything.
126
            clean_entry (ComputedStructureEntry): If the SlabEntry is for an
127
                adsorbed slab, this is the corresponding SlabEntry for the
128
                clean slab
129
            marker (str): Custom marker for gamma plots ("--" and "-" are typical)
130
            color (str or rgba): Custom color for gamma plots
131
        """
132
        self.miller_index = miller_index
1✔
133
        self.label = label
1✔
134
        self.adsorbates = [] if not adsorbates else adsorbates
1✔
135
        self.clean_entry = clean_entry
1✔
136
        self.ads_entries_dict = {str(list(ads.composition.as_dict())[0]): ads for ads in self.adsorbates}
1✔
137
        self.mark = marker
1✔
138
        self.color = color
1✔
139

140
        super().__init__(
1✔
141
            structure,
142
            energy,
143
            correction=correction,
144
            parameters=parameters,
145
            data=data,
146
            entry_id=entry_id,
147
        )
148

149
    def as_dict(self):
1✔
150
        """
151
        Returns dict which contains Slab Entry data.
152
        """
153
        d = {"@module": type(self).__module__, "@class": type(self).__name__}
×
154
        d["structure"] = self.structure
×
155
        d["energy"] = self.energy
×
156
        d["miller_index"] = self.miller_index
×
157
        d["label"] = self.label
×
158
        d["adsorbates"] = self.adsorbates
×
159
        d["clean_entry"] = self.clean_entry
×
160

161
        return d
×
162

163
    def gibbs_binding_energy(self, eads=False):
1✔
164
        """
165
        Returns the adsorption energy or Gibb's binding energy
166
            of an adsorbate on a surface
167
        Args:
168
            eads (bool): Whether to calculate the adsorption energy
169
                (True) or the binding energy (False) which is just
170
                adsorption energy normalized by number of adsorbates.
171
        """
172
        n = self.get_unit_primitive_area
1✔
173
        Nads = self.Nads_in_slab
1✔
174

175
        BE = (self.energy - n * self.clean_entry.energy) / Nads - sum(ads.energy_per_atom for ads in self.adsorbates)
1✔
176
        return BE * Nads if eads else BE
1✔
177

178
    def surface_energy(self, ucell_entry, ref_entries=None):
1✔
179
        """
180
        Calculates the surface energy of this SlabEntry.
181
        Args:
182
            ucell_entry (entry): An entry object for the bulk
183
            ref_entries (list: [entry]): A list of entries for each type
184
                of element to be used as a reservoir for non-stoichiometric
185
                systems. The length of this list MUST be n-1 where n is the
186
                number of different elements in the bulk entry. The chempot
187
                of the element ref_entry that is not in the list will be
188
                treated as a variable.
189

190
        Returns (Add (Sympy class)): Surface energy
191
        """
192
        # Set up
193
        ref_entries = [] if not ref_entries else ref_entries
1✔
194

195
        # Check if appropriate ref_entries are present if the slab is non-stoichiometric
196
        # TODO: There should be a way to identify which specific species are
197
        # non-stoichiometric relative to the others in systems with more than 2 species
198
        slab_comp = self.composition.as_dict()
1✔
199
        ucell_entry_comp = ucell_entry.composition.reduced_composition.as_dict()
1✔
200
        slab_clean_comp = Composition({el: slab_comp[el] for el in ucell_entry_comp})
1✔
201
        if slab_clean_comp.reduced_composition != ucell_entry.composition.reduced_composition:
1✔
202
            list_els = [list(entry.composition.as_dict())[0] for entry in ref_entries]
1✔
203
            if not any(el in list_els for el in ucell_entry.composition.as_dict()):
1✔
204
                warnings.warn("Elemental references missing for the non-dopant species.")
×
205

206
        gamma = (Symbol("E_surf") - Symbol("Ebulk")) / (2 * Symbol("A"))
1✔
207
        ucell_comp = ucell_entry.composition
1✔
208
        ucell_reduced_comp = ucell_comp.reduced_composition
1✔
209
        ref_entries_dict = {str(list(ref.composition.as_dict())[0]): ref for ref in ref_entries}
1✔
210
        ref_entries_dict.update(self.ads_entries_dict)
1✔
211

212
        # Calculate Gibbs free energy of the bulk per unit formula
213
        gbulk = ucell_entry.energy / ucell_comp.get_integer_formula_and_factor()[1]
1✔
214

215
        # First we get the contribution to the bulk energy
216
        # from each element with an existing ref_entry.
217
        bulk_energy, gbulk_eqn = 0, 0
1✔
218
        for el, ref in ref_entries_dict.items():
1✔
219
            N, delu = self.composition.as_dict()[el], Symbol("delu_" + str(el))
1✔
220
            if el in ucell_comp.as_dict():
1✔
221
                gbulk_eqn += ucell_reduced_comp[el] * (delu + ref.energy_per_atom)
1✔
222
            bulk_energy += N * (Symbol("delu_" + el) + ref.energy_per_atom)
1✔
223

224
        # Next, we add the contribution to the bulk energy from
225
        # the variable element (the element without a ref_entry),
226
        # as a function of the other elements
227
        for ref_el in ucell_comp.as_dict():
1✔
228
            if str(ref_el) not in ref_entries_dict:
1✔
229
                break
1✔
230
        refEperA = (gbulk - gbulk_eqn) / ucell_reduced_comp.as_dict()[ref_el]
1✔
231
        bulk_energy += self.composition.as_dict()[ref_el] * refEperA
1✔
232
        se = gamma.subs(
1✔
233
            {
234
                Symbol("E_surf"): self.energy,
235
                Symbol("Ebulk"): bulk_energy,
236
                Symbol("A"): self.surface_area,
237
            }
238
        )
239

240
        return float(se) if type(se).__name__ == "Float" else se
1✔
241

242
    @property
1✔
243
    def get_unit_primitive_area(self):
1✔
244
        """
245
        Returns the surface area of the adsorbed system per
246
        unit area of the primitive slab system.
247
        """
248
        A_ads = self.surface_area
1✔
249
        A_clean = self.clean_entry.surface_area
1✔
250
        n = A_ads / A_clean
1✔
251
        return n
1✔
252

253
    @property
1✔
254
    def get_monolayer(self):
1✔
255
        """
256
        Returns the primitive unit surface area density of the
257
            adsorbate.
258
        """
259
        unit_a = self.get_unit_primitive_area
1✔
260
        Nsurfs = self.Nsurfs_ads_in_slab
1✔
261
        Nads = self.Nads_in_slab
1✔
262
        return Nads / (unit_a * Nsurfs)
1✔
263

264
    @property
1✔
265
    def Nads_in_slab(self):
1✔
266
        """
267
        Returns the TOTAL number of adsorbates in the slab on BOTH sides
268
        """
269
        return sum(self.composition.as_dict()[a] for a in self.ads_entries_dict)
1✔
270

271
    @property
1✔
272
    def Nsurfs_ads_in_slab(self):
1✔
273
        """
274
        Returns the TOTAL number of adsorbed surfaces in the slab
275
        """
276
        struct = self.structure
1✔
277
        weights = [s.species.weight for s in struct]
1✔
278
        center_of_mass = np.average(struct.frac_coords, weights=weights, axis=0)
1✔
279

280
        Nsurfs = 0
1✔
281
        # Are there adsorbates on top surface?
282
        if any(
1✔
283
            site.species_string in self.ads_entries_dict for site in struct if site.frac_coords[2] > center_of_mass[2]
284
        ):
285
            Nsurfs += 1
1✔
286
        # Are there adsorbates on bottom surface?
287
        if any(
1✔
288
            site.species_string in self.ads_entries_dict for site in struct if site.frac_coords[2] < center_of_mass[2]
289
        ):
290
            Nsurfs += 1
1✔
291

292
        return Nsurfs
1✔
293

294
    @classmethod
1✔
295
    def from_dict(cls, d):
1✔
296
        """
297
        Returns a SlabEntry by reading in an dictionary
298
        """
299
        structure = SlabEntry.from_dict(d["structure"])
×
300
        energy = SlabEntry.from_dict(d["energy"])
×
301
        miller_index = d["miller_index"]
×
302
        label = d["label"]
×
303
        adsorbates = d["adsorbates"]
×
304
        clean_entry = d["clean_entry"]
×
305

306
        return cls(
×
307
            structure,
308
            energy,
309
            miller_index,
310
            label=label,
311
            adsorbates=adsorbates,
312
            clean_entry=clean_entry,
313
        )
314

315
    @property
1✔
316
    def surface_area(self):
1✔
317
        """
318
        Calculates the surface area of the slab
319
        """
320
        m = self.structure.lattice.matrix
1✔
321
        return np.linalg.norm(np.cross(m[0], m[1]))
1✔
322

323
    @property
1✔
324
    def cleaned_up_slab(self):
1✔
325
        """
326
        Returns a slab with the adsorbates removed
327
        """
328
        ads_strs = list(self.ads_entries_dict)
1✔
329
        cleaned = self.structure.copy()
1✔
330
        cleaned.remove_species(ads_strs)
1✔
331
        return cleaned
1✔
332

333
    @property
1✔
334
    def create_slab_label(self):
1✔
335
        """
336
        Returns a label (str) for this particular slab based
337
            on composition, coverage and Miller index.
338
        """
339
        if "label" in self.data:
1✔
340
            return self.data["label"]
×
341

342
        label = str(self.miller_index)
1✔
343
        ads_strs = list(self.ads_entries_dict)
1✔
344

345
        cleaned = self.cleaned_up_slab
1✔
346
        label += f" {cleaned.composition.reduced_composition}"
1✔
347

348
        if self.adsorbates:
1✔
349
            for ads in ads_strs:
1✔
350
                label += f"+{ads}"
1✔
351
            label += f", {self.get_monolayer:.3f} ML"
1✔
352
        return label
1✔
353

354
    @staticmethod
1✔
355
    def from_computed_structure_entry(entry, miller_index, label=None, adsorbates=None, clean_entry=None, **kwargs):
1✔
356
        """
357
        Returns SlabEntry from a ComputedStructureEntry
358
        """
359
        return SlabEntry(
×
360
            entry.structure,
361
            entry.energy,
362
            miller_index,
363
            label=label,
364
            adsorbates=adsorbates,
365
            clean_entry=clean_entry,
366
            **kwargs,
367
        )
368

369

370
class SurfaceEnergyPlotter:
1✔
371
    """
372
    A class used for generating plots to analyze the thermodynamics of surfaces
373
        of a material. Produces stability maps of different slab configurations,
374
        phases diagrams of two parameters to determine stability of configurations
375
        (future release), and Wulff shapes.
376

377
    .. attribute:: all_slab_entries
378

379
        Either a list of SlabEntry objects (note for a list, the SlabEntry must
380
            have the adsorbates and clean_entry parameter pulgged in) or a Nested
381
            dictionary containing a list of entries for slab calculations as
382
            items and the corresponding Miller index of the slab as the key.
383
            To account for adsorption, each value is a sub-dictionary with the
384
            entry of a clean slab calculation as the sub-key and a list of
385
            entries for adsorption calculations as the sub-value. The sub-value
386
            can contain different adsorption configurations such as a different
387
            site or a different coverage, however, ordinarily only the most stable
388
            configuration for a particular coverage will be considered as the
389
            function of the adsorbed surface energy has an intercept dependent on
390
            the adsorption energy (ie an adsorption site with a higher adsorption
391
            energy will always provide a higher surface energy than a site with a
392
            lower adsorption energy). An example parameter is provided:
393
            {(h1,k1,l1): {clean_entry1: [ads_entry1, ads_entry2, ...],
394
                          clean_entry2: [...], ...}, (h2,k2,l2): {...}}
395
            where clean_entry1 can be a pristine surface and clean_entry2 can be a
396
            reconstructed surface while ads_entry1 can be adsorption at site 1 with
397
            a 2x2 coverage while ads_entry2 can have a 3x3 coverage. If adsorption
398
            entries are present (i.e. if all_slab_entries[(h,k,l)][clean_entry1]), we
399
            consider adsorption in all plots and analysis for this particular facet.
400

401
    ..attribute:: color_dict
402

403
        Dictionary of colors (r,g,b,a) when plotting surface energy stability. The
404
            keys are individual surface entries where clean surfaces have a solid
405
            color while the corresponding adsorbed surface will be transparent.
406

407
    .. attribute:: ucell_entry
408

409
        ComputedStructureEntry of the bulk reference for this particular material.
410

411
    .. attribute:: ref_entries
412

413
        List of ComputedStructureEntries to be used for calculating chemical potential.
414

415
    .. attribute:: color_dict
416

417
        Randomly generated dictionary of colors associated with each facet.
418
    """
419

420
    def __init__(self, all_slab_entries, ucell_entry, ref_entries=None):
1✔
421
        """
422
        Object for plotting surface energy in different ways for clean and
423
            adsorbed surfaces.
424
        Args:
425
            all_slab_entries (dict or list): Dictionary or list containing
426
                all entries for slab calculations. See attributes.
427
            ucell_entry (ComputedStructureEntry): ComputedStructureEntry
428
                of the bulk reference for this particular material.
429
            ref_entries ([ComputedStructureEntries]): A list of entries for
430
                each type of element to be used as a reservoir for
431
                non-stoichiometric systems. The length of this list MUST be
432
                n-1 where n is the number of different elements in the bulk
433
                entry. The bulk energy term in the grand surface potential can
434
                be defined by a summation of the chemical potentials for each
435
                element in the system. As the bulk energy is already provided,
436
                one can solve for one of the chemical potentials as a function
437
                of the other chemical potetinals and bulk energy. i.e. there
438
                are n-1 variables (chempots). e.g. if your ucell_entry is for
439
                LiFePO4 than your ref_entries should have an entry for Li, Fe,
440
                and P if you want to use the chempot of O as the variable.
441
        """
442
        self.ucell_entry = ucell_entry
1✔
443
        self.ref_entries = ref_entries
1✔
444
        self.all_slab_entries = (
1✔
445
            all_slab_entries if type(all_slab_entries).__name__ == "dict" else entry_dict_from_list(all_slab_entries)
446
        )
447
        self.color_dict = self.color_palette_dict()
1✔
448

449
        se_dict, as_coeffs_dict = {}, {}
1✔
450
        for hkl in self.all_slab_entries:
1✔
451
            for clean in self.all_slab_entries[hkl]:
1✔
452
                se = clean.surface_energy(self.ucell_entry, ref_entries=self.ref_entries)
1✔
453
                if type(se).__name__ == "float":
1✔
454
                    se_dict[clean] = se
1✔
455
                    as_coeffs_dict[clean] = {1: se}
1✔
456
                else:
457
                    se_dict[clean] = se
×
458
                    as_coeffs_dict[clean] = se.as_coefficients_dict()
×
459
                for dope in self.all_slab_entries[hkl][clean]:
1✔
460
                    se = dope.surface_energy(self.ucell_entry, ref_entries=self.ref_entries)
1✔
461
                    if type(se).__name__ == "float":
1✔
462
                        se_dict[dope] = se
×
463
                        as_coeffs_dict[dope] = {1: se}
×
464
                    else:
465
                        se_dict[dope] = se
1✔
466
                        as_coeffs_dict[dope] = se.as_coefficients_dict()
1✔
467
        self.surfe_dict = se_dict
1✔
468
        self.as_coeffs_dict = as_coeffs_dict
1✔
469

470
        list_of_chempots = []
1✔
471
        for v in self.as_coeffs_dict.values():
1✔
472
            if type(v).__name__ == "float":
1✔
473
                continue
×
474
            for du in v:
1✔
475
                if du not in list_of_chempots:
1✔
476
                    list_of_chempots.append(du)
1✔
477
        self.list_of_chempots = list_of_chempots
1✔
478

479
    def get_stable_entry_at_u(
1✔
480
        self,
481
        miller_index,
482
        delu_dict=None,
483
        delu_default=0,
484
        no_doped=False,
485
        no_clean=False,
486
    ):
487
        """
488
        Returns the entry corresponding to the most stable slab for a particular
489
            facet at a specific chempot. We assume that surface energy is constant
490
            so all free variables must be set with delu_dict, otherwise they are
491
            assumed to be equal to delu_default.
492

493
        Args:
494
            miller_index ((h,k,l)): The facet to find the most stable slab in
495
            delu_dict (dict): Dictionary of the chemical potentials to be set as
496
                constant. Note the key should be a sympy Symbol object of the
497
                format: Symbol("delu_el") where el is the name of the element.
498
            delu_default (float): Default value for all unset chemical potentials
499
            no_doped (bool): Consider stability of clean slabs only.
500
            no_clean (bool): Consider stability of doped slabs only.
501

502
        Returns:
503
            SlabEntry, surface_energy (float)
504
        """
505
        all_delu_dict = self.set_all_variables(delu_dict, delu_default)
1✔
506

507
        def get_coeffs(e):
1✔
508
            coeffs = []
1✔
509
            for du in all_delu_dict:
1✔
510
                if type(self.as_coeffs_dict[e]).__name__ == "float":
1✔
511
                    coeffs.append(self.as_coeffs_dict[e])
×
512
                elif du in self.as_coeffs_dict[e]:
1✔
513
                    coeffs.append(self.as_coeffs_dict[e][du])
1✔
514
                else:
515
                    coeffs.append(0)
1✔
516
            return np.array(coeffs)
1✔
517

518
        all_entries, all_coeffs = [], []
1✔
519
        for entry in self.all_slab_entries[miller_index]:
1✔
520
            if not no_clean:
1✔
521
                all_entries.append(entry)
1✔
522
                all_coeffs.append(get_coeffs(entry))
1✔
523
            if not no_doped:
1✔
524
                for ads_entry in self.all_slab_entries[miller_index][entry]:
1✔
525
                    all_entries.append(ads_entry)
1✔
526
                    all_coeffs.append(get_coeffs(ads_entry))
1✔
527

528
        du_vals = np.array(list(all_delu_dict.values()))
1✔
529
        all_gamma = list(np.dot(all_coeffs, du_vals.T))
1✔
530

531
        return all_entries[all_gamma.index(min(all_gamma))], float(min(all_gamma))
1✔
532

533
    def wulff_from_chempot(
1✔
534
        self,
535
        delu_dict=None,
536
        delu_default=0,
537
        symprec=1e-5,
538
        no_clean=False,
539
        no_doped=False,
540
    ):
541
        """
542
        Method to get the Wulff shape at a specific chemical potential.
543

544
        Args:
545
            delu_dict (dict): Dictionary of the chemical potentials to be set as
546
                constant. Note the key should be a sympy Symbol object of the
547
                format: Symbol("delu_el") where el is the name of the element.
548
            delu_default (float): Default value for all unset chemical potentials
549
            symprec (float): See WulffShape.
550
            no_doped (bool): Consider stability of clean slabs only.
551
            no_clean (bool): Consider stability of doped slabs only.
552

553
        Returns:
554
            (WulffShape): The WulffShape at u_ref and u_ads.
555
        """
556
        latt = SpacegroupAnalyzer(self.ucell_entry.structure).get_conventional_standard_structure().lattice
1✔
557

558
        miller_list = list(self.all_slab_entries)
1✔
559
        e_surf_list = []
1✔
560
        for hkl in miller_list:
1✔
561
            # For all configurations, calculate surface energy as a
562
            # function of u. Use the lowest surface energy (corresponds
563
            # to the most stable slab termination at that particular u)
564
            gamma = self.get_stable_entry_at_u(
1✔
565
                hkl,
566
                delu_dict=delu_dict,
567
                delu_default=delu_default,
568
                no_clean=no_clean,
569
                no_doped=no_doped,
570
            )[1]
571
            e_surf_list.append(gamma)
1✔
572

573
        return WulffShape(latt, miller_list, e_surf_list, symprec=symprec)
1✔
574

575
    def area_frac_vs_chempot_plot(
1✔
576
        self,
577
        ref_delu,
578
        chempot_range,
579
        delu_dict=None,
580
        delu_default=0,
581
        increments=10,
582
        no_clean=False,
583
        no_doped=False,
584
    ):
585
        """
586
        1D plot. Plots the change in the area contribution
587
        of each facet as a function of chemical potential.
588

589
        Args:
590
            ref_delu (sympy Symbol): The free variable chempot with the format:
591
                Symbol("delu_el") where el is the name of the element.
592
            chempot_range (list): Min/max range of chemical potential to plot along
593
            delu_dict (dict): Dictionary of the chemical potentials to be set as
594
                constant. Note the key should be a sympy Symbol object of the
595
                format: Symbol("delu_el") where el is the name of the element.
596
            delu_default (float): Default value for all unset chemical potentials
597
            increments (int): Number of data points between min/max or point
598
                of intersection. Defaults to 10 points.
599

600
        Returns:
601
            (Pylab): Plot of area frac on the Wulff shape
602
                for each facet vs chemical potential.
603
        """
604
        delu_dict = delu_dict or {}
×
605
        chempot_range = sorted(chempot_range)
×
606
        all_chempots = np.linspace(min(chempot_range), max(chempot_range), increments)
×
607

608
        # initialize a dictionary of lists of fractional areas for each hkl
609
        hkl_area_dict = {}
×
610
        for hkl in self.all_slab_entries:
×
611
            hkl_area_dict[hkl] = []
×
612

613
        # Get plot points for each Miller index
614
        for u in all_chempots:
×
615
            delu_dict[ref_delu] = u
×
616
            wulffshape = self.wulff_from_chempot(
×
617
                delu_dict=delu_dict,
618
                no_clean=no_clean,
619
                no_doped=no_doped,
620
                delu_default=delu_default,
621
            )
622

623
            for hkl in wulffshape.area_fraction_dict:
×
624
                hkl_area_dict[hkl].append(wulffshape.area_fraction_dict[hkl])
×
625

626
        # Plot the area fraction vs chemical potential for each facet
627
        plt = pretty_plot(width=8, height=7)
×
628
        axes = plt.gca()
×
629

630
        for hkl in self.all_slab_entries:
×
631
            clean_entry = list(self.all_slab_entries[hkl])[0]
×
632
            # Ignore any facets that never show up on the
633
            # Wulff shape regardless of chemical potential
634
            if all(a == 0 for a in hkl_area_dict[hkl]):
×
635
                continue
×
636
            plt.plot(
×
637
                all_chempots,
638
                hkl_area_dict[hkl],
639
                "--",
640
                color=self.color_dict[clean_entry],
641
                label=str(hkl),
642
            )
643

644
        # Make the figure look nice
645
        plt.ylabel(r"Fractional area $A^{Wulff}_{hkl}/A^{Wulff}$")
×
646
        self.chempot_plot_addons(
×
647
            plt,
648
            chempot_range,
649
            str(ref_delu).split("_")[1],
650
            axes,
651
            rect=[-0.0, 0, 0.95, 1],
652
            pad=5,
653
            ylim=[0, 1],
654
        )
655

656
        return plt
×
657

658
    def get_surface_equilibrium(self, slab_entries, delu_dict=None):
1✔
659
        """
660
        Takes in a list of SlabEntries and calculates the chemical potentials
661
            at which all slabs in the list coexists simultaneously. Useful for
662
            building surface phase diagrams. Note that to solve for x equations
663
            (x slab_entries), there must be x free variables (chemical potentials).
664
            Adjust delu_dict as need be to get the correct number of free variables.
665
        Args:
666
            slab_entries (array): The coefficients of the first equation
667
            delu_dict (dict): Dictionary of the chemical potentials to be set as
668
                constant. Note the key should be a sympy Symbol object of the
669
                format: Symbol("delu_el") where el is the name of the element.
670

671
        Returns:
672
            (array): Array containing a solution to x equations with x
673
                variables (x-1 chemical potential and 1 surface energy)
674
        """
675
        # Generate all possible coefficients
676
        all_parameters = []
1✔
677
        all_eqns = []
1✔
678
        for slab_entry in slab_entries:
1✔
679
            se = self.surfe_dict[slab_entry]
1✔
680

681
            # remove the free chempots we wish to keep constant and
682
            # set the equation to 0 (subtract gamma from both sides)
683
            if type(se).__name__ == "float":
1✔
684
                all_eqns.append(se - Symbol("gamma"))
1✔
685
            else:
686
                se = sub_chempots(se, delu_dict) if delu_dict else se
1✔
687
                all_eqns.append(se - Symbol("gamma"))
1✔
688
                all_parameters.extend([p for p in list(se.free_symbols) if p not in all_parameters])
1✔
689

690
        all_parameters.append(Symbol("gamma"))
1✔
691
        # Now solve the system of linear eqns to find the chempot
692
        # where the slabs are at equilibrium with each other
693

694
        soln = linsolve(all_eqns, all_parameters)
1✔
695
        if not soln:
1✔
696
            warnings.warn("No solution")
1✔
697
            return soln
1✔
698
        return {p: list(soln)[0][i] for i, p in enumerate(all_parameters)}
1✔
699

700
    def stable_u_range_dict(
1✔
701
        self,
702
        chempot_range,
703
        ref_delu,
704
        no_doped=True,
705
        no_clean=False,
706
        delu_dict=None,
707
        miller_index=(),
708
        dmu_at_0=False,
709
        return_se_dict=False,
710
    ):
711
        """
712
        Creates a dictionary where each entry is a key pointing to a
713
        chemical potential range where the surface of that entry is stable.
714
        Does so by enumerating through all possible solutions (intersect)
715
        for surface energies of a specific facet.
716

717
        Args:
718
            chempot_range ([max_chempot, min_chempot]): Range to consider the
719
                stability of the slabs.
720
            ref_delu (sympy Symbol): The range stability of each slab is based
721
                on the chempot range of this chempot. Should be a sympy Symbol
722
                object of the format: Symbol("delu_el") where el is the name of
723
                the element
724
            no_doped (bool): Consider stability of clean slabs only.
725
            no_clean (bool): Consider stability of doped slabs only.
726
            delu_dict (dict): Dictionary of the chemical potentials to be set as
727
                constant. Note the key should be a sympy Symbol object of the
728
                format: Symbol("delu_el") where el is the name of the element.
729
            miller_index (list): Miller index for a specific facet to get a
730
                dictionary for.
731
            dmu_at_0 (bool): If True, if the surface energies corresponding to
732
                the chemical potential range is between a negative and positive
733
                value, the value is a list of three chemical potentials with the
734
                one in the center corresponding a surface energy of 0. Uselful
735
                in identifying unphysical ranges of surface energies and their
736
                chemical potential range.
737
            return_se_dict (bool): Whether or not to return the corresponding
738
                dictionary of surface energies
739
        """
740
        if delu_dict is None:
1✔
741
            delu_dict = {}
1✔
742
        chempot_range = sorted(chempot_range)
1✔
743
        stable_urange_dict, se_dict = {}, {}
1✔
744

745
        # Get all entries for a specific facet
746
        for hkl in self.all_slab_entries:
1✔
747
            entries_in_hkl = []
1✔
748
            # Skip this facet if this is not the facet we want
749
            if miller_index and hkl != tuple(miller_index):
1✔
750
                continue
×
751
            if not no_clean:
1✔
752
                entries_in_hkl.extend(self.all_slab_entries[hkl])
1✔
753
            if not no_doped:
1✔
754
                for entry in self.all_slab_entries[hkl]:
1✔
755
                    entries_in_hkl.extend(self.all_slab_entries[hkl][entry])
1✔
756

757
            for entry in entries_in_hkl:
1✔
758
                stable_urange_dict[entry] = []
1✔
759
                se_dict[entry] = []
1✔
760
            # if there is only one entry for this facet, then just give it the
761
            # default urange, you can't make combinations with just 1 item
762
            if len(entries_in_hkl) == 1:
1✔
763
                stable_urange_dict[entries_in_hkl[0]] = chempot_range
×
764
                u1, u2 = delu_dict.copy(), delu_dict.copy()
×
765
                u1[ref_delu], u2[ref_delu] = chempot_range[0], chempot_range[1]
×
766
                se = self.as_coeffs_dict[entries_in_hkl[0]]
×
767
                se_dict[entries_in_hkl[0]] = [
×
768
                    sub_chempots(se, u1),
769
                    sub_chempots(se, u2),
770
                ]
771
                continue
×
772

773
            for pair in itertools.combinations(entries_in_hkl, 2):
1✔
774
                # I'm assuming ref_delu was not set in delu_dict,
775
                # so the solution should be for ref_delu
776
                solution = self.get_surface_equilibrium(pair, delu_dict=delu_dict)
1✔
777

778
                # Check if this solution is stable
779
                if not solution:
1✔
780
                    continue
×
781
                new_delu_dict = delu_dict.copy()
1✔
782
                new_delu_dict[ref_delu] = solution[ref_delu]
1✔
783
                stable_entry, gamma = self.get_stable_entry_at_u(
1✔
784
                    hkl, new_delu_dict, no_doped=no_doped, no_clean=no_clean
785
                )
786
                if stable_entry not in pair:
1✔
787
                    continue
×
788

789
                # Now check if the solution is within the chempot range
790
                if not chempot_range[0] <= solution[ref_delu] <= chempot_range[1]:
1✔
791
                    continue
1✔
792

793
                for entry in pair:
×
794
                    stable_urange_dict[entry].append(solution[ref_delu])
×
795
                    se_dict[entry].append(gamma)
×
796

797
            # Now check if all entries have 2 chempot values. If only
798
            # one, we need to set the other value as either the upper
799
            # limit or lower limit of the user provided chempot_range
800
            new_delu_dict = delu_dict.copy()
1✔
801
            for u in chempot_range:
1✔
802
                new_delu_dict[ref_delu] = u
1✔
803
                entry, gamma = self.get_stable_entry_at_u(
1✔
804
                    hkl, delu_dict=new_delu_dict, no_doped=no_doped, no_clean=no_clean
805
                )
806
                stable_urange_dict[entry].append(u)
1✔
807
                se_dict[entry].append(gamma)
1✔
808

809
        if dmu_at_0:
1✔
810
            for entry, v in se_dict.items():
×
811
                # if se are of opposite sign, determine chempot when se=0.
812
                # Useful for finding a chempot range where se is unphysical
813
                if not stable_urange_dict[entry]:
×
814
                    continue
×
815
                if v[0] * v[1] < 0:
×
816
                    # solve for gamma=0
817
                    se = self.as_coeffs_dict[entry]
×
818
                    v.append(0)
×
819
                    stable_urange_dict[entry].append(solve(sub_chempots(se, delu_dict), ref_delu)[0])
×
820

821
        # sort the chempot ranges for each facet
822
        for entry, v in stable_urange_dict.items():
1✔
823
            se_dict[entry] = [se for i, se in sorted(zip(v, se_dict[entry]))]
1✔
824
            stable_urange_dict[entry] = sorted(v)
1✔
825

826
        if return_se_dict:
1✔
827
            return stable_urange_dict, se_dict
×
828
        return stable_urange_dict
1✔
829

830
    def color_palette_dict(self, alpha=0.35):
1✔
831
        """
832
        Helper function to assign each facet a unique color using a dictionary.
833

834
        Args:
835
            alpha (float): Degree of transparency
836

837
        return (dict): Dictionary of colors (r,g,b,a) when plotting surface
838
            energy stability. The keys are individual surface entries where
839
            clean surfaces have a solid color while the corresponding adsorbed
840
            surface will be transparent.
841
        """
842
        color_dict = {}
1✔
843
        for hkl in self.all_slab_entries:
1✔
844
            rgb_indices = [0, 1, 2]
1✔
845
            color = [0, 0, 0, 1]
1✔
846
            random.shuffle(rgb_indices)
1✔
847
            for i, ind in enumerate(rgb_indices):
1✔
848
                if i == 2:
1✔
849
                    break
1✔
850
                color[ind] = np.random.uniform(0, 1)
1✔
851

852
            # Get the clean (solid) colors first
853
            clean_list = np.linspace(0, 1, len(self.all_slab_entries[hkl]))
1✔
854
            for i, clean in enumerate(self.all_slab_entries[hkl]):
1✔
855
                c = copy.copy(color)
1✔
856
                c[rgb_indices[2]] = clean_list[i]
1✔
857
                color_dict[clean] = c
1✔
858

859
                # Now get the adsorbed (transparent) colors
860
                for ads_entry in self.all_slab_entries[hkl][clean]:
1✔
861
                    c_ads = copy.copy(c)
1✔
862
                    c_ads[3] = alpha
1✔
863
                    color_dict[ads_entry] = c_ads
1✔
864

865
        return color_dict
1✔
866

867
    def chempot_vs_gamma_plot_one(
1✔
868
        self,
869
        plt,
870
        entry,
871
        ref_delu,
872
        chempot_range,
873
        delu_dict=None,
874
        delu_default=0,
875
        label="",
876
        JPERM2=False,
877
    ):
878
        """
879
        Helper function to  help plot the surface energy of a
880
        single SlabEntry as a function of chemical potential.
881

882
        Args:
883
            plt (Plot): A plot.
884
            entry (SlabEntry): Entry of the slab whose surface energy we want
885
                to plot
886
            ref_delu (sympy Symbol): The range stability of each slab is based
887
                on the chempot range of this chempot. Should be a sympy Symbol
888
                object of the format: Symbol("delu_el") where el is the name of
889
                the element
890
            chempot_range ([max_chempot, min_chempot]): Range to consider the
891
                stability of the slabs.
892
            delu_dict (dict): Dictionary of the chemical potentials to be set as
893
                constant. Note the key should be a sympy Symbol object of the
894
                format: Symbol("delu_el") where el is the name of the element.
895
            delu_default (float): Default value for all unset chemical potentials
896
            label (str): Label of the slab for the legend.
897
            JPERM2 (bool): Whether to plot surface energy in /m^2 (True) or
898
                eV/A^2 (False)
899

900
        Returns:
901
            (Plot): Plot of surface energy vs chemical potential for one entry.
902
        """
903
        if delu_dict is None:
×
904
            delu_dict = {}
×
905
        chempot_range = sorted(chempot_range)
×
906

907
        # use dashed lines for slabs that are not stoichiometric
908
        # wrt bulk. Label with formula if non-stoichiometric
909
        ucell_comp = self.ucell_entry.composition.reduced_composition
×
910
        if entry.adsorbates:
×
911
            s = entry.cleaned_up_slab
×
912
            clean_comp = s.composition.reduced_composition
×
913
        else:
914
            clean_comp = entry.composition.reduced_composition
×
915

916
        mark = "--" if ucell_comp != clean_comp else "-"
×
917

918
        delu_dict = self.set_all_variables(delu_dict, delu_default)
×
919
        delu_dict[ref_delu] = chempot_range[0]
×
920
        gamma_min = self.as_coeffs_dict[entry]
×
921
        gamma_min = gamma_min if type(gamma_min).__name__ == "float" else sub_chempots(gamma_min, delu_dict)
×
922
        delu_dict[ref_delu] = chempot_range[1]
×
923
        gamma_max = self.as_coeffs_dict[entry]
×
924
        gamma_max = gamma_max if type(gamma_max).__name__ == "float" else sub_chempots(gamma_max, delu_dict)
×
925
        gamma_range = [gamma_min, gamma_max]
×
926

927
        se_range = np.array(gamma_range) * EV_PER_ANG2_TO_JOULES_PER_M2 if JPERM2 else gamma_range
×
928

929
        mark = entry.mark if entry.mark else mark
×
930
        c = entry.color if entry.color else self.color_dict[entry]
×
931
        plt.plot(chempot_range, se_range, mark, color=c, label=label)
×
932

933
        return plt
×
934

935
    def chempot_vs_gamma(
1✔
936
        self,
937
        ref_delu,
938
        chempot_range,
939
        miller_index=(),
940
        delu_dict=None,
941
        delu_default=0,
942
        JPERM2=False,
943
        show_unstable=False,
944
        ylim=None,
945
        plt=None,
946
        no_clean=False,
947
        no_doped=False,
948
        use_entry_labels=False,
949
        no_label=False,
950
    ):
951
        """
952
        Plots the surface energy as a function of chemical potential.
953
            Each facet will be associated with its own distinct colors.
954
            Dashed lines will represent stoichiometries different from that
955
            of the mpid's compound. Transparent lines indicates adsorption.
956

957
        Args:
958
            ref_delu (sympy Symbol): The range stability of each slab is based
959
                on the chempot range of this chempot. Should be a sympy Symbol
960
                object of the format: Symbol("delu_el") where el is the name of
961
                the element
962
            chempot_range ([max_chempot, min_chempot]): Range to consider the
963
                stability of the slabs.
964
            miller_index (list): Miller index for a specific facet to get a
965
                dictionary for.
966
            delu_dict (dict): Dictionary of the chemical potentials to be set as
967
                constant. Note the key should be a sympy Symbol object of the
968
                format: Symbol("delu_el") where el is the name of the element.
969
            delu_default (float): Default value for all unset chemical potentials
970
            JPERM2 (bool): Whether to plot surface energy in /m^2 (True) or
971
                eV/A^2 (False)
972
            show_unstable (bool): Whether or not to show parts of the surface
973
                energy plot outside the region of stability.
974
            ylim ([ymax, ymin]): Range of y axis
975
            no_doped (bool): Whether to plot for the clean slabs only.
976
            no_clean (bool): Whether to plot for the doped slabs only.
977
            use_entry_labels (bool): If True, will label each slab configuration
978
                according to their given label in the SlabEntry object.
979
            no_label (bool): Option to turn off labels.
980

981
        Returns:
982
            (Plot): Plot of surface energy vs chempot for all entries.
983
        """
984
        if delu_dict is None:
×
985
            delu_dict = {}
×
986
        chempot_range = sorted(chempot_range)
×
987

988
        plt = pretty_plot(width=8, height=7) if not plt else plt
×
989
        axes = plt.gca()
×
990

991
        for hkl in self.all_slab_entries:
×
992
            if miller_index and hkl != tuple(miller_index):
×
993
                continue
×
994
            # Get the chempot range of each surface if we only
995
            # want to show the region where each slab is stable
996
            if not show_unstable:
×
997
                stable_u_range_dict = self.stable_u_range_dict(
×
998
                    chempot_range, ref_delu, no_doped=no_doped, delu_dict=delu_dict, miller_index=hkl
999
                )
1000

1001
            already_labelled = []
×
1002
            label = ""
×
1003
            for clean_entry in self.all_slab_entries[hkl]:
×
1004
                urange = stable_u_range_dict[clean_entry] if not show_unstable else chempot_range
×
1005
                # Don't plot if the slab is unstable, plot if it is.
1006
                if urange != []:
×
1007
                    label = clean_entry.label
×
1008
                    if label in already_labelled:
×
1009
                        label = None
×
1010
                    else:
1011
                        already_labelled.append(label)
×
1012
                    if not no_clean:
×
1013
                        if use_entry_labels:
×
1014
                            label = clean_entry.label
×
1015
                        if no_label:
×
1016
                            label = ""
×
1017
                        plt = self.chempot_vs_gamma_plot_one(
×
1018
                            plt,
1019
                            clean_entry,
1020
                            ref_delu,
1021
                            urange,
1022
                            delu_dict=delu_dict,
1023
                            delu_default=delu_default,
1024
                            label=label,
1025
                            JPERM2=JPERM2,
1026
                        )
1027
                if not no_doped:
×
1028
                    for ads_entry in self.all_slab_entries[hkl][clean_entry]:
×
1029
                        # Plot the adsorbed slabs
1030
                        # Generate a label for the type of slab
1031
                        urange = stable_u_range_dict[ads_entry] if not show_unstable else chempot_range
×
1032
                        if urange != []:
×
1033
                            if use_entry_labels:
×
1034
                                label = ads_entry.label
×
1035
                            if no_label:
×
1036
                                label = ""
×
1037
                            plt = self.chempot_vs_gamma_plot_one(
×
1038
                                plt,
1039
                                ads_entry,
1040
                                ref_delu,
1041
                                urange,
1042
                                delu_dict=delu_dict,
1043
                                delu_default=delu_default,
1044
                                label=label,
1045
                                JPERM2=JPERM2,
1046
                            )
1047

1048
        # Make the figure look nice
1049
        plt.ylabel(r"Surface energy (J/$m^{2}$)") if JPERM2 else plt.ylabel(r"Surface energy (eV/$\AA^{2}$)")
×
1050
        plt = self.chempot_plot_addons(plt, chempot_range, str(ref_delu).split("_")[1], axes, ylim=ylim)
×
1051

1052
        return plt
×
1053

1054
    def monolayer_vs_BE(self, plot_eads=False):
1✔
1055
        """
1056
        Plots the binding energy as a function of monolayers (ML), i.e.
1057
            the fractional area adsorbate density for all facets. For each
1058
            facet at a specific monlayer, only plot the lowest binding energy.
1059

1060
        Args:
1061
            plot_eads (bool): Option to plot the adsorption energy (binding
1062
                 energy multiplied by number of adsorbates) instead.
1063

1064
        Returns:
1065
            (Plot): Plot of binding energy vs monolayer for all facets.
1066
        """
1067
        plt = pretty_plot(width=8, height=7)
×
1068
        for hkl in self.all_slab_entries:
×
1069
            ml_be_dict = {}
×
1070
            for clean_entry in self.all_slab_entries[hkl]:
×
1071
                if self.all_slab_entries[hkl][clean_entry]:
×
1072
                    for ads_entry in self.all_slab_entries[hkl][clean_entry]:
×
1073
                        if ads_entry.get_monolayer not in ml_be_dict:
×
1074
                            ml_be_dict[ads_entry.get_monolayer] = 1000
×
1075
                        be = ads_entry.gibbs_binding_energy(eads=plot_eads)
×
1076
                        if be < ml_be_dict[ads_entry.get_monolayer]:
×
1077
                            ml_be_dict[ads_entry.get_monolayer] = be
×
1078
            # sort the binding energies and monolayers
1079
            # in order to properly draw a line plot
1080
            vals = sorted(ml_be_dict.items())
×
1081
            monolayers, BEs = zip(*vals)
×
1082
            plt.plot(monolayers, BEs, "-o", c=self.color_dict[clean_entry], label=hkl)
×
1083

1084
        adsorbates = tuple(ads_entry.ads_entries_dict)
×
1085
        plt.xlabel(f"{' '.join(adsorbates)} Coverage (ML)")
×
1086
        plt.ylabel("Adsorption Energy (eV)") if plot_eads else plt.ylabel("Binding Energy (eV)")
×
1087
        plt.legend()
×
1088
        plt.tight_layout()
×
1089

1090
        return plt
×
1091

1092
    @staticmethod
1✔
1093
    def chempot_plot_addons(plt, xrange, ref_el, axes, pad=2.4, rect=None, ylim=None):
1✔
1094
        """
1095
        Helper function to a chempot plot look nicer.
1096

1097
        Args:
1098
            plt (Plot) Plot to add things to.
1099
            xrange (list): xlim parameter
1100
            ref_el (str): Element of the referenced chempot.
1101
            axes(axes) Axes object from matplotlib
1102
            pad (float) For tight layout
1103
            rect (list): For tight layout
1104
            ylim (ylim parameter):
1105

1106
        return (Plot): Modified plot with addons.
1107
        return (Plot): Modified plot with addons.
1108
        """
1109
        # Make the figure look nice
1110
        plt.legend(bbox_to_anchor=(1.01, 1), loc=2, borderaxespad=0.0)
×
1111
        axes.set_xlabel(rf"Chemical potential $\Delta\mu_{{{ref_el}}}$ (eV)")
×
1112

1113
        ylim = ylim or axes.get_ylim()
×
1114
        plt.xticks(rotation=60)
×
1115
        plt.ylim(ylim)
×
1116
        xlim = axes.get_xlim()
×
1117
        plt.xlim(xlim)
×
1118
        plt.tight_layout(pad=pad, rect=rect or [-0.047, 0, 0.84, 1])
×
1119
        plt.plot([xrange[0], xrange[0]], ylim, "--k")
×
1120
        plt.plot([xrange[1], xrange[1]], ylim, "--k")
×
1121
        xy = [np.mean([xrange[1]]), np.mean(ylim)]
×
1122
        plt.annotate(f"{ref_el}-rich", xy=xy, xytext=xy, rotation=90, fontsize=17)
×
1123
        xy = [np.mean([xlim[0]]), np.mean(ylim)]
×
1124
        plt.annotate(f"{ref_el}-poor", xy=xy, xytext=xy, rotation=90, fontsize=17)
×
1125

1126
        return plt
×
1127

1128
    def BE_vs_clean_SE(
1✔
1129
        self,
1130
        delu_dict,
1131
        delu_default=0,
1132
        plot_eads=False,
1133
        annotate_monolayer=True,
1134
        JPERM2=False,
1135
    ):
1136
        """
1137
        For each facet, plot the clean surface energy against the most
1138
            stable binding energy.
1139
        Args:
1140
            delu_dict (dict): Dictionary of the chemical potentials to be set as
1141
                constant. Note the key should be a sympy Symbol object of the
1142
                format: Symbol("delu_el") where el is the name of the element.
1143
            delu_default (float): Default value for all unset chemical potentials
1144
            plot_eads (bool): Option to plot the adsorption energy (binding
1145
                energy multiplied by number of adsorbates) instead.
1146
            annotate_monolayer (bool): Whether or not to label each data point
1147
                with its monolayer (adsorbate density per unit primiitve area)
1148
            JPERM2 (bool): Whether to plot surface energy in /m^2 (True) or
1149
                eV/A^2 (False)
1150

1151
        Returns:
1152
            (Plot): Plot of clean surface energy vs binding energy for
1153
                all facets.
1154
        """
1155
        plt = pretty_plot(width=8, height=7)
×
1156
        for hkl in self.all_slab_entries:
×
1157
            for clean_entry in self.all_slab_entries[hkl]:
×
1158
                all_delu_dict = self.set_all_variables(delu_dict, delu_default)
×
1159
                if self.all_slab_entries[hkl][clean_entry]:
×
1160
                    clean_se = self.as_coeffs_dict[clean_entry]
×
1161
                    se = sub_chempots(clean_se, all_delu_dict)
×
1162
                    for ads_entry in self.all_slab_entries[hkl][clean_entry]:
×
1163
                        ml = ads_entry.get_monolayer
×
1164
                        be = ads_entry.gibbs_binding_energy(eads=plot_eads)
×
1165

1166
                        # Now plot the surface energy vs binding energy
1167
                        plt.scatter(se, be)
×
1168
                        if annotate_monolayer:
×
1169
                            plt.annotate(f"{ml:.2f}", xy=[se, be], xytext=[se, be])
×
1170

1171
        plt.xlabel(r"Surface energy ($J/m^2$)") if JPERM2 else plt.xlabel(r"Surface energy ($eV/\AA^2$)")
×
1172
        plt.ylabel("Adsorption Energy (eV)") if plot_eads else plt.ylabel("Binding Energy (eV)")
×
1173
        plt.tight_layout()
×
1174
        plt.xticks(rotation=60)
×
1175

1176
        return plt
×
1177

1178
    def surface_chempot_range_map(
1✔
1179
        self,
1180
        elements,
1181
        miller_index,
1182
        ranges,
1183
        incr=50,
1184
        no_doped=False,
1185
        no_clean=False,
1186
        delu_dict=None,
1187
        plt=None,
1188
        annotate=True,
1189
        show_unphyiscal_only=False,
1190
        fontsize=10,
1191
    ):
1192
        """
1193
        Adapted from the get_chempot_range_map() method in the PhaseDiagram
1194
            class. Plot the chemical potential range map based on surface
1195
            energy stability. Currently works only for 2-component PDs. At
1196
            the moment uses a brute force method by enumerating through the
1197
            range of the first element chempot with a specified increment
1198
            and determines the chempot rangeo fht e second element for each
1199
            SlabEntry. Future implementation will determine the chempot range
1200
            map first by solving systems of equations up to 3 instead of 2.
1201
        Args:
1202
            elements (list): Sequence of elements to be considered as independent
1203
                variables. E.g., if you want to show the stability ranges of
1204
                all Li-Co-O phases wrt to duLi and duO, you will supply
1205
                [Element("Li"), Element("O")]
1206
            miller_index ([h, k, l]): Miller index of the surface we are interested in
1207
            ranges ([[range1], [range2]]): List of chempot ranges (max and min values)
1208
                for the first and second element.
1209
            incr (int): Number of points to sample along the range of the first chempot
1210
            no_doped (bool): Whether or not to include doped systems.
1211
            no_clean (bool): Whether or not to include clean systems.
1212
            delu_dict (dict): Dictionary of the chemical potentials to be set as
1213
                constant. Note the key should be a sympy Symbol object of the
1214
                format: Symbol("delu_el") where el is the name of the element.
1215
            annotate (bool): Whether to annotate each "phase" with the label of
1216
                the entry. If no label, uses the reduced formula
1217
            show_unphyiscal_only (bool): Whether to only show the shaded region where
1218
                surface energy is negative. Useful for drawing other chempot range maps.
1219
        """
1220
        # Set up
1221
        delu_dict = delu_dict or {}
×
1222
        plt = pretty_plot(12, 8) if not plt else plt
×
1223
        el1, el2 = str(elements[0]), str(elements[1])
×
1224
        delu1 = Symbol(f"delu_{str(elements[0])}")
×
1225
        delu2 = Symbol(f"delu_{str(elements[1])}")
×
1226
        range1 = ranges[0]
×
1227
        range2 = ranges[1]
×
1228

1229
        # Find a range map for each entry (surface). This part is very slow, will
1230
        # need to implement a more sophisticated method of getting the range map
1231
        vertices_dict = {}
×
1232
        for dmu1 in np.linspace(range1[0], range1[1], incr):
×
1233
            # Get chemical potential range of dmu2 for each increment of dmu1
1234
            new_delu_dict = delu_dict.copy()
×
1235
            new_delu_dict[delu1] = dmu1
×
1236
            range_dict, se_dict = self.stable_u_range_dict(
×
1237
                range2,
1238
                delu2,
1239
                dmu_at_0=True,
1240
                miller_index=miller_index,
1241
                no_doped=no_doped,
1242
                no_clean=no_clean,
1243
                delu_dict=new_delu_dict,
1244
                return_se_dict=True,
1245
            )
1246

1247
            # Save the chempot range for dmu1 and dmu2
1248
            for entry, v in range_dict.items():
×
1249
                if not v:
×
1250
                    continue
×
1251
                if entry not in vertices_dict:
×
1252
                    vertices_dict[entry] = []
×
1253

1254
                selist = se_dict[entry]
×
1255
                vertices_dict[entry].append({delu1: dmu1, delu2: [v, selist]})
×
1256

1257
        # Plot the edges of the phases
1258
        for entry, v in vertices_dict.items():
×
1259
            xvals, yvals = [], []
×
1260

1261
            # Plot each edge of a phase within the borders
1262
            for ii, pt1 in enumerate(v):
×
1263
                # Determine if the surface energy at this lower range
1264
                # of dmu2 is negative. If so, shade this region.
1265
                if len(pt1[delu2][1]) == 3:
×
1266
                    if pt1[delu2][1][0] < 0:
×
1267
                        neg_dmu_range = [pt1[delu2][0][0], pt1[delu2][0][1]]
×
1268
                    else:
1269
                        neg_dmu_range = [pt1[delu2][0][1], pt1[delu2][0][2]]
×
1270
                    # Shade the threshold and region at which se<=0
1271
                    plt.plot([pt1[delu1], pt1[delu1]], neg_dmu_range, "k--")
×
1272
                elif pt1[delu2][1][0] < 0 and pt1[delu2][1][1] < 0:
×
1273
                    # Any chempot at this point will result
1274
                    # in se<0, shade the entire y range
1275
                    if not show_unphyiscal_only:
×
1276
                        plt.plot([pt1[delu1], pt1[delu1]], range2, "k--")
×
1277

1278
                if ii == len(v) - 1:
×
1279
                    break
×
1280
                pt2 = v[ii + 1]
×
1281
                if not show_unphyiscal_only:
×
1282
                    plt.plot(
×
1283
                        [pt1[delu1], pt2[delu1]],
1284
                        [pt1[delu2][0][0], pt2[delu2][0][0]],
1285
                        "k",
1286
                    )
1287

1288
                # Need these values to get a good position for labelling phases
1289
                xvals.extend([pt1[delu1], pt2[delu1]])
×
1290
                yvals.extend([pt1[delu2][0][0], pt2[delu2][0][0]])
×
1291

1292
            # Plot the edge along the max x value
1293
            pt = v[-1]
×
1294
            delu1, delu2 = pt
×
1295
            xvals.extend([pt[delu1], pt[delu1]])
×
1296
            yvals.extend(pt[delu2][0])
×
1297
            if not show_unphyiscal_only:
×
1298
                plt.plot([pt[delu1], pt[delu1]], [pt[delu2][0][0], pt[delu2][0][-1]], "k")
×
1299

1300
            if annotate:
×
1301
                # Label the phases
1302
                x = np.mean([max(xvals), min(xvals)])
×
1303
                y = np.mean([max(yvals), min(yvals)])
×
1304
                label = entry.label if entry.label else entry.composition.reduced_formula
×
1305
                plt.annotate(label, xy=[x, y], xytext=[x, y], fontsize=fontsize)
×
1306

1307
        # Label plot
1308
        plt.xlim(range1)
×
1309
        plt.ylim(range2)
×
1310
        plt.xlabel(rf"$\Delta\mu_{{{el1}}} (eV)$", fontsize=25)
×
1311
        plt.ylabel(rf"$\Delta\mu_{{{el2}}} (eV)$", fontsize=25)
×
1312
        plt.xticks(rotation=60)
×
1313

1314
        return plt
×
1315

1316
    def set_all_variables(self, delu_dict, delu_default):
1✔
1317
        """
1318
        Sets all chemical potential values and returns a dictionary where
1319
            the key is a sympy Symbol and the value is a float (chempot).
1320

1321
        Args:
1322
            entry (SlabEntry): Computed structure entry of the slab
1323
            delu_dict (dict): Dictionary of the chemical potentials to be set as
1324
                constant. Note the key should be a sympy Symbol object of the
1325
                format: Symbol("delu_el") where el is the name of the element.
1326
            delu_default (float): Default value for all unset chemical potentials
1327

1328
        Returns:
1329
            Dictionary of set chemical potential values
1330
        """
1331
        # Set up the variables
1332
        all_delu_dict = {}
1✔
1333
        for du in self.list_of_chempots:
1✔
1334
            if delu_dict and du in delu_dict:
1✔
1335
                all_delu_dict[du] = delu_dict[du]
1✔
1336
            elif du == 1:
1✔
1337
                all_delu_dict[du] = du
1✔
1338
            else:
1339
                all_delu_dict[du] = delu_default
1✔
1340

1341
        return all_delu_dict
1✔
1342

1343
        # def surface_phase_diagram(self, y_param, x_param, miller_index):
1344
        #     return
1345
        #
1346
        # def wulff_shape_extrapolated_model(self):
1347
        #     return
1348
        #
1349
        # def surface_pourbaix_diagram(self):
1350
        #
1351
        #     return
1352
        #
1353
        # def surface_p_vs_t_phase_diagram(self):
1354
        #
1355
        #     return
1356
        #
1357
        # def broken_bond_vs_gamma(self):
1358
        #
1359
        #     return
1360

1361

1362
def entry_dict_from_list(all_slab_entries):
1✔
1363
    """
1364
    Converts a list of SlabEntry to an appropriate dictionary. It is
1365
    assumed that if there is no adsorbate, then it is a clean SlabEntry
1366
    and that adsorbed SlabEntry has the clean_entry parameter set.
1367

1368
    Args:
1369
        all_slab_entries (list): List of SlabEntry objects
1370

1371
    Returns:
1372
        (dict): Dictionary of SlabEntry with the Miller index as the main
1373
            key to a dictionary with a clean SlabEntry as the key to a
1374
            list of adsorbed SlabEntry.
1375
    """
1376

1377
    entry_dict = {}
1✔
1378

1379
    for entry in all_slab_entries:
1✔
1380
        hkl = tuple(entry.miller_index)
1✔
1381
        if hkl not in entry_dict:
1✔
1382
            entry_dict[hkl] = {}
1✔
1383
        if entry.clean_entry:
1✔
1384
            clean = entry.clean_entry
1✔
1385
        else:
1386
            clean = entry
1✔
1387
        if clean not in entry_dict[hkl]:
1✔
1388
            entry_dict[hkl][clean] = []
1✔
1389
        if entry.adsorbates:
1✔
1390
            entry_dict[hkl][clean].append(entry)
1✔
1391

1392
    return entry_dict
1✔
1393

1394

1395
class WorkFunctionAnalyzer:
1✔
1396
    """
1397
    A class used for calculating the work function
1398
        from a slab model and visualizing the behavior
1399
        of the local potential along the slab.
1400

1401
    .. attribute:: efermi
1402

1403
        The Fermi energy
1404

1405
    .. attribute:: locpot_along_c
1406

1407
        Local potential in eV along points along the  axis
1408

1409
    .. attribute:: vacuum_locpot
1410

1411
        The maximum local potential along the c direction for
1412
            the slab model, ie the potential at the vacuum
1413

1414
    .. attribute:: work_function
1415

1416
        The minimum energy needed to move an electron from the
1417
            surface to infinity. Defined as the difference between
1418
            the potential at the vacuum and the Fermi energy.
1419

1420
    .. attribute:: slab
1421

1422
        The slab structure model
1423

1424
    .. attribute:: along_c
1425

1426
        Points along the c direction with same
1427
            increments as the locpot in the c axis
1428

1429
    .. attribute:: ave_locpot
1430

1431
        Mean of the minimum and maximmum (vacuum) locpot along c
1432

1433
    .. attribute:: sorted_sites
1434

1435
        List of sites from the slab sorted along the c direction
1436

1437
    .. attribute:: ave_bulk_p
1438

1439
        The average locpot of the slab region along the c direction
1440
    """
1441

1442
    def __init__(self, structure: Structure, locpot_along_c, efermi, shift=0, blength=3.5):
1✔
1443
        """
1444
        Initializes the WorkFunctionAnalyzer class.
1445

1446
        Args:
1447
            structure (Structure): Structure object modelling the surface
1448
            locpot_along_c (list): Local potential along the c direction
1449
            outcar (MSONable): Outcar vasp output object
1450
            shift (float): Parameter to translate the slab (and
1451
                therefore the vacuum) of the slab structure, thereby
1452
                translating the plot along the x axis.
1453
            blength (float (Ang)): The longest bond length in the material.
1454
                Used to handle pbc for noncontiguous slab layers
1455
        """
1456
        # ensure shift between 0 and 1
1457
        if shift < 0:
1✔
1458
            shift += -1 * int(shift) + 1
1✔
1459
        elif shift >= 1:
1✔
1460
            shift -= int(shift)
×
1461
        self.shift = shift
1✔
1462

1463
        # properties that can be shifted
1464
        slab = structure.copy()
1✔
1465
        slab.translate_sites([i for i, site in enumerate(slab)], [0, 0, self.shift])
1✔
1466
        self.slab = slab
1✔
1467
        self.sorted_sites = sorted(self.slab, key=lambda site: site.frac_coords[2])
1✔
1468

1469
        # Get the plot points between 0 and c
1470
        # increments of the number of locpot points
1471
        self.along_c = np.linspace(0, 1, num=len(locpot_along_c))
1✔
1472

1473
        # Get the plot points between 0 and c
1474
        # increments of the number of locpot points
1475
        locpot_along_c_mid, locpot_end, locpot_start = [], [], []
1✔
1476
        for i, s in enumerate(self.along_c):
1✔
1477
            j = s + self.shift
1✔
1478
            if j > 1:
1✔
1479
                locpot_start.append(locpot_along_c[i])
1✔
1480
            elif j < 0:
1✔
1481
                locpot_end.append(locpot_along_c[i])
×
1482
            else:
1483
                locpot_along_c_mid.append(locpot_along_c[i])
1✔
1484
        self.locpot_along_c = locpot_start + locpot_along_c_mid + locpot_end
1✔
1485

1486
        # identify slab region
1487
        self.slab_regions = get_slab_regions(self.slab, blength=blength)
1✔
1488
        # get the average of the signal in the bulk-like region of the
1489
        # slab, i.e. the average of the oscillating region. This gives
1490
        # a rough appr. of the potential in the interior of the slab
1491
        bulk_p = []
1✔
1492
        for r in self.slab_regions:
1✔
1493
            bulk_p.extend([p for i, p in enumerate(self.locpot_along_c) if r[1] >= self.along_c[i] > r[0]])
1✔
1494
        if len(self.slab_regions) > 1:
1✔
1495
            bulk_p.extend([p for i, p in enumerate(self.locpot_along_c) if self.slab_regions[1][1] <= self.along_c[i]])
1✔
1496
            bulk_p.extend([p for i, p in enumerate(self.locpot_along_c) if self.slab_regions[0][0] >= self.along_c[i]])
1✔
1497
        self.ave_bulk_p = np.mean(bulk_p)
1✔
1498

1499
        # shift independent quantities
1500
        self.efermi = efermi
1✔
1501
        self.vacuum_locpot = max(self.locpot_along_c)
1✔
1502
        # get the work function
1503
        self.work_function = self.vacuum_locpot - self.efermi
1✔
1504
        # for setting ylim and annotating
1505
        self.ave_locpot = (self.vacuum_locpot - min(self.locpot_along_c)) / 2
1✔
1506

1507
    def get_locpot_along_slab_plot(self, label_energies=True, plt=None, label_fontsize=10):
1✔
1508
        """
1509
        Returns a plot of the local potential (eV) vs the
1510
            position along the c axis of the slab model (Ang)
1511

1512
        Args:
1513
            label_energies (bool): Whether to label relevant energy
1514
                quantities such as the work function, Fermi energy,
1515
                vacuum locpot, bulk-like locpot
1516
            plt (plt): Matplotlib pylab object
1517
            label_fontsize (float): Fontsize of labels
1518

1519
        Returns plt of the locpot vs c axis
1520
        """
1521
        plt = pretty_plot(width=6, height=4) if not plt else plt
×
1522

1523
        # plot the raw locpot signal along c
1524
        plt.plot(self.along_c, self.locpot_along_c, "b--")
×
1525

1526
        # Get the local averaged signal of the locpot along c
1527
        xg, yg = [], []
×
1528
        for i, p in enumerate(self.locpot_along_c):
×
1529
            # average signal is just the bulk-like potential when in the slab region
1530
            in_slab = False
×
1531
            for r in self.slab_regions:
×
1532
                if r[0] <= self.along_c[i] <= r[1]:
×
1533
                    in_slab = True
×
1534
            if len(self.slab_regions) > 1:
×
1535
                if self.along_c[i] >= self.slab_regions[1][1]:
×
1536
                    in_slab = True
×
1537
                if self.along_c[i] <= self.slab_regions[0][0]:
×
1538
                    in_slab = True
×
1539

1540
            if in_slab:
×
1541
                yg.append(self.ave_bulk_p)
×
1542
                xg.append(self.along_c[i])
×
1543
            elif p < self.ave_bulk_p:
×
1544
                yg.append(self.ave_bulk_p)
×
1545
                xg.append(self.along_c[i])
×
1546
            else:
1547
                yg.append(p)
×
1548
                xg.append(self.along_c[i])
×
1549
        xg, yg = zip(*sorted(zip(xg, yg)))
×
1550
        plt.plot(xg, yg, "r", linewidth=2.5, zorder=-1)
×
1551

1552
        # make it look nice
1553
        if label_energies:
×
1554
            plt = self.get_labels(plt, label_fontsize=label_fontsize)
×
1555
        plt.xlim([0, 1])
×
1556
        plt.ylim([min(self.locpot_along_c), self.vacuum_locpot + self.ave_locpot * 0.2])
×
1557
        plt.xlabel(r"Fractional coordinates ($\hat{c}$)", fontsize=25)
×
1558
        plt.xticks(fontsize=15, rotation=45)
×
1559
        plt.ylabel(r"Potential (eV)", fontsize=25)
×
1560
        plt.yticks(fontsize=15)
×
1561

1562
        return plt
×
1563

1564
    def get_labels(self, plt, label_fontsize=10):
1✔
1565
        """
1566
        Handles the optional labelling of the plot with relevant quantities
1567
        Args:
1568
            plt (plt): Plot of the locpot vs c axis
1569
            label_fontsize (float): Fontsize of labels
1570
        Returns Labelled plt
1571
        """
1572
        # center of vacuum and bulk region
1573
        if len(self.slab_regions) > 1:
×
1574
            label_in_vac = (self.slab_regions[0][1] + self.slab_regions[1][0]) / 2
×
1575
            if abs(self.slab_regions[0][0] - self.slab_regions[0][1]) > abs(
×
1576
                self.slab_regions[1][0] - self.slab_regions[1][1]
1577
            ):
1578
                label_in_bulk = self.slab_regions[0][1] / 2
×
1579
            else:
1580
                label_in_bulk = (self.slab_regions[1][1] + self.slab_regions[1][0]) / 2
×
1581
        else:
1582
            label_in_bulk = (self.slab_regions[0][0] + self.slab_regions[0][1]) / 2
×
1583
            if self.slab_regions[0][0] > 1 - self.slab_regions[0][1]:
×
1584
                label_in_vac = self.slab_regions[0][0] / 2
×
1585
            else:
1586
                label_in_vac = (1 + self.slab_regions[0][1]) / 2
×
1587

1588
        plt.plot([0, 1], [self.vacuum_locpot] * 2, "b--", zorder=-5, linewidth=1)
×
1589
        xy = [label_in_bulk, self.vacuum_locpot + self.ave_locpot * 0.05]
×
1590
        plt.annotate(
×
1591
            f"$V_{{vac}}={self.vacuum_locpot:.2f}$",
1592
            xy=xy,
1593
            xytext=xy,
1594
            color="b",
1595
            fontsize=label_fontsize,
1596
        )
1597

1598
        # label the fermi energy
1599
        plt.plot([0, 1], [self.efermi] * 2, "g--", zorder=-5, linewidth=3)
×
1600
        xy = [label_in_bulk, self.efermi + self.ave_locpot * 0.05]
×
1601
        plt.annotate(
×
1602
            f"$E_F={self.efermi:.2f}$",
1603
            xytext=xy,
1604
            xy=xy,
1605
            fontsize=label_fontsize,
1606
            color="g",
1607
        )
1608

1609
        # label the bulk-like locpot
1610
        plt.plot([0, 1], [self.ave_bulk_p] * 2, "r--", linewidth=1.0, zorder=-1)
×
1611
        xy = [label_in_vac, self.ave_bulk_p + self.ave_locpot * 0.05]
×
1612
        plt.annotate(
×
1613
            f"$V^{{interior}}_{{slab}}={self.ave_bulk_p:.2f}$",
1614
            xy=xy,
1615
            xytext=xy,
1616
            color="r",
1617
            fontsize=label_fontsize,
1618
        )
1619

1620
        # label the work function as a barrier
1621
        plt.plot(
×
1622
            [label_in_vac] * 2,
1623
            [self.efermi, self.vacuum_locpot],
1624
            "k--",
1625
            zorder=-5,
1626
            linewidth=2,
1627
        )
1628
        xy = [label_in_vac, self.efermi + self.ave_locpot * 0.05]
×
1629
        plt.annotate(
×
1630
            rf"$\Phi={self.work_function:.2f}$",
1631
            xy=xy,
1632
            xytext=xy,
1633
            fontsize=label_fontsize,
1634
        )
1635

1636
        return plt
×
1637

1638
    def is_converged(self, min_points_frac=0.015, tol: float = 0.0025):
1✔
1639
        """
1640
        A well converged work function should have a flat electrostatic
1641
            potential within some distance (min_point) about where the peak
1642
            electrostatic potential is found along the c direction of the
1643
            slab. This is dependent on the size of the slab.
1644
        Args:
1645
            min_point (fractional coordinates): The number of data points
1646
                +/- the point of where the electrostatic potential is at
1647
                its peak along the c direction.
1648
            tol (float): If the electrostatic potential stays the same
1649
                within this tolerance, within the min_points, it is converged.
1650

1651
        Returns a bool (whether or not the work function is converged)
1652
        """
1653
        conv_within = tol * (max(self.locpot_along_c) - min(self.locpot_along_c))
1✔
1654
        min_points = int(min_points_frac * len(self.locpot_along_c))
1✔
1655
        peak_i = self.locpot_along_c.index(self.vacuum_locpot)
1✔
1656
        all_flat = []
1✔
1657
        for i in range(len(self.along_c)):
1✔
1658
            if peak_i - min_points < i < peak_i + min_points:
1✔
1659
                if abs(self.vacuum_locpot - self.locpot_along_c[i]) > conv_within:
1✔
1660
                    all_flat.append(False)
×
1661
                else:
1662
                    all_flat.append(True)
1✔
1663
        return all(all_flat)
1✔
1664

1665
    @staticmethod
1✔
1666
    def from_files(poscar_filename, locpot_filename, outcar_filename, shift=0, blength=3.5):
1✔
1667
        """
1668
        :param poscar_filename: POSCAR file
1669
        :param locpot_filename: LOCPOT file
1670
        :param outcar_filename: OUTCAR file
1671
        :param shift: shift
1672
        :param blength: The longest bond length in the material.
1673
            Used to handle pbc for noncontiguous slab layers
1674
        :return: WorkFunctionAnalyzer
1675
        """
1676
        poscar = Poscar.from_file(poscar_filename)
1✔
1677
        locpot = Locpot.from_file(locpot_filename)
1✔
1678
        outcar = Outcar(outcar_filename)
1✔
1679
        return WorkFunctionAnalyzer(
1✔
1680
            poscar.structure,
1681
            locpot.get_average_along_axis(2),
1682
            outcar.efermi,
1683
            shift=shift,
1684
            blength=blength,
1685
        )
1686

1687

1688
class NanoscaleStability:
1✔
1689
    """
1690
    A class for analyzing the stability of nanoparticles of different
1691
        polymorphs with respect to size. The Wulff shape will be the
1692
        model for the nanoparticle. Stability will be determined by
1693
        an energetic competition between the weighted surface energy
1694
        (surface energy of the Wulff shape) and the bulk energy. A
1695
        future release will include a 2D phase diagram (e.g. wrt size
1696
        vs chempot for adsorbed or non-stoichiometric surfaces). Based
1697
        on the following work:
1698

1699
        Kang, S., Mo, Y., Ong, S. P., & Ceder, G. (2014). Nanoscale
1700
            stabilization of sodium oxides: Implications for Na-O2
1701
            batteries. Nano Letters, 14(2), 1016-1020.
1702
            https://doi.org/10.1021/nl404557w
1703

1704
    .. attribute:: se_analyzers
1705

1706
        List of SurfaceEnergyPlotter objects. Each item corresponds to a
1707
            different polymorph.
1708

1709
    .. attribute:: symprec
1710

1711
        See WulffShape.
1712
    """
1713

1714
    def __init__(self, se_analyzers, symprec=1e-5):
1✔
1715
        """
1716
        Analyzes the nanoscale stability of different polymorphs.
1717
        """
1718
        self.se_analyzers = se_analyzers
1✔
1719
        self.symprec = symprec
1✔
1720

1721
    def solve_equilibrium_point(self, analyzer1, analyzer2, delu_dict=None, delu_default=0, units="nanometers"):
1✔
1722
        """
1723
        Gives the radial size of two particles where equilibrium is reached
1724
            between both particles. NOTE: the solution here is not the same
1725
            as the solution visualized in the plot because solving for r
1726
            requires that both the total surface area and volume of the
1727
            particles are functions of r.
1728

1729
        Args:
1730
            analyzer1 (SurfaceEnergyPlotter): Analyzer associated with the
1731
                first polymorph
1732
            analyzer2 (SurfaceEnergyPlotter): Analyzer associated with the
1733
                second polymorph
1734
            delu_dict (dict): Dictionary of the chemical potentials to be set as
1735
                constant. Note the key should be a sympy Symbol object of the
1736
                format: Symbol("delu_el") where el is the name of the element.
1737
            delu_default (float): Default value for all unset chemical potentials
1738
            units (str): Can be nanometers or Angstrom
1739

1740
        Returns:
1741
            Particle radius in nm
1742
        """
1743
        # Set up
1744
        wulff1 = analyzer1.wulff_from_chempot(
1✔
1745
            delu_dict=delu_dict or {}, delu_default=delu_default, symprec=self.symprec
1746
        )
1747
        wulff2 = analyzer2.wulff_from_chempot(
1✔
1748
            delu_dict=delu_dict or {}, delu_default=delu_default, symprec=self.symprec
1749
        )
1750

1751
        # Now calculate r
1752
        delta_gamma = wulff1.weighted_surface_energy - wulff2.weighted_surface_energy
1✔
1753
        delta_E = self.bulk_gform(analyzer1.ucell_entry) - self.bulk_gform(analyzer2.ucell_entry)
1✔
1754
        r = (-3 * delta_gamma) / (delta_E)
1✔
1755

1756
        return r / 10 if units == "nanometers" else r
1✔
1757

1758
    def wulff_gform_and_r(
1✔
1759
        self,
1760
        wulffshape,
1761
        bulk_entry,
1762
        r,
1763
        from_sphere_area=False,
1764
        r_units="nanometers",
1765
        e_units="keV",
1766
        normalize=False,
1767
        scale_per_atom=False,
1768
    ):
1769
        """
1770
        Calculates the formation energy of the particle with arbitrary radius r.
1771

1772
        Args:
1773
            wulffshape (WulffShape): Initial, unscaled WulffShape
1774
            bulk_entry (ComputedStructureEntry): Entry of the corresponding bulk.
1775
            r (float (Ang)): Arbitrary effective radius of the WulffShape
1776
            from_sphere_area (bool): There are two ways to calculate the bulk
1777
                formation energy. Either by treating the volume and thus surface
1778
                area of the particle as a perfect sphere, or as a Wulff shape.
1779
            r_units (str): Can be nanometers or Angstrom
1780
            e_units (str): Can be keV or eV
1781
            normalize (bool): Whether or not to normalize energy by volume
1782
            scale_per_atom (True): Whether or not to normalize by number of
1783
                atoms in the particle
1784

1785
        Returns:
1786
            particle formation energy (float in keV), effective radius
1787
        """
1788
        # Set up
1789
        miller_se_dict = wulffshape.miller_energy_dict
1✔
1790
        new_wulff = self.scaled_wulff(wulffshape, r)
1✔
1791
        new_wulff_area = new_wulff.miller_area_dict
1✔
1792

1793
        # calculate surface energy of the particle
1794
        if not from_sphere_area:
1✔
1795
            # By approximating the particle as a Wulff shape
1796
            w_vol = new_wulff.volume
×
1797
            tot_wulff_se = 0
×
1798
            for hkl, v in new_wulff_area.items():
×
1799
                tot_wulff_se += miller_se_dict[hkl] * v
×
1800
            Ebulk = self.bulk_gform(bulk_entry) * w_vol
×
1801
            new_r = new_wulff.effective_radius
×
1802

1803
        else:
1804
            # By approximating the particle as a perfect sphere
1805
            w_vol = (4 / 3) * np.pi * r**3
1✔
1806
            sphere_sa = 4 * np.pi * r**2
1✔
1807
            tot_wulff_se = wulffshape.weighted_surface_energy * sphere_sa
1✔
1808
            Ebulk = self.bulk_gform(bulk_entry) * w_vol
1✔
1809
            new_r = r
1✔
1810

1811
        new_r = new_r / 10 if r_units == "nanometers" else new_r
1✔
1812
        e = Ebulk + tot_wulff_se
1✔
1813
        e = e / 1000 if e_units == "keV" else e
1✔
1814
        e = e / ((4 / 3) * np.pi * new_r**3) if normalize else e
1✔
1815
        bulk_struct = bulk_entry.structure
1✔
1816
        density = len(bulk_struct) / bulk_struct.lattice.volume
1✔
1817
        e = e / (density * w_vol) if scale_per_atom else e
1✔
1818

1819
        return e, new_r
1✔
1820

1821
    @staticmethod
1✔
1822
    def bulk_gform(bulk_entry):
1✔
1823
        """
1824
        Returns the formation energy of the bulk
1825
        Args:
1826
            bulk_entry (ComputedStructureEntry): Entry of the corresponding bulk.
1827
        """
1828
        return bulk_entry.energy / bulk_entry.structure.lattice.volume
1✔
1829

1830
    def scaled_wulff(self, wulffshape, r):
1✔
1831
        """
1832
        Scales the Wulff shape with an effective radius r. Note that the resulting
1833
            Wulff does not necessarily have the same effective radius as the one
1834
            provided. The Wulff shape is scaled by its surface energies where first
1835
            the surface energies are scale by the minimum surface energy and then
1836
            multiplied by the given effective radius.
1837

1838
        Args:
1839
            wulffshape (WulffShape): Initial, unscaled WulffShape
1840
            r (float): Arbitrary effective radius of the WulffShape
1841

1842
        Returns:
1843
            WulffShape (scaled by r)
1844
        """
1845
        # get the scaling ratio for the energies
1846
        r_ratio = r / wulffshape.effective_radius
1✔
1847
        miller_list = list(wulffshape.miller_energy_dict)
1✔
1848
        # Normalize the magnitude of the facet normal vectors
1849
        # of the Wulff shape by the minimum surface energy.
1850
        se_list = np.array(list(wulffshape.miller_energy_dict.values()))
1✔
1851
        # Scale the magnitudes by r_ratio
1852
        scaled_se = se_list * r_ratio
1✔
1853

1854
        return WulffShape(wulffshape.lattice, miller_list, scaled_se, symprec=self.symprec)
1✔
1855

1856
    def plot_one_stability_map(
1✔
1857
        self,
1858
        analyzer,
1859
        max_r,
1860
        delu_dict=None,
1861
        label="",
1862
        increments=50,
1863
        delu_default=0,
1864
        plt=None,
1865
        from_sphere_area=False,
1866
        e_units="keV",
1867
        r_units="nanometers",
1868
        normalize=False,
1869
        scale_per_atom=False,
1870
    ):
1871
        """
1872
        Returns the plot of the formation energy of a particle against its
1873
            effect radius
1874

1875
        Args:
1876
            analyzer (SurfaceEnergyPlotter): Analyzer associated with the
1877
                first polymorph
1878
            max_r (float): The maximum radius of the particle to plot up to.
1879
            delu_dict (dict): Dictionary of the chemical potentials to be set as
1880
                constant. Note the key should be a sympy Symbol object of the
1881
                format: Symbol("delu_el") where el is the name of the element.
1882
            label (str): Label of the plot for legend
1883
            increments (int): Number of plot points
1884
            delu_default (float): Default value for all unset chemical potentials
1885
            plt (pylab): Plot
1886
            from_sphere_area (bool): There are two ways to calculate the bulk
1887
                formation energy. Either by treating the volume and thus surface
1888
                area of the particle as a perfect sphere, or as a Wulff shape.
1889
            r_units (str): Can be nanometers or Angstrom
1890
            e_units (str): Can be keV or eV
1891
            normalize (str): Whether or not to normalize energy by volume
1892
        """
1893
        plt = plt or pretty_plot(width=8, height=7)
×
1894

1895
        wulffshape = analyzer.wulff_from_chempot(delu_dict=delu_dict, delu_default=delu_default, symprec=self.symprec)
×
1896

1897
        gform_list, r_list = [], []
×
1898
        for r in np.linspace(1e-6, max_r, increments):
×
1899
            gform, r = self.wulff_gform_and_r(
×
1900
                wulffshape,
1901
                analyzer.ucell_entry,
1902
                r,
1903
                from_sphere_area=from_sphere_area,
1904
                r_units=r_units,
1905
                e_units=e_units,
1906
                normalize=normalize,
1907
                scale_per_atom=scale_per_atom,
1908
            )
1909
            gform_list.append(gform)
×
1910
            r_list.append(r)
×
1911

1912
        ru = "nm" if r_units == "nanometers" else r"\AA"
×
1913
        plt.xlabel(rf"Particle radius (${ru}$)")
×
1914
        eu = f"${e_units}/{ru}^3$"
×
1915
        plt.ylabel(rf"$G_{{form}}$ ({eu})")
×
1916

1917
        plt.plot(r_list, gform_list, label=label)
×
1918

1919
        return plt
×
1920

1921
    def plot_all_stability_map(
1✔
1922
        self,
1923
        max_r,
1924
        increments=50,
1925
        delu_dict=None,
1926
        delu_default=0,
1927
        plt=None,
1928
        labels=None,
1929
        from_sphere_area=False,
1930
        e_units="keV",
1931
        r_units="nanometers",
1932
        normalize=False,
1933
        scale_per_atom=False,
1934
    ):
1935
        """
1936
        Returns the plot of the formation energy of a particles
1937
            of different polymorphs against its effect radius
1938

1939
        Args:
1940
            max_r (float): The maximum radius of the particle to plot up to.
1941
            increments (int): Number of plot points
1942
            delu_dict (dict): Dictionary of the chemical potentials to be set as
1943
                constant. Note the key should be a sympy Symbol object of the
1944
                format: Symbol("delu_el") where el is the name of the element.
1945
            delu_default (float): Default value for all unset chemical potentials
1946
            plt (pylab): Plot
1947
            labels (list): List of labels for each plot, corresponds to the
1948
                list of se_analyzers
1949
            from_sphere_area (bool): There are two ways to calculate the bulk
1950
                formation energy. Either by treating the volume and thus surface
1951
                area of the particle as a perfect sphere, or as a Wulff shape.
1952
        """
1953
        plt = plt or pretty_plot(width=8, height=7)
×
1954

1955
        for i, analyzer in enumerate(self.se_analyzers):
×
1956
            label = labels[i] if labels else ""
×
1957
            plt = self.plot_one_stability_map(
×
1958
                analyzer,
1959
                max_r,
1960
                delu_dict,
1961
                label=label,
1962
                plt=plt,
1963
                increments=increments,
1964
                delu_default=delu_default,
1965
                from_sphere_area=from_sphere_area,
1966
                e_units=e_units,
1967
                r_units=r_units,
1968
                normalize=normalize,
1969
                scale_per_atom=scale_per_atom,
1970
            )
1971

1972
        return plt
×
1973

1974
        # class GetChempotRange:
1975
        #     def __init__(self, entry):
1976
        #         self.entry = entry
1977
        #
1978
        #
1979
        # class SlabEntryGenerator:
1980
        #     def __init__(self, entry):
1981
        #         self.entry = entry
1982

1983

1984
def sub_chempots(gamma_dict, chempots):
1✔
1985
    """
1986
    Uses dot product of numpy array to sub chemical potentials
1987
        into the surface grand potential. This is much faster
1988
        than using the subs function in sympy.
1989
    Args:
1990
        gamma_dict (dict): Surface grand potential equation
1991
            as a coefficient dictionary
1992
        chempots (dict): Dictionary assigning each chemical
1993
            potential (key) in gamma a value
1994
    Returns:
1995
        Surface energy as a float
1996
    """
1997

1998
    coeffs = [gamma_dict[k] for k in gamma_dict]
×
1999
    chempot_vals = []
×
2000
    for k in gamma_dict:
×
2001
        if k not in chempots:
×
2002
            chempot_vals.append(k)
×
2003
        elif k == 1:
×
2004
            chempot_vals.append(1)
×
2005
        else:
2006
            chempot_vals.append(chempots[k])
×
2007

2008
    return np.dot(coeffs, chempot_vals)
×
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