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
diffpes/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """Differentiable ARPES simulations in JAX.
2
+
3
+ Extended Summary
4
+ ----------------
5
+ A comprehensive toolkit for Angle-Resolved PhotoEmission
6
+ Spectroscopy (ARPES) simulations using JAX for automatic
7
+ differentiation and GPU acceleration. Supports five levels
8
+ of physical sophistication from basic Gaussian convolution
9
+ to full polarization-dependent dipole matrix element
10
+ calculations.
11
+
12
+ Routine Listings
13
+ ----------------
14
+ :mod:`inout`
15
+ VASP file parsers (POSCAR, EIGENVAL, KPOINTS, DOSCAR, PROCAR).
16
+ :mod:`radial`
17
+ Differentiable radial primitives (Bessel, wavefunctions, integrals).
18
+ :mod:`simul`
19
+ ARPES simulation functions at five complexity levels.
20
+ :mod:`types`
21
+ PyTree data structures and factory functions.
22
+ :mod:`utils`
23
+ Mathematical utilities (Faddeeva function, normalization).
24
+
25
+ Examples
26
+ --------
27
+ >>> import diffpes
28
+ >>> bands = diffpes.inout.read_eigenval("EIGENVAL", fermi_energy=-1.5)
29
+ >>> orb = diffpes.inout.read_procar("PROCAR")
30
+ >>> params = diffpes.types.make_simulation_params(sigma=0.04)
31
+ >>> spectrum = diffpes.simul.simulate_basic(bands, orb, params)
32
+
33
+ Notes
34
+ -----
35
+ All computations are JAX-compatible and support automatic
36
+ differentiation for gradient-based optimization of ARPES
37
+ simulation parameters.
38
+ """
39
+
40
+ import os
41
+ from importlib.metadata import version
42
+
43
+ os.environ.setdefault(
44
+ "XLA_FLAGS",
45
+ "--xla_cpu_multi_thread_eigen=true intra_op_parallelism_threads=0",
46
+ )
47
+
48
+ import jax # noqa: E402
49
+
50
+ jax.config.update("jax_enable_x64", True)
51
+
52
+ from . import inout, maths, radial, simul, tightb, types, utils # noqa: E402
53
+
54
+ __version__: str = version("diffpes")
55
+
56
+ __all__: list[str] = [
57
+ "__version__",
58
+ "inout",
59
+ "maths",
60
+ "radial",
61
+ "simul",
62
+ "tightb",
63
+ "types",
64
+ "utils",
65
+ ]
@@ -0,0 +1,88 @@
1
+ """VASP file parsers for ARPES simulation input.
2
+
3
+ Extended Summary
4
+ ----------------
5
+ Provides parsers for VASP output files (POSCAR, EIGENVAL, KPOINTS,
6
+ DOSCAR, PROCAR, CHGCAR) that return PyTree data structures suitable
7
+ for ARPES simulations.
8
+
9
+ Routine Listings
10
+ ----------------
11
+ :func:`apply_kpath_ticks`
12
+ Annotate a plot axis with KPathInfo symmetry labels.
13
+ :func:`load_from_h5`
14
+ Load PyTrees from an HDF5 file.
15
+ :func:`plot_arpes_spectrum`
16
+ Plot an ARPES map from an ArpesSpectrum PyTree.
17
+ :func:`plot_arpes_with_kpath`
18
+ Plot an ARPES map and apply KPathInfo axis annotations.
19
+ :func:`plot_band_scatter_preset`
20
+ Plot projected bands with size-weighted scatter markers.
21
+ :func:`plot_band_scatter_with_kpath`
22
+ Plot projected-band scatter and annotate with KPathInfo.
23
+ :func:`list_band_scatter_presets`
24
+ Return supported preset names for band-scatter plotting.
25
+ :func:`read_doscar`
26
+ Parse VASP DOSCAR into DensityOfStates.
27
+ :func:`read_chgcar`
28
+ Parse VASP CHGCAR into VolumetricData.
29
+ :func:`read_eigenval`
30
+ Parse VASP EIGENVAL into BandStructure.
31
+ :func:`read_kpoints`
32
+ Parse VASP KPOINTS into KPathInfo.
33
+ :func:`read_poscar`
34
+ Parse VASP POSCAR into CrystalGeometry.
35
+ :func:`read_procar`
36
+ Parse VASP PROCAR into OrbitalProjection.
37
+ :func:`save_to_h5`
38
+ Save one or more named PyTrees to an HDF5 file.
39
+
40
+ Notes
41
+ -----
42
+ All parsers use standard Python I/O (not JAX) since file
43
+ parsing is inherently sequential. They convert parsed data
44
+ to JAX arrays via factory functions.
45
+ """
46
+
47
+ from .chgcar import read_chgcar
48
+ from .doscar import read_doscar
49
+ from .eigenval import read_eigenval
50
+ from .hdf5 import load_from_h5, save_to_h5
51
+ from .helpers import (
52
+ aggregate_atoms,
53
+ check_consistency,
54
+ reduce_orbitals,
55
+ select_atoms,
56
+ )
57
+ from .kpoints import read_kpoints
58
+ from .plotting import (
59
+ apply_kpath_ticks,
60
+ list_band_scatter_presets,
61
+ plot_arpes_spectrum,
62
+ plot_arpes_with_kpath,
63
+ plot_band_scatter_preset,
64
+ plot_band_scatter_with_kpath,
65
+ )
66
+ from .poscar import read_poscar
67
+ from .procar import read_procar
68
+
69
+ __all__: list[str] = [
70
+ "aggregate_atoms",
71
+ "apply_kpath_ticks",
72
+ "check_consistency",
73
+ "list_band_scatter_presets",
74
+ "load_from_h5",
75
+ "plot_band_scatter_preset",
76
+ "plot_band_scatter_with_kpath",
77
+ "plot_arpes_spectrum",
78
+ "plot_arpes_with_kpath",
79
+ "read_chgcar",
80
+ "read_doscar",
81
+ "read_eigenval",
82
+ "read_kpoints",
83
+ "read_poscar",
84
+ "read_procar",
85
+ "reduce_orbitals",
86
+ "save_to_h5",
87
+ "select_atoms",
88
+ ]
@@ -0,0 +1,459 @@
1
+ """VASP CHGCAR file parser.
2
+
3
+ Extended Summary
4
+ ----------------
5
+ Reads VASP CHGCAR volumetric files and returns a
6
+ :class:`~diffpes.types.VolumetricData` PyTree containing the crystal
7
+ geometry, charge density, and optional magnetization density.
8
+ For SOC calculations (4 grid blocks), returns an
9
+ :class:`~diffpes.types.SOCVolumetricData` with vector magnetization.
10
+
11
+ Routine Listings
12
+ ----------------
13
+ :func:`read_chgcar`
14
+ Parse a VASP CHGCAR file into VolumetricData or SOCVolumetricData.
15
+ """
16
+
17
+ from pathlib import Path
18
+
19
+ import jax.numpy as jnp
20
+ import numpy as np
21
+ from beartype.typing import Optional, Tuple
22
+
23
+ from diffpes.types import (
24
+ SOCVolumetricData,
25
+ VolumetricData,
26
+ make_soc_volumetric_data,
27
+ make_volumetric_data,
28
+ )
29
+
30
+ _LATTICE_ROWS: int = 3
31
+ _XYZ_COMPONENTS: int = 3
32
+ _SCALAR_LINE_COMPONENTS: int = 3
33
+
34
+
35
+ _N_SOC_MAG_BLOCKS: int = 3
36
+
37
+
38
+ def read_chgcar(
39
+ filename: str = "CHGCAR",
40
+ ) -> VolumetricData | SOCVolumetricData:
41
+ """Parse a VASP CHGCAR file.
42
+
43
+ Supports three layouts:
44
+
45
+ - **ISPIN=1**: 1 grid block (charge only).
46
+ - **ISPIN=2**: 2 grid blocks (charge + scalar magnetization).
47
+ - **SOC** (LSORBIT): 4 grid blocks (charge, mx, my, mz).
48
+
49
+ Extended Summary
50
+ ----------------
51
+ The CHGCAR file produced by VASP begins with a POSCAR-style header
52
+ (comment, scaling factor, lattice vectors, species, atom counts,
53
+ coordinate type, and atomic positions) followed by one or more
54
+ volumetric data blocks on a real-space FFT grid. Each block starts
55
+ with a line of three integers (``NGX NGY NGZ``) giving the grid
56
+ dimensions, followed by the flattened grid values written in
57
+ Fortran column-major order (x fastest, z slowest).
58
+
59
+ Implementation Logic
60
+ --------------------
61
+ 1. **Read POSCAR header** -- delegates to :func:`_read_poscar_header`
62
+ to obtain the lattice matrix, fractional coordinates, element
63
+ symbols, and atom counts.
64
+
65
+ 2. **Compute cell volume** -- ``|a . (b x c)|`` from the three
66
+ lattice vectors. Raises if the volume is zero (degenerate cell).
67
+
68
+ 3. **Locate first grid block** -- scans remaining lines for the
69
+ first line containing exactly three positive integers (the FFT
70
+ grid dimensions) via :func:`_find_next_grid_line`.
71
+
72
+ 4. **Parse charge density** -- reads ``NGX * NGY * NGZ`` floats
73
+ from subsequent lines via :func:`_parse_float_block`, reshapes
74
+ in Fortran order, and divides by the cell volume to convert
75
+ from the VASP convention (charge * volume) to charge density
76
+ (electrons / Angstrom^3).
77
+
78
+ 5. **Parse magnetization blocks** -- repeats the grid-search and
79
+ float-parsing loop up to three more times (for ISPIN=2 or SOC
80
+ calculations). The loop terminates early if no further grid
81
+ header is found.
82
+
83
+ 6. **Construct return value** -- based on the number of
84
+ magnetization grids found:
85
+
86
+ * 0 grids: ISPIN=1. Returns ``VolumetricData`` with
87
+ ``magnetization=None``.
88
+ * 1 grid: ISPIN=2. Returns ``VolumetricData`` with a scalar
89
+ magnetization density (spin-up minus spin-down).
90
+ * 3 grids: SOC. Returns ``SOCVolumetricData`` with the
91
+ three grids stacked into a ``(NGX, NGY, NGZ, 3)``
92
+ magnetization vector field (mx, my, mz). The scalar
93
+ ``magnetization`` field is set to the mz component for
94
+ backward compatibility.
95
+
96
+ Parameters
97
+ ----------
98
+ filename : str, optional
99
+ Path to CHGCAR file. Default is ``"CHGCAR"``.
100
+
101
+ Returns
102
+ -------
103
+ volumetric : VolumetricData or SOCVolumetricData
104
+ ``VolumetricData`` for ISPIN=1 or ISPIN=2 files.
105
+ ``SOCVolumetricData`` for SOC files (4 grid blocks).
106
+
107
+ Raises
108
+ ------
109
+ ValueError
110
+ If the lattice volume is zero or if the grid dimensions cannot
111
+ be located or the data block is truncated.
112
+
113
+ Notes
114
+ -----
115
+ All returned densities are in units of electrons / Angstrom^3 (the
116
+ raw VASP values, which encode charge * volume, are divided by the
117
+ cell volume). Coordinates are stored in fractional form after
118
+ conversion from Cartesian if necessary. The grid arrays use
119
+ Fortran (column-major) ordering to match the VASP write convention.
120
+ """
121
+ path: Path = Path(filename)
122
+ with path.open("r") as fid:
123
+ lattice: np.ndarray
124
+ coords: np.ndarray
125
+ symbols: tuple[str, ...]
126
+ atom_counts: list[int]
127
+ lattice, coords, symbols, atom_counts = _read_poscar_header(fid)
128
+ rest_lines: list[str] = [line.rstrip("\n") for line in fid]
129
+
130
+ volume: float = abs(
131
+ float(
132
+ np.dot(
133
+ lattice[0, :],
134
+ np.cross(lattice[1, :], lattice[2, :]),
135
+ )
136
+ )
137
+ )
138
+ if volume == 0.0:
139
+ msg: str = "CHGCAR lattice volume is zero."
140
+ raise ValueError(msg)
141
+
142
+ first_grid_idx: Optional[int]
143
+ grid_shape: tuple[int, int, int]
144
+ first_grid_idx, grid_shape = _find_next_grid_line(rest_lines, 0)
145
+ if first_grid_idx is None:
146
+ msg: str = "Could not locate CHGCAR charge-density grid dimensions."
147
+ raise ValueError(msg)
148
+
149
+ ngrid: int = int(np.prod(np.asarray(grid_shape, dtype=np.int64)))
150
+ charge_vals: np.ndarray
151
+ end_idx: int
152
+ charge_vals, end_idx = _parse_float_block(
153
+ rest_lines,
154
+ first_grid_idx + 1,
155
+ ngrid,
156
+ )
157
+ charge_grid: np.ndarray = (
158
+ charge_vals.reshape(grid_shape, order="F") / volume
159
+ )
160
+
161
+ # Read all remaining grid blocks (up to 3 for SOC: mx, my, mz)
162
+ mag_grids: list[np.ndarray] = []
163
+ scan_idx: int = end_idx
164
+ while len(mag_grids) < _N_SOC_MAG_BLOCKS:
165
+ next_idx: Optional[int]
166
+ next_shape: tuple[int, int, int]
167
+ next_idx, next_shape = _find_next_grid_line(rest_lines, scan_idx)
168
+ if next_idx is None:
169
+ break
170
+ ngrid_mag: int = int(np.prod(np.asarray(next_shape, dtype=np.int64)))
171
+ mag_vals: np.ndarray
172
+ mag_vals, scan_idx = _parse_float_block(
173
+ rest_lines,
174
+ next_idx + 1,
175
+ ngrid_mag,
176
+ )
177
+ mag_grids.append(mag_vals.reshape(next_shape, order="F") / volume)
178
+
179
+ lattice_arr: jnp.ndarray = jnp.asarray(lattice, dtype=jnp.float64)
180
+ coords_arr: jnp.ndarray = jnp.asarray(coords, dtype=jnp.float64)
181
+ charge_arr: jnp.ndarray = jnp.asarray(charge_grid, dtype=jnp.float64)
182
+ counts_arr: jnp.ndarray = jnp.asarray(atom_counts, dtype=jnp.int32)
183
+
184
+ if len(mag_grids) == _N_SOC_MAG_BLOCKS:
185
+ # SOC: blocks are mx, my, mz
186
+ mag_vector: np.ndarray = np.stack(mag_grids, axis=-1)
187
+ result_soc: SOCVolumetricData = make_soc_volumetric_data(
188
+ lattice=lattice_arr,
189
+ coords=coords_arr,
190
+ charge=charge_arr,
191
+ magnetization=jnp.asarray(mag_grids[2], dtype=jnp.float64),
192
+ magnetization_vector=jnp.asarray(mag_vector, dtype=jnp.float64),
193
+ grid_shape=grid_shape,
194
+ symbols=symbols,
195
+ atom_counts=counts_arr,
196
+ )
197
+ return result_soc
198
+
199
+ result: VolumetricData = make_volumetric_data(
200
+ lattice=lattice_arr,
201
+ coords=coords_arr,
202
+ charge=charge_arr,
203
+ magnetization=(
204
+ None
205
+ if not mag_grids
206
+ else jnp.asarray(mag_grids[0], dtype=jnp.float64)
207
+ ),
208
+ grid_shape=grid_shape,
209
+ symbols=symbols,
210
+ atom_counts=counts_arr,
211
+ )
212
+ return result
213
+
214
+
215
+ def _read_poscar_header(
216
+ fid, # noqa: ANN001
217
+ ) -> tuple[np.ndarray, np.ndarray, tuple[str, ...], list[int]]:
218
+ """Read the POSCAR-like header section at the start of a CHGCAR file.
219
+
220
+ Extended Summary
221
+ ----------------
222
+ The first section of a CHGCAR file is identical to a POSCAR file:
223
+ comment, scaling factor, 3x3 lattice matrix, optional species names,
224
+ atom counts, optional selective-dynamics flag, coordinate type, and
225
+ atomic positions. This helper reads all of those fields from the
226
+ already-opened file handle and returns them as NumPy arrays / Python
227
+ containers.
228
+
229
+ Implementation Logic
230
+ --------------------
231
+ 1. Read and discard the comment line.
232
+ 2. Read the universal scaling factor (float).
233
+ 3. Read three lines of three floats each for the lattice vectors and
234
+ multiply by the scaling factor.
235
+ 4. Read the next line. If it contains no digits, treat it as the
236
+ VASP-5 element-symbol line and advance to the following line for
237
+ atom counts. Otherwise parse atom counts directly.
238
+ 5. Compute total atom count as the sum of per-species counts.
239
+ 6. Read the coordinate-type line. If it starts with ``'s'`` or
240
+ ``'S'``, selective dynamics is present; consume it and read the
241
+ next line for the actual coordinate type.
242
+ 7. Determine whether coordinates are Cartesian (``'c'``/``'k'``)
243
+ or direct (fractional).
244
+ 8. Read ``natoms`` coordinate lines of three floats each.
245
+ 9. If Cartesian, scale by the scaling factor and convert to
246
+ fractional via ``np.linalg.solve(lattice.T, coords.T).T``.
247
+
248
+ Parameters
249
+ ----------
250
+ fid : file-like
251
+ Open file handle positioned at the start of the CHGCAR file.
252
+
253
+ Returns
254
+ -------
255
+ lattice : np.ndarray
256
+ Scaled lattice vectors, shape ``(3, 3)``.
257
+ coords : np.ndarray
258
+ Fractional atomic coordinates, shape ``(natoms, 3)``.
259
+ symbols : tuple[str, ...]
260
+ Element symbols (empty tuple for VASP-4 style files).
261
+ atom_counts : list[int]
262
+ Number of atoms per species.
263
+
264
+ Raises
265
+ ------
266
+ ValueError
267
+ If a lattice line has fewer than 3 components or a coordinate
268
+ line has fewer than 3 components.
269
+ """
270
+ _comment: str = fid.readline().strip()
271
+ scale: float = float(fid.readline().strip())
272
+
273
+ lattice: np.ndarray = np.zeros(
274
+ (_LATTICE_ROWS, _XYZ_COMPONENTS),
275
+ dtype=np.float64,
276
+ )
277
+ for row in range(_LATTICE_ROWS):
278
+ vals: list[float] = [float(x) for x in fid.readline().split()]
279
+ if len(vals) < _XYZ_COMPONENTS:
280
+ msg: str = "Invalid CHGCAR lattice line."
281
+ raise ValueError(msg)
282
+ lattice[row, :] = vals[:_XYZ_COMPONENTS]
283
+ lattice = lattice * scale
284
+
285
+ line: str = fid.readline().strip()
286
+ symbols: tuple[str, ...] = ()
287
+ if line and not any(char.isdigit() for char in line):
288
+ symbols = tuple(line.split())
289
+ line = fid.readline().strip()
290
+ atom_counts: list[int] = [int(x) for x in line.split()]
291
+ natoms: int = sum(atom_counts)
292
+
293
+ coord_line: str = fid.readline().strip()
294
+ if coord_line and coord_line[0].lower() == "s":
295
+ coord_line = fid.readline().strip()
296
+ cartesian: bool = bool(coord_line) and coord_line[0].lower() in ("c", "k")
297
+
298
+ coords: np.ndarray = np.zeros((natoms, _XYZ_COMPONENTS), dtype=np.float64)
299
+ for atom_idx in range(natoms):
300
+ vals = [float(x) for x in fid.readline().split()[:_XYZ_COMPONENTS]]
301
+ if len(vals) < _XYZ_COMPONENTS:
302
+ msg: str = "Invalid CHGCAR coordinate line."
303
+ raise ValueError(msg)
304
+ coords[atom_idx, :] = vals
305
+
306
+ if cartesian:
307
+ coords = coords * scale
308
+ coords = np.linalg.solve(lattice.T, coords.T).T
309
+
310
+ return lattice, coords, symbols, atom_counts
311
+
312
+
313
+ def _find_next_grid_line(
314
+ lines: list[str],
315
+ start_idx: int,
316
+ ) -> Tuple[Optional[int], Tuple[int, int, int]]:
317
+ """Find the next line containing exactly three positive integers.
318
+
319
+ Extended Summary
320
+ ----------------
321
+ Scans forward through ``lines`` starting at ``start_idx`` to locate
322
+ the next FFT grid-dimension header. In CHGCAR files, each
323
+ volumetric data block is preceded by a single line of three positive
324
+ integers ``NGX NGY NGZ``.
325
+
326
+ Implementation Logic
327
+ --------------------
328
+ 1. Iterate from ``start_idx`` to the end of ``lines``.
329
+ 2. Skip blank lines and lines that do not split into exactly 3
330
+ tokens.
331
+ 3. Attempt to parse all three tokens as integers. If any token
332
+ fails to convert, skip the line.
333
+ 4. If all three integers are positive, return the line index and the
334
+ ``(NGX, NGY, NGZ)`` tuple.
335
+ 5. If no matching line is found, return ``(None, (0, 0, 0))``.
336
+
337
+ Parameters
338
+ ----------
339
+ lines : list[str]
340
+ All remaining lines of the CHGCAR file (after the POSCAR
341
+ header has been consumed).
342
+ start_idx : int
343
+ Index within ``lines`` at which to begin scanning.
344
+
345
+ Returns
346
+ -------
347
+ idx : int or None
348
+ Line index of the grid header, or ``None`` if not found.
349
+ grid_shape : tuple[int, int, int]
350
+ ``(NGX, NGY, NGZ)`` grid dimensions, or ``(0, 0, 0)`` if
351
+ not found.
352
+ """
353
+ for idx in range(start_idx, len(lines)):
354
+ stripped: str = lines[idx].strip()
355
+ if not stripped:
356
+ continue
357
+ parts: list[str] = stripped.split()
358
+ if len(parts) != _SCALAR_LINE_COMPONENTS:
359
+ continue
360
+ try:
361
+ values: tuple[int, int, int] = (
362
+ int(parts[0]),
363
+ int(parts[1]),
364
+ int(parts[2]),
365
+ )
366
+ except ValueError:
367
+ continue
368
+ if values[0] > 0 and values[1] > 0 and values[2] > 0:
369
+ return idx, values
370
+ return None, (0, 0, 0)
371
+
372
+
373
+ def _parse_float_block(
374
+ lines: list[str],
375
+ start_idx: int,
376
+ nvals: int,
377
+ ) -> tuple[np.ndarray, int]:
378
+ """Parse ``nvals`` whitespace-separated floats starting at ``start_idx``.
379
+
380
+ Extended Summary
381
+ ----------------
382
+ Reads a contiguous block of floating-point values spread across
383
+ multiple lines in the CHGCAR file. VASP writes volumetric data in
384
+ rows of typically 5 or 10 values per line, but the exact count can
385
+ vary. This function consumes lines until exactly ``nvals`` values
386
+ have been collected.
387
+
388
+ Implementation Logic
389
+ --------------------
390
+ 1. Initialize an empty collection and a running line index.
391
+ 2. For each line starting at ``start_idx``:
392
+
393
+ a. Skip blank lines.
394
+ b. Attempt to parse every whitespace-separated token as a float.
395
+ c. If any token fails to parse, treat the entire line as
396
+ non-data (e.g., an augmentation-charge header) and stop
397
+ consuming from it.
398
+ d. Append parsed floats to the collection, taking at most as
399
+ many as needed to reach ``nvals``.
400
+
401
+ 3. After the loop, verify that exactly ``nvals`` values were
402
+ collected. Raise ``ValueError`` on a short read.
403
+
404
+ Parameters
405
+ ----------
406
+ lines : list[str]
407
+ All remaining lines of the CHGCAR file.
408
+ start_idx : int
409
+ Index within ``lines`` at which to begin reading floats.
410
+ nvals : int
411
+ Total number of floats to collect.
412
+
413
+ Returns
414
+ -------
415
+ values : np.ndarray
416
+ 1D array of shape ``(nvals,)`` with dtype ``float64``.
417
+ end_idx : int
418
+ Index of the first line *after* the last consumed line,
419
+ suitable for passing as ``start_idx`` to a subsequent call.
420
+
421
+ Raises
422
+ ------
423
+ ValueError
424
+ If the end of ``lines`` is reached before ``nvals`` floats
425
+ have been collected.
426
+ """
427
+ values: list[float] = []
428
+ idx: int = start_idx
429
+
430
+ while idx < len(lines) and len(values) < nvals:
431
+ stripped: str = lines[idx].strip()
432
+ if not stripped:
433
+ idx += 1
434
+ continue
435
+
436
+ parts: list[str] = stripped.split()
437
+ row_vals: list[float] = []
438
+ row_valid: bool = True
439
+ for token in parts:
440
+ try:
441
+ row_vals.append(float(token))
442
+ except ValueError:
443
+ row_valid = False
444
+ break
445
+ if row_valid:
446
+ needed: int = nvals - len(values)
447
+ values.extend(row_vals[:needed])
448
+ idx += 1
449
+
450
+ if len(values) != nvals:
451
+ msg: str = "Unexpected end of CHGCAR data block."
452
+ raise ValueError(msg)
453
+
454
+ return np.asarray(values, dtype=np.float64), idx
455
+
456
+
457
+ __all__: list[str] = [
458
+ "read_chgcar",
459
+ ]