"""Classes for reading/manipulating/writing VASP output files."""

from __future__ import annotations

import hashlib
import itertools
import logging
import math
import os
import re
import warnings
from collections import defaultdict
from contextlib import contextmanager
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast

import numpy as np
import orjson
from lxml import etree
from monty.dev import requires
from monty.io import zopen
from monty.json import MSONable, jsanitize
from monty.os.path import zpath
from monty.re import regrep
from tqdm import tqdm

from pymatgen.core import Composition, Element, Lattice, Structure
from pymatgen.core import constants as _const
from pymatgen.core.entries import ComputedEntry, ComputedStructureEntry
from pymatgen.core.trajectory import Trajectory
from pymatgen.core.units import unitized
from pymatgen.electronic_structure.bandstructure import (
    BandStructure,
    BandStructureSymmLine,
    get_reconstructed_band_structure,
)
from pymatgen.electronic_structure.core import Magmom, Orbital, OrbitalType, Spin
from pymatgen.electronic_structure.dos import CompleteDos, Dos
from pymatgen.io.common import VolumetricData as BaseVolumetricData
from pymatgen.io.core import ParseError
from pymatgen.io.vasp.inputs import Incar, Kpoints, KpointsSupportedModes, Poscar, Potcar
from pymatgen.io.wannier90 import Unk
from pymatgen.optimization.fast_parser import parse_n_doubles
from pymatgen.util.io_utils import clean_lines, micro_pyawk
from pymatgen.util.num import make_symmetric_matrix_from_upper_tri

try:
    import h5py
except ImportError:
    h5py = None

if TYPE_CHECKING:
    from collections.abc import Callable, Iterator, Sequence
    from typing import IO, Literal, Self, TypeAlias

    from h5py import File as H5File
    from h5py import Group as H5Group
    from numpy.typing import NDArray

    from pymatgen.util.typing import Kpoint, PathLike


logger = logging.getLogger(__name__)


def _parse_parameters(val_type: str, val: str) -> bool | str | float | int:
    """
    Helper function to convert a Vasprun parameter into the proper type.
    Boolean, int and float types are converted.

    Args:
        val_type: Value type parsed from vasprun.xml.
        val: Actual string value parsed for vasprun.xml.
    """
    if val_type == "logical":
        return val == "T"
    if val_type == "int":
        return int(val)
    if val_type == "string":
        return val.strip()
    return float(val)


def _parse_v_parameters(
    val_type: str,
    val: str,
    filename: PathLike,
    param_name: str,
) -> list[bool] | list[float] | list[int] | list[str]:
    """
    Helper function to convert a Vasprun array-type parameter into
    the proper type. Boolean, int and float types are converted.

    Args:
        val_type: Value type parsed from vasprun.xml.
        val: Actual string value parsed for vasprun.xml.
        filename: Fullpath of vasprun.xml. Used for robust error handling.
            e.g. if vasprun.xml contains *** for some Incar parameters,
            the code will try to read from an INCAR file present in the same
            directory.
        param_name: Name of parameter.

    Returns:
        Parsed value.
    """
    err = ValueError("Error in parsing vasprun.xml")

    if val_type == "logical":
        return [i == "T" for i in val.split()]

    if val_type == "string":
        return val.split()

    if val_type == "int":
        try:
            ints = [int(i) for i in val.split()]
        except ValueError as exc:
            # Fix an error in vasprun where
            # sometimes LDAUL/J is displayed as 2****
            ints = _parse_from_incar(filename, param_name)
            if ints is None:
                raise err from exc

        return ints

    try:
        floats = [float(i) for i in val.split()]
    except ValueError as exc:
        # Fix an error in vasprun where
        # sometimes MAGMOM is displayed as 2****
        floats = _parse_from_incar(filename, param_name)
        if floats is None:
            raise err from exc

    return floats


def _parse_vasp_array(elem) -> list[list[bool]] | NDArray[np.float64]:
    if elem.get("type") == "logical":
        return [[i == "T" for i in v.text.split()] for v in elem]

    try:
        # numerical data, try parse with numpy loadtxt for efficiency:
        return np.loadtxt([e.text for e in elem], ndmin=2, dtype=np.float64)
    except ValueError:  # unexpectedly couldn't re-shape to grid
        return np.array([list(map(_vasprun_float, e.text.split())) for e in elem])


def _parse_from_incar(filename: PathLike, key: str) -> Any:
    """Helper function to parse a parameter from the INCAR."""
    dirname = os.path.dirname(os.fspath(filename)) or "."
    for fn in os.listdir(dirname):
        if re.search("INCAR", fn):
            warnings.warn(f"INCAR found. Using {key} from INCAR.", stacklevel=2)
            incar = Incar.from_file(os.path.join(dirname, fn))
            return incar.get(key)
    return None


def _vasprun_float(flt: float | str) -> float:
    """
    Large numbers are often represented as ********* in the vasprun.
    This function parses these values as np.nan.
    """
    try:
        return float(flt)

    except ValueError:
        if "*" in str(flt):
            warnings.warn(
                "Float overflow (*******) encountered in vasprun",
                stacklevel=2,
            )
            return np.nan
        raise


@dataclass
class KpointOptProps:
    """Simple container class to store KPOINTS_OPT data in a separate namespace. Used by Vasprun."""

    tdos: Dos | None = None
    idos: Dos | None = None
    pdos: list | None = None
    efermi: float | None = None
    eigenvalues: dict | None = None
    projected_eigenvalues: dict | None = None
    projected_magnetization: NDArray | None = None
    kpoints: Kpoints | None = None
    actual_kpoints: list | None = None
    actual_kpoints_weights: list | None = None
    dos_has_errors: bool | None = None

    # TODO: remove after 2026-09-06
    @property
    def projected_magnetisation(self) -> NDArray | None:
        warnings.warn(
            "`projected_magnetisation` is deprecated and will be removed. "
            "Use `projected_magnetization` (US spelling) instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.projected_magnetization

    @projected_magnetisation.setter
    def projected_magnetisation(self, value: NDArray | None) -> None:
        warnings.warn(
            "`projected_magnetisation` is deprecated and will be removed. "
            "Use `projected_magnetization` (US spelling) instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        self.projected_magnetization = value


@dataclass
class BandgapProps(MSONable):
    vbm: float | None = None
    cbm: float | None = None
    direct_gap_eigenvalues: tuple[float, float] | None = None
    efermi: float | None = None
    vbm_k: tuple[float, float, float] | None = None
    cbm_k: tuple[float, float, float] | None = None
    direct_gap_k: tuple[float, float, float] | None = None


class Vasprun(MSONable):
    """
    Parser for vasprun.xml files. Uses lxml with explicit tags with iterparsing to reduce callback
    overhead. Speedup over Dom is at least 2-3x for 10-430MB files.

    **VASP results**

    Attributes:
        ionic_steps (list): All ionic steps in the run as a list of {"structure": structure at end of run,
            "electronic_steps": {All electronic step data in vasprun file}, "stresses": stress matrix}.
        tdos (Dos): Total dos calculated at the end of run. Note that this is rounded to 4 decimal
            places by VASP.
        idos (Dos): Integrated dos calculated at the end of run. Rounded to 4 decimal places by VASP.
        pdos (list): List of list of PDos objects. Access as pdos[atomindex][orbitalindex].
        efermi (float): Fermi energy.
        eigenvalues (dict): Final eigenvalues as a dict of {(spin, kpoint index):[[eigenvalue, occu]]}.
            The kpoint index is 0-based (unlike the 1-based indexing in VASP).
        projected_eigenvalues (dict): Final projected eigenvalues as a dict of {spin: nd-array}.
            To access a particular value, you need to do
            Vasprun.projected_eigenvalues[spin][kpoint index][band index][atom index][orbital_index].
            The kpoint, band and atom indices are 0-based (unlike the 1-based indexing in VASP).
        projected_magnetization (NDArray): Final projected magnetization as a numpy array with the
            shape (nkpoints, nbands, natoms, norbitals, 3). Where the last axis is the contribution in the
            3 Cartesian directions. This attribute is only set if spin-orbit coupling (LSORBIT = True) or
            non-collinear magnetism (LNONCOLLINEAR = True) is turned on in the INCAR.
        dielectric_data (dict): Dictionary, with the tag comment as key, containing other variants of
            the real and imaginary part of the dielectric constant (e.g., computed by RPA) in function of
            the energy (frequency). Optical properties (e.g. absorption coefficient) can be obtained through this.
            The data is given as a tuple of 3 values containing each of them the energy, the real part tensor,
            and the imaginary part tensor ([energies],[[real_partxx,real_partyy,real_partzz,real_partxy,
            real_partyz,real_partxz]],[[imag_partxx,imag_partyy,imag_partzz,imag_partxy, imag_partyz, imag_partxz]]).
            The data can be the current, density or freq_dependent (BSE) dielectric data.
        nionic_steps (int): The total number of ionic steps. This number is always equal to the total number
            of steps in the actual run even if ionic_step_skip is used.
        force_constants (NDArray): Force constants computed in phonon DFPT run(IBRION = 8).
            The data is a 4D array of shape (natoms, natoms, 3, 3).
        normalmode_eigenvals (NDArray): Normal mode frequencies. 1D array of size 3*natoms.
        normalmode_eigenvecs (NDArray): Normal mode eigen vectors. 3D array of shape (3*natoms, natoms, 3).
        md_data (list): Available only for ML MD runs, i.e., INCAR with ML_LMLFF = .TRUE. md_data is a list of
            dict with the following format: [{'energy': {'e_0_energy': -525.07195568, 'e_fr_energy': -525.07195568,
            'e_wo_entrp': -525.07195568, 'kinetic': 3.17809233, 'lattice kinetic': 0.0, 'nosekinetic': 1.323e-5,
            'nosepot': 0.0, 'total': -521.89385012}, 'forces': [[0.17677989, 0.48309874, 1.85806696], ...],
            'structure': Structure object}].
        incar (Incar): Incar object for parameters specified in INCAR file.
        parameters (Incar): Incar object with parameters that VASP actually used, including all defaults.
        kpoints (Kpoints): Kpoints object for KPOINTS specified in run.
        actual_kpoints (list): List of actual kpoints, e.g. [[0.25, 0.125, 0.08333333], [-0.25, 0.125, 0.08333333],
            [0.25, 0.375, 0.08333333], ....].
        actual_kpoints_weights (list): List of kpoint weights, e.g. [0.04166667, 0.04166667, 0.04166667, 0.04166667,
            0.04166667, ....].
        atomic_symbols (list): List of atomic symbols, e.g. ["Li", "Fe", "Fe", "P", "P", "P"].
        potcar_symbols (list): List of POTCAR symbols. e.g. ["PAW_PBE Li 17Jan2003", "PAW_PBE Fe 06Sep2000", ..].
        kpoints_opt_props (KpointOptProps): Object whose attributes are the data from KPOINTS_OPT (if present,
            else None). Attributes of the same name have the same format and meaning as Vasprun (or they are
            None if absent). Attributes are: tdos, idos, pdos, efermi, eigenvalues, projected_eigenvalues,
            projected magnetisation, kpoints, actual_kpoints, actual_kpoints_weights, dos_has_errors.

    Author: Shyue Ping Ong
    """

    def __init__(
        self,
        filename: PathLike,
        ionic_step_skip: int | None = None,
        ionic_step_offset: int = 0,
        parse_dos: bool = True,
        parse_eigen: bool = True,
        parse_projected_eigen: bool = False,
        parse_potcar_file: PathLike | bool = True,
        occu_tol: float = 1e-8,
        separate_spins: bool = False,
        exception_on_bad_xml: bool = True,
    ) -> None:
        """Initialize a Vasprun.

        Args:
            filename (str): Filename to parse
            ionic_step_skip (int): If ionic_step_skip is a number > 1,
                only every ionic_step_skip ionic steps will be read for
                structure and energies. This is very useful if you are parsing
                very large vasprun.xml files and you are not interested in every
                single ionic step. Note that the final energies may not be the
                actual final energy in the vasprun.
            ionic_step_offset (int): Used together with ionic_step_skip. If set,
                the first ionic step read will be offset by the amount of
                ionic_step_offset. For example, if you want to start reading
                every 10th structure but only from the 3rd structure onwards,
                set ionic_step_skip to 10 and ionic_step_offset to 3. Main use
                case is when doing statistical structure analysis with
                extremely long time scale multiple VASP calculations of
                varying numbers of steps.
            parse_dos (bool): Whether to parse the dos. Defaults to True. Set
                to False to shave off significant time from the parsing if you
                are not interested in getting those data.
                Note that the DOS output from VASP is rounded to 4 decimal places,
                which can give some slight inaccuracies.
            parse_eigen (bool): Whether to parse the eigenvalues. Defaults to
                True. Set to False to shave off significant time from the
                parsing if you are not interested in getting those data.
            parse_projected_eigen (bool): Whether to parse the projected
                eigenvalues and magnetization. Defaults to False. Set to True to obtain
                projected eigenvalues and magnetization. **Note that this can take an
                extreme amount of time and memory.** So use this wisely.
            parse_potcar_file (bool | PathLike): Whether to parse the potcar file to read
                the potcar hashes for the potcar_spec attribute. Defaults to True,
                where no hashes will be determined and the potcar_spec dictionaries
                will read {"symbol": ElSymbol, "hash": None}. By Default, looks in
                the same directory as the vasprun.xml, with same extensions as
                Vasprun.xml. If a path is provided, look at that path.
            occu_tol (float): Sets the minimum tol for the determination of the
                vbm and cbm. Usually the default of 1e-8 works well enough,
                but there may be pathological cases.
            separate_spins (bool): Whether the band gap, CBM, and VBM should be
                reported for each individual spin channel. Defaults to False,
                which computes the eigenvalue band properties independent of
                the spin orientation. If True, the calculation must be spin-polarized.
            exception_on_bad_xml (bool): Whether to throw a ParseException if a
                malformed XML is detected. Default to True, which ensures only
                proper vasprun.xml are parsed. You can set to False if you want
                partial results (e.g., if you are monitoring a calculation during a
                run), but use the results with care. A warning is issued.
        """
        self.filename = filename
        self.ionic_step_skip = ionic_step_skip
        self.ionic_step_offset = ionic_step_offset
        self.occu_tol = occu_tol
        self.separate_spins = separate_spins
        self.exception_on_bad_xml = exception_on_bad_xml

        if ionic_step_skip or ionic_step_offset:
            with zopen(filename, mode="rb") as file:
                # Remove parts of the xml file and parse the string
                content: bytes = file.read()
                if b"<calculation>" in content:
                    steps: list[bytes] = content.split(b"<calculation>")

                    # The text before the first <calculation> is the preamble!
                    preamble: bytes = steps.pop(0)
                    self.nionic_steps: int = len(steps)
                    new_steps = steps[ionic_step_offset :: int(ionic_step_skip or 1)]

                    # Add the tailing information in the last step from the run
                    to_parse: bytes = b"<calculation>".join(new_steps)
                    if steps[-1] != new_steps[-1]:
                        to_parse = preamble + b"<calculation>" + to_parse + steps[-1].split(b"</calculation>")[-1]
                    else:
                        to_parse = preamble + b"<calculation>" + to_parse
                    self._parse(
                        BytesIO(to_parse),
                        parse_dos=parse_dos,
                        parse_eigen=parse_eigen,
                        parse_projected_eigen=parse_projected_eigen,
                    )
                else:
                    # MLFF MD output stores each step as a sequence of top-level
                    # structure, force, stress, and energy tags rather than in a
                    # <calculation> tag. Parse it in full, then subsample md_data.
                    self._parse(
                        BytesIO(content),
                        parse_dos=parse_dos,
                        parse_eigen=parse_eigen,
                        parse_projected_eigen=parse_projected_eigen,
                    )
                    self.nionic_steps = len(self.md_data)
                    self.md_data: list[dict] = self.md_data[ionic_step_offset :: int(ionic_step_skip or 1)]
        else:
            with zopen(filename, mode="rb") as file:
                self._parse(
                    file,
                    parse_dos=parse_dos,
                    parse_eigen=parse_eigen,
                    parse_projected_eigen=parse_projected_eigen,
                )
                self.nionic_steps = len(self.md_data) if self.md_data else len(self.ionic_steps)

            if parse_potcar_file:
                self.update_potcar_spec(parse_potcar_file)
                self.update_charge_from_potcar(parse_potcar_file)

        if self.incar.get("ALGO") not in {"Chi", "Bse"} and not self.converged and self.parameters.get("IBRION") != 0:
            msg = f"{filename} is an unconverged VASP run.\n"
            msg += f"Electronic convergence reached: {self.converged_electronic}.\n"
            msg += f"Ionic convergence reached: {self.converged_ionic}."
            warnings.warn(
                msg,
                UnconvergedVASPWarning,
                stacklevel=2,
            )

    # TODO: remove after 2026-09-06
    @property
    def projected_magnetisation(self) -> NDArray | None:
        warnings.warn(
            "`projected_magnetisation` is deprecated and will be removed. "
            "Use `projected_magnetization` (US spelling) instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        return self.projected_magnetization

    @projected_magnetisation.setter
    def projected_magnetisation(self, value: NDArray | None) -> None:
        warnings.warn(
            "`projected_magnetisation` is deprecated and will be removed. "
            "Use `projected_magnetization` (US spelling) instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        self.projected_magnetization = value

    def _parse(
        self,
        stream,
        parse_dos: bool,
        parse_eigen: bool,
        parse_projected_eigen: bool,
    ) -> None:
        self.efermi: float | None = None
        self.eigenvalues: dict[Any, NDArray] | None = None
        self.projected_eigenvalues: dict[Any, NDArray] | None = None
        self.projected_magnetization = None
        self.dielectric_data: dict[str, tuple] = {}
        self.incar: Incar = Incar({})
        self.kpoints_opt_props: KpointOptProps | None = None
        ionic_steps: list[dict[str, Any]] = []

        md_data: list[dict] = []
        parsed_header: bool = False
        in_kpoints_opt: bool = False
        ml_run: bool = False
        try:
            # When parsing XML, start tags tell us when we have entered a block
            # while end tags are when we have actually read the data.
            # To know if a particular tag is nested within another block,
            # we have to read the start tags (otherwise we will only learn of
            # the nesting after we have left the data behind).
            # When parsing KPOINTS_OPT data, some of the tags in vasprun.xml
            # can only be distinguished from their regular counterparts by
            # whether they are nested within another block. This is why we
            # must read both start and end tags and have flags to tell us
            # when we have entered or left a block. (2024-01-26)
            _TAGS = [
                "atominfo",
                "calculation",
                "dielectricfunction",
                "dos",
                "dynmat",
                "eigenvalues",
                "eigenvalues_kpoints_opt",
                "energy",
                "generator",
                "incar",
                "kpoints",
                "parameters",
                "projected",
                "projected_kpoints_opt",
                "structure",
                "varray",
            ]
            for event, elem in etree.iterparse(stream, events=["start", "end"], tag=_TAGS):
                tag = elem.tag
                if event == "start":
                    # The start event tells us when we have entered blocks
                    if tag == "calculation":
                        parsed_header = True
                    elif tag in ("eigenvalues_kpoints_opt", "projected_kpoints_opt"):
                        in_kpoints_opt = True

                else:  # event == "end":
                    # The end event happens when we have read a block, so have
                    # its data.
                    if not parsed_header:
                        if tag == "generator":
                            self.generator = self._parse_params(elem)
                        elif tag == "incar":
                            self.incar = self._parse_params(elem)
                            ml_run = self.incar.get("ML_LMLFF")
                        elif tag == "kpoints":
                            if not hasattr(self, "kpoints"):
                                (
                                    self.kpoints,
                                    self.actual_kpoints,
                                    self.actual_kpoints_weights,
                                ) = self._parse_kpoints(elem)
                        elif tag == "parameters":
                            self.parameters = self._parse_params(elem)
                        elif tag == "structure" and elem.attrib.get("name") == "initialpos":
                            self.initial_structure = self._parse_structure(elem)
                            self.final_structure = self.initial_structure
                        elif tag == "atominfo":
                            self.atomic_symbols, self.potcar_symbols = self._parse_atominfo(elem)
                            self.potcar_spec: list[dict] = [
                                {"titel": titel, "hash": None, "summary_stats": {}} for titel in self.potcar_symbols
                            ]

                    if tag == "calculation":
                        parsed_header = True
                        if not self.parameters.get("LCHIMAG", False):
                            ionic_steps.append(self._parse_ionic_step(elem))
                        else:
                            ionic_steps.extend(self._parse_chemical_shielding(elem))

                    elif parse_dos and tag == "dos":
                        if elem.get("comment") == "kpoints_opt":
                            kpoints_opt_props = self.kpoints_opt_props = self.kpoints_opt_props or KpointOptProps()
                            try:
                                (
                                    kpoints_opt_props.tdos,
                                    kpoints_opt_props.idos,
                                    kpoints_opt_props.pdos,
                                ) = self._parse_dos(elem)
                                kpoints_opt_props.efermi = kpoints_opt_props.tdos.efermi
                                kpoints_opt_props.dos_has_errors = False
                            except Exception:
                                kpoints_opt_props.dos_has_errors = True
                        else:
                            try:
                                self.tdos, self.idos, self.pdos = self._parse_dos(elem)
                                self.efermi = self.tdos.efermi
                                self.dos_has_errors = False
                            except Exception:
                                self.dos_has_errors = True

                    elif parse_eigen and tag == "eigenvalues" and not in_kpoints_opt:
                        self.eigenvalues = self._parse_eigen(elem)

                    elif parse_projected_eigen and tag == "projected" and not in_kpoints_opt:
                        self.projected_eigenvalues, self.projected_magnetization = self._parse_projected_eigen(elem)

                    elif tag in ("eigenvalues_kpoints_opt", "projected_kpoints_opt"):
                        in_kpoints_opt = False
                        if self.kpoints_opt_props is None:
                            self.kpoints_opt_props = KpointOptProps()
                        if parse_eigen:
                            # projected_kpoints_opt includes occupation information whereas
                            # eigenvalues_kpoints_opt doesn't.
                            self.kpoints_opt_props.eigenvalues = self._parse_eigen(elem.find("eigenvalues"))  # type:ignore[arg-type]
                        if tag == "eigenvalues_kpoints_opt":
                            (
                                self.kpoints_opt_props.kpoints,
                                self.kpoints_opt_props.actual_kpoints,
                                self.kpoints_opt_props.actual_kpoints_weights,
                            ) = self._parse_kpoints(elem.find("kpoints"))  # type:ignore[arg-type]
                        elif parse_projected_eigen:  # and tag == "projected_kpoints_opt": (implied)
                            (
                                self.kpoints_opt_props.projected_eigenvalues,
                                self.kpoints_opt_props.projected_magnetization,
                            ) = self._parse_projected_eigen(elem)

                    elif tag == "dielectricfunction":
                        label = elem.attrib.get("comment", None)
                        if label is None:
                            if self.incar.get("ALGO", "Normal") == "Bse":
                                label = "freq_dependent"
                            elif "density" not in self.dielectric_data:
                                label = "density"
                            elif "velocity" not in self.dielectric_data:
                                # "velocity-velocity" is also named
                                # "current-current" in OUTCAR
                                label = "velocity"
                            else:
                                warnings.warn(
                                    "Additional unlabelled dielectric data in vasprun.xml are stored as unlabelled.",
                                    stacklevel=2,
                                )
                                label = "unlabelled"
                        # VASP 6+ has labels for the density and current
                        # derived dielectric constants

                        if label == "density-density":
                            label = "density"
                        elif label == "current-current":
                            label = "velocity"

                        self.dielectric_data[label] = self._parse_diel(elem)

                    elif tag == "varray" and elem.attrib.get("name") == "opticaltransitions":
                        self.optical_transition = _parse_vasp_array(elem)

                    elif tag == "structure" and elem.attrib.get("name") == "finalpos":
                        self.final_structure = self._parse_structure(elem)

                    elif tag == "dynmat":
                        hessian, eigenvalues, eigenvectors = self._parse_dynmat(elem)
                        # n_atoms is not the total number of atoms, only those for which force constants were calculated
                        # https://github.com/materialsproject/pymatgen/issues/3084
                        n_atoms = len(hessian) // 3
                        hessian_arr = np.asarray(hessian, dtype="double")
                        self.force_constants = hessian_arr.reshape(n_atoms, 3, n_atoms, 3).transpose(0, 2, 1, 3).copy()
                        self.normalmode_eigenvals = np.array(eigenvalues)
                        self.normalmode_eigenvecs = np.array([np.array(ev).reshape(n_atoms, 3) for ev in eigenvectors])

                    elif ml_run:
                        if tag == "structure" and elem.attrib.get("name") is None:
                            md_data.append({})
                            md_data[-1]["structure"] = self._parse_structure(elem)
                        elif tag == "varray" and elem.attrib.get("name") == "forces":
                            md_data[-1]["forces"] = _parse_vasp_array(elem)
                        elif tag == "varray" and elem.attrib.get("name") == "stress":
                            md_data[-1]["stress"] = _parse_vasp_array(elem)
                        elif tag == "energy":
                            d = {i.attrib["name"]: float(i.text) for i in elem.findall("i")}  # type:ignore[arg-type]
                            if "kinetic" in d:
                                md_data[-1]["energy"] = d

        except etree.XMLSyntaxError:
            if self.exception_on_bad_xml:
                raise
            warnings.warn(
                "XML is malformed. Parsing has stopped but partial data is available.",
                stacklevel=2,
            )

        self.ionic_steps = ionic_steps
        self.md_data = md_data
        self.vasp_version = self.generator["version"]

    @property
    def structures(self) -> list[Structure]:
        """List of Structures for each ionic step."""
        return [step["structure"] for step in self.ionic_steps]

    @property
    def epsilon_static(self) -> list[float]:
        """The static part of the dielectric constant.
        Present only when it's a DFPT run (LEPSILON=TRUE).
        """
        return self.ionic_steps[-1].get("epsilon", [])

    @property
    def epsilon_static_wolfe(self) -> list[float]:
        """The static part of the dielectric constant without any local
        field effects. Present only when it's a DFPT run (LEPSILON=TRUE).
        """
        return self.ionic_steps[-1].get("epsilon_rpa", [])

    @property
    def epsilon_ionic(self) -> list[float]:
        """The ionic part of the static dielectric constant.
        Present when it's a DFPT run (LEPSILON=TRUE) and IBRION=5, 6, 7 or 8.
        """
        return self.ionic_steps[-1].get("epsilon_ion", [])

    @property
    def dielectric(self) -> tuple[list, list, list]:
        """The real and imaginary part of the dielectric constant (e.g.,
        computed by RPA) in function of the energy (frequency).
        Optical properties (e.g. absorption coefficient) can be obtained through this.

        Returns:
            The data is given as a tuple of 3 for the energy, the real part
            tensor, and the imaginary part tensor:
            ([energies], [[real_partxx, real_partyy, real_partzz, real_partxy,
            real_partyz, real_partxz]], [[imag_partxx, imag_partyy, imag_partzz,
            imag_partxy, imag_partyz, imag_partxz]]).
        """
        keys = self.dielectric_data.keys()
        if "density" not in keys:
            return self.dielectric_data[
                "INVERSE MACROSCOPIC DIELECTRIC TENSOR (including local field effects in RPA (Hartree))"
            ]
        return self.dielectric_data["density"]

    @property
    def optical_absorption_coeff(self) -> list[float] | None:
        """The optical absorption coefficient from the dielectric constants.
        Note that this method is only implemented for optical properties
        calculated with GGA and BSE.
        """
        diel_data = self.dielectric_data.get("freq_dependent") or self.dielectric_data["density"]
        if diel_data:
            real_avg = [sum(diel_data[1][i][:3]) / 3 for i in range(len(diel_data[0]))]
            imag_avg = [sum(diel_data[2][i][:3]) / 3 for i in range(len(diel_data[0]))]

            def optical_absorb_coeff(freq: float, real: float, imag: float) -> float:
                """Calculate optical absorption coefficient,
                the unit is cm^-1.
                """
                hc = _const.h * _const.c / _const.e * 100  # eV*cm (J*m -> eV*cm)
                return math.tau * math.sqrt(math.sqrt(real**2 + imag**2) - real) * math.sqrt(2) / hc * freq

            return list(
                itertools.starmap(
                    optical_absorb_coeff,
                    zip(
                        diel_data[0],
                        real_avg,
                        imag_avg,
                        strict=True,
                    ),
                )
            )
        return None

    @property
    def converged_electronic(self) -> bool:
        """Whether electronic step converged in the final ionic step."""
        # MLFF MD output has no electronic steps because forces and energies
        # are calculated by the machine-learned force field.
        if self.incar.get("ML_LMLFF"):
            return True

        final_elec_steps: list[dict[str, Any]] | Literal[0] = (
            self.ionic_steps[-1]["electronic_steps"] if self.incar.get("ALGO", "").lower() != "chi" else 0
        )
        # In a response function run there is no ionic steps, there is no SCF step
        if final_elec_steps == 0:
            final_elec_steps = []
            warnings.warn(
                "there is no ionic step in response function ALGO=CHI.",
                stacklevel=2,
            )

        if self.incar.get("LEPSILON"):
            idx = 1
            to_check = {"e_wo_entrp", "e_fr_energy", "e_0_energy"}
            while set(final_elec_steps[idx]) == to_check:
                idx += 1
            return idx + 1 != self.parameters["NELM"]
        if self.incar.get("ALGO", "") == "Exact" and self.incar.get("NELM") == 1:
            return True
        return len(final_elec_steps) < self.parameters["NELM"]

    @property
    def converged_ionic(self) -> bool:
        """Whether ionic step convergence has been reached, i.e. VASP
        exited before reaching the max ionic steps for a relaxation run.
        In case IBRION=0 (MD) or EDIFFG=0, returns True if the max ionic
        steps are reached.
        """
        nsw = self.parameters.get("NSW", 0)
        ibrion = self.parameters.get("IBRION", -1 if nsw in (-1, 0) else 0)
        if ibrion == 0:
            return nsw <= 1 or self.md_n_steps == nsw

        # Context re EDIFFG: the use case for EDIFFG=0 is to ensure a relaxation runs for
        # NSW steps (the non-AIMD way to generate a relaxation trajectory with DFT). In
        # that case, user isn't worried about convergence w.r.t. forces or energy. The
        # next if statement prevents custodian from trying to correct the calc because
        # Vasprun.converged_ionic = False.
        ediffg = self.parameters.get("EDIFFG", 1)
        if ibrion in {1, 2} and ediffg == 0:
            return nsw <= 1 or nsw == len(self.ionic_steps)

        return nsw <= 1 or len(self.ionic_steps) < nsw

    @property
    def converged(self) -> bool:
        """Whether a relaxation run has both ionically and
        electronically converged.
        """
        return self.converged_electronic and self.converged_ionic

    @property
    @unitized("eV")
    def final_energy(self) -> float:
        """Final energy from the VASP run."""
        try:
            final_istep = self.ionic_steps[-1]
            total_energy = final_istep["e_0_energy"]

            # Fix a bug in vasprun.xml.
            # See https://www.vasp.at/forum/viewtopic.php?f=3&t=16942
            final_estep = final_istep["electronic_steps"][-1]
            electronic_energy_diff = final_estep["e_0_energy"] - final_estep["e_fr_energy"]
            total_energy_bugfix = np.round(electronic_energy_diff + final_istep["e_fr_energy"], 8)
            if np.abs(total_energy - total_energy_bugfix) > 1e-7:
                return total_energy_bugfix

            return total_energy

        except (IndexError, KeyError):
            warnings.warn(
                "Calculation does not have a total energy. Possibly a GW or similar kind of run. Infinity is returned.",
                stacklevel=2,
            )
            return float("inf")

    @property
    def complete_dos(self) -> CompleteDos:
        """A CompleteDos object which incorporates the total DOS and all projected DOS."""
        final_struct = self.final_structure
        pdoss = {final_struct[i]: pdos for i, pdos in enumerate(self.pdos)}
        return CompleteDos(self.final_structure, self.tdos, pdoss)

    @property
    def complete_dos_normalized(self) -> CompleteDos:
        """A CompleteDos object which incorporates the total DOS and all projected DOS.
        Normalized by the volume of the unit cell with units of states/eV/unit cell
        volume.
        """
        final_struct = self.final_structure
        pdoss = {final_struct[i]: pdos for i, pdos in enumerate(self.pdos)}
        return CompleteDos(self.final_structure, self.tdos, pdoss, normalize=True)

    @property
    def hubbards(self) -> dict[str, float]:
        """Hubbard U values used for a GGA+U run, otherwise an empty dict."""
        symbols = [s.split()[1] for s in self.potcar_symbols]
        symbols = [re.split(r"_", s)[0] for s in symbols]
        if not self.incar.get("LDAU", False):
            return {}

        us = self.incar.get("LDAUU", self.parameters.get("LDAUU"))
        js = self.incar.get("LDAUJ", self.parameters.get("LDAUJ"))
        if len(js) != len(us):
            js = [0] * len(us)
        if len(us) == len(symbols):
            return {symbols[idx]: us[idx] - js[idx] for idx in range(len(symbols))}
        if sum(us) == 0 and sum(js) == 0:
            return {}

        raise VaspParseError("Length of U value parameters and atomic symbols are mismatched")

    @property
    def run_type(self) -> str:
        """The run type. Currently detects GGA, metaGGA, HF, HSE, B3LYP,
        and hybrid functionals based on relevant INCAR tags. LDA is assigned if
        PAW POTCARs are used and no other functional is detected.

        Hubbard U terms and vdW corrections are detected automatically as well.
        """
        # Care should be taken: if a GGA tag is not specified, VASP will default
        # to the functional specified by the POTCAR. It is not clear how
        # VASP handles the "--" value.
        GGA_TYPES = {
            "RE": "revPBE",
            "PE": "PBE",
            "PS": "PBEsol",
            "RP": "revPBE+Padé",
            "AM": "AM05",
            "OR": "optPBE",
            "BO": "optB88",
            "MK": "optB86b",
            "--": "GGA",
        }

        METAGGA_TYPES = {
            "TPSS": "TPSS",
            "RTPSS": "revTPSS",
            "M06L": "M06-L",
            "MBJ": "modified Becke-Johnson",
            "SCAN": "SCAN",
            "R2SCAN": "R2SCAN",
            "RSCAN": "RSCAN",
            "MS0": "MadeSimple0",
            "MS1": "MadeSimple1",
            "MS2": "MadeSimple2",
        }

        IVDW_TYPES = {
            0: "no-correction",
            1: "DFT-D2",
            10: "DFT-D2",
            11: "DFT-D3",
            12: "DFT-D3-BJ",
            2: "TS",
            20: "TS",
            21: "TS-H",
            202: "MBD",
            4: "dDsC",
        }

        if math.isclose(self.parameters.get("AEXX", 1.00), 1.00):
            run_type = "HF"
        elif math.isclose(self.parameters.get("HFSCREEN", 0.30), 0.30):
            run_type = "HSE03"
        elif 0.2 <= self.parameters.get("HFSCREEN", 0.20) <= 0.21:
            run_type = "HSE06"
        elif math.isclose(self.parameters.get("AEXX", 0.20), 0.20):
            run_type = "B3LYP"
        elif self.parameters.get("LHFCALC", True):
            run_type = "PBEO or other Hybrid Functional"
        elif self.incar.get("METAGGA") and self.incar.get("METAGGA") not in {
            "--",
            "None",
        }:
            incar_tag = self.incar.get("METAGGA", "").upper()
            run_type = METAGGA_TYPES.get(incar_tag, incar_tag)
        elif self.parameters.get("GGA"):
            incar_tag = self.parameters.get("GGA", "").upper()
            run_type = GGA_TYPES.get(incar_tag, incar_tag)
        elif self.potcar_symbols[0].split()[0] == "PAW":
            run_type = "LDA"
        else:
            run_type = "unknown"
            warnings.warn(
                "Unknown run type!",
                stacklevel=2,
            )

        if self.is_hubbard or self.parameters.get("LDAU", True):
            run_type += "+U"

        if self.parameters.get("LUSE_VDW", False):
            run_type += "+rVV10"
        elif self.incar.get("IVDW") in IVDW_TYPES:
            run_type += f"+vdW-{IVDW_TYPES[self.incar.get('IVDW', 0)]}"
        elif self.incar.get("IVDW"):
            run_type += "+vdW-unknown"

        return run_type

    @property
    def is_hubbard(self) -> bool:
        """Whether is a DFT+U run."""
        if len(self.hubbards) == 0:
            return False
        return sum(self.hubbards.values()) > 1e-8

    @property
    def is_spin(self) -> bool:
        """Whether is spin-polarized."""
        return self.parameters.get("ISPIN", 1) == 2

    @property
    def md_n_steps(self) -> int:
        """Number of steps for MD runs.

        Count all the actual MD steps if ML enabled.
        """
        return len(self.md_data) if self.md_data else self.nionic_steps

    def get_computed_entry(
        self,
        inc_structure: bool = True,
        parameters: list[str] | None = None,
        data: list | None = None,
        entry_id: str | None = None,
    ) -> ComputedStructureEntry | ComputedEntry:
        """Get a ComputedEntry or ComputedStructureEntry from the Vasprun.

        Args:
            inc_structure (bool): Whether to return ComputedStructureEntries
                instead of ComputedEntries.
            parameters (list): Input parameters to include. It has to be one of
                the properties supported by the Vasprun object. If is None,
                a default set of parameters that are
                necessary for typical post-processing will be set.
            data (list): Output data to include. Have to be the properties
                supported by the Vasprun object.
            entry_id (str): An entry id for the ComputedEntry.
                Defaults to "vasprun-{current datetime}"

        Returns:
            ComputedStructureEntry/ComputedEntry
        """
        if entry_id is None:
            calc_date = re.sub(" ", "", self.generator["DATE"])
            calc_time = self.generator["TIME"]
            hashed_structure = hashlib.md5(str(self.final_structure).encode("utf-8")).hexdigest()  # noqa: S324

            entry_id = f"vasprun-{calc_date}-{calc_time}-{hashed_structure}"
        param_names = {
            "is_hubbard",
            "hubbards",
            "potcar_symbols",
            "potcar_spec",
            "run_type",
        }
        if parameters is not None and len(parameters) > 0:
            param_names.update(parameters)
        params = {param: getattr(self, param) for param in param_names}
        _data = {} if data is None else {param: getattr(self, param) for param in data}

        if inc_structure:
            return ComputedStructureEntry(
                self.final_structure,
                self.final_energy,
                parameters=params,
                data=_data,
                entry_id=entry_id,
            )
        return ComputedEntry(
            self.final_structure.composition,
            self.final_energy,
            parameters=params,
            data=_data,
            entry_id=entry_id,
        )

    def get_band_structure(
        self,
        kpoints_filename: str | None = None,
        efermi: float | Literal["smart"] | None = None,
        line_mode: bool = False,
        force_hybrid_mode: bool = False,
        ignore_kpoints_opt: bool = False,
    ) -> BandStructureSymmLine | BandStructure:
        """Get the band structure.

        Args:
            kpoints_filename: Full path of the KPOINTS file from which
                the band structure is generated.
                If None is provided, the code will try to intelligently
                determine the appropriate KPOINTS file by substituting the
                filename of the vasprun.xml with KPOINTS (or KPOINTS_OPT).
                The latter is the default behavior.
            efermi: The Fermi energy associated with the bandstructure, in eV. By
                default (None), uses the value reported by VASP in vasprun.xml. To
                manually set the Fermi energy, pass a float. Pass 'smart' to use the
                `calculate_efermi()` method, which calculates the Fermi level by first
                checking whether it lies within a small tolerance (by default 0.001 eV)
                of a band edge) If it does, the Fermi level is placed in the center of
                the bandgap. Otherwise, the value is identical to the value reported by
                VASP.
            line_mode: Force the band structure to be considered as
                a run along symmetry lines. (Default: False)
            force_hybrid_mode: Makes it possible to read in self-consistent band
                structure calculations for every type of functional. (Default: False)
            ignore_kpoints_opt: Normally, if KPOINTS_OPT data exists, it has
                the band structure data. Set this flag to ignore it. (Default: False)

        Returns:
            BandStructure (or more specifically a BandStructureSymmLine object if the run
            is detected to be a run along symmetry lines)

            Two types of runs along symmetry lines are accepted: non-sc with
            Line-Mode in the KPOINT file or hybrid, self-consistent with a
            uniform grid+a few kpoints along symmetry lines (explicit KPOINTS
            file) (it's not possible to run a non-sc band structure with hybrid
            functionals). The explicit KPOINTS file needs to have data on the
            kpoint label as commentary.

            If VASP was run with KPOINTS_OPT, it reads the data from that
            file unless told otherwise. This overrides hybrid mode.
        """
        use_kpoints_opt = not ignore_kpoints_opt and (getattr(self, "kpoints_opt_props", None) is not None)
        if not kpoints_filename:
            kpts_path = os.path.join(
                os.path.dirname(self.filename),
                "KPOINTS_OPT" if use_kpoints_opt else "KPOINTS",
            )
            kpoints_filename = zpath(kpts_path)
        if kpoints_filename and not os.path.isfile(kpoints_filename) and line_mode:
            name = "KPOINTS_OPT" if use_kpoints_opt else "KPOINTS"
            raise VaspParseError(f"{name} not found but needed to obtain band structure along symmetry lines.")

        # Note that we're using the Fermi energy of the self-consistent grid
        # run even if we're reading bands from KPOINTS_OPT.
        if efermi == "smart":
            e_fermi: float | None = self.calculate_efermi()
        elif efermi is None:
            e_fermi = self.efermi
        else:
            e_fermi = efermi

        if e_fermi is None:
            raise ValueError("e_fermi is None.")

        kpoint_file: Kpoints | None = None
        if kpoints_filename and os.path.isfile(kpoints_filename):
            kpoint_file = Kpoints.from_file(kpoints_filename)
        lattice_new = Lattice(self.final_structure.lattice.reciprocal_lattice.matrix)

        if use_kpoints_opt:
            if self.kpoints_opt_props is None or self.kpoints_opt_props.actual_kpoints is None:
                raise RuntimeError("KPOINTS_opt or actual_kpoints is None.")
            kpoints = [np.array(kpt) for kpt in self.kpoints_opt_props.actual_kpoints]
        else:
            if self.actual_kpoints is None:
                raise RuntimeError("actual_kpoints is None.")
            kpoints = [np.array(kpt) for kpt in self.actual_kpoints]

        p_eig_vals: defaultdict[Spin, list] = defaultdict(list)
        eigenvals: defaultdict[Spin, list] = defaultdict(list)

        n_kpts = len(kpoints)

        if use_kpoints_opt:
            if self.kpoints_opt_props is None:
                raise RuntimeError("KPOINTS_opt is None.")
            eig_vals = self.kpoints_opt_props.eigenvalues
            projected_eig_vals = self.kpoints_opt_props.projected_eigenvalues
        else:
            eig_vals = self.eigenvalues
            projected_eig_vals = self.projected_eigenvalues

        if eig_vals is None:
            raise ValueError("Eigenvalues are None.")

        for spin, val in eig_vals.items():
            val = np.swapaxes(val, 0, 1)
            eigenvals[spin] = val[:, :, 0]  # type:ignore[assignment]

            if projected_eig_vals:
                proj_eig_vals = projected_eig_vals[spin]
                # Original axes for self.projected_eigenvalues are kpoints, band, ion, orb.
                # For BS input, we need band, kpoints, orb, ion.
                proj_eig_vals = np.swapaxes(proj_eig_vals, 0, 1)  # Swap kpoint and band axes
                proj_eig_vals = np.swapaxes(proj_eig_vals, 2, 3)  # Swap ion and orb axes

                p_eig_vals[spin] = proj_eig_vals  # type:ignore[assignment]

        # Check if we have an hybrid band structure computation
        # for this we look at the presence of the LHFCALC tag
        # (but hybrid mode is redundant if using kpoints_opt)
        hybrid_band = False
        if self.parameters.get("LHFCALC", False) or 0.0 in self.actual_kpoints_weights:
            hybrid_band = True

        if kpoint_file is not None and kpoint_file.style == Kpoints.supported_modes.Line_mode:
            line_mode = True

        if line_mode:
            labels_dict = {}
            if kpoint_file is None:
                raise RuntimeError("Kpoint file cannot be None for line mode.")

            if (hybrid_band or force_hybrid_mode) and not use_kpoints_opt:
                start_bs_index = 0
                for i in range(len(self.actual_kpoints)):
                    if math.isclose(self.actual_kpoints_weights[i], 0.0):
                        start_bs_index = i
                        break
                for i in range(start_bs_index, len(kpoint_file.kpts)):
                    if kpoint_file.labels is not None and kpoint_file.labels[i] is not None:
                        labels_dict[kpoint_file.labels[i]] = kpoint_file.kpts[i]
                # Remake the data only considering line band structure k-points
                # (weight = 0.0 kpoints)
                n_bands = len(eigenvals[Spin.up])
                kpoints = kpoints[start_bs_index:n_kpts]
                up_eigen = [eigenvals[Spin.up][i][start_bs_index:n_kpts] for i in range(n_bands)]
                if self.projected_eigenvalues:
                    p_eig_vals[Spin.up] = [p_eig_vals[Spin.up][i][start_bs_index:n_kpts] for i in range(n_bands)]
                if self.is_spin:
                    down_eigen = [eigenvals[Spin.down][i][start_bs_index:n_kpts] for i in range(n_bands)]
                    eigenvals[Spin.up] = up_eigen
                    eigenvals[Spin.down] = down_eigen
                    if self.projected_eigenvalues:
                        p_eig_vals[Spin.down] = [
                            p_eig_vals[Spin.down][i][start_bs_index:n_kpts] for i in range(n_bands)
                        ]
                else:
                    eigenvals[Spin.up] = up_eigen
            else:
                if kpoint_file.labels is not None:
                    if "" in kpoint_file.labels:
                        raise ValueError(
                            "A band structure along symmetry lines requires a label "
                            "for each kpoint. Check your KPOINTS file"
                        )
                    labels_dict = dict(zip(kpoint_file.labels, kpoint_file.kpts, strict=True))
                labels_dict.pop(None, None)  # type: ignore[call-overload]

            return BandStructureSymmLine(
                np.array(kpoints),
                eigenvals,
                lattice_new,
                e_fermi,
                labels_dict,
                structure=self.final_structure,
                projections=p_eig_vals,  # type:ignore[arg-type]
            )

        return BandStructure(
            kpoints,
            eigenvals,
            lattice_new,
            e_fermi,
            structure=self.final_structure,
            projections=p_eig_vals,  # type:ignore[arg-type]
        )

    @property
    def eigenvalue_band_properties(
        self,
    ) -> (
        tuple[float, float, float, bool]
        | tuple[
            tuple[float, float],
            tuple[float, float],
            tuple[float, float],
            tuple[bool, bool],
        ]
    ):
        """Band properties from the eigenvalues as a tuple,
        (band gap, cbm, vbm, is_band_gap_direct).
        In the case of separate_spins=True, the band gap, cbm, vbm, and is_band_gap_direct are each
        lists of length 2, with index 0 representing the spin-up channel and index 1 representing the
        spin-down channel.
        """
        vbm = -float("inf")
        vbm_kpoint = None
        cbm = float("inf")
        cbm_kpoint = None

        vbm_spins = []
        vbm_spins_kpoints = []
        cbm_spins = []
        cbm_spins_kpoints = []

        if self.eigenvalues is None:
            raise ValueError("eigenvalues is None.")

        if self.separate_spins and len(self.eigenvalues) != 2:
            raise ValueError("The separate_spins flag can only be True if ISPIN = 2")

        for eigenvalue in self.eigenvalues.values():
            if self.separate_spins:
                vbm = -float("inf")
                cbm = float("inf")
            for kpoint, val in enumerate(eigenvalue):
                for eigenval, occu in val:
                    if occu > self.occu_tol and eigenval > vbm:
                        vbm = eigenval
                        vbm_kpoint = kpoint
                    elif occu <= self.occu_tol and eigenval < cbm:
                        cbm = eigenval
                        cbm_kpoint = kpoint
            if self.separate_spins:
                vbm_spins.append(vbm)
                vbm_spins_kpoints.append(vbm_kpoint)
                cbm_spins.append(cbm)
                cbm_spins_kpoints.append(cbm_kpoint)
        if self.separate_spins:
            return (
                (
                    max(cbm_spins[0] - vbm_spins[0], 0),
                    max(cbm_spins[1] - vbm_spins[1], 0),
                ),
                (cbm_spins[0], cbm_spins[1]),
                (vbm_spins[0], vbm_spins[1]),
                (
                    vbm_spins_kpoints[0] == cbm_spins_kpoints[0],
                    vbm_spins_kpoints[1] == cbm_spins_kpoints[1],
                ),
            )
        return max(cbm - vbm, 0), cbm, vbm, vbm_kpoint == cbm_kpoint

    def calculate_efermi(self, tol: float = 0.001) -> float:
        """Calculate the Fermi level using a robust algorithm.

        Sometimes VASP can set the Fermi level inside a band
        due to issues in the way band occupancies are handled.
        This method tries to detect and correct this.

        More details: https://www.vasp.at/forum/viewtopic.php?f=4&t=17981.
        """
        if self.eigenvalues is None:
            raise ValueError("eigenvalues cannot be None.")
        if self.efermi is None:
            raise ValueError("efermi cannot be None.")

        # Drop weights and set shape n_bands, n_kpoints
        all_eigs = np.concatenate([eigs[:, :, 0].transpose(1, 0) for eigs in self.eigenvalues.values()])

        def crosses_band(fermi: float) -> bool:
            eigs_below = np.any(all_eigs < fermi, axis=1)
            eigs_above = np.any(all_eigs > fermi, axis=1)
            return np.any(eigs_above & eigs_below)  # type:ignore[return-value]

        def get_vbm_cbm(fermi):
            return np.max(all_eigs[all_eigs < fermi]), np.min(all_eigs[all_eigs > fermi])

        # Fermi level doesn't cross a band: safe to use VASP Fermi level
        if not crosses_band(self.efermi):
            return self.efermi

        # If the Fermi level crosses a band, check if we are very close to band gap.
        # If so, then likely this is a VASP tetrahedron bug
        if not crosses_band(self.efermi + tol):
            # efermi resides in the valence band
            # Set Fermi level as average of CBM and VBM
            vbm, cbm = get_vbm_cbm(self.efermi + tol)
            return (cbm + vbm) / 2

        if not crosses_band(self.efermi - tol):
            # efermi resides in the conduction band
            # Set Fermi level as average of CBM and VBM
            vbm, cbm = get_vbm_cbm(self.efermi - tol)
            return (cbm + vbm) / 2

        # If is a metal
        return self.efermi

    def get_potcars(self, path: PathLike | bool) -> Potcar | None:
        """Get the POTCAR from the specified path.

        Args:
            path (PathLike | bool): If a str or Path, the path to search for POTCARs.
                If a bool, whether to take the search path from the specified vasprun.xml

        Returns:
            Potcar | None: The POTCAR from the specified path or None if not found/no path specified.
        """
        if not path:
            return None

        if isinstance(path, str | Path) and "POTCAR" in str(path):
            potcar_paths = [str(path)]
        else:
            # the abspath is needed here in cases where no leading directory is specified,
            # e.g. Vasprun("vasprun.xml"). see gh-3586:
            search_path = os.path.dirname(os.path.abspath(self.filename)) if path is True else str(path)

            potcar_paths = [
                f"{search_path}/{fn}" for fn in os.listdir(search_path) if fn.startswith("POTCAR") and ".spec" not in fn
            ]

        for potcar_path in potcar_paths:
            try:
                potcar = Potcar.from_file(potcar_path)
                if {d.header for d in potcar} == set(self.potcar_symbols):
                    return potcar
            except Exception:
                continue

        warnings.warn(
            "No POTCAR file with matching TITEL fields was found in\n" + "\n  ".join(potcar_paths),
            stacklevel=2,
        )

        return None

    def get_trajectory(self) -> Trajectory:
        """
        Get a Trajectory, an alternative representation of self.structures
        as a single object. Forces are added as site properties.

        Returns:
            Trajectory
        """
        structs: list[Structure] = []
        steps = self.md_data or self.ionic_steps
        for step in steps:
            struct = step["structure"].copy()
            struct.add_site_property("forces", step["forces"])
            structs.append(struct)
        return Trajectory.from_structures(structs, constant_lattice=False)

    def update_potcar_spec(self, path: PathLike | bool) -> None:
        """Update the specs based on the POTCARs found.

        Args:
            path (PathLike | bool): Path to search for POTCARs.

        Note that the vasprun.xml spec typically hasn't included the POTCAR symbols,
        since these are derived from the TITEL.
        """
        if potcar := self.get_potcars(path):
            self.potcar_spec = [
                ps.spec(extra_spec=[]) for sym in self.potcar_symbols for ps in potcar if ps.symbol == sym.split()[1]
            ]

    def update_charge_from_potcar(self, path: PathLike | bool) -> None:
        """Update the charge of a structure based on the POTCARs found.

        Args:
            path (PathLike | bool): Path to search for POTCARs.
        """
        potcar = self.get_potcars(path)

        if potcar and self.incar.get("ALGO", "").upper() not in {
            "GW0",
            "G0W0",
            "GW",
            "BSE",
            "CHI",
            # VASP renamed the GW tags in v6.
            "QPGW",
            "QPGW0",
            "EVGW",
            "EVGW0",
            "GWR",
            "GW0R",
        }:
            nelect = self.parameters["NELECT"]
            if len(potcar) == len(self.initial_structure.composition.element_composition):
                potcar_nelect = sum(
                    self.initial_structure.composition.element_composition[ps.element] * ps.ZVAL for ps in potcar
                )
            else:
                nums = [len(list(g)) for _, g in itertools.groupby(self.atomic_symbols)]
                potcar_nelect = sum(ps.ZVAL * num for ps, num in zip(potcar, nums, strict=False))
            charge = potcar_nelect - nelect

            for struct in self.structures:
                struct._charge = charge
            if hasattr(self, "initial_structure"):
                self.initial_structure._charge = charge
            if hasattr(self, "final_structure"):
                self.final_structure._charge = charge

    def as_dict(self) -> dict:
        """JSON-serializable dict representation."""
        comp = self.final_structure.composition
        unique_symbols = sorted(set(self.atomic_symbols))
        dct: dict[str, Any] = {
            "vasp_version": self.vasp_version,
            "has_vasp_completed": self.converged,
            "nsites": len(self.final_structure),
            "unit_cell_formula": comp.as_dict(),
            "reduced_cell_formula": Composition(comp.reduced_formula).as_dict(),
            "pretty_formula": comp.reduced_formula,
            "is_hubbard": self.is_hubbard,
            "hubbards": self.hubbards,
            "elements": unique_symbols,
            "nelements": len(unique_symbols),
            "run_type": self.run_type,
        }

        vin: dict[str, Any] = {
            "incar": dict(self.incar.items()),
            "crystal": self.initial_structure.as_dict(),
            "kpoints": self.kpoints.as_dict(),
        }
        actual_kpts = [
            {
                "abc": list(self.actual_kpoints[idx]),
                "weight": self.actual_kpoints_weights[idx],
            }
            for idx in range(len(self.actual_kpoints))
        ]
        vin["kpoints"]["actual_points"] = actual_kpts
        vin["nkpoints"] = len(actual_kpts)
        if kpt_opt_props := self.kpoints_opt_props:
            if (
                kpt_opt_props.kpoints is None
                or kpt_opt_props.actual_kpoints is None
                or kpt_opt_props.actual_kpoints_weights is None
            ):
                raise ValueError("kpt_opt_props.kpoints/actual_kpoints/actual_kpoints_weights cannot be None.")
            vin["kpoints_opt"] = kpt_opt_props.kpoints.as_dict()
            actual_kpts = [
                {
                    "abc": list(kpt_opt_props.actual_kpoints[idx]),
                    "weight": kpt_opt_props.actual_kpoints_weights[idx],
                }
                for idx in range(len(kpt_opt_props.actual_kpoints))
            ]
            vin["kpoints_opt"]["actual_kpoints"] = actual_kpts
            vin["nkpoints_opt"] = len(actual_kpts)
        vin["potcar"] = [s.split(" ")[1] for s in self.potcar_symbols]
        vin["potcar_spec"] = self.potcar_spec
        vin["potcar_type"] = [s.split(" ")[0] for s in self.potcar_symbols]
        vin["parameters"] = dict(self.parameters.items())
        vin["lattice_rec"] = self.final_structure.lattice.reciprocal_lattice.as_dict()
        dct["input"] = vin

        n_sites = len(self.final_structure)

        try:
            vout = {
                "ionic_steps": self.ionic_steps,
                "final_energy": self.final_energy,
                "final_energy_per_atom": self.final_energy / n_sites,
                "crystal": self.final_structure.as_dict(),
                "efermi": self.efermi,
            }
        except (ArithmeticError, TypeError):
            vout = {
                "ionic_steps": self.ionic_steps,
                "final_energy": self.final_energy,
                "final_energy_per_atom": None,
                "crystal": self.final_structure.as_dict(),
                "efermi": self.efermi,
            }

        if self.eigenvalues:
            eigen = {str(spin): v.tolist() for spin, v in self.eigenvalues.items()}
            vout["eigenvalues"] = eigen
            gap, cbm, vbm, is_direct = self.eigenvalue_band_properties
            vout |= {"bandgap": gap, "cbm": cbm, "vbm": vbm, "is_gap_direct": is_direct}

            if self.projected_eigenvalues:
                vout["projected_eigenvalues"] = {
                    str(spin): v.tolist() for spin, v in self.projected_eigenvalues.items()
                }

            if self.projected_magnetization is not None:
                vout["projected_magnetization"] = self.projected_magnetization.tolist()

        if kpt_opt_props and kpt_opt_props.eigenvalues:
            eigen = {str(spin): v.tolist() for spin, v in kpt_opt_props.eigenvalues.items()}
            vout["eigenvalues_kpoints_opt"] = eigen
            # TODO: implement kpoints_opt eigenvalue_band_proprties.
            # gap, cbm, vbm, is_direct = self.eigenvalue_band_properties
            # vout |= {"bandgap": gap, "cbm": cbm, "vbm": vbm, "is_gap_direct": is_direct}

            if kpt_opt_props.projected_eigenvalues:
                vout["projected_eigenvalues_kpoints_opt"] = {
                    str(spin): v.tolist() for spin, v in kpt_opt_props.projected_eigenvalues.items()
                }

            if kpt_opt_props.projected_magnetization is not None:
                vout["projected_magnetization_kpoints_opt"] = kpt_opt_props.projected_magnetization.tolist()

        vout["epsilon_static"] = self.epsilon_static
        vout["epsilon_static_wolfe"] = self.epsilon_static_wolfe
        vout["epsilon_ionic"] = self.epsilon_ionic
        dct["output"] = vout
        return jsanitize(dct, strict=True)

    def _parse_params(self, elem: etree.Element) -> Incar:
        """Parse INCAR parameters and more."""
        params: dict[str, Any] = {}
        for c in elem:
            # VASP 6.4.3 can add trailing whitespace
            # for example, <i type="string" name="GGA    ">PE</i>
            name = c.attrib.get("name", "").strip()
            if c.tag not in {"i", "v"}:
                p = self._parse_params(c)
                if name == "response functions":
                    # Delete duplicate fields from "response functions",
                    # which overrides the values in the root params.
                    p = {k: v for k, v in p.items() if k not in params}  # type:ignore[assignment]
                params |= p

            else:
                ptype = c.attrib.get("type", "")
                val = c.text.strip() if c.text else ""
                try:
                    if c.tag == "i":
                        params[name] = _parse_parameters(ptype, val)
                    else:
                        params[name] = _parse_v_parameters(ptype, val, self.filename, name)

                except Exception:
                    if name == "RANDOM_SEED":
                        # Handle the case where RANDOM_SEED > 99999, which results in *****
                        params[name] = None
                    else:
                        raise
        elem.clear()
        return Incar(params)

    @staticmethod
    def _parse_atominfo(elem: etree.Element) -> tuple[list[str], list[str]]:
        """Parse atom symbols and POTCAR symbols."""

        def parse_atomic_symbol(symbol: str) -> str:
            """Parse and ensure atomic symbols are valid elements."""
            try:
                return str(Element(symbol))

            # vasprun.xml uses "X" instead of "Xe" for Xenon
            except ValueError:
                if symbol == "X":
                    return "Xe"
                if symbol == "r":
                    return "Zr"
                raise

        atomic_symbols = []
        potcar_symbols = []

        for a in elem.findall("array"):
            if a.attrib["name"] == "atoms":
                atomic_symbols = [rc.find("c").text.strip() for rc in a.find("set")]  # type: ignore[union-attr]
            elif a.attrib["name"] == "atomtypes":
                potcar_symbols = [rc.findall("c")[4].text.strip() for rc in a.find("set")]  # type: ignore[union-attr]

        elem.clear()
        return [parse_atomic_symbol(sym) for sym in atomic_symbols], potcar_symbols

    @staticmethod
    def _parse_kpoints(
        elem: etree.Element,
    ) -> tuple[Kpoints, list[tuple[float, float, float]], list[float]]:
        """Parse Kpoints."""
        gen = elem.find("generation")
        e = elem if gen is None else gen
        kpoint = Kpoints("Kpoints from vasprun.xml")
        kpoint.style = Kpoints.supported_modes.from_str(e.attrib.get("param", "Reciprocal"))  # type: ignore[union-attr]

        for v in e.findall("v"):  # type: ignore[union-attr]
            name = v.attrib.get("name")  # type: ignore[union-attr]
            tokens = v.text.split()  # type: ignore[union-attr]

            if name == "divisions":
                kpoint.kpts = [
                    cast("Kpoint", tuple(int(i) for i in tokens)),
                ]
            elif name == "usershift":
                kpoint.kpts_shift = cast("tuple[float, float, float]", tuple(float(i) for i in tokens))
            elif name in {"genvec1", "genvec2", "genvec3", "shift"}:
                setattr(kpoint, name, [float(i) for i in tokens])

        actual_kpoints = []
        weights = []
        for va in elem.findall("varray"):
            name = va.attrib["name"]
            if name == "kpointlist":
                actual_kpoints = cast("list[tuple[float, float, float]]", list(map(tuple, _parse_vasp_array(va))))  # type:ignore[arg-type]
            elif name == "weights":
                weights_array = _parse_vasp_array(va)
                if isinstance(weights_array, np.ndarray):
                    weights_array = weights_array.flatten()
                weights = list(weights_array)
        elem.clear()

        if kpoint.style == Kpoints.supported_modes.Reciprocal:
            kpoint = Kpoints(
                comment="Kpoints from vasprun.xml",
                style=Kpoints.supported_modes.Reciprocal,
                num_kpts=len(kpoint.kpts),
                kpts=actual_kpoints,
                kpts_weights=weights,  # type: ignore[arg-type]
            )
        return kpoint, actual_kpoints, weights  # type:ignore[return-value]

    def _parse_structure(self, elem: etree.Element) -> Structure:
        """Parse Structure with lattice, positions and selective dynamics info."""
        lattice = _parse_vasp_array(elem.find("crystal").find("varray"))  # type: ignore[union-attr]
        pos = _parse_vasp_array(elem.find("varray"))
        struct = Structure(lattice, self.atomic_symbols, pos)
        selective_dyn = elem.find("varray/[@name='selective']")

        if selective_dyn is not None:
            struct.add_site_property("selective_dynamics", _parse_vasp_array(selective_dyn))
        return struct

    @staticmethod
    def _parse_diel(elem: etree.Element) -> tuple[list, list, list]:
        """Parse dielectric properties."""
        real_elem = elem.find("real")
        imag_elem = elem.find("imag")
        if real_elem is not None and imag_elem is not None:
            imag = [
                [_vasprun_float(line) for line in r.text.split()]  # type: ignore[union-attr]
                for r in imag_elem.find("array").find("set").findall("r")  # type: ignore[union-attr]
            ]
            real = [
                [_vasprun_float(line) for line in r.text.split()]  # type: ignore[union-attr]
                for r in real_elem.find("array").find("set").findall("r")  # type: ignore[union-attr]
            ]
            return [e[0] for e in imag], [e[1:] for e in real], [e[1:] for e in imag]
        return [], [], []

    @staticmethod
    def _parse_optical_transition(elem: etree.Element) -> tuple[NDArray, NDArray]:
        """Parse optical transitions."""
        for va in elem.findall("varray"):
            if va.attrib.get("name") == "opticaltransitions":
                # optical transitions array contains oscillator strength and probability of transition
                oscillator_strength = _parse_vasp_array(va)[:]
                probability_transition = _parse_vasp_array(va)[:, 1]
                elem.clear()
                return oscillator_strength, probability_transition  # type:ignore[return-value]

        raise RuntimeError("Failed to parse optical transitions.")

    def _parse_chemical_shielding(self, elem: etree.Element) -> list[dict[str, Any]]:
        """Parse NMR chemical shielding."""
        istep: dict[str, Any] = {}
        # not all calculations have a structure
        _struct = elem.find("structure")
        struct = None if _struct is None else self._parse_structure(_struct)

        for va in elem.findall("varray"):
            istep[va.attrib["name"]] = _parse_vasp_array(va)

        istep["structure"] = struct
        istep["electronic_steps"] = []
        calculation = [
            istep,
        ]
        for scstep in elem.findall("scstep"):
            try:
                e_steps_dict = {i.attrib["name"]: _vasprun_float(i.text) for i in scstep.find("energy").findall("i")}  # type: ignore[union-attr, arg-type]
                cur_ene = e_steps_dict["e_fr_energy"]
                min_steps = 1 if len(calculation) >= 1 else self.parameters.get("NELMIN", 5)
                if len(calculation[-1]["electronic_steps"]) <= min_steps:
                    calculation[-1]["electronic_steps"].append(e_steps_dict)
                else:
                    last_ene = calculation[-1]["electronic_steps"][-1]["e_fr_energy"]  # type: ignore[call-overload]
                    if abs(cur_ene - last_ene) < 1.0:
                        calculation[-1]["electronic_steps"].append(e_steps_dict)
                    else:
                        calculation.append({"electronic_steps": [e_steps_dict]})
            except AttributeError:  # not all calculations have an energy
                pass
        calculation[-1].update(calculation[-1]["electronic_steps"][-1])
        elem.clear()
        return calculation

    def _parse_ionic_step(self, elem: etree.Element) -> dict[str, float]:
        """Parse an ionic step."""
        try:
            ion_step: dict[str, Any] = {
                i.attrib["name"]: _vasprun_float(i.text)  # type: ignore[arg-type]
                for i in elem.find("energy").findall("i")  # type: ignore[union-attr]
            }
        # Not all calculations have an energy
        except AttributeError:
            ion_step = {}

        elec_steps = []
        for scstep in elem.findall("scstep"):
            try:
                e_step_dict = {i.attrib["name"]: _vasprun_float(i.text) for i in scstep.find("energy").findall("i")}  # type: ignore[union-attr, arg-type]
                elec_steps.append(e_step_dict)
            # Not all calculations have an energy
            except AttributeError:
                pass

        try:
            struct = self._parse_structure(elem.find("structure"))  # type: ignore[arg-type]
        except AttributeError:  # not all calculations have a structure
            struct = None

        for va in elem.findall("varray"):
            ion_step[va.attrib["name"]] = _parse_vasp_array(va)
        ion_step["electronic_steps"] = elec_steps
        ion_step["structure"] = struct
        elem.clear()
        return ion_step

    @staticmethod
    def _parse_dos(elem: etree.Element) -> tuple[Dos, Dos, list[dict]]:
        """Parse density of states (DOS)."""
        efermi = float(elem.find("i").text)  # type: ignore[union-attr, arg-type]
        energies: NDArray | None = None
        tdensities = {}
        idensities = {}

        soc_run = len(elem.find("total").find("array").find("set").findall("set")) > 2  # type: ignore[union-attr]
        for s in elem.find("total").find("array").find("set").findall("set"):  # type: ignore[union-attr]
            data = _parse_vasp_array(s)
            energies = data[:, 0]
            spin = Spin.up if s.attrib["comment"] == "spin 1" else Spin.down
            if spin != Spin.up and soc_run:  # other 'spins' are x,y,z SOC projections
                continue
            tdensities[spin] = data[:, 1]
            idensities[spin] = data[:, 2]

        pdoss = []
        partial = elem.find("partial")
        if partial is not None:
            orbs = [ss.text for ss in partial.find("array").findall("field")]  # type: ignore[union-attr]
            orbs.pop(0)
            lm = any("x" in s for s in orbs if s is not None)
            for s in partial.find("array").find("set").findall("set"):  # type: ignore[union-attr]
                pdos: dict[Orbital | OrbitalType, dict[Spin, NDArray]] = defaultdict(dict)

                for ss in s.findall("set"):
                    spin = Spin.up if ss.attrib["comment"] == "spin 1" else Spin.down
                    if spin != Spin.up and soc_run:  # other 'spins' are x,y,z SOC projections
                        continue
                    data = _parse_vasp_array(ss)
                    _n_row, n_col = data.shape
                    for col_idx in range(1, n_col):
                        orb = Orbital(col_idx - 1) if lm else OrbitalType(col_idx - 1)
                        pdos[orb][spin] = data[:, col_idx]  # type: ignore[index]
                pdoss.append(pdos)
        elem.clear()

        if energies is None:
            raise ValueError("energies is None")
        return (
            Dos(efermi, energies, tdensities),
            Dos(efermi, energies, idensities),
            pdoss,
        )

    @staticmethod
    def _parse_eigen(elem: etree.Element) -> dict[Spin, NDArray]:
        """Parse eigenvalues."""
        eigenvalues: dict[Spin, NDArray] = defaultdict(list)  # type:ignore[arg-type]
        for s in elem.find("array").find("set").findall("set"):  # type: ignore[union-attr]
            spin = Spin.up if s.attrib["comment"] == "spin 1" else Spin.down
            for ss in s.findall("set"):
                eigenvalues[spin].append(_parse_vasp_array(ss))
        eigenvalues = {spin: np.array(v) for spin, v in eigenvalues.items()}
        elem.clear()
        return eigenvalues

    @staticmethod
    def _parse_projected_eigen(
        elem: etree.Element,
    ) -> tuple[dict[Spin, NDArray], NDArray | None]:
        """Parse projected eigenvalues."""
        root = elem.find("array").find("set")  # type: ignore[union-attr]
        _proj_eigen: dict[int, NDArray] = defaultdict(list)  # type:ignore[arg-type]
        for s in root.findall("set"):  # type: ignore[union-attr]
            spin: int = int(re.match(r"spin(\d+)", s.attrib["comment"])[1])  # type: ignore[index]

            # Force spin to be +1 or -1
            for ss in s.findall("set"):
                dk = []
                for sss in ss.findall("set"):
                    db = _parse_vasp_array(sss)
                    dk.append(db)
                _proj_eigen[spin].append(dk)
        _proj_eigen = {spin: np.array(v) for spin, v in _proj_eigen.items()}

        if len(_proj_eigen) > 2:
            # non-collinear magentism (also spin-orbit coupling) enabled, last three
            # "spin channels" are the projected magnetization of the orbitals in the
            # x, y, and z Cartesian coordinates
            proj_mag = np.stack([_proj_eigen.pop(i) for i in range(2, 5)], axis=-1)  # type: ignore[call-overload]
            proj_eigen: dict[Spin, NDArray] = {Spin.up: _proj_eigen[1]}
        else:
            proj_eigen = {Spin.up if k == 1 else Spin.down: v for k, v in _proj_eigen.items()}
            proj_mag = None

        elem.clear()
        return proj_eigen, proj_mag

    @staticmethod
    def _parse_dynmat(elem: etree.Element) -> tuple[list, list, list]:
        """Parse dynamical matrix."""
        hessian: list[list[float]] = []
        eigenvalues: list[float] = []
        eigenvectors: list[list[float]] = []

        for v in elem.findall("v"):
            if v.attrib["name"] == "eigenvalues":
                eigenvalues = [float(i) for i in v.text.split()]

        for va in elem.findall("varray"):
            if va.attrib["name"] == "hessian":
                hessian.extend([float(i) for i in v.text.split()] for v in va.findall("v"))

            elif va.attrib["name"] == "eigenvectors":
                eigenvectors.extend([float(i) for i in v.text.split()] for v in va.findall("v"))
        return hessian, eigenvalues, eigenvectors


class BSVasprun(Vasprun):
    """
    A highly optimized version of Vasprun that parses only eigenvalues for
    bandstructures. All other properties like structures, parameters,
    etc. are ignored.
    """

    def __init__(
        self,
        filename: PathLike,
        parse_projected_eigen: bool | str = False,
        parse_potcar_file: bool | str = False,
        occu_tol: float = 1e-8,
        separate_spins: bool = False,
    ) -> None:
        """Initialize a BSVasprun.

        Args:
            filename: Filename to parse
            parse_projected_eigen: Whether to parse the projected
                eigenvalues. Defaults to False. Set to True to obtain projected
                eigenvalues. **Note that this can take an extreme amount of time
                and memory.** So use this wisely.
            parse_potcar_file: Whether to parse the potcar file to read
                the potcar hashes for the potcar_spec attribute. Defaults to True,
                where no hashes will be determined and the potcar_spec dictionaries
                will read {"symbol": ElSymbol, "hash": None}. By Default, looks in
                the same directory as the vasprun.xml, with same extensions as
                Vasprun.xml. If a string is provided, looks at that filepath.
            occu_tol: Sets the minimum tol for the determination of the
                vbm and cbm. Usually the default of 1e-8 works well enough,
                but there may be pathological cases.
            separate_spins (bool): Whether the band gap, CBM, and VBM should be
                reported for each individual spin channel. Defaults to False,
                which computes the eigenvalue band properties independent of
                the spin orientation. If True, the calculation must be spin-polarized.
        """
        self.filename = filename
        self.occu_tol = occu_tol
        self.separate_spins = separate_spins

        with zopen(filename, mode="rb") as file:
            self.efermi = None
            parsed_header = False
            in_kpoints_opt = False
            self.eigenvalues = self.projected_eigenvalues = None
            self.kpoints_opt_props = None
            for event, elem in etree.iterparse(file, events=["start", "end"]):
                tag = elem.tag
                if event == "start" and not in_kpoints_opt:
                    # The start event tells us when we have entered blocks
                    if tag in {"eigenvalues_kpoints_opt", "projected_kpoints_opt"} or (
                        tag == "dos" and elem.attrib.get("comment") == "kpoints_opt"
                    ):
                        in_kpoints_opt = True
                elif not parsed_header:
                    if tag == "generator":
                        self.generator = self._parse_params(elem)
                    elif tag == "incar":
                        self.incar = self._parse_params(elem)
                    elif tag == "kpoints":
                        (
                            self.kpoints,
                            self.actual_kpoints,
                            self.actual_kpoints_weights,
                        ) = self._parse_kpoints(elem)
                    elif tag == "parameters":
                        self.parameters = self._parse_params(elem)
                    elif tag == "atominfo":
                        self.atomic_symbols, self.potcar_symbols = self._parse_atominfo(elem)
                        self.potcar_spec = [
                            {"titel": p, "hash": None, "summary_stats": {}} for p in self.potcar_symbols
                        ]
                        parsed_header = True
                elif tag == "i" and elem.attrib.get("name") == "efermi":
                    if in_kpoints_opt:
                        if self.kpoints_opt_props is None:
                            self.kpoints_opt_props = KpointOptProps()
                        self.kpoints_opt_props.efermi = float(elem.text)  # type:ignore[arg-type]
                        in_kpoints_opt = False
                    else:
                        self.efermi = float(elem.text)  # type:ignore[arg-type]
                elif tag == "eigenvalues" and not in_kpoints_opt:
                    self.eigenvalues = self._parse_eigen(elem)
                elif parse_projected_eigen and tag == "projected" and not in_kpoints_opt:
                    self.projected_eigenvalues, self.projected_magnetization = self._parse_projected_eigen(elem)
                elif tag in ("eigenvalues_kpoints_opt", "projected_kpoints_opt"):
                    if self.kpoints_opt_props is None:
                        self.kpoints_opt_props = KpointOptProps()
                    in_kpoints_opt = False
                    # projected_kpoints_opt includes occupation information whereas
                    # eigenvalues_kpoints_opt doesn't.
                    self.kpoints_opt_props.eigenvalues = self._parse_eigen(elem.find("eigenvalues"))  # type:ignore[arg-type]
                    if tag == "eigenvalues_kpoints_opt":
                        (
                            self.kpoints_opt_props.kpoints,
                            self.kpoints_opt_props.actual_kpoints,
                            self.kpoints_opt_props.actual_kpoints_weights,
                        ) = self._parse_kpoints(elem.find("kpoints"))  # type:ignore[arg-type]
                    elif parse_projected_eigen:  # and tag == "projected_kpoints_opt": (implied)
                        (
                            self.kpoints_opt_props.projected_eigenvalues,
                            self.kpoints_opt_props.projected_magnetization,
                        ) = self._parse_projected_eigen(elem)
                elif tag == "structure" and elem.attrib.get("name") == "finalpos":
                    self.final_structure = self._parse_structure(elem)
        self.vasp_version = self.generator["version"]
        if parse_potcar_file:
            self.update_potcar_spec(parse_potcar_file)

    def as_dict(self) -> dict:
        """JSON-serializable dict representation."""
        comp = self.final_structure.composition
        unique_symbols = sorted(set(self.atomic_symbols))
        dct = {
            "vasp_version": self.vasp_version,
            "has_vasp_completed": True,
            "nsites": len(self.final_structure),
            "unit_cell_formula": comp.as_dict(),
            "reduced_cell_formula": Composition(comp.reduced_formula).as_dict(),
            "pretty_formula": comp.reduced_formula,
            "is_hubbard": self.is_hubbard,
            "hubbards": self.hubbards,
            "elements": unique_symbols,
            "nelements": len(unique_symbols),
            "run_type": self.run_type,
        }

        vin: dict[str, Any] = {
            "incar": dict(self.incar),
            "crystal": self.final_structure.as_dict(),
            "kpoints": self.kpoints.as_dict(),
        }
        actual_kpts = [
            {
                "abc": list(self.actual_kpoints[i]),
                "weight": self.actual_kpoints_weights[i],
            }
            for i in range(len(self.actual_kpoints))
        ]
        vin["kpoints"]["actual_points"] = actual_kpts
        if kpt_opt_props := getattr(self, "kpoints_opt_props", None):
            vin["kpoints_opt"] = kpt_opt_props.kpoints.as_dict()
            actual_kpts = [
                {
                    "abc": list(kpt_opt_props.actual_kpoints[idx]),
                    "weight": kpt_opt_props.actual_kpoints_weights[idx],
                }
                for idx in range(len(kpt_opt_props.actual_kpoints))
            ]
            vin["kpoints_opt"]["actual_kpoints"] = actual_kpts
            vin["nkpoints_opt"] = len(actual_kpts)
        vin["potcar"] = [s.split(" ")[1] for s in self.potcar_symbols]
        vin["potcar_spec"] = self.potcar_spec
        vin["potcar_type"] = [s.split(" ")[0] for s in self.potcar_symbols]
        vin["parameters"] = dict(self.parameters)
        vin["lattice_rec"] = self.final_structure.lattice.reciprocal_lattice.as_dict()
        dct["input"] = vin

        vout: dict[str, Any] = {
            "crystal": self.final_structure.as_dict(),
            "efermi": self.efermi,
        }

        if self.eigenvalues:
            eigen: dict[Any, Any] = defaultdict(dict)
            for spin, values in self.eigenvalues.items():
                for idx, val in enumerate(values):
                    eigen[idx][str(spin)] = val
            vout["eigenvalues"] = eigen
            gap, cbm, vbm, is_direct = self.eigenvalue_band_properties
            vout |= {"bandgap": gap, "cbm": cbm, "vbm": vbm, "is_gap_direct": is_direct}

            if self.projected_eigenvalues:
                peigen: list[dict] = [{} for _ in eigen]
                for spin, val in self.projected_eigenvalues.items():
                    for kpoint_index, vv in enumerate(val):
                        if str(spin) not in peigen[kpoint_index]:
                            peigen[kpoint_index][str(spin)] = vv
                vout["projected_eigenvalues"] = peigen

        if kpt_opt_props and kpt_opt_props.eigenvalues:
            eigen = {str(spin): v.tolist() for spin, v in kpt_opt_props.eigenvalues.items()}
            vout["eigenvalues_kpoints_opt"] = eigen
            # TODO: implement kpoints_opt eigenvalue_band_proprties.
            # gap, cbm, vbm, is_direct = self.eigenvalue_band_properties
            # vout |= {"bandgap": gap, "cbm": cbm, "vbm": vbm, "is_gap_direct": is_direct}

            if kpt_opt_props.projected_eigenvalues:
                vout["projected_eigenvalues_kpoints_opt"] = {
                    str(spin): v.tolist() for spin, v in kpt_opt_props.projected_eigenvalues.items()
                }

        dct["output"] = vout
        return jsanitize(dct, strict=True)


class Outcar:
    """Parser for data in OUTCAR that is not available in vasprun.xml.

    Note, this class works a bit differently than most of the other
    VASP parsers, since OUTCAR can be very different depending on which
    "type of run" performed.

    Creating an Outcar instance with a filename reads "regular parameters" that
    are always present. One can then call a specific reader method depending on the
    type of run being performed, including (see the docstring of corresponding
    method for more details):
        - read_avg_core_poten
        - read_chemical_shielding
        - read_core_state_eigen
        - read_corrections
        - read_cs_core_contribution
        - read_cs_g0_contribution
        - read_cs_raw_symmetrized_tensors
        - read_elastic_tensor
        - read_electrostatic_potential
        - read_fermi_contact_shift
        - read_freq_dielectric
        - read_igpar
        - read_internal_strain_tensor
        - read_lcalcpol
        - read_lepsilon
        - read_lepsilon_ionic
        - read_neb
        - read_nmr_efg
        - read_nmr_efg_tensor
        - read_onsite_density_matrices
        - read_piezo_tensor
        - read_pseudo_zval
        - read_table_pattern
        - read_vacuum_potential

    Attributes:
        magnetization (tuple[dict[str, float]]): Magnetization on each ion, e.g.
            ({"d": 0.0, "p": 0.003, "s": 0.002, "tot": 0.005}, ... ).
        chemical_shielding (dict): Chemical shielding on each ion with core and valence contributions.
        unsym_cs_tensor (list): Unsymmetrized chemical shielding tensor matrixes on each ion.
            e.g. [[[sigma11, sigma12, sigma13], [sigma21, sigma22, sigma23], [sigma31, sigma32, sigma33]], ...]
        cs_g0_contribution (NDArray): G=0 contribution to chemical shielding. 2D rank 3 matrix.
        cs_core_contribution (dict[str, float]): Core contribution to chemical shielding. e.g.
            {'Mg': -412.8, 'C': -200.5, 'O': -271.1}
        efg (tuple[dict[str, float]]): Electric Field Gradient (EFG) tensor on each ion, e.g.
            ({"cq": 0.1, "eta", 0.2, "nuclear_quadrupole_moment": 0.3}, {"cq": 0.7, "eta", 0.8,
            "nuclear_quadrupole_moment": 0.9}, ...)
        charge (tuple[dict[str, float]]): Charge on each ion, e.g.
            ({"p": 0.154, "s": 0.078, "d": 0.0, "tot": 0.232}, ...)
        is_stopped (bool): True if OUTCAR is from a stopped run (using STOPCAR, see VASP Manual).
        run_stats (dict[str, float | None]): Various useful run stats including "System time (sec)",
            "Total CPU time used (sec)", "Elapsed time (sec)", "Maximum memory used (kb)",
            "Average memory used (kb)", "User time (sec)", "cores".
        elastic_tensor (NDArray): Total elastic moduli (Kbar) is given in a 6x6 array matrix.
        drift (NDArray): Total drift for each step in eV/Atom.
        ngf (tuple): Dimensions for the Augmentation grid.
        sampling_radii (NDArray): Size of the sampling radii in VASP for the test charges for the electrostatic
            potential at each atom. Total array size is the number of elements present in the calculation.
        electrostatic_potential (NDArray): Average electrostatic potential at each atomic position in order of
            the atoms in POSCAR.
        final_energy_contribs (dict[str, float]): Individual contributions to the total final energy.
            Include contributions from keys, e.g.:
            {'DENC': -505778.5184347, 'EATOM': 15561.06492564, 'EBANDS': -804.53201231, 'EENTRO': -0.08932659,
            'EXHF': 0.0, 'Ediel_sol': 0.0, 'PAW double counting': 664.6726974100002, 'PSCENC': 742.48691646,
            'TEWEN': 489742.86847338, 'XCENC': -169.64189814}
        efermi (float): Fermi energy.
        filename (PathLike): Filename.
        final_energy (float): Final energy after extrapolation of sigma back to 0, i.e. energy(sigma->0).
        final_energy_wo_entrp (float): Final energy before extrapolation of sigma, i.e. energy without entropy.
        final_fr_energy (float): Final "free energy", i.e. free energy TOTEN.
        has_onsite_density_matrices (bool): Whether onsite density matrices have been set.
        lcalcpol (bool): If LCALCPOL has been set.
        lepsilon (bool): If LEPSILON has been set.
        nelect (float): The number of electrons in the calculation.
        spin (bool): If spin-polarization is enabled via ISPIN.
        total_mag (float): Total magnetization (in terms of the number of unpaired electrons).

    Authors: Rickard Armiento, Shyue Ping Ong
    """

    def __init__(self, filename: PathLike) -> None:
        """Initialize an Outcar.

        Args:
            filename (PathLike): OUTCAR file to parse.
        """
        self.filename: str = str(filename)
        self.is_stopped: bool = False

        # Slurp the OUTCAR once. Every `read_pattern` / `read_table_pattern`
        # call in __init__ (20+) reuses this cache instead of re-opening and
        # decompressing the file. Helpers (read_chemical_shielding etc.) that
        # use micro_pyawk still hit disk; they are conditional on
        # NMR/LEPSILON/LCALCPOL and out of scope here.
        with zopen(self.filename, mode="rt", encoding="utf-8") as _f:
            self._text: str = _f.read()  # type:ignore[assignment]
        self._lines: list[str] = self._text.splitlines()

        # Assume a compilation with parallelization enabled.
        # Will be checked later.
        # If VASP is compiled in serial, the OUTCAR is written slightly differently.
        serial_compilation: bool = False

        # Data from the end of OUTCAR
        charge = []
        mag_x = []
        mag_y = []
        mag_z = []
        header = []
        run_stats: dict[str, float | None] = {}
        total_mag: float | None = None
        nelect: float | None = None
        efermi: float | None = None
        e_fr_energy: float | None = None
        e_wo_entrp: float | None = None
        e0: float | None = None

        time_patt = re.compile(r"\((sec|kb)\)")
        efermi_patt = re.compile(r"E-fermi\s*:\s*(\S+)")
        nelect_patt = re.compile(r"number of electron\s+(\S+)\s+magnetization")
        mag_patt = re.compile(r"number of electron\s+\S+\s+magnetization\s+(\S+)")
        e_fr_energy_pattern = re.compile(r"free  energy   TOTEN\s+=\s+([\d\-\.]+)")
        e_wo_entrp_pattern = re.compile(r"energy  without entropy\s*=\s+([\d\-\.]+)")
        e0_pattern = re.compile(r"energy\(sigma->0\)\s*=\s+([\d\-\.]+)")

        # Iterate the cached lines in reverse (replaces reverse_readfile +
        # all_lines accumulation; same memory footprint).
        for line in reversed(self._lines):
            clean = line.strip()
            if clean.find("soft stop encountered!  aborting job") != -1:
                self.is_stopped = True
            else:
                if time_patt.search(line):
                    tok = line.strip().split(":")
                    try:
                        # try-catch because VASP 6.2.0 may print
                        # Average memory used (kb):          N/A
                        # which cannot be parsed as float
                        run_stats[tok[0].strip()] = float(tok[1].strip())
                    except ValueError:
                        run_stats[tok[0].strip()] = None
                    continue

                if match := efermi_patt.search(clean):
                    try:
                        # try-catch because VASP sometimes prints
                        # 'E-fermi: ********     XC(G=0):  -6.1327
                        # alpha+bet : -1.8238'
                        efermi = float(match[1])
                        continue
                    except ValueError:
                        efermi = None
                        continue
                if match := nelect_patt.search(clean):
                    nelect = float(match[1])

                if match := mag_patt.search(clean):
                    total_mag = float(match[1])

                if e_fr_energy is None and (match := e_fr_energy_pattern.search(clean)):
                    e_fr_energy = float(match[1])
                if e_wo_entrp is None and (match := e_wo_entrp_pattern.search(clean)):
                    e_wo_entrp = float(match[1])
                if e0 is None and (match := e0_pattern.search(clean)):
                    e0 = float(match[1])

            if nelect is not None and total_mag is not None and efermi is not None and run_stats:
                break

        # For single atom systems, VASP doesn't print a total line, so
        # reverse parsing is very difficult
        read_charge = False
        read_mag_x = False
        read_mag_y = False  # for SOC calculations only
        read_mag_z = False
        for line in self._lines:
            clean = line.strip()
            if read_charge or read_mag_x or read_mag_y or read_mag_z:
                if clean.startswith("# of ion"):
                    header = re.split(r"\s{2,}", clean.strip())
                    header.pop(0)
                elif match := re.match(r"\s*(\d+)\s+(([\d\.\-]+)\s+)+", clean):
                    tokens = [float(i) for i in re.findall(r"[\d\.\-]+", clean)]
                    tokens.pop(0)
                    if read_charge:
                        charge.append(dict(zip(header, tokens, strict=True)))
                    elif read_mag_x:
                        mag_x.append(dict(zip(header, tokens, strict=True)))
                    elif read_mag_y:
                        mag_y.append(dict(zip(header, tokens, strict=True)))
                    elif read_mag_z:
                        mag_z.append(dict(zip(header, tokens, strict=True)))
                elif clean.startswith("tot"):
                    read_charge = False
                    read_mag_x = False
                    read_mag_y = False
                    read_mag_z = False
            if clean == "total charge":
                charge = []
                read_charge = True
                read_mag_x, read_mag_y, read_mag_z = False, False, False
            elif clean == "magnetization (x)":
                mag_x = []
                read_mag_x = True
                read_charge, read_mag_y, read_mag_z = False, False, False
            elif clean == "magnetization (y)":
                mag_y = []
                read_mag_y = True
                read_charge, read_mag_x, read_mag_z = False, False, False
            elif clean == "magnetization (z)":
                mag_z = []
                read_mag_z = True
                read_charge, read_mag_x, read_mag_y = False, False, False
            elif re.search("electrostatic", clean):
                read_charge, read_mag_x, read_mag_y, read_mag_z = (
                    False,
                    False,
                    False,
                    False,
                )

        # Merge x, y and z components of magmoms if present (SOC calculation)
        if mag_y and mag_z:
            # TODO: detect spin axis
            mag = [
                {key: Magmom([mag_x[idx][key], mag_y[idx][key], mag_z[idx][key]]) for key in mag_x[0]}
                for idx in range(len(mag_x))
            ]
        else:
            mag = mag_x  # type:ignore[assignment]

        # Data from beginning of OUTCAR (reuse cached lines).
        run_stats["cores"] = None
        for line in self._lines:
            if "serial" in line:
                # Activate serial parallelization
                run_stats["cores"] = 1
                serial_compilation = True
                break
            if "running" in line:
                if line.split()[1] == "on":
                    run_stats["cores"] = int(line.split()[2])
                else:
                    run_stats["cores"] = int(line.split()[1])
                break

        self.run_stats = run_stats
        self.magnetization = tuple(mag)
        self.charge = tuple(charge)
        self.efermi = efermi
        self.nelect = nelect
        self.total_mag = total_mag
        self.final_energy = e0
        self.final_energy_wo_entrp = e_wo_entrp
        self.final_fr_energy = e_fr_energy
        self.data: dict[str, Any] = {}

        # Read "number of bands" (NBANDS)
        self.read_pattern(
            {"nbands": r"number\s+of\s+bands\s+NBANDS=\s+(\d+)"},
            terminate_on_match=True,
            postprocess=int,
        )
        self.data["nbands"] = self.data["nbands"][0][0]

        # Read "total number of plane waves" (NPLWV)
        self.read_pattern(
            {"nplwv": r"total plane-waves  NPLWV =\s+(\*{6}|\d+)"},
            terminate_on_match=True,
        )
        try:
            self.data["nplwv"] = [[int(self.data["nplwv"][0][0])]]
        except ValueError:
            self.data["nplwv"] = [[None]]

        nplwvs_at_kpoints = [
            n
            for [n] in self.read_table_pattern(
                r"\n{3}-{104}\n{3}",
                r".+plane waves:\s+(\*{6,}|\d+)",
                (
                    r"maximum number of plane-waves"
                    if serial_compilation
                    else r"maximum and minimum number of plane-waves"
                ),
                last_one_only=False,
                first_one_only=True,
            )
        ]
        self.data["nplwvs_at_kpoints"] = [None for n in nplwvs_at_kpoints]
        for n, nplwv in enumerate(nplwvs_at_kpoints):
            try:
                self.data["nplwvs_at_kpoints"][n] = int(nplwv)
            except ValueError:
                pass

        # Read the drift
        self.read_pattern(
            {"drift": r"total drift:\s+([\.\-\d]+)\s+([\.\-\d]+)\s+([\.\-\d]+)"},
            postprocess=float,
        )
        self.drift = self.data.get("drift", [])

        # Check if calculation is spin polarized
        self.read_pattern({"spin": r"ISPIN\s*=\s*2"})
        self.spin = bool(self.data.get("spin", False))

        # Check if calculation is non-collinear
        self.read_pattern({"noncollinear": r"LNONCOLLINEAR\s*=\s*T"})
        self.noncollinear = bool(self.data.get("noncollinear", False))

        # Check if the calculation type is DFPT
        self.read_pattern(
            {"ibrion": r"IBRION =\s+([\-\d]+)"},
            terminate_on_match=True,
            postprocess=int,
        )
        if self.data.get("ibrion", [[0]])[0][0] > 6:
            self.dfpt = True
            self.read_internal_strain_tensor()
        else:
            self.dfpt = False

        # Check if LEPSILON is True and read piezo data if so
        self.read_pattern({"epsilon": r"LEPSILON\s*=\s*T"})
        if self.data.get("epsilon", False):
            self.lepsilon = True
            self.read_lepsilon()
            # Only read ionic contribution if DFPT is turned on
            if self.dfpt:
                self.read_lepsilon_ionic()
        else:
            self.lepsilon = False

        # Check if LCALCPOL is True and read polarization data if so
        self.read_pattern({"calcpol": r"LCALCPOL\s*=\s*T"})
        if self.data.get("calcpol", False):
            self.lcalcpol = True
            self.read_lcalcpol()
            self.read_pseudo_zval()
        else:
            self.lcalcpol = False

        # Read electrostatic potential
        self.electrostatic_potential: list[float] | None = None
        self.ngf: list[int] | None = None
        self.sampling_radii: list[float] | None = None
        self.read_pattern({"electrostatic": r"average \(electrostatic\) potential at core"})
        if self.data.get("electrostatic", False):
            self.read_electrostatic_potential()

        self.read_pattern({"nmr_cs": r"LCHIMAG\s*=\s*(T)"})
        if self.data.get("nmr_cs"):
            self.nmr_cs: bool = True
            self.read_chemical_shielding()
            self.read_cs_g0_contribution()
            self.read_cs_core_contribution()
            self.read_cs_raw_symmetrized_tensors()
        else:
            self.nmr_cs = False

        self.read_pattern({"nmr_efg": r"NMR quadrupolar parameters"})
        if self.data.get("nmr_efg"):
            self.nmr_efg: bool = True
            self.read_nmr_efg()
            self.read_nmr_efg_tensor()
        else:
            self.nmr_efg = False

        self.read_pattern(
            {"has_onsite_density_matrices": r"onsite density matrix"},
            terminate_on_match=True,
        )
        if "has_onsite_density_matrices" in self.data:
            self.has_onsite_density_matrices: bool = True
            self.read_onsite_density_matrices()
        else:
            self.has_onsite_density_matrices = False

        # Store the individual contributions to the final total energy
        final_energy_contribs = {}
        for key in (
            "PSCENC",
            "TEWEN",
            "DENC",
            "EXHF",
            "XCENC",
            "PAW double counting",
            "EENTRO",
            "EBANDS",
            "EATOM",
            "Ediel_sol",
        ):
            if key == "PAW double counting":
                self.read_pattern({key: rf"{key}\s+=\s+([\.\-\d]+)\s+([\.\-\d]+)"})
            else:
                self.read_pattern({key: rf"{key}\s+=\s+([\d\-\.]+)"})
            if not self.data[key]:
                continue
            final_energy_contribs[key] = sum(map(float, self.data[key][-1]))
        self.final_energy_contribs = final_energy_contribs

    @staticmethod
    def _parse_sci_notation(line: str) -> list[float]:
        """
        Parse lines with values in scientific notation and potentially
        without spaces in between the values. This assumes that the scientific
        notation always lists two digits for the exponent, e.g. 3.535E-02.

        Args:
            line: line to parse.

        Returns:
            list[float]: numbers if found, empty list if not.
        """
        if match := re.findall(r"[\.\-\d]+E[\+\-]\d{2}", line):
            return [float(t) for t in match]
        return []

    def as_dict(self) -> dict[str, Any]:
        """MSONable dict."""
        dct = {
            "@module": type(self).__module__,
            "@class": type(self).__name__,
            "efermi": self.efermi,
            "run_stats": self.run_stats,
            "magnetization": self.magnetization,
            "charge": self.charge,
            "total_magnetization": self.total_mag,
            "nelect": self.nelect,
            "is_stopped": self.is_stopped,
            "drift": self.drift,
            "ngf": self.ngf,
            "sampling_radii": self.sampling_radii,
            "electrostatic_potential": self.electrostatic_potential,
        }

        if self.lepsilon:
            dct |= {
                "piezo_tensor": self.piezo_tensor,
                "dielectric_tensor": self.dielectric_tensor,
                "born": self.born,
            }

        if self.dfpt:
            dct["internal_strain_tensor"] = self.internal_strain_tensor

        if self.dfpt and self.lepsilon:
            dct |= {
                "piezo_ionic_tensor": self.piezo_ionic_tensor,
                "dielectric_ionic_tensor": self.dielectric_ionic_tensor,
            }

        if self.lcalcpol:
            dct |= {"p_elec": self.p_elec, "p_ion": self.p_ion}
            if self.spin and not self.noncollinear:
                dct |= {"p_sp1": self.p_sp1, "p_sp2": self.p_sp2}
            dct["zval_dict"] = self.zval_dict

        if self.nmr_cs:
            dct.update(
                nmr_cs={
                    "valence and core": self.data["chemical_shielding"]["valence_and_core"],
                    "valence_only": self.data["chemical_shielding"]["valence_only"],
                    "g0": self.data["cs_g0_contribution"],
                    "core": self.data["cs_core_contribution"],
                    "raw": self.data["unsym_cs_tensor"],
                }
            )

        if self.nmr_efg:
            dct.update(
                nmr_efg={
                    "raw": self.data["unsym_efg_tensor"],
                    "parameters": self.data["efg"],
                }
            )

        if self.has_onsite_density_matrices:
            # Cast Spin to str for consistency with electronic_structure
            # TODO: improve handling of Enum (de)serialization in monty
            onsite_density_matrices = [{str(k): v for k, v in d.items()} for d in self.data["onsite_density_matrices"]]
            dct["onsite_density_matrices"] = onsite_density_matrices

        return dct

    def read_pattern(
        self,
        patterns: dict[str, str],
        reverse: bool = False,
        terminate_on_match: bool = False,
        postprocess: Callable = str,
    ) -> None:
        r"""
        General pattern reading. Use monty's regrep method and take the same
        arguments.

        Args:
            patterns (dict[str, str]): Patterns, e.g.
                {"energy": r"energy\\(sigma->0\\)\\s+=\\s+([\\d\\-.]+)"}.
            reverse (bool): Read files in reverse. Defaults to false. Useful for
                large files like OUTCARs, especially when used with
                terminate_on_match.
            terminate_on_match (bool): Whether to terminate when there is at
                least one match for each key in patterns.
            postprocess (Callable): A post processing function to convert all
                matches. Defaults to str, i.e., no change.

        Renders accessible from self.data:
            Any attribute in patterns. For example,
            {"energy": r"energy\\(sigma->0\\)\\s+=\\s+([\\d\\-.]+)"} will set the
            value of self.data["energy"] = [[-1234], [-3453], ...], to the
            results from regex and postprocess. Note that the values
            are list[list], because you can grep multiple items on one line.
        """
        # Use the cached OUTCAR text (populated in __init__) to avoid
        # re-opening the file on every call; fall back to monty.regrep
        # only if the cache hasn't been set (e.g. when subclasses skip
        # the parent __init__).
        if getattr(self, "_lines", None) is not None:
            compiled = {k: re.compile(p) for k, p in patterns.items()}
            matches: dict[str, list] = {k: [] for k in patterns}
            line_iter = reversed(self._lines) if reverse else iter(self._lines)
            for line in line_iter:
                for k, p in compiled.items():
                    if m := p.search(line):
                        matches[k].append([postprocess(g) for g in m.groups()])
                if terminate_on_match and all(matches[k] for k in patterns):
                    break
            for key in patterns:
                self.data[key] = matches[key]
            return

        raw_matches = regrep(
            filename=self.filename,
            patterns=patterns,
            reverse=reverse,
            terminate_on_match=terminate_on_match,
            postprocess=postprocess,
        )
        for key in patterns:
            self.data[key] = [i[0] for i in raw_matches.get(key, [])]

    def read_table_pattern(
        self,
        header_pattern: str,
        row_pattern: str,
        footer_pattern: str,
        postprocess: Callable = str,
        attribute_name: str | None = None,
        last_one_only: bool = True,
        first_one_only: bool = False,
    ) -> list:
        r"""Parse table-like data. A table composes of three parts: header,
        main body, footer. All the data matches "row pattern" in the main body
        will be returned.

        Args:
            header_pattern (str): The regular expression pattern matches the
                table header. This pattern should match all the text
                immediately before the main body of the table. For multiple
                sections table match the text until the section of
                interest. MULTILINE and DOTALL options are enforced, as a
                result, the "." meta-character will also match "\n" in this
                section.
            row_pattern (str): The regular expression matches a single line in
                the table. Capture interested field using regular expression
                groups.
            footer_pattern (str): The regular expression matches the end of the
                table. E.g. a long dash line.
            postprocess (Callable): A post processing function to convert all
                matches. Defaults to str, i.e., no change.
            attribute_name (str): Name of this table. If present the parsed data
                will be attached to "data. e.g. self.data["efg"] = [...]
            last_one_only (bool): All the tables will be parsed, if this option
                is set to True, only the last table will be returned. The
                enclosing list will be removed. i.e. Only a single table will
                be returned. Default to be True. Incompatible with first_one_only.
            first_one_only (bool): Only the first occurrence of the table will be
                parsed and the parsing procedure will stop. The enclosing list
                will be removed. i.e. Only a single table will be returned.
                Incompatible with last_one_only.

        Returns:
            List of tables or a single table if last_one_only/first_one_only is True.
                1) A table is a list of rows. 2) A row is either a list of attribute
                values in case the capturing group is defined without name in row_pattern,
                or a dict in case that named capturing groups are defined by row_pattern.
        """
        if last_one_only and first_one_only:
            raise ValueError("last_one_only and first_one_only options are incompatible")

        TableData: TypeAlias = list[list[Any] | dict[str, Any]]

        # Reuse the cached OUTCAR text if available.
        if (cached := getattr(self, "_text", None)) is not None:
            text: str = cached
        else:
            with zopen(self.filename, mode="rt", encoding="utf-8") as file:
                text = file.read()  # type:ignore[assignment]

        # Check for truncated table which could cause parser to hang
        header_match = re.search(header_pattern, text, re.MULTILINE | re.DOTALL)
        if header_match is None or not re.search(footer_pattern, text[header_match.end() :], re.MULTILINE | re.DOTALL):
            if header_match is not None:
                warnings.warn(
                    f"Table in {getattr(self, 'filename', 'OUTCAR')} is truncated; "
                    f"expected footer matching {footer_pattern!r}.",
                    stacklevel=2,
                )
            retained_data: list[TableData] | TableData = []
            if attribute_name is not None:
                self.data[attribute_name] = retained_data
            return retained_data

        table_pattern_text = header_pattern + r"\s*^(?P<table_body>(?:\s+" + row_pattern + r")+)\s+" + footer_pattern
        table_pattern = re.compile(table_pattern_text, re.MULTILINE | re.DOTALL)
        rp = re.compile(row_pattern)

        tables: list[TableData] = []
        for mt in table_pattern.finditer(text):
            table_body_text = mt.group("table_body")
            table_contents: TableData = []
            for line in table_body_text.split("\n"):
                ml = rp.search(line)
                # Skip empty lines
                if not ml:
                    continue
                d = ml.groupdict()
                if len(d) > 0:
                    processed_line: list[Any] | dict[str, Any] = {k: postprocess(v) for k, v in d.items()}
                else:
                    processed_line = [postprocess(v) for v in ml.groups()]
                table_contents.append(processed_line)
            tables.append(table_contents)
            if first_one_only:
                break
        retained_data = tables[-1] if last_one_only or first_one_only else tables
        if attribute_name is not None:
            self.data[attribute_name] = retained_data
        return retained_data

    def read_electrostatic_potential(self) -> None:
        """Parse the eletrostatic potential for the last ionic step.

        Renders accessible as attributes:
            ngf (list[int, int, int]): Number of grid points along x, y, z dimensions.
            sampling_radii (list[float, float, float]): Test charge radii.
            electrostatic_potential (list[float]): The eletrostatic potential.
        """
        pattern = {"ngf": r"\s+dimension x,y,z NGXF=\s+([\.\-\d]+)\sNGYF=\s+([\.\-\d]+)\sNGZF=\s+([\.\-\d]+)"}
        self.read_pattern(pattern, postprocess=int)
        self.ngf = self.data.get("ngf", [[]])[0]

        pattern = {"radii": r"the test charge radii are((?:\s+[\.\-\d]+)+)"}
        self.read_pattern(pattern, reverse=True, terminate_on_match=True, postprocess=str)
        self.sampling_radii = [*map(float, self.data["radii"][0][0].split())]

        header_pattern = r"\(the norm of the test charge is\s+[\.\-\d]+\)"
        table_pattern = r"((?:\s+\d+\s*[\.\-\d]+)+)"
        footer_pattern = r"\s+E-fermi :"

        pot_patterns: list = self.read_table_pattern(header_pattern, table_pattern, footer_pattern)
        pot_patterns_str: str = "".join(itertools.chain.from_iterable(pot_patterns))
        pots: list = re.findall(r"\s+\d+\s*([\.\-\d]+)+", pot_patterns_str)

        self.electrostatic_potential = [*map(float, pots)]

    def read_freq_dielectric(self) -> None:
        """
        Parse the frequency dependent dielectric function (obtained with LOPTICS).

        Renders accessible as attributes:
            plasma_frequencies (dict[Literal["intraband", "interband"], NDArray[float64]]):
                plasma frequency in eV.
            dielectric_energies (NDArray[float64]): Dielectric energies.
            dielectric_tensor_function (NDArray[complex128]): Dielectric tensor function.
        """
        plasma_pattern = r"plasma frequency squared.*"
        dielectric_pattern = (
            r"frequency dependent\s+IMAGINARY "
            r"DIELECTRIC FUNCTION \(independent particle, "
            r"no local field effects\)(\sdensity-density)*$"
        )
        row_pattern = r"\s+".join([r"([\.\-\d]+)"] * 3)
        plasma_frequencies = defaultdict(list)
        read_plasma: str | bool = False
        read_dielectric = False
        energies = []
        data: dict[str, Any] = {"REAL": [], "IMAGINARY": []}
        count = 0
        component = "IMAGINARY"
        with zopen(self.filename, mode="rt", encoding="utf-8") as file:
            line: str
            for line in file:  # type:ignore[assignment]
                line = line.strip()
                if re.match(plasma_pattern, line):
                    read_plasma = "intraband" if "intraband" in line else "interband"
                elif re.match(dielectric_pattern, line):
                    read_plasma = False
                    read_dielectric = True
                    row_pattern = r"\s+".join([r"([\.\-\d]+)"] * 7)

                if read_plasma and re.match(row_pattern, line):
                    plasma_frequencies[read_plasma].append([float(t) for t in line.strip().split()])
                elif read_plasma and type(self)._parse_sci_notation(line):
                    plasma_frequencies[read_plasma].append(type(self)._parse_sci_notation(line))
                elif read_dielectric:
                    tokens = None
                    if re.match(row_pattern, line.strip()):
                        tokens = line.strip().split()
                    elif type(self)._parse_sci_notation(line.strip()):
                        tokens = type(self)._parse_sci_notation(line.strip())  # type:ignore[assignment]
                    elif re.match(r"\s*-+\s*", line):
                        count += 1

                    if tokens:
                        if component == "IMAGINARY":
                            energies.append(float(tokens[0]))
                        xx, yy, zz, xy, yz, xz = (float(t) for t in tokens[1:])
                        matrix = [[xx, xy, xz], [xy, yy, yz], [xz, yz, zz]]
                        data[component].append(matrix)

                    if count == 2:
                        component = "REAL"
                    elif count == 3:
                        break

        self.plasma_frequencies: dict[Any, NDArray[np.float64]] = {
            k: np.array(v[:3]) for k, v in plasma_frequencies.items()
        }
        self.dielectric_energies: NDArray[np.float64] = np.array(energies)
        self.dielectric_tensor_function: NDArray[np.complex128] = np.array(data["REAL"]) + 1j * np.array(
            data["IMAGINARY"]
        )

    def read_chemical_shielding(self) -> None:
        """Parse the NMR chemical shieldings data. Only the second part "absolute, valence and core"
        will be parsed. And only the three right most field (ISO_SHIELDING, SPAN, SKEW) will be retrieved.

        Renders accessible from self.data:
            chemical_shielding (dict[Literal["valence_only", "valence_and_core"], list[list[float]]]):
                Chemical shieldings in the order of atoms from the OUTCAR. Maryland notation is adopted.
        """
        header_pattern = (
            r"\s+CSA tensor \(J\. Mason, Solid State Nucl\. Magn\. Reson\. 2, "  # codespell:ignore reson
            r"285 \(1993\)\)\s+"
            r"\s+-{50,}\s+"
            r"\s+EXCLUDING G=0 CONTRIBUTION\s+INCLUDING G=0 CONTRIBUTION\s+"
            r"\s+-{20,}\s+-{20,}\s+"
            r"\s+ATOM\s+ISO_SHIFT\s+SPAN\s+SKEW\s+ISO_SHIFT\s+SPAN\s+SKEW\s+"
            r"-{50,}\s*$"
        )
        first_part_pattern = r"\s+\(absolute, valence only\)\s+$"
        swallon_valence_body_pattern = r".+?\(absolute, valence and core\)\s+$"
        row_pattern = r"\d+(?:\s+[-]?\d+\.\d+){3}\s+" + r"\s+".join([r"([-]?\d+\.\d+)"] * 3)
        footer_pattern = r"-{50,}\s*$"
        h1 = header_pattern + first_part_pattern
        cs_valence_only: list[list[float]] = self.read_table_pattern(
            h1, row_pattern, footer_pattern, postprocess=float, last_one_only=True
        )
        h2 = header_pattern + swallon_valence_body_pattern
        cs_valence_and_core: list[list[float]] = self.read_table_pattern(
            h2, row_pattern, footer_pattern, postprocess=float, last_one_only=True
        )
        chemical_shielding: dict[Literal["valence_only", "valence_and_core"], list[list[float]]] = {
            "valence_only": cs_valence_only,
            "valence_and_core": cs_valence_and_core,
        }
        self.data["chemical_shielding"] = chemical_shielding

    def read_cs_g0_contribution(self) -> None:
        """Parse the G0 contribution of NMR chemical shielding.

        Renders accessible from self.data:
            cs_g0_contribution (list[list[float]]): G0 contribution matrix.
        """
        header_pattern = (
            r"^\s+G\=0 CONTRIBUTION TO CHEMICAL SHIFT \(field along BDIR\)\s+$\n"
            r"^\s+-{50,}$\n"
            r"^\s+BDIR\s+X\s+Y\s+Z\s*$\n"
            r"^\s+-{50,}\s*$\n"
        )
        row_pattern = r"(?:\d+)\s+" + r"\s+".join([r"([-]?\d+\.\d+)"] * 3)
        footer_pattern = r"\s+-{50,}\s*$"
        self.read_table_pattern(
            header_pattern,
            row_pattern,
            footer_pattern,
            postprocess=float,
            last_one_only=True,
            attribute_name="cs_g0_contribution",
        )

    def read_cs_core_contribution(self) -> None:
        """Parse the core contribution of NMR chemical shielding.

        Renders accessible from self.data:
            cs_core_contribution (dict[str, float]): Core contribution from each element.
        """
        header_pattern = r"^\s+Core NMR properties\s*$\n\n^\s+typ\s+El\s+Core shift \(ppm\)\s*$\n^\s+-{20,}$\n"
        row_pattern = r"\d+\s+(?P<element>[A-Z][a-z]?\w?)\s+(?P<shift>[-]?\d+\.\d+)"
        footer_pattern = r"\s+-{20,}\s*$"
        self.read_table_pattern(
            header_pattern,
            row_pattern,
            footer_pattern,
            postprocess=str,
            last_one_only=True,
            attribute_name="cs_core_contribution",
        )
        core_contrib: dict[str, float] = {d["element"]: float(d["shift"]) for d in self.data["cs_core_contribution"]}
        self.data["cs_core_contribution"] = core_contrib

    def read_cs_raw_symmetrized_tensors(self) -> None:
        """Parse the matrix form of NMR tensor before corrected to table.

        Renders accessible from self.data:
            unsym_cs_tensor (list[list[list[float]]]): Unsymmetrized tensors in the order of atoms.
        """
        header_pattern = r"\s+-{50,}\s+\s+Absolute Chemical Shift tensors\s+\s+-{50,}$"
        first_part_pattern = r"\s+UNSYMMETRIZED TENSORS\s+$"
        row_pattern = r"\s+".join([r"([-]?\d+\.\d+)"] * 3)
        unsym_footer_pattern = r"^\s+SYMMETRIZED TENSORS\s+$"

        with zopen(self.filename, mode="rt", encoding="utf-8") as file:
            text: str = file.read()  # type:ignore[assignment]
        unsym_table_pattern_text = header_pattern + first_part_pattern + r"(?P<table_body>.+)" + unsym_footer_pattern
        table_pattern = re.compile(unsym_table_pattern_text, re.MULTILINE | re.DOTALL)
        row_pat = re.compile(row_pattern)

        if match := table_pattern.search(text):
            table_text = match["table_body"]
            micro_header_pattern = r"ion\s+\d+"
            micro_table_pattern_text = micro_header_pattern + r"\s*^(?P<table_body>(?:\s*" + row_pattern + r")+)\s+"
            micro_table_pattern = re.compile(micro_table_pattern_text, re.MULTILINE | re.DOTALL)
            unsym_tensors: list[list[list[float]]] = []
            for mt in micro_table_pattern.finditer(table_text):
                table_body_text = mt.group("table_body")
                tensor_matrix: list[list[float]] = []
                for line in table_body_text.rstrip().split("\n"):
                    ml = row_pat.search(line)
                    if ml is None:
                        raise RuntimeError(f"failure to find pattern, {ml=}")
                    processed_line: list[float] = [float(v) for v in ml.groups()]
                    tensor_matrix.append(processed_line)
                unsym_tensors.append(tensor_matrix)
            self.data["unsym_cs_tensor"] = unsym_tensors
        else:
            raise ValueError("NMR UNSYMMETRIZED TENSORS is not found")

    def read_nmr_efg_tensor(self) -> list[NDArray[np.float64]]:
        """Parses the NMR Electric Field Gradient Raw Tensors.

        Returns:
            list[NDArray[float64]]: Electric Field Gradient Tensors in the order of atoms.

        Renders accessible from self.data:
            unsym_efg_tensor (list[NDArray[float64]]): Electric Field Gradient
                Tensors in the order of atoms.
        """
        header_pattern = (
            r"Electric field gradients \(V/A\^2\)\n-*\n ion\s+V_xx\s+V_yy\s+V_zz\s+V_xy\s+V_xz\s+V_yz\n-*\n"
        )

        row_pattern = r"\d+\s+([-\d\.]+)\s+([-\d\.]+)\s+([-\d\.]+)\s+([-\d\.]+)\s+([-\d\.]+)\s+([-\d\.]+)"
        footer_pattern = r"-*\n"

        data = self.read_table_pattern(header_pattern, row_pattern, footer_pattern, postprocess=float)
        tensors: list[NDArray[np.float64]] = [make_symmetric_matrix_from_upper_tri(d) for d in data]
        self.data["unsym_efg_tensor"] = tensors
        return tensors

    def read_nmr_efg(self) -> None:
        """Parse the NMR Electric Field Gradient interpreted values.

        Renders accessible from self.data:
            efg (list[dict[Literal["cq", "eta", "nuclear_quadrupole_moment"], float]]):
                Electric Field Gradient tensors in the order of atoms.
                Each dict key/value pair corresponds to a component of the tensors.
        """
        header_pattern = (
            r"^\s+NMR quadrupolar parameters\s+$\n"
            r"^\s+Cq : quadrupolar parameter\s+Cq=e[*]Q[*]V_zz/h$\n"
            r"^\s+eta: asymmetry parameters\s+\(V_yy - V_xx\)/ V_zz$\n"
            r"^\s+Q  : nuclear electric quadrupole moment in mb \(millibarn\)$\n"
            r"^-{50,}$\n"
            r"^\s+ion\s+Cq\(MHz\)\s+eta\s+Q \(mb\)\s+$\n"
            r"^-{50,}\s*$\n"
        )
        row_pattern = (
            r"\d+\s+(?P<cq>[-]?\d+\.\d+)\s+(?P<eta>[-]?\d+\.\d+)\s+(?P<nuclear_quadrupole_moment>[-]?\d+\.\d+)"
        )
        footer_pattern = r"-{50,}\s*$"
        self.read_table_pattern(
            header_pattern,
            row_pattern,
            footer_pattern,
            postprocess=float,
            last_one_only=True,
            attribute_name="efg",
        )

    def read_elastic_tensor(self) -> None:
        """
        Parse the elastic tensor data.

        Renders accessible from self.data:
            elastic_tensor[list[list[float]]]: 6x6 array corresponding to the elastic tensor.
        """
        header_pattern = r"TOTAL ELASTIC MODULI \(kBar\)\s+Direction\s+([X-Z][X-Z]\s+)+\-+"
        row_pattern = r"[X-Z][X-Z]\s+" + r"\s+".join([r"(\-*[\.\d]+)"] * 6)
        footer_pattern = r"\-+"
        et_table: list[list[float]] = self.read_table_pattern(
            header_pattern, row_pattern, footer_pattern, postprocess=float
        )
        self.data["elastic_tensor"] = et_table

    def read_piezo_tensor(self) -> None:
        """Parse the piezo tensor data.

        Renders accessible from self.data:
            piezo_tensor (list[list[float]]): The piezo tensor.
        """
        header_pattern = r"PIEZOELECTRIC TENSOR  for field in x, y, z\s+\(C/m\^2\)\s+([X-Z][X-Z]\s+)+\-+"
        row_pattern = r"[x-z]\s+" + r"\s+".join([r"(\-*[\.\d]+)"] * 6)
        footer_pattern = r"BORN EFFECTIVE"
        piezo_tensor: list[list[float]] = self.read_table_pattern(
            header_pattern, row_pattern, footer_pattern, postprocess=float
        )
        self.data["piezo_tensor"] = piezo_tensor

    def read_onsite_density_matrices(self) -> None:
        """Parse the onsite density matrices.

        Renders accessible from self.data:
            onsite_density_matrices (list[dict[Spin, list[list[float]]]]):
                Onsite density matrices with index corresponding to atom index in Structure.
        """
        # Matrix size will vary depending on if d or f orbitals are present.
        # Therefore regex assumes f, but filter out None values if d.
        header_pattern = r"spin component  1\n"
        row_pattern = r"[^\S\r\n]*(?:(-?[\d.]+))" + r"(?:[^\S\r\n]*(-?[\d.]+)[^\S\r\n]*)?" * 6 + r".*?"
        footer_pattern = r"\nspin component  2"
        spin1_component = self.read_table_pattern(
            header_pattern,
            row_pattern,
            footer_pattern,
            postprocess=lambda x: float(x) if x else None,
            last_one_only=False,
        )

        # Filter out None
        spin1_component = [[[e for e in row if e is not None] for row in matrix] for matrix in spin1_component]

        # And repeat for Spin.down
        header_pattern = r"spin component  2\n"
        row_pattern = r"[^\S\r\n]*(?:([\d.-]+))" + r"(?:[^\S\r\n]*(-?[\d.]+)[^\S\r\n]*)?" * 6 + r".*?"
        footer_pattern = r"\n occupancies and eigenvectors"
        spin2_component = self.read_table_pattern(
            header_pattern,
            row_pattern,
            footer_pattern,
            postprocess=lambda x: float(x) if x else None,
            last_one_only=False,
        )

        spin2_component = [[[e for e in row if e is not None] for row in matrix] for matrix in spin2_component]

        onsite_density_matrices: list[dict[Spin, list[list[float]]]] = [
            {Spin.up: spin1_component[idx], Spin.down: spin2_component[idx]} for idx in range(len(spin1_component))
        ]
        self.data["onsite_density_matrices"] = onsite_density_matrices

    def read_corrections(
        self,
        reverse: bool = True,
        terminate_on_match: bool = True,
    ) -> None:
        """Read the dipol qudropol correction.

        Args:
            reverse (bool): Whether to start from end of OUTCAR. Defaults to True.
            terminate_on_match (bool): Whether to terminate once match is found. Defaults to True.

        Renders accessible from self.data:
            dipol_quadrupol_correction (float): Dipol qudropol correction.
        """
        patterns = {"dipol_quadrupol_correction": r"dipol\+quadrupol energy correction\s+([\d\-\.]+)"}
        self.read_pattern(
            patterns,
            reverse=reverse,
            terminate_on_match=terminate_on_match,
            postprocess=float,
        )
        dipol_quadrupol_correction: float = self.data["dipol_quadrupol_correction"][0][0]
        self.data["dipol_quadrupol_correction"] = dipol_quadrupol_correction

    def read_neb(
        self,
        reverse: bool = True,
        terminate_on_match: bool = True,
    ) -> None:
        """
        Read NEB data. This only works with OUTCARs from both normal
        VASP NEB calculations or from the CI NEB method implemented by
        Henkelman et al.

        Args:
            reverse (bool): Read files in reverse. Defaults to false. Useful for
                large files, especially when used with terminate_on_match.
                Defaults to True here since we usually want only the final value.
            terminate_on_match (bool): Whether to terminate when there is at
                least one match in each key in pattern. Defaults to True here
                since we usually want only the final value.

        Renders accessible from self.data:
            energy (float): Final energy.
            tangent_force (float): Final tangent force.
        """
        patterns = {
            "energy": r"energy\(sigma->0\)\s+=\s+([\d\-\.]+)",
            "tangent_force": r"(NEB: projections on to tangent \(spring, REAL\)\s+\S+|tangential force \(eV/A\))\s+"
            r"([\d\-\.]+)",
        }
        self.read_pattern(
            patterns,
            reverse=reverse,
            terminate_on_match=terminate_on_match,
            postprocess=str,
        )
        self.data["energy"] = float(self.data["energy"][0][0])
        if self.data.get("tangent_force"):
            self.data["tangent_force"] = float(self.data["tangent_force"][0][1])

    def read_igpar(self) -> None:
        """Read IGPAR.

        See VASP sections "LBERRY, IGPAR, NPPSTR, DIPOL" for info on
        what these are.

        Renders accessible as attributes:
            er_ev (dict[Spin, NDArray[float]]): e<r>_ev.
            er_bp (dict[Spin, NDArray[float]]): e<r>_bp.
            er_ev_tot (NDArray[float]): spin up + spin down summed.
            er_bp_tot (NDArray[float]): spin up + spin down summed.
            p_elec (int): spin up + spin down summed.
            p_ion (int): spin up + spin down summed.
        """
        # Variables to be filled
        self.er_ev: dict[Spin, NDArray[np.float64]] = {}  # array(3*float)
        self.er_bp: dict[Spin, NDArray[np.float64]] = {}  # array(3*float)
        self.er_ev_tot: NDArray | None = None  # array(3*float)
        self.er_bp_tot: NDArray | None = None  # array(3*float)
        self.p_elec: NDArray | None = None
        self.p_ion: NDArray | None = None
        try:
            search: list[list] = []

            # Non-spin cases
            def er_ev(results: Outcar, match: re.Match[str]) -> None:
                results.er_ev[Spin.up] = np.array(map(float, match.groups()[1:4])) / 2
                results.er_ev[Spin.down] = results.er_ev[Spin.up]
                results.context = 2

            er_ev_pattern = r"^ *e<r>_ev=\( *([-0-9.Ee+]*) *([-0-9.Ee+]*) *([-0-9.Ee+]*) *\)"
            search.append([er_ev_pattern, None, er_ev])

            def er_bp(results: Outcar, match: re.Match[str]) -> None:
                results.er_bp[Spin.up] = np.array([float(match[i]) for i in range(1, 4)]) / 2
                results.er_bp[Spin.down] = results.er_bp[Spin.up]

            er_bp_pattern = r"^ *e<r>_bp=\( *([-0-9.Ee+]*) *([-0-9.Ee+]*) *([-0-9.Ee+]*) *\)"
            search.append([er_bp_pattern, lambda results, _line: results.context == 2, er_bp])

            # Spin cases
            def er_ev_up(results: Outcar, match: re.Match[str]) -> None:
                results.er_ev[Spin.up] = np.array([float(match[i]) for i in range(1, 4)])
                results.context = Spin.up

            spin1_ev_pattern = r"^.*Spin component 1 *e<r>_ev=\( *([-0-9.Ee+]*) *([-0-9.Ee+]*) *([-0-9.Ee+]*) *\)"
            search.append([spin1_ev_pattern, None, er_ev_up])

            def er_bp_up(results: Outcar, match: re.Match[str]) -> None:
                results.er_bp[Spin.up] = np.array([float(match[1]), float(match[2]), float(match[3])])

            spin_bp_pattern = r"^ *e<r>_bp=\( *([-0-9.Ee+]*) *([-0-9.Ee+]*) *([-0-9.Ee+]*) *\)"
            search.append(
                [
                    spin_bp_pattern,
                    lambda results, _line: results.context == Spin.up,
                    er_bp_up,
                ]
            )

            def er_ev_dn(results: Outcar, match: re.Match[str]) -> None:
                results.er_ev[Spin.down] = np.array([float(match[1]), float(match[2]), float(match[3])])
                results.context = Spin.down

            spin2_pattern = r"^.*Spin component 2 *e<r>_ev=\( *([-0-9.Ee+]*) *([-0-9.Ee+]*) *([-0-9.Ee+]*) *\)"
            search.append([spin2_pattern, None, er_ev_dn])

            def er_bp_dn(results: Outcar, match: re.Match[str]) -> None:
                results.er_bp[Spin.down] = np.array([float(match[i]) for i in range(1, 4)])

            e_r_bp_pattern = r"^ *e<r>_bp=\( *([-0-9.Ee+]*) *([-0-9.Ee+]*) *([-0-9.Ee+]*) *\)"
            search.append(
                [
                    e_r_bp_pattern,
                    lambda results, _line: results.context == Spin.down,
                    er_bp_dn,
                ]
            )

            # Always present spin/non-spin
            def p_elc(results: Outcar, match: re.Match[str]) -> None:
                results.p_elc = np.array([float(match[i]) for i in range(1, 4)])

            elec_dipole_moment_pattern = (
                r"^.*Total electronic dipole moment: *p\[elc\]=\( *([-0-9.Ee+]*) *([-0-9.Ee+]*) *([-0-9.Ee+]*) *\)"
            )
            search.append([elec_dipole_moment_pattern, None, p_elc])

            def p_ion(results: Outcar, match: re.Match[str]) -> None:
                results.p_ion = np.array([float(match[i]) for i in range(1, 4)])

            ionic_dipole_moment_pattern = (
                r"^.*ionic dipole moment: *p\[ion\]=\( *([-0-9.Ee+]*) *([-0-9.Ee+]*) *([-0-9.Ee+]*) *\)"
            )
            search.append([ionic_dipole_moment_pattern, None, p_ion])

            self.context: int | Spin | None = None
            self.er_ev = {Spin.up: None, Spin.down: None}  # type:ignore[dict-item]
            self.er_bp = {Spin.up: None, Spin.down: None}  # type:ignore[dict-item]

            micro_pyawk(self.filename, search, self)  # type:ignore[arg-type]

            if self.er_ev[Spin.up] is not None and self.er_ev[Spin.down] is not None:
                self.er_ev_tot = self.er_ev[Spin.up] + self.er_ev[Spin.down]  # type: ignore[operator,assignment]

            if self.er_bp[Spin.up] is not None and self.er_bp[Spin.down] is not None:
                self.er_bp_tot = self.er_bp[Spin.up] + self.er_bp[Spin.down]  # type: ignore[operator,assignment]

        except Exception as exc:
            raise RuntimeError("IGPAR OUTCAR could not be parsed.") from exc

    def read_internal_strain_tensor(self) -> None:
        """Read the internal strain tensor.

        Renders accessible as attributes:
            internal_strain_tensor (list[NDArray[float64]]): Voigt notation tensors for each site.
        """
        search: list[list] = []

        def internal_strain_start(results, match: str) -> None:
            results.internal_strain_ion = int(match[1]) - 1
            results.internal_strain_tensor.append(np.zeros((3, 6)))

        search.append(
            [
                r"INTERNAL STRAIN TENSOR FOR ION\s+(\d+)\s+for displacements in x,y,z  \(eV/Angst\):",
                None,
                internal_strain_start,
            ]
        )

        def internal_strain_data(results, match: str) -> None:
            if match[1].lower() == "x":
                index = 0
            elif match[1].lower() == "y":
                index = 1
            elif match[1].lower() == "z":
                index = 2
            else:
                raise IndexError(f"Couldn't parse row index from symbol for internal strain tensor: {match[1]}")
            results.internal_strain_tensor[results.internal_strain_ion][index] = np.array(
                [float(match[i]) for i in range(2, 8)]
            )
            if index == 2:
                results.internal_strain_ion = None

        search.append(
            [
                r"^\s+([x,y,z])\s+" + r"([-]?\d+\.\d+)\s+" * 6,
                lambda results, _line: results.internal_strain_ion is not None,
                internal_strain_data,
            ]
        )

        self.internal_strain_ion = None
        self.internal_strain_tensor: list[NDArray[np.float64]] = []
        micro_pyawk(self.filename, search, self)  # type:ignore[arg-type]

    def read_lepsilon(self) -> None:
        """Read a LEPSILON run.

        Renders accessible as attributes:
            dielectric_tensor (list[list[float]]): Dielectric tensor.
            piezo_tensor (list[list[float]]): The piezo tensor.
        """
        try:
            search: list[list] = []

            def dielectric_section_start(results: Outcar, match: re.Match[str]) -> None:
                results.dielectric_index = -1

            search.append(
                [
                    r"MACROSCOPIC STATIC DIELECTRIC TENSOR \(",
                    None,
                    dielectric_section_start,
                ]
            )

            def dielectric_section_start2(results: Outcar, match: re.Match[str]) -> None:
                results.dielectric_index = 0

            search.append(
                [
                    r"-------------------------------------",
                    lambda results, _line: results.dielectric_index == -1,
                    dielectric_section_start2,
                ]
            )

            def dielectric_data(results: Outcar, match: re.Match[str]) -> None:
                results.dielectric_tensor[results.dielectric_index, :] = np.array(
                    [float(match[i]) for i in range(1, 4)]
                )
                results.dielectric_index += 1

            search.append(
                [
                    r"^ *([-0-9.Ee+]+) +([-0-9.Ee+]+) +([-0-9.Ee+]+) *$",
                    lambda results, _line: (
                        results.dielectric_index >= 0 if results.dielectric_index is not None else None
                    ),
                    dielectric_data,
                ]
            )

            def dielectric_section_stop(results: Outcar, match: re.Match[str]) -> None:
                results.dielectric_index = None

            search.append(
                [
                    r"-------------------------------------",
                    lambda results, _line: (
                        results.dielectric_index >= 1 if results.dielectric_index is not None else None
                    ),
                    dielectric_section_stop,
                ]
            )

            self.dielectric_index: int | None = None
            self.dielectric_tensor = np.zeros((3, 3))

            def piezo_section_start(results: Outcar, _match: re.Match[str]) -> None:
                results.piezo_index = 0

            search.append(
                [
                    r"PIEZOELECTRIC TENSOR  for field in x, y, z        \(C/m\^2\)",
                    None,
                    piezo_section_start,
                ]
            )

            def piezo_data(results: Outcar, match: re.Match[str]) -> None:
                results.piezo_tensor[results.piezo_index, :] = np.array([float(match[i]) for i in range(1, 7)])
                results.piezo_index += 1

            search.append(
                [
                    (
                        r"^ *[xyz] +([-0-9.Ee+]+) +([-0-9.Ee+]+)"
                        r" +([-0-9.Ee+]+) *([-0-9.Ee+]+) +([-0-9.Ee+]+) +([-0-9.Ee+]+)*$"
                    ),
                    lambda results, _line: results.piezo_index >= 0 if results.piezo_index is not None else None,
                    piezo_data,
                ]
            )

            def piezo_section_stop(results: Outcar, _match: re.Match[str]) -> None:
                results.piezo_index = None

            search.append(
                [
                    r"-------------------------------------",
                    lambda results, _line: results.piezo_index >= 1 if results.piezo_index is not None else None,
                    piezo_section_stop,
                ]
            )

            self.piezo_index: int | None = None
            self.piezo_tensor = np.zeros((3, 6))

            def born_section_start(results: Outcar, _match: re.Match[str]) -> None:
                results.born_ion = -1

            search.append([r"BORN EFFECTIVE CHARGES ", None, born_section_start])

            def born_ion(results: Outcar, match: re.Match[str]) -> None:
                results.born_ion = int(match[1]) - 1
                results.born.append(np.zeros((3, 3)))

            search.append(
                [
                    r"ion +([0-9]+)",
                    lambda results, _line: results.born_ion is not None,
                    born_ion,
                ]
            )

            def born_data(results: Outcar, match: re.Match[str]) -> None:
                results.born[results.born_ion][int(match[1]) - 1, :] = np.array([float(match[i]) for i in range(2, 5)])

            search.append(
                [
                    r"^ *([1-3]+) +([-0-9.Ee+]+) +([-0-9.Ee+]+) +([-0-9.Ee+]+)$",
                    lambda results, _line: results.born_ion >= 0 if results.born_ion is not None else results.born_ion,
                    born_data,
                ]
            )

            def born_section_stop(results: Outcar, _match: re.Match[str]) -> None:
                results.born_ion = None

            search.append(
                [
                    r"-------------------------------------",
                    lambda results, _line: results.born_ion >= 1 if results.born_ion is not None else results.born_ion,
                    born_section_stop,
                ]
            )

            self.born_ion: int | None = None
            self.born: list | NDArray = []

            micro_pyawk(self.filename, search, self)  # type:ignore[arg-type]

            self.born = np.array(self.born)

            self.dielectric_tensor = self.dielectric_tensor.tolist()  # type:ignore[assignment]
            self.piezo_tensor = self.piezo_tensor.tolist()  # type:ignore[assignment]

        except Exception as exc:
            raise RuntimeError("LEPSILON OUTCAR could not be parsed.") from exc

    def read_lepsilon_ionic(self) -> None:
        """Read the ionic component of a LEPSILON run.

        Renders accessible as attributes:
            dielectric_ionic_tensor (list[list[float]]): Ionic dielectric tensor.
            piezo_ionic_tensor (list[list[float]]): Ionic piezoelectric tensor.
        """
        try:
            search: list[list] = []

            def dielectric_section_start(results: Outcar, _match: re.Match[str]) -> None:
                results.dielectric_ionic_index = -1

            search.append(
                [
                    r"MACROSCOPIC STATIC DIELECTRIC TENSOR IONIC",
                    None,
                    dielectric_section_start,
                ]
            )

            def dielectric_section_start2(results: Outcar, _match: re.Match[str]) -> None:
                results.dielectric_ionic_index = 0

            search.append(
                [
                    r"-------------------------------------",
                    lambda results, _line: (
                        results.dielectric_ionic_index == -1
                        if results.dielectric_ionic_index is not None
                        else results.dielectric_ionic_index
                    ),
                    dielectric_section_start2,
                ]
            )

            def dielectric_data(results: Outcar, match: re.Match[str]) -> None:
                results.dielectric_ionic_tensor[results.dielectric_ionic_index, :] = np.array(
                    [float(match[i]) for i in range(1, 4)]
                )
                results.dielectric_ionic_index += 1

            search.append(
                [
                    r"^ *([-0-9.Ee+]+) +([-0-9.Ee+]+) +([-0-9.Ee+]+) *$",
                    lambda results, _line: (
                        results.dielectric_ionic_index >= 0
                        if results.dielectric_ionic_index is not None
                        else results.dielectric_ionic_index
                    ),
                    dielectric_data,
                ]
            )

            def dielectric_section_stop(results: Outcar, _match: re.Match[str]) -> None:
                results.dielectric_ionic_index = None

            search.append(
                [
                    r"-------------------------------------",
                    lambda results, _line: (
                        results.dielectric_ionic_index >= 1
                        if results.dielectric_ionic_index is not None
                        else results.dielectric_ionic_index
                    ),
                    dielectric_section_stop,
                ]
            )

            self.dielectric_ionic_index: int | None = None
            self.dielectric_ionic_tensor = np.zeros((3, 3))

            def piezo_section_start(results: Outcar, _match: re.Match[str]) -> None:
                results.piezo_ionic_index = 0

            search.append(
                [
                    "PIEZOELECTRIC TENSOR IONIC CONTR  for field in x, y, z        ",
                    None,
                    piezo_section_start,
                ]
            )

            def piezo_data(results: Outcar, match: re.Match[str]) -> None:
                results.piezo_ionic_tensor[results.piezo_ionic_index, :] = np.array(
                    [float(match[i]) for i in range(1, 7)]
                )
                results.piezo_ionic_index += 1

            search.append(
                [
                    (
                        r"^ *[xyz] +([-0-9.Ee+]+) +([-0-9.Ee+]+)"
                        r" +([-0-9.Ee+]+) *([-0-9.Ee+]+) +([-0-9.Ee+]+) +([-0-9.Ee+]+)*$"
                    ),
                    lambda results, _line: (
                        results.piezo_ionic_index >= 0
                        if results.piezo_ionic_index is not None
                        else results.piezo_ionic_index
                    ),
                    piezo_data,
                ]
            )

            def piezo_section_stop(results: Outcar, _match: re.Match[str]) -> None:
                results.piezo_ionic_index = None

            search.append(
                [
                    "-------------------------------------",
                    lambda results, _line: (
                        results.piezo_ionic_index >= 1
                        if results.piezo_ionic_index is not None
                        else results.piezo_ionic_index
                    ),
                    piezo_section_stop,
                ]
            )

            self.piezo_ionic_index: int | None = None
            self.piezo_ionic_tensor = np.zeros((3, 6))

            micro_pyawk(self.filename, search, self)  # type:ignore[arg-type]

            self.dielectric_ionic_tensor = self.dielectric_ionic_tensor.tolist()  # type:ignore[assignment]
            self.piezo_ionic_tensor = self.piezo_ionic_tensor.tolist()  # type:ignore[assignment]

        except Exception as exc:
            raise RuntimeError("ionic part of LEPSILON OUTCAR could not be parsed.") from exc

    def read_lcalcpol(self) -> None:
        """Read the LCALCPOL.

        Renders accessible as attributes:
            p_elec (NDArray[float64]): Total electronic dipole moment.
            p_ion (NDArray[float64]): Ionic dipole moment.
            p_sp1 (NDArray[float] | None): Spin up.
            p_sp2 (NDArray[float] | None): Spin down.
        """
        self.p_elec = None
        self.p_ion = None
        self.p_sp1: NDArray | None = None
        self.p_sp2: NDArray | None = None

        try:
            search: list[list] = []

            # Always present spin/non-spin
            def p_elec(results: Outcar, match: re.Match[str]) -> None:
                results.p_elec = np.array(
                    [
                        float(match[1]),
                        float(match[2]),
                        float(match[3]),
                    ]
                )

            search.append(
                [
                    (
                        r"^.*Total electronic dipole moment: "
                        r"*p\[elc\]=\( *([-0-9.Ee+]*) *([-0-9.Ee+]*) "
                        r"*([-0-9.Ee+]*) *\)"
                    ),
                    None,
                    p_elec,
                ]
            )

            # If spin-polarized (and not noncollinear)
            # save spin-polarized electronic values
            if self.spin and not self.noncollinear:

                def p_sp1(results: Outcar, match: re.Match[str]) -> None:
                    results.p_sp1 = np.array(
                        [
                            float(match[1]),
                            float(match[2]),
                            float(match[3]),
                        ]
                    )

                search.append(
                    [
                        r"^.*p\[sp1\]=\( *([-0-9.Ee+]*) *([-0-9.Ee+]*) *([-0-9.Ee+]*) *\)",
                        None,
                        p_sp1,
                    ]
                )

                def p_sp2(results: Outcar, match: re.Match[str]) -> None:
                    results.p_sp2 = np.array(
                        [
                            float(match[1]),
                            float(match[2]),
                            float(match[3]),
                        ]
                    )

                search.append(
                    [
                        r"^.*p\[sp2\]=\( *([-0-9.Ee+]*) *([-0-9.Ee+]*) *([-0-9.Ee+]*) *\)",
                        None,
                        p_sp2,
                    ]
                )

            def p_ion(results: Outcar, match: re.Match[str]) -> None:
                results.p_ion = np.array(
                    [
                        float(match[1]),
                        float(match[2]),
                        float(match[3]),
                    ]
                )

            search.append(
                [
                    r"^.*Ionic dipole moment: *p\[ion\]=\( *([-0-9.Ee+]*) *([-0-9.Ee+]*) *([-0-9.Ee+]*) *\)",
                    None,
                    p_ion,
                ]
            )

            micro_pyawk(self.filename, search, self)  # type:ignore[arg-type]

            # Fix polarization units in new versions of VASP
            regex = r"^.*Ionic dipole moment: .*"
            search = [[regex, None, lambda x, y: x.append(y.group(0))]]
            results = micro_pyawk(self.filename, search, [])  # type:ignore[arg-type]

            if "|e|" in results[0]:
                self.p_elec *= -1  # type: ignore[operator, assignment]
                self.p_ion *= -1  # type: ignore[operator, assignment]
                if self.spin and not self.noncollinear:
                    self.p_sp1 *= -1  # type: ignore[operator, assignment]
                    self.p_sp2 *= -1  # type: ignore[operator, assignment]

        except Exception as exc:
            raise RuntimeError("LCALCPOL OUTCAR could not be parsed.") from exc

    def read_pseudo_zval(self) -> None:
        """Create a pseudopotential valence electron number (ZVAL) dictionary.

        Renders accessible as attributes:
            zval_dict (dict[str, float]): ZVAL for each element.
        """
        try:

            def atom_symbols(results: Outcar, match: re.Match[str]) -> None:
                element_symbol = match[1]
                if not hasattr(results, "atom_symbols"):
                    results.atom_symbols = []
                results.atom_symbols.append(element_symbol.strip())

            def zvals(results: Outcar, match: re.Match[str]) -> None:
                zvals = match[1]
                results.zvals = map(float, re.findall(r"-?\d+\.\d*", zvals))

            search: list[list] = []
            search.extend(
                (
                    ["(?<=VRHFIN =)(.*)(?=:)", None, atom_symbols],
                    ["^\\s+ZVAL.*=(.*)", None, zvals],
                )
            )

            micro_pyawk(self.filename, search, self)  # type:ignore[arg-type]

            self.zval_dict: dict[str, float] = dict(zip(self.atom_symbols, self.zvals, strict=True))  # type: ignore[attr-defined]

            # Clean up
            del self.atom_symbols  # type: ignore[attr-defined]
            del self.zvals  # type: ignore[attr-defined]

        except Exception as exc:
            raise RuntimeError("ZVAL dict could not be parsed.") from exc

    def read_core_state_eigen(self) -> list[dict[str, list[float]]]:
        """Read the core state eigenenergies at each ionic step.

        Returns:
            list[dict[str, list[float]]]: The atom such as [{"AO": [core_state_eig, ]}, ].
            The core state eigenenergie list for each AO is over all ionic step.

        Example:
            The core state eigenenergie of the 2s AO of the 6th atom of the
            structure at the last ionic step is [5]["2s"][-1].
        """
        with zopen(self.filename, mode="rt", encoding="utf-8") as foutcar:
            line: str = foutcar.readline()  # type:ignore[assignment]
            core_state_eigs: list[dict[str, list[float]]] = []

            while line != "":
                line = foutcar.readline()  # type:ignore[assignment]

                if "NIONS =" in line:
                    natom = int(line.split("NIONS =")[1])
                    core_state_eigs = [defaultdict(list) for _ in range(natom)]

                if "the core state eigen" in line:
                    iat = -1
                    while line != "":
                        line = foutcar.readline()  # type:ignore[assignment]
                        # don't know number of lines to parse without knowing
                        # specific species, so stop parsing when we reach
                        # "E-fermi" instead
                        if "E-fermi" in line:
                            break
                        data = line.split()
                        # data will contain odd number of elements if it is
                        # the start of a new entry, or even number of elements
                        # if it continues the previous entry
                        if len(data) % 2 == 1:
                            iat += 1  # started parsing a new ion
                            data = data[1:]  # remove element with ion number
                        for i in range(0, len(data), 2):
                            core_state_eigs[iat][data[i]].append(float(data[i + 1]))
        return core_state_eigs

    def read_avg_core_poten(self) -> list[list[float]]:
        """Read the core potential at each ionic step.

        Returns:
            list[list[float]]: The average core potentials for each atom of each ionic
                step as: [[avg_core_pot, ], ].

        Example:
            The average core potential of the 2nd atom of the structure at the
            last ionic step is: [-1][1].
        """
        with zopen(self.filename, mode="rt", encoding="utf-8") as foutcar:
            line = foutcar.readline()
            avg_core_pots: list[list[float]] = []
            while line != "":
                line = foutcar.readline()
                if "the norm of the test charge is" in line:
                    avg_pot: list[float] = []
                    while line != "":
                        line = foutcar.readline()
                        # don't know number of lines to parse without knowing
                        # specific species, so stop parsing when we reach
                        # "E-fermi" instead
                        if "E-fermi" in line:
                            avg_core_pots.append(avg_pot)
                            break

                        # the average core potentials of up to 5 elements are
                        # given per line; the potentials are separated by several
                        # spaces and numbered from 1 to natoms; the potentials are
                        # parsed in a fixed width format
                        npots = int((len(line) - 1) / 17)
                        for i in range(npots):
                            start = i * 17
                            avg_pot.append(float(line[start + 8 : start + 17]))

        return avg_core_pots

    def read_fermi_contact_shift(self) -> None:
        """Read Fermi contact (isotropic) hyperfine coupling parameter.

        Output example:
            Fermi contact (isotropic) hyperfine coupling parameter (MHz)
            -------------------------------------------------------------
            ion      A_pw      A_1PS     A_1AE     A_1c      A_tot
            -------------------------------------------------------------
            1      -0.002    -0.002    -0.051     0.000    -0.052
            2      -0.002    -0.002    -0.051     0.000    -0.052
            3       0.056     0.056     0.321    -0.048     0.321
            -------------------------------------------------------------
            which corresponds to:
                [[-0.002, -0.002, -0.051, 0.0, -0.052],
                [-0.002, -0.002, -0.051, 0.0, -0.052],
                [0.056, 0.056, 0.321, -0.048, 0.321]] from 'fch' data.

        Renders accessible from self.data:
            fermi_contact_shift (dict[Literal["fch", "dh", "th"], list[list[float]]]):
                Fermi contact (isotropic) hyperfine coupling parameter.
        """
        # Fermi contact (isotropic) hyperfine coupling parameter (MHz)
        header_pattern1 = (
            r"\s*Fermi contact \(isotropic\) hyperfine coupling parameter \(MHz\)\s+"
            r"\s*\-+"
            r"\s*ion\s+A_pw\s+A_1PS\s+A_1AE\s+A_1c\s+A_tot\s+"
            r"\s*\-+"
        )
        row_pattern1 = r"(?:\d+)\s+" + r"\s+".join([r"([-]?\d+\.\d+)"] * 5)
        footer_pattern = r"\-+"
        fch_table: list[list[float]] = self.read_table_pattern(
            header_pattern1,
            row_pattern1,
            footer_pattern,
            postprocess=float,
            last_one_only=True,
        )

        # Dipolar hyperfine coupling parameters (MHz)
        header_pattern2 = (
            r"\s*Dipolar hyperfine coupling parameters \(MHz\)\s+"
            r"\s*\-+"
            r"\s*ion\s+A_xx\s+A_yy\s+A_zz\s+A_xy\s+A_xz\s+A_yz\s+"
            r"\s*\-+"
        )
        row_pattern2 = r"(?:\d+)\s+" + r"\s+".join([r"([-]?\d+\.\d+)"] * 6)
        dh_table: list[list[float]] = self.read_table_pattern(
            header_pattern2,
            row_pattern2,
            footer_pattern,
            postprocess=float,
            last_one_only=True,
        )

        # Total hyperfine coupling parameters after diagonalization (MHz)
        header_pattern3 = (
            r"\s*Total hyperfine coupling parameters after diagonalization \(MHz\)\s+"
            r"\s*\(convention: \|A_zz\| > \|A_xx\| > \|A_yy\|\)\s+"
            r"\s*\-+"
            r"\s*ion\s+A_xx\s+A_yy\s+A_zz\s+asymmetry \(A_yy - A_xx\)/ A_zz\s+"
            r"\s*\-+"
        )
        row_pattern3 = r"(?:\d+)\s+" + r"\s+".join([r"([-]?\d+\.\d+)"] * 4)
        th_table: list[list[float]] = self.read_table_pattern(
            header_pattern3,
            row_pattern3,
            footer_pattern,
            postprocess=float,
            last_one_only=True,
        )

        fc_shift_table: dict[Literal["fch", "dh", "th"], list[list[float]]] = {
            "fch": fch_table,
            "dh": dh_table,
            "th": th_table,
        }

        self.data["fermi_contact_shift"] = fc_shift_table

    def read_vacuum_potentials(
        self,
        reverse: bool = True,
        terminate_on_match: bool = True,
    ) -> None:
        """Read the vacuum potentials. (See LVACPOTAV INCAR tag. Note IDIPOL must be 1-3 for potentials to be output.)

        Args:
            reverse (bool): Whether to start from end of OUTCAR. Defaults to True.
            terminate_on_match (bool): Whether to terminate once match is found. Defaults to True.

        Renders accessible from self.data:
            vacuum_potential_upper (float): Upper vacuum potential
            vacuum_potential_lower (float): Lower vacuum potential
        """
        pattern = r"vacuum level on the upper side and lower side of the slab\s+([\d\.\-]+)\s+([\d\.\-]+)"
        self.read_pattern(
            {"vacuum_potentials": pattern},
            reverse=reverse,
            terminate_on_match=terminate_on_match,
            postprocess=float,
        )
        vacuum_potentials = self.data.get("vacuum_potentials", None)  # upper, lower
        if vacuum_potentials:
            self.data["vacuum_potential_upper"] = vacuum_potentials[0][0]
            self.data["vacuum_potential_lower"] = vacuum_potentials[0][1]
            self.data.pop("vacuum_potentials")


class VolumetricData(BaseVolumetricData):
    """Container for volumetric data that allows
    for reading/writing with Poscar-type data.
    """

    @staticmethod
    def _plain_loadtxt(file: IO[bytes], nelem: int) -> NDArray:
        now = file.tell()
        ncol = len(file.readline().split())
        nrow, remainder = divmod(nelem, ncol)
        file.seek(now)
        data = np.loadtxt(file, max_rows=nrow).flatten()
        if remainder:
            data = np.concatenate((data, np.loadtxt(file, max_rows=1).flatten()))
        if data.size != nelem:
            raise ValueError(f"Expected {nelem} values, got {data.size}")
        return data

    @staticmethod
    def parse_file(filename: PathLike) -> tuple[Poscar, dict, dict]:
        """
        Parse a generic volumetric data file in the VASP like format.
        Used by subclasses for parsing files.

        Args:
            filename (PathLike): Path of file to parse.
        Returns:
            tuple[Poscar, dict, dict]: Poscar object, data dict, data_aug dict
        """
        with zopen(filename, mode="rb") as file:
            # parse poscar
            poscar = None
            poscar_string: list[bytes] = []

            while poscar is None:
                line = file.readline()
                if not line:
                    break
                line = line.strip()

                if line or len(poscar_string) == 0:
                    poscar_string.append(line)
                else:
                    poscar = Poscar.from_str(b"\n".join(poscar_string).decode("utf-8"))
            if poscar is None:
                raise ValueError("Couldn't parse Poscar from volumetric data file.")

            all_dataset: list[NDArray] = []
            all_dataset_aug: list[dict[int, NDArray]] = []

            # parse volumetric data
            while True:
                line = file.readline()
                if not line:
                    break
                line = line.strip()
                if not line:
                    continue
                if line.startswith(b"augmentation occupancies (imaginary part)"):
                    _, k, n = line.rsplit(maxsplit=2)
                    nelem = int(n)
                    arr = np.empty(nelem)
                    if (parsed := parse_n_doubles(file, arr, nelem)) != nelem:
                        raise ValueError(f"Expected {nelem} values, got {parsed}")
                    key = int(k)
                    all_dataset_aug[-1][key] = np.asarray(all_dataset_aug[-1][key], dtype=np.complex128) + 1j * arr
                elif line.startswith(b"augmentation occupancies"):
                    _, k, n = line.rsplit(maxsplit=2)
                    nelem = int(n)
                    arr = np.empty(nelem)
                    if (parsed := parse_n_doubles(file, arr, nelem)) != nelem:
                        raise ValueError(f"Expected {nelem} values, got {parsed}")
                    all_dataset_aug[-1][int(k)] = arr
                elif b"." in line:
                    arr = np.loadtxt(BytesIO(line), max_rows=1)
                    # This line's numeric payload is parsed for format alignment but not otherwise used.
                else:
                    dims = np.loadtxt(BytesIO(line), max_rows=1, dtype=int)
                    if dims.size != 3:
                        raise ValueError(f"Expected 3 values, got {dims.size}")
                    nelem = int(dims.prod())
                    arr = np.empty(nelem)
                    if (parsed := parse_n_doubles(file, arr, nelem)) != nelem:
                        raise ValueError(f"Expected {nelem} values, got {parsed}")
                    arr = arr.reshape(dims, order="F")
                    arr = np.ascontiguousarray(arr)
                    all_dataset.append(arr)
                    all_dataset_aug.append({})

            if len(all_dataset) == 4:
                ref_sign = np.sign(all_dataset[1] * 1.01 + all_dataset[2] * 1.02 + all_dataset[3] * 1.03)
                diff = np.sqrt(all_dataset[1] ** 2 + all_dataset[2] ** 2 + all_dataset[3] ** 2) * ref_sign
                data = {
                    "total": all_dataset[0],
                    "diff_x": all_dataset[1],
                    "diff_y": all_dataset[2],
                    "diff_z": all_dataset[3],
                    "diff": diff,
                }
                data_aug = {
                    "total": all_dataset_aug[0],
                    "diff_x": all_dataset_aug[1],
                    "diff_y": all_dataset_aug[2],
                    "diff_z": all_dataset_aug[3],
                }

            elif len(all_dataset) == 2:
                data = {"total": all_dataset[0], "diff": all_dataset[1]}
                data_aug = {"total": all_dataset_aug[0], "diff": all_dataset_aug[1]}

            else:
                data = {"total": all_dataset[0]}
                data_aug = {"total": all_dataset_aug[0]}

            return poscar, data, data_aug

    def write_file(
        self,
        file_name: PathLike,
        vasp4_compatible: bool = False,
    ) -> None:
        """Write the VolumetricData object to a VASP compatible file.

        Args:
            file_name (PathLike): The output file.
            vasp4_compatible (bool): True if the format is VASP4 compatible.
        """

        def format_fortran_float(flt: float) -> str:
            """Fortran code prints floats with a leading zero in scientific
            notation. When writing CHGCAR files, we adopt this convention
            to ensure written CHGCAR files are byte-to-byte identical to
            their input files as far as possible.

            Args:
                flt (float): Float to print.

            Returns:
                str: The float in Fortran format.
            """
            flt_str = f"{flt:.10E}"
            if flt >= 0:
                return f"0.{flt_str[0]}{flt_str[2:12]}E{int(flt_str[13:]) + 1:+03}"
            return f"-.{flt_str[1]}{flt_str[3:13]}E{int(flt_str[14:]) + 1:+03}"

        def write_spin(data_type: str) -> None:
            lines = []
            count = 0
            file.write(f"   {dim[0]}   {dim[1]}   {dim[2]}\n")  # type:ignore[arg-type]
            for k, j, i in itertools.product(list(range(dim[2])), list(range(dim[1])), list(range(dim[0]))):
                lines.append(format_fortran_float(self.data[data_type][i, j, k]))
                count += 1
                if count % 5 == 0:
                    file.write(" " + "".join(lines) + "\n")  # type:ignore[arg-type]
                    lines = []
                else:
                    lines.append(" ")
            if count % 5 != 0:
                file.write(" " + "".join(lines) + " \n")  # type:ignore[arg-type]

        def write_aug(data_type: str) -> None:
            if self.data_aug is None:
                return

            aug_data: Any = self.data_aug.get(data_type, {})

            if isinstance(aug_data, (list, tuple)):
                for line in aug_data:
                    file.write(f"{line}\n")
                return

            if not isinstance(aug_data, dict):
                return

            def write_values(values: NDArray) -> None:
                lines = []
                count = 0
                for value in np.asarray(values).ravel():
                    lines.append(format_fortran_float(float(value)))
                    count += 1
                    if count % 5 == 0:
                        file.write(" " + "".join(lines) + "\n")
                        lines = []
                    else:
                        lines.append(" ")
                if count % 5 != 0:
                    file.write(" " + "".join(lines) + " \n")

            for key in sorted(aug_data):
                values = np.asarray(aug_data[key])
                if np.iscomplexobj(values):
                    file.write(f"augmentation occupancies   {key} {values.size:3d}\n")
                    write_values(values.real)
                    file.write(f"augmentation occupancies (imaginary part)   {key} {values.size:3d}\n")
                    write_values(values.imag)
                else:
                    file.write(f"augmentation occupancies   {key} {values.size:3d}\n")
                    write_values(values)

        with zopen(file_name, mode="wt", encoding="utf-8") as file:
            poscar = Poscar(self.structure)

            # Use original name if it's been set (e.g. from Chgcar)
            comment = getattr(self, "name", poscar.comment)

            lines = f"{comment}\n"
            lines += "   1.00000000000000\n"
            for vec in self.structure.lattice.matrix:
                lines += f" {vec[0]:12.6f}{vec[1]:12.6f}{vec[2]:12.6f}\n"
            if not vasp4_compatible:
                lines += "".join(f"{s:5}" for s in poscar.site_symbols) + "\n"
            lines += "".join(f"{x:6}" for x in poscar.natoms) + "\n"
            lines += "Direct\n"
            for site in self.structure:
                dim, b, c = site.frac_coords
                lines += f"{dim:10.6f}{b:10.6f}{c:10.6f}\n"
            lines += " \n"
            file.write(lines)  # type:ignore[arg-type]
            dim = self.dim

            data_keys = ("spin_up", "spin_down") if {"spin_up", "spin_down"}.issubset(self.data) else ("total",)
            if self.is_soc:
                data_keys = ("total", "diff_x", "diff_y", "diff_z")
            elif self.is_spin_polarized and data_keys == ("total",):
                data_keys = ("total", "diff")
            for data_key in data_keys:
                write_spin(data_key)
                write_aug(data_key)


def _normalize_direct_spin_channels(data: dict[str, NDArray]) -> dict[str, NDArray]:
    """Map VASP's direct collinear-spin blocks to unambiguous keys."""
    if {"spin_up", "spin_down"}.issubset(data):
        return data
    if set(data) == {"total", "diff"}:
        return {"spin_up": data["total"], "spin_down": data["diff"]}
    return data


class Locpot(VolumetricData):
    """LOCPOT file reader."""

    def __init__(self, poscar: Poscar | Structure, data: dict[str, NDArray], **kwargs) -> None:
        """Initialize a Locpot.

        Args:
            poscar (Poscar | Structure): Poscat or Structure object containing structure.
            data (NDArray): Actual data.
        """
        if isinstance(poscar, Poscar):
            struct = poscar.structure
            self.poscar = poscar
            self.name = poscar.comment
        elif isinstance(poscar, Structure):
            struct = poscar
            self.poscar = Poscar(poscar)
        else:
            raise TypeError("Unsupported POSCAR type.")

        # Normalize legacy "total"/"diff" keys to native "spin_up"/"spin_down" keys
        data = _normalize_direct_spin_channels(data)
        deprecated_keys = {"total": "spin_up", "diff": "spin_down"} if "spin_up" in data else None
        kwargs.pop("data_key", None)
        super().__init__(struct, data, data_key="spin_up" if "spin_up" in data else "total", **kwargs)
        if deprecated_keys:
            # Preserve legacy keys as warning-backed aliases of the native spin-channel keys
            self.data = type(self.data)(self.data, deprecated_keys=deprecated_keys)

    @classmethod
    def from_file(cls, filename: PathLike, **kwargs) -> Self:
        """Read a LOCPOT file.

        Args:
            filename (PathLike): Path to LOCPOT file.

        Returns:
            Locpot
        """
        poscar, data, _data_aug = VolumetricData.parse_file(filename)
        return cls(poscar, data, **kwargs)


class Chgcar(VolumetricData):
    """CHGCAR file reader."""

    def __init__(
        self,
        poscar: Poscar | Structure,
        data: dict[str, NDArray],
        data_aug: dict[str, NDArray] | None = None,
        **kwargs,
    ) -> None:
        """Initialize a Chgcar.

        Args:
            poscar (Poscar | Structure): Object containing structure.
            data: Actual data.
            data_aug: Augmentation charge data.
        """
        # Allow Poscar or Structure to be passed
        if isinstance(poscar, Poscar):
            struct = poscar.structure
            self.poscar = poscar
            self.name: str | None = poscar.comment  # type: ignore[assignment]
        elif isinstance(poscar, Structure):
            struct = poscar
            self.poscar = Poscar(poscar)
            self.name = None
        else:
            raise TypeError("Unsupported POSCAR type.")

        super().__init__(struct, data, data_aug=data_aug, **kwargs)
        self._distance_matrix: dict = {}

    @classmethod
    def from_file(cls, filename: str) -> Self:
        """Read a CHGCAR file.

        Args:
            filename (str): Path to CHGCAR file.

        Returns:
            Chgcar
        """
        poscar, data, data_aug = VolumetricData.parse_file(filename)
        return cls(poscar, data, data_aug=data_aug)  # type:ignore[arg-type]

    @property
    def net_magnetization(self) -> float | None:
        """Net magnetic moment from Chgcar."""
        return np.sum(self.data["diff"]) if self.is_spin_polarized else None


class Elfcar(VolumetricData):
    """Read an ELFCAR file which contains the Electron Localization Function (ELF).

    For collinear spin-polarized calculations, the data is stored under the
    ``"spin_up"`` and ``"spin_down"`` keys. The legacy ``"total"`` and
    ``"diff"`` aliases are deprecated.

    This also contains information on the kinetic energy density.
    """

    def __init__(
        self,
        poscar: Poscar | Structure,
        data: dict[str, NDArray],
        **kwargs,
    ) -> None:
        """Initialize an Elfcar.

        Args:
            poscar (Poscar or Structure): Object containing structure.
            data: Actual data.
        """
        # Allow Poscar or Structure to be passed
        if isinstance(poscar, Poscar):
            tmp_struct = poscar.structure
            self.poscar = poscar
        elif isinstance(poscar, Structure):
            tmp_struct = poscar
            self.poscar = Poscar(poscar)
        else:
            raise TypeError("Unsupported POSCAR type.")

        # Normalize legacy "total"/"diff" keys to native "spin_up"/"spin_down" keys
        data = _normalize_direct_spin_channels(data)
        deprecated_keys = {"total": "spin_up", "diff": "spin_down"} if "spin_up" in data else None
        kwargs.pop("data_key", None)
        super().__init__(tmp_struct, data, data_key="spin_up" if "spin_up" in data else "total", **kwargs)
        if deprecated_keys:
            # Preserve legacy keys as warning-backed aliases of the native spin-channel keys
            self.data = type(self.data)(self.data, deprecated_keys=deprecated_keys)

    @classmethod
    def from_file(cls, filename: str) -> Self:
        """
        Read a ELFCAR file.

        Args:
            filename: Filename

        Returns:
            Elfcar
        """
        poscar, data, _data_aug = VolumetricData.parse_file(filename)
        return cls(poscar, data)

    def get_alpha(self) -> VolumetricData:
        """Get the parameter alpha where ELF = 1/(1 + alpha^2)."""
        alpha_data = {key: np.sqrt((1 / val) - 1) for key, val in self.data.items()}
        return VolumetricData(self.structure, alpha_data)


class Procar(MSONable):
    """
    PROCAR file reader.

    Updated to use code from easyunfold (https://smtg-bham.github.io/easyunfold; band-structure
    unfolding package) to allow SOC PROCAR parsing, and parsing multiple PROCAR files together.
    easyunfold's PROCAR parser can be used if finer control over projections (k-point weighting,
    normalisation per band, quick orbital sub-selection etc) is needed.

    Attributes:
        data (dict): The PROCAR data of the form below. It should VASP uses 1-based indexing,
            but all indices are converted to 0-based here.
            {spin: np.array accessed with (k-point index, band index, ion index, orbital index)}
        weights (NDArray): The weights associated with each k-point as an array of length nkpoints.
        phase_factors (dict): Phase factors, where present (e.g. LORBIT = 12). A dict of the form:
            {spin: complex np.array accessed with (k-point index, band index, ion index, orbital index)}
        nbands (int): Number of bands.
        nkpoints (int): Number of k-points.
        nions (int): Number of ions.
        nspins (int): Number of spins.
        is_soc (bool): Whether the PROCAR contains spin-orbit coupling (LSORBIT = True) data.
        kpoints (NDArray): The k-points as an np.array of shape (nkpoints, 3).
        occupancies (dict): The occupancies of the bands as a dict of the form:
            {spin: np.array accessed with (k-point index, band index)}
        eigenvalues (dict): The eigenvalues of the bands as a dict of the form:
            {spin: np.array accessed with (k-point index, band index)}
        xyz_data (dict): The PROCAR projections data along the x,y and z magnetisation projection
            directions, with is_soc = True (see VASP wiki for more info).
            {'x'/'y'/'z': np.array accessed with (k-point index, band index, ion index, orbital index)}
    """

    def __init__(self, filename: PathLike | list[PathLike]):
        """Initialize a Procar.

        Args:
            filename: The path to PROCAR(.gz) file to read, or list of paths.
        """
        # get PROCAR filenames list to parse:
        filenames = filename if isinstance(filename, list) else [filename]
        self.nions: int | None = None  # used to check for consistency in files later
        self.nspins: int | None = None  # used to check for consistency in files later
        self.is_soc: bool | None = None  # used to check for consistency in files later
        self.orbitals = None  # used to check for consistency in files later
        self.read(filenames)

    def read(self, filenames: list[PathLike]):
        """
        Read in PROCAR projections data, possibly from multiple files.

        Args:
            filenames: List of PROCAR files to read.
        """
        parsed_kpoints = None
        occupancies_list, kpoints_list, weights_list = [], [], []
        eigenvalues_list, data_list, xyz_data_list = [], [], []
        phase_factors_list = []
        for filename in tqdm(filenames, desc="Reading PROCARs", unit="file", disable=len(filenames) == 1):
            (
                kpoints,
                weights,
                eigenvalues,
                occupancies,
                data,
                phase_factors,
                xyz_data,
            ) = self._read(filename, parsed_kpoints=parsed_kpoints)

            # Append to respective lists
            occupancies_list.append(occupancies)
            kpoints_list.append(kpoints)
            weights_list.append(weights)
            eigenvalues_list.append(eigenvalues)
            data_list.append(data)
            xyz_data_list.append(xyz_data)
            phase_factors_list.append(phase_factors)

        # Combine arrays along the kpoints axis:
        # nbands (axis = 2) could differ between arrays, so set missing values to zero:
        max_nbands = max(eig_dict[Spin.up].shape[1] for eig_dict in eigenvalues_list)
        for dict_array in itertools.chain(
            occupancies_list,
            eigenvalues_list,
            data_list,
            xyz_data_list,
            phase_factors_list,
        ):
            if dict_array:
                for key, array in dict_array.items():
                    if array.shape[1] < max_nbands:
                        if len(array.shape) == 2:  # occupancies, eigenvalues
                            dict_array[key] = np.pad(
                                array,
                                ((0, 0), (0, max_nbands - array.shape[2])),
                                mode="constant",
                            )
                        elif len(array.shape) == 4:  # data, phase_factors
                            dict_array[key] = np.pad(
                                array,
                                (
                                    (0, 0),
                                    (0, max_nbands - array.shape[2]),
                                    (0, 0),
                                    (0, 0),
                                ),
                                mode="constant",
                            )
                        elif len(array.shape) == 5:  # xyz_data
                            dict_array[key] = np.pad(
                                array,
                                (
                                    (0, 0),
                                    (0, max_nbands - array.shape[2]),
                                    (0, 0),
                                    (0, 0),
                                    (0, 0),
                                ),
                                mode="constant",
                            )
                        else:
                            raise ValueError("Unexpected array shape encountered!")

        # set nbands, nkpoints, and other attributes:
        self.nbands = max_nbands
        self.kpoints = np.concatenate(kpoints_list, axis=0)
        self.nkpoints = len(self.kpoints)
        self.occupancies = {
            spin: np.concatenate([occupancies[spin] for occupancies in occupancies_list], axis=0)
            for spin in occupancies_list[0]
        }
        self.eigenvalues = {
            spin: np.concatenate([eigenvalues[spin] for eigenvalues in eigenvalues_list], axis=0)
            for spin in eigenvalues_list[0]
        }
        self.weights = np.concatenate(weights_list, axis=0)
        self.data = {spin: np.concatenate([data[spin] for data in data_list], axis=0) for spin in data_list[0]}
        self.phase_factors = {
            spin: np.concatenate([phase_factors[spin] for phase_factors in phase_factors_list], axis=0)
            for spin in phase_factors_list[0]
        }
        if self.is_soc:
            self.xyz_data: dict | None = {
                key: np.concatenate([xyz_data[key] for xyz_data in xyz_data_list], axis=0) for key in xyz_data_list[0]
            }
        else:
            self.xyz_data = None

    def _parse_kpoint_line(self, line: str) -> tuple[float, float, float]:
        """
        Parse k-point vector from a PROCAR line.

        Sometimes VASP outputs the kpoints joined together like
        '0.00000000-0.50000000-0.50000000' when there are negative signs,
        so need to be able to recognise and handle this.
        """
        fields: list[str] = line.split()
        kpoint_fields: list[str] = fields[3 : fields.index("weight")]
        _kpoint_fields: list[list[str]] = [" -".join(field.split("-")).split() for field in kpoint_fields]
        kpoint_fields = [val for sublist in _kpoint_fields for val in sublist]  # flattened

        # tuple to make it hashable, rounded to 5 decimal places to ensure proper kpoint matching
        return cast("tuple[float, float, float]", tuple(round(float(val), 5) for val in kpoint_fields))

    def _read(self, filename: PathLike, parsed_kpoints: set[tuple[float, float, float]] | None = None):
        """Main function for reading in the PROCAR projections data.

        Args:
            filename (PathLike): Path to PROCAR file to read.
            parsed_kpoints (set[tuple[Kpoint]]): Set of tuples of already-parsed kpoints (e.g. from multiple
                zero-weighted bandstructure calculations), to ensure redundant/duplicate parsing.
        """
        if parsed_kpoints is None:
            parsed_kpoints = set()
        line: str
        with zopen(filename, mode="rt", encoding="utf-8") as file:
            preamble_expr = re.compile(r"# of k-points:\s*(\d+)\s+# of bands:\s*(\d+)\s+# of ions:\s*(\d+)")
            kpoint_expr = re.compile(r"^k-point\s+(\d+).*weight = ([0-9\.]+)")
            band_expr = re.compile(r"^band\s+(\d+)")
            ion_expr = re.compile(r"^ion.*")
            total_expr = re.compile(r"^tot.*")
            expr = re.compile(r"^([0-9]+)\s+")
            current_kpoint = 0
            current_band = 0
            spin = Spin.down  # switched to Spin.up for first block

            n_kpoints = None
            kpoints: list[tuple[float, float, float]] = []
            n_bands = None
            n_ions = None
            weights: NDArray[np.float64] | None = None
            headers = None
            data: dict[Spin, NDArray] = {}
            eigenvalues: dict[Spin, NDArray] | None = None
            occupancies: dict[Spin, NDArray] | None = None
            phase_factors: dict[Spin, NDArray] | None = None
            xyz_data: dict[str, NDArray] | None = None  # 'x'/'y'/'z' as keys for SOC projections dict
            # keep track of parsed kpoints, to avoid redundant/duplicate parsing with multiple PROCARs:
            this_procar_parsed_kpoints = (
                set()
            )  # set of tuples of parsed (kvectors, 0/1 for Spin.up/down) for this PROCAR

            # first dynamically determine whether PROCAR is SOC or not; SOC PROCARs have 4 lists of projections (
            # total and x,y,z) for each band, while non-SOC have only 1 list of projections:
            tot_count = 0
            band_count = 0
            for line in file:  # type:ignore[assignment]
                if total_expr.match(line):
                    tot_count += 1
                elif band_expr.match(line):
                    band_count += 1
                if band_count == 2:
                    break

            file.seek(0)  # reset file handle to beginning
            if tot_count == 1:
                is_soc = False
            elif tot_count == 4:
                is_soc = True
            else:
                raise ValueError(
                    "Number of lines starting with 'tot' in PROCAR does not match expected values (4x or 1x number of "
                    "lines with 'band'), indicating a corrupted file!"
                )
            if self.is_soc is not None and self.is_soc != is_soc:
                raise ValueError("Mismatch in SOC setting (LSORBIT) in supplied PROCARs!")
            self.is_soc = is_soc

            skipping_kpoint = False  # true when skipping projections for a previously-parsed kpoint
            skipped_kpoint_count = 0  # keep track of number of k-points skipped
            ion_line_count = 0  # printed twice when phase factors present
            proj_data_parsed_for_band = 0  # 0 for non-SOC, 1-4 for SOC/phase factors
            for line in file:  # type:ignore[assignment]
                line = line.strip()  # type:ignore[assignment]
                if ion_expr.match(line):
                    ion_line_count += 1

                if kpoint_expr.match(line):
                    kvec = self._parse_kpoint_line(line)
                    match = kpoint_expr.match(line)
                    current_kpoint = int(match[1]) - 1 - skipped_kpoint_count  # type: ignore[index]
                    if current_kpoint == 0:
                        spin = Spin.up if spin == Spin.down else Spin.down

                    if (
                        kvec not in parsed_kpoints
                        and (kvec, {Spin.down: 0, Spin.up: 1}[spin]) not in this_procar_parsed_kpoints
                    ):
                        this_procar_parsed_kpoints.add((kvec, {Spin.down: 0, Spin.up: 1}[spin]))
                        skipping_kpoint = False
                        if spin == Spin.up:
                            kpoints.append(kvec)  # only add once
                    else:  # skip ahead to next kpoint:
                        skipping_kpoint = True
                        skipped_kpoint_count += 1
                        continue

                    if spin == Spin.up:  # record k-weight only once
                        weights[current_kpoint] = float(match[2])  # type: ignore[index]
                    proj_data_parsed_for_band = 0

                elif skipping_kpoint:
                    continue

                elif band_expr.match(line):
                    ion_line_count = 0  # printed a second time when phase factors present
                    match = band_expr.match(line)
                    current_band = int(match[1]) - 1  # type: ignore[index]
                    tokens = line.split()
                    eigenvalues[spin][current_kpoint, current_band] = float(tokens[4])  # type: ignore[index]
                    occupancies[spin][current_kpoint, current_band] = float(tokens[-1])  # type: ignore[index]
                    # keep track of parsed projections for each band (1x w/non-SOC, 4x w/SOC):
                    proj_data_parsed_for_band = 0

                elif headers is None and ion_expr.match(line):
                    headers = line.split()
                    headers.pop(0)
                    headers.pop(-1)

                    data = defaultdict(lambda: np.zeros((n_kpoints, n_bands, n_ions, len(headers))))  # type:ignore[arg-type, type-var]
                    phase_factors = defaultdict(
                        lambda: np.full(  # type:ignore[type-var]
                            (n_kpoints, n_bands, n_ions, len(headers)),  # type:ignore[arg-type]
                            np.nan,
                            dtype=np.complex128,
                        )
                    )
                    if self.is_soc:  # dict keys are now "x", "y", "z" rather than Spin.up/down
                        xyz_data = defaultdict(lambda: np.zeros((n_kpoints, n_bands, n_ions, len(headers))))  # type:ignore[arg-type, type-var]

                elif expr.match(line):
                    tokens = line.split()
                    index = int(tokens.pop(0)) - 1
                    if headers is None:
                        raise ValueError("headers is None")
                    num_data = np.array([float(t) for t in tokens[: len(headers)]])
                    if phase_factors is None:
                        raise ValueError("phase_factors is None")

                    if proj_data_parsed_for_band == 0:
                        data[spin][current_kpoint, current_band, index, :] = num_data

                    elif self.is_soc and proj_data_parsed_for_band < 4:
                        proj_direction = {1: "x", 2: "y", 3: "z"}[proj_data_parsed_for_band]
                        if xyz_data is None:
                            raise ValueError(f"{xyz_data=}")
                        xyz_data[proj_direction][current_kpoint, current_band, index, :] = num_data

                    elif len(tokens) > len(headers):  # note no xyz projected phase factors with SOC
                        # New format of PROCAR (VASP 5.4.4)
                        num_data = np.array([float(t) for t in tokens[: 2 * len(headers)]])
                        for orb in range(len(headers)):
                            phase_factors[spin][current_kpoint, current_band, index, orb] = complex(
                                num_data[2 * orb], num_data[2 * orb + 1]
                            )
                    elif np.isnan(phase_factors[spin][current_kpoint, current_band, index, 0]):
                        # Old format of PROCAR (VASP 5.4.1 and before)
                        phase_factors[spin][current_kpoint, current_band, index, :] = num_data
                    else:
                        phase_factors[spin][current_kpoint, current_band, index, :] += 1j * num_data

                elif total_expr.match(line):
                    proj_data_parsed_for_band += 1

                elif preamble_expr.match(line):
                    match = preamble_expr.match(line)
                    if match is None:
                        raise RuntimeError(f"Failed to find preamable pattern, {match=}")
                    n_kpoints = int(match[1])
                    n_bands = int(match[2])
                    if eigenvalues is None:  # first spin
                        weights = np.zeros(n_kpoints)
                        eigenvalues = defaultdict(lambda: np.zeros((n_kpoints, n_bands)))
                        occupancies = defaultdict(lambda: np.zeros((n_kpoints, n_bands)))
                    n_ions = int(match[3])

                    if self.nions is not None and self.nions != n_ions:  # parsing multiple PROCARs but nions mismatch!
                        raise ValueError(f"Mismatch in number of ions in supplied PROCARs: ({n_ions} vs {self.nions})!")

            self.nions = n_ions  # attributes that should be consistent between multiple files are set here
            if self.orbitals is not None and self.orbitals != headers:  # multiple PROCARs but orbitals mismatch!
                raise ValueError(f"Mismatch in orbital headers in supplied PROCARs: {headers} vs {self.orbitals}!")
            self.orbitals = headers  # type:ignore[assignment]
            if self.nspins is not None and self.nspins != len(data):  # parsing multiple PROCARs but nspins mismatch!
                raise ValueError("Mismatch in number of spin channels in supplied PROCARs!")
            self.nspins = len(data)

            # chop off empty kpoints in arrays, including last k-point in case it was skipped
            nkpoints = current_kpoint + 1
            weights = np.array(weights[: nkpoints - int(skipping_kpoint)])  # type: ignore[index]
            data = {spin: data[spin][: nkpoints - int(skipping_kpoint)] for spin in data}  # type: ignore[index]
            eigenvalues = {spin: eigenvalues[spin][: nkpoints - int(skipping_kpoint)] for spin in eigenvalues}  # type: ignore[union-attr,index]
            occupancies = {spin: occupancies[spin][: nkpoints - int(skipping_kpoint)] for spin in occupancies}  # type: ignore[union-attr,index]
            phase_factors = {spin: phase_factors[spin][: nkpoints - int(skipping_kpoint)] for spin in phase_factors}  # type: ignore[union-attr,index]
            if self.is_soc:
                xyz_data = {spin: xyz_data[spin][: nkpoints - int(skipping_kpoint)] for spin in xyz_data}  # type: ignore[union-attr,index]

            # Update the parsed kpoints
            parsed_kpoints.update({kvec_spin_tuple[0] for kvec_spin_tuple in this_procar_parsed_kpoints})

            return (
                kpoints,
                weights,
                eigenvalues,
                occupancies,
                data,
                phase_factors,
                xyz_data,
            )

    def get_projection_on_elements(self, structure: Structure) -> dict[Spin, list[list[dict[str, float]]]]:
        """Get a dict of projections on elements.

        Args:
            structure (Structure): Input structure.

        Returns:
            A dict as {Spin: [band index][kpoint index][{Element: values}]].
        """
        if self.data is None:
            raise ValueError("data cannot be None.")
        if self.nkpoints is None:
            raise ValueError("nkpoints cannot be None.")
        if self.nbands is None:
            raise ValueError("nbands cannot be None.")
        if self.nions is None:
            raise ValueError("nions cannot be None.")

        elem_proj: dict[Spin, list] = {}
        for spin in self.data:
            elem_proj[spin] = [[defaultdict(float) for _ in range(self.nkpoints)] for _ in range(self.nbands)]

        for iat in range(self.nions):
            name = structure.species[iat].symbol
            for spin, data in self.data.items():
                for kpoint, band in itertools.product(range(self.nkpoints), range(self.nbands)):
                    elem_proj[spin][band][kpoint][name] += np.sum(data[kpoint, band, iat, :])

        return elem_proj

    def get_occupation(self, atom_index: int, orbital: str) -> dict:
        """Get the occupation for a particular orbital of a particular atom.

        Args:
            atom_num (int): Index of atom in the PROCAR. It should be noted
                that VASP uses 1-based indexing for atoms, but this is
                converted to 0-based indexing in this parser to be
                consistent with representation of structures in pymatgen.
            orbital (str): An orbital. If it is a single character, e.g. s,
                p, d or f, the sum of all s-type, p-type, d-type or f-type
                orbitals occupations are returned respectively. If it is a
                specific orbital, e.g. px, dxy, etc., only the occupation
                of that orbital is returned.

        Returns:
            dict: Sum occupation of orbital of atom.
        """
        if self.orbitals is None:
            raise ValueError("orbitals is None")
        orbital_index = self.orbitals.index(orbital)

        if self.data is None:
            raise ValueError("data is None")
        return {
            spin: np.sum(data[:, :, atom_index, orbital_index] * self.weights[:, None])  # type: ignore[call-overload]
            for spin, data in self.data.items()
        }


class Oszicar:
    """OSZICAR parser for VASP.

    In general, while OSZICAR is useful for a quick look at the output from a VASP run,
    we recommend using the Vasprun parser instead, which gives far richer information.

    Attributes:
        electronic_steps (list[list[dict]]): All electronic steps. e.g.
            [[{"rms": 160.0, "E": 4507.24605593, "dE": 4507.2, "N": 1, "deps": -17777.0, "ncg": 16576}, ...], [....]
            where electronic_steps[index] refers the list of electronic steps in one ionic_step,
            electronic_steps[index][subindex] refers to a particular electronic step at subindex in ionic step at
            index. The dict of properties depends on the type of VASP run, but in general, "E", "dE" and "rms" should
            be present in almost all runs.
        ionic_steps (list[dict[str, float]]): All ionic_steps, e.g.
            [{"dE": -526.36, "E0": -526.36024, "mag": 0.0, "F": -526.36024}, ...]
            This is the typical output from VASP at the end of each ionic step. The stored dict might be different
            depending on the type of VASP run.
    """

    def __init__(self, filename: PathLike) -> None:
        """Initialize an Oszicar.

        Args:
            filename (PathLike): The file to parse.
        """

        def smart_convert(header: str, num: float | str) -> float | str:
            try:
                return int(num) if header in {"N", "ncg"} else float(num)

            except ValueError:
                return "--"

        electronic_steps = []
        ionic_steps = []
        ionic_general_pattern = re.compile(r"(\w+)=\s*(\S+)")
        electronic_pattern = re.compile(r"\s*\w+\s*:(.*)")

        header: list = []
        with zopen(filename, mode="rt", encoding="utf-8") as file:
            line: str
            for line in file:  # type:ignore[assignment]
                if match := electronic_pattern.match(line.strip()):
                    tokens = match[1].split()
                    data = {header[idx]: smart_convert(header[idx], tokens[idx]) for idx in range(len(tokens))}
                    if tokens[0] == "1":
                        electronic_steps.append([data])
                    else:
                        electronic_steps[-1].append(data)
                elif re.match(r"^\s*N\s+E\s*", line.strip()):
                    header = line.strip().replace("d eps", "deps").split()
                elif line.strip() != "":
                    # remove space first and apply field agnostic extraction
                    matches = re.findall(ionic_general_pattern, re.sub(r"d E ", "dE", line))
                    ionic_steps.append({key: _vasprun_float(value) for key, value in matches})

        self.electronic_steps = electronic_steps
        self.ionic_steps = ionic_steps

    @property
    def all_energies(self) -> tuple[tuple[float | str, ...], ...]:
        """Compilation of all energies from all electronic steps and ionic steps
        as a tuple of list of energies, e.g.
        ((4507.24605593, 143.824705755, -512.073149912, ...), ...).
        """
        all_energies: list[tuple] = []
        for i in range(len(self.electronic_steps)):
            energies: list[float | str] = [step["E"] for step in self.electronic_steps[i]]
            energies.append(self.ionic_steps[i]["F"])
            all_energies.append(tuple(energies))
        return tuple(all_energies)

    @property
    @unitized("eV")
    def final_energy(self) -> float:
        """Final energy from a run."""
        return self.ionic_steps[-1]["E0"]

    def as_dict(self) -> dict[str, list]:
        """MSONable dict."""
        return {
            "electronic_steps": self.electronic_steps,
            "ionic_steps": self.ionic_steps,
        }


class VaspParseError(ParseError):
    """Exception class for VASP parsing."""


def get_band_structure_from_vasp_multiple_branches(
    dir_name: PathLike,
    efermi: float | None = None,
    projections: bool = False,
) -> BandStructureSymmLine | BandStructure | None:
    """Get band structure info from a VASP directory.

    It takes into account that a run can be divided in several branches,
    each inside a directory named "branch_x". If the run has not been
    divided in branches the function will turn to parse vasprun.xml
    directly from the selected directory.

    Args:
        dir_name (PathLike): Parent directory containing all bandstructure runs.
        efermi (float): Fermi level for bandstructure.
        projections (bool): True if you want to get the data on site
            projections if any. Note that this is sometimes very large

    Returns:
        A BandStructure/BandStructureSymmLine Object.
        None if no vasprun.xml found in given directory and branch directory.
    """
    dir_path = Path(dir_name)
    if (dir_path / "branch_0").is_dir():
        # Get and sort all branch directories
        branch_dirs = sorted(
            (d.resolve() for d in dir_path.glob("branch_*") if d.is_dir()),
            key=lambda d: int(d.name.split("_")[-1]),
        )

        # Collect BandStructure from all branches
        bs_branches: list[BandStructure | BandStructureSymmLine] = []
        for directory in branch_dirs:
            vasprun_path = directory / "vasprun.xml"
            if not vasprun_path.is_file():
                raise FileNotFoundError(f"cannot find vasprun.xml in {directory=}")

            run = Vasprun(vasprun_path, parse_projected_eigen=projections)
            bs_branches.append(run.get_band_structure(efermi=efermi))

        return get_reconstructed_band_structure(bs_branches, efermi)

    # Read vasprun.xml directly if no branch head (branch_0) is found
    # TODO: remove this branch and raise error directly after 2026-06-01
    vasprun_path = dir_path / "vasprun.xml"
    if vasprun_path.is_file():
        warnings.warn(
            (
                f"no branch dir found, reading directly from {dir_name=}\n"
                "this fallback branch would be removed after 2026-06-01\n"
                "please check your data dir or use Vasprun.get_band_structure directly"
            ),
            DeprecationWarning,
            stacklevel=2,
        )
        return Vasprun(vasprun_path, parse_projected_eigen=projections).get_band_structure(
            kpoints_filename=None, efermi=efermi
        )

    raise FileNotFoundError(f"failed to find any vasprun.xml in selected {dir_name=}")


class Xdatcar:
    """XDATCAR parser. Only tested with VASP 5.x files.

    Attributes:
        structures (list[Structure]): Structures parsed from XDATCAR.
        comment (str): Optional comment.

    Authors: Ram Balachandran
    """

    def __init__(
        self,
        filename: PathLike,
        ionicstep_start: int = 1,
        ionicstep_end: int | None = None,
        comment: str | None = None,
    ) -> None:
        """
        Init a Xdatcar.

        Args:
            filename (PathLike): The XDATCAR file.
            ionicstep_start (int): Starting index of ionic step.
            ionicstep_end (int): Ending index of ionic step.
            comment (str): Optional comment attached to this set of structures.
        """
        preamble = None
        coords_str: list = []
        structures: list = []
        preamble_done: bool = False
        parse_poscar: bool = False
        num_sites: int | None = None
        restart_preamble: bool = False
        if ionicstep_start < 1:
            raise ValueError("Start ionic step cannot be less than 1")
        if ionicstep_end is not None and ionicstep_end < 1:
            raise ValueError("End ionic step cannot be less than 1")

        file_len = sum(1 for _ in zopen(filename, mode="rt", encoding="utf-8"))
        ionicstep_cnt = 1
        ionicstep_start = ionicstep_start or 0
        with zopen(filename, mode="rt", encoding="utf-8") as file:
            title = None
            for iline, line in enumerate(file):
                line = line.strip()
                if preamble is None:
                    preamble = [line]
                    title = line

                elif title == line and len(coords_str) > 0:
                    # sometimes the title is the same as the only chemical species in the structure
                    # only enter this block if the coords have been read
                    parse_poscar = True
                    restart_preamble = True
                    preamble_done = False

                elif not preamble_done:
                    if line == "" or "Direct configuration=" in line:
                        preamble_done = True
                    else:
                        preamble.append(line)

                elif line == "" or ("Direct configuration=" in line and len(coords_str) > 0):
                    parse_poscar = True
                    restart_preamble = False
                else:
                    coords_str.append(line)

                if (parse_poscar and (num_sites is None or len(coords_str) == num_sites)) or iline == file_len - 1:
                    if num_sites is None:
                        num_sites = len(coords_str)

                    poscar = Poscar.from_str("\n".join([*preamble, "Direct", *coords_str]))
                    if (ionicstep_end is None and ionicstep_cnt >= ionicstep_start) or (
                        ionicstep_end is not None and ionicstep_start <= ionicstep_cnt < ionicstep_end
                    ):
                        structures.append(poscar.structure)
                    elif (ionicstep_end is not None) and ionicstep_cnt >= ionicstep_end:
                        break

                    ionicstep_cnt += 1
                    coords_str = []
                    parse_poscar = False
                    if restart_preamble:
                        preamble = [line]

            if preamble is None:
                raise ValueError("preamble is None")

        self.structures = structures
        self.comment = comment or self.structures[0].formula

    def __str__(self) -> str:
        return self.get_str()

    def __len__(self) -> int:
        return len(self.structures)

    def __iter__(self) -> Iterator[Structure]:
        """Iterator of Xdatcar, yielding a pymatgen Structure."""
        for idx in range(len(self)):
            yield self.structures[idx]

    def __getitem__(self, frames: int | slice | list[int] | np.ndarray) -> Structure | list[Structure]:
        """Get a subset of the Xdatcar.

        Args:
            frames (int, slice, list of int, or numpy Array): Indices of the Xdatcar to return.

        Returns:
            Structure, if frames is an int; otherwise, a list of Structure
        """
        if isinstance(frames, int | slice):
            return self.structures[frames]
        return [self.structures[idx] for idx in frames]

    @property
    def site_symbols(self) -> list[str]:
        """Sequence of symbols associated with the Xdatcar.
        Similar to 6th line in VASP 5+ Xdatcar.
        """
        syms = [site.specie.symbol for site in self.structures[0]]
        return [a[0] for a in itertools.groupby(syms)]

    @property
    def natoms(self) -> list[int]:
        """Sequence of number of sites of each type associated with the Poscar.
        Similar to 7th line in VASP 5+ Xdatcar.
        """
        syms = [site.specie.symbol for site in self.structures[0]]
        return [len(tuple(a[1])) for a in itertools.groupby(syms)]

    def concatenate(
        self,
        filename: PathLike,
        ionicstep_start: int = 1,
        ionicstep_end: int | None = None,
    ) -> None:
        """Concatenate structures in file to Xdatcar.

        Args:
            filename (PathLike): The XDATCAR file to be concatenated.
            ionicstep_start (int): Starting number of ionic step.
            ionicstep_end (int): Ending number of ionic step.

        TODO (rambalachandran): Check to ensure the new concatenating file
            has the same lattice structure and atoms as the Xdatcar class.
        """
        preamble = None
        coords_str: list[str] = []
        structures = self.structures
        preamble_done = False
        if ionicstep_start < 1:
            raise ValueError("Start ionic step cannot be less than 1")
        if ionicstep_end is not None and ionicstep_end < 1:
            raise ValueError("End ionic step cannot be less than 1")

        ionicstep_cnt = 1
        with zopen(filename, mode="rt", encoding="utf-8") as file:
            for line in file:
                line = line.strip()
                if preamble is None:
                    preamble = [line]
                elif not preamble_done:
                    if line == "" or "Direct configuration=" in line:
                        preamble_done = True
                        tmp_preamble = [preamble[0]]
                        for i in range(1, len(preamble)):
                            if preamble[0] != preamble[i]:
                                tmp_preamble.append(preamble[i])
                            else:
                                break
                        preamble = tmp_preamble
                    else:
                        preamble.append(line)
                elif line == "" or "Direct configuration=" in line:
                    poscar = Poscar.from_str("\n".join([*preamble, "Direct", *coords_str]))  # type:ignore[list-item]
                    if (ionicstep_end is None and ionicstep_cnt >= ionicstep_start) or (
                        ionicstep_end is not None and ionicstep_start <= ionicstep_cnt < ionicstep_end
                    ):
                        structures.append(poscar.structure)
                    ionicstep_cnt += 1
                    coords_str = []
                else:
                    coords_str.append(line)  # type:ignore[arg-type]

            if preamble is None:
                raise ValueError("preamble is None")
            poscar = Poscar.from_str("\n".join([*preamble, "Direct", *coords_str]))  # type:ignore[list-item]

            if (
                (ionicstep_end is None and ionicstep_cnt >= ionicstep_start)
                or ionicstep_start <= ionicstep_cnt < ionicstep_end  # type: ignore[operator]
            ):
                structures.append(poscar.structure)
        self.structures = structures

    def get_str(
        self,
        ionicstep_start: int = 1,
        ionicstep_end: int | None = None,
        significant_figures: int = 8,
    ) -> str:
        """Get Xdatcar as a string.

        Args:
            ionicstep_start (int): Starting index of ionic step.
            ionicstep_end (int): Ending index of ionic step.
            significant_figures (int): Number of significant digits.

        Returns:
            str: Xdatcar as a string.
        """
        if ionicstep_start < 1:
            raise ValueError("Start ionic step cannot be less than 1")
        if ionicstep_end is not None and ionicstep_end < 1:
            raise ValueError("End ionic step cannot be less than 1")

        lattice = self.structures[0].lattice
        if np.linalg.det(lattice.matrix) < 0:
            lattice = Lattice(-lattice.matrix)
        lines = [self.comment, "1.0", str(lattice)]
        lines.extend((" ".join(self.site_symbols), " ".join(map(str, self.natoms))))

        format_str = f"{{:.{significant_figures}f}}"
        ionicstep_cnt = 1
        output_cnt = 1
        for cnt, structure in enumerate(self.structures, start=1):
            ionicstep_cnt = cnt
            if (
                (ionicstep_end is None and ionicstep_cnt >= ionicstep_start)
                or ionicstep_start <= ionicstep_cnt < ionicstep_end  # type: ignore[operator]
            ):
                lines.append(f"Direct configuration={' ' * (7 - len(str(output_cnt)))}{output_cnt}")
                for site in structure:
                    coords = site.frac_coords
                    line = " ".join(format_str.format(c) for c in coords)
                    lines.append(line)
                output_cnt += 1
        return "\n".join(lines) + "\n"

    def write_file(self, filename: PathLike, **kwargs) -> None:
        """Write Xdatcar into a file.

        Args:
            filename (PathLike): The output XDATCAR file.
            **kwargs: The same as those for the Xdatcar.get_str
                method and are passed through directly.
        """
        with zopen(filename, mode="wt", encoding="utf-8") as file:
            file.write(self.get_str(**kwargs))  # type:ignore[arg-type]


class Dynmat:
    """DYNMAT file reader.

    Attributes:
        data (dict): A nested dict containing the DYNMAT data of the form:
            [atom <int>][disp <int>]['dispvec'] =
                displacement vector (part of first line in dynmat block, e.g. "0.01 0 0")
            [atom <int>][disp <int>]['dynmat'] =
                <list> list of dynmat lines for this atom and this displacement

    Authors: Patrick Huck
    """

    def __init__(self, filename: PathLike) -> None:
        """Initialize a Dynmat.

        Args:
            filename: Name of file containing DYNMAT.
        """
        with zopen(filename, mode="rt", encoding="utf-8") as file:
            lines: list[str] = list(clean_lines(file.readlines()))  # type:ignore[arg-type]
            self._nspecs, self._natoms, self._ndisps = map(int, lines[0].split())
            self._masses = map(float, lines[1].split())
            self.data: dict[int, dict] = defaultdict(dict)
            atom, disp = None, None
            for idx, line in enumerate(lines[2:]):
                v = list(map(float, line.split()))
                if not idx % (self._natoms + 1):
                    atom, disp = map(int, v[:2])
                    if atom not in self.data:
                        self.data[atom] = {}
                    if disp not in self.data[atom]:
                        self.data[atom][disp] = {}
                    self.data[atom][disp]["dispvec"] = v[2:]
                else:
                    if "dynmat" not in self.data[atom][disp]:  # type: ignore[index]
                        self.data[atom][disp]["dynmat"] = []  # type: ignore[index]
                    self.data[atom][disp]["dynmat"].append(v)  # type: ignore[index]

    def get_phonon_frequencies(self) -> list[float]:
        """Calculate phonon frequencies.

        WARNING: This method is most likely incorrect or suboptimal,
            hence for demonstration purposes only.

        Returns:
            list[float]: phonon frequencies.
        """
        frequencies: list[float] = []
        for k, v0 in self.data.items():
            for v1 in v0.values():
                vec = map(abs, v1["dynmat"][k - 1])
                frequency = math.sqrt(sum(vec)) * 2.0 * math.pi * 15.633302  # THz
                frequencies.append(frequency)
        return frequencies

    @property
    def nspecs(self) -> int:
        """The number of species."""
        return self._nspecs

    @property
    def natoms(self) -> int:
        """The number of atoms."""
        return self._natoms

    @property
    def ndisps(self) -> int:
        """The number of displacements."""
        return self._ndisps

    @property
    def masses(self) -> list[float]:
        """The list of atomic masses."""
        return list(self._masses)


def get_adjusted_fermi_level(
    efermi: float,
    cbm: float,
    band_structure: BandStructureSymmLine,
    energy_step: float = 0.01,
) -> float:
    """
    When running a band structure computation, the Fermi level needs to be
    taken from the static run that gave the charge density used for the non-self
    consistent band structure run. Sometimes this Fermi level is too low
    because of the mismatch between the uniform grid used in the static run
    and the band structure k-points (e.g., the VBM is on Gamma and the Gamma
    point is not in the uniform mesh).

    Here we use a procedure looking for energy levels higher than the static
    Fermi level and lower than the LUMO. If any of these levels make the
    band structure appears insulating (not metallic anymore), we keep this
    adjusted fermi level.

    This procedure has shown to detect most insulators correctly.

    Args:
        efermi (float): The Fermi energy of the static run.
        cbm (float): The conduction band minimum of the static run.
        band_structure (BandStructureSymmLine): A band structure object.
        energy_step (float): The step length for increasing energy during search.

    Returns:
        float: A new adjusted Fermi level.
    """
    # Make a working copy of band_structure
    bs_working = BandStructureSymmLine.from_dict(band_structure.as_dict())

    if bs_working.is_metal():
        energy = efermi
        while energy < cbm:
            energy += energy_step
            bs_working._efermi = energy
            if not bs_working.is_metal():
                return energy
    return efermi


# A note to future confused people (i.e. myself):
# I use numpy.fromfile instead of scipy.io.FortranFile here because the records
# are of fixed length, so the record length is only written once. In fortran,
# this amounts to using open(..., form='unformatted', recl=recl_len). In
# contrast when you write UNK files, the record length is written at the
# beginning of each record. This allows you to use scipy.io.FortranFile. In
# fortran, this amounts to using open(..., form='unformatted') [i.e. no recl=].
class Wavecar:
    """
    Container for the (pseudo-) wavefunctions from VASP.

    Coefficients are read from the given WAVECAR file and the corresponding
    G-vectors are generated using the algorithm developed in WaveTrans (see
    acknowledgments below). To understand how the wavefunctions are evaluated,
    please see the evaluate_wavefunc docstring.

    It should be noted that the pseudopotential augmentation is not included in
    the WAVECAR file. As a result, some caution should be exercised when
    deriving value from this information.

    The usefulness of this class is to allow the user to do projections or band
    unfolding style manipulations of the wavefunction. An example of this can
    be seen in the work of Shen et al. 2017
    (https://doi.org/10.1103/PhysRevMaterials.1.065001).

    Attributes:
        vasp_type ("std" | "gam" | "ncl"): The VASP type WAVECAR was generated with.
        nk (int): Number of k-points from the WAVECAR.
        nb (int): Number of bands per k-point.
        encut (float): Energy cutoff (used to define G_{cut}).
        efermi (float): Fermi energy.
        a (NDArray): Primitive lattice vectors of the cell (e.g. a_1 = self.a[0, :]).
        b (NDArray): Reciprocal lattice vectors of the cell (e.g. b_1 = self.b[0, :]).
        vol (float): The volume of the unit cell in real space.
        kpoints (NDArray): A list of k-points read from the WAVECAR file.
        band_energy (list): A list of band eigenenergies (and corresponding occupancies) for each kpoint,
            where the first index corresponds to the index of the k-point (e.g. self.band_energy[kp]).
        Gpoints (list): A list of generated G-points for each k-point (a double list), which
            are used with the coefficients for each k-point and band to recreate
            the wavefunction (e.g. self.Gpoints[kp] is the list of G-points for
            k-point kp). The G-points depend on the k-point and reciprocal lattice
            and therefore are identical for each band at the same k-point. Each
            G-point is represented by integer multipliers (e.g. assuming
            Gpoints[kp][n] == [n_1, n_2, n_3], then
            G_n = n_1*b_1 + n_2*b_2 + n_3*b_3)
        coeffs (list): A list of coefficients for each k-point and band for reconstructing the wavefunction.
            For non-spin-polarized, the first index corresponds to the kpoint and the second corresponds to the band
            (e.g. self.coeffs[kp][b] corresponds to k-point kp and band b). For spin-polarized calculations,
            the first index is for the spin. If the calculation was non-collinear, then self.coeffs[kp][b] will have
            two columns (one for each component of the spinor).

    Acknowledgments:
        This code is based upon the Fortran program, WaveTrans, written by
        R. M. Feenstra and M. Widom from the Dept. of Physics at Carnegie
        Mellon University. To see the original work, please visit:
        https://www.andrew.cmu.edu/user/feenstra/wavetrans/

    Author: Mark Turiansky
    """

    def __init__(
        self,
        filename: PathLike = "WAVECAR",
        verbose: bool = False,
        precision: Literal["normal", "accurate"] = "normal",
        vasp_type: Literal["std", "gam", "ncl"] | None = None,
    ) -> None:
        """Extract information from the given WAVECAR.

        Args:
            filename (PathLike): input file. Defaults to WAVECAR.
            verbose (bool): determines whether processing information is shown
            precision (str): determines how fine the fft mesh is (normal or
                accurate), only the first letter matters.
            vasp_type (str): determines the VASP type that is used, allowed
                values are {'std', 'gam', 'ncl'} (only first letter is required).
        """
        self.filename = filename
        valid_types = {"std", "gam", "ncl"}
        initials = {x[0] for x in valid_types}
        if vasp_type is not None and vasp_type.lower()[0] not in initials:
            raise ValueError(
                f"invalid {vasp_type=}, must be one of {valid_types} (we only check the first letter {initials})"
            )
        self.vasp_type = vasp_type

        # c = 0.26246582250210965422
        # 2m/hbar^2 in agreement with VASP
        self._C = 0.262465831
        with open(self.filename, "rb") as file:
            # Read the header information
            recl, spin, rtag = np.fromfile(file, dtype=np.float64, count=3).astype(int)
            if verbose:
                logger.info(f"{recl=}, {spin=}, {rtag=}")
            recl8 = int(recl / 8)
            self.spin = spin

            # Make sure we have correct precision
            valid_rtags = {45200, 45210, 53300, 53310}
            if rtag not in valid_rtags:
                # note that rtag=45200 and 45210 may not work if file was actually
                # generated by old version of VASP, since that would write eigenvalues
                # and occupations in way that does not span FORTRAN records, but
                # reader below appears to assume that record boundaries can be ignored
                # (see OUTWAV vs. OUTWAV_4 in VASP fileio.F)
                raise ValueError(f"Invalid {rtag=}, must be one of {valid_rtags}")

            # Pad to end of fortran REC=1
            np.fromfile(file, dtype=np.float64, count=recl8 - 3)

            # Extract kpoint, bands, energy, and lattice information
            self.nk, self.nb = np.fromfile(file, dtype=np.float64, count=2).astype(int)
            self.encut = np.fromfile(file, dtype=np.float64, count=1)[0]
            self.a = np.fromfile(file, dtype=np.float64, count=9).reshape((3, 3))
            self.efermi = np.fromfile(file, dtype=np.float64, count=1)[0]
            if verbose:
                logger.info(
                    f"kpoints = {self.nk}, bands = {self.nb}, energy cutoff = {self.encut}, fermi "
                    f"energy= {self.efermi:.04f}\n"
                )
                logger.info(f"primitive lattice vectors = \n{self.a}")

            self.vol = np.dot(self.a[0, :], np.cross(self.a[1, :], self.a[2, :]))
            if verbose:
                logger.info(f"volume = {self.vol}\n")

            # Calculate reciprocal lattice
            b = np.array(
                [
                    np.cross(self.a[1, :], self.a[2, :]),
                    np.cross(self.a[2, :], self.a[0, :]),
                    np.cross(self.a[0, :], self.a[1, :]),
                ]
            )
            b = 2 * np.pi * b / self.vol
            self.b = b
            if verbose:
                logger.info(f"reciprocal lattice vectors = \n{b}")
                logger.info(f"reciprocal lattice vector magnitudes = \n{np.linalg.norm(b, axis=1)}\n")

            # Calculate maximum number of b vectors in each direction
            self._generate_nbmax()
            if verbose:
                logger.info(f"max number of G values = {self._nbmax}\n\n")
            self.ng = self._nbmax * 3 if precision.lower()[0] == "n" else self._nbmax * 4

            # Pad to end of fortran REC=2
            np.fromfile(file, dtype=np.float64, count=recl8 - 13)

            # Read records
            self.Gpoints = [None for _ in range(self.nk)]
            self.kpoints = []

            # mypy typing
            self.coeffs: list[list[list[Any]]] | list[list[Any]]
            self.band_energy: list[Any]
            if spin == 2:
                self.coeffs = [[[None for _ in range(self.nb)] for _ in range(self.nk)] for _ in range(spin)]
                self.band_energy = [[] for _ in range(spin)]
            else:
                self.coeffs = [[None for _ in range(self.nb)] for _ in range(self.nk)]
                self.band_energy = []

            for i_spin in range(spin):
                if verbose:
                    logger.info(f"Reading spin {i_spin}")

                for i_nk in range(self.nk):
                    # Information for this kpoint
                    nplane = int(np.fromfile(file, dtype=np.float64, count=1)[0])
                    kpoint = np.fromfile(file, dtype=np.float64, count=3)

                    if i_spin == 0:
                        self.kpoints.append(kpoint)
                    elif not np.allclose(self.kpoints[i_nk], kpoint, rtol=1e-7, atol=0):
                        raise ValueError(f"kpoints of {i_nk=} mismatch")

                    if verbose:
                        logger.info(f"kpoint {i_nk: 4} with {nplane: 5} plane waves at {kpoint}")

                    # Energy and occupation information
                    enocc = np.fromfile(file, dtype=np.float64, count=3 * self.nb).reshape((self.nb, 3))
                    if spin == 2:
                        self.band_energy[i_spin].append(enocc)
                    else:
                        self.band_energy.append(enocc)

                    if verbose:
                        logger.info("enocc =\n%s", enocc[:, [0, 2]])

                    # Pad the end of record that contains nplane, kpoints, evals and occs
                    np.fromfile(file, dtype=np.float64, count=(recl8 - 4 - 3 * self.nb) % recl8)

                    if self.vasp_type is None:
                        self.Gpoints[i_nk], extra_gpoints, extra_coeff_inds = self._generate_G_points(  # type: ignore[call-overload]
                            kpoint, gamma=True
                        )
                        if len(self.Gpoints[i_nk]) == nplane:  # type: ignore[arg-type]
                            self.vasp_type = "gam"
                        else:
                            self.Gpoints[i_nk], extra_gpoints, extra_coeff_inds = self._generate_G_points(  # type: ignore[call-overload]
                                kpoint, gamma=False
                            )
                            self.vasp_type = "std" if len(self.Gpoints[i_nk]) == nplane else "ncl"  # type: ignore[arg-type]

                        if verbose:
                            logger.info(f"\ndetermined {self.vasp_type = }\n")
                    else:
                        self.Gpoints[i_nk], extra_gpoints, extra_coeff_inds = self._generate_G_points(  # type: ignore[call-overload]
                            kpoint, gamma=self.vasp_type.lower()[0] == "g"
                        )

                    if len(self.Gpoints[i_nk]) != nplane and 2 * len(self.Gpoints[i_nk]) != nplane:  # type: ignore[arg-type]
                        raise ValueError(
                            f"Incorrect {vasp_type=}. Please open an issue if you are certain this WAVECAR"
                            " was generated with the given vasp_type."
                        )

                    self.Gpoints[i_nk] = np.array(self.Gpoints[i_nk] + extra_gpoints, dtype=np.float64)  # type: ignore[arg-type, operator]

                    # Extract coefficients
                    for inb in range(self.nb):
                        if rtag in (45200, 53300):
                            data = np.fromfile(file, dtype=np.complex64, count=nplane)
                            np.fromfile(file, dtype=np.float64, count=recl8 - nplane)
                        elif rtag in (45210, 53310):
                            # TODO: This should handle double precision coefficients,
                            # but I don't have a WAVECAR to test it with
                            data = np.fromfile(file, dtype=np.complex128, count=nplane)
                            np.fromfile(file, dtype=np.float64, count=recl8 - 2 * nplane)
                        else:
                            raise RuntimeError("Invalid rtag value.")

                        extra_coeffs = []
                        if len(extra_coeff_inds) > 0:
                            # Reconstruct extra coefficients missing from gamma-only executable WAVECAR
                            for G_ind in extra_coeff_inds:
                                # No idea where this factor of sqrt(2) comes from,
                                # but empirically it appears to be necessary
                                data[G_ind] /= np.sqrt(2)
                                extra_coeffs.append(np.conj(data[G_ind]))

                        if spin == 2:
                            self.coeffs[i_spin][i_nk][inb] = np.array(list(data) + extra_coeffs, dtype=np.complex64)  # type: ignore[index]
                        else:
                            self.coeffs[i_nk][inb] = np.array(list(data) + extra_coeffs, dtype=np.complex128)

                        if self.vasp_type is not None and self.vasp_type.lower()[0] == "n":
                            self.coeffs[i_nk][inb].shape = (2, nplane // 2)  # type: ignore[union-attr]

    def _generate_nbmax(self) -> None:
        """Helper function to determine maximum number of b vectors for
        each direction.

        This algorithm is adapted from WaveTrans (see Class docstring).
        """
        bmag = np.linalg.norm(self.b, axis=1)
        b = self.b

        # Calculate maximum integers in each direction for G
        phi12 = np.arccos(np.dot(b[0, :], b[1, :]) / (bmag[0] * bmag[1]))
        sphi123 = np.dot(b[2, :], np.cross(b[0, :], b[1, :])) / (bmag[2] * np.linalg.norm(np.cross(b[0, :], b[1, :])))
        nbmaxA = np.sqrt(self.encut * self._C) / bmag
        nbmaxA[0] /= np.abs(np.sin(phi12))
        nbmaxA[1] /= np.abs(np.sin(phi12))
        nbmaxA[2] /= np.abs(sphi123)
        nbmaxA += 1

        phi13 = np.arccos(np.dot(b[0, :], b[2, :]) / (bmag[0] * bmag[2]))
        sphi123 = np.dot(b[1, :], np.cross(b[0, :], b[2, :])) / (bmag[1] * np.linalg.norm(np.cross(b[0, :], b[2, :])))
        nbmaxB = np.sqrt(self.encut * self._C) / bmag
        nbmaxB[0] /= np.abs(np.sin(phi13))
        nbmaxB[1] /= np.abs(sphi123)
        nbmaxB[2] /= np.abs(np.sin(phi13))
        nbmaxB += 1

        phi23 = np.arccos(np.dot(b[1, :], b[2, :]) / (bmag[1] * bmag[2]))
        sphi123 = np.dot(b[0, :], np.cross(b[1, :], b[2, :])) / (bmag[0] * np.linalg.norm(np.cross(b[1, :], b[2, :])))
        nbmaxC = np.sqrt(self.encut * self._C) / bmag
        nbmaxC[0] /= np.abs(sphi123)
        nbmaxC[1] /= np.abs(np.sin(phi23))
        nbmaxC[2] /= np.abs(np.sin(phi23))
        nbmaxC += 1

        self._nbmax = np.max([nbmaxA, nbmaxB, nbmaxC], axis=0).astype(int)

    def _generate_G_points(
        self,
        kpoint: NDArray,
        gamma: bool = False,
    ) -> tuple[list, list, list]:
        """Helper method to generate G-points based on nbmax.

        This function iterates over possible G-point values and determines
        if the energy is less than G_{cut}. Valid values are appended to
        the output array. This function should not be called outside of
        initialization.

        Args:
            kpoint (NDArray): The current k-point value.
            gamma (bool): determines if G points for gamma-point only executable
                          should be generated.

        Returns:
            tuple[list, list, list]: Valid G-points
        """
        kmax = self._nbmax[0] + 1 if gamma else 2 * self._nbmax[0] + 1

        gpoints = []
        extra_gpoints = []
        extra_coeff_inds = []
        G_ind = 0
        for i in range(2 * self._nbmax[2] + 1):
            i3 = i - 2 * self._nbmax[2] - 1 if i > self._nbmax[2] else i
            for j in range(2 * self._nbmax[1] + 1):
                j2 = j - 2 * self._nbmax[1] - 1 if j > self._nbmax[1] else j
                for k in range(kmax):
                    k1 = k - 2 * self._nbmax[0] - 1 if k > self._nbmax[0] else k
                    if gamma and ((k1 == 0 and j2 < 0) or (k1 == 0 and j2 == 0 and i3 < 0)):
                        continue
                    G = np.array([k1, j2, i3])
                    v = kpoint + G
                    g = np.linalg.norm(np.dot(v, self.b))
                    E = g**2 / self._C
                    if self.encut > E:
                        gpoints.append(G)
                        if gamma and (k1, j2, i3) != (0, 0, 0):
                            extra_gpoints.append(-G)
                            extra_coeff_inds.append(G_ind)
                        G_ind += 1
        return gpoints, extra_gpoints, extra_coeff_inds

    def evaluate_wavefunc(
        self,
        kpoint: int,
        band: int,
        r: NDArray,
        spin: int = 0,
        spinor: int = 0,
    ) -> np.complex64:
        r"""Evaluate the wavefunction for a given position, r.

        The wavefunction is given by the k-point and band. It is evaluated
        at the given position by summing over the components. Formally,

        \psi_n^k (r) = \sum_{i=1}^N c_i^{n,k} \exp (i (k + G_i^{n,k}) \cdot r)

        where \psi_n^k is the wavefunction for the nth band at k-point k, N is
        the number of plane waves, c_i^{n,k} is the ith coefficient that
        corresponds to the nth band and k-point k, and G_i^{n,k} is the ith
        G-point corresponding to k-point k.

        NOTE: This function is very slow; a discrete fourier transform is the
        preferred method of evaluation (see Wavecar.fft_mesh).

        Args:
            kpoint (int): the index of the kpoint where the wavefunction will be evaluated.
            band (int): the index of the band where the wavefunction will be evaluated.
            r (NDArray): the position where the wavefunction will be evaluated.
            spin (int): spin index for the desired wavefunction (only for
                ISPIN = 2, default = 0).
            spinor (int): component of the spinor that is evaluated (only used
                if vasp_type == 'ncl').

        Returns:
            A complex value corresponding to the evaluation of the wavefunction.
        """
        if self.vasp_type is None:
            raise RuntimeError("vasp_type cannot be None.")

        v = self.Gpoints[kpoint] + self.kpoints[kpoint]
        u = np.dot(np.dot(v, self.b), r)

        if self.vasp_type.lower()[0] == "n":
            c = self.coeffs[kpoint][band][spinor, :]  # type: ignore[call-overload, index]
        elif self.spin == 2:
            c = self.coeffs[spin][kpoint][band]  # type: ignore[index]
        else:
            c = self.coeffs[kpoint][band]
        return np.sum(np.dot(c, np.exp(1j * u, dtype=np.complex64))) / np.sqrt(self.vol)

    def fft_mesh(
        self,
        kpoint: int,
        band: int,
        spin: int = 0,
        spinor: int = 0,
        shift: bool = True,
    ) -> NDArray:
        """Place the coefficients of a wavefunction onto an fft mesh.

        Once the mesh has been obtained, a discrete fourier transform can be
        used to obtain real-space evaluation of the wavefunction. The output
        of this function can be passed directly to numpy's fft function. For
        example:

            mesh = Wavecar('WAVECAR').fft_mesh(kpoint, band)
            evals = np.fft.ifftn(mesh)

        Args:
            kpoint (int): the index of the kpoint where the wavefunction will be evaluated
            band (int): the index of the band where the wavefunction will be evaluated
            spin (int): the spin of the wavefunction for the desired
                wavefunction (only for ISPIN = 2, default = 0)
            spinor (int): component of the spinor that is evaluated (only used
                if vasp_type == 'ncl')
            shift (bool): determines if the zero frequency coefficient is
                placed at index (0, 0, 0) or centered

        Returns:
            a numpy ndarray representing the 3D mesh of coefficients
        """
        if self.vasp_type is None:
            raise RuntimeError("vasp_type cannot be None.")

        if self.vasp_type.lower()[0] == "n":
            tcoeffs = self.coeffs[kpoint][band][spinor, :]  # type: ignore[call-overload, index]
        elif self.spin == 2:
            tcoeffs = self.coeffs[spin][kpoint][band]  # type: ignore[index]
        else:
            tcoeffs = self.coeffs[kpoint][band]

        mesh = np.zeros(tuple(self.ng), dtype=np.complex128)
        for gp, coeff in zip(self.Gpoints[kpoint], tcoeffs, strict=False):  # type: ignore[call-overload]
            t = tuple(gp.astype(int) + (self.ng / 2).astype(int))
            mesh[t] = coeff

        return np.fft.ifftshift(mesh) if shift else mesh

    def get_parchg(
        self,
        poscar: Poscar,
        kpoint: int,
        band: int,
        spin: int | None = None,
        spinor: int | None = None,
        phase: bool = False,
        scale: int = 2,
    ) -> Chgcar:
        """Generate a Chgcar object, which is the charge density of the specified
        wavefunction.

        This function generates a Chgcar object with the charge density of the
        wavefunction specified by band and kpoint (and spin, if the WAVECAR
        corresponds to a spin-polarized calculation). The phase tag is a
        feature that is not present in VASP. For a real wavefunction, the phase
        tag being turned on means that the charge density is multiplied by the
        sign of the wavefunction at that point in space. A warning is generated
        if the phase tag is on and the chosen kpoint is not Gamma.

        Note: Augmentation from the PAWs is NOT included in this function. The
        maximal charge density will differ from the PARCHG from VASP, but the
        qualitative shape of the charge density will match.

        Args:
            poscar (pymatgen.io.vasp.inputs.Poscar): Poscar object that has the
                structure associated with the WAVECAR file
            kpoint (int): the index of the kpoint for the wavefunction
            band (int): the index of the band for the wavefunction
            spin (int): optional argument to specify the spin. If the Wavecar
                has ISPIN = 2, spin is None generates a Chgcar with total spin
                and magnetization, and spin == {0, 1} specifies just the spin
                up or down component.
            spinor (int): optional argument to specify the spinor component
                for noncollinear data wavefunctions (allowed values of None,
                0, or 1)
            phase (bool): flag to determine if the charge density is multiplied
                by the sign of the wavefunction. Only valid for real
                wavefunctions.
            scale (int): scaling for the FFT grid. The default value of 2 is at
                least as fine as the VASP default.

        Returns:
            A Chgcar object.
        """
        if phase and not np.allclose(self.kpoints[kpoint], 0.0):
            warnings.warn(
                "phase is True should only be used for the Gamma kpoint! I hope you know what you're doing!",
                stacklevel=2,
            )

        # Scaling of ng for the fft grid, need to restore value at the end
        temp_ng = self.ng
        self.ng = self.ng * scale
        N = np.prod(self.ng)

        data = {}
        if self.spin == 2:
            if spin is not None:
                wfr = np.fft.ifftn(self.fft_mesh(kpoint, band, spin=spin)) * N
                den = np.abs(np.conj(wfr) * wfr)
                if phase:
                    den = np.sign(np.real(wfr)) * den
                data["total"] = den
            else:
                wfr = np.fft.ifftn(self.fft_mesh(kpoint, band, spin=0)) * N
                denup = np.abs(np.conj(wfr) * wfr)
                wfr = np.fft.ifftn(self.fft_mesh(kpoint, band, spin=1)) * N
                dendn = np.abs(np.conj(wfr) * wfr)
                data["total"] = denup + dendn
                data["diff"] = denup - dendn
        else:
            if spinor is not None:
                wfr = np.fft.ifftn(self.fft_mesh(kpoint, band, spinor=spinor)) * N
                den = np.abs(np.conj(wfr) * wfr)
            else:
                wfr = np.fft.ifftn(self.fft_mesh(kpoint, band, spinor=0)) * N
                wfr_t = np.fft.ifftn(self.fft_mesh(kpoint, band, spinor=1)) * N
                den = np.abs(np.conj(wfr) * wfr)
                den += np.abs(np.conj(wfr_t) * wfr_t)

            if self.vasp_type is None:
                raise RuntimeError("vasp_type cannot be None.")

            if phase and (self.vasp_type.lower()[0] != "n" or spinor is not None):
                den = np.sign(np.real(wfr)) * den
            data["total"] = den

        self.ng = temp_ng
        return Chgcar(poscar, data)

    def write_unks(self, directory: PathLike) -> None:
        """Write the UNK files to the given directory.

        Write the cell-periodic part of the Bloch wavefunctions from the
        WAVECAR file to each of the UNK files. There will be one UNK file for
        each of the kpoints in the WAVECAR file.

        Note:
            Wannier90 expects the full kpoint grid instead of the symmetry-
            reduced one that VASP stores the wavefunctions on. You should run
            a nscf calculation with ISYM=0 to obtain the correct grid.

        Args:
            directory (PathLike): directory to write the UNK files.
        """
        out_dir = Path(directory).expanduser()
        if not out_dir.exists():
            out_dir.mkdir(parents=False)
        elif not out_dir.is_dir():
            raise ValueError("invalid directory")

        if self.vasp_type is None:
            raise RuntimeError("vasp_type cannot be None.")

        N = np.prod(self.ng)
        for ik in range(self.nk):
            fname = f"UNK{ik + 1:05d}."
            if self.vasp_type.lower()[0] == "n":
                data = np.empty((self.nb, 2, *self.ng), dtype=np.complex128)
                for ib in range(self.nb):
                    data[ib, 0, :, :, :] = np.fft.ifftn(self.fft_mesh(ik, ib, spinor=0)) * N
                    data[ib, 1, :, :, :] = np.fft.ifftn(self.fft_mesh(ik, ib, spinor=1)) * N
                Unk(ik + 1, data).write_file(str(out_dir / f"{fname}NC"))
            else:
                data = np.empty((self.nb, *self.ng), dtype=np.complex128)
                for ispin in range(self.spin):
                    for ib in range(self.nb):
                        data[ib, :, :, :] = np.fft.ifftn(self.fft_mesh(ik, ib, spin=ispin)) * N
                    Unk(ik + 1, data).write_file(str(out_dir / f"{fname}{ispin + 1}"))


class Eigenval:
    """EIGENVAL file reader.

    Attributes:
        filename (PathLike): The input file.
        occu_tol (float): Tolerance for determining occupation in band properties.
        ispin (int): Spin polarization tag.
        nelect (int): Number of electrons.
        nkpt (int): Number of kpoints.
        nbands (int): Number of bands.
        kpoints (list): List of kpoints.
        kpoints_weights (list): Weights of each kpoint in the BZ, should sum to 1.
        eigenvalues (dict): Eigenvalues as a dict of {(spin): NDArray(shape=(nkpt, nbands, 2))}.
            This representation is based on actual ordering in VASP and is meant as an intermediate representation
            to be converted into proper objects. The kpoint index is 0-based (unlike the 1-based indexing in VASP).
    """

    def __init__(
        self,
        filename: PathLike,
        occu_tol: float = 1e-8,
        separate_spins: bool = False,
    ) -> None:
        """Read input from filename to construct Eigenval object.

        Args:
            filename (PathLike): filename of EIGENVAL to read.
            occu_tol (float): tolerance for determining band gap.
            separate_spins (bool): whether the band gap, CBM, and VBM should be
                reported for each individual spin channel. Defaults to False,
                which computes the eigenvalue band properties independent of
                the spin orientation. If True, the calculation must be spin-polarized.
        """
        self.filename = filename
        self.occu_tol = occu_tol
        self.separate_spins = separate_spins

        with zopen(filename, mode="rt", encoding="utf-8") as file:
            self.ispin = int(file.readline().split()[-1])

            # Remove useless header information
            for _ in range(4):
                file.readline()

            self.nelect, self.nkpt, self.nbands = list(map(int, file.readline().split()))

            self.kpoints = []
            self.kpoints_weights = []
            if self.ispin == 2:
                self.eigenvalues = {
                    Spin.up: np.zeros((self.nkpt, self.nbands, 2)),
                    Spin.down: np.zeros((self.nkpt, self.nbands, 2)),
                }
            else:
                self.eigenvalues = {Spin.up: np.zeros((self.nkpt, self.nbands, 2))}

            ikpt = -1
            for line in file:
                if re.search(r"(\s+[\-+0-9eE.]+){4}", str(line)):
                    ikpt += 1
                    kpt = list(map(float, line.split()))
                    self.kpoints.append(kpt[:-1])
                    self.kpoints_weights.append(kpt[-1])
                    for i in range(self.nbands):
                        sl = list(map(float, file.readline().split()))
                        if len(sl) == 3:
                            self.eigenvalues[Spin.up][ikpt, i, 0] = sl[1]
                            self.eigenvalues[Spin.up][ikpt, i, 1] = sl[2]
                        elif len(sl) == 5:
                            self.eigenvalues[Spin.up][ikpt, i, 0] = sl[1]
                            self.eigenvalues[Spin.up][ikpt, i, 1] = sl[3]
                            self.eigenvalues[Spin.down][ikpt, i, 0] = sl[2]
                            self.eigenvalues[Spin.down][ikpt, i, 1] = sl[4]

    @property
    def eigenvalue_band_properties(
        self,
    ) -> (
        tuple[float, float, float, bool]
        | tuple[
            tuple[float, float],
            tuple[float, float],
            tuple[float, float],
            tuple[bool, bool],
        ]
    ):
        """Band properties from the eigenvalues as a tuple of
        (band gap, cbm, vbm, is_band_gap_direct).
        In the case of separate_spins=True,
        the band gap, cbm, vbm, and is_band_gap_direct are each tuples of 2,
        with index 0 representing the spin-up channel and index 1 representing
        the spin-down channel.
        """
        vbm = -float("inf")
        vbm_kpoint = None
        cbm = float("inf")
        cbm_kpoint = None
        vbm_spins = []
        vbm_spins_kpoints = []
        cbm_spins = []
        cbm_spins_kpoints = []
        if self.separate_spins and len(self.eigenvalues) != 2:
            raise ValueError("The separate_spins flag can only be True if ISPIN = 2")

        for d in self.eigenvalues.values():
            if self.separate_spins:
                vbm = -float("inf")
                cbm = float("inf")
            for k, val in enumerate(d):
                for eigenval, occu in val:
                    if occu > self.occu_tol and eigenval > vbm:
                        vbm = eigenval
                        vbm_kpoint = k
                    elif occu <= self.occu_tol and eigenval < cbm:
                        cbm = eigenval
                        cbm_kpoint = k
            if self.separate_spins:
                vbm_spins.append(vbm)
                vbm_spins_kpoints.append(vbm_kpoint)
                cbm_spins.append(cbm)
                cbm_spins_kpoints.append(cbm_kpoint)

        if self.separate_spins:
            return (
                (
                    max(cbm_spins[0] - vbm_spins[0], 0),
                    max(cbm_spins[1] - vbm_spins[1], 0),
                ),
                (cbm_spins[0], cbm_spins[1]),
                (vbm_spins[0], vbm_spins[1]),
                (
                    vbm_spins_kpoints[0] == cbm_spins_kpoints[0],
                    vbm_spins_kpoints[1] == cbm_spins_kpoints[1],
                ),
            )
        return max(cbm - vbm, 0), cbm, vbm, vbm_kpoint == cbm_kpoint


@dataclass
class Waveder(MSONable):
    """Representation of the WAVEDER file.

    The LOPTICS tag produces a WAVEDER file which contains the derivative of the orbitals with respect to k.
    Since the data is complex, we need to split it into the real and imaginary parts for JSON serialization.

    Note:
        The way that VASP writes the WAVEDER and WAVEDERF has slightly different logic when indexing the bands.
        This results in the formatted WAVDERF only indexing between filled bands. (i.e. all the matrix elements
        are between the states i=1:8 and j=1:8 in a two atom Si calculation, which is likely a VASP bug).
        As such, it is recommended to used the hidden ``LVEL=.True.`` flag in VASP which will force indexing over
        all bands.

    The order of the indices of the data are:
        [
            band index1,
            band index2,
            kpoint index,
            spin index,
            cartesian direction,
        ]

    Attributes:
        cder_real: Real part of the derivative of the orbitals with respect to k.
        cder_imag: Imaginary part of the derivative of the orbitals with respect to k.

    Author: Miguel Dias Costa, Kamal Choudhary, Jimmy-Xuan Shen
    """

    cder_real: NDArray
    cder_imag: NDArray

    @classmethod
    def from_formatted(cls, filename: PathLike) -> Self:
        """Read the WAVEDERF file.

        Note: This file is only produced when LOPTICS is true and VASP has been
        recompiled after uncommenting the line that calls
        WRT_CDER_BETWEEN_STATES_FORMATTED in linear_optics.F.

        It is recommended to use `from_binary` instead since the binary file is
        much smaller and contains the same information.

        Args:
            filename (PathLike): The name of the WAVEDER file.

        Returns:
            A Waveder object.
        """
        with zopen(filename, mode="rt", encoding="utf-8") as file:
            nspin, nkpts, nbands = file.readline().split()
        # 1 and 4 are the eigenvalues of the bands (this data is missing in the WAVEDER file)
        # 6:12 are the complex matrix elements in each cartesian direction.
        data = np.loadtxt(filename, skiprows=1, usecols=(1, 4, 6, 7, 8, 9, 10, 11))
        data = data.reshape(int(nspin), int(nkpts), int(nbands), int(nbands), 8)  # slowest to fastest
        cder_real = data[:, :, :, :, 2::2]
        cder_imag = data[:, :, :, :, 3::2]
        # Change to [band1, band2, kpt, spin, cartesian]
        cder_real = np.transpose(cder_real, (2, 3, 1, 0, 4))
        cder_imag = np.transpose(cder_imag, (2, 3, 1, 0, 4))
        # TODO: add eigenvalues?
        return cls(cder_real, cder_imag)

    @classmethod
    def from_binary(
        cls,
        filename: PathLike,
        data_type: Literal["complex64", "float64", "float32"] = "complex64",
    ) -> Self:
        """Read the WAVEDER file.

        Args:
            filename: Name of file containing WAVEDER.
            data_type: Data type of the WAVEDER file. Default is complex64.
                If the file was generated with the "gamma" version of VASP,
                the data type can be either "float64" or "float32".

        Returns:
            Waveder object.
        """

        def read_data(dtype):
            """Read records from Fortran binary file and convert to np.array of given dtype."""
            data = b""
            while True:
                prefix = np.fromfile(file, dtype=np.int32, count=1)[0]
                data += file.read(abs(prefix))
                suffix = np.fromfile(file, dtype=np.int32, count=1)[0]
                if abs(prefix) - abs(suffix):
                    raise RuntimeError(
                        f"Read wrong amount of bytes.\nExpected: {prefix}, read: {len(data)}, suffix: {suffix}."
                    )
                if prefix > 0:
                    break
            return np.frombuffer(data, dtype=dtype)

        with open(filename, "rb") as file:
            nbands, nelect, nk, ispin = read_data(np.int32)
            _ = read_data(np.float64)  # nodes_in_dielectric_function
            _ = read_data(np.float64)  # wplasmon
            me_datatype = np.dtype(data_type)
            cder = read_data(me_datatype)

            cder_data = cder.reshape((3, ispin, nk, nelect, nbands)).T
            return cls(cder_data.real, cder_data.imag)

    @property
    def cder(self) -> NDArray:
        """The complex derivative of the orbitals with respect to k."""
        if self.cder_real.shape[0] != self.cder_real.shape[1]:
            warnings.warn(
                "Not all band pairs are present in the WAVEDER file."
                "If you want to get all the matrix elements set LVEL=.True. in the INCAR.",
                stacklevel=2,
            )
        return self.cder_real + 1j * self.cder_imag

    @property
    def nspin(self) -> int:
        """The number of spin channels."""
        return self.cder_real.shape[3]

    @property
    def nkpoints(self) -> int:
        """The number of k-points."""
        return self.cder_real.shape[2]

    @property
    def nbands(self) -> int:
        """The number of bands."""
        return self.cder_real.shape[0]

    def get_orbital_derivative_between_states(
        self,
        band_i: int,
        band_j: int,
        kpoint: int,
        spin: Literal[0, 1],
        cart_dir: Literal[0, 1, 2],
    ) -> float:
        """
        Get a float between bands band_i and band_j for k-point index,
        spin-channel and Cartesian direction.

        Args:
            band_i (int): Index of band i
            band_j (int): Index of band j
            kpoint (int): Index of k-point
            spin (int): Spin-channel (0 or 1)
            cart_dir (int): Index of Cartesian direction (0, 1, 2)

        Returns:
            a float value
        """
        idx = (band_i, band_j, kpoint, spin, cart_dir)
        return self.cder_real[idx] + 1j * self.cder_imag[idx]


@dataclass
class WSWQ(MSONable):
    r"""Read a WSWQ file.
    The WSWQ file is used to calculation the wave function overlaps between:
        - W: Wavefunctions in the current directory's WAVECAR file.
        - WQ: Wavefunctions stored in the WAVECAR.qqq file.

    The overlap is computed using the overlap operator S
    which make the PAW wavefunctions orthogonormal:
        <W_k,m| S | W_k,n> = \delta_{mn}

    The WSWQ file contains matrix elements of the overlap operator S evaluated
    between the planewave wavefunctions W and WQ:
        COVL_k,mn = < W_s,k,m | S | WQ_s,k,n >

    The indices of WSWQ.data are:
        [spin][kpoint][band_i][band_j]

    Attributes:
        nspin: Number of spin channels
        nkpoints: Number of k-points
        nbands: Number of bands
        me_real: Real part of the overlap matrix elements
        me_imag: Imaginary part of the overlap matrix elements
    """

    nspin: int
    nkpoints: int
    nbands: int
    me_real: NDArray
    me_imag: NDArray

    @property
    def data(self) -> NDArray:
        """Complex overlap matrix."""
        return self.me_real + 1j * self.me_imag

    @classmethod
    def from_file(cls, filename: str) -> Self:
        """Construct a WSWQ object from a file.

        Args:
            filename (str): Name of WSWQ file.

        Returns:
            WSWQ object.
        """
        # Grab the spin and kpoint values from lines containing "spin"
        spin_res = regrep(
            filename,
            {"spin": r"spin\s*=\s*(\d+)\s?\,\s?kpoint\s*=\s*(\d+)"},
            reverse=True,
            terminate_on_match=True,
            postprocess=int,
        )["spin"]
        (nspin, nkpoints), _ = spin_res[0]

        # Grab the index values from the parts with "i    =" and "j    ="
        ij_res = regrep(
            filename,
            {"ij": r"i\s*=\s*(\d+)\s?\,\s?j\s*=\s*(\d+)"},
            reverse=True,
            terminate_on_match=True,
            postprocess=int,
        )["ij"]
        (nbands, _), _ = ij_res[0]

        # Grab the matrix elements from the parts after the ":"
        data_res = regrep(
            filename,
            {"data": r"\:\s*([-+]?\d*\.\d+)\s+([-+]?\d*\.\d+)"},
            reverse=False,
            terminate_on_match=False,
            postprocess=float,
        )["data"]
        if len(data_res) != nspin * nkpoints * nbands * nbands:
            raise ValueError("incorrect length of data_res")

        data = np.array([complex(real_part, img_part) for (real_part, img_part), _ in data_res])

        # NOTE: loop order (slow->fast) spin -> kpoint -> j -> i
        data = data.reshape((nspin, nkpoints, nbands, nbands))
        data = np.swapaxes(data, 2, 3)  # swap i and j
        return cls(
            nspin=nspin,
            nkpoints=nkpoints,
            nbands=nbands,
            me_real=np.real(data),
            me_imag=np.imag(data),
        )


class UnconvergedVASPWarning(Warning):
    """Warning for unconverged VASP run."""


# Development note for Vaspwave:
# - Keep the public API aligned with Wavecar where practical.
# - Keep HDF5 access helpers separate from wavefunction reconstruction helpers.
# - File-backed metadata access should stay in the `_parse_*`, `_read_*`, and
#   `_get_*metadata` helpers.
# - Reciprocal-space and real-space reconstruction should stay in the
#   `_initialize_*state`, coefficient-conversion, mesh-building, and IFFT
#   helpers.
# - When extending support (for example spin-polarized or non-gamma files),
#   prefer adding capability checks in one place rather than scattering
#   conditionals across multiple public methods.
# - VASP writes `vaspwave.h5` wave datasets through the same `MRG_PW_BAND` /
#   `MRG_PW_BAND_GAMMA` merge path used for legacy `WAVECAR`, so std and ncl
#   datasets are already stored in the public Wavecar-style canonical layout.
#   The only coefficient reconstruction currently needed here is the gamma-only
#   symmetry completion.
@requires(h5py is not None, "h5py must be installed to read vaspwave.h5")
class Vaspwave(Vasprun):
    """
    Class to read vaspwave.h5 files.

    This class is intended as an HDF5-native companion to Wavecar. The current
    implementation supports gamma-only, std, and ncl wavefunction access with a
    public interface that matches Wavecar where practical. Files without a
    ``/wave`` group can still be used to read native charge-density and
    local-potential grids.

    Examples:
        Read native charge-density and local-potential grids from
        ``vaspwave.h5``:

        >>> vaspwave = Vaspwave("vaspwave.h5")
        >>> chgcar = vaspwave.get_chgcar()
        >>> locpot = vaspwave.get_locpot()
        >>> chgcar.write_file("CHGCAR")
        >>> locpot.write_file("LOCPOT")

        Use wavefunction data, when the file contains a ``/wave`` group, with
        a Wavecar-like interface:

        >>> coeffs = vaspwave.get_band_coeffs(0, 0, 0)
        >>> mesh = vaspwave.fft_mesh(0, 0)

        Generate a partial charge density from a selected wavefunction:

        >>> poscar = Poscar.from_file("POSCAR")
        >>> parchg = vaspwave.get_parchg(poscar, 0, 0)
    """

    def __init__(self, filename: str | Path) -> None:
        self.filename = str(filename)
        self._wave_path_map: dict[tuple[int, int], str] = {}
        self._has_wavefunction_data = False
        self._gamma_only = False
        self._C = 0.262465831
        self._parse()

    # -------------------------------------------------------------------------
    # Parse and initialization
    # -------------------------------------------------------------------------
    @classmethod
    def _parse_hdf5_value(cls, val: Any) -> Any:
        """Parse HDF5 values recursively, turning them into Python objects."""
        if hasattr(val, "items"):
            val = {k: cls._parse_hdf5_value(v) for k, v in val.items()}
        else:
            val = np.array(val).tolist()
            if isinstance(val, bytes):
                val = val.decode()
            elif isinstance(val, list):
                val = [cls._parse_hdf5_value(x) for x in val]
        return val

    def _parse_file_metadata(self) -> dict[str, Any]:
        """Read raw metadata needed to initialize a ``Vaspwave`` object.

        This method only extracts file-backed metadata and does not derive any
        wavefunction reconstruction state. Files without a ``/wave`` group
        return only version and structure metadata.

        Returns:
            dict[str, Any]: Parsed metadata including version information,
                structure data, and a ``has_wavefunction_data`` flag. When a
                ``/wave`` group exists, the metadata also includes wavefunction
                dimensions, cutoff, lattice matrix, and Fermi energy.
        """
        with zopen(self.filename, "rb") as vwave_file, h5py.File(vwave_file, "r") as h5_file:
            version = self._parse_hdf5_value(h5_file["version"])
            structure = self._parse_hdf5_structure(h5_file)
            if "wave" not in h5_file:
                return {
                    "version": version,
                    "structure": structure,
                    "has_wavefunction_data": False,
                }

            wave_group = h5_file["wave"]
            self._register_wave_paths(wave_group)

            return {
                "version": version,
                "structure": structure,
                "has_wavefunction_data": True,
                "spin": int(self._parse_hdf5_value(wave_group["rispin"])),
                "nk": int(self._parse_hdf5_value(wave_group["rnkpts"])),
                "nb": int(self._parse_hdf5_value(wave_group["rnb_tot"])),
                "encut": float(self._parse_hdf5_value(wave_group["enmax"])),
                "a": np.array(self._parse_hdf5_value(wave_group["amat"]), dtype=float),
                "efermi": float(self._parse_hdf5_value(wave_group["efermi"])),
            }

    def _initialize_kpoint_state(self) -> None:
        """Initialize per-k-point metadata used by wavefunction reconstruction."""
        # `Vasprun.kpoints` is typed as `Kpoints`; `Vaspwave` overrides with a list of
        # k-point vectors. Annotate explicitly so mypy doesn't see the parent type.
        self.kpoints: list[np.ndarray] = []  # type: ignore[assignment]
        self.num_planewaves: list[int] = []
        self.band_energy: list[Any]
        if self.spin == 2:
            self.band_energy = [[] for _ in range(self.spin)]
        else:
            self.band_energy = []

        for ik in range(self.nk):
            kpoint_data = self._get_kpoint_metadata(0, ik)
            self.kpoints.append(np.array(kpoint_data["vkpt"], dtype=float))
            self.num_planewaves.append(int(kpoint_data["num_planewaves"]))
            if self.spin == 2:
                for ispin in range(self.spin):
                    spin_kpoint_data = self._get_kpoint_metadata(ispin, ik)
                    self.band_energy[ispin].append(self._build_band_energy_array(spin_kpoint_data))
            else:
                self.band_energy.append(self._build_band_energy_array(kpoint_data))

    def _initialize_reconstruction_state(self) -> None:
        """Initialize reciprocal-space state used to reconstruct wavefunctions."""
        self._generate_nbmax()
        self.ng = self._nbmax * 3
        self._gamma_only = self._is_gamma_only()
        # Each entry is replaced by an ndarray below; declare the union so mypy
        # accepts both the placeholder and the populated state.
        self.Gpoints: list[np.ndarray | None] = [None for _ in range(self.nk)]
        self._gamma_extra_coeff_inds: list[list[int]] = [[] for _ in range(self.nk)]

        for ik, kpoint in enumerate(self.kpoints):
            if self._gamma_only:
                gpoints, extra_gpoints, extra_coeff_inds = self._generate_G_points(kpoint, gamma=True)
                self.Gpoints[ik] = np.array(gpoints + extra_gpoints, dtype=np.float64)
                self._gamma_extra_coeff_inds[ik] = extra_coeff_inds
            else:
                gpoints, _, _ = self._generate_G_points(kpoint, gamma=False)
                if len(gpoints) != self.num_planewaves[ik] and 2 * len(gpoints) != self.num_planewaves[ik]:
                    raise ValueError(
                        f"Generated {len(gpoints)} G-points for kpoint {ik}, expected {self.num_planewaves[ik]}."
                    )
                self.Gpoints[ik] = np.array(gpoints, dtype=np.float64)

        # Every entry has been populated above; the cast tells mypy the None
        # placeholders are gone before we measure lengths.
        populated_gpoints = cast("list[np.ndarray]", self.Gpoints)
        if self._gamma_only:
            self.vasp_type = "gam"
        elif all(
            2 * len(gpoints) == num_pw for gpoints, num_pw in zip(populated_gpoints, self.num_planewaves, strict=False)
        ):
            self.vasp_type = "ncl"
        else:
            self.vasp_type = "std"

    def _initialize_wave_state(self, metadata: dict[str, Any]) -> None:
        """Populate derived state from parsed file metadata.

        Args:
            metadata (dict[str, Any]): Raw metadata returned by
                ``_parse_file_metadata()``.
        """
        self.version = metadata["version"]
        self.structure = metadata["structure"]
        self._has_wavefunction_data = metadata["has_wavefunction_data"]
        if not self._has_wavefunction_data:
            return

        self.spin = metadata["spin"]
        self.nk = metadata["nk"]
        self.nb = metadata["nb"]
        self.encut = metadata["encut"]
        self.efermi = metadata["efermi"]
        self.a = metadata["a"]
        self.b, self.vol = self._compute_reciprocal_lattice_and_volume(self.a)
        self._initialize_kpoint_state()
        self._initialize_reconstruction_state()

    def _parse(self) -> None:
        """Parse ``vaspwave.h5`` metadata and initialize reconstruction state."""
        metadata = self._parse_file_metadata()
        self._initialize_wave_state(metadata)

    # -------------------------------------------------------------------------
    # HDF5 access helpers
    # -------------------------------------------------------------------------
    @contextmanager
    def _open_hdf5_file(self):
        """Open ``vaspwave.h5`` and yield an active HDF5 handle."""
        with zopen(self.filename, "rb") as vwave_file, h5py.File(vwave_file, "r") as h5_file:
            yield h5_file

    def _read_hdf5_dataset(self, dataset_path: str, h5_file=None) -> np.ndarray:
        """Read a dataset from ``vaspwave.h5`` into a NumPy array."""
        if h5_file is not None:
            return np.array(h5_file[dataset_path])
        with self._open_hdf5_file() as opened_h5_file:
            return np.array(opened_h5_file[dataset_path])

    def _read_hdf5_dataset_slice(self, dataset_path: str, key: Any, h5_file=None) -> np.ndarray:
        """Read a slice from a dataset in ``vaspwave.h5`` into a NumPy array."""
        if h5_file is not None:
            return np.array(h5_file[dataset_path][key])
        with self._open_hdf5_file() as opened_h5_file:
            return np.array(opened_h5_file[dataset_path][key])

    def _register_wave_paths(self, wave_group) -> None:
        """Register lazy-read paths to per-spin/per-kpoint wavefunction datasets.

        Args:
            wave_group: Open HDF5 group corresponding to ``/wave``.
        """
        for ispin in range(int(self._parse_hdf5_value(wave_group["rispin"]))):
            spin_key = f"spin_{ispin + 1}"
            if spin_key not in wave_group:
                continue
            for ik in range(int(self._parse_hdf5_value(wave_group["rnkpts"]))):
                kpoint_key = f"kpoint_{ik + 1}"
                if kpoint_key not in wave_group[spin_key]:
                    continue
                self._wave_path_map[(ispin, ik)] = f"/wave/{spin_key}/{kpoint_key}/wave"

    def _get_kpoint_metadata(self, spin_index: int, kpoint_index: int, h5_file=None) -> dict[str, Any]:
        """Read metadata for a single spin/k-point block.

        Args:
            spin_index (int): Zero-based spin index.
            kpoint_index (int): Zero-based k-point index.

        Returns:
            dict[str, Any]: Parsed metadata for the selected spin/k-point block.
        """
        group_path = f"/wave/spin_{spin_index + 1}/kpoint_{kpoint_index + 1}"
        if h5_file is None:
            with self._open_hdf5_file() as opened_h5_file:
                return self._get_kpoint_metadata(spin_index, kpoint_index, h5_file=opened_h5_file)

        kpoint_group = h5_file[group_path]
        return {
            "vkpt": self._parse_hdf5_value(kpoint_group["vkpt"]),
            "fertot": self._parse_hdf5_value(kpoint_group["fertot"]),
            "celtot": self._parse_hdf5_value(kpoint_group["celtot"]),
            "num_planewaves": self._parse_hdf5_value(kpoint_group["num_planewaves"]),
        }

    def _get_wave_dataset_path(self, spin_index: int, kpoint_index: int) -> str:
        """Get the HDF5 dataset path for a spin/k-point wavefunction block."""
        if (spin_index, kpoint_index) not in self._wave_path_map:
            raise KeyError(f"No wavefunction dataset registered for spin={spin_index}, kpoint={kpoint_index}.")
        return self._wave_path_map[(spin_index, kpoint_index)]

    # -------------------------------------------------------------------------
    # Shared structure / metadata transforms
    # -------------------------------------------------------------------------
    @staticmethod
    def _build_band_energy_array(kpoint_data: dict[str, Any]) -> np.ndarray:
        """Build the Wavecar-like band energy array from celtot and fertot."""
        celtot = np.array(kpoint_data["celtot"], dtype=float)
        fertot = np.array(kpoint_data["fertot"], dtype=float)
        return np.column_stack((celtot[:, 0], celtot[:, 1], fertot))

    @staticmethod
    def _parse_structure(positions: dict[str, Any]) -> Structure:
        """Parse the positions-style structure layout used by ``vaspwave.h5``.

        The expected dictionary layout is shared by ``/structure/positions``
        and ``/locpot/position``. Required keys are ``ion_types``,
        ``number_ion_types``, ``scale``, ``lattice_vectors``,
        ``position_ions``, and ``direct_coordinates``.
        """
        species = []
        for ispecie, specie in enumerate(positions["ion_types"]):
            species += [specie for _ in range(positions["number_ion_types"][ispecie])]

        return Structure(
            lattice=Lattice(positions["scale"] * np.array(positions["lattice_vectors"])),
            species=species,
            coords=positions["position_ions"],
            coords_are_cartesian=(positions["direct_coordinates"] != 1),
        )

    @staticmethod
    def _get_structure_mismatch(structure: Structure, other: Structure) -> str | None:
        """Return the first mismatched field between two VASPwave structures."""
        if list(structure.species) != list(other.species):
            return "species"
        if not np.allclose(structure.lattice.matrix, other.lattice.matrix):
            return "lattice"
        if not np.allclose(structure.frac_coords, other.frac_coords):
            return "fractional coordinates"
        return None

    @classmethod
    def _parse_hdf5_structure(cls, h5_file: H5File | H5Group) -> Structure | None:
        """Read structure metadata from ``/structure/positions`` or ``/locpot/position``.

        If both paths are present, they are expected to describe the same
        structure, allowing small floating-point differences in lattice and
        fractional coordinates.
        """
        structure_paths = ("/structure/positions", "/locpot/position")
        structures = [
            cls._parse_structure(cls._parse_hdf5_value(h5_file[path])) for path in structure_paths if path in h5_file
        ]

        if not structures:
            return None

        structure = structures[0]
        # VASP normally writes the same structure to both branches; tolerate
        # small floating-point differences if both are present.
        for other in structures[1:]:
            mismatch = cls._get_structure_mismatch(structure, other)
            if mismatch is not None:
                raise ValueError(
                    "vaspwave.h5 contains inconsistent structures at /structure/positions and /locpot/position "
                    f"({mismatch} differ)."
                )
        return structure

    def _get_volumetric_structure(self) -> Structure:
        """Get the structure object used to construct volumetric VASP outputs."""
        if self.structure is None:
            raise ValueError(
                "vaspwave.h5 does not contain structure data at /structure/positions or /locpot/position. Attach a "
                "structure manually by setting vaspwave.structure before calling this method."
            )
        return self.structure

    def _require_wavefunction_data(self) -> None:
        """Raise a clear error if this file does not contain wavefunction data."""
        if not self._has_wavefunction_data:
            raise ValueError("vaspwave.h5 does not contain wavefunction data at /wave.")

    @staticmethod
    def _compute_reciprocal_lattice_and_volume(a: np.ndarray) -> tuple[np.ndarray, float]:
        """Compute reciprocal lattice vectors and real-space cell volume."""
        vol = np.dot(a[0, :], np.cross(a[1, :], a[2, :]))
        b = (
            2
            * np.pi
            * np.array(
                [
                    np.cross(a[1, :], a[2, :]),
                    np.cross(a[2, :], a[0, :]),
                    np.cross(a[0, :], a[1, :]),
                ]
            )
            / vol
        )
        return b, vol

    # -------------------------------------------------------------------------
    # Reconstruction-state initialization
    # -------------------------------------------------------------------------
    def _generate_nbmax(self) -> None:
        """Determine maximum number of reciprocal vectors for each direction."""
        bmag = np.linalg.norm(self.b, axis=1)
        b = self.b

        phi12 = np.arccos(np.dot(b[0, :], b[1, :]) / (bmag[0] * bmag[1]))
        sphi123 = np.dot(b[2, :], np.cross(b[0, :], b[1, :])) / (bmag[2] * np.linalg.norm(np.cross(b[0, :], b[1, :])))
        nbmaxA = np.sqrt(self.encut * self._C) / bmag
        nbmaxA[0] /= np.abs(np.sin(phi12))
        nbmaxA[1] /= np.abs(np.sin(phi12))
        nbmaxA[2] /= np.abs(sphi123)
        nbmaxA += 1

        phi13 = np.arccos(np.dot(b[0, :], b[2, :]) / (bmag[0] * bmag[2]))
        sphi123 = np.dot(b[1, :], np.cross(b[0, :], b[2, :])) / (bmag[1] * np.linalg.norm(np.cross(b[0, :], b[2, :])))
        nbmaxB = np.sqrt(self.encut * self._C) / bmag
        nbmaxB[0] /= np.abs(np.sin(phi13))
        nbmaxB[1] /= np.abs(sphi123)
        nbmaxB[2] /= np.abs(np.sin(phi13))
        nbmaxB += 1

        phi23 = np.arccos(np.dot(b[1, :], b[2, :]) / (bmag[1] * bmag[2]))
        sphi123 = np.dot(b[0, :], np.cross(b[1, :], b[2, :])) / (bmag[0] * np.linalg.norm(np.cross(b[1, :], b[2, :])))
        nbmaxC = np.sqrt(self.encut * self._C) / bmag
        nbmaxC[0] /= np.abs(sphi123)
        nbmaxC[1] /= np.abs(np.sin(phi23))
        nbmaxC[2] /= np.abs(np.sin(phi23))
        nbmaxC += 1

        self._nbmax = np.max([nbmaxA, nbmaxB, nbmaxC], axis=0).astype(int)

    def _generate_G_points(
        self,
        kpoint: NDArray,
        gamma: bool = False,
    ) -> tuple[list, list, list]:
        """Generate reciprocal lattice vectors used for wavefunction reconstruction."""
        kmax = self._nbmax[0] + 1 if gamma else 2 * self._nbmax[0] + 1

        gpoints = []
        extra_gpoints = []
        extra_coeff_inds = []
        G_ind = 0
        for i in range(2 * self._nbmax[2] + 1):
            i3 = i - 2 * self._nbmax[2] - 1 if i > self._nbmax[2] else i
            for j in range(2 * self._nbmax[1] + 1):
                j2 = j - 2 * self._nbmax[1] - 1 if j > self._nbmax[1] else j
                for k in range(kmax):
                    k1 = k - 2 * self._nbmax[0] - 1 if k > self._nbmax[0] else k
                    if gamma and ((k1 == 0 and j2 < 0) or (k1 == 0 and j2 == 0 and i3 < 0)):
                        continue
                    G = np.array([k1, j2, i3])
                    v = kpoint + G
                    g = np.linalg.norm(np.dot(v, self.b))
                    E = g**2 / self._C
                    if self.encut > E:
                        gpoints.append(G)
                        if gamma and (k1, j2, i3) != (0, 0, 0):
                            extra_gpoints.append(-G)
                            extra_coeff_inds.append(G_ind)
                        G_ind += 1
        return gpoints, extra_gpoints, extra_coeff_inds

    def _is_gamma_only(self) -> bool:
        """Check whether the current file matches the gamma-only implementation."""
        if self.spin != 1 or self.nk != 1:
            return False
        if not all(np.allclose(kpoint, 0.0, atol=1e-8) for kpoint in self.kpoints):
            return False

        gamma_gpoints, _, _ = self._generate_G_points(self.kpoints[0], gamma=True)
        return self.num_planewaves[0] == len(gamma_gpoints)

    # -------------------------------------------------------------------------
    # Wave reconstruction helpers
    # -------------------------------------------------------------------------
    @staticmethod
    def _normalize_index(index: int, size: int, label: str) -> int:
        """Normalize Python-style negative indices and validate bounds."""
        if index < 0:
            index += size
        if not 0 <= index < size:
            raise IndexError(f"{label} index {index} out of range for {size} {label.lower()}s.")
        return index

    def _normalize_wave_indices(self, kpoint: int, band: int) -> tuple[int, int]:
        """Normalize Python-style wavefunction indices and validate bounds.

        Args:
            kpoint (int): K-point index, allowing negative Python-style values.
            band (int): Band index, allowing negative Python-style values.

        Returns:
            tuple[int, int]: Normalized non-negative ``(kpoint, band)`` indices.
        """
        return self._normalize_index(kpoint, self.nk, "Kpoint"), self._normalize_index(band, self.nb, "Band")

    def _normalize_spin_index(self, spin_index: int) -> int:
        """Normalize and validate a collinear spin index."""
        return self._normalize_index(spin_index, self.spin, "Spin")

    def _validate_spin_and_spinor_args(self, spin: int = 0, spinor: int = 0) -> tuple[int, int]:
        """Validate supported spin and spinor arguments for wavefunction APIs."""
        if self.vasp_type not in {"gam", "std", "ncl"}:
            raise NotImplementedError("Unsupported vaspwave.h5 type.")
        if self.spin not in {1, 2}:
            raise NotImplementedError("Unsupported vaspwave.h5 spin setting.")
        if self.vasp_type == "ncl":
            if spin != 0:
                raise NotImplementedError("Spin-resolved ncl vaspwave.h5 is not implemented yet.")
            spinor = self._normalize_index(spinor, 2, "Spinor")
            return 0, spinor

        if spinor != 0:
            raise NotImplementedError("Spinor-resolved vaspwave.h5 is not implemented yet.")
        return self._normalize_spin_index(spin), 0

    @staticmethod
    def _transpose_volumetric_component(component: np.ndarray) -> np.ndarray:
        """Transpose one HDF5 volumetric component into pymatgen grid order."""
        return np.transpose(component, (2, 1, 0))

    @staticmethod
    def _build_spin_polarized_volumetric_data(data: np.ndarray) -> dict[str, np.ndarray]:
        """Build collinear spin-polarized volumetric data from a two-component HDF5 grid."""
        total = Vaspwave._transpose_volumetric_component(data[0])
        diff = Vaspwave._transpose_volumetric_component(data[1])
        return {"total": total, "diff": diff}

    @staticmethod
    def _build_noncollinear_volumetric_data(data: np.ndarray) -> dict[str, np.ndarray]:
        """Build noncollinear volumetric data from a four-component HDF5 grid."""
        total = Vaspwave._transpose_volumetric_component(data[0])
        diff_x = Vaspwave._transpose_volumetric_component(data[1])
        diff_y = Vaspwave._transpose_volumetric_component(data[2])
        diff_z = Vaspwave._transpose_volumetric_component(data[3])
        ref_sign = np.sign(diff_x * 1.01 + diff_y * 1.02 + diff_z * 1.03)
        diff = np.sqrt(diff_x**2 + diff_y**2 + diff_z**2) * ref_sign
        return {"total": total, "diff_x": diff_x, "diff_y": diff_y, "diff_z": diff_z, "diff": diff}

    @staticmethod
    def _validate_volumetric_dataset(grid: np.ndarray, data: np.ndarray, dataset_name: str) -> dict[str, np.ndarray]:
        """Validate a volumetric dataset and return pymatgen-formatted data dict.

        Args:
            grid (np.ndarray): Grid dimensions stored alongside the dataset.
            data (np.ndarray): Raw volumetric dataset read from HDF5.
            dataset_name (str): Full dataset path used for error messages.

        Returns:
            dict[str, np.ndarray]: Volumetric data in pymatgen's standard key layout.
        """
        if data.ndim != 4:
            raise ValueError(f"Expected {dataset_name} to have 4 dimensions, got shape {data.shape}.")
        if tuple(grid.tolist()) != tuple(data.shape[1:][::-1]):
            dataset_parent = dataset_name.rsplit("/", 1)[0]
            grid_dataset_name = f"{dataset_parent}/grid"
            raise ValueError(
                f"{grid_dataset_name} {tuple(grid.tolist())} does not match {dataset_name} shape {data.shape[1:]}."
            )

        if data.shape[0] == 1:
            return {"total": Vaspwave._transpose_volumetric_component(data[0])}
        if data.shape[0] == 2:
            return Vaspwave._build_spin_polarized_volumetric_data(data)
        if data.shape[0] == 4:
            return Vaspwave._build_noncollinear_volumetric_data(data)

        raise NotImplementedError(f"Unsupported {dataset_name} component count {data.shape[0]}.")

    def _read_validated_volumetric_dataset(self, grid_path: str, data_path: str, h5_file=None) -> dict[str, np.ndarray]:
        """Read and validate a volumetric HDF5 dataset."""
        grid = self._read_hdf5_dataset(grid_path, h5_file=h5_file)
        data = self._read_hdf5_dataset(data_path, h5_file=h5_file)
        return self._validate_volumetric_dataset(grid, data, data_path)

    def _read_charge_augmentation_data(self) -> dict[str, dict[int, np.ndarray]]:
        """Read charge augmentation occupancies from ``vaspwave.h5``."""
        data_aug: dict[str, dict[int, np.ndarray]] = {}

        with self._open_hdf5_file() as h5_file:
            if "/charge/charge" not in h5_file:
                return data_aug

            component_count = h5_file["/charge/charge"].shape[0]
            data_keys: tuple[str, ...]
            if component_count == 1:
                data_keys = ("total",)
            elif component_count == 2:
                data_keys = ("total", "diff")
            elif component_count == 4:
                data_keys = ("total", "diff_x", "diff_y", "diff_z")
            else:
                if any(name.startswith("aug_occupancies") for name in h5_file["/charge"]):
                    warnings.warn(
                        f"Unsupported /charge/charge component count {component_count}; "
                        "skipping charge augmentation occupancies.",
                        stacklevel=2,
                    )
                return data_aug

            for idx, data_key in enumerate(data_keys, start=1):
                ions_path = f"/charge/aug_occupancies_ions_s{idx:02d}"
                values_path = f"/charge/aug_occupancies_s{idx:02d}"
                if ions_path not in h5_file or values_path not in h5_file:
                    continue

                lengths = np.array(h5_file[ions_path])
                values = np.array(h5_file[values_path])
                data_aug[data_key] = {
                    ion_idx: values[ion_idx - 1, :length]
                    for ion_idx, length in enumerate(lengths.astype(int), start=1)
                    if length
                }

        return data_aug

    # -------------------------------------------------------------------------
    # Coefficient conversion pipeline
    # -------------------------------------------------------------------------
    #
    # ``vaspwave.h5`` stores serialized wavefunction rows that must be decoded
    # into complex coefficients before they are exposed through the public API.
    # ``Vaspwave`` keeps that raw/decode/canonical boundary explicit so public
    # methods continue to return coefficients aligned with ``self.Gpoints``.
    #
    # For the currently supported data:
    # - std and ncl rows already map onto the canonical coefficient layout
    # - gamma-only rows still require symmetry completion
    #
    def _read_raw_band_coeffs(self, spin_index: int, kpoint_index: int, band_index: int, h5_file=None) -> np.ndarray:
        """Read one raw band row from the HDF5 wave dataset."""
        dataset_path = self._get_wave_dataset_path(spin_index, kpoint_index)
        return self._read_hdf5_dataset_slice(dataset_path, (band_index, slice(None), slice(None)), h5_file=h5_file)

    @staticmethod
    def _decode_raw_band_coeffs(coeffs_re_im: np.ndarray) -> np.ndarray:
        """Decode a raw ``(nplane, 2)`` HDF5 row into serialized complex coefficients."""
        if coeffs_re_im.ndim != 2 or coeffs_re_im.shape[1] != 2:
            raise ValueError("vaspwave.h5 wave coefficients must have shape (nplane, 2).")
        return coeffs_re_im[:, 0] + 1j * coeffs_re_im[:, 1]

    def _to_canonical_band_coeffs(self, coeffs: np.ndarray, kpoint_index: int) -> np.ndarray:
        """Convert serialized HDF5 coefficients into canonical coefficients.

        Args:
            coeffs (np.ndarray): Serialized complex coefficients read from the
                HDF5 ``wave`` dataset.
            kpoint_index (int): Zero-based k-point index.

        Returns:
            np.ndarray: Canonical complex coefficients aligned with
                ``self.Gpoints[kpoint_index]``.
        """
        if self.vasp_type == "ncl":
            gpoints = self.Gpoints[kpoint_index]
            if gpoints is None:
                raise RuntimeError(f"G-points for kpoint {kpoint_index} have not been initialized.")
            if len(coeffs) != 2 * len(gpoints):
                raise ValueError("Serialized ncl coefficients do not match the expected spinor-packed size.")
            return np.array(coeffs, dtype=np.complex128).reshape((2, len(gpoints)))

        if not self._gamma_only:
            # `vhdf5.F` writes the HDF5 wave dataset after `MRG_PW_BAND`,
            # matching the canonical coefficient layout exposed by Wavecar.
            return np.array(coeffs, dtype=np.complex128)

        coeffs = np.array(coeffs, copy=True)
        extra_coeffs = []
        for g_ind in self._gamma_extra_coeff_inds[kpoint_index]:
            coeffs[g_ind] /= np.sqrt(2)
            extra_coeffs.append(np.conj(coeffs[g_ind]))
        return np.array(list(coeffs) + extra_coeffs, dtype=np.complex128)

    def _build_mesh_from_coeffs(self, kpoint: int, coeffs: np.ndarray, shift: bool = True) -> np.ndarray:
        """Place complex coefficients onto the FFT mesh for a given k-point."""
        gpoints = self.Gpoints[kpoint]
        if gpoints is None:
            raise RuntimeError(f"G-points for kpoint {kpoint} have not been initialized.")
        if len(coeffs) != len(gpoints):
            raise ValueError("Number of coefficients does not match the number of generated G-points.")

        mesh = np.zeros(tuple(self.ng), dtype=np.complex128)
        for gp, coeff in zip(gpoints, coeffs, strict=False):
            t = tuple(gp.astype(int) + (self.ng / 2).astype(int))
            mesh[t] = coeff
        return np.fft.ifftshift(mesh) if shift else mesh

    def _get_band_coeffs(self, spin_index: int, kpoint_index: int, band_index: int, h5_file=None) -> np.ndarray:
        """Internal helper to read one band of canonical plane-wave coefficients."""
        coeffs_re_im = self._read_raw_band_coeffs(spin_index, kpoint_index, band_index, h5_file=h5_file)
        coeffs = self._decode_raw_band_coeffs(coeffs_re_im)
        return self._to_canonical_band_coeffs(coeffs, kpoint_index)

    def _fft_mesh(
        self, kpoint: int, band: int, spin: int = 0, spinor: int = 0, shift: bool = True, h5_file=None
    ) -> np.ndarray:
        """Internal helper to place coefficients onto an FFT mesh."""
        tcoeffs = self._get_band_coeffs(spin, kpoint, band, h5_file=h5_file)
        if self.vasp_type == "ncl":
            tcoeffs = tcoeffs[spinor]
        return self._build_mesh_from_coeffs(kpoint, tcoeffs, shift=shift)

    def _ifft_wavefunction(self, kpoint: int, band: int, spin: int = 0, spinor: int = 0, h5_file=None) -> np.ndarray:
        """Reconstruct a real-space wavefunction on the current FFT grid."""
        coeffs = self._get_band_coeffs(spin, kpoint, band, h5_file=h5_file)
        if self.vasp_type == "ncl":
            coeffs = coeffs[spinor]
        N = np.prod(self.ng)
        return np.fft.ifftn(self._build_mesh_from_coeffs(kpoint, coeffs)) * N

    # -------------------------------------------------------------------------
    # Public API
    # -------------------------------------------------------------------------
    def get_band_coeffs(self, spin_index: int, kpoint_index: int, band_index: int) -> np.ndarray:
        """Read a single band of plane-wave coefficients from ``vaspwave.h5``.

        Note:
            ``Vaspwave`` reads serialized band rows from the HDF5 dataset and
            converts them into canonical coefficients exposed through the
            ``Wavecar``-like public API. For std and ncl files, the HDF5 rows
            already store the canonical coefficient layout written by VASP.
            Gamma-only files reconstruct the symmetry-related coefficients
            explicitly.

        Args:
            spin_index (int): Spin index. For spin-polarized std data,
                ``0`` and ``1`` select the two collinear spin channels. For
                gamma-only, spin-unpolarized std, and ncl data, only ``0`` is
                supported.
            kpoint_index (int): K-point index of the desired wavefunction.
            band_index (int): Band index of the desired wavefunction.

        Returns:
            np.ndarray: Complex plane-wave coefficients for the requested
                wavefunction.
        """
        self._require_wavefunction_data()
        if self.vasp_type not in {"gam", "std", "ncl"}:
            raise NotImplementedError("Unsupported vaspwave.h5 type.")
        if self.spin not in {1, 2}:
            raise NotImplementedError("Unsupported vaspwave.h5 spin setting.")
        spin_index = self._normalize_spin_index(spin_index)
        kpoint_index, band_index = self._normalize_wave_indices(kpoint_index, band_index)
        return self._get_band_coeffs(spin_index, kpoint_index, band_index)

    def fft_mesh(
        self,
        kpoint: int,
        band: int,
        spin: int = 0,
        spinor: int = 0,
        shift: bool = True,
    ) -> np.ndarray:
        """Place supported ``vaspwave.h5`` coefficients onto an FFT mesh.

        Args:
            kpoint (int): K-point index of the desired wavefunction.
            band (int): Band index of the desired wavefunction.
            spin (int): Spin index. For spin-polarized std data, ``0`` and
                ``1`` select the two collinear spin channels. For gamma-only,
                spin-unpolarized std, and ncl data, only ``0`` is supported.
            spinor (int): Spinor component index. Supported for ``ncl`` files;
                otherwise only ``0`` is supported.
            shift (bool): Whether to shift the zero-frequency component to the
                origin of the FFT mesh.

        Returns:
            np.ndarray: Complex FFT mesh containing the wavefunction
                coefficients.
        """
        self._require_wavefunction_data()
        spin, spinor = self._validate_spin_and_spinor_args(spin=spin, spinor=spinor)
        kpoint, band = self._normalize_wave_indices(kpoint, band)
        return self._fft_mesh(kpoint, band, spin=spin, spinor=spinor, shift=shift)

    def evaluate_wavefunc(
        self,
        kpoint: int,
        band: int,
        r: np.ndarray,
        spin: int = 0,
        spinor: int = 0,
    ) -> np.complex64:
        """Evaluate a supported ``vaspwave.h5`` wavefunction at position ``r``.

        Args:
            kpoint (int): K-point index of the desired wavefunction.
            band (int): Band index of the desired wavefunction.
            r (np.ndarray): Real-space position at which to evaluate the
                wavefunction.
            spin (int): Spin index. For spin-polarized std data, ``0`` and
                ``1`` select the two collinear spin channels. For gamma-only,
                spin-unpolarized std, and ncl data, only ``0`` is supported.
            spinor (int): Spinor component index. Supported for ``ncl`` files;
                otherwise only ``0`` is supported.

        Returns:
            np.complex64: Wavefunction value evaluated at ``r``.
        """
        self._require_wavefunction_data()
        spin, spinor = self._validate_spin_and_spinor_args(spin=spin, spinor=spinor)
        kpoint, band = self._normalize_wave_indices(kpoint, band)
        v = self.Gpoints[kpoint] + self.kpoints[kpoint]
        u = np.dot(np.dot(v, self.b), r)
        coeffs = self.get_band_coeffs(spin, kpoint, band)
        if self.vasp_type == "ncl":
            coeffs = coeffs[spinor]
        return np.sum(np.dot(coeffs, np.exp(1j * u, dtype=np.complex64))) / np.sqrt(self.vol)

    def get_parchg(
        self,
        poscar: Poscar,
        kpoint: int,
        band: int,
        spin: int | None = None,
        spinor: int | None = None,
        phase: bool = False,
        scale: int = 2,
    ) -> Chgcar:
        """Generate a ``Chgcar`` object for supported ``vaspwave.h5`` data.

        Note:
            As with ``Wavecar.get_parchg()``, PAW augmentation is not included.

        Args:
            poscar (Poscar): Structure associated with the wavefunction data.
            kpoint (int): K-point index of the desired wavefunction.
            band (int): Band index of the desired wavefunction.
            spin (int | None): Spin index. Only ``None`` and ``0`` are
                supported.
            spinor (int | None): Spinor component index. Supported for ``ncl``
                files, where ``None`` returns the summed spinor density and
                ``0`` or ``1`` returns a single spinor component. For non-NCL
                files, this argument is accepted for ``Wavecar`` compatibility;
                any provided value selects the single-component branch used by
                ``Wavecar.get_parchg()``.
            phase (bool): Whether to preserve the sign of the real part of the
                wavefunction in the returned density.
            scale (int): Scaling factor applied to the FFT grid.

        Returns:
            Chgcar: Partial charge density derived from the selected
                wavefunction.
        """
        self._require_wavefunction_data()
        kpoint, band = self._normalize_wave_indices(kpoint, band)
        if phase and not np.allclose(self.kpoints[kpoint], 0.0):
            warnings.warn(
                "phase is True should only be used for the Gamma kpoint! I hope you know what you're doing!",
                stacklevel=2,
            )

        temp_ng = self.ng.copy()
        self.ng = self.ng * scale
        try:
            with self._open_hdf5_file() as h5_file:
                if self.spin == 2:
                    if spin is not None:
                        spin = self._normalize_spin_index(spin)
                        wfr = self._ifft_wavefunction(kpoint, band, spin=spin, h5_file=h5_file)
                        den = np.abs(np.conj(wfr) * wfr)
                        if phase:
                            den = np.sign(np.real(wfr)) * den
                        return Chgcar(poscar, {"total": den})

                    wfr_up = self._ifft_wavefunction(kpoint, band, spin=0, h5_file=h5_file)
                    den_up = np.abs(np.conj(wfr_up) * wfr_up)
                    wfr_dn = self._ifft_wavefunction(kpoint, band, spin=1, h5_file=h5_file)
                    den_dn = np.abs(np.conj(wfr_dn) * wfr_dn)
                    return Chgcar(poscar, {"total": den_up + den_dn, "diff": den_up - den_dn})

                if self.vasp_type == "ncl":
                    if spin not in (None, 0):
                        raise NotImplementedError("Spin-resolved ncl vaspwave.h5 is not implemented yet.")

                    if spinor is not None:
                        spinor = self._normalize_index(spinor, 2, "Spinor")
                        wfr = self._ifft_wavefunction(kpoint, band, spin=0, spinor=spinor, h5_file=h5_file)
                        den = np.abs(np.conj(wfr) * wfr)
                    else:
                        wfr = self._ifft_wavefunction(kpoint, band, spin=0, spinor=0, h5_file=h5_file)
                        wfr_t = self._ifft_wavefunction(kpoint, band, spin=0, spinor=1, h5_file=h5_file)
                        den = np.abs(np.conj(wfr) * wfr)
                        den += np.abs(np.conj(wfr_t) * wfr_t)

                    if phase and spinor is not None:
                        den = np.sign(np.real(wfr)) * den
                    return Chgcar(poscar, {"total": den})

                if spin not in (None, 0):
                    raise NotImplementedError("Spin-resolved vaspwave.h5 is not implemented yet.")

                wfr = self._ifft_wavefunction(kpoint, band, spin=0, h5_file=h5_file)
                den = np.abs(np.conj(wfr) * wfr)
                if spinor is None:
                    # Match Wavecar's collinear branch, which sums both spinor
                    # channels even though fft_mesh ignores spinor for std/gam.
                    den += den
                if phase:
                    den = np.sign(np.real(wfr)) * den
                return Chgcar(poscar, {"total": den})
        finally:
            self.ng = temp_ng

    def get_chgcar(self) -> Chgcar:
        """Read the native charge-density grid stored in ``/charge/charge``.

        Returns:
            Chgcar: Charge density object using ``self.structure``, parsed from
            ``/structure/positions`` or ``/locpot/position``.

        Raises:
            ValueError: If ``vaspwave.h5`` does not contain structure data.
        """
        data = self._read_validated_volumetric_dataset("/charge/grid", "/charge/charge")
        return Chgcar(
            self._get_volumetric_structure(),
            data,
            data_aug=cast("Any", self._read_charge_augmentation_data()),
        )

    def get_locpot(self) -> Locpot:
        """Read the native local-potential grid stored in ``/locpot/total``.

        Returns:
            Locpot: Local potential object using ``self.structure``, parsed from
            ``/structure/positions`` or ``/locpot/position``.

        Raises:
            ValueError: If ``vaspwave.h5`` does not contain structure data.
        """
        data = self._read_validated_volumetric_dataset("/locpot/grid", "/locpot/total")
        return Locpot(self._get_volumetric_structure(), data)

    def write_unks(self, directory: PathLike) -> None:
        """Write supported ``vaspwave.h5`` wavefunctions to UNK files.

        Note:
            ``ISPIN=2`` std files currently write the expected pair of UNK
            files, but their contents are not yet validated to be pointwise
            identical to ``Wavecar.write_unks()`` for the local HDF5 sample.
        """
        self._require_wavefunction_data()
        out_dir = Path(directory).expanduser()
        if not out_dir.exists():
            out_dir.mkdir(parents=False)
        elif not out_dir.is_dir():
            raise ValueError("invalid directory")

        with self._open_hdf5_file() as h5_file:
            for ik in range(self.nk):
                if self.vasp_type == "ncl":
                    data = np.empty((self.nb, 2, *self.ng), dtype=np.complex128)
                    for ib in range(self.nb):
                        data[ib, 0, :, :, :] = self._ifft_wavefunction(ik, ib, spinor=0, h5_file=h5_file)
                        data[ib, 1, :, :, :] = self._ifft_wavefunction(ik, ib, spinor=1, h5_file=h5_file)
                    Unk(ik + 1, data).write_file(str(out_dir / f"UNK{ik + 1:05d}.NC"))
                    continue

                data = np.empty((self.nb, *self.ng), dtype=np.complex128)
                for ispin in range(self.spin):
                    for ib in range(self.nb):
                        data[ib, :, :, :] = self._ifft_wavefunction(ik, ib, spin=ispin, h5_file=h5_file)
                    Unk(ik + 1, data).write_file(str(out_dir / f"UNK{ik + 1:05d}.{ispin + 1}"))


@requires(h5py is not None, "h5py must be installed to read vaspout.h5")
class Vaspout(Vasprun):
    """
    Class to read vaspout.h5 files.

    This class inherits from Vasprun, as the vaspout.h5 file is intended
    to be a forward-looking replacement for vasprun.xml.

    Thus to later accommodate a smooth transition to vaspout.h5, this class
    uses the same structure as Vasprun but overrides many of its methods.

    Parameters
    -----------
        filename : str or Path
            The name of the vaspout.h5 file to parse, can be compressed.
        occu_tol: float = 1e-8
            Sets the minimum tol for the determination of the
            vbm and cbm. Usually the default of 1e-8 works well enough,
            but there may be pathological cases.
        parse_dos : bool = True
            Whether to parse the dos. Defaults to True. Set
            to False to shave off significant time from the parsing if you
            are not interested in getting those data.
        parse_eigen : bool = True
            Whether to parse the eigenvalues. Defaults to
            True. Set to False to shave off significant time from the
            parsing if you are not interested in getting those data.
        parse_projected_eigen : bool = False
            Whether to parse the projected
            eigenvalues and magnetization. Defaults to False. Set to True to obtain
            projected eigenvalues and magnetization. **Note that this can take an
            extreme amount of time and memory.** So use this wisely.
        separate_spins : bool
            Whether the band gap, CBM, and VBM should be
            reported for each individual spin channel. Defaults to False,
            which computes the eigenvalue band properties independent of
            the spin orientation. If True, the calculation must be spin-polarized.
        store_potcar : bool
            Whether to store the full POTCAR data.
    """

    def __init__(
        self,
        filename: str | Path,
        occu_tol: float = 1e-8,
        parse_dos: bool = True,
        parse_eigen: bool = True,
        parse_projected_eigen: bool = False,
        separate_spins: bool = False,
        store_potcar: bool = True,
    ) -> None:
        self.filename = str(filename)
        self.occu_tol = occu_tol
        self.separate_spins = separate_spins
        self.store_potcar = store_potcar

        self._parse(parse_dos, parse_eigen, parse_projected_eigen)

    @classmethod
    def _parse_hdf5_value(cls, val: Any) -> Any:
        """
        Parse HDF5 values recursively, turning it into a dict-like entry.

        This could be a staticmethod, but a recursive staticmethod seems to only
        work in python >= 3.10. Using a classmethod to work with 3.9.

        Args:
            val (Any), input value
        Returns:
            Any, output value. Recursion is performed until a bytes-like object is input.
        """
        if hasattr(val, "items"):
            val = {k: cls._parse_hdf5_value(v) for k, v in val.items()}
        else:
            val = np.array(val).tolist()
            if isinstance(val, bytes):
                val = val.decode()
            elif isinstance(val, list):
                val = [cls._parse_hdf5_value(x) for x in val]
        return val

    def _parse(self, parse_dos: bool, parse_eigen: bool, parse_projected_eigen: bool) -> None:  # type: ignore[override]
        """
        Parse data contained in vaspout.h5.

        Args:
            parse_dos (bool)
                Whether to parse the DOS
            parse_eigen (bool)
                Whether to parse the bandstructure / electronic eigenvalues
            parse_projected_eigen (bool)
                Whether to parse the projected bandstructure.
                TODO: this information is not currently included in vaspout.h5, add later?
        """
        with zopen(self.filename, "rb") as vout_file, h5py.File(vout_file, "r") as h5_file:
            # Loading only certain blocks into memory at a given time to lessen memory usage
            vasp_version = self._parse_hdf5_value(h5_file["version"])
            self._parse_params(self._parse_hdf5_value(h5_file["input"]))
            self._get_ionic_steps(self._parse_hdf5_value(h5_file["intermediate"]["ion_dynamics"]))

            self.bandgap_props: dict[str, dict[str, BandgapProps]] | None = None
            if h5_file["intermediate"].get("band"):
                self.bandgap_props = self._parse_bandgap_props(self._parse_hdf5_value(h5_file["intermediate"]["band"]))

            # -----
            # TODO: determine if these following fields are stored in vaspout.h5
            self.md_data = []
            # -----

            outputs = self._parse_hdf5_value(h5_file["results"])

        self._parse_results(outputs)

        if parse_dos:
            try:
                self._parse_dos(
                    electron_dos=outputs["electron_dos"],
                    projectors=outputs.get("projectors", {}).get("lchar", None),
                )
                self.dos_has_errors = False

                if outputs.get("electron_dos_kpoints_opt"):
                    self._parse_dos(
                        electron_dos=outputs["electron_dos"],
                        projectors=outputs.get("projectors_kpoints_opt", {}).get("lchar", None),
                        kpoints_opt=True,
                    )

            except Exception:
                self.dos_has_errors = True

        if parse_eigen:
            self.eigenvalues = self._parse_eigen(outputs["electron_eigenvalues"])
            if (eigv := outputs.get("electron_eigenvalues_kpoints_opt")) and self.kpoints_opt_props:
                self.kpoints_opt_props.eigenvalues = self._parse_eigen(
                    eigv,
                    ispin=outputs["electron_eigenvalues"]["ispin"],
                    nb_tot=outputs["electron_eigenvalues"]["nb_tot"],
                )

        self.projected_eigenvalues = None
        self.projected_magnetization = None
        if parse_projected_eigen:
            # TODO: are these contained in vaspout.h5?
            self.projected_eigenvalues = None
            self.projected_magnetization = None

        self.vasp_version = ".".join(f"{vasp_version.get(tag, '')}" for tag in ("major", "minor", "patch"))

        # TODO: are the other generator tags, like computer platform, stored in vaspout.h5?
        self.generator = {"version": self.vasp_version}  # type:ignore[assignment]

    @staticmethod
    def _parse_structure(positions: dict) -> Structure:  # type: ignore[override]
        """
        Parse the structure from vaspout format.

        Args:
            positions (dict), dict representation of POSCAR
        Returns:
            pymatgen Structure
        """
        species = []
        for ispecie, specie in enumerate(positions["ion_types"]):
            species += [specie for _ in range(positions["number_ion_types"][ispecie])]

        # TODO : figure out how site_properties are stored in vaspout
        site_properties: dict[str, list] = {}
        if positions["selective_dynamics"] == 1:
            site_properties["selective_dynamics"] = []

        return Structure(
            lattice=Lattice(positions["scale"] * np.array(positions["lattice_vectors"])),
            species=species,
            coords=positions["position_ions"],
            coords_are_cartesian=(positions["direct_coordinates"] == 1),
        )

    @staticmethod
    def _parse_kpoints(kpoints: dict) -> tuple[Kpoints, list | None, list | None]:  # type: ignore[override]
        _kpoints_style_from_mode = {
            KpointsSupportedModes.Reciprocal: {"mode": "e", "coordinate_space": "R"},
            KpointsSupportedModes.Automatic: {"mode": "a"},
            KpointsSupportedModes.Gamma: {"mode": "g"},
            KpointsSupportedModes.Line_mode: {"mode": "l"},
            KpointsSupportedModes.Monkhorst: {"mode": "m"},
        }
        kpts = {"comment": kpoints.get("system", "Unknown")}

        for kpoints_style, props in _kpoints_style_from_mode.items():
            if all(kpoints.get(k) == v for k, v in props.items()):
                kpts["style"] = kpoints_style
                break

        if not kpts.get("style"):
            raise ValueError("Could not identify KPOINTS style.")

        if coord_type := kpoints.get("coordinate_space"):
            kpts["coord_type"] = "Reciprocal" if coord_type == "R" else "Cartesian"

        actual_kpoints = None
        actual_kpoint_weights = None
        if kpoints.get("coordinates_kpoints"):
            kpts["num_kpts"] = len(kpoints["coordinates_kpoints"])
            kpts["labels"] = [None for _ in range(kpts["num_kpts"])]
            for i, idx in enumerate(kpoints.get("positions_labels_kpoints", [])):
                kpts["labels"][idx - 1] = kpoints["labels_kpoints"][i]

            kpts["kpts"] = kpoints["coordinates_kpoints"]
            actual_kpoints = kpoints["coordinates_kpoints"]

            # NB: no weights for KPOINTS_OPT
            actual_kpoint_weights = kpoints.get("weights_kpoints", [1.0 for _ in range(len(actual_kpoints))])

        elif all(kpoints.get(f"nkp{axis}") for axis in ("x", "y", "z")):
            kpts["num_kpts"] = kpoints["number_kpoints"]
            kpts["kpts"] = [[kpoints[f"nkp{axis}"] for axis in ("x", "y", "z")]]

        return (
            Kpoints(**kpts),
            actual_kpoints,
            actual_kpoint_weights,
        )

    @staticmethod
    def _parse_atominfo(composition: Composition):
        # TODO: this function seems irrelevant but is used in Vasprun, do we need this?
        atom_symbols = []
        for element in composition:
            atom_symbols += [str(element) for _ in range(int(composition[element]))]
        return atom_symbols

    def _parse_params(self, input_data: dict):  # type: ignore[override]
        self.incar = Incar(input_data["incar"])

        # TODO: set defaults in parameters to match vasprun?
        self.parameters = Incar.from_dict(self.incar.as_dict())  # type: ignore[attr-defined]

        self.kpoints: list[Any] | None = None  # type: ignore[assignment]
        self.actual_kpoints: list[Any] | None = None  # type: ignore[assignment]
        self.actual_kpoints_weights: Sequence[float] = None  # type: ignore[assignment]
        if not self.incar.get("KSPACING"):
            (
                self.kpoints,
                self.actual_kpoints,
                self.actual_kpoints_weights,
            ) = self._parse_kpoints(input_data["kpoints"])  # type: ignore[assignment]

        self.kpoints_opt_props: KpointOptProps | None = None
        if input_data.get("kpoints_opt"):
            self.kpoints_opt_props = KpointOptProps()
            (
                self.kpoints_opt_props.kpoints,
                self.kpoints_opt_props.actual_kpoints,
                self.kpoints_opt_props.actual_kpoints_weights,
            ) = self._parse_kpoints(input_data["kpoints_opt"])

        self.initial_structure = self._parse_structure(input_data["poscar"])
        self.atomic_symbols = self._parse_atominfo(self.initial_structure.composition)

        self.potcar = None
        self.potcar_symbols = []
        self.potcar_spec = []
        if input_data["potcar"].get("content"):
            # Unmodified vaspout.h5 with full POTCAR
            calc_potcar = Potcar.from_str(input_data["potcar"]["content"])
            self.potcar = calc_potcar if self.store_potcar else None
            # The `potcar_symbols` attr is extraordinarily confusingly
            # named, these are really TITELs # codespell:ignore
            self.potcar_symbols = [potcar.TITEL for potcar in calc_potcar]

            # For parity with vasprun.xml, we do not store the POTCAR symbols in
            # the vaspout.h5 POTCAR spec. These are derived from the TITEL fields
            # and are thus redundant.
            self.potcar_spec = [p.spec(extra_spec=[]) for p in calc_potcar]

        elif input_data["potcar"].get("spec"):
            # modified vaspout.h5 with only POTCAR spec

            self.potcar_spec = orjson.loads(input_data["potcar"]["spec"])
            self.potcar_symbols = [spec["titel"] for spec in self.potcar_spec]

        # TODO: do we want POSCAR stored?
        self.poscar = Poscar(
            structure=self.initial_structure,
            comment=input_data["poscar"].get("system"),
            selective_dynamics=self.initial_structure.site_properties.get("selective_dynamics"),
            velocities=self.initial_structure.site_properties.get("velocities"),
        )

    def _get_ionic_steps(self, ion_dynamics) -> None:
        # use same key accession as in vasprun.xml
        vasp_key_to_pmg = {
            "free energy    TOTEN": "e_fr_energy",
            "energy without entropy": "e_wo_entrp",
            "energy(sigma->0)": "e_0_energy",
        }

        # label s, p, d,... contributions to charge and magnetic moment in same way as Outcar
        _to_outcar_tag = {
            "total charge": "charge",  # older style
            "magnetization (x)": "magnetization",
            "charge": "charge",  # newer style
            "x": "magnetization",
        }

        self.nionic_steps = len(ion_dynamics["energies"])
        self.ionic_steps = []

        ionic_step_keys = [
            key
            for key in (
                "forces",
                "stresses",
            )
            if ion_dynamics.get(key)
        ]

        for istep in range(self.nionic_steps):
            step = {
                **{
                    vasp_key_to_pmg[ion_dynamics["energies_tags"][ivalue]]: value
                    for ivalue, value in enumerate(ion_dynamics["energies"][istep])
                },
                **{key: ion_dynamics[key][istep] for key in ionic_step_keys},
                "structure": Structure(
                    lattice=Lattice(ion_dynamics["lattice_vectors"][istep]),
                    species=self.initial_structure.species,
                    coords=ion_dynamics["position_ions"][istep],
                    coords_are_cartesian=False,  # TODO check this is always False
                ),
                # Placeholder - there's currently no info about electronic steps
                # in vaspout.h5
                "electronic_steps": [],
            }
            if chg_dens_props := ion_dynamics.get("magnetism"):
                old_style = chg_dens_props.get("component_tags", [])  # Appears to be VASP <= 6.4.2
                new_style = chg_dens_props.get("spin_moments", {})  # Appears to be VASP >= 6.4.3
                if old_style:
                    components = old_style
                    moments = chg_dens_props["moments"]
                    orbitals = chg_dens_props["orbital_tags"]

                elif new_style:
                    components = new_style["components"]
                    moments = new_style.get("values")
                    orbitals = new_style["orbitals"]
                else:
                    warnings.warn(
                        "Unknown format for the on-site charges and magnetic moments in vaspout.h5.", stacklevel=2
                    )

                if old_style or new_style:
                    for ik, k in enumerate(components):  # pyright: ignore[reportPossiblyUnboundVariable]
                        site_prop = [
                            {orb: moments[istep][ik][iion][iorb] for iorb, orb in enumerate(orbitals)}  # pyright: ignore[reportPossiblyUnboundVariable]
                            for iion in range(len(self.poscar.structure))
                        ]
                        for iion in range(len(self.poscar.structure)):
                            site_prop[iion]["tot"] = sum(site_prop[iion].values())
                        step["structure"].add_site_property(_to_outcar_tag.get(k), site_prop)

            self.ionic_steps += [step]

    def _parse_results(self, results: dict) -> None:
        self.final_structure = self._parse_structure(results["positions"])

    def _parse_dos(self, electron_dos: dict, projectors: list | None = None, kpoints_opt: bool = False):  # type: ignore[override]
        dos = {"efermi": electron_dos["efermi"]}
        densities: dict = {}
        for dos_type in (
            "dos",
            "dosi",
        ):
            if electron_dos.get(dos_type):
                densities[dos_type] = {}
                for ispin in range(len(electron_dos[dos_type])):
                    densities[dos_type][Spin((-1) ** ispin)] = electron_dos[dos_type][ispin]

        dos["tdos"] = Dos(dos["efermi"], electron_dos["energies"], densities["dos"])
        dos["idos"] = Dos(dos["efermi"], electron_dos["energies"], densities["dosi"])

        dos["pdos"] = []

        # for whatever reason, the naming of orbitals is different in vaspout.h5
        vasp_to_pmg_orb = {
            "x2-y2": "dx2",
            "fy3x2": "f_3",
            "fxyz": "f_2",
            "fyz2": "f_1",
            "fz3": "f0",
            "fxz2": "f1",
            "fzx2": "f2",
            "fx3": "f3",
        }

        if pdos := electron_dos.get("dospar"):
            projectors = projectors or []
            projectors = [char.strip() for char in projectors]
            orbtyp = Orbital if any("x" in char for char in projectors) else OrbitalType
            for site_pdos in pdos:
                site_res_pdos: dict = defaultdict(dict)
                for ispin in range(len(site_pdos)):
                    for ilm in range(len(site_pdos[ispin])):
                        orb_str = projectors[ilm]
                        orb_idx = orbtyp.__members__[vasp_to_pmg_orb.get(orb_str, orb_str)]
                        site_res_pdos[orb_idx][Spin((-1) ** ispin)] = np.array(site_pdos[ispin][ilm])
                dos["pdos"] += [site_res_pdos]

        if kpoints_opt:
            for k, v in dos.items():
                setattr(self.kpoints_opt_props, k, v)
        else:
            for k, v in dos.items():
                setattr(self, k, v)

    @staticmethod
    def _parse_eigen(eigenvalues_complete: dict, ispin: int | None = None, nb_tot: int | None = None):  # type: ignore[override]
        eigenvalues = {}
        ispin = ispin or eigenvalues_complete["ispin"]
        nb_tot = nb_tot or eigenvalues_complete["nb_tot"]
        nkpoints = eigenvalues_complete.get("kpoints") or len(eigenvalues_complete.get("kpoint_coords", []))
        for i_spin in range(ispin):
            eigenvalues[Spin.up if i_spin == 0 else Spin.down] = np.array(
                [
                    [
                        [
                            eigenvalues_complete["eigenvalues"][i_spin][i][j],
                            eigenvalues_complete["fermiweights"][i_spin][i][j],
                        ]
                        for j in range(nb_tot)
                    ]
                    for i in range(nkpoints)
                ]
            )
        return eigenvalues

    @staticmethod
    def _parse_bandgap_props(band_props: dict[str, list]) -> dict[str, dict[str, BandgapProps]] | None:
        """
        Parse the bandgap properties VASP calculates.

        These are a dict with the following keys:
            gap_from_kpoint, gap_from_weight, labels

        `labels` are the corresponding labels of the values in gap_from_*

        The lists in gap_from_* have shape (1, ISPIN + 1, len(band_props['labels']) )
        The specific order of spins in the middle index are: total, spin-up, spin-down


        """

        known_gap_keys = ("gap_from_kpoint", "gap_from_weight")
        spin_keys: tuple[str, ...] = tuple()
        for ref_k in known_gap_keys:
            if bps := band_props.get(ref_k, []):
                if len(bps[0]) == 1:
                    spin_keys = ("total",)
                elif len(bps[0]) == 3:
                    spin_keys = (
                        "total",
                        Spin.up.name,
                        Spin.down.name,
                    )
                else:
                    warnings.warn(f"Unknown bandgap property shape {len(band_props)} in vaspout.h5.", stacklevel=2)
                break

        if len(spin_keys) == 0:
            return None

        bg_props: dict[str, dict[str, BandgapProps]] = {}
        for k in [x for x in known_gap_keys if band_props.get(x)]:
            bg_props[k] = {}
            for i, spin in enumerate(spin_keys):
                vals = {label: band_props[k][0][i][idx] for idx, label in enumerate(band_props["labels"])}

                props = {
                    "vbm": vals.get("valence band maximum"),
                    "cbm": vals.get("conduction band minimum"),
                }

                if all(dgv := tuple(vals.get(x) for x in ("direct gap bottom", "direct gap top"))):
                    props["direct_gap_eigenvalues"] = dgv

                for band_pos, band_pos_label in {
                    "VBM": "vbm",
                    "CBM": "cbm",
                    "direct": "direct_gap",
                }.items():
                    if all(kv := tuple(vals.get(f"{x} ({band_pos})") for x in ("kx", "ky", "kz"))):
                        props[f"{band_pos_label}_k"] = kv
                bg_props[k][spin] = BandgapProps(**props)

        return bg_props

    @property
    @unitized("eV")
    def final_energy(self):
        """Final energy from vaspout."""
        return self.ionic_steps[-1]["e_0_energy"]

    def remove_potcar_and_write_file(
        self, filename: str | Path | None = None, fake_potcar_str: str | None = None
    ) -> None:
        """
        Utility function to replace the full POTCAR with its spec, and write a vaspout.h5.

        This is needed for applications where one might upload VASP output
        to a public database. Since vaspout.h5 includes the full POTCAR, it's necessary
        to replace it here with just the spec.

        Args:
            filename : str, Path, or None (default)
                Name of the output file. If None, defaults to self.filename (in-place modification).
            fake_potcar_str : str or None (default)
                If a str, a POTCAR represented as a str. Used in the context of tests to replace
                a POTCAR with a scrambled/fake POTCAR. If None, the Vaspout.potcar Field
                ("/input/potcar/content" field of vaspout.h5) is removed.
        """

        def recursive_to_dataset(h5_obj, level, obj):
            if hasattr(obj, "items"):
                if level != "/":
                    h5_obj.create_group(level)
                for k, v in obj.items():
                    recursive_to_dataset(h5_obj[level], k, v)
            else:
                if isinstance(obj, str):
                    obj = obj.encode()
                data = np.array(obj)
                if "U" in str(data.dtype):
                    data = data.astype("S")
                h5_obj.create_dataset(level, data=data)

        filename = filename or self.filename
        _, fname_ext = os.path.splitext(filename)  # type: ignore[type-var]

        # determine if output file is to be compressed
        is_compressed = fname_ext.lower() in {".bz2", ".gz", ".z", ".xz", ".lzma"}

        with zopen(self.filename, "rb") as vout_file, h5py.File(vout_file, "r") as h5_file:
            hdf5_data = self._parse_hdf5_value(h5_file)

        if fake_potcar_str:
            hdf5_data["input"]["potcar"]["content"] = fake_potcar_str
            potcar_spec = [psingle.spec() for psingle in Potcar.from_str(fake_potcar_str)]
        else:
            del hdf5_data["input"]["potcar"]["content"]
            potcar_spec = self.potcar_spec

        # rather than define custom HDF5 hierarchy for POTCAR spec, just dump JSONable dict to str
        hdf5_data["input"]["potcar"]["spec"] = orjson.dumps(potcar_spec, option=orjson.OPT_SERIALIZE_NUMPY).decode()

        # if file is to be compressed, first write uncompressed file
        with h5py.File(filename, "w") as h5_file:
            recursive_to_dataset(h5_file, "/", hdf5_data)

        # now compress the file
        if is_compressed:
            with open(filename, "rb") as fin:
                byte_data = fin.read()
            with zopen(filename, "wb") as fout:
                fout.write(byte_data)
