diffpes 2026.3.1__py3-none-any.whl

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.
Files changed (55) hide show
  1. diffpes/__init__.py +65 -0
  2. diffpes/inout/__init__.py +88 -0
  3. diffpes/inout/chgcar.py +459 -0
  4. diffpes/inout/doscar.py +248 -0
  5. diffpes/inout/eigenval.py +258 -0
  6. diffpes/inout/hdf5.py +818 -0
  7. diffpes/inout/helpers.py +295 -0
  8. diffpes/inout/kpoints.py +433 -0
  9. diffpes/inout/plotting.py +757 -0
  10. diffpes/inout/poscar.py +174 -0
  11. diffpes/inout/procar.py +331 -0
  12. diffpes/inout/py.typed +0 -0
  13. diffpes/maths/__init__.py +68 -0
  14. diffpes/maths/dipole.py +368 -0
  15. diffpes/maths/gaunt.py +496 -0
  16. diffpes/maths/spherical_harmonics.py +336 -0
  17. diffpes/py.typed +0 -0
  18. diffpes/radial/__init__.py +47 -0
  19. diffpes/radial/bessel.py +198 -0
  20. diffpes/radial/integrate.py +128 -0
  21. diffpes/radial/wavefunctions.py +335 -0
  22. diffpes/simul/__init__.py +146 -0
  23. diffpes/simul/broadening.py +258 -0
  24. diffpes/simul/crosssections.py +196 -0
  25. diffpes/simul/expanded.py +1021 -0
  26. diffpes/simul/forward.py +586 -0
  27. diffpes/simul/oam.py +112 -0
  28. diffpes/simul/polarization.py +443 -0
  29. diffpes/simul/py.typed +0 -0
  30. diffpes/simul/resolution.py +122 -0
  31. diffpes/simul/self_energy.py +142 -0
  32. diffpes/simul/spectrum.py +1116 -0
  33. diffpes/simul/workflow.py +424 -0
  34. diffpes/tightb/__init__.py +68 -0
  35. diffpes/tightb/diagonalize.py +340 -0
  36. diffpes/tightb/hamiltonian.py +286 -0
  37. diffpes/tightb/projections.py +134 -0
  38. diffpes/types/__init__.py +162 -0
  39. diffpes/types/aliases.py +52 -0
  40. diffpes/types/bands.py +1064 -0
  41. diffpes/types/dos.py +510 -0
  42. diffpes/types/geometry.py +306 -0
  43. diffpes/types/kpath.py +365 -0
  44. diffpes/types/params.py +496 -0
  45. diffpes/types/py.typed +0 -0
  46. diffpes/types/radial_params.py +482 -0
  47. diffpes/types/self_energy.py +271 -0
  48. diffpes/types/tb_model.py +531 -0
  49. diffpes/types/volumetric.py +608 -0
  50. diffpes/utils/__init__.py +30 -0
  51. diffpes/utils/math.py +255 -0
  52. diffpes/utils/py.typed +0 -0
  53. diffpes-2026.3.1.dist-info/METADATA +176 -0
  54. diffpes-2026.3.1.dist-info/RECORD +55 -0
  55. diffpes-2026.3.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,248 @@
1
+ """VASP DOSCAR file parser.
2
+
3
+ Extended Summary
4
+ ----------------
5
+ Reads VASP DOSCAR files and returns a
6
+ :class:`~diffpes.types.DensityOfStates` or
7
+ :class:`~diffpes.types.FullDensityOfStates` PyTree depending on
8
+ the ``return_mode`` parameter.
9
+
10
+ Routine Listings
11
+ ----------------
12
+ :func:`read_doscar`
13
+ Parse a VASP DOSCAR file into a DensityOfStates or
14
+ FullDensityOfStates.
15
+
16
+ Notes
17
+ -----
18
+ Handles both spin-polarized (ISPIN=2) and non-polarized (ISPIN=1)
19
+ DOSCAR formats. The Fermi level is extracted directly from the
20
+ file header.
21
+ """
22
+
23
+ from pathlib import Path
24
+
25
+ import jax.numpy as jnp
26
+ import numpy as np
27
+ from beartype.typing import Literal, Optional, Union
28
+ from jaxtyping import Array, Float
29
+
30
+ from diffpes.types import (
31
+ DensityOfStates,
32
+ FullDensityOfStates,
33
+ make_density_of_states,
34
+ make_full_density_of_states,
35
+ )
36
+
37
+ _NONSPIN_COLS: int = 3
38
+ _SPIN_COLS: int = 5
39
+
40
+
41
+ def read_doscar( # noqa: PLR0912, PLR0915
42
+ filename: str = "DOSCAR",
43
+ return_mode: Literal["legacy", "full"] = "legacy",
44
+ ) -> Union[DensityOfStates, FullDensityOfStates]:
45
+ """Parse a VASP DOSCAR file.
46
+
47
+ Reads a VASP DOSCAR file containing total (and optionally
48
+ site-projected) density of states on a uniform energy grid.
49
+
50
+ Extended Summary
51
+ ----------------
52
+ The DOSCAR file format written by VASP consists of:
53
+
54
+ * **Header** (6 lines):
55
+ - Line 1: system header with ``NATOMS`` as the first integer.
56
+ - Lines 2-5: additional metadata (unused here).
57
+ - Line 6: ``EMIN EMAX NEDOS EFERMI ...`` -- energy window
58
+ bounds, number of DOS grid points, and the Fermi energy.
59
+
60
+ * **Total DOS block** (``NEDOS`` lines): each line contains the
61
+ energy value followed by density-of-states columns.
62
+
63
+ - ISPIN=1: 3 columns -- ``energy, DOS_up, intDOS_up``.
64
+ - ISPIN=2: 5 columns -- ``energy, DOS_up, DOS_down, intDOS_up,
65
+ intDOS_down``.
66
+
67
+ * **Per-atom PDOS blocks** (optional, ``NATOMS`` blocks of
68
+ ``NEDOS`` lines each): each block begins with a header line
69
+ identical in format to line 6 of the main header, followed by
70
+ orbital-projected DOS values. Column counts vary depending on
71
+ ``LORBIT`` and spin polarization.
72
+
73
+ Implementation Logic
74
+ --------------------
75
+ 1. **Parse header** -- read the first 6 lines. Extract ``NATOMS``
76
+ from line 1 and ``NEDOS`` / ``EFERMI`` from line 6.
77
+
78
+ 2. **Read total DOS block** -- peek at the first data line to
79
+ determine the column count (3 for non-spin, 5 for spin).
80
+ Read all ``NEDOS`` rows into a ``(NEDOS, ncols)`` array.
81
+
82
+ 3. **Legacy mode early return** -- if ``return_mode == "legacy"``,
83
+ extract only ``energy`` (column 0) and ``total_dos`` (column 1,
84
+ i.e. spin-up DOS) and return a ``DensityOfStates``.
85
+
86
+ 4. **Full mode column extraction** -- split columns into spin-up
87
+ DOS, optional spin-down DOS, and integrated DOS arrays.
88
+
89
+ 5. **Read PDOS blocks** -- for each of the ``NATOMS`` atoms:
90
+
91
+ a. Read the per-atom header line (EMIN/EMAX/NEDOS/EFERMI).
92
+ b. Read the first data line to determine the PDOS column count.
93
+ c. Read the remaining ``NEDOS - 1`` lines.
94
+ d. Strip the energy column (column 0) and store the orbital
95
+ columns.
96
+
97
+ 6. **Stack PDOS** -- if any PDOS blocks were successfully read,
98
+ stack them into a ``(NATOMS, NEDOS, C)`` array where ``C`` is
99
+ the number of orbital columns.
100
+
101
+ 7. **Construct return value** -- build a ``FullDensityOfStates``
102
+ PyTree containing all extracted arrays.
103
+
104
+ Parameters
105
+ ----------
106
+ filename : str, optional
107
+ Path to DOSCAR file. Default is ``"DOSCAR"``.
108
+ return_mode : {"legacy", "full"}, optional
109
+ ``"legacy"`` (default) returns a ``DensityOfStates`` with
110
+ only spin-up total DOS (backward-compatible). ``"full"``
111
+ returns a ``FullDensityOfStates`` with both spin channels,
112
+ integrated DOS, and PDOS blocks when present.
113
+
114
+ Returns
115
+ -------
116
+ dos : DensityOfStates or FullDensityOfStates
117
+ Density of states data.
118
+
119
+ Notes
120
+ -----
121
+ In ``"full"`` mode the parser also reads per-atom PDOS blocks
122
+ that follow the total DOS section. Each atom's PDOS block has
123
+ the same number of energy grid points (``NEDOS``) as the total
124
+ DOS block. The PDOS orbital ordering follows the VASP convention
125
+ determined by ``LORBIT`` (e.g. ``s, p_y, p_z, p_x, d_{xy}, ...``
126
+ for ``LORBIT=11``). The Fermi energy stored in the returned
127
+ object is taken directly from the file header (line 6, column 4).
128
+ """
129
+ path: Path = Path(filename)
130
+ with path.open("r") as fid:
131
+ header: list[str] = fid.readline().split()
132
+ natoms: int = int(header[0])
133
+ fid.readline()
134
+ fid.readline()
135
+ fid.readline()
136
+ fid.readline()
137
+ meta: list[float] = [float(x) for x in fid.readline().split()]
138
+ nedos: int = int(meta[2])
139
+ efermi: float = meta[3]
140
+
141
+ # Read total DOS block
142
+ first_line: str = fid.readline()
143
+ first_vals: list[float] = [float(x) for x in first_line.split()]
144
+ ncols: int = len(first_vals)
145
+ data: np.ndarray = np.zeros((nedos, ncols), dtype=np.float64)
146
+ data[0, :] = first_vals
147
+ for i in range(1, nedos):
148
+ vals: list[float] = [float(x) for x in fid.readline().split()]
149
+ data[i, :] = vals
150
+
151
+ if return_mode == "legacy":
152
+ energy: Float[Array, " E"] = jnp.asarray(
153
+ data[:, 0], dtype=jnp.float64
154
+ )
155
+ total_dos: Float[Array, " E"] = jnp.asarray(
156
+ data[:, 1], dtype=jnp.float64
157
+ )
158
+ result_legacy: DensityOfStates = make_density_of_states(
159
+ energy=energy,
160
+ total_dos=total_dos,
161
+ fermi_energy=efermi,
162
+ )
163
+ return result_legacy
164
+
165
+ # Full mode: extract all columns
166
+ is_spin: bool = ncols == _SPIN_COLS
167
+ energy_arr: Float[Array, " E"] = jnp.asarray(data[:, 0], dtype=jnp.float64)
168
+ dos_up_arr: Float[Array, " E"] = jnp.asarray(data[:, 1], dtype=jnp.float64)
169
+ dos_down_arr: Optional[Float[Array, " E"]] = None
170
+ int_up_arr: Float[Array, " E"]
171
+ int_down_arr: Optional[Float[Array, " E"]] = None
172
+
173
+ if is_spin:
174
+ dos_down_arr = jnp.asarray(data[:, 2], dtype=jnp.float64)
175
+ int_up_arr = jnp.asarray(data[:, 3], dtype=jnp.float64)
176
+ int_down_arr = jnp.asarray(data[:, 4], dtype=jnp.float64)
177
+ else:
178
+ int_up_arr = jnp.asarray(data[:, 2], dtype=jnp.float64)
179
+
180
+ # Read PDOS blocks if present
181
+ pdos_arr: Optional[Float[Array, "A E C"]] = None
182
+ pdos_blocks: list[np.ndarray] = []
183
+ for _atom in range(natoms):
184
+ # Each PDOS block may have a header line
185
+ # repeating EMIN EMAX NEDOS EFERMI
186
+ # or just start with data lines
187
+ line: str = fid.readline()
188
+ if not line or not line.strip():
189
+ break
190
+ # Check if this is a PDOS header (same format as total DOS header)
191
+ line_vals: list[float] = [float(x) for x in line.split()]
192
+ if _NONSPIN_COLS <= len(line_vals) <= _SPIN_COLS:
193
+ # Could be either a header or short PDOS line
194
+ # PDOS header has same EMIN EMAX NEDOS EFERMI format
195
+ # We detect by checking if first value matches energy range
196
+ # Actually, DOSCAR PDOS blocks always have a header line
197
+ pdos_ncols_check: str = fid.readline()
198
+ if not pdos_ncols_check.strip():
199
+ break
200
+ pdos_first: list[float] = [
201
+ float(x) for x in pdos_ncols_check.split()
202
+ ]
203
+ pdos_ncols: int = len(pdos_first)
204
+ atom_data: np.ndarray = np.zeros(
205
+ (nedos, pdos_ncols), dtype=np.float64
206
+ )
207
+ atom_data[0, :] = pdos_first
208
+ for j in range(1, nedos):
209
+ row_line: str = fid.readline()
210
+ if not row_line.strip():
211
+ break
212
+ atom_data[j, :] = [float(x) for x in row_line.split()]
213
+ # Store only the orbital columns (skip energy column 0)
214
+ pdos_blocks.append(atom_data[:, 1:])
215
+ else:
216
+ # This line is the first PDOS data line (no header)
217
+ pdos_ncols = len(line_vals)
218
+ atom_data = np.zeros((nedos, pdos_ncols), dtype=np.float64)
219
+ atom_data[0, :] = line_vals
220
+ for j in range(1, nedos):
221
+ row_line = fid.readline()
222
+ if not row_line.strip():
223
+ break
224
+ atom_data[j, :] = [float(x) for x in row_line.split()]
225
+ pdos_blocks.append(atom_data[:, 1:])
226
+
227
+ if pdos_blocks:
228
+ # Stack into (A, E, C) array
229
+ pdos_arr = jnp.asarray(
230
+ np.stack(pdos_blocks, axis=0), dtype=jnp.float64
231
+ )
232
+
233
+ result_full: FullDensityOfStates = make_full_density_of_states(
234
+ energy=energy_arr,
235
+ total_dos_up=dos_up_arr,
236
+ integrated_dos_up=int_up_arr,
237
+ fermi_energy=efermi,
238
+ total_dos_down=dos_down_arr,
239
+ integrated_dos_down=int_down_arr,
240
+ pdos=pdos_arr,
241
+ natoms=natoms,
242
+ )
243
+ return result_full
244
+
245
+
246
+ __all__: list[str] = [
247
+ "read_doscar",
248
+ ]
@@ -0,0 +1,258 @@
1
+ """VASP EIGENVAL file parser.
2
+
3
+ Extended Summary
4
+ ----------------
5
+ Reads VASP EIGENVAL files containing electronic band energies and
6
+ returns a :class:`~diffpes.types.BandStructure` or
7
+ :class:`~diffpes.types.SpinBandStructure` PyTree depending on the
8
+ ``return_mode`` parameter.
9
+
10
+ Routine Listings
11
+ ----------------
12
+ :func:`read_eigenval`
13
+ Parse a VASP EIGENVAL file into a BandStructure or
14
+ SpinBandStructure.
15
+
16
+ Notes
17
+ -----
18
+ Handles both spin-polarized (ISPIN=2) and non-polarized (ISPIN=1)
19
+ calculations. Bands are sorted by energy within each k-point.
20
+ """
21
+
22
+ from pathlib import Path
23
+
24
+ import jax.numpy as jnp
25
+ import numpy as np
26
+ from beartype.typing import Literal, Optional, Union
27
+
28
+ from diffpes.types import (
29
+ BandStructure,
30
+ SpinBandStructure,
31
+ make_band_structure,
32
+ make_spin_band_structure,
33
+ )
34
+
35
+ _ISPIN_SPIN_POLARIZED: int = 2
36
+ _KPOINT_LINE_VALUES: int = 4
37
+ _BAND_LINE_MIN_VALUES: int = 2
38
+ _BAND_LINE_SPIN_VALUES: int = 3
39
+ _EIG_UP_INDEX: int = 1
40
+ _EIG_DOWN_INDEX: int = 2
41
+
42
+
43
+ def read_eigenval(
44
+ filename: str = "EIGENVAL",
45
+ fermi_energy: float = 0.0,
46
+ return_mode: Literal["legacy", "full"] = "legacy",
47
+ ) -> Union[BandStructure, SpinBandStructure]:
48
+ """Parse a VASP EIGENVAL file.
49
+
50
+ Reads a VASP EIGENVAL file that contains electronic eigenvalues
51
+ (band energies) at each k-point. Supports both ISPIN=1 (non-
52
+ polarized) and ISPIN=2 (spin-polarized) calculations.
53
+
54
+ Extended Summary
55
+ ----------------
56
+ The EIGENVAL file format written by VASP consists of:
57
+
58
+ * **Header** (6 lines):
59
+ - Line 1: four integers including ``ISPIN`` as the 4th value.
60
+ ``ISPIN=2`` indicates spin-polarized eigenvalues.
61
+ - Lines 2-5: additional metadata (unused here).
62
+ - Line 6: ``NELECT NKPOINTS NBANDS`` -- number of electrons,
63
+ k-points, and bands.
64
+
65
+ * **K-point / band blocks** (``NKPOINTS`` blocks): each block
66
+ starts with a k-point line of 4 floats (``kx ky kz weight``),
67
+ followed by ``NBANDS`` eigenvalue lines.
68
+
69
+ - ISPIN=1: each band line has 2 values -- ``band_index energy``.
70
+ - ISPIN=2: each band line has 3 values --
71
+ ``band_index energy_up energy_down``.
72
+
73
+ Implementation Logic
74
+ --------------------
75
+ 1. **Parse header** -- read the first 6 lines. Extract ``ISPIN``
76
+ from line 1 (4th integer) and ``NKPOINTS`` / ``NBANDS`` from
77
+ line 6.
78
+
79
+ 2. **Allocate arrays** -- create ``(NKPOINTS, 4)`` for k-point
80
+ coordinates+weights and ``(NKPOINTS, NBANDS)`` for spin-up
81
+ eigenvalues. If ``ISPIN=2``, allocate a matching spin-down
82
+ array.
83
+
84
+ 3. **Read k-point blocks** -- for each k-point:
85
+
86
+ a. Read the next non-empty line and parse 4 floats for the
87
+ k-point coordinates and weight.
88
+ b. For each band, read the next non-empty line and extract the
89
+ eigenvalue(s). For spin-polarized files, store both spin-up
90
+ (column 1) and spin-down (column 2).
91
+
92
+ 4. **Sort eigenvalues** -- sort bands by energy within each
93
+ k-point (ascending) for both spin channels. This ensures
94
+ consistent band ordering even if VASP wrote them unordered.
95
+
96
+ 5. **Construct return value**:
97
+
98
+ * ``return_mode="legacy"`` or ISPIN=1: return a
99
+ ``BandStructure`` with only spin-up eigenvalues, 3D k-point
100
+ coordinates, and k-point weights.
101
+ * ``return_mode="full"`` with ISPIN=2: return a
102
+ ``SpinBandStructure`` containing both spin channels.
103
+
104
+ Parameters
105
+ ----------
106
+ filename : str, optional
107
+ Path to EIGENVAL file. Default is ``"EIGENVAL"``.
108
+ fermi_energy : float, optional
109
+ Fermi level in eV used to reference the eigenvalues.
110
+ Default is 0.0.
111
+ return_mode : {"legacy", "full"}, optional
112
+ ``"legacy"`` (default) returns a ``BandStructure`` with only
113
+ spin-up eigenvalues (backward-compatible). ``"full"`` returns
114
+ a ``SpinBandStructure`` with both spin channels when ISPIN=2,
115
+ or a ``BandStructure`` when ISPIN=1.
116
+
117
+ Returns
118
+ -------
119
+ bands : BandStructure or SpinBandStructure
120
+ Band structure with eigenvalues and k-points. The type
121
+ depends on ``return_mode`` and the spin polarization.
122
+
123
+ Raises
124
+ ------
125
+ ValueError
126
+ If a k-point line has fewer than 4 values, or a band line
127
+ has fewer values than expected for the given ISPIN, or if
128
+ EOF is encountered before all blocks are read.
129
+
130
+ Notes
131
+ -----
132
+ For spin-polarized calculations (ISPIN=2), each eigenvalue line
133
+ contains three columns (index, energy-up, energy-down). In
134
+ ``"legacy"`` mode only the spin-up energy is extracted. In
135
+ ``"full"`` mode both channels are preserved in a
136
+ ``SpinBandStructure``. The Fermi energy is **not** embedded in
137
+ the EIGENVAL file; it must be obtained separately (e.g. from
138
+ DOSCAR or OUTCAR). Eigenvalues are stored as-is from the file
139
+ (not shifted by ``fermi_energy``); the Fermi energy is carried
140
+ alongside the eigenvalues in the returned PyTree for downstream
141
+ consumers to apply the shift if needed.
142
+ """
143
+ path: Path = Path(filename)
144
+ with path.open("r") as fid:
145
+ header: list[int] = [int(x) for x in fid.readline().split()]
146
+ ispin: int = header[3]
147
+ fid.readline()
148
+ fid.readline()
149
+ fid.readline()
150
+ fid.readline()
151
+ meta: list[int] = [int(x) for x in fid.readline().split()]
152
+ _nelect: int = meta[0]
153
+ nkpoints: int = meta[1]
154
+ nbands: int = meta[2]
155
+ kpoints: np.ndarray = np.zeros((nkpoints, 4), dtype=np.float64)
156
+ eigenvalues_up: np.ndarray = np.zeros(
157
+ (nkpoints, nbands), dtype=np.float64
158
+ )
159
+ eigenvalues_down: Optional[np.ndarray] = None
160
+ if ispin == _ISPIN_SPIN_POLARIZED:
161
+ eigenvalues_down = np.zeros((nkpoints, nbands), dtype=np.float64)
162
+ for k in range(nkpoints):
163
+ kpoint_line: str = _read_next_nonempty_line(fid)
164
+ if not kpoint_line:
165
+ msg = "Unexpected EOF while reading EIGENVAL k-point block."
166
+ raise ValueError(msg)
167
+ kpoint_vals: list[float] = [float(x) for x in kpoint_line.split()]
168
+ if len(kpoint_vals) < _KPOINT_LINE_VALUES:
169
+ msg = "Invalid EIGENVAL k-point line; expected 4 values."
170
+ raise ValueError(msg)
171
+ kpoints[k, :] = kpoint_vals[:_KPOINT_LINE_VALUES]
172
+ for b in range(nbands):
173
+ band_line: str = _read_next_nonempty_line(fid)
174
+ if not band_line:
175
+ msg = "Unexpected EOF while reading EIGENVAL band line."
176
+ raise ValueError(msg)
177
+ vals: list[float] = [float(x) for x in band_line.split()]
178
+ if len(vals) < _BAND_LINE_MIN_VALUES:
179
+ msg = "Invalid EIGENVAL band line; expected band energy."
180
+ raise ValueError(msg)
181
+ eigenvalues_up[k, b] = vals[_EIG_UP_INDEX]
182
+ if (
183
+ ispin == _ISPIN_SPIN_POLARIZED
184
+ and eigenvalues_down is not None
185
+ ):
186
+ if len(vals) < _BAND_LINE_SPIN_VALUES:
187
+ msg = (
188
+ "Invalid spin-polarized EIGENVAL band line; "
189
+ "expected spin-down energy."
190
+ )
191
+ raise ValueError(msg)
192
+ eigenvalues_down[k, b] = vals[_EIG_DOWN_INDEX]
193
+ eigenvalues_up = np.sort(eigenvalues_up, axis=1)
194
+ if eigenvalues_down is not None:
195
+ eigenvalues_down = np.sort(eigenvalues_down, axis=1)
196
+
197
+ if (
198
+ return_mode == "full"
199
+ and ispin == _ISPIN_SPIN_POLARIZED
200
+ and eigenvalues_down is not None
201
+ ):
202
+ return make_spin_band_structure(
203
+ eigenvalues_up=jnp.asarray(eigenvalues_up),
204
+ eigenvalues_down=jnp.asarray(eigenvalues_down),
205
+ kpoints=jnp.asarray(kpoints[:, :3]),
206
+ kpoint_weights=jnp.asarray(kpoints[:, 3]),
207
+ fermi_energy=fermi_energy,
208
+ )
209
+ bands: BandStructure = make_band_structure(
210
+ eigenvalues=jnp.asarray(eigenvalues_up),
211
+ kpoints=jnp.asarray(kpoints[:, :3]),
212
+ kpoint_weights=jnp.asarray(kpoints[:, 3]),
213
+ fermi_energy=fermi_energy,
214
+ )
215
+ return bands
216
+
217
+
218
+ def _read_next_nonempty_line(fid) -> str: # noqa: ANN001
219
+ """Read and return the next non-empty line, or ``""`` at EOF.
220
+
221
+ Extended Summary
222
+ ----------------
223
+ Helper that skips blank lines in the EIGENVAL file. VASP separates
224
+ k-point blocks with empty lines; this function transparently
225
+ consumes them so that the main parsing loop does not need to handle
226
+ blank-line logic.
227
+
228
+ Implementation Logic
229
+ --------------------
230
+ 1. Call ``fid.readline()`` in a loop.
231
+ 2. If the returned string is falsy (empty string ``""``), EOF has
232
+ been reached -- return ``""``.
233
+ 3. If the stripped line is non-empty, return the original
234
+ (unstripped) line.
235
+ 4. Otherwise continue to the next line.
236
+
237
+ Parameters
238
+ ----------
239
+ fid : file-like
240
+ Open file handle positioned somewhere within the EIGENVAL file.
241
+
242
+ Returns
243
+ -------
244
+ line : str
245
+ The next non-empty line (with trailing newline), or ``""`` if
246
+ the end of the file is reached.
247
+ """
248
+ while True:
249
+ line: str = fid.readline()
250
+ if not line:
251
+ return ""
252
+ if line.strip():
253
+ return line
254
+
255
+
256
+ __all__: list[str] = [
257
+ "read_eigenval",
258
+ ]