elecboltz 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Saleh Shamloo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: elecboltz
3
+ Version: 0.1.0
4
+ Summary: Boltzmann transport for conductivity of materials using an FEM
5
+ License-File: LICENSE
6
+ Requires-Dist: numpy>=1.26.4
7
+ Requires-Dist: scipy>=1.12.0
8
+ Requires-Dist: sympy>=1.10.1
9
+ Requires-Dist: scikit-image>=0.22.0
10
+ Dynamic: license-file
@@ -0,0 +1,29 @@
1
+ # FEM for Boltzmann Transport Conductivity
2
+ Conductivity calculated using Boltzmann Transport theory, using a Finite Element Method (FEM).
3
+ In addition to arbitrary Fermi surfaces, arbitrary scattering kernels are also supported.
4
+
5
+ Developed and maintained by the [Grissonnanche group](https://gaelgrissonnanche.com).
6
+
7
+ ## Installation
8
+ ```
9
+ pip install elecboltz
10
+ ```
11
+
12
+ ## Usage
13
+ Here is a minimal example for a simple free electron system.
14
+ ```python
15
+ import elecboltz
16
+
17
+ band = elecboltz.BandStructure(
18
+ dispersion="kx**2 + ky**2", chemical_potential=1.0,
19
+ unit_cell=(2*np.pi, 2*np.pi, 2*np.pi), periodic=2)
20
+ band.discretize()
21
+
22
+ cond = elecboltz.Conductivity(
23
+ band=band, scattering=1.0, field=(1.0, 2.0, 3.0))
24
+ sigma = cond.solve()
25
+ print(sigma)
26
+ ```
27
+
28
+ ## Documentation
29
+ The documentation is available at [elecboltz.readthedocs.io](https://elecboltz.readthedocs.io).
@@ -0,0 +1,3 @@
1
+ from .bandstructure import BandStructure
2
+ from .conductivity import Conductivity
3
+ from .params import easy_params
@@ -0,0 +1,399 @@
1
+ import numpy as np
2
+ import sympy
3
+ import itertools
4
+ from skimage.measure import marching_cubes
5
+ # units
6
+ from scipy.constants import hbar, eV, angstrom
7
+ # type hinting
8
+ from collections.abc import Collection
9
+ from .integrate import adaptive_octree_integrate
10
+ # conversion from energy gradient units to m/s for velocity
11
+ velocity_units = 1e-3 * eV * angstrom / hbar
12
+
13
+
14
+ class BandStructure:
15
+ """
16
+ Contains bandstructure information for a given material.
17
+
18
+ In addition to the dispersion relation and general parameters, this
19
+ class also contains methods for discretizing the Fermi surface and
20
+ calculating electronic properties.
21
+
22
+ Parameters
23
+ ----------
24
+ dispersion : str
25
+ The dispersion relation. Expresses the dispersion relation
26
+ in terms of symbols in `wavevector_names` and additional
27
+ parameters in `band_params`. It must be parsable and
28
+ differentiable by `sympy`. Energy units are milli eV.
29
+ chemical_potential : float
30
+ The chemical potential in milli eV.
31
+ unit_cell : Collection[float]
32
+ The dimensions of the unit cell in angstrom.
33
+ band_params : dict, optional
34
+ The parameters of the dispersion relation. Energy units are
35
+ milli eV and distance units are angstrom.
36
+ domain_size : Collection[float]
37
+ The ratio of the reciprocal space domain sidelengths to simple
38
+ cubic unit cell dimensions in reciprocal space. The product
39
+ of the numbers in this collection must be equal to the number
40
+ of atoms in the conventional unit cell specified by `unit_cell`.
41
+ periodic : bool or int or Collection[bool] or Collection[int]
42
+ If bool, whether periodic boundary conditions are applied to all
43
+ axes or not. If a single int, specifies which single axis is
44
+ periodic. If a collection, specifies which axes are periodic.
45
+ If the collection is of integers, the integers specify the
46
+ periodic axes, e.g. [0, 2] means periodic in x and z axes.
47
+ If the collection is of booleans, it specifies whether each axis
48
+ is periodic or not, e.g. [True, False, True] means periodic in
49
+ x and z axes, but not in y axis.
50
+ axis_names : str or Collection[str], optional
51
+ The names of the unit cell axes. Must be parsable by
52
+ `sympy.symbols`.
53
+ wavevector_names : str or Collection[str], optional
54
+ The names of the wavevector components. Must be parsable by
55
+ `sympy.symbols`.
56
+ resolution : int or Collection[int], optional
57
+ Controls the resolution of the grids used for discretizing the
58
+ Fermi surface. If a collection of integers is provided, each
59
+ element corresponds to the resolution along the respective
60
+ axis. If a single integer is provided, it is used for all axes.
61
+ ncorrect : int, optional
62
+ The number of correction steps for improving the accuracy of
63
+ the discretization of the Fermi surface.
64
+ sort_axis : int, optional
65
+ The axis along which to sort the points after triangulation.
66
+ If None, do not sort the points.
67
+
68
+ Attributes
69
+ ----------
70
+ dispersion : str
71
+ The dispersion relation. Updating this will automatically
72
+ update `energy_func` and `velocity_func`.
73
+ chemical_potential : float
74
+ The chemical potential in milli eV.
75
+ unit_cell : Collection[float]
76
+ The dimensions of the unit cell in angstrom.
77
+ domain_size : Collection[float]
78
+ The ratio of the reciprocal space domain sidelengths to simple
79
+ cubic unit cell dimensions in reciprocal space. The product
80
+ of the numbers in this collection must be equal to the number
81
+ of atoms in the conventional unit cell specified by `unit_cell`.
82
+ periodic : Collection[int]
83
+ The periodic axes.
84
+ band_params : dict
85
+ The parameters of the dispersion relation. Updating this
86
+ will automatically update `energy_func` and `velocity_func`.
87
+ energy_func : function
88
+ The energy function for the dispersion relation. Takes
89
+ kx, ky, and kz in angstrome^-1 as arguments and returns
90
+ the energy in milli eV.
91
+ velocity_func : function
92
+ The velocity function for the dispersion relation. Takes
93
+ kx, ky, and kz in angstrome^-1 as arguments and returns
94
+ the velocity vector as a list [vx, vy, vz] in units of m/s.
95
+ kpoints : (N, 3) numpy.ndarray
96
+ The discretized k-points on the Fermi surface. Each row
97
+ corresponds to a k-point in the form [kx, ky, kz].
98
+ kfaces : (F, 3) numpy.ndarray
99
+ The faces of the triangulated surface in k-space. Each row
100
+ corresponds to a face in the form [i, j, k], where i, j,
101
+ and k are the indices of the vertices of the face in the
102
+ `kpoints` array.
103
+ kpoints_periodic : (N, 3) numpy.ndarray of float
104
+ The kpoints on the Fermi surface with the duplicate boundary
105
+ points removed. Is only different from `kpoints` if `periodic`
106
+ is True.
107
+ kfaces_periodic : (F, 3) numpy.ndarray of int
108
+ Same as `kfaces`, but points to the unique points in
109
+ `kpoints_periodic`. is only different from `kfaces` if
110
+ `periodic` is True.
111
+ resolution : int or Collection[int]
112
+ The resolution of the grids used for approximating the Fermi
113
+ surface geometry with the marching cubes algorithm.
114
+ ncorrect : int
115
+ The number of Newton--Raphson steps applied to correct the
116
+ triangulated surface after the marching cubes algorithm.
117
+ sort_axis : int, optional
118
+ The axis along which to sort the points after triangulation.
119
+ axis_names : str or Collection[str]
120
+ The names of the unit cell axes.
121
+ wavevector_names : str or Collection[str]
122
+ The names of the wavevector components.
123
+ """
124
+ def __init__(
125
+ self, dispersion: str, chemical_potential: float,
126
+ unit_cell: Collection[float], band_params: dict = {},
127
+ domain_size: Collection[float] = [1.0, 1.0, 1.0],
128
+ periodic: bool | Collection[int|bool] = True,
129
+ axis_names: Collection[str] | str = ['a', 'b', 'c'],
130
+ wavevector_names: Collection[str] | str = ['kx', 'ky', 'kz'],
131
+ sort_axis: int = None, resolution: int | Collection[int] = 21,
132
+ ncorrect: int = 2, **kwargs):
133
+ # avoid triggering the __setattr__ method for the first time
134
+ super().__setattr__('dispersion', dispersion)
135
+ super().__setattr__('band_params', band_params)
136
+ self.chemical_potential = chemical_potential
137
+ self.unit_cell = unit_cell
138
+ self.domain_size = domain_size
139
+ self.periodic = periodic
140
+ self.axis_names = axis_names
141
+ self.wavevector_names = wavevector_names
142
+ self.resolution = resolution
143
+ self.ncorrect = ncorrect
144
+ self._parse_dispersion()
145
+ self.kpoints = None
146
+ self.kfaces = None
147
+ self.kpoints_periodic = None
148
+ self.kfaces_periodic = None
149
+ self.sort_axis = sort_axis
150
+
151
+ def __setattr__(self, name, value):
152
+ if name == 'dispersion' or name == 'band_params':
153
+ self._parse_dispersion()
154
+ if name == 'resolution':
155
+ if isinstance(value, Collection):
156
+ value = np.array(value)
157
+ else:
158
+ value = np.array([value, value, value])
159
+ if name in {'unit_cell', 'domain_size'}:
160
+ value = np.array(value, dtype=float)
161
+ if name == 'periodic':
162
+ if isinstance(value, bool):
163
+ if value:
164
+ value = [0, 1, 2]
165
+ else:
166
+ value = []
167
+ if isinstance(value, int):
168
+ value = [value]
169
+ elif isinstance(value, Collection) and all(
170
+ isinstance(i, bool) for i in value):
171
+ value = [i for i, v in enumerate(value) if v]
172
+ super().__setattr__(name, value)
173
+
174
+ def discretize(self):
175
+ """
176
+ Discretize the Fermi surface.
177
+
178
+ First, the surface is triangulated using the marching cubes
179
+ algorithm with `resolution` controlling the resolution of the
180
+ grid. Next, to improve the accuracy of the isosurface,
181
+ `ncorrect` steps of the Newton--Raphson root-finding method are
182
+ applied to the output of marching cubes. Finally, after the
183
+ surface construction, periodic boundary conditions are applied
184
+ to "stitch" the open ends of the surface together.
185
+ """
186
+ self._gvec = self.domain_size * np.pi / self.unit_cell
187
+ self.kpoints, self.kfaces, _, _ = marching_cubes(
188
+ self.energy_func(*np.mgrid[
189
+ -self._gvec[0]:self._gvec[0]:1j*self.resolution[0],
190
+ -self._gvec[1]:self._gvec[1]:1j*self.resolution[1],
191
+ -self._gvec[2]:self._gvec[2]:1j*self.resolution[2]]),
192
+ level=self.chemical_potential)
193
+ self.kpoints *= (2*self._gvec / (self.resolution-1))[None, :]
194
+ self.kpoints -= self._gvec[None, :]
195
+ for _ in range(self.ncorrect):
196
+ self.kpoints = self._apply_newton_correction(self.kpoints)
197
+ if self.sort_axis:
198
+ self._sort_and_reindex(self.sort_axis)
199
+ self._stitch_periodic_boundaries()
200
+
201
+ def calculate_filling_fraction(self, depth: int = 7) -> float:
202
+ """
203
+ Calculate the filling fraction n of the material.
204
+
205
+ The filling fraction is calculated by integrating the volume
206
+ in the reciprocal space having energy bellow the Fermi level
207
+ (calculated by an adaptive octree integration method), then
208
+ dividing by the volume of the unit cell in the reciprocal space.
209
+
210
+ Parameters
211
+ ----------
212
+ depth : int, optional
213
+ The depth of the adaptive octree integration. Higher values
214
+ result in more accurate integration, but take exponentially
215
+ longer to compute.
216
+
217
+ Returns
218
+ -------
219
+ float
220
+ The filling fraction n of the material.
221
+ """
222
+ self._gvec = self.domain_size * np.pi / self.unit_cell
223
+ # the extra factor of 2 is the spin degeneracy
224
+ return 2 * adaptive_octree_integrate(
225
+ lambda kx, ky, kz: (self.energy_func(kx, ky, kz)
226
+ < self.chemical_potential),
227
+ (-self._gvec[0], self._gvec[0], -self._gvec[1], self._gvec[1],
228
+ -self._gvec[2], self._gvec[2]), depth
229
+ ) / 8 / np.prod(self._gvec)
230
+
231
+ def calculate_electron_density(self, depth: int = 7) -> float:
232
+ """
233
+ Calculate the electron density n_e of the material.
234
+
235
+ Note that the surface needs to be discretized before calling
236
+ this method. First, the filling fraction is calculated (see
237
+ `calculate_filling_fraction`). The electron density is obtained
238
+ by dividing the filling fraction by the volume of the unit cell
239
+ in real space.
240
+
241
+ Parameters
242
+ ----------
243
+ depth : int, optional
244
+ The depth of the adaptive octree integration in
245
+ `calculate_filling_fraction`.
246
+
247
+ Returns
248
+ -------
249
+ float
250
+ The electron density n_e of the material in SI units.
251
+ """
252
+ filling_fraction = self.calculate_filling_fraction(depth)
253
+ # the volume of the unit cell in real space is scaled by
254
+ # the inverse scaling of the unit cell in reciprocal space
255
+ unit_cell_volume = (np.prod(self.unit_cell) * angstrom**3
256
+ / np.prod(self.domain_size))
257
+ return filling_fraction / unit_cell_volume
258
+
259
+ def calculate_mass(self):
260
+ """
261
+ Calculate the effective mass of the charge carries.
262
+
263
+ Returns
264
+ -------
265
+ float
266
+ The effective mass divided by the rest mass of
267
+ the electron, m_e.
268
+ """
269
+ # Placeholder for actual calculation
270
+ return 0.0
271
+
272
+ def _parse_dispersion(self):
273
+ """
274
+ Parse the dispersion relation and extract the necessary
275
+ information for further calculations.
276
+ """
277
+ ksymbols = sympy.symbols(self.wavevector_names)
278
+ all_symbols = (ksymbols + sympy.symbols(self.axis_names)
279
+ + sympy.symbols(list(self.band_params.keys())))
280
+ # symbolic expressions
281
+ self._energy_sympy = sympy.sympify(self.dispersion)
282
+ self._velocities_sympy = [
283
+ sympy.diff(self._energy_sympy, k) * velocity_units
284
+ for k in sympy.symbols(self.wavevector_names)]
285
+ velocity_magnitude_sympy = sympy.sqrt(
286
+ sum(v**2 for v in self._velocities_sympy))
287
+ vhats_sympy = [
288
+ v / velocity_magnitude_sympy if velocity_magnitude_sympy != 0
289
+ else 0 for v in self._velocities_sympy]
290
+ self._curvature_sympy = -0.5 * sum(
291
+ sympy.diff(vhat, k) for vhat, k in zip(vhats_sympy, ksymbols))
292
+ # replace zero velocities with zero arrays
293
+ for i, v in enumerate(self._velocities_sympy):
294
+ if v == 0:
295
+ self._velocities_sympy[i] = f"numpy.zeros_like({ksymbols[i]})"
296
+ # convert expressions into python functions
297
+ self._energy_func_full = sympy.lambdify(
298
+ all_symbols, self._energy_sympy)
299
+ self._velocity_funcs_full = [
300
+ sympy.lambdify(all_symbols, vexpr, 'numpy')
301
+ for vexpr in self._velocities_sympy]
302
+ self._curvature_func_full = sympy.lambdify(
303
+ all_symbols, self._curvature_sympy, 'numpy')
304
+ # reduce the arguments to only kx, ky, kz
305
+ self.energy_func = lambda kx, ky, kz: self._energy_func_full(
306
+ kx, ky, kz, *self.unit_cell, **self.band_params)
307
+ self.velocity_func = lambda kx, ky, kz: [
308
+ vfunc(kx, ky, kz, *self.unit_cell, **self.band_params)
309
+ for vfunc in self._velocity_funcs_full]
310
+ self.curvature_func = lambda kx, ky, kz: self._curvature_func_full(
311
+ kx, ky, kz, *self.unit_cell, **self.band_params)
312
+
313
+ def _sort_and_reindex(self, sort_axis):
314
+ new_order, self.kfaces = self._generate_reindex(sort_axis)
315
+ self.kpoints = self.kpoints[new_order]
316
+
317
+ def _generate_reindex(self, sort_axis):
318
+ new_order = np.argsort(self.kpoints[:, sort_axis])
319
+ old_to_new_map = np.empty(len(new_order), dtype=int)
320
+ old_to_new_map[new_order] = np.arange(len(new_order))
321
+ return new_order, old_to_new_map[self.kfaces]
322
+
323
+ def _stitch_periodic_boundaries(self):
324
+ """
325
+ Find duplicate points on the periodic boundaries, then make the
326
+ periodic mesh arrays.
327
+ """
328
+ duplicates = dict()
329
+ threshold = np.min(self._gvec / self.resolution) / 10
330
+ for axis in self.periodic:
331
+ low_border = np.argwhere(
332
+ self.kpoints[:, axis] + self._gvec[axis] < threshold).ravel()
333
+ high_border = np.argwhere(
334
+ self.kpoints[:, axis] - self._gvec[axis] > -threshold).ravel()
335
+ if len(low_border) == 0 or len(high_border) == 0:
336
+ continue
337
+
338
+ min_dist = min(
339
+ self._get_min_border_distance(low_border),
340
+ self._get_min_border_distance(high_border))
341
+
342
+ k1 = self.kpoints[low_border][None, :]
343
+ k2 = self.kpoints[high_border][:, None]
344
+ kdiff = k2 - k1
345
+ kdiff[:, :, axis] += self._gvec[axis]
346
+ kdiff[:, :, axis] %= 2 * self._gvec[axis]
347
+ kdiff[:, :, axis] -= self._gvec[axis]
348
+ kdiff = np.linalg.norm(kdiff, axis=-1)
349
+
350
+ min_pair = np.argmin(kdiff, axis=1)
351
+ is_duplicate = kdiff[np.arange(len(high_border)), min_pair
352
+ ] < min_dist / 2
353
+ duplicates.update(dict(zip(
354
+ high_border[is_duplicate],
355
+ low_border[min_pair[is_duplicate]])))
356
+ self._build_periodic_mesh(duplicates)
357
+
358
+ def _get_min_border_distance(self, border):
359
+ """
360
+ Find minimum intra-layer distance to set the threshold
361
+ for duplicate point detection.
362
+ """
363
+ is_triangle_point_in_border = np.isin(self.kfaces, border)
364
+ border_triangles = self.kfaces[np.any(
365
+ is_triangle_point_in_border, axis=1)]
366
+ points = self.kpoints[border_triangles]
367
+ is_triangle_point_in_border = is_triangle_point_in_border[
368
+ np.any(is_triangle_point_in_border, axis=1)]
369
+ is_pair_intra_layer = np.logical_xor(
370
+ is_triangle_point_in_border,
371
+ np.roll(is_triangle_point_in_border, 1, axis=-1))
372
+ return np.min(np.linalg.norm(
373
+ (points - np.roll(points, 1, axis=1))[is_pair_intra_layer],
374
+ axis=-1))
375
+
376
+ def _build_periodic_mesh(self, duplicates):
377
+ """
378
+ Build the periodic kpoints and kfaces arrays by removing
379
+ duplicate points and reindexing.
380
+ """
381
+ unique_mask = np.full(len(self.kpoints), True)
382
+ unique_mask[list(duplicates.keys())] = False
383
+ self.kpoints_periodic = self.kpoints[unique_mask]
384
+ reindex_map = np.cumsum(unique_mask) - 1
385
+ self.kfaces_periodic = np.empty_like(self.kfaces)
386
+ for i, face in enumerate(self.kfaces):
387
+ for j, point in enumerate(face):
388
+ if point in duplicates:
389
+ reindex_map[point] = reindex_map[
390
+ duplicates[point]]
391
+ self.kfaces_periodic[i, j] = reindex_map[point]
392
+
393
+ def _apply_newton_correction(self, points):
394
+ residuals = self.energy_func(
395
+ points[:, 0], points[:, 1], points[:, 2]) - self.chemical_potential
396
+ gradients = np.column_stack(self.velocity_func(
397
+ points[:, 0], points[:, 1], points[:, 2])) / velocity_units
398
+ gradient_norms = np.linalg.norm(gradients, axis=-1)
399
+ return points - (residuals/gradient_norms**2)[:, None]*gradients