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

materialsproject / pymatgen / 4075885785

pending completion
4075885785

push

github

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

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

81013 of 102710 relevant lines covered (78.88%)

0.79 hits per line

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

90.25
/pymatgen/electronic_structure/bandstructure.py
1
# Copyright (c) Pymatgen Development Team.
2
# Distributed under the terms of the MIT License.
3

4
"""
1✔
5
This module provides classes to define everything related to band structures.
6
"""
7

8
from __future__ import annotations
1✔
9

10
import collections
1✔
11
import itertools
1✔
12
import math
1✔
13
import re
1✔
14
import warnings
1✔
15

16
import numpy as np
1✔
17
from monty.json import MSONable
1✔
18

19
from pymatgen.core.lattice import Lattice
1✔
20
from pymatgen.core.periodic_table import Element, get_el_sp
1✔
21
from pymatgen.core.structure import Structure
1✔
22
from pymatgen.electronic_structure.core import Orbital, Spin
1✔
23
from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
1✔
24
from pymatgen.util.coord import pbc_diff
1✔
25

26
__author__ = "Geoffroy Hautier, Shyue Ping Ong, Michael Kocher"
1✔
27
__copyright__ = "Copyright 2012, The Materials Project"
1✔
28
__version__ = "1.0"
1✔
29
__maintainer__ = "Geoffroy Hautier"
1✔
30
__email__ = "geoffroy@uclouvain.be"
1✔
31
__status__ = "Development"
1✔
32
__date__ = "March 14, 2012"
1✔
33

34

35
class Kpoint(MSONable):
1✔
36
    """
37
    Class to store kpoint objects. A kpoint is defined with a lattice and frac
38
    or Cartesian coordinates syntax similar than the site object in
39
    pymatgen.core.structure.
40
    """
41

42
    def __init__(
1✔
43
        self,
44
        coords,
45
        lattice,
46
        to_unit_cell=False,
47
        coords_are_cartesian=False,
48
        label=None,
49
    ):
50
        """
51
        Args:
52
            coords: coordinate of the kpoint as a numpy array
53
            lattice: A pymatgen.core.lattice.Lattice lattice object representing
54
                the reciprocal lattice of the kpoint
55
            to_unit_cell: Translates fractional coordinate to the basic unit
56
                cell, i.e., all fractional coordinates satisfy 0 <= a < 1.
57
                Defaults to False.
58
            coords_are_cartesian: Boolean indicating if the coordinates given are
59
                in Cartesian or fractional coordinates (by default fractional)
60
            label: the label of the kpoint if any (None by default)
61
        """
62
        self._lattice = lattice
1✔
63
        self._fcoords = lattice.get_fractional_coords(coords) if coords_are_cartesian else coords
1✔
64
        self._label = label
1✔
65

66
        if to_unit_cell:
1✔
67
            for i, fc in enumerate(self._fcoords):
×
68
                self._fcoords[i] -= math.floor(fc)
×
69

70
        self._ccoords = lattice.get_cartesian_coords(self._fcoords)
1✔
71

72
    @property
1✔
73
    def lattice(self):
1✔
74
        """
75
        The lattice associated with the kpoint. It's a
76
        pymatgen.core.lattice.Lattice object
77
        """
78
        return self._lattice
1✔
79

80
    @property
1✔
81
    def label(self):
1✔
82
        """
83
        The label associated with the kpoint
84
        """
85
        return self._label
1✔
86

87
    @property
1✔
88
    def frac_coords(self):
1✔
89
        """
90
        The fractional coordinates of the kpoint as a numpy array
91
        """
92
        return np.copy(self._fcoords)
1✔
93

94
    @property
1✔
95
    def cart_coords(self):
1✔
96
        """
97
        The Cartesian coordinates of the kpoint as a numpy array
98
        """
99
        return np.copy(self._ccoords)
1✔
100

101
    @property
1✔
102
    def a(self):
1✔
103
        """
104
        Fractional a coordinate of the kpoint
105
        """
106
        return self._fcoords[0]
1✔
107

108
    @property
1✔
109
    def b(self):
1✔
110
        """
111
        Fractional b coordinate of the kpoint
112
        """
113
        return self._fcoords[1]
1✔
114

115
    @property
1✔
116
    def c(self):
1✔
117
        """
118
        Fractional c coordinate of the kpoint
119
        """
120
        return self._fcoords[2]
1✔
121

122
    def __str__(self):
1✔
123
        """
124
        Returns a string with fractional, Cartesian coordinates and label
125
        """
126
        return f"{self.frac_coords} {self.cart_coords} {self.label}"
×
127

128
    def as_dict(self):
1✔
129
        """
130
        JSON-serializable dict representation of a kpoint
131
        """
132
        return {
1✔
133
            "lattice": self.lattice.as_dict(),
134
            "fcoords": self.frac_coords.tolist(),
135
            "ccoords": self.cart_coords.tolist(),
136
            "label": self.label,
137
            "@module": type(self).__module__,
138
            "@class": type(self).__name__,
139
        }
140

141
    @classmethod
1✔
142
    def from_dict(cls, d):
1✔
143
        """
144
        Create from dict.
145

146
        Args:
147
            A dict with all data for a kpoint object.
148

149
        Returns:
150
            A Kpoint object
151
        """
152
        return cls(
1✔
153
            coords=d["fcoords"],
154
            lattice=Lattice.from_dict(d["lattice"]),
155
            coords_are_cartesian=False,
156
            label=d["label"],
157
        )
158

159

160
class BandStructure:
1✔
161
    """
162
    This is the most generic band structure data possible
163
    it's defined by a list of kpoints + energies for each of them
164

165
    .. attribute:: kpoints:
166
        the list of kpoints (as Kpoint objects) in the band structure
167

168
    .. attribute:: lattice_rec
169

170
        the reciprocal lattice of the band structure.
171

172
    .. attribute:: efermi
173

174
        the fermi energy
175

176
    .. attribute::  is_spin_polarized
177

178
        True if the band structure is spin-polarized, False otherwise
179

180
    .. attribute:: bands
181

182
        The energy eigenvalues as a {spin: ndarray}. Note that the use of an
183
        ndarray is necessary for computational as well as memory efficiency
184
        due to the large amount of numerical data. The indices of the ndarray
185
        are [band_index, kpoint_index].
186

187
    .. attribute:: nb_bands
188

189
        returns the number of bands in the band structure
190

191
    .. attribute:: structure
192

193
        returns the structure
194

195
    .. attribute:: projections
196

197
        The projections as a {spin: ndarray}. Note that the use of an
198
        ndarray is necessary for computational as well as memory efficiency
199
        due to the large amount of numerical data. The indices of the ndarray
200
        are [band_index, kpoint_index, orbital_index, ion_index].
201
    """
202

203
    def __init__(
1✔
204
        self,
205
        kpoints: np.ndarray,
206
        eigenvals: dict[Spin, np.ndarray],
207
        lattice: Lattice,
208
        efermi: float,
209
        labels_dict=None,
210
        coords_are_cartesian: bool = False,
211
        structure: Structure | None = None,
212
        projections: dict[Spin, np.ndarray] | None = None,
213
    ) -> None:
214
        """
215
        Args:
216
            kpoints: list of kpoint as numpy arrays, in frac_coords of the
217
                given lattice by default
218
            eigenvals: dict of energies for spin up and spin down
219
                {Spin.up:[][],Spin.down:[][]}, the first index of the array
220
                [][] refers to the band and the second to the index of the
221
                kpoint. The kpoints are ordered according to the order of the
222
                kpoints array. If the band structure is not spin polarized, we
223
                only store one data set under Spin.up
224
            lattice: The reciprocal lattice as a pymatgen Lattice object.
225
                Pymatgen uses the physics convention of reciprocal lattice vectors
226
                WITH a 2*pi coefficient
227
            efermi (float): fermi energy
228
            labels_dict: (dict) of {} this links a kpoint (in frac coords or
229
                Cartesian coordinates depending on the coords) to a label.
230
            coords_are_cartesian: Whether coordinates are cartesian.
231
            structure: The crystal structure (as a pymatgen Structure object)
232
                associated with the band structure. This is needed if we
233
                provide projections to the band structure
234
            projections: dict of orbital projections as {spin: ndarray}. The
235
                indices of the ndarrayare [band_index, kpoint_index, orbital_index,
236
                ion_index].If the band structure is not spin polarized, we only
237
                store one data set under Spin.up.
238
        """
239
        self.efermi = efermi
1✔
240
        self.lattice_rec = lattice
1✔
241
        self.kpoints = []
1✔
242
        self.labels_dict = {}
1✔
243
        self.structure = structure
1✔
244
        self.projections = projections or {}
1✔
245
        self.projections = {k: np.array(v) for k, v in self.projections.items()}
1✔
246

247
        if labels_dict is None:
1✔
248
            labels_dict = {}
1✔
249

250
        if len(self.projections) != 0 and self.structure is None:
1✔
251
            raise Exception("if projections are provided a structure object needs also to be given")
×
252

253
        for k in kpoints:
1✔
254
            # let see if this kpoint has been assigned a label
255
            label = None
1✔
256
            for c in labels_dict:
1✔
257
                if np.linalg.norm(k - np.array(labels_dict[c])) < 0.0001:
1✔
258
                    label = c
1✔
259
                    self.labels_dict[label] = Kpoint(
1✔
260
                        k,
261
                        lattice,
262
                        label=label,
263
                        coords_are_cartesian=coords_are_cartesian,
264
                    )
265
            self.kpoints.append(Kpoint(k, lattice, label=label, coords_are_cartesian=coords_are_cartesian))
1✔
266
        self.bands = {spin: np.array(v) for spin, v in eigenvals.items()}
1✔
267
        self.nb_bands = len(eigenvals[Spin.up])
1✔
268
        self.is_spin_polarized = len(self.bands) == 2
1✔
269

270
    def get_projection_on_elements(self):
1✔
271
        """
272
        Method returning a dictionary of projections on elements.
273

274
        Returns:
275
            a dictionary in the {Spin.up:[][{Element:values}],
276
            Spin.down:[][{Element:values}]} format
277
            if there is no projections in the band structure
278
            returns an empty dict
279
        """
280
        result = {}
1✔
281
        structure = self.structure
1✔
282
        for spin, v in self.projections.items():
1✔
283
            result[spin] = [
1✔
284
                [collections.defaultdict(float) for i in range(len(self.kpoints))] for j in range(self.nb_bands)
285
            ]
286
            for i, j, k in itertools.product(
1✔
287
                range(self.nb_bands),
288
                range(len(self.kpoints)),
289
                range(structure.num_sites),
290
            ):
291
                result[spin][i][j][str(structure[k].specie)] += np.sum(v[i, j, :, k])
1✔
292
        return result
1✔
293

294
    def get_projections_on_elements_and_orbitals(self, el_orb_spec):
1✔
295
        """
296
        Method returning a dictionary of projections on elements and specific
297
        orbitals
298

299
        Args:
300
            el_orb_spec: A dictionary of Elements and Orbitals for which we want
301
                to have projections on. It is given as: {Element:[orbitals]},
302
                e.g., {'Cu':['d','s']}
303

304
        Returns:
305
            A dictionary of projections on elements in the
306
            {Spin.up:[][{Element:{orb:values}}],
307
            Spin.down:[][{Element:{orb:values}}]} format
308
            if there is no projections in the band structure returns an empty
309
            dict.
310
        """
311
        result = {}
1✔
312
        structure = self.structure
1✔
313
        el_orb_spec = {get_el_sp(el): orbs for el, orbs in el_orb_spec.items()}
1✔
314
        for spin, v in self.projections.items():
1✔
315
            result[spin] = [
1✔
316
                [{str(e): collections.defaultdict(float) for e in el_orb_spec} for i in range(len(self.kpoints))]
317
                for j in range(self.nb_bands)
318
            ]
319

320
            for i, j, k in itertools.product(
1✔
321
                range(self.nb_bands),
322
                range(len(self.kpoints)),
323
                range(structure.num_sites),
324
            ):
325
                sp = structure[k].specie
1✔
326
                for orb_i in range(len(v[i][j])):
1✔
327
                    o = Orbital(orb_i).name[0]
1✔
328
                    if sp in el_orb_spec:
1✔
329
                        if o in el_orb_spec[sp]:
1✔
330
                            result[spin][i][j][str(sp)][o] += v[i][j][orb_i][k]
1✔
331
        return result
1✔
332

333
    def is_metal(self, efermi_tol=1e-4) -> bool:
1✔
334
        """
335
        Check if the band structure indicates a metal by looking if the fermi
336
        level crosses a band.
337

338
        Returns:
339
            True if a metal, False if not
340
        """
341
        for values in self.bands.values():
1✔
342
            for i in range(self.nb_bands):
1✔
343
                if np.any(values[i, :] - self.efermi < -efermi_tol) and np.any(values[i, :] - self.efermi > efermi_tol):
1✔
344
                    return True
1✔
345
        return False
1✔
346

347
    def get_vbm(self):
1✔
348
        """
349
        Returns data about the VBM.
350

351
        Returns:
352
            dict as {"band_index","kpoint_index","kpoint","energy"}
353
            - "band_index": A dict with spin keys pointing to a list of the
354
            indices of the band containing the VBM (please note that you
355
            can have several bands sharing the VBM) {Spin.up:[],
356
            Spin.down:[]}
357
            - "kpoint_index": The list of indices in self.kpoints for the
358
            kpoint VBM. Please note that there can be several
359
            kpoint_indices relating to the same kpoint (e.g., Gamma can
360
            occur at different spots in the band structure line plot)
361
            - "kpoint": The kpoint (as a kpoint object)
362
            - "energy": The energy of the VBM
363
            - "projections": The projections along sites and orbitals of the
364
            VBM if any projection data is available (else it is an empty
365
            dictionary). The format is similar to the projections field in
366
            BandStructure: {spin:{'Orbital': [proj]}} where the array
367
            [proj] is ordered according to the sites in structure
368
        """
369
        if self.is_metal():
1✔
370
            return {
×
371
                "band_index": [],
372
                "kpoint_index": [],
373
                "kpoint": [],
374
                "energy": None,
375
                "projections": {},
376
            }
377
        max_tmp = -float("inf")
1✔
378
        index = None
1✔
379
        kpointvbm = None
1✔
380
        for value in self.bands.values():
1✔
381
            for i, j in zip(*np.where(value < self.efermi)):
1✔
382
                if value[i, j] > max_tmp:
1✔
383
                    max_tmp = float(value[i, j])
1✔
384
                    index = j
1✔
385
                    kpointvbm = self.kpoints[j]
1✔
386

387
        list_ind_kpts = []
1✔
388
        if kpointvbm.label is not None:
1✔
389
            for i, kpt in enumerate(self.kpoints):
1✔
390
                if kpt.label == kpointvbm.label:
1✔
391
                    list_ind_kpts.append(i)
1✔
392
        else:
393
            list_ind_kpts.append(index)
1✔
394
        # get all other bands sharing the vbm
395
        list_ind_band = collections.defaultdict(list)
1✔
396
        for spin in self.bands:
1✔
397
            for i in range(self.nb_bands):
1✔
398
                if math.fabs(self.bands[spin][i][index] - max_tmp) < 0.001:
1✔
399
                    list_ind_band[spin].append(i)
1✔
400
        proj = {}
1✔
401
        for spin, value in self.projections.items():
1✔
402
            if len(list_ind_band[spin]) == 0:
1✔
403
                continue
1✔
404
            proj[spin] = value[list_ind_band[spin][0]][list_ind_kpts[0]]
1✔
405
        return {
1✔
406
            "band_index": list_ind_band,
407
            "kpoint_index": list_ind_kpts,
408
            "kpoint": kpointvbm,
409
            "energy": max_tmp,
410
            "projections": proj,
411
        }
412

413
    def get_cbm(self):
1✔
414
        """
415
        Returns data about the CBM.
416

417
        Returns:
418
            {"band_index","kpoint_index","kpoint","energy"}
419
            - "band_index": A dict with spin keys pointing to a list of the
420
            indices of the band containing the CBM (please note that you
421
            can have several bands sharing the CBM) {Spin.up:[],
422
            Spin.down:[]}
423
            - "kpoint_index": The list of indices in self.kpoints for the
424
            kpoint CBM. Please note that there can be several
425
            kpoint_indices relating to the same kpoint (e.g., Gamma can
426
            occur at different spots in the band structure line plot)
427
            - "kpoint": The kpoint (as a kpoint object)
428
            - "energy": The energy of the CBM
429
            - "projections": The projections along sites and orbitals of the
430
            CBM if any projection data is available (else it is an empty
431
            dictionary). The format is similar to the projections field in
432
            BandStructure: {spin:{'Orbital': [proj]}} where the array
433
            [proj] is ordered according to the sites in structure
434
        """
435
        if self.is_metal():
1✔
436
            return {
×
437
                "band_index": [],
438
                "kpoint_index": [],
439
                "kpoint": [],
440
                "energy": None,
441
                "projections": {},
442
            }
443
        max_tmp = float("inf")
1✔
444

445
        index = None
1✔
446
        kpointcbm = None
1✔
447
        for value in self.bands.values():
1✔
448
            for i, j in zip(*np.where(value >= self.efermi)):
1✔
449
                if value[i, j] < max_tmp:
1✔
450
                    max_tmp = float(value[i, j])
1✔
451
                    index = j
1✔
452
                    kpointcbm = self.kpoints[j]
1✔
453

454
        list_index_kpoints = []
1✔
455
        if kpointcbm.label is not None:
1✔
456
            for i, kpt in enumerate(self.kpoints):
1✔
457
                if kpt.label == kpointcbm.label:
1✔
458
                    list_index_kpoints.append(i)
1✔
459
        else:
460
            list_index_kpoints.append(index)
1✔
461

462
        # get all other bands sharing the cbm
463
        list_index_band = collections.defaultdict(list)
1✔
464
        for spin in self.bands:
1✔
465
            for i in range(self.nb_bands):
1✔
466
                if math.fabs(self.bands[spin][i][index] - max_tmp) < 0.001:
1✔
467
                    list_index_band[spin].append(i)
1✔
468
        proj = {}
1✔
469
        for spin, value in self.projections.items():
1✔
470
            if len(list_index_band[spin]) == 0:
1✔
471
                continue
1✔
472
            proj[spin] = value[list_index_band[spin][0]][list_index_kpoints[0]]
1✔
473

474
        return {
1✔
475
            "band_index": list_index_band,
476
            "kpoint_index": list_index_kpoints,
477
            "kpoint": kpointcbm,
478
            "energy": max_tmp,
479
            "projections": proj,
480
        }
481

482
    def get_band_gap(self):
1✔
483
        r"""
484
        Returns band gap data.
485

486
        Returns:
487
            A dict {"energy","direct","transition"}:
488
            "energy": band gap energy
489
            "direct": A boolean telling if the gap is direct or not
490
            "transition": kpoint labels of the transition (e.g., "\\Gamma-X")
491
        """
492
        if self.is_metal():
1✔
493
            return {"energy": 0.0, "direct": False, "transition": None}
1✔
494
        cbm = self.get_cbm()
1✔
495
        vbm = self.get_vbm()
1✔
496
        result = dict(direct=False, energy=0.0, transition=None)
1✔
497

498
        result["energy"] = cbm["energy"] - vbm["energy"]
1✔
499

500
        if (cbm["kpoint"].label is not None and cbm["kpoint"].label == vbm["kpoint"].label) or np.linalg.norm(
1✔
501
            cbm["kpoint"].cart_coords - vbm["kpoint"].cart_coords
502
        ) < 0.01:
503
            result["direct"] = True
1✔
504

505
        result["transition"] = "-".join(
1✔
506
            [
507
                str(c.label)
508
                if c.label is not None
509
                else "(" + ",".join(f"{c.frac_coords[i]:.3f}" for i in range(3)) + ")"
510
                for c in [vbm["kpoint"], cbm["kpoint"]]
511
            ]
512
        )
513

514
        return result
1✔
515

516
    def get_direct_band_gap_dict(self):
1✔
517
        """
518
        Returns a dictionary of information about the direct
519
        band gap
520

521
        Returns:
522
            a dictionary of the band gaps indexed by spin
523
            along with their band indices and k-point index
524
        """
525
        if self.is_metal():
1✔
526
            raise ValueError("get_direct_band_gap_dict should only be used with non-metals")
1✔
527
        direct_gap_dict = {}
1✔
528
        for spin, v in self.bands.items():
1✔
529
            above = v[np.all(v > self.efermi, axis=1)]
1✔
530
            min_above = np.min(above, axis=0)
1✔
531
            below = v[np.all(v < self.efermi, axis=1)]
1✔
532
            max_below = np.max(below, axis=0)
1✔
533
            diff = min_above - max_below
1✔
534
            kpoint_index = np.argmin(diff)
1✔
535
            band_indices = [
1✔
536
                np.argmax(below[:, kpoint_index]),
537
                np.argmin(above[:, kpoint_index]) + len(below),
538
            ]
539
            direct_gap_dict[spin] = {
1✔
540
                "value": diff[kpoint_index],
541
                "kpoint_index": kpoint_index,
542
                "band_indices": band_indices,
543
            }
544
        return direct_gap_dict
1✔
545

546
    def get_direct_band_gap(self):
1✔
547
        """
548
        Returns the direct band gap.
549

550
        Returns:
551
             the value of the direct band gap
552
        """
553
        if self.is_metal():
1✔
554
            return 0.0
1✔
555
        dg = self.get_direct_band_gap_dict()
1✔
556
        return min(v["value"] for v in dg.values())
1✔
557

558
    def get_sym_eq_kpoints(self, kpoint, cartesian=False, tol: float = 1e-2):
1✔
559
        """
560
        Returns a list of unique symmetrically equivalent k-points.
561

562
        Args:
563
            kpoint (1x3 array): coordinate of the k-point
564
            cartesian (bool): kpoint is in Cartesian or fractional coordinates
565
            tol (float): tolerance below which coordinates are considered equal
566

567
        Returns:
568
            ([1x3 array] or None): if structure is not available returns None
569
        """
570
        if not self.structure:
1✔
571
            return None
1✔
572
        sg = SpacegroupAnalyzer(self.structure)
1✔
573
        symmops = sg.get_point_group_operations(cartesian=cartesian)
1✔
574
        points = np.dot(kpoint, [m.rotation_matrix for m in symmops])
1✔
575
        rm_list = []
1✔
576
        # identify and remove duplicates from the list of equivalent k-points:
577
        for i in range(len(points) - 1):
1✔
578
            for j in range(i + 1, len(points)):
1✔
579
                if np.allclose(pbc_diff(points[i], points[j]), [0, 0, 0], tol):
1✔
580
                    rm_list.append(i)
1✔
581
                    break
1✔
582
        return np.delete(points, rm_list, axis=0)
1✔
583

584
    def get_kpoint_degeneracy(self, kpoint, cartesian=False, tol: float = 1e-2):
1✔
585
        """
586
        Returns degeneracy of a given k-point based on structure symmetry
587
        Args:
588
            kpoint (1x3 array): coordinate of the k-point
589
            cartesian (bool): kpoint is in Cartesian or fractional coordinates
590
            tol (float): tolerance below which coordinates are considered equal
591

592
        Returns:
593
            (int or None): degeneracy or None if structure is not available
594
        """
595
        all_kpts = self.get_sym_eq_kpoints(kpoint, cartesian, tol=tol)
1✔
596
        if all_kpts is not None:
1✔
597
            return len(all_kpts)
1✔
598
        return None
1✔
599

600
    def as_dict(self):
1✔
601
        """
602
        JSON-serializable dict representation of BandStructure.
603
        """
604
        d = {
1✔
605
            "@module": type(self).__module__,
606
            "@class": type(self).__name__,
607
            "lattice_rec": self.lattice_rec.as_dict(),
608
            "efermi": self.efermi,
609
            "kpoints": [],
610
        }
611
        # kpoints are not kpoint objects dicts but are frac coords (this makes
612
        # the dict smaller and avoids the repetition of the lattice
613
        for k in self.kpoints:
1✔
614
            d["kpoints"].append(k.as_dict()["fcoords"])
1✔
615

616
        d["bands"] = {str(int(spin)): self.bands[spin].tolist() for spin in self.bands}
1✔
617
        d["is_metal"] = self.is_metal()
1✔
618
        vbm = self.get_vbm()
1✔
619
        d["vbm"] = {
1✔
620
            "energy": vbm["energy"],
621
            "kpoint_index": vbm["kpoint_index"],
622
            "band_index": {str(int(spin)): vbm["band_index"][spin] for spin in vbm["band_index"]},
623
            "projections": {str(spin): v.tolist() for spin, v in vbm["projections"].items()},
624
        }
625
        cbm = self.get_cbm()
1✔
626
        d["cbm"] = {
1✔
627
            "energy": cbm["energy"],
628
            "kpoint_index": cbm["kpoint_index"],
629
            "band_index": {str(int(spin)): cbm["band_index"][spin] for spin in cbm["band_index"]},
630
            "projections": {str(spin): v.tolist() for spin, v in cbm["projections"].items()},
631
        }
632
        d["band_gap"] = self.get_band_gap()
1✔
633
        d["labels_dict"] = {}
1✔
634
        d["is_spin_polarized"] = self.is_spin_polarized
1✔
635

636
        # MongoDB does not accept keys starting with $. Add a blank space to fix the problem
637
        for c, label in self.labels_dict.items():
1✔
638
            mongo_key = c if not c.startswith("$") else " " + c
1✔
639
            d["labels_dict"][mongo_key] = label.as_dict()["fcoords"]
1✔
640
        d["projections"] = {}
1✔
641
        if len(self.projections) != 0:
1✔
642
            d["structure"] = self.structure.as_dict()
1✔
643
            d["projections"] = {str(int(spin)): np.array(v).tolist() for spin, v in self.projections.items()}
1✔
644
        return d
1✔
645

646
    @classmethod
1✔
647
    def from_dict(cls, d):
1✔
648
        """
649
        Create from dict.
650

651
        Args:
652
            A dict with all data for a band structure object.
653

654
        Returns:
655
            A BandStructure object
656
        """
657
        # Strip the label to recover initial string
658
        # (see trick used in as_dict to handle $ chars)
659
        labels_dict = {k.strip(): v for k, v in d["labels_dict"].items()}
1✔
660
        projections = {}
1✔
661
        structure = None
1✔
662
        if isinstance(list(d["bands"].values())[0], dict):
1✔
663
            eigenvals = {Spin(int(k)): np.array(d["bands"][k]["data"]) for k in d["bands"]}
1✔
664
        else:
665
            eigenvals = {Spin(int(k)): d["bands"][k] for k in d["bands"]}
1✔
666

667
        if "structure" in d:
1✔
668
            structure = Structure.from_dict(d["structure"])
1✔
669

670
        try:
1✔
671
            if d.get("projections"):
1✔
672
                if isinstance(d["projections"]["1"][0][0], dict):
1✔
673
                    raise ValueError("Old band structure dict format detected!")
1✔
674
                projections = {Spin(int(spin)): np.array(v) for spin, v in d["projections"].items()}
1✔
675

676
            return cls(
1✔
677
                d["kpoints"],
678
                eigenvals,
679
                Lattice(d["lattice_rec"]["matrix"]),
680
                d["efermi"],
681
                labels_dict,
682
                structure=structure,
683
                projections=projections,
684
            )
685

686
        except Exception:
1✔
687
            warnings.warn(
1✔
688
                "Trying from_dict failed. Now we are trying the old "
689
                "format. Please convert your BS dicts to the new "
690
                "format. The old format will be retired in pymatgen "
691
                "5.0."
692
            )
693
            return cls.from_old_dict(d)
1✔
694

695
    @classmethod
1✔
696
    def from_old_dict(cls, d):
1✔
697
        """
698
        Args:
699
            d (dict): A dict with all data for a band structure symm line
700
                object.
701
        Returns:
702
            A BandStructureSymmLine object
703
        """
704
        # Strip the label to recover initial string (see trick used in as_dict to handle $ chars)
705
        labels_dict = {k.strip(): v for k, v in d["labels_dict"].items()}
1✔
706
        projections = {}
1✔
707
        structure = None
1✔
708
        if "projections" in d and len(d["projections"]) != 0:
1✔
709
            structure = Structure.from_dict(d["structure"])
1✔
710
            projections = {}
1✔
711
            for spin in d["projections"]:
1✔
712
                dd = []
1✔
713
                for i in range(len(d["projections"][spin])):
1✔
714
                    ddd = []
1✔
715
                    for j in range(len(d["projections"][spin][i])):
1✔
716
                        dddd = []
1✔
717
                        for k in range(len(d["projections"][spin][i][j])):
1✔
718
                            ddddd = []
1✔
719
                            orb = Orbital(k).name
1✔
720
                            for l in range(len(d["projections"][spin][i][j][orb])):
1✔
721
                                ddddd.append(d["projections"][spin][i][j][orb][l])
1✔
722
                            dddd.append(np.array(ddddd))
1✔
723
                        ddd.append(np.array(dddd))
1✔
724
                    dd.append(np.array(ddd))
1✔
725
                projections[Spin(int(spin))] = np.array(dd)
1✔
726

727
        return BandStructure(
1✔
728
            d["kpoints"],
729
            {Spin(int(k)): d["bands"][k] for k in d["bands"]},
730
            Lattice(d["lattice_rec"]["matrix"]),
731
            d["efermi"],
732
            labels_dict,
733
            structure=structure,
734
            projections=projections,
735
        )
736

737

738
class BandStructureSymmLine(BandStructure, MSONable):
1✔
739
    r"""
740
    This object stores band structures along selected (symmetry) lines in the
741
    Brillouin zone. We call the different symmetry lines (ex: \\Gamma to Z)
742
    "branches".
743
    """
744

745
    def __init__(
1✔
746
        self,
747
        kpoints,
748
        eigenvals,
749
        lattice,
750
        efermi,
751
        labels_dict,
752
        coords_are_cartesian=False,
753
        structure=None,
754
        projections=None,
755
    ):
756
        """
757
        Args:
758
            kpoints: list of kpoint as numpy arrays, in frac_coords of the
759
                given lattice by default
760
            eigenvals: dict of energies for spin up and spin down
761
                {Spin.up:[][],Spin.down:[][]}, the first index of the array
762
                [][] refers to the band and the second to the index of the
763
                kpoint. The kpoints are ordered according to the order of the
764
                kpoints array. If the band structure is not spin polarized, we
765
                only store one data set under Spin.up.
766
            lattice: The reciprocal lattice.
767
                Pymatgen uses the physics convention of reciprocal lattice vectors
768
                WITH a 2*pi coefficient
769
            efermi: fermi energy
770
            label_dict: (dict) of {} this link a kpoint (in frac coords or
771
                Cartesian coordinates depending on the coords).
772
            coords_are_cartesian: Whether coordinates are cartesian.
773
            structure: The crystal structure (as a pymatgen Structure object)
774
                associated with the band structure. This is needed if we
775
                provide projections to the band structure.
776
            projections: dict of orbital projections as {spin: ndarray}. The
777
                indices of the ndarrayare [band_index, kpoint_index, orbital_index,
778
                ion_index].If the band structure is not spin polarized, we only
779
                store one data set under Spin.up.
780
        """
781
        super().__init__(
1✔
782
            kpoints,
783
            eigenvals,
784
            lattice,
785
            efermi,
786
            labels_dict,
787
            coords_are_cartesian,
788
            structure,
789
            projections,
790
        )
791
        self.distance = []
1✔
792
        self.branches = []
1✔
793
        one_group = []
1✔
794
        branches_tmp = []
1✔
795
        # get labels and distance for each kpoint
796
        previous_kpoint = self.kpoints[0]
1✔
797
        previous_distance = 0.0
1✔
798

799
        previous_label = self.kpoints[0].label
1✔
800
        for i, kpt in enumerate(self.kpoints):
1✔
801
            label = kpt.label
1✔
802
            if label is not None and previous_label is not None:
1✔
803
                self.distance.append(previous_distance)
1✔
804
            else:
805
                self.distance.append(np.linalg.norm(kpt.cart_coords - previous_kpoint.cart_coords) + previous_distance)
1✔
806
            previous_kpoint = kpt
1✔
807
            previous_distance = self.distance[i]
1✔
808
            if label:
1✔
809
                if previous_label:
1✔
810
                    if len(one_group) != 0:
1✔
811
                        branches_tmp.append(one_group)
1✔
812
                    one_group = []
1✔
813
            previous_label = label
1✔
814
            one_group.append(i)
1✔
815

816
        if len(one_group) != 0:
1✔
817
            branches_tmp.append(one_group)
1✔
818
        for b in branches_tmp:
1✔
819
            self.branches.append(
1✔
820
                {
821
                    "start_index": b[0],
822
                    "end_index": b[-1],
823
                    "name": str(self.kpoints[b[0]].label) + "-" + str(self.kpoints[b[-1]].label),
824
                }
825
            )
826

827
        self.is_spin_polarized = False
1✔
828
        if len(self.bands) == 2:
1✔
829
            self.is_spin_polarized = True
1✔
830

831
    def get_equivalent_kpoints(self, index):
1✔
832
        """
833
        Returns the list of kpoint indices equivalent (meaning they are the
834
        same frac coords) to the given one.
835

836
        Args:
837
            index: the kpoint index
838

839
        Returns:
840
            a list of equivalent indices
841

842
        TODO: now it uses the label we might want to use coordinates instead
843
        (in case there was a mislabel)
844
        """
845
        # if the kpoint has no label it can"t have a repetition along the band
846
        # structure line object
847

848
        if self.kpoints[index].label is None:
1✔
849
            return [index]
1✔
850

851
        list_index_kpoints = []
1✔
852
        for i, kpt in enumerate(self.kpoints):
1✔
853
            if kpt.label == self.kpoints[index].label:
1✔
854
                list_index_kpoints.append(i)
1✔
855

856
        return list_index_kpoints
1✔
857

858
    def get_branch(self, index):
1✔
859
        r"""
860
        Returns in what branch(es) is the kpoint. There can be several
861
        branches.
862

863
        Args:
864
            index: the kpoint index
865

866
        Returns:
867
            A list of dictionaries [{"name","start_index","end_index","index"}]
868
            indicating all branches in which the k_point is. It takes into
869
            account the fact that one kpoint (e.g., \\Gamma) can be in several
870
            branches
871
        """
872
        to_return = []
1✔
873
        for i in self.get_equivalent_kpoints(index):
1✔
874
            for b in self.branches:
1✔
875
                if b["start_index"] <= i <= b["end_index"]:
1✔
876
                    to_return.append(
1✔
877
                        {
878
                            "name": b["name"],
879
                            "start_index": b["start_index"],
880
                            "end_index": b["end_index"],
881
                            "index": i,
882
                        }
883
                    )
884
        return to_return
1✔
885

886
    def apply_scissor(self, new_band_gap):
1✔
887
        """
888
        Apply a scissor operator (shift of the CBM) to fit the given band gap.
889
        If it's a metal. We look for the band crossing the fermi level
890
        and shift this one up. This will not work all the time for metals!
891

892
        Args:
893
            new_band_gap: the band gap the scissor band structure need to have.
894

895
        Returns:
896
            a BandStructureSymmLine object with the applied scissor shift
897
        """
898
        if self.is_metal():
1✔
899
            # moves then the highest index band crossing the fermi level
900
            # find this band...
901
            max_index = -1000
×
902
            # spin_index = None
903
            for i in range(self.nb_bands):
×
904
                below = False
×
905
                above = False
×
906
                for j in range(len(self.kpoints)):
×
907
                    if self.bands[Spin.up][i][j] < self.efermi:
×
908
                        below = True
×
909
                    if self.bands[Spin.up][i][j] > self.efermi:
×
910
                        above = True
×
911
                if above and below:
×
912
                    if i > max_index:
×
913
                        max_index = i
×
914
                        # spin_index = Spin.up
915
                if self.is_spin_polarized:
×
916
                    below = False
×
917
                    above = False
×
918
                    for j in range(len(self.kpoints)):
×
919
                        if self.bands[Spin.down][i][j] < self.efermi:
×
920
                            below = True
×
921
                        if self.bands[Spin.down][i][j] > self.efermi:
×
922
                            above = True
×
923
                    if above and below:
×
924
                        if i > max_index:
×
925
                            max_index = i
×
926
                            # spin_index = Spin.down
927
            old_dict = self.as_dict()
×
928
            shift = new_band_gap
×
929
            for spin in old_dict["bands"]:
×
930
                for k in range(len(old_dict["bands"][spin])):
×
931
                    for v in range(len(old_dict["bands"][spin][k])):
×
932
                        if k >= max_index:
×
933
                            old_dict["bands"][spin][k][v] = old_dict["bands"][spin][k][v] + shift
×
934
        else:
935
            shift = new_band_gap - self.get_band_gap()["energy"]
1✔
936
            old_dict = self.as_dict()
1✔
937
            for spin in old_dict["bands"]:
1✔
938
                for k in range(len(old_dict["bands"][spin])):
1✔
939
                    for v in range(len(old_dict["bands"][spin][k])):
1✔
940
                        if old_dict["bands"][spin][k][v] >= old_dict["cbm"]["energy"]:
1✔
941
                            old_dict["bands"][spin][k][v] = old_dict["bands"][spin][k][v] + shift
1✔
942
            old_dict["efermi"] = old_dict["efermi"] + shift
1✔
943
        return self.from_dict(old_dict)
1✔
944

945
    def as_dict(self):
1✔
946
        """
947
        JSON-serializable dict representation of BandStructureSymmLine.
948
        """
949
        d = super().as_dict()
1✔
950
        d["branches"] = self.branches
1✔
951
        return d
1✔
952

953

954
class LobsterBandStructureSymmLine(BandStructureSymmLine):
1✔
955
    """
956
    Lobster subclass of BandStructure with customized functions.
957
    """
958

959
    def as_dict(self):
1✔
960
        """
961
        JSON-serializable dict representation of BandStructureSymmLine.
962
        """
963
        d = {
1✔
964
            "@module": type(self).__module__,
965
            "@class": type(self).__name__,
966
            "lattice_rec": self.lattice_rec.as_dict(),
967
            "efermi": self.efermi,
968
            "kpoints": [],
969
        }
970
        # kpoints are not kpoint objects dicts but are frac coords (this makes
971
        # the dict smaller and avoids the repetition of the lattice
972
        for k in self.kpoints:
1✔
973
            d["kpoints"].append(k.as_dict()["fcoords"])
1✔
974
        d["branches"] = self.branches
1✔
975
        d["bands"] = {str(int(spin)): self.bands[spin].tolist() for spin in self.bands}
1✔
976
        d["is_metal"] = self.is_metal()
1✔
977
        vbm = self.get_vbm()
1✔
978
        d["vbm"] = {
1✔
979
            "energy": vbm["energy"],
980
            "kpoint_index": [int(x) for x in vbm["kpoint_index"]],
981
            "band_index": {str(int(spin)): vbm["band_index"][spin] for spin in vbm["band_index"]},
982
            "projections": {str(spin): v for spin, v in vbm["projections"].items()},
983
        }
984
        cbm = self.get_cbm()
1✔
985
        d["cbm"] = {
1✔
986
            "energy": cbm["energy"],
987
            "kpoint_index": [int(x) for x in cbm["kpoint_index"]],
988
            "band_index": {str(int(spin)): cbm["band_index"][spin] for spin in cbm["band_index"]},
989
            "projections": {str(spin): v for spin, v in cbm["projections"].items()},
990
        }
991
        d["band_gap"] = self.get_band_gap()
1✔
992
        d["labels_dict"] = {}
1✔
993
        d["is_spin_polarized"] = self.is_spin_polarized
1✔
994
        # MongoDB does not accept keys starting with $. Add a blank space to fix the problem
995
        for c, label in self.labels_dict.items():
1✔
996
            mongo_key = c if not c.startswith("$") else " " + c
1✔
997
            d["labels_dict"][mongo_key] = label.as_dict()["fcoords"]
1✔
998
        if len(self.projections) != 0:
1✔
999
            d["structure"] = self.structure.as_dict()
1✔
1000
            d["projections"] = {str(int(spin)): np.array(v).tolist() for spin, v in self.projections.items()}
1✔
1001
        return d
1✔
1002

1003
    @classmethod
1✔
1004
    def from_dict(cls, d):
1✔
1005
        """
1006
        Args:
1007
            d (dict): A dict with all data for a band structure symm line
1008
                object.
1009

1010
        Returns:
1011
            A BandStructureSymmLine object
1012
        """
1013
        try:
1✔
1014
            # Strip the label to recover initial string (see trick used in as_dict to handle $ chars)
1015
            labels_dict = {k.strip(): v for k, v in d["labels_dict"].items()}
1✔
1016
            projections = {}
1✔
1017
            structure = None
1✔
1018
            if d.get("projections"):
1✔
1019
                if isinstance(d["projections"]["1"][0][0], dict):
1✔
1020
                    raise ValueError("Old band structure dict format detected!")
1✔
1021
                structure = Structure.from_dict(d["structure"])
×
1022
                projections = {Spin(int(spin)): np.array(v) for spin, v in d["projections"].items()}
×
1023

1024
            return LobsterBandStructureSymmLine(
×
1025
                d["kpoints"],
1026
                {Spin(int(k)): d["bands"][k] for k in d["bands"]},
1027
                Lattice(d["lattice_rec"]["matrix"]),
1028
                d["efermi"],
1029
                labels_dict,
1030
                structure=structure,
1031
                projections=projections,
1032
            )
1033
        except Exception:
1✔
1034
            warnings.warn(
1✔
1035
                "Trying from_dict failed. Now we are trying the old "
1036
                "format. Please convert your BS dicts to the new "
1037
                "format. The old format will be retired in pymatgen "
1038
                "5.0."
1039
            )
1040
            return LobsterBandStructureSymmLine.from_old_dict(d)
1✔
1041

1042
    @classmethod
1✔
1043
    def from_old_dict(cls, d):
1✔
1044
        """
1045
        Args:
1046
            d (dict): A dict with all data for a band structure symm line
1047
                object.
1048
        Returns:
1049
            A BandStructureSymmLine object
1050
        """
1051
        # Strip the label to recover initial string (see trick used in as_dict to handle $ chars)
1052
        labels_dict = {k.strip(): v for k, v in d["labels_dict"].items()}
1✔
1053
        projections = {}
1✔
1054
        structure = None
1✔
1055
        if "projections" in d and len(d["projections"]) != 0:
1✔
1056
            structure = Structure.from_dict(d["structure"])
1✔
1057
            projections = {}
1✔
1058
            for spin in d["projections"]:
1✔
1059
                dd = []
1✔
1060
                for i in range(len(d["projections"][spin])):
1✔
1061
                    ddd = []
1✔
1062
                    for j in range(len(d["projections"][spin][i])):
1✔
1063
                        ddd.append(d["projections"][spin][i][j])
1✔
1064
                    dd.append(np.array(ddd))
1✔
1065
                projections[Spin(int(spin))] = np.array(dd)
1✔
1066

1067
        return LobsterBandStructureSymmLine(
1✔
1068
            d["kpoints"],
1069
            {Spin(int(k)): d["bands"][k] for k in d["bands"]},
1070
            Lattice(d["lattice_rec"]["matrix"]),
1071
            d["efermi"],
1072
            labels_dict,
1073
            structure=structure,
1074
            projections=projections,
1075
        )
1076

1077
    def get_projection_on_elements(self):
1✔
1078
        """
1079
        Method returning a dictionary of projections on elements.
1080
        It sums over all available orbitals for each element.
1081

1082
        Returns:
1083
            a dictionary in the {Spin.up:[][{Element:values}],
1084
            Spin.down:[][{Element:values}]} format
1085
            if there is no projections in the band structure
1086
            returns an empty dict
1087
        """
1088
        result = {}
1✔
1089
        for spin, v in self.projections.items():
1✔
1090
            result[spin] = [
1✔
1091
                [collections.defaultdict(float) for i in range(len(self.kpoints))] for j in range(self.nb_bands)
1092
            ]
1093
            for i, j in itertools.product(range(self.nb_bands), range(len(self.kpoints))):
1✔
1094
                for key, item in v[i][j].items():
1✔
1095
                    for item2 in item.values():
1✔
1096
                        specie = str(Element(re.split(r"[0-9]+", key)[0]))
1✔
1097
                        result[spin][i][j][specie] += item2
1✔
1098
        return result
1✔
1099

1100
    def get_projections_on_elements_and_orbitals(self, el_orb_spec):
1✔
1101
        """
1102
        Method returning a dictionary of projections on elements and specific
1103
        orbitals
1104

1105
        Args:
1106
            el_orb_spec: A dictionary of Elements and Orbitals for which we want
1107
                to have projections on. It is given as: {Element:[orbitals]},
1108
                e.g., {'Si':['3s','3p']} or {'Si':['3s','3p_x', '3p_y', '3p_z']} depending on input files
1109

1110
        Returns:
1111
            A dictionary of projections on elements in the
1112
            {Spin.up:[][{Element:{orb:values}}],
1113
            Spin.down:[][{Element:{orb:values}}]} format
1114
            if there is no projections in the band structure returns an empty
1115
            dict.
1116
        """
1117
        result = {}
1✔
1118
        el_orb_spec = {get_el_sp(el): orbs for el, orbs in el_orb_spec.items()}
1✔
1119
        for spin, v in self.projections.items():
1✔
1120
            result[spin] = [
1✔
1121
                [{str(e): collections.defaultdict(float) for e in el_orb_spec} for i in range(len(self.kpoints))]
1122
                for j in range(self.nb_bands)
1123
            ]
1124

1125
            for i, j in itertools.product(range(self.nb_bands), range(len(self.kpoints))):
1✔
1126
                for key, item in v[i][j].items():
1✔
1127
                    for key2, item2 in item.items():
1✔
1128
                        specie = str(Element(re.split(r"[0-9]+", key)[0]))
1✔
1129
                        if get_el_sp(str(specie)) in el_orb_spec:
1✔
1130
                            if key2 in el_orb_spec[get_el_sp(str(specie))]:
1✔
1131
                                result[spin][i][j][specie][key2] += item2
1✔
1132
        return result
1✔
1133

1134

1135
def get_reconstructed_band_structure(list_bs, efermi=None):
1✔
1136
    """
1137
    This method takes a list of band structures and reconstructs
1138
    one band structure object from all of them.
1139

1140
    This is typically very useful when you split non self consistent
1141
    band structure runs in several independent jobs and want to merge back
1142
    the results
1143

1144
    Args:
1145
        list_bs: A list of BandStructure or BandStructureSymmLine objects.
1146
        efermi: The Fermi energy of the reconstructed band structure. If
1147
            None is assigned an average of all the Fermi energy in each
1148
            object in the list_bs is used.
1149

1150
    Returns:
1151
        A BandStructure or BandStructureSymmLine object (depending on
1152
        the type of the list_bs objects)
1153
    """
1154
    if efermi is None:
1✔
1155
        efermi = sum(b.efermi for b in list_bs) / len(list_bs)
1✔
1156

1157
    kpoints = []
1✔
1158
    labels_dict = {}
1✔
1159
    rec_lattice = list_bs[0].lattice_rec
1✔
1160
    nb_bands = min(list_bs[i].nb_bands for i in range(len(list_bs)))
1✔
1161

1162
    kpoints = np.concatenate([[k.frac_coords for k in bs.kpoints] for bs in list_bs])
1✔
1163
    dicts = [bs.labels_dict for bs in list_bs]
1✔
1164
    labels_dict = {k: v.frac_coords for d in dicts for k, v in d.items()}
1✔
1165

1166
    eigenvals = {}
1✔
1167
    eigenvals[Spin.up] = np.concatenate([bs.bands[Spin.up][:nb_bands] for bs in list_bs], axis=1)
1✔
1168

1169
    if list_bs[0].is_spin_polarized:
1✔
1170
        eigenvals[Spin.down] = np.concatenate([bs.bands[Spin.down][:nb_bands] for bs in list_bs], axis=1)
×
1171

1172
    projections = {}
1✔
1173
    if len(list_bs[0].projections) != 0:
1✔
1174
        projs = [bs.projections[Spin.up][:nb_bands] for bs in list_bs]
×
1175
        projections[Spin.up] = np.concatenate(projs, axis=1)
×
1176

1177
        if list_bs[0].is_spin_polarized:
×
1178
            projs = [bs.projections[Spin.down][:nb_bands] for bs in list_bs]
×
1179
            projections[Spin.down] = np.concatenate(projs, axis=1)
×
1180

1181
    if isinstance(list_bs[0], BandStructureSymmLine):
1✔
1182
        return BandStructureSymmLine(
1✔
1183
            kpoints,
1184
            eigenvals,
1185
            rec_lattice,
1186
            efermi,
1187
            labels_dict,
1188
            structure=list_bs[0].structure,
1189
            projections=projections,
1190
        )
1191
    return BandStructure(
×
1192
        kpoints,
1193
        eigenvals,
1194
        rec_lattice,
1195
        efermi,
1196
        labels_dict,
1197
        structure=list_bs[0].structure,
1198
        projections=projections,
1199
    )
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