from __future__ import annotations

import cmath
import gzip
import json
import logging
import os
import re
import tarfile
from contextlib import contextmanager
from pathlib import Path
from shutil import copyfile, copyfileobj

import numpy as np
import pytest
from lxml import etree as ET
from monty.io import zopen
from monty.shutil import decompress_file
from numpy.testing import assert_allclose
from pytest import approx

from pymatgen.core import Element
from pymatgen.core.lattice import Lattice
from pymatgen.core.structure import Structure
from pymatgen.electronic_structure.bandstructure import BandStructure, BandStructureSymmLine
from pymatgen.electronic_structure.core import Magmom, Orbital, OrbitalType, Spin
from pymatgen.io.vasp.inputs import Incar, Kpoints, Poscar, Potcar
from pymatgen.io.vasp.outputs import (
    WSWQ,
    BandgapProps,
    BSVasprun,
    Chgcar,
    Dynmat,
    Eigenval,
    Elfcar,
    KpointOptProps,
    Locpot,
    Oszicar,
    Outcar,
    Procar,
    UnconvergedVASPWarning,
    Vaspout,
    VaspParseError,
    Vasprun,
    Vaspwave,
    Wavecar,
    Waveder,
    Xdatcar,
    _parse_from_incar,
    get_band_structure_from_vasp_multiple_branches,
)
from pymatgen.io.wannier90 import Unk
from pymatgen.util.testing import FAKE_POTCAR_DIR, TEST_FILES_DIR, VASP_IN_DIR, VASP_OUT_DIR, MatSciTest

try:
    import h5py
except ImportError:
    h5py = None

TEST_DIR = f"{TEST_FILES_DIR}/io/vasp"


class TestVasprun(MatSciTest):
    def test_parse_from_incar_bare_filename(self):
        incar_path = Path(self.tmp_path) / "INCAR"
        incar_path.write_text("ENCUT = 520\n", encoding="utf-8")
        cwd = os.getcwd()
        os.chdir(self.tmp_path)
        try:
            assert _parse_from_incar("vasprun.xml", "ENCUT") == 520
        finally:
            os.chdir(cwd)

    def test_vasprun_soc(self):
        # Test that SOC vaspruns are parsed appropriately, giving just Spin.Up tdos, idos and pdos
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.int_Te_SOC.xml.gz")
        dos_density_dicts_to_check = [vasp_run.complete_dos.densities, vasp_run.tdos.densities, vasp_run.idos.densities]
        dos_density_dicts_to_check += [
            densities for orbital_dict in vasp_run.complete_dos.pdos.values() for densities in orbital_dict.values()
        ]
        for i, dos_density_dict in enumerate(dos_density_dicts_to_check):
            assert set(dos_density_dict.keys()) == {Spin.up}, f"Failed spin keys check for {i}th dos obj!"

        assert vasp_run.complete_dos.spin_polarization is None

    def test_vasprun_ml(self):
        # Test for ML MD simulation
        # The trajectory data is stored in md_data
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.ml_md.xml.gz")
        assert len(vasp_run.md_data) == 100
        for frame in vasp_run.md_data:
            assert "structure" in frame
            assert "forces" in frame
            assert "energy" in frame
        assert vasp_run.md_data[-1]["energy"]["total"] == approx(-491.51831988)
        assert vasp_run.md_n_steps == 100
        assert vasp_run.converged_ionic

    def test_vasprun_ml_without_calculation_tags(self):
        """Test MLFF MD output that stores steps outside ``<calculation>`` tags."""
        filepath = f"{VASP_OUT_DIR}/vasprun.ml_md_no_calculation.xml.gz"
        vasp_run = Vasprun(filepath, parse_potcar_file=False)
        assert len(vasp_run.ionic_steps) == 0
        assert len(vasp_run.md_data) == vasp_run.nionic_steps == 10
        assert vasp_run.converged

        vasp_run_skip = Vasprun(filepath, ionic_step_skip=3, ionic_step_offset=1, parse_potcar_file=False)
        assert vasp_run_skip.nionic_steps == 10
        assert [step["energy"]["total"] for step in vasp_run_skip.md_data] == approx(
            [-23.39220561, -23.36690383, -23.34485475]
        )

    def test_vasprun_md(self):
        # Test for simple MD simulation (no ML).
        # Does not generate the `md_data` attribute in Vasprun. Data based on `ionic_steps`
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.md.xml.gz")
        assert len(vasp_run.ionic_steps) == 10
        assert vasp_run.final_energy == approx(-327.73014059)
        assert vasp_run.md_n_steps == 10
        assert vasp_run.converged_ionic

    def test_vasprun_ediffg_set_to_0(self):
        # Test for case where EDIFFG is set to 0. This should pass if all ionic steps
        # complete and are electronically converged.
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.ediffg_set_to_0.xml.gz")
        assert len(vasp_run.ionic_steps) == 3
        assert vasp_run.final_energy == approx(-34.60164204)
        assert vasp_run.converged_ionic is True
        assert vasp_run.converged_electronic is True
        assert vasp_run.converged is True
        assert vasp_run.parameters["EDIFFG"] == 0
        assert vasp_run.parameters["EDIFF"] == approx(1e-5)

    def test_bad_random_seed(self):
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.bad_random_seed.xml.gz")
        assert vasp_run.incar["ISMEAR"] == 0
        assert vasp_run.incar["RANDOM_SEED"] is None

    def test_multiple_dielectric(self):
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.GW0.xml.gz")
        assert len(vasp_run.dielectric_data) == 4
        assert "HEAD OF MICROSCOPIC DIELECTRIC TENSOR (INDEPENDENT PARTICLE)" in vasp_run.dielectric_data

    def test_charge_charge_dielectric(self):
        """
        VASP 5.4.4 writes out two dielectric functions to vasprun.xml
        These are the "density-density" and "velocity-velocity" linear response functions.
        See the comments in `linear_optics.F` for details.
        """
        vasp_run = Vasprun(
            f"{VASP_OUT_DIR}/vasprun.dielectric_5.4.4.xml.gz",
            parse_potcar_file=False,
        )
        assert vasp_run.dielectric is not None
        assert "density" in vasp_run.dielectric_data
        assert "velocity" in vasp_run.dielectric_data

    def test_BSE(self):
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.BSE.xml.gz")
        absorption_coeff = vasp_run.optical_absorption_coeff
        assert absorption_coeff[1] == approx(0.8327890433502937)
        assert vasp_run.final_structure == vasp_run.initial_structure
        assert "freq_dependent" in vasp_run.dielectric_data

    def test_vasprun_with_more_than_two_unlabelled_dielectric_functions(self):
        with pytest.warns(
            UserWarning,
            match="Additional unlabelled dielectric data in vasprun.xml are stored as unlabelled.",
        ):
            vr = Vasprun(f"{VASP_OUT_DIR}/vasprun.dielectric_bad.xml.gz")
        assert "unlabelled" in vr.dielectric_data

    def test_bad_vasprun(self):
        with pytest.raises(ET.XMLSyntaxError):
            Vasprun(f"{VASP_OUT_DIR}/vasprun.bad.xml.gz")

        with pytest.warns(
            UserWarning,
            match="XML is malformed. Parsing has stopped but partial data is available",
        ):
            vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.bad.xml.gz", exception_on_bad_xml=False)
        assert len(vasp_run.ionic_steps) == 1
        assert vasp_run.final_energy == approx(-269.00551374)

    @pytest.mark.filterwarnings("ignore::pymatgen.io.vasp.outputs.UnconvergedVASPWarning")
    def test_runtype(self):
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.GW0.xml.gz")
        assert vasp_run.run_type in "HF"

        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.pbesol_vdw.xml.gz")
        assert vasp_run.run_type in "PBEsol+vdW-DFT-D3-BJ"

        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.hse06.xml.gz")
        assert vasp_run.run_type in "HSE06"

        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.scan_rvv10.xml.gz")
        assert vasp_run.run_type in "SCAN+rVV10"

        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.dfpt.ionic.xml.gz")
        assert vasp_run.run_type in "GGA"

        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.dfpt.xml.gz")
        assert vasp_run.run_type in "GGA+U"

        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.r2scan.xml.gz")
        assert vasp_run.run_type in "R2SCAN"

        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.scan.xml.gz")
        assert vasp_run.run_type in "SCAN"

        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.pbesol.xml.gz")
        assert vasp_run.run_type in "PBEsol"

        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.rscan.xml.gz")
        assert vasp_run.run_type in "RSCAN"

        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.random.xml.gz")
        assert vasp_run.run_type in "RANDOMFUNCTIONAL"

        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.unknown.xml.gz")
        with pytest.warns(UserWarning, match="Unknown run type!"):
            assert vasp_run.run_type in "unknown"

    def test_vdw(self):
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.vdw.xml.gz")
        assert vasp_run.final_energy == approx(-9.78310677)

    def test_energies(self):
        # VASP 5.4.1
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.etest1.xml.gz")
        assert vasp_run.final_energy == approx(-11.18981538)

        # VASP 6.2.1
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.etest2.xml.gz")
        assert vasp_run.final_energy == approx(-11.18986774)

        # VASP 5.4.1
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.etest3.xml.gz")
        assert vasp_run.final_energy == approx(-15.89355325)

        # VASP 6.2.1
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.etest4.xml.gz")
        assert vasp_run.final_energy == approx(-15.89364691)

    def test_nonlmn(self):
        filepath = f"{VASP_OUT_DIR}/vasprun.nonlm.xml.gz"
        vasp_run = Vasprun(filepath, parse_potcar_file=False)
        orbs = list(vasp_run.complete_dos.pdos[vasp_run.final_structure[0]])
        assert OrbitalType.s in orbs

    @pytest.mark.filterwarnings("ignore:invalid value encountered in divide")
    def test_standard(self):
        filepath = f"{VASP_OUT_DIR}/vasprun.xml.gz"
        vasp_run = Vasprun(filepath, parse_potcar_file=False)

        # Test NELM parsing
        assert vasp_run.parameters["NELM"] == 60

        # test pDOS parsing
        assert vasp_run.complete_dos.spin_polarization == approx(1.0)
        assert Vasprun(f"{VASP_OUT_DIR}/vasprun.etest1.xml.gz").complete_dos.spin_polarization is None

        pdos0 = vasp_run.complete_dos.pdos[vasp_run.final_structure[0]]
        assert pdos0[Orbital.s][Spin.up][16] == approx(0.0026)
        assert pdos0[Orbital.pz][Spin.down][16] == approx(0.0012)
        assert pdos0[Orbital.s][Spin.up].shape == (301,)

        pdos0_norm = vasp_run.complete_dos_normalized.pdos[vasp_run.final_structure[0]]
        assert pdos0_norm[Orbital.s][Spin.up][16] == approx(0.0026)  # the site data should not change
        assert pdos0_norm[Orbital.s][Spin.up].shape == (301,)

        cdos_norm, cdos = vasp_run.complete_dos_normalized, vasp_run.complete_dos
        ratio = np.nanmax(cdos.densities[Spin.up] / cdos_norm.densities[Spin.up])
        assert ratio == approx(vasp_run.final_structure.volume)  # the site data should not change

        # check you can normalize an existing DOS
        cdos_norm2 = cdos.get_normalized()
        ratio = np.nanmax(cdos.densities[Spin.up] / cdos_norm2.densities[Spin.up])
        assert ratio == approx(vasp_run.final_structure.volume)  # the site data should not change

        # but doing so twice should not change the data
        cdos_norm3 = cdos_norm2.get_normalized()
        ratio = np.nanmax(cdos.densities[Spin.up] / cdos_norm3.densities[Spin.up])
        assert ratio == approx(vasp_run.final_structure.volume)  # the site data should not change

        pdos0_norm = vasp_run.complete_dos_normalized.pdos[vasp_run.final_structure[0]]
        assert pdos0_norm[Orbital.s][Spin.up][16] == approx(0.0026)  # the site data should not change
        assert pdos0_norm[Orbital.s][Spin.up].shape == (301,)

        cdos_norm, cdos = vasp_run.complete_dos_normalized, vasp_run.complete_dos
        ratio = np.nanmax(cdos.densities[Spin.up] / cdos_norm.densities[Spin.up])
        assert ratio == approx(vasp_run.final_structure.volume)  # the site data should not change

        filepath2 = f"{VASP_OUT_DIR}/vasprun.lifepo4.xml.gz"
        vasprun_ggau = Vasprun(filepath2, parse_projected_eigen=True, parse_potcar_file=False)
        total_sc_steps = sum(len(i["electronic_steps"]) for i in vasp_run.ionic_steps)
        assert len(vasp_run.ionic_steps) == 29
        assert len(vasp_run.structures) == len(vasp_run.ionic_steps)

        trajectory = vasp_run.get_trajectory()
        assert len(trajectory) == len(vasp_run.ionic_steps)
        assert "forces" in trajectory[0].site_properties

        for idx, step in enumerate(vasp_run.ionic_steps):
            assert vasp_run.structures[idx] == step["structure"]

        assert all(
            vasp_run.structures[idx] == vasp_run.ionic_steps[idx]["structure"]
            for idx in range(len(vasp_run.ionic_steps))
        )

        assert total_sc_steps == 308, "Incorrect number of energies read from vasprun.xml"

        assert vasp_run.atomic_symbols == ["Li"] + 4 * ["Fe"] + 4 * ["P"] + 16 * ["O"]
        assert vasp_run.final_structure.reduced_formula == "LiFe4(PO4)4"
        assert isinstance(vasp_run.incar, Incar), f"{vasp_run.incar=}"
        assert isinstance(vasp_run.kpoints, Kpoints), f"{vasp_run.kpoints=}"
        assert isinstance(vasp_run.eigenvalues, dict), f"{vasp_run.eigenvalues=}"
        assert vasp_run.final_energy == approx(-269.38319884, abs=1e-7)
        assert vasp_run.tdos.get_gap() == approx(2.0698, abs=1e-4)
        expected = (2.539, 4.0906, 1.5516, False)
        assert vasp_run.eigenvalue_band_properties == approx(expected)
        assert vasp_run.is_hubbard is False
        assert vasp_run.potcar_symbols == [
            "PAW_PBE Li 17Jan2003",
            "PAW_PBE Fe 06Sep2000",
            "PAW_PBE Fe 06Sep2000",
            "PAW_PBE P 17Jan2003",
            "PAW_PBE O 08Apr2002",
        ]
        assert isinstance(vasp_run.kpoints, Kpoints), f"{vasp_run.kpoints=}"
        assert isinstance(vasp_run.actual_kpoints, list), f"{vasp_run.actual_kpoints=}"
        assert isinstance(vasp_run.actual_kpoints_weights, list), f"{vasp_run.actual_kpoints_weights=}"
        assert isinstance(vasp_run.actual_kpoints_weights[0], float), f"{vasp_run.actual_kpoints_weights[0]=}"
        for atom_doses in vasp_run.pdos:
            for orbital_dos in atom_doses:
                assert isinstance(orbital_dos, Orbital), f"{orbital_dos=}"

        # test skipping ionic steps.
        vasprun_skip = Vasprun(filepath, 3, parse_potcar_file=False)
        assert vasprun_skip.nionic_steps == 29
        assert len(vasprun_skip.ionic_steps) == int(vasp_run.nionic_steps / 3) + 1
        assert len(vasprun_skip.ionic_steps) == len(vasprun_skip.structures)
        assert len(vasprun_skip.ionic_steps) == int(vasp_run.nionic_steps / 3) + 1
        # Check that nionic_steps is preserved no matter what.
        assert vasprun_skip.nionic_steps == vasp_run.nionic_steps

        assert vasprun_skip.final_energy != approx(vasp_run.final_energy)

        # Test with ionic_step_offset
        vasprun_offset = Vasprun(filepath, 3, 6, parse_potcar_file=False)
        assert len(vasprun_offset.ionic_steps) == len(vasp_run.ionic_steps) // 3 - 1
        assert vasprun_offset.structures[0] == vasprun_skip.structures[2]

        assert vasprun_ggau.is_hubbard
        assert vasprun_ggau.hubbards["Fe"] == approx(4.3)
        assert vasprun_ggau.projected_eigenvalues[Spin.up][0][0][96][0] == approx(0.0032)
        dct = vasprun_ggau.as_dict()
        assert dct["elements"] == ["Fe", "Li", "O", "P"]
        assert dct["nelements"] == 4

        entry = vasp_run.get_computed_entry(inc_structure=True)
        entry_id_toks = entry.entry_id.split("-")
        assert entry_id_toks[0] == "vasprun"
        assert entry_id_toks[1] == "20100729"
        assert entry_id_toks[2] == "15.0"
        assert entry_id_toks[3] == "da7b01a471dc249323505c0676ae7350"

        assert entry.parameters["run_type"] == "HSE06"

    def test_unconverged(self):
        filepath = f"{VASP_OUT_DIR}/vasprun.unconverged.xml.gz"
        with pytest.warns(
            UnconvergedVASPWarning,
            match="vasprun.unconverged.xml.gz is an unconverged VASP run",
        ) as warns:
            vasprun_unconverged = Vasprun(filepath, parse_potcar_file=False)
        assert len(warns) >= 1

        assert vasprun_unconverged.converged_ionic
        assert not vasprun_unconverged.converged_electronic
        assert not vasprun_unconverged.converged

    def test_dfpt(self):
        filepath = f"{VASP_OUT_DIR}/vasprun.dfpt.xml.gz"
        vasprun_dfpt = Vasprun(filepath, parse_potcar_file=False)
        assert vasprun_dfpt.epsilon_static[0][0] == approx(3.26105533)
        assert vasprun_dfpt.epsilon_static[0][1] == approx(-0.00459066)
        assert vasprun_dfpt.epsilon_static[2][2] == approx(3.24330517)
        assert vasprun_dfpt.epsilon_static_wolfe[0][0] == approx(3.33402531)
        assert vasprun_dfpt.epsilon_static_wolfe[0][1] == approx(-0.00559998)
        assert vasprun_dfpt.epsilon_static_wolfe[2][2] == approx(3.31237357)
        assert vasprun_dfpt.converged

    @pytest.mark.filterwarnings("ignore::pymatgen.io.vasp.outputs.UnconvergedVASPWarning")
    def test_dfpt_ionic(self):
        filepath = f"{VASP_OUT_DIR}/vasprun.dfpt.ionic.xml.gz"
        vasprun_dfpt_ionic = Vasprun(filepath, parse_potcar_file=False)
        assert vasprun_dfpt_ionic.epsilon_ionic[0][0] == approx(515.73485838)
        assert vasprun_dfpt_ionic.epsilon_ionic[0][1] == approx(-0.00263523)
        assert vasprun_dfpt_ionic.epsilon_ionic[2][2] == approx(19.02110169)

    def test_dfpt_unconverged(self):
        filepath = f"{VASP_OUT_DIR}/vasprun.dfpt.unconverged.xml.gz"
        with pytest.warns(UnconvergedVASPWarning):
            vasprun_dfpt_unconverged = Vasprun(filepath, parse_potcar_file=False)
        assert not vasprun_dfpt_unconverged.converged_electronic
        assert vasprun_dfpt_unconverged.converged_ionic
        assert not vasprun_dfpt_unconverged.converged

    def test_chi(self):
        filepath = f"{VASP_OUT_DIR}/vasprun.chi.xml.gz"
        vasprun_chi = Vasprun(filepath, parse_potcar_file=False)
        assert vasprun_chi.incar.get("ALGO") == "Chi"

    def test_uniform(self):
        vasprun_uniform = Vasprun(f"{VASP_OUT_DIR}/vasprun.uniform.xml.gz", parse_potcar_file=False)
        assert vasprun_uniform.kpoints.style == Kpoints.supported_modes.Reciprocal

    def test_no_projected(self):
        vasprun_no_pdos = Vasprun(f"{VASP_OUT_DIR}/vasprun_Li_no_projected.xml.gz", parse_potcar_file=False)
        assert vasprun_no_pdos.complete_dos is not None
        assert not vasprun_no_pdos.dos_has_errors

    def test_dielectric(self):
        vasprun_diel = Vasprun(f"{VASP_OUT_DIR}/vasprun.dielectric.xml.gz", parse_potcar_file=False)
        assert vasprun_diel.dielectric[0][10] == approx(0.4294)
        assert vasprun_diel.dielectric[1][51][0] == approx(19.941)
        assert vasprun_diel.dielectric[1][51][1] == approx(19.941)
        assert vasprun_diel.dielectric[1][51][2] == approx(19.941)
        assert vasprun_diel.dielectric[1][51][3] == approx(0.0)
        assert vasprun_diel.dielectric[2][85][0] == approx(34.186)
        assert vasprun_diel.dielectric[2][85][1] == approx(34.186)
        assert vasprun_diel.dielectric[2][85][2] == approx(34.186)
        assert vasprun_diel.dielectric[2][85][3] == approx(0.0)

    def test_dielectric_vasp608(self):
        # test reading dielectric constant in vasp 6.0.8
        vasp_xml_path = f"{VASP_OUT_DIR}/vasprun.dielectric_6.0.8.xml.gz"
        vasprun_diel = Vasprun(vasp_xml_path, parse_potcar_file=False)
        assert vasprun_diel.dielectric[0][10] == approx(0.4338)
        assert vasprun_diel.dielectric[1][51][0] == approx(5.267)
        assert vasprun_diel.dielectric_data["density"][0][10] == approx(0.4338)
        assert vasprun_diel.dielectric_data["density"][1][51][0] == approx(5.267)
        assert vasprun_diel.dielectric_data["velocity"][0][10] == approx(0.4338)
        assert vasprun_diel.dielectric_data["velocity"][1][51][0] == approx(1.0741)
        assert len(vasprun_diel.dielectric_data) == 2

    def test_indirect_vasprun(self):
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.indirect.xml.gz")
        gap, cbm, vbm, direct = vasp_run.eigenvalue_band_properties
        assert gap == approx(0.6119)
        assert cbm == approx(6.2231)
        assert vbm == approx(5.6112)
        assert not direct

    def test_optical_vasprun(self):
        vasp_xml_path = f"{VASP_OUT_DIR}/vasprun.optical_transitions.xml.gz"
        vasprun_optical = Vasprun(vasp_xml_path, parse_potcar_file=False)
        optical_trans = vasprun_optical.optical_transition
        assert optical_trans[0][0] == approx(3.084)
        assert optical_trans[3][0] == approx(3.087)
        assert optical_trans[0][1] == approx(0.001)
        assert optical_trans[1][1] == approx(0.001)
        assert optical_trans[7][1] == approx(0.001)
        assert optical_trans[19][1] == approx(0.001)
        assert optical_trans[54][0] == approx(3.3799999999)
        assert optical_trans[55][0] == approx(3.381)
        assert optical_trans[56][0] == approx(3.381)
        assert optical_trans[54][1] == approx(10554.9860)
        assert optical_trans[55][1] == approx(0.0)
        assert optical_trans[56][1] == approx(0.001)

    def test_force_constants(self):
        vasprun_fc = Vasprun(f"{VASP_OUT_DIR}/vasprun.dfpt.phonon.xml.gz", parse_potcar_file=False)
        assert vasprun_fc.force_constants.shape == (16, 16, 3, 3)
        assert_allclose(
            vasprun_fc.force_constants[8, 9],
            [
                [-0.00184451, 0, 0],
                [0, -0.00933824, -0.03021279],
                [0, -0.03021279, 0.01202547],
            ],
        )
        assert vasprun_fc.normalmode_eigenvals.size == 48
        assert_allclose(
            vasprun_fc.normalmode_eigenvals[17:29],
            [
                -0.59067079,
                -0.59067079,
                -0.59067003,
                -0.59067003,
                -0.59067003,
                -0.59067003,
                -0.585009,
                -0.585009,
                -0.58500895,
                -0.58500883,
                -0.5062956,
                -0.5062956,
            ],
        )
        assert vasprun_fc.normalmode_eigenvecs.shape == (48, 16, 3)
        assert_allclose(
            vasprun_fc.normalmode_eigenvecs[33],
            [
                [0.0884346, -0.08837289, -0.24995639],
                [-0.0884346, 0.08837289, 0.24995639],
                [0.15306645, -0.05105771, -0.14441306],
                [-0.15306645, 0.05105771, 0.14441306],
                [-0.0884346, 0.08837289, 0.24995639],
                [0.0884346, -0.08837289, -0.24995639],
                [-0.15306645, 0.05105771, 0.14441306],
                [0.15306645, -0.05105771, -0.14441306],
                [-0.0884346, 0.08837289, 0.24995639],
                [0.0884346, -0.08837289, -0.24995639],
                [-0.15306645, 0.05105771, 0.14441306],
                [0.15306645, -0.05105771, -0.14441306],
                [0.0884346, -0.08837289, -0.24995639],
                [-0.0884346, 0.08837289, 0.24995639],
                [0.15306645, -0.05105771, -0.14441306],
                [-0.15306645, 0.05105771, 0.14441306],
            ],
        )

    def test_xe(self):
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.xe.xml.gz", parse_potcar_file=False)
        assert vasp_run.atomic_symbols == ["Xe"]

    def test_invalid_element(self):
        with pytest.raises(ValueError, match="'Z' is not a valid Element"):
            Vasprun(f"{VASP_OUT_DIR}/vasprun.wrong_sp.xml.gz")

    def test_selective_dynamics(self):
        vasp_run = Vasprun(f"{VASP_OUT_DIR}/vasprun.indirect.xml.gz")
        assert list(vasp_run.final_structure.site_properties.get("selective_dynamics")) == [[True] * 3, [False] * 3]

    def test_as_dict(self):
        filepath = f"{VASP_OUT_DIR}/vasprun.xml.gz"
        vasp_run = Vasprun(filepath, parse_potcar_file=False)
        # Test that as_dict() is json-serializable
        assert json.dumps(vasp_run.as_dict()) is not None
        assert vasp_run.as_dict()["input"]["potcar_type"] == [
            "PAW_PBE",
            "PAW_PBE",
            "PAW_PBE",
            "PAW_PBE",
            "PAW_PBE",
        ]
        assert vasp_run.as_dict()["input"]["nkpoints"] == 24

    def test_get_band_structure(self):
        filepath = f"{VASP_OUT_DIR}/vasprun_Si_bands.xml.gz"
        vasp_run = Vasprun(filepath, parse_projected_eigen=True, parse_potcar_file=False)
        band_struct = vasp_run.get_band_structure(kpoints_filename=f"{VASP_IN_DIR}/KPOINTS_Si_bands")
        cbm = band_struct.get_cbm()
        vbm = band_struct.get_vbm()
        assert cbm["kpoint_index"] == [13], "wrong cbm kpoint index"
        assert cbm["energy"] == approx(6.2301), "wrong cbm energy"
        assert cbm["band_index"] == {Spin.up: [4], Spin.down: [4]}, "wrong cbm bands"
        assert vbm["kpoint_index"] == [0, 63, 64]
        assert vbm["energy"] == approx(5.6158), "wrong vbm energy"
        assert vbm["band_index"] == {
            Spin.up: [1, 2, 3],
            Spin.down: [1, 2, 3],
        }, "wrong vbm bands"
        assert vbm["kpoint"].label == "\\Gamma", "wrong vbm label"
        assert cbm["kpoint"].label is None, "wrong cbm label"

        projected = band_struct.get_projection_on_elements()
        assert projected[Spin.up][0][0]["Si"] == approx(0.4238)
        projected = band_struct.get_projections_on_elements_and_orbitals({"Si": ["s"]})
        assert projected[Spin.up][0][0]["Si"]["s"] == approx(0.4238)

        # Test compressed files case 1: compressed KPOINTS in current dir
        copyfile(f"{VASP_OUT_DIR}/vasprun_Si_bands.xml.gz", "vasprun.xml.gz")

        # Check for error if no KPOINTS file
        vasp_run = Vasprun("vasprun.xml.gz", parse_projected_eigen=True, parse_potcar_file=False)
        with pytest.raises(
            VaspParseError,
            match="KPOINTS not found but needed to obtain band structure along symmetry lines",
        ):
            _ = vasp_run.get_band_structure(line_mode=True)

        # Check KPOINTS.gz successfully inferred and used if present
        with (
            open(f"{VASP_IN_DIR}/KPOINTS_Si_bands", "rb") as f_in,
            gzip.open("KPOINTS.gz", "wb") as f_out,
        ):
            copyfileobj(f_in, f_out)
        bs_kpts_gzip = vasp_run.get_band_structure()
        assert band_struct.efermi == bs_kpts_gzip.efermi
        assert band_struct.as_dict() == bs_kpts_gzip.as_dict()

        # Test compressed files case 2: compressed vasprun in another dir
        os.mkdir("deeper")
        copyfile(f"{VASP_IN_DIR}/KPOINTS_Si_bands", Path("deeper") / "KPOINTS")
        copyfile(f"{VASP_OUT_DIR}/vasprun_Si_bands.xml.gz", Path("deeper") / "vasprun.xml.gz")
        vasp_run = Vasprun(
            f"{'deeper'}/vasprun.xml.gz",
            parse_projected_eigen=True,
            parse_potcar_file=False,
        )
        bs_vasprun_gzip = vasp_run.get_band_structure(line_mode=True)
        assert band_struct.efermi == bs_vasprun_gzip.efermi
        assert band_struct.as_dict() == bs_vasprun_gzip.as_dict()

        # test hybrid band structures
        vasp_run.actual_kpoints_weights[-1] = 0.0
        band_struct = vasp_run.get_band_structure(kpoints_filename=f"{VASP_IN_DIR}/KPOINTS_Si_bands")
        cbm = band_struct.get_cbm()
        vbm = band_struct.get_vbm()
        assert cbm["kpoint_index"] == [0]
        assert cbm["energy"] == approx(6.3676)
        assert cbm["kpoint"].label is None
        assert vbm["kpoint_index"] == [0]
        assert vbm["energy"] == approx(2.8218)
        assert vbm["kpoint"].label is None

        # test self-consistent band structure calculation for non-hybrid functionals
        vasp_run = Vasprun(
            f"{VASP_OUT_DIR}/vasprun.force_hybrid_like_calc.xml.gz",
            parse_projected_eigen=True,
            parse_potcar_file=False,
        )
        band_struct = vasp_run.get_band_structure(
            kpoints_filename=f"{VASP_IN_DIR}/KPOINTS_force_hybrid_like_calc",
            force_hybrid_mode=True,
            line_mode=True,
        )

        dict_to_test = band_struct.get_band_gap()

        assert dict_to_test["direct"]
        assert dict_to_test["energy"] == approx(6.007899999999999)
        assert dict_to_test["transition"] == "\\Gamma-\\Gamma"
        assert band_struct.get_branch(0)[0]["start_index"] == 0
        assert band_struct.get_branch(0)[0]["end_index"] == 0

    def test_projected_magnetization(self):
        filepath = f"{VASP_OUT_DIR}/vasprun.lvel.Si2H.xml.gz"
        vasp_run = Vasprun(filepath, parse_projected_eigen=True)
        assert vasp_run.projected_magnetization is not None
        assert vasp_run.projected_magnetization.shape == (76, 240, 4, 9, 3)
        assert vasp_run.projected_magnetization[0, 0, 0, 0, 0] == approx(-0.0712)

    def test_smart_efermi(self):
        # branch 1 - E_fermi does not cross a band
        vrun = Vasprun(f"{VASP_OUT_DIR}/vasprun.LiF.xml.gz")
        smart_fermi = vrun.calculate_efermi()
        assert smart_fermi == approx(vrun.efermi, abs=1e-4)
        eigen_gap = vrun.eigenvalue_band_properties[0]
        bs_gap = vrun.get_band_structure(efermi=smart_fermi).get_band_gap()["energy"]
        assert bs_gap == approx(eigen_gap, abs=1e-3)

        # branch 2 - E_fermi crosses a band but bandgap=0
        vrun = Vasprun(f"{VASP_OUT_DIR}/vasprun.Al.xml.gz")
        smart_fermi = vrun.calculate_efermi()
        assert smart_fermi == approx(vrun.efermi, abs=1e-4)
        eigen_gap = vrun.eigenvalue_band_properties[0]
        bs_gap = vrun.get_band_structure(efermi=smart_fermi).get_band_gap()["energy"]
        assert bs_gap == approx(eigen_gap, abs=1e-3)

        # branch 3 - E_fermi crosses a band in an insulator
        vrun = Vasprun(f"{VASP_OUT_DIR}/vasprun.LiH_bad_efermi.xml.gz")
        smart_fermi = vrun.calculate_efermi()
        assert smart_fermi != approx(vrun.efermi, abs=1e-4)
        eigen_gap = vrun.eigenvalue_band_properties[0]
        bs_gap = vrun.get_band_structure(efermi="smart").get_band_gap()["energy"]
        assert bs_gap == approx(eigen_gap, abs=1e-3)
        assert vrun.get_band_structure(efermi=None).get_band_gap()["energy"] != approx(eigen_gap, abs=1e-3)
        assert bs_gap != 0

        # branch 4 - E_fermi incorrectly placed inside a band
        vrun = Vasprun(f"{VASP_OUT_DIR}/vasprun.bad_fermi.xml.gz")
        smart_fermi = vrun.calculate_efermi()
        assert smart_fermi == approx(6.0165)

    def test_float_overflow(self):
        # test we interpret VASP's *********** for overflowed values as NaNs
        # https://github.com/materialsproject/pymatgen/pull/3452
        filepath = f"{VASP_OUT_DIR}/vasprun.sc_overflow.xml.gz"
        with pytest.warns(UserWarning, match="Float overflow .* encountered in vasprun"):
            vasp_run = Vasprun(filepath)

        first_ionic_step = vasp_run.ionic_steps[0]
        elec_step = first_ionic_step["electronic_steps"][29]
        assert np.isnan(elec_step["e_wo_entrp"])
        assert np.isnan(elec_step["e_fr_energy"])
        assert np.isnan(first_ionic_step["forces"]).any()

    def test_update_potcar(self):
        filepath = f"{VASP_OUT_DIR}/vasprun.xml.gz"
        potcar_path = f"{VASP_IN_DIR}/POTCAR_LiFePO4.gz"
        potcar_path2 = f"{VASP_IN_DIR}/POTCAR_2_LiFePO4.gz"

        vasp_run = Vasprun(filepath, parse_potcar_file=False)
        potcars = Potcar.from_file(potcar_path)
        expected_spec = [{"titel": titel, "hash": None, "summary_stats": {}} for titel in vasp_run.potcar_symbols]
        assert vasp_run.potcar_spec == expected_spec

        vasp_run.update_potcar_spec(potcar_path)
        potcars = Potcar.from_file(potcar_path)
        expected_spec = []
        for titel in vasp_run.potcar_symbols:
            for potcar in potcars:
                if titel == potcar.TITEL:
                    break
            expected_spec += [
                {
                    "titel": titel,
                    "hash": None,
                    "summary_stats": potcar._summary_stats,
                }
            ]
        assert vasp_run.potcar_spec == expected_spec

        with pytest.warns(UserWarning, match="No POTCAR file with matching TITEL fields was found in"):
            Vasprun(filepath, parse_potcar_file=potcar_path2)

        vasp_run = Vasprun(filepath, parse_potcar_file=potcar_path)
        assert vasp_run.potcar_spec == expected_spec

        with pytest.warns(UserWarning, match="No POTCAR file with matching TITEL fields was found in"):
            Vasprun(filepath, parse_potcar_file=potcar_path2)

    def test_search_for_potcar(self):
        filepath = f"{VASP_OUT_DIR}/vasprun.xml.gz"
        vasp_run = Vasprun(filepath, parse_potcar_file=True)
        assert [spec["titel"] for spec in vasp_run.potcar_spec] == [
            "PAW_PBE Li 17Jan2003",
            "PAW_PBE Fe 06Sep2000",
            "PAW_PBE Fe 06Sep2000",
            "PAW_PBE P 17Jan2003",
            "PAW_PBE O 08Apr2002",
        ]

    def test_potcar_not_found(self):
        filepath = f"{VASP_OUT_DIR}/vasprun.xml.gz"
        # Ensure no potcar is found and nothing is updated
        with pytest.warns(UserWarning, match="No POTCAR file with matching TITEL fields was found in") as warns:
            vasp_run = Vasprun(filepath, parse_potcar_file=".")
        assert len(warns) >= 2, f"{len(warns)=}"
        assert vasp_run.potcar_spec == [
            {"titel": "PAW_PBE Li 17Jan2003", "hash": None, "summary_stats": {}},
            {"titel": "PAW_PBE Fe 06Sep2000", "hash": None, "summary_stats": {}},
            {"titel": "PAW_PBE Fe 06Sep2000", "hash": None, "summary_stats": {}},
            {"titel": "PAW_PBE P 17Jan2003", "hash": None, "summary_stats": {}},
            {"titel": "PAW_PBE O 08Apr2002", "hash": None, "summary_stats": {}},
        ]

    def test_parsing_chemical_shift_calculations(self):
        filepath = f"{TEST_DIR}/fixtures/nmr/cs/basic/vasprun.xml.chemical_shift.scstep"
        vasp_run = Vasprun(filepath)

        n_estep = len(vasp_run.ionic_steps[-1]["electronic_steps"])
        assert n_estep == 10
        assert vasp_run.converged

    def test_parsing_efg_calcs(self):
        filepath = f"{TEST_DIR}/fixtures/nmr/efg/AlPO4/vasprun.xml"
        vasp_run = Vasprun(filepath)
        n_elec_steps = len(vasp_run.ionic_steps[-1]["electronic_steps"])
        assert n_elec_steps == 18
        assert vasp_run.converged

    def test_charged_structure(self):
        vpath = f"{VASP_OUT_DIR}/vasprun.charged.xml.gz"
        potcar_path = f"{FAKE_POTCAR_DIR}/POT_GGA_PAW_PBE/POTCAR.Si.gz"
        vasp_run = Vasprun(vpath, parse_potcar_file=False)
        vasp_run.update_charge_from_potcar(potcar_path)
        assert vasp_run.parameters.get("NELECT", 8) == 9
        assert vasp_run.structures[0].charge == -1
        assert vasp_run.initial_structure.charge == -1
        assert vasp_run.final_structure.charge == -1

        vpath = f"{VASP_OUT_DIR}/vasprun.split.charged.xml.gz"
        potcar_path = f"{VASP_IN_DIR}/POTCAR_split_charged.gz"
        vasp_run = Vasprun(vpath, parse_potcar_file=False)
        vasp_run.update_charge_from_potcar(potcar_path)
        assert vasp_run.parameters.get("NELECT", 0) == 7
        assert vasp_run.structures[-1].charge == -1
        assert vasp_run.initial_structure.charge == -1
        assert vasp_run.final_structure.charge == -1

    def test_kpointset_electronvelocities(self):
        vpath = f"{VASP_OUT_DIR}/vasprun.lvel.Si2H.xml.gz"
        vasp_run = Vasprun(vpath, parse_potcar_file=False)
        assert vasp_run.eigenvalues[Spin.up].shape[0] == len(vasp_run.actual_kpoints)

    def test_eigenvalue_band_properties_separate_spins(self):
        eig = Vasprun(f"{VASP_OUT_DIR}/vasprun_eig_separate_spins.xml.gz", separate_spins=True)
        props = eig.eigenvalue_band_properties
        eig2 = Vasprun(f"{VASP_OUT_DIR}/vasprun_eig_separate_spins.xml.gz", separate_spins=False)
        props2 = eig2.eigenvalue_band_properties
        assert props[0][0] == approx(2.8772, abs=1e-4)
        assert props[0][1] == approx(1.2810, abs=1e-4)
        assert props[1][0] == approx(3.6741, abs=1e-4)
        assert props[1][1] == approx(1.6225, abs=1e-4)
        assert props[2][0] == approx(0.7969, abs=1e-4)
        assert props[2][1] == approx(0.3415, abs=1e-4)
        assert props2[0] == approx(np.min(props[1]) - np.max(props[2]), abs=1e-4)
        assert props[3][0]
        assert props[3][1]

    def test_kpoints_opt(self):
        vasp_run = Vasprun(f"{TEST_DIR}/fixtures/kpoints_opt/vasprun.xml.gz", parse_projected_eigen=True)
        # This calculation was run using KPOINTS_OPT
        kpt_opt_props = vasp_run.kpoints_opt_props
        # Check the k-points were read correctly.
        assert len(vasp_run.actual_kpoints) == 10
        assert len(vasp_run.actual_kpoints_weights) == 10
        assert len(kpt_opt_props.actual_kpoints) == 100
        assert len(kpt_opt_props.actual_kpoints_weights) == 100
        # Check the eigenvalues were read correctly.
        assert vasp_run.eigenvalues[Spin.up].shape == (10, 24, 2)
        assert kpt_opt_props.eigenvalues[Spin.up].shape == (100, 24, 2)
        assert vasp_run.eigenvalues[Spin.up][0, 0, 0] == approx(-6.1471)
        assert kpt_opt_props.eigenvalues[Spin.up][0, 0, 0] == approx(-6.1536)
        # Check the projected eigenvalues were read correctly
        assert vasp_run.projected_eigenvalues[Spin.up].shape == (10, 24, 8, 9)
        assert kpt_opt_props.projected_eigenvalues[Spin.up].shape == (100, 24, 8, 9)
        assert vasp_run.projected_eigenvalues[Spin.up][0, 1, 0, 0] == approx(0.0492)
        # I think these zeroes are a bug in VASP (maybe my VASP) transcribing from PROCAR_OPT to vasprun.xml
        # No matter. The point of the parser is to read what's in the file.
        assert kpt_opt_props.projected_eigenvalues[Spin.up][0, 1, 0, 0] == approx(0.0000)
        # Test as_dict
        vasp_run_dct = vasp_run.as_dict()
        assert vasp_run_dct["input"]["nkpoints_opt"] == 100
        assert vasp_run_dct["input"]["nkpoints"] == 10
        assert vasp_run_dct["output"]["eigenvalues_kpoints_opt"]["1"][0][0][0] == approx(-6.1536)

    def test_kpoints_opt_band_structure(self):
        vasp_run = Vasprun(
            f"{TEST_DIR}/fixtures/kpoints_opt/vasprun.xml.gz", parse_potcar_file=False, parse_projected_eigen=True
        )
        bs = vasp_run.get_band_structure(f"{TEST_DIR}/fixtures/kpoints_opt/KPOINTS_OPT")
        assert isinstance(bs, BandStructureSymmLine)
        cbm = bs.get_cbm()
        vbm = bs.get_vbm()
        assert cbm["kpoint_index"] == [38], "wrong cbm kpoint index"
        assert cbm["energy"] == approx(6.4394), "wrong cbm energy"
        assert cbm["band_index"] == {Spin.up: [16], Spin.down: [16]}, "wrong cbm bands"
        assert vbm["kpoint_index"] == [0, 39, 40]
        assert vbm["energy"] == approx(5.7562), "wrong vbm energy"
        assert vbm["band_index"] == {
            Spin.down: [13, 14, 15],
            Spin.up: [13, 14, 15],
        }, "wrong vbm bands"
        vbm_kp_label = vbm["kpoint"].label
        assert vbm["kpoint"].label == "\\Gamma", f"Unpexpected {vbm_kp_label=}"
        cmb_kp_label = cbm["kpoint"].label
        assert cmb_kp_label is None, f"Unpexpected {cmb_kp_label=}"
        # Test projection
        projected = bs.get_projection_on_elements()
        assert np.isnan(projected[Spin.up][0][0]["Si"])
        # Due to some error in my VASP, the transcription of PROCAR_OPT into
        # vasprun.xml is filled to the brim with errors in the projections.
        # At some point we might get a healthier vasprun.xml, but the point here
        # is to test the parser, not VASP.
        projected = bs.get_projections_on_elements_and_orbitals({"Si": ["s"]})
        assert projected[Spin.up][0][58]["Si"]["s"] == approx(-0.0271)

    def test_parse_potcar_cwd_relative(self):
        # Test to ensure that common use cases of vasprun parsing work
        # in the current working directory using relative paths,
        # either when leading ./ is specified or not for vasprun.xml
        # See gh-3586
        copyfile(f"{VASP_OUT_DIR}/vasprun.Al.xml.gz", "vasprun.xml.gz")

        potcar_path = f"{VASP_IN_DIR}/fake_potcars/POTPAW_PBE_54/POTCAR.Al.gz"
        copyfile(potcar_path, "POTCAR.gz")

        potcar = Potcar.from_file(potcar_path)
        for leading_path in ("", "./"):
            vrun = Vasprun(f"{leading_path}vasprun.xml.gz", parse_potcar_file=True)
            # Note that the TITEL is not updated in Vasprun.potcar_spec
            # Since the fake POTCARs modify the TITEL (to indicate fakeness), can't compare
            for ipot in range(len(potcar)):
                assert vrun.potcar_spec[ipot]["summary_stats"] == potcar[ipot]._summary_stats


class TestOutcar:
    @pytest.mark.parametrize("compressed", [True, False])
    def test_init(self, compressed: bool):
        """Test from both compressed and uncompressed versions,
        as there was a bug in monty causing different behaviours.
        """
        if compressed:
            outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.gz")
        else:
            copyfile(f"{VASP_OUT_DIR}/OUTCAR.gz", "./OUTCAR.gz")
            decompress_file("./OUTCAR.gz")
            outcar = Outcar("./OUTCAR")

        expected_mag = (
            {"d": 0.0, "p": 0.003, "s": 0.002, "tot": 0.005},
            {"d": 0.798, "p": 0.008, "s": 0.007, "tot": 0.813},
            {"d": 0.798, "p": 0.008, "s": 0.007, "tot": 0.813},
            {"d": 0.0, "p": -0.117, "s": 0.005, "tot": -0.112},
            {"d": 0.0, "p": -0.165, "s": 0.004, "tot": -0.162},
            {"d": 0.0, "p": -0.117, "s": 0.005, "tot": -0.112},
            {"d": 0.0, "p": -0.165, "s": 0.004, "tot": -0.162},
        )
        expected_chg = (
            {"p": 0.154, "s": 0.078, "d": 0.0, "tot": 0.232},
            {"p": 0.707, "s": 0.463, "d": 8.316, "tot": 9.486},
            {"p": 0.707, "s": 0.463, "d": 8.316, "tot": 9.486},
            {"p": 3.388, "s": 1.576, "d": 0.0, "tot": 4.964},
            {"p": 3.365, "s": 1.582, "d": 0.0, "tot": 4.947},
            {"p": 3.388, "s": 1.576, "d": 0.0, "tot": 4.964},
            {"p": 3.365, "s": 1.582, "d": 0.0, "tot": 4.947},
        )

        assert outcar.magnetization == approx(expected_mag, abs=1e-5), "Wrong magnetization read from Outcar"
        assert outcar.charge == approx(expected_chg, abs=1e-5), "Wrong charge read from Outcar"
        assert not outcar.is_stopped
        assert outcar.run_stats == {
            "System time (sec)": 0.938,
            "Total CPU time used (sec)": 545.142,
            "Elapsed time (sec)": 546.709,
            "Maximum memory used (kb)": 0.0,
            "Average memory used (kb)": 0.0,
            "User time (sec)": 544.204,
            "cores": 8,
        }
        assert outcar.efermi == approx(2.0112)
        assert outcar.nelect == approx(44.9999991)
        assert outcar.total_mag == approx(0.9999998)

        assert outcar.as_dict() is not None

        assert not outcar.lepsilon

        toten = 0
        for k in outcar.final_energy_contribs:
            toten += outcar.final_energy_contribs[k]
        assert toten == approx(outcar.final_energy, abs=1e-6)

    def test_stopped_old(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR.stopped.gz"
        outcar = Outcar(filepath)
        assert outcar.is_stopped

        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.lepsilon_old_born.gz")

        assert outcar.lepsilon
        assert outcar.dielectric_tensor[0][0] == approx(3.716432)
        assert outcar.dielectric_tensor[0][1] == approx(-0.20464)
        assert outcar.dielectric_tensor[1][2] == approx(-0.20464)
        assert outcar.dielectric_ionic_tensor[0][0] == approx(0.001419)
        assert outcar.dielectric_ionic_tensor[0][2] == approx(0.001419)
        assert outcar.dielectric_ionic_tensor[2][2] == approx(0.001419)
        assert outcar.piezo_tensor[0][0] == approx(0.52799)
        assert outcar.piezo_tensor[1][3] == approx(0.35998)
        assert outcar.piezo_tensor[2][5] == approx(0.35997)
        assert outcar.piezo_ionic_tensor[0][0] == approx(0.05868)
        assert outcar.piezo_ionic_tensor[1][3] == approx(0.06241)
        assert outcar.piezo_ionic_tensor[2][5] == approx(0.06242)
        assert outcar.born[0][1][2] == approx(-0.385)
        assert outcar.born[1][2][0] == approx(0.36465)
        assert outcar.internal_strain_tensor[0][0][0] == approx(-572.5437, abs=1e-4)
        assert outcar.internal_strain_tensor[0][1][0] == approx(683.2985, abs=1e-4)
        assert outcar.internal_strain_tensor[0][1][3] == approx(73.07059, abs=1e-4)
        assert outcar.internal_strain_tensor[1][0][0] == approx(570.98927, abs=1e-4)
        assert outcar.internal_strain_tensor[1][1][0] == approx(-683.68519, abs=1e-4)
        assert outcar.internal_strain_tensor[1][2][2] == approx(570.98927, abs=1e-4)

    def test_stopped(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR.stopped.gz"
        outcar = Outcar(filepath)
        assert outcar.is_stopped

        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.lepsilon.gz")

        assert outcar.lepsilon
        assert outcar.dielectric_tensor[0][0] == approx(3.716432)
        assert outcar.dielectric_tensor[0][1] == approx(-0.20464)
        assert outcar.dielectric_tensor[1][2] == approx(-0.20464)
        assert outcar.dielectric_ionic_tensor[0][0] == approx(0.001419)
        assert outcar.dielectric_ionic_tensor[0][2] == approx(0.001419)
        assert outcar.dielectric_ionic_tensor[2][2] == approx(0.001419)
        assert outcar.piezo_tensor[0][0] == approx(0.52799)
        assert outcar.piezo_tensor[1][3] == approx(0.35998)
        assert outcar.piezo_tensor[2][5] == approx(0.35997)
        assert outcar.piezo_ionic_tensor[0][0] == approx(0.05868)
        assert outcar.piezo_ionic_tensor[1][3] == approx(0.06241)
        assert outcar.piezo_ionic_tensor[2][5] == approx(0.06242)
        assert outcar.born[0][1][2] == approx(-0.385)
        assert outcar.born[1][2][0] == approx(0.36465)
        assert outcar.internal_strain_tensor[0][0][0] == approx(-572.5437, abs=1e-4)
        assert outcar.internal_strain_tensor[0][1][0] == approx(683.2985, abs=1e-4)
        assert outcar.internal_strain_tensor[0][1][3] == approx(73.07059, abs=1e-4)
        assert outcar.internal_strain_tensor[1][0][0] == approx(570.98927, abs=1e-4)
        assert outcar.internal_strain_tensor[1][1][0] == approx(-683.68519, abs=1e-4)
        assert outcar.internal_strain_tensor[1][2][2] == approx(570.98927, abs=1e-4)

    def test_soc(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR.NiO_SOC.gz"
        outcar = Outcar(filepath)
        expected_mag = (
            {
                "s": Magmom([0.0, 0.0, -0.001]),
                "p": Magmom([0.0, 0.0, -0.003]),
                "d": Magmom([0.0, 0.0, 1.674]),
                "tot": Magmom([0.0, 0.0, 1.671]),
            },
            {
                "s": Magmom([0.0, 0.0, 0.001]),
                "p": Magmom([0.0, 0.0, 0.003]),
                "d": Magmom([0.0, 0.0, -1.674]),
                "tot": Magmom([0.0, 0.0, -1.671]),
            },
            {
                "s": Magmom([0.0, 0.0, 0.0]),
                "p": Magmom([0.0, 0.0, 0.0]),
                "d": Magmom([0.0, 0.0, 0.0]),
                "tot": Magmom([0.0, 0.0, 0.0]),
            },
            {
                "s": Magmom([0.0, 0.0, 0.0]),
                "p": Magmom([0.0, 0.0, 0.0]),
                "d": Magmom([0.0, 0.0, 0.0]),
                "tot": Magmom([0.0, 0.0, 0.0]),
            },
        )
        # test note: Magmom class uses np.allclose() when testing for equality
        # so fine to use == operator here
        assert outcar.magnetization == expected_mag, "Wrong vector magnetization read from Outcar for SOC calculation"

        assert outcar.noncollinear is True

    def test_polarization(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR.BaTiO3.polar"
        outcar = Outcar(filepath)
        assert outcar.spin
        assert outcar.noncollinear is False
        assert outcar.p_ion == approx([0.0, 0.0, -5.56684])
        assert outcar.p_elec == approx([0.00024, 0.00019, 3.61674])

    def test_pseudo_zval(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR.BaTiO3.polar"
        outcar = Outcar(filepath)
        assert outcar.zval_dict == approx({"Ba": 10.0, "Ti": 10.0, "O": 6.0})

        filepath = f"{VASP_OUT_DIR}/OUTCAR.LaSnNO2.polar"
        outcar = Outcar(filepath)
        assert outcar.zval_dict == approx({"La": 11.0, "N": 5.0, "O": 6.0, "Sn": 14.0})

    def test_dielectric(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR.dielectric"
        outcar = Outcar(filepath)
        outcar.read_corrections()
        assert outcar.data["dipol_quadrupol_correction"] == approx(0.03565)
        assert outcar.final_energy == approx(-797.46294064)

    def test_freq_dielectric(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR.LOPTICS"
        outcar = Outcar(filepath)
        outcar.read_freq_dielectric()
        assert outcar.dielectric_energies[0] == approx(0)
        assert outcar.dielectric_energies[-1] == approx(39.826101)
        assert outcar.dielectric_tensor_function[0][0, 0] == approx(8.96938800)
        assert outcar.dielectric_tensor_function[-1][0, 0] == approx(7.36167000e-01 + 1.53800000e-03j)
        assert len(outcar.dielectric_energies) == len(outcar.dielectric_tensor_function)
        assert_allclose(
            outcar.dielectric_tensor_function[0],
            outcar.dielectric_tensor_function[0].transpose(),
        )

        plasma_freq = outcar.plasma_frequencies
        assert_allclose(plasma_freq["intraband"], np.zeros((3, 3)))
        assert_allclose(
            plasma_freq["interband"],
            [
                [367.49, 63.939, 11.976],
                [63.939, 381.155, -24.461],
                [11.976, -24.461, 297.844],
            ],
        )

    def test_freq_dielectric_vasp544(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR.LOPTICS.vasp544"
        outcar = Outcar(filepath)
        outcar.read_freq_dielectric()
        assert outcar.dielectric_energies[0] == approx(0)
        assert outcar.dielectric_energies[-1] == approx(39.63964)
        assert outcar.dielectric_tensor_function[0][0, 0] == approx(12.769435 + 0j)
        assert outcar.dielectric_tensor_function[-1][0, 0] == approx(0.828615 + 0.016594j)
        assert len(outcar.dielectric_energies) == len(outcar.dielectric_tensor_function)
        assert_allclose(
            outcar.dielectric_tensor_function[0],
            outcar.dielectric_tensor_function[0].transpose(),
        )

    def test_parse_sci_notation(self):
        invalid_pattern = "23535.35 35235.34 325325.3"
        valid_pattern1 = " 0.00000E+00 0.00000E+00 0.00000E+00 0.00000E+00 0.00000E+00 0.00000E+00 0.00000E+00"
        valid_pattern2 = " 0.62963E+00 0.15467E+02 0.15467E+02 0.15467E+02-0.30654E-16-0.91612E-16 0.52388E-16"

        assert Outcar._parse_sci_notation(invalid_pattern) == []
        assert Outcar._parse_sci_notation(valid_pattern1) == [0, 0, 0, 0, 0, 0, 0]
        assert Outcar._parse_sci_notation(valid_pattern2) == [
            0.62963,
            0.15467e02,
            0.15467e02,
            0.15467e02,
            -0.30654e-16,
            -0.91612e-16,
            0.52388e-16,
        ]

    def test_read_elastic_tensor(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR.total_tensor.Li2O.gz"
        outcar = Outcar(filepath)

        outcar.read_elastic_tensor()

        assert outcar.data["elastic_tensor"][0][0] == approx(1986.3391)
        assert outcar.data["elastic_tensor"][0][1] == approx(187.8324)
        assert outcar.data["elastic_tensor"][3][3] == approx(586.3034)

    def test_read_lcalcpol(self):
        # outcar with electrons Angst units
        folder = "io/vasp/fixtures/BTO_221_99_polarization/interpolation_6_polarization/"
        filepath = TEST_FILES_DIR / folder / "OUTCAR"
        outcar = Outcar(filepath)

        outcar.read_lcalcpol()

        p_ion = [0.0, 0.0, 19.70306]
        p_elec = [4.02264, 4.02263, -4.08851]
        p_sp1 = [2.01124, 2.01124, -2.04426]
        p_sp2 = [2.01139, 2.01139, -2.04426]

        assert outcar.p_ion == approx(p_ion)
        assert outcar.p_elec == approx(p_elec)
        assert outcar.p_sp1 == approx(p_sp1)
        assert outcar.p_sp2 == approx(p_sp2)

        # outcar with |e| Angst units
        filepath = f"{VASP_OUT_DIR}/OUTCAR_vasp_6.3.gz"
        outcar = Outcar(filepath)

        outcar.read_lcalcpol()

        p_ion = [-79.03374, -0.0, -28.44354]
        p_elec = [9.01127e00, -1.00000e-05, 3.24308e00]
        p_sp1 = [4.50564, 0.0, 1.62154]
        p_sp2 = [4.50563e00, -1.00000e-05, 1.62154e00]

        assert outcar.p_ion == approx(p_ion)
        assert outcar.p_elec == approx(p_elec)
        assert outcar.p_sp1 == approx(p_sp1)
        assert outcar.p_sp2 == approx(p_sp2)

    def test_read_piezo_tensor(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR.lepsilon.gz"
        outcar = Outcar(filepath)

        outcar.read_piezo_tensor()
        assert outcar.data["piezo_tensor"][0][0] == approx(0.52799)
        assert outcar.data["piezo_tensor"][1][3] == approx(0.35998)
        assert outcar.data["piezo_tensor"][2][5] == approx(0.35997)

    def test_core_state_eigen(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR.CL.gz"
        cl = Outcar(filepath).read_core_state_eigen()
        assert cl[6]["2s"][-1] == approx(-174.4779)
        filepath = f"{VASP_OUT_DIR}/OUTCAR.icorelevel"
        outcar = Outcar(filepath)
        cl = outcar.read_core_state_eigen()
        assert cl[4]["3d"][-1] == approx(-31.4522)

        # test serialization
        outcar.as_dict()

    def test_avg_core_poten(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR.lepsilon.gz"
        cp = Outcar(filepath).read_avg_core_poten()
        assert cp[-1][1] == approx(-90.0487)

        filepath = f"{VASP_OUT_DIR}/OUTCAR.gz"
        cp = Outcar(filepath).read_avg_core_poten()
        assert cp[0][6] == approx(-73.1068)

        filepath = f"{VASP_OUT_DIR}/OUTCAR.bad_core_poten.gz"
        cp = Outcar(filepath).read_avg_core_poten()
        assert cp[0][1] == approx(-101.5055)

    def test_single_atom(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR.Al"
        outcar = Outcar(filepath)
        expected_mag = ({"p": 0.0, "s": 0.0, "d": 0.0, "tot": 0.0},)
        expected_chg = ({"p": 0.343, "s": 0.425, "d": 0.0, "tot": 0.768},)

        assert outcar.magnetization == approx(expected_mag)
        assert outcar.charge == approx(expected_chg)
        assert not outcar.is_stopped
        assert outcar.run_stats == {
            "System time (sec)": 0.592,
            "Total CPU time used (sec)": 50.194,
            "Elapsed time (sec)": 52.337,
            "Maximum memory used (kb)": 62900.0,
            "Average memory used (kb)": 0.0,
            "User time (sec)": 49.602,
            "cores": 32,
        }
        assert outcar.efermi == approx(8.0942)
        assert outcar.nelect == 3
        assert outcar.total_mag == approx(8.2e-06)

        assert outcar.as_dict() is not None

    def test_chemical_shielding(self):
        filename = f"{TEST_DIR}/fixtures/nmr/cs/core.diff/hydromagnesite/OUTCAR"
        outcar = Outcar(filename)
        expected_chemical_shielding = [
            [191.9974, 69.5232, 0.6342],
            [195.0808, 68.183, 0.833],
            [192.0389, 69.5762, 0.6329],
            [195.0844, 68.1756, 0.8336],
            [192.005, 69.5289, 0.6339],
            [195.0913, 68.1859, 0.833],
            [192.0237, 69.565, 0.6333],
            [195.0788, 68.1733, 0.8337],
        ]

        assert len(outcar.data["chemical_shielding"]["valence_only"][20:28]) == approx(len(expected_chemical_shielding))

        assert_allclose(
            outcar.data["chemical_shielding"]["valence_and_core"][20:28],
            expected_chemical_shielding,
            atol=1e-5,
        )

    def test_chemical_shielding_with_different_core_contribution(self):
        filename = f"{TEST_DIR}/fixtures/nmr/cs/core.diff/core.diff.chemical.shifts.OUTCAR"
        outcar = Outcar(filename)
        c_vo = outcar.data["chemical_shielding"]["valence_only"][7]
        assert list(c_vo) == approx([198.7009, 73.7484, 1])
        c_vc = outcar.data["chemical_shielding"]["valence_and_core"][7]
        assert list(c_vc) == approx([-1.9406, 73.7484, 1])

    def test_cs_raw_tensors(self):
        filename = f"{TEST_DIR}/fixtures/nmr/cs/core.diff/core.diff.chemical.shifts.OUTCAR"
        outcar = Outcar(filename)
        unsym_tensors = outcar.data["unsym_cs_tensor"]
        assert unsym_tensors[0] == [
            [-145.814605, -4.263425, 0.000301],
            [4.263434, -145.812238, -8.7e-05],
            [0.000136, -0.000189, -142.794068],
        ]
        assert unsym_tensors[29] == [
            [287.789318, -53.799325, 30.900024],
            [-53.799571, 225.668117, -17.839598],
            [3.801103, -2.195218, 88.896756],
        ]

    def test_cs_g0_contribution(self):
        filename = f"{TEST_DIR}/fixtures/nmr/cs/core.diff/core.diff.chemical.shifts.OUTCAR"
        outcar = Outcar(filename)
        g0_contrib = outcar.data["cs_g0_contribution"]
        assert g0_contrib == [
            [-8.773535, 9e-06, 1e-06],
            [1.7e-05, -8.773536, -0.0792],
            [-6e-06, -0.008328, -9.320237],
        ]

    def test_cs_core_contribution(self):
        filename = f"{TEST_DIR}/fixtures/nmr/cs/core.diff/core.diff.chemical.shifts.OUTCAR"
        outcar = Outcar(filename)
        core_contrib = outcar.data["cs_core_contribution"]
        assert core_contrib == {
            "Mg": -412.8248405,
            "C": -200.5098812,
            "O": -271.0766979,
        }

    def test_nmr_efg(self):
        filename = f"{TEST_DIR}/fixtures/nmr/efg/AlPO4/OUTCAR"
        outcar = Outcar(filename)
        expected_efg = [
            {"eta": 0.465, "nuclear_quadrupole_moment": 146.6, "cq": -5.573},
            {"eta": 0.465, "nuclear_quadrupole_moment": 146.6, "cq": -5.573},
            {"eta": 0.137, "nuclear_quadrupole_moment": 146.6, "cq": 6.327},
            {"eta": 0.137, "nuclear_quadrupole_moment": 146.6, "cq": 6.327},
            {"eta": 0.112, "nuclear_quadrupole_moment": 146.6, "cq": -7.453},
            {"eta": 0.112, "nuclear_quadrupole_moment": 146.6, "cq": -7.453},
            {"eta": 0.42, "nuclear_quadrupole_moment": 146.6, "cq": -5.58},
            {"eta": 0.42, "nuclear_quadrupole_moment": 146.6, "cq": -5.58},
        ]
        assert len(outcar.data["efg"][2:10]) == len(expected_efg)
        for e1, e2 in zip(outcar.data["efg"][2:10], expected_efg, strict=True):
            for k in e1:
                assert e1[k] == approx(e2[k], abs=1e-5)

        exepected_tensors = [
            [[11.11, 1.371, 2.652], [1.371, 3.635, -3.572], [2.652, -3.572, -14.746]],
            [[11.11, -1.371, 2.652], [-1.371, 3.635, 3.572], [2.652, 3.572, -14.746]],
            [[-3.098, 6.511, 7.732], [6.511, 1.419, 11.445], [7.732, 11.445, 1.678]],
            [
                [-3.098, -6.511, 7.732],
                [-6.511, 1.419, -11.445],
                [7.732, -11.445, 1.678],
            ],
            [
                [2.344, -10.775, -7.006],
                [-10.775, -7.152, -11.309],
                [-7.006, -11.309, 4.808],
            ],
            [
                [2.344, 10.775, -7.006],
                [10.775, -7.152, 11.309],
                [-7.006, 11.309, 4.808],
            ],
            [[2.404, -0.588, -6.83], [-0.588, 10.435, 3.159], [-6.83, 3.159, -12.839]],
            [[2.404, 0.588, -6.83], [0.588, 10.435, -3.159], [-6.83, -3.159, -12.839]],
        ]

        assert len(outcar.data["unsym_efg_tensor"][2:10]) == len(exepected_tensors)
        for e1, e2 in zip(outcar.data["unsym_efg_tensor"][2:10], exepected_tensors, strict=True):
            assert_allclose(e1, e2)

    def test_read_fermi_contact_shift(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR_fc"
        outcar = Outcar(filepath)
        outcar.read_fermi_contact_shift()
        assert outcar.data["fermi_contact_shift"]["fch"][0][0] == approx(-0.002)
        assert outcar.data["fermi_contact_shift"]["th"][0][0] == approx(-0.052)
        assert outcar.data["fermi_contact_shift"]["dh"][0][0] == approx(0.0)

    def test_read_vacuum_potentials(self):
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.vacuum_potentials.gz")
        outcar.read_vacuum_potentials()
        assert outcar.data["vacuum_potential_upper"] == approx(1.778)
        assert outcar.data["vacuum_potential_lower"] == approx(1.779)

    def test_drift(self):
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.gz")
        assert len(outcar.drift) == 5
        assert np.sum(outcar.drift) == approx(0)

        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.CL.gz")
        assert len(outcar.drift) == 79
        assert np.sum(outcar.drift) == approx(0.448010)

    def test_electrostatic_potential(self):
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.gz")
        assert outcar.ngf == [54, 30, 54]
        assert_allclose(outcar.sampling_radii, [0.9748, 0.9791, 0.7215])
        assert_allclose(
            outcar.electrostatic_potential,
            [-26.0704, -45.5046, -45.5046, -72.9539, -73.0621, -72.9539, -73.0621],
        )

    def test_mag_electrostatic_error(self):
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.electrostaticerror.gz")
        # fmt: off
        assert outcar.electrostatic_potential == [
            -21.1667, -19.6865, -22.3983, -22.3307, -20.5213, -20.9292, -21.5063, -21.3554, -21.74,
            -21.7018, -20.3422, -20.6128, -21.4405, -21.0022, -21.975, -21.915, -21.0156, -21.9027,
            -22.3712, -21.5816, -21.8535, -20.5061, -22.2474, -22.1904, -22.2203, -20.1727, -21.1068,
            -20.1669, -22.1272, -21.3446, -82.4717, -83.035, -81.8289, -82.5957, -81.7813, -82.5011,
            -82.6098, -82.2885, -81.606, -99.1621, -99.3146, -99.1742, -99.4728, -100.2139, -99.852,
            -99.3575, -99.4135, -98.9092, -99.8867, -99.3707, -99.0794, -98.8376, -99.3656, -98.6474,
            -99.3264, -98.844, -99.074, -98.9354, -99.1643, -99.2412, -68.7667, -68.2528, -66.7326,
            -67.7113, -69.2228, -67.014, -69.1456, -67.3151, -68.2625, -67.6156, -69.8112, -68.9266,
            -67.8286, -69.3289, -68.7017, -67.2834, -68.4665, -68.0188, -67.7083, -69.7195, -67.4078,
            -67.9646, -68.584, -69.2387, -69.7822, -67.0701, -67.8236, -68.2468, -68.6533, -68.3218,
            -67.5923, -69.1266, -68.4615, -68.302, -67.999, -68.6709, -68.9973, -67.4147, -68.4463,
            -68.0899, -67.665, -69.6705, -68.6433, -68.4288, -66.9027, -67.3211, -68.604, -69.1299,
            -67.5565, -69.0845, -67.4289, -66.6864, -67.6484, -67.9783, -67.7661, -66.9797, -67.8007,
            -68.3194, -69.3671, -67.2708,
        ]
        # fmt: on

    def test_onsite_density_matrix(self):
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.LinearResponseU.gz")
        matrices = outcar.data["onsite_density_matrices"]
        assert matrices[0][Spin.up][0][0] == approx(1.0227)
        assert len(matrices[0][Spin.up]) == 5
        assert len(matrices[0][Spin.up][0]) == 5
        assert "onsite_density_matrices" in outcar.as_dict()
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR_merged_numbers")
        matrices = outcar.data["onsite_density_matrices"]
        assert matrices[0][Spin.up][0][-1] == approx(0.0)
        assert len(matrices[0][Spin.up]) == 7
        assert len(matrices[0][Spin.up][0]) == 7
        assert "onsite_density_matrices" in outcar.as_dict()
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR_merged_numbers2")
        assert "onsite_density_matrices" in outcar.as_dict()

    def test_nbands(self):
        # Test VASP 5.2.11
        nbands = Outcar(f"{VASP_OUT_DIR}/OUTCAR.gz").data["nbands"]
        assert nbands == 33
        assert isinstance(nbands, int)

        # Test VASP 5.4.4
        assert Outcar(f"{VASP_OUT_DIR}/OUTCAR.LOPTICS.vasp544").data["nbands"] == 128

        # Test VASP 6.3.0
        assert Outcar(f"{VASP_OUT_DIR}/OUTCAR_vasp_6.3.gz").data["nbands"] == 64

        # Test NBANDS set by user but overridden by VASP
        # VASP 6.3.2
        assert Outcar(f"{VASP_OUT_DIR}/OUTCAR.nbands_overridden.gz").data["nbands"] == 32

    def test_nplwvs(self):
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.gz")
        assert outcar.data["nplwv"] == [[34560]]
        # fmt: off
        assert outcar.data["nplwvs_at_kpoints"] == [
            1719, 1714, 1722, 1728, 1722, 1726, 1722, 1720, 1717, 1724, 1715, 1724, 1726,
            1724, 1728, 1715, 1722, 1715, 1726, 1730, 1730, 1715, 1716, 1729, 1727, 1723,
            1721, 1712, 1723, 1719, 1717, 1717, 1724, 1719, 1719, 1727, 1726, 1730, 1719,
            1720, 1718, 1717, 1722, 1719, 1709, 1714, 1724, 1726, 1718, 1713, 1720, 1713,
            1711, 1713, 1715, 1717, 1728, 1726, 1712, 1722, 1714, 1713, 1717, 1714, 1714,
            1717, 1712, 1710, 1721, 1722, 1724, 1720, 1726, 1719, 1722, 1714,
        ]
        # fmt: on
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.CL.gz")
        assert outcar.data["nplwv"] == [[None]]
        assert outcar.data["nplwvs_at_kpoints"] == [85687]

    def test_serial_compilation(self):
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.serial.gz")
        assert outcar.data["nplwv"] == [[74088]]
        assert outcar.data["nplwvs_at_kpoints"] == [4418, 4390, 4421, 4404]

    def test_vasp620_format(self):
        filepath = f"{VASP_OUT_DIR}/OUTCAR.vasp.6.2.0.gz"
        outcar = Outcar(filepath)
        assert outcar.run_stats["Average memory used (kb)"] is None

        filepath = f"{VASP_OUT_DIR}/OUTCAR.vasp.6.2.1.mpi.gz"
        outcar = Outcar(filepath)
        assert outcar.run_stats["cores"] == 64

    def test_energies(self):
        # VASP 5.2.1
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.etest1.gz")
        assert outcar.final_energy == approx(-11.18981538)
        assert outcar.final_energy_wo_entrp == approx(-11.13480014)
        assert outcar.final_fr_energy == approx(-11.21732300)

        # VASP 6.2.1
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.etest2.gz")
        assert outcar.final_energy == approx(-11.18986774)
        assert outcar.final_energy_wo_entrp == approx(-11.13485250)
        assert outcar.final_fr_energy == approx(-11.21737536)

        # VASP 5.2.1
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.etest3.gz")
        assert outcar.final_energy == approx(-15.89355325)
        assert outcar.final_energy_wo_entrp == approx(-15.83853800)
        assert outcar.final_fr_energy == approx(-15.92106087)

        # VASP 6.2.1
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.etest4.gz")
        assert outcar.final_energy == approx(-15.89364691)
        assert outcar.final_energy_wo_entrp == approx(-15.83863167)
        assert outcar.final_fr_energy == approx(-15.92115453)

    def test_read_table_pattern(self):
        outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.gz")

        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 :"

        pots = outcar.read_table_pattern(header_pattern, table_pattern, footer_pattern, last_one_only=True)
        ref_last = [
            ["       1 -26.0704       2 -45.5046       3 -45.5046       4 -72.9539       5 -73.0621"],
            ["       6 -72.9539       7 -73.0621"],
        ]
        assert pots == ref_last

        pots = outcar.read_table_pattern(
            header_pattern,
            table_pattern,
            footer_pattern,
            last_one_only=False,
            first_one_only=True,
        )
        ref_first = [
            ["       1 -26.1149       2 -45.5359       3 -45.5359       4 -72.9831       5 -73.1068"],
            ["       6 -72.9831       7 -73.1068"],
        ]
        assert pots == ref_first

        with pytest.raises(
            ValueError,
            match="last_one_only and first_one_only options are incompatible",
        ):
            outcar.read_table_pattern(
                header_pattern,
                table_pattern,
                footer_pattern,
                last_one_only=True,
                first_one_only=True,
            )

    def test_truncated_table_returns_without_hanging(self):
        with pytest.warns(UserWarning, match="Table in .* is truncated"):
            outcar = Outcar(f"{VASP_OUT_DIR}/OUTCAR.truncated.gz")
        assert outcar.data["nplwvs_at_kpoints"] == []


class TestBSVasprun(MatSciTest):
    def test_get_band_structure(self):
        filepath = f"{VASP_OUT_DIR}/vasprun_Si_bands.xml.gz"
        vasprun = BSVasprun(filepath, parse_potcar_file=False)
        bs = vasprun.get_band_structure(kpoints_filename=f"{VASP_IN_DIR}/KPOINTS_Si_bands")
        cbm = bs.get_cbm()
        vbm = bs.get_vbm()
        assert cbm["kpoint_index"] == [13], "wrong cbm kpoint index"
        assert cbm["energy"] == approx(6.2301), "wrong cbm energy"
        assert cbm["band_index"] == {Spin.up: [4], Spin.down: [4]}, "wrong cbm bands"
        assert vbm["kpoint_index"] == [0, 63, 64]
        assert vbm["energy"] == approx(5.6158), "wrong vbm energy"
        assert vbm["band_index"] == {
            Spin.up: [1, 2, 3],
            Spin.down: [1, 2, 3],
        }, "wrong vbm bands"
        assert vbm["kpoint"].label == "\\Gamma", "wrong vbm label"
        assert cbm["kpoint"].label is None, "wrong cbm label"
        dct = vasprun.as_dict()
        assert "eigenvalues" in dct["output"]

    def test_kpoints_opt(self):
        vasp_run = BSVasprun(
            f"{TEST_DIR}/fixtures/kpoints_opt/vasprun.xml.gz", parse_potcar_file=False, parse_projected_eigen=True
        )
        bs = vasp_run.get_band_structure(f"{TEST_DIR}/fixtures/kpoints_opt/KPOINTS_OPT")
        assert isinstance(bs, BandStructureSymmLine)
        cbm = bs.get_cbm()
        vbm = bs.get_vbm()
        assert cbm["kpoint_index"] == [38], "wrong cbm kpoint index"
        assert cbm["energy"] == approx(6.4394), "wrong cbm energy"
        assert cbm["band_index"] == {Spin.up: [16], Spin.down: [16]}, "wrong cbm bands"
        # Strangely, when I call with parse_projected_eigen, it gives empty Spin.down,
        # but without parse_projected_eigen it does not give it.
        # So at one point it called the empty key.
        assert vbm["kpoint_index"] == [0, 39, 40]
        assert vbm["energy"] == approx(5.7562), "wrong vbm energy"
        assert vbm["band_index"] == {
            Spin.down: [13, 14, 15],
            Spin.up: [13, 14, 15],
        }, "wrong vbm bands"
        assert vbm["kpoint"].label == "\\Gamma", "wrong vbm label"
        assert cbm["kpoint"].label is None, "wrong cbm label"
        # Test projection
        projected = bs.get_projection_on_elements()
        assert np.isnan(projected[Spin.up][0][0]["Si"])
        # Due to some error in my VASP, the transcription of PROCAR_OPT into
        # vasprun.xml is filled to the brim with errors in the projections.
        # At some point we might get a healthier vasprun.xml, but the point here
        # is to test the parser, not VASP.
        projected = bs.get_projections_on_elements_and_orbitals({"Si": ["s"]})
        assert projected[Spin.up][0][58]["Si"]["s"] == approx(-0.0271)
        vrun_dct = vasp_run.as_dict()
        assert {*vrun_dct["output"]} >= {"eigenvalues", "eigenvalues_kpoints_opt"}


class TestOszicar(MatSciTest):
    def test_init(self):
        fpath = f"{VASP_OUT_DIR}/OSZICAR"
        oszicar = Oszicar(fpath)
        assert len(oszicar.electronic_steps) == len(oszicar.ionic_steps)
        assert len(oszicar.all_energies) == 60
        assert oszicar.final_energy == approx(-526.63928)
        assert set(oszicar.ionic_steps[-1]) == {"F", "E0", "dE", "mag"}

    def test_static(self):
        fpath = f"{TEST_DIR}/fixtures/static_silicon/OSZICAR"
        oszicar = Oszicar(fpath)
        assert oszicar.final_energy == approx(-10.645278)
        assert set(oszicar.ionic_steps[-1]) == {"F", "E0", "dE", "mag"}

    def test_float_overflow(self):
        oszicar_path = Path(self.tmp_path) / "OSZICAR"
        oszicar_path.write_text(
            """\
       N       E                     dE             d eps       ncg     rms          rms(c)
DAV:   1     0.831012218308E+03    0.83101E+03   -0.25939E+05  4672   0.894E+02
          1 T= ****** E= 0.64876404E+06 F= 0.49220367E+06 E0= 0.49220367E+06  EK= 0.15656E+06 SP= 0.00E+00 SK= 0.00E+00
""",
            encoding="utf-8",
        )

        with pytest.warns(UserWarning, match="Float overflow .* encountered in vasprun"):
            oszicar = Oszicar(oszicar_path)

        ionic_step = oszicar.ionic_steps[0]
        assert np.isnan(ionic_step["T"])

        assert ionic_step["E"] == approx(648_764.04)
        assert ionic_step["F"] == approx(492_203.67)


class TestGetBandStructureFromVaspMultipleBranches:
    def test_read_multi_branches(self):
        """TODO: This functionality still needs a test."""

    def test_missing_vasprun_in_branch_dir(self):
        """Test vasprun.xml missing from branch_*."""
        os.makedirs("no_vasp/branch_0", exist_ok=False)

        with pytest.raises(FileNotFoundError, match=re.escape("cannot find vasprun.xml in directory")):
            get_band_structure_from_vasp_multiple_branches("no_vasp")

    def test_no_branch_head(self):
        """Test branch_0 is missing and read dir_name/vasprun.xml directly."""

        copyfile(f"{VASP_OUT_DIR}/vasprun.force_hybrid_like_calc.xml.gz", "./vasprun.xml.gz")
        decompress_file("./vasprun.xml.gz")

        with pytest.warns(DeprecationWarning, match="no branch dir found, reading directly from"):
            bs = get_band_structure_from_vasp_multiple_branches(".")
        assert isinstance(bs, BandStructure)

    def test_cannot_read_anything(self):
        """Test no branch_0/, no dir_name/vasprun.xml, no vasprun.xml at all."""
        with pytest.raises(FileNotFoundError, match=re.escape("failed to find any vasprun.xml in selected")):
            get_band_structure_from_vasp_multiple_branches(".")


class TestLocpot(MatSciTest):
    def test_init(self):
        filepath = f"{VASP_OUT_DIR}/LOCPOT.gz"
        locpot = Locpot.from_file(filepath)
        assert sum(locpot.get_average_along_axis(0)) == approx(-217.05226954)
        assert locpot.get_axis_grid(0)[-1] == approx(2.87629, abs=1e-2)
        assert locpot.get_axis_grid(1)[-1] == approx(2.87629, abs=1e-2)
        assert locpot.get_axis_grid(2)[-1] == approx(2.87629, abs=1e-2)

        # Test copy preserve type
        locpot_copy = locpot.copy()
        assert type(locpot_copy) is type(locpot)

        # make sure locpot constructor works with data_aug=None
        poscar, data, _data_aug = Locpot.parse_file(filepath)
        l2 = Locpot(poscar=poscar, data=data, data_aug=None)
        assert l2.data_aug == {}

    def test_vasp_6x_style(self):
        filepath = f"{VASP_OUT_DIR}/LOCPOT.vasp642.gz"
        locpot = Locpot.from_file(filepath)
        assert locpot.dim == (2, 2, 5)
        assert {str(ele) for ele in locpot.structure.composition} == {"Mg", "Si"}

    def test_collinear_spin_channels(self):
        locpot = Locpot(
            Structure(Lattice.cubic(1), ["H"], [[0, 0, 0]]),
            {"total": np.ones((2, 2, 2)), "diff": np.full((2, 2, 2), 2)},
        )
        assert set(locpot.data) == {"spin_up", "spin_down"}
        assert_allclose(locpot.spin_data[Spin.up], 1)
        assert_allclose(locpot.spin_data[Spin.down], 2)
        with pytest.warns(DeprecationWarning, match="'total'.*'spin_up'"):
            assert_allclose(locpot.data["total"], 1)


class TestChgcar(MatSciTest):
    @classmethod
    def setup_class(cls):
        filepath = f"{VASP_OUT_DIR}/CHGCAR.nospin.gz"
        cls.chgcar_no_spin = Chgcar.from_file(filepath)

        filepath = f"{VASP_OUT_DIR}/CHGCAR.spin.gz"
        cls.chgcar_spin = Chgcar.from_file(filepath)

        filepath = f"{VASP_OUT_DIR}/CHGCAR.Fe3O4.gz"
        cls.chgcar_fe3o4 = Chgcar.from_file(filepath)

        filepath = f"{VASP_OUT_DIR}/CHGCAR.NiO_SOC.gz"
        cls.chgcar_NiO_soc = Chgcar.from_file(filepath)

    def test_init(self):
        assert self.chgcar_no_spin.get_integrated_diff(0, 2)[0, 1] == approx(0)
        assert self.chgcar_spin.get_integrated_diff(0, 1)[0, 1] == approx(-0.0043896932237534022)
        # test sum
        chgcar = self.chgcar_spin + self.chgcar_spin
        assert chgcar.get_integrated_diff(0, 1)[0, 1] == approx(-0.0043896932237534022 * 2)

        chgcar = sum([self.chgcar_spin, self.chgcar_spin])
        assert chgcar.get_integrated_diff(0, 1)[0, 1] == approx(-0.0043896932237534022 * 2)

        chgcar = self.chgcar_spin - self.chgcar_spin
        assert chgcar.get_integrated_diff(0, 1)[0, 1] == approx(0)

        expected = [
            1.56472768,
            3.25985108,
            3.49205728,
            3.66275028,
            3.8045896,
            5.10813352,
        ]
        actual = self.chgcar_fe3o4.get_integrated_diff(0, 3, 6)
        assert_allclose(actual[:, 1], expected)

        # Test copy preserve type
        chgcar_copy = chgcar.copy()
        assert type(chgcar_copy) is type(chgcar)

    def test_write(self):
        self.chgcar_spin.write_file(out_path := f"{self.tmp_path}/CHGCAR_pmg")
        chgcar_from_file = Chgcar.from_file(out_path)
        assert_allclose(chgcar_from_file.data["total"], self.chgcar_spin.data["total"])
        assert_allclose(chgcar_from_file.data["diff"], self.chgcar_spin.data["diff"])
        assert_allclose(chgcar_from_file.data_aug["total"][1], self.chgcar_spin.data_aug["total"][1])
        assert_allclose(chgcar_from_file.data_aug["diff"][1], self.chgcar_spin.data_aug["diff"][1])

    def test_write_preserves_legacy_aug_lines(self):
        legacy_aug = {
            "total": [
                "augmentation occupancies   1   3",
                " 0.1000000000E+01 0.2000000000E+01 0.3000000000E+01 ",
            ]
        }
        chgcar = Chgcar(self.chgcar_no_spin.structure, self.chgcar_no_spin.data, data_aug=legacy_aug)
        out_path = f"{self.tmp_path}/CHGCAR_legacy_aug"
        chgcar.write_file(out_path)
        contents = Path(out_path).read_text(encoding="utf-8")
        assert "augmentation occupancies   1   3" in contents
        assert "0.1000000000E+01 0.2000000000E+01 0.3000000000E+01" in contents

    def test_soc_chgcar(self):
        assert set(self.chgcar_NiO_soc.data) == {
            "total",
            "diff_x",
            "diff_y",
            "diff_z",
            "diff",
        }
        assert self.chgcar_NiO_soc.is_soc
        assert self.chgcar_NiO_soc.data["diff"].shape == self.chgcar_NiO_soc.data["diff_y"].shape

        # check our construction of chg.data['diff'] makes sense
        # this has been checked visually too and seems reasonable
        assert abs(self.chgcar_NiO_soc.data["diff"][0][0][0]) == np.linalg.norm(
            [self.chgcar_NiO_soc.data[f"diff_{key}"][0][0][0] for key in "xyz"]
        )

        # and that the net magnetization is about zero
        # note: we get ~ 0.08 here, seems a little high compared to
        # vasp output, but might be due to chgcar limitations?
        assert self.chgcar_NiO_soc.net_magnetization == approx(0.0, abs=1e-0)

        self.chgcar_NiO_soc.write_file(out_path := f"{self.tmp_path}/CHGCAR_pmg_soc")
        chg_from_file = Chgcar.from_file(out_path)
        assert chg_from_file.is_soc
        assert_allclose(chg_from_file.data["total"], self.chgcar_NiO_soc.data["total"])
        assert_allclose(chg_from_file.data["diff"], self.chgcar_NiO_soc.data["diff"])
        assert_allclose(chg_from_file.data_aug["total"][1], self.chgcar_NiO_soc.data_aug["total"][1])
        assert_allclose(chg_from_file.data_aug["diff_x"][1], self.chgcar_NiO_soc.data_aug["diff_x"][1])
        assert_allclose(chg_from_file.data_aug["diff_y"][1], self.chgcar_NiO_soc.data_aug["diff_y"][1])
        assert_allclose(chg_from_file.data_aug["diff_z"][1], self.chgcar_NiO_soc.data_aug["diff_z"][1])

        # Hand-constructed SOC data without the scalar "diff" key is still spin-polarized
        soc_data = {key: np.ones((2, 2, 2)) for key in ("total", "diff_x", "diff_y", "diff_z")}
        chg = Chgcar(self.chgcar_NiO_soc.structure, soc_data)
        assert chg.is_spin_polarized
        assert chg.is_soc

    @pytest.mark.skipif(h5py is None, reason="h5py required for HDF5 support.")
    def test_hdf5(self):
        chgcar = Chgcar.from_file(f"{VASP_OUT_DIR}/CHGCAR.NiO_SOC.gz")
        chgcar.to_hdf5(out_path := f"{self.tmp_path}/chgcar_test.hdf5")

        with h5py.File(out_path, mode="r") as dct:
            assert_allclose(dct["vdata"]["total"], chgcar.data["total"])
            assert_allclose(dct["vdata"]["diff"], chgcar.data["diff"])
            assert_allclose(dct["lattice"], chgcar.structure.lattice.matrix)
            assert_allclose(dct["fcoords"], chgcar.structure.frac_coords)
            for z in dct["Z"]:
                assert z in [Element.Ni.Z, Element.O.Z]

            for sp in dct["species"]:
                assert sp in [b"Ni", b"O"]

        chgcar2 = Chgcar.from_hdf5(out_path)
        assert_allclose(chgcar2.data["total"], chgcar.data["total"])

    def test_spin_data(self):
        for v in self.chgcar_spin.spin_data.values():
            assert v.shape == (48, 48, 48)

    def test_add(self):
        chgcar_sum = self.chgcar_spin + self.chgcar_spin
        assert_allclose(chgcar_sum.data["total"], self.chgcar_spin.data["total"] * 2)
        chgcar_copy = self.chgcar_spin.copy()
        chgcar_copy.structure = self.get_structure("Li2O")
        with pytest.warns(
            UserWarning,
            match="Structures are different. Make sure you know what you are doing...",
        ) as warns:
            chgcar_sum = chgcar_copy + self.chgcar_spin
        assert len(warns) == 1
        with pytest.raises(
            ValueError,
            match=r"operands could not be broadcast together with shapes \(48,48,48\) \(72,72,72\)",
        ):
            _ = self.chgcar_spin + self.chgcar_fe3o4
        with pytest.raises(
            ValueError,
            match="Data have different keys! Maybe one is spin-polarized and the other is not",
        ):
            _ = self.chgcar_spin + self.chgcar_no_spin

    def test_as_dict_and_from_dict(self):
        dct = self.chgcar_NiO_soc.as_dict()
        chgcar_from_dict = Chgcar.from_dict(dct)
        assert_allclose(self.chgcar_NiO_soc.data["total"], chgcar_from_dict.data["total"])
        assert_allclose(
            self.chgcar_NiO_soc.structure.lattice.matrix,
            chgcar_from_dict.structure.lattice.matrix,
        )


class TestAeccars(MatSciTest):
    # https://github.com/materialsproject/pymatgen/pull/3343
    def test_read_write_file(self):
        aeccar0_test = Chgcar.from_file(f"{TEST_FILES_DIR}/command_line/bader/AECCAR0.gz")
        aeccar0_outpath = f"{self.tmp_path}/AECCAR0_test"
        aeccar0_test.write_file(aeccar0_outpath)
        aeccar0_read = Chgcar.from_file(aeccar0_outpath)
        assert_allclose(aeccar0_test.data["total"], aeccar0_read.data["total"])

        aeccar2 = Chgcar.from_file(f"{TEST_FILES_DIR}/command_line/bader/AECCAR2.gz")
        aeccar2_outpath = f"{self.tmp_path}/AECCAR2_test"
        aeccar2.write_file(aeccar2_outpath)
        aeccar2_read = Chgcar.from_file(aeccar2_outpath)
        assert_allclose(aeccar2.data["total"], aeccar2_read.data["total"])


class TestElfcar(MatSciTest):
    def test_init(self):
        elfcar = Elfcar.from_file(f"{VASP_OUT_DIR}/ELFCAR.gz")
        assert np.mean(elfcar.data["spin_up"]) == approx(0.19076207645194002)
        assert np.mean(elfcar.data["spin_down"]) == approx(0.19076046677910055)
        assert_allclose(elfcar.spin_data[Spin.up], elfcar.data["spin_up"])
        assert_allclose(elfcar.spin_data[Spin.down], elfcar.data["spin_down"])
        with pytest.warns(DeprecationWarning, match="'total'.*'spin_up'"):
            assert_allclose(elfcar.data["total"], elfcar.data["spin_up"])
        with pytest.warns(DeprecationWarning, match="'diff'.*'spin_down'"):
            assert_allclose(elfcar.data["diff"], elfcar.data["spin_down"])
        with pytest.raises(ValueError, match="total/difference"):
            elfcar.get_integrated_diff(0, 1)
        reconstituted = Elfcar.from_dict(elfcar.as_dict())
        assert elfcar.data == reconstituted.data
        assert elfcar.poscar.structure == reconstituted.poscar.structure

        legacy_dict = elfcar.as_dict()
        legacy_dict["data"] = {"total": elfcar.data["spin_up"], "diff": elfcar.data["spin_down"]}
        migrated = Elfcar.from_dict(legacy_dict)
        assert set(migrated.data) == {"spin_up", "spin_down"}
        assert_allclose(migrated.data["spin_up"], elfcar.data["spin_up"])
        assert_allclose(migrated.data["spin_down"], elfcar.data["spin_down"])

        # Test copy preserve type
        elfcar_copy = elfcar.copy()
        assert type(elfcar_copy) is type(elfcar)

    def test_round_trip_write(self):
        elfcar = Elfcar.from_file(f"{VASP_OUT_DIR}/ELFCAR.gz")
        out_path = f"{self.tmp_path}/ELFCAR_pmg"
        elfcar.write_file(out_path)
        reloaded = Elfcar.from_file(out_path)
        assert_allclose(reloaded.data["spin_up"], elfcar.data["spin_up"])
        assert_allclose(reloaded.data["spin_down"], elfcar.data["spin_down"])
        assert reloaded.poscar.structure == elfcar.poscar.structure

    def test_truncated_file(self):
        elfcar = Elfcar.from_file(f"{VASP_OUT_DIR}/ELFCAR.gz")
        out_path = f"{self.tmp_path}/ELFCAR_malformed"
        elfcar.write_file(out_path)
        with open(out_path, encoding="utf-8") as file:
            malformed = file.read().replace("   18   18   70", "   18   18", 1)
        with open(out_path, "w", encoding="utf-8") as file:
            file.write(malformed)
        with pytest.raises(
            ValueError,
            match=(
                r"invalid literal for int|could not convert string|cannot reshape array of size|"
                r"Expected \d+ values, got \d+"
            ),
        ):
            Elfcar.from_file(out_path)

    def test_alpha(self):
        elfcar = Elfcar.from_file(f"{VASP_OUT_DIR}/ELFCAR.gz")
        alpha = elfcar.get_alpha()
        assert np.median(alpha.data["spin_up"]) == approx(2.936678808979031)

    def test_interpolation(self):
        elfcar = Elfcar.from_file(f"{VASP_OUT_DIR}/ELFCAR.gz")
        assert elfcar.value_at(0.4, 0.5, 0.6) == approx(0.0918471)
        assert len(elfcar.linear_slice([0.0, 0.0, 0.0], (1.0, 1.0, 1.0))) == 100

        with pytest.raises(ValueError, match="lengths of p1 and p2 should be 3"):
            elfcar.linear_slice(
                [0.0],
                [1.0, 1.0, 1.0],
            )


class TestProcar(MatSciTest):
    def test_init(self):
        filepath = f"{VASP_OUT_DIR}/PROCAR.simple"
        procar = Procar(filepath)
        assert procar.get_occupation(0, "d")[Spin.up] == approx(0)
        assert procar.get_occupation(0, "s")[Spin.up] == approx(0.35381249999999997)
        assert procar.get_occupation(0, "p")[Spin.up] == approx(1.19540625)
        with pytest.raises(ValueError, match="not in list"):
            procar.get_occupation(1, "m")
        assert procar.nbands == 10
        assert procar.nkpoints == 10
        assert procar.nions == 3
        filepath = f"{VASP_OUT_DIR}/PROCAR.gz"
        procar = Procar(filepath)
        assert procar.get_occupation(0, "dxy")[Spin.up] == approx(0.96214813853000025)
        assert procar.get_occupation(0, "dxy")[Spin.down] == approx(0.85796295426000124)

    def test_soc_procar(self):
        filepath = f"{VASP_OUT_DIR}/PROCAR.SOC.gz"
        procar = Procar(filepath)
        assert procar.nions == 4
        assert procar.nkpoints == 25
        assert procar.nspins == 1
        assert procar.is_soc  # SOC PROCAR
        nb = procar.nbands
        nk = procar.nkpoints
        assert procar.eigenvalues[Spin.up].shape == (nk, nb)
        assert procar.kpoints.shape == (nk, 3)
        assert len(procar.weights) == nk
        assert_allclose(procar.kpoints[0][0], 0.0)
        assert procar.occupancies[Spin.up].shape == (nk, nb)

        # spot check some values:
        assert procar.data[Spin.up][0, 1, 1, 0] == approx(0.095)
        assert procar.data[Spin.up][0, 1, 1, 1] == approx(0)

        assert procar.xyz_data["x"][0, 1, 1, 0] == approx(-0.063)
        assert procar.xyz_data["z"][0, 1, 1, 1] == approx(0)

    def test_multiple_procars(self):
        filepaths = [
            f"{VASP_OUT_DIR}/PROCAR.split1.gz",
            f"{VASP_OUT_DIR}/PROCAR.split2.gz",
        ]
        procar = Procar(filepaths)
        assert procar.nions == 4
        assert procar.nkpoints == 96  # 96 overall, 48 in first PROCAR, 96 in second (48 duplicates)
        assert procar.nspins == 1  # SOC PROCAR, also with LORBIT = 14
        assert procar.is_soc  # SOC PROCAR
        nb = procar.nbands
        nk = procar.nkpoints
        assert procar.eigenvalues[Spin.up].shape == (nk, nb)
        assert procar.kpoints.shape == (nk, 3)
        assert len(procar.weights) == nk
        assert procar.occupancies[Spin.up].shape == (nk, nb)

        # spot check some values:
        assert procar.data[Spin.up][0, 1, 1, 0] == approx(0.094)
        assert procar.data[Spin.up][0, 1, 1, 1] == approx(0)

        assert procar.xyz_data["x"][0, 1, 1, 0] == approx(0)
        assert procar.xyz_data["z"][0, 1, 1, 1] == approx(0)

        assert procar.phase_factors[Spin.up][0, 1, 0, 0] == approx(-0.159 + 0.295j)

    def test_phase_factors(self):
        filepath = f"{VASP_OUT_DIR}/PROCAR.phase.gz"
        procar = Procar(filepath)
        assert procar.phase_factors[Spin.up][0, 0, 0, 0] == approx(-0.746 + 0.099j)
        assert procar.phase_factors[Spin.down][0, 0, 0, 0] == approx(0.372 - 0.654j)

        # Two Li should have same phase factor.
        assert procar.phase_factors[Spin.up][0, 0, 0, 0] == approx(procar.phase_factors[Spin.up][0, 0, 1, 0])
        assert procar.phase_factors[Spin.up][0, 0, 2, 0] == approx(-0.053 + 0.007j)
        assert procar.phase_factors[Spin.down][0, 0, 2, 0] == approx(0.027 - 0.047j)

        # new style phase factors (VASP 5.4.4+)
        filepath = f"{VASP_OUT_DIR}/PROCAR.new_format_5.4.4.gz"
        procar = Procar(filepath)
        assert procar.phase_factors[Spin.up][0, 0, 0, 0] == approx(-0.13 + 0.199j)

    def test_get_projection_on_elements(self):
        filepath = f"{VASP_OUT_DIR}/PROCAR.simple"
        procar = Procar(filepath)
        struct = Structure(
            Lattice.cubic(3.0),
            ["Li", "Na", "K"],
            [[0.0, 0.0, 0.0], [0.25, 0.25, 0.25], [0.75, 0.75, 0.75]],
        )
        dct = procar.get_projection_on_elements(struct)
        assert dct[Spin.up][2][2] == approx({"Na": 0.042, "K": 0.646, "Li": 0.042})
        # https://github.com/materialsproject/pymatgen/pull/3261
        struct.replace_species({"K": "Na"})
        d2 = procar.get_projection_on_elements(struct)
        assert d2[Spin.up][2][2] == approx({"Na": 0.688, "Li": 0.042})

    def test_skip_kpoint(self):
        filepath = f"{VASP_OUT_DIR}/PROCAR.repeatedpoints"
        procar = Procar(filepath)
        # length of projection's k-points axis should be equal to the number of k-points
        assert procar.nkpoints == len(procar.data[Spin.up][:, 0, 0, 0])
        assert procar.phase_factors[Spin.up][1, 0, 0, 0] == approx(-0.087 + -0.166j)
        assert procar.kpoints[1][0] == approx(0.25000000)


class TestXdatcar:
    def test_init(self):
        filepath = f"{VASP_OUT_DIR}/XDATCAR_4"
        xdatcar = Xdatcar(filepath)
        structures = xdatcar.structures
        assert len(structures) == 4
        for struct in structures:
            assert struct.formula == "Li2 O1"

        filepath = f"{VASP_OUT_DIR}/XDATCAR_5"
        xdatcar = Xdatcar(filepath)
        structures = xdatcar.structures
        assert len(structures) == 4
        for struct in structures:
            assert struct.formula == "Li2 O1"

        xdatcar.concatenate(f"{VASP_OUT_DIR}/XDATCAR_4")
        assert len(xdatcar.structures) == 8
        assert xdatcar.get_str() is not None

        filepath = f"{VASP_OUT_DIR}/XDATCAR_6"
        xdatcar = Xdatcar(filepath)
        structures = xdatcar.structures

        assert structures[0].lattice != structures[-1].lattice

        xdatcar = Xdatcar(f"{VASP_OUT_DIR}/XDATCAR_monatomic.gz")
        assert len(xdatcar.structures) == 10
        assert len(xdatcar) == len(xdatcar.structures)

        idxs = [1, 2, 3]
        assert xdatcar[idxs] == [xdatcar.structures[i] for i in idxs]
        assert all(isinstance(struct, Structure) for struct in xdatcar)

        assert all(len(structure.composition) == 1 for structure in xdatcar.structures)

    def test_bad_format(self):
        # ensure XDATCAR can be read even when formatting is poor
        xdatcar = Xdatcar(f"{VASP_OUT_DIR}/XDATCAR.bad_fmt.gz")
        assert isinstance(xdatcar, Xdatcar)


class TestDynmat:
    def test_init(self):
        filepath = f"{VASP_OUT_DIR}/DYNMAT"
        dct = Dynmat(filepath)
        assert dct.nspecs == 2
        assert dct.natoms == 6
        assert dct.ndisps == 3
        assert_allclose(dct.masses, [63.546, 196.966])
        assert 4 in dct.data
        assert 2 in dct.data[4]
        assert_allclose(dct.data[4][2]["dispvec"], [0.0, 0.05, 0.0])
        assert_allclose(dct.data[4][2]["dynmat"][3], [0.055046, -0.298080, 0.0])
        # TODO: test get_phonon_frequencies once cross-checked


class TestWavecar(MatSciTest):
    def setup_method(self):
        latt_mat = np.array(np.eye(3) * 10, dtype=float)  # lattice vectors
        self.vol = np.dot(latt_mat[0, :], np.cross(latt_mat[1, :], latt_mat[2, :]))  # unit cell volume
        # reciprocal lattice vectors
        recip_latt_mat = [
            np.cross(latt_mat[1, :], latt_mat[2, :]),
            np.cross(latt_mat[2, :], latt_mat[0, :]),
            np.cross(latt_mat[0, :], latt_mat[1, :]),
        ]
        self.recip_latt_mat = 2 * np.pi * np.array(recip_latt_mat) / self.vol
        self.latt_mat = latt_mat
        self.wavecar = Wavecar(f"{VASP_OUT_DIR}/WAVECAR.N2")
        self.wH2 = Wavecar(f"{VASP_OUT_DIR}/WAVECAR.H2_low_symm")
        self.wH2_gamma = Wavecar(f"{VASP_OUT_DIR}/WAVECAR.H2_low_symm.gamma")
        self.w_ncl = Wavecar(f"{VASP_OUT_DIR}/WAVECAR.H2.ncl")
        self.w_frac_encut = Wavecar(f"{VASP_OUT_DIR}/WAVECAR.frac_encut")

    def test_standard(self, caplog):
        wavecar = self.wavecar
        a = np.array([[10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]])
        vol = np.dot(a[0, :], np.cross(a[1, :], a[2, :]))
        b = np.array(
            [
                np.cross(a[1, :], a[2, :]),
                np.cross(a[2, :], a[0, :]),
                np.cross(a[0, :], a[1, :]),
            ]
        )
        b = 2 * np.pi * b / vol

        assert wavecar.filename == f"{VASP_OUT_DIR}/WAVECAR.N2"
        assert wavecar.efermi == approx(-5.7232, abs=1e-4)
        assert wavecar.encut == approx(25.0)
        assert wavecar.nb == 9
        assert wavecar.nk == 1
        assert_allclose(wavecar.a, a)
        assert_allclose(wavecar.b, b)
        assert wavecar.vol == approx(vol)
        assert len(wavecar.kpoints) == wavecar.nk
        assert len(wavecar.coeffs) == wavecar.nk
        assert len(wavecar.coeffs[0]) == wavecar.nb
        assert len(wavecar.band_energy) == wavecar.nk
        assert wavecar.band_energy[0].shape == (wavecar.nb, 3)
        assert len(wavecar.Gpoints[0]) <= 257
        for k in range(wavecar.nk):
            for b in range(wavecar.nb):
                assert len(wavecar.coeffs[k][b]) == len(wavecar.Gpoints[k])

        # Test WAVECAR with fractional ENCUT
        assert self.w_frac_encut.encut == approx(100.5)

        # Test malformed WAVECARs
        with pytest.raises(ValueError, match=r"Invalid rtag=.+, must be one of"):
            Wavecar(f"{VASP_OUT_DIR}/WAVECAR.N2.malformed")

        with pytest.raises(ValueError, match="invalid vasp_type='poop'"):
            Wavecar(f"{VASP_OUT_DIR}/WAVECAR.N2", vasp_type="poop")

        with pytest.raises(
            ValueError,
            match=r"Incorrect vasp_type='g'. Please open an issue if you are certain",
        ):
            Wavecar(f"{VASP_OUT_DIR}/WAVECAR.N2", vasp_type="g")

        with pytest.raises(ValueError, match=r"cannot reshape array of size 257 into shape \(2,128\)"):
            Wavecar(f"{VASP_OUT_DIR}/WAVECAR.N2", vasp_type="n")

        with caplog.at_level(logging.INFO):
            Wavecar(f"{VASP_OUT_DIR}/WAVECAR.N2", verbose=True)
        assert any("reciprocal lattice vector magnitudes" in msg for msg in caplog.messages)

    def test_n2_45210(self):
        wavecar = Wavecar(f"{VASP_OUT_DIR}/WAVECAR.N2.45210")
        assert wavecar.filename == f"{VASP_OUT_DIR}/WAVECAR.N2.45210"
        assert wavecar.efermi == approx(-5.7232, abs=1e-4)
        assert wavecar.encut == approx(25.0)
        assert wavecar.nb == 9
        assert wavecar.nk == 1
        assert_allclose(wavecar.a, self.latt_mat)
        assert_allclose(wavecar.b, self.recip_latt_mat)
        assert wavecar.vol == approx(self.vol)
        assert len(wavecar.kpoints) == wavecar.nk
        assert len(wavecar.coeffs) == wavecar.nk
        assert len(wavecar.coeffs[0]) == wavecar.nb
        assert len(wavecar.band_energy) == wavecar.nk
        assert wavecar.band_energy[0].shape == (wavecar.nb, 3)
        assert len(wavecar.Gpoints[0]) <= 257

    def test_n2_spin(self):
        w = Wavecar(f"{VASP_OUT_DIR}/WAVECAR.N2.spin")
        assert len(w.coeffs) == 2
        assert len(w.band_energy) == 2
        assert len(w.kpoints) == w.nk
        assert len(w.Gpoints) == w.nk
        assert len(w.coeffs[0][0]) == w.nb
        assert len(w.band_energy[0]) == w.nk

        orig_gen_g_points = Wavecar._generate_G_points
        try:
            Wavecar._generate_G_points = lambda _x, _y, gamma: []
            with pytest.raises(ValueError, match=r"not enough values to unpack \(expected 3, got 0\)"):
                Wavecar(f"{VASP_OUT_DIR}/WAVECAR.N2")
        finally:
            Wavecar._generate_G_points = orig_gen_g_points

    def test_generate_nbmax(self):
        self.wavecar._generate_nbmax()
        assert self.wavecar._nbmax.tolist() == [5, 5, 5]

    def test_generate_g_points(self):
        for k in range(self.wavecar.nk):
            kp = self.wavecar.kpoints[k]
            assert len(self.wavecar._generate_G_points(kp)) <= 257

    def test_evaluate_wavefunc(self):
        self.wavecar.Gpoints.append(np.array([0, 0, 0]))
        self.wavecar.kpoints.append(np.array([0, 0, 0]))
        self.wavecar.coeffs.append([[1 + 1j]])
        assert self.wavecar.evaluate_wavefunc(-1, -1, [0, 0, 0]) == approx((1 + 1j) / np.sqrt(self.vol), abs=1e-4)
        assert self.wavecar.evaluate_wavefunc(0, 0, [0, 0, 0]) == approx(
            np.sum(self.wavecar.coeffs[0][0]) / np.sqrt(self.vol), abs=1e-4
        )
        w = Wavecar(f"{VASP_OUT_DIR}/WAVECAR.N2.spin")
        w.Gpoints.append(np.array([0, 0, 0]))
        w.kpoints.append(np.array([0, 0, 0]))
        w.coeffs[0].append([[1 + 1j]])
        assert w.evaluate_wavefunc(-1, -1, [0, 0, 0]) == approx((1 + 1j) / np.sqrt(self.vol), abs=1e-4)

    def test_fft_mesh_basic(self):
        mesh = self.wavecar.fft_mesh(0, 5)
        ind = np.argmax(np.abs(mesh))
        assert np.unravel_index(ind, mesh.shape) == (14, 1, 1)
        assert cmath.isclose(mesh[tuple((self.wavecar.ng / 2).astype(int))], 0j)
        mesh = self.wavecar.fft_mesh(0, 5, shift=False)
        ind = np.argmax(np.abs(mesh))
        assert np.unravel_index(ind, mesh.shape) == (6, 8, 8)
        assert cmath.isclose(mesh[0, 0, 0], 0j)

    def test_fft_mesh_advanced(self):
        ik = ib = 0
        mesh = self.wH2.fft_mesh(ik, ib)
        mesh_gamma = self.wH2_gamma.fft_mesh(ik, ib)
        mesh_ncl = self.w_ncl.fft_mesh(ik, ib)

        # check equality of plane-wave coefficients
        ind_max = np.unravel_index(np.argmax(np.abs(mesh)), mesh.shape)
        phase = mesh[ind_max] / mesh_gamma[ind_max]
        assert np.max(np.abs(mesh - phase * mesh_gamma)) <= 1.0e-6

        # transform to real space for further checking
        mesh = np.fft.ifftn(mesh)
        mesh_gamma = np.fft.ifftn(mesh_gamma)
        mesh_ncl = np.fft.ifftn(mesh_ncl)

        # check equality in real space for regular vs. gamma only
        ind_max = np.unravel_index(np.argmax(np.abs(mesh)), mesh.shape)
        phase = mesh[ind_max] / mesh_gamma[ind_max]
        assert np.max(np.abs(mesh - phase * mesh_gamma)) <= 1.0e-6

        # spot check some points in real space
        p1 = (
            int(mesh.shape[0] / 2),
            int(mesh.shape[1] / 2) - 1,
            int(mesh.shape[2] / 2) - 2,
        )
        p2 = (p1[0] + 1, p1[1], p1[2])
        c = np.diag((5, 4, 6))  # this needs to match POSCAR, which we don't have
        r1 = np.dot(np.array(p1) / mesh.shape, c)
        r2 = np.dot(np.array(p2) / mesh.shape, c)

        # check equality of FFT and slow FT for regular mesh (ratio, to account for normalization)
        v1 = self.wH2.evaluate_wavefunc(ik, ib, r1)
        v2 = self.wH2.evaluate_wavefunc(ik, ib, r2)
        assert np.abs(mesh[p1]) / np.abs(mesh[p2]) == approx(np.abs(v1) / np.abs(v2), abs=1e-6)

        # spot check one value that we happen to know from reference run
        assert v1 == approx(-0.01947068011502887 + 0.23340228099620275j, abs=1e-8)

        # check equality of FFT and slow FT for gamma-only mesh (ratio again)
        v1_gamma = self.wH2_gamma.evaluate_wavefunc(ik, ib, r1)
        v2_gamma = self.wH2_gamma.evaluate_wavefunc(ik, ib, r2)
        assert np.abs(mesh_gamma[p1]) / np.abs(mesh_gamma[p2]) == approx(np.abs(v1_gamma) / np.abs(v2_gamma), abs=1e-6)

        # check equality of FFT and slow FT for ncl mesh (ratio again)
        v1_ncl = self.w_ncl.evaluate_wavefunc(ik, ib, r1)
        v2_ncl = self.w_ncl.evaluate_wavefunc(ik, ib, r2)
        assert np.abs(mesh_ncl[p1]) / np.abs(mesh_ncl[p2]) == approx(np.abs(v1_ncl) / np.abs(v2_ncl), abs=1e-6)

    def test_get_parchg(self):
        poscar = Poscar.from_file(f"{VASP_IN_DIR}/POSCAR")

        wavecar = self.wavecar
        chgcar = wavecar.get_parchg(poscar, 0, 0, spin=0, phase=False)
        assert "total" in chgcar.data
        assert "diff" not in chgcar.data
        assert chgcar.data["total"].size == np.prod(wavecar.ng * 2)
        assert np.all(chgcar.data["total"] > 0.0)

        chgcar = wavecar.get_parchg(poscar, 0, 0, spin=0, phase=True)
        assert "total" in chgcar.data
        assert "diff" not in chgcar.data
        assert chgcar.data["total"].size == np.prod(wavecar.ng * 2)
        assert not np.all(chgcar.data["total"] > 0.0)

        wavecar = Wavecar(f"{VASP_OUT_DIR}/WAVECAR.N2.spin")
        chgcar = wavecar.get_parchg(poscar, 0, 0, phase=False, scale=1)
        assert "total" in chgcar.data
        assert "diff" in chgcar.data
        assert chgcar.data["total"].size == np.prod(wavecar.ng)
        assert np.all(chgcar.data["total"] > 0.0)
        assert not np.all(chgcar.data["diff"] > 0.0)

        chgcar = wavecar.get_parchg(poscar, 0, 0, spin=0, phase=False)
        assert "total" in chgcar.data
        assert "diff" not in chgcar.data
        assert chgcar.data["total"].size == np.prod(wavecar.ng * 2)
        assert np.all(chgcar.data["total"] > 0.0)

        chgcar = wavecar.get_parchg(poscar, 0, 0, spin=0, phase=True)
        assert "total" in chgcar.data
        assert "diff" not in chgcar.data
        assert chgcar.data["total"].size == np.prod(wavecar.ng * 2)
        assert not np.all(chgcar.data["total"] > 0.0)

        wavecar = self.w_ncl
        wavecar.coeffs.append([np.ones((2, 100))])
        chgcar = wavecar.get_parchg(poscar, -1, 0, phase=False, spinor=None)
        assert "total" in chgcar.data
        assert "diff" not in chgcar.data
        assert chgcar.data["total"].size == np.prod(wavecar.ng * 2)
        # this assert was disabled as it started failing during the numpy v2 migration
        # on 2024-08-06. unclear what it was testing in the first place
        # assert not np.all(chgcar.data["total"] > 0.0)

        chgcar = wavecar.get_parchg(poscar, -1, 0, phase=True, spinor=0)
        assert "total" in chgcar.data
        assert "diff" not in chgcar.data
        assert chgcar.data["total"].size == np.prod(wavecar.ng * 2)
        assert not np.all(chgcar.data["total"] > 0.0)

        wavecar.coeffs[-1] = [np.zeros((2, 100))]
        chgcar = wavecar.get_parchg(poscar, -1, 0, phase=False, spinor=1)
        assert "total" in chgcar.data
        assert "diff" not in chgcar.data
        assert chgcar.data["total"].size == np.prod(wavecar.ng * 2)
        assert_allclose(chgcar.data["total"], 0.0)

    def test_write_unks(self):
        unk_std = Unk.from_file(f"{TEST_FILES_DIR}/io/wannier90/UNK.N2.std")
        unk_ncl = Unk.from_file(f"{TEST_FILES_DIR}/io/wannier90/UNK.H2.ncl")

        with pytest.raises(ValueError, match="invalid directory"):
            self.wavecar.write_unks(f"{TEST_FILES_DIR}/io/wannier90/UNK.N2.std")

        # different grids
        self.wavecar.write_unks("./unk_dir")
        assert len(list(Path("./unk_dir").glob("UNK*"))) == 1
        unk = Unk.from_file("./unk_dir/UNK00001.1")
        assert unk != unk_std

        # correct grid
        self.wavecar.ng = np.array([12, 12, 12])
        self.wavecar.write_unks(".")
        unk = Unk.from_file("UNK00001.1")
        assert unk == unk_std

        # ncl test
        self.w_ncl.write_unks(".")
        unk = Unk.from_file("UNK00001.NC")
        assert unk == unk_ncl


class TestEigenval(MatSciTest):
    def test_init(self):
        eig = Eigenval(f"{VASP_OUT_DIR}/EIGENVAL.gz")
        assert eig.ispin == 1
        assert eig.nkpt == len(eig.kpoints)
        assert eig.nkpt == len(eig.kpoints_weights)
        assert eig.nkpt == eig.eigenvalues[Spin.up].shape[0]
        assert eig.nelect == 16
        assert eig.nbands == eig.eigenvalues[Spin.up].shape[1]
        assert np.max(eig.eigenvalues[Spin.up]) > 0
        assert np.min(eig.eigenvalues[Spin.up]) < 0

    def test_ispin2(self):
        eig = Eigenval(f"{VASP_OUT_DIR}/EIGENVAL.ispin2.gz")
        assert eig.ispin == 2
        assert eig.nkpt == eig.eigenvalues[Spin.up].shape[0]
        assert eig.nbands == eig.eigenvalues[Spin.up].shape[1]
        assert eig.nkpt == eig.eigenvalues[Spin.down].shape[0]
        assert eig.nbands == eig.eigenvalues[Spin.down].shape[1]

    def test_eigenvalue_band_properties(self):
        eig = Eigenval(f"{VASP_OUT_DIR}/EIGENVAL.gz")
        props = eig.eigenvalue_band_properties
        assert props[0] == approx(6.4153, abs=1e-4)
        assert props[1] == approx(7.5587, abs=1e-4)
        assert props[2] == approx(1.1434, abs=1e-4)
        assert props[3] is False

    def test_eigenvalue_band_properties_separate_spins(self):
        eig = Eigenval(f"{VASP_OUT_DIR}/EIGENVAL_separate_spins.gz", separate_spins=True)
        props = eig.eigenvalue_band_properties
        eig2 = Eigenval(f"{VASP_OUT_DIR}/EIGENVAL_separate_spins.gz", separate_spins=False)
        props2 = eig2.eigenvalue_band_properties

        assert np.array(props)[:3, :2].flat == approx([2.8772, 1.2810, 3.6741, 1.6225, 0.7969, 0.3415], abs=1e-4)
        assert props2[0] == approx(np.min(props[1]) - np.max(props[2]), abs=1e-4)
        assert props[3][0]
        assert props[3][1]


class TestWaveder(MatSciTest):
    def setup_method(self):
        wder = Waveder.from_binary(f"{VASP_OUT_DIR}/WAVEDER", "float64")
        assert wder.nbands == 36
        assert wder.nkpoints == 56
        band_i = band_j = kp_index = spin_index = cart_dir_index = 0
        cder = wder.get_orbital_derivative_between_states(band_i, band_j, kp_index, spin_index, cart_dir_index)
        assert cder == approx(-1.33639226092e-103, abs=1e-114)

    def test_consistency(self):
        wder_ref = np.loadtxt(f"{VASP_OUT_DIR}/WAVEDERF.Si.gz", skiprows=1)

        def _check(wder):
            with zopen(f"{VASP_OUT_DIR}/WAVEDERF.Si.gz", mode="rt", encoding="utf-8") as file:
                first_line = [int(a) for a in file.readline().split()]
            assert wder.nkpoints == first_line[1]
            assert wder.nbands == first_line[2]
            assert [wder.get_orbital_derivative_between_states(0, idx, 0, 0, 0).real for idx in range(10)] == approx(
                wder_ref[:10, 6], abs=1e-10
            )
            assert wder.cder[0, :10, 0, 0, 0].real == approx(wder_ref[:10, 6], abs=1e-10)
            assert wder.cder[0, :10, 0, 0, 0].imag == approx(wder_ref[:10, 7], abs=1e-10)
            assert wder.cder[0, :10, 0, 0, 1].real == approx(wder_ref[:10, 8], abs=1e-10)
            assert wder.cder[0, :10, 0, 0, 1].imag == approx(wder_ref[:10, 9], abs=1e-10)
            assert wder.cder[0, :10, 0, 0, 2].real == approx(wder_ref[:10, 10], abs=1e-10)
            assert wder.cder[0, :10, 0, 0, 2].imag == approx(wder_ref[:10, 11], abs=1e-10)

        wder = Waveder.from_binary(f"{VASP_OUT_DIR}/WAVEDER.Si")
        _check(wder)
        wderf = Waveder.from_formatted(f"{VASP_OUT_DIR}/WAVEDERF.Si.gz")
        _check(wderf)


class TestWSWQ(MatSciTest):
    def setup_method(self):
        self.wswq = WSWQ.from_file(f"{VASP_OUT_DIR}/WSWQ.gz")

    def test_consistency(self):
        assert self.wswq.nbands == 18
        assert self.wswq.nkpoints == 20
        assert self.wswq.nspin == 2
        assert self.wswq.me_real.shape == (2, 20, 18, 18)
        assert self.wswq.me_imag.shape == (2, 20, 18, 18)
        for itr, (r, i) in enumerate(zip(self.wswq.me_real[0][0][4], self.wswq.me_imag[0][0][4], strict=True)):
            if itr == 4:
                assert np.linalg.norm([r, i]) > 0.999
            else:
                assert np.linalg.norm([r, i]) < 0.001


try:
    import h5py
except ImportError:
    h5py = None


@pytest.mark.skipif(condition=h5py is None, reason="h5py must be installed to use the .Vaspout class.")
class TestVaspout(MatSciTest):
    def setup_method(self):
        self.vaspout = Vaspout(f"{VASP_OUT_DIR}/vaspout.line_mode_band_structure.h5.gz")
        self.vaspout_kpoints_opt = Vaspout(f"{VASP_OUT_DIR}/vaspout.kpoints_opt.h5.gz")

    def test_parse(self):
        from pymatgen.io.vasp.inputs import Incar

        assert self.vaspout.final_energy == approx(-8.953035077096956)
        assert self.vaspout.kpoints.num_kpts == 163
        assert all(not attr for attr in [self.vaspout.is_spin, self.vaspout.is_hubbard])

        input_docs = [(self.vaspout.incar, Incar), (self.vaspout.kpoints, Kpoints), (self.vaspout.potcar, Potcar)]
        assert all(isinstance(*doc) for doc in input_docs)

        assert len(self.vaspout.ionic_steps) == 1

        # double check that these POTCARs have been scrambled
        assert all("FAKE" in psingle.data for psingle in self.vaspout.potcar)

    def test_as_dict(self):
        vout_dict = self.vaspout.as_dict()
        assert isinstance(vout_dict, dict)
        assert all(
            key in vout_dict
            for key in [
                "vasp_version",
                "has_vasp_completed",
                "nsites",
                "unit_cell_formula",
                "reduced_cell_formula",
                "pretty_formula",
                "is_hubbard",
                "hubbards",
                "elements",
                "nelements",
                "run_type",
                "input",
                "output",
            ]
        )

    def test_remove_potcar(self):
        new_vaspout_file = f"{self.tmp_path}/vaspout.h5.gz"
        self.vaspout.remove_potcar_and_write_file(filename=new_vaspout_file)
        cleansed_vout = Vaspout(new_vaspout_file)

        cleansed_vout_d = cleansed_vout.as_dict()
        assert all(cleansed_vout_d[k] == v for k, v in self.vaspout.as_dict().items() if k != "potcar")
        assert cleansed_vout.potcar is None

    def test_kpoints_opt(self):
        assert isinstance(self.vaspout_kpoints_opt.kpoints_opt_props, KpointOptProps)
        assert isinstance(self.vaspout_kpoints_opt.kpoints_opt_props.kpoints, Kpoints)
        assert isinstance(self.vaspout_kpoints_opt.kpoints_opt_props.actual_kpoints, list)
        assert all(
            getattr(self.vaspout_kpoints_opt.kpoints_opt_props, k, None) is not None
            for k in (
                "tdos",
                "idos",
                "pdos",
                "efermi",
                "eigenvalues",
            )
        )

        assert all(
            isinstance(bg_prop, BandgapProps)
            for v in self.vaspout_kpoints_opt.bandgap_props.values()
            for bg_prop in v.values()
        )


@pytest.mark.skipif(condition=h5py is None, reason="h5py must be installed to use the .Vaspwave class.")
class TestVaspwave(MatSciTest):
    @pytest.fixture(autouse=True)
    def setup_vaspwave_fixtures(self):
        self.ispin2_std_dir = self._extract_vaspwave_fixture_dir("H2-std-h5")
        self.h2_ncl_dir = self._extract_vaspwave_fixture_dir("H2-ncl-h5")

    def _extract_vaspwave_fixture_dir(self, fixture_name: str) -> Path:
        """Extract a bundled Vaspwave fixture archive into the test tempdir."""
        extracted_root = Path(self.tmp_path) / "vaspwave-fixtures"
        fixture_dir = extracted_root / "vaspwave" / fixture_name
        if fixture_dir.exists():
            return fixture_dir
        archive_path = Path(TEST_DIR) / "outputs" / "vaspwave-H2.tar.gz"
        if not archive_path.exists():
            return fixture_dir
        extracted_root.mkdir(parents=True, exist_ok=True)
        with tarfile.open(archive_path, "r:gz") as tar:
            if hasattr(tarfile, "data_filter"):
                tar.extractall(extracted_root, filter="data")
            else:
                tar.extractall(extracted_root)  # noqa: S202
        return fixture_dir

    @staticmethod
    def _write_minimal_vaspwave_h5(filename: str | Path) -> None:
        with h5py.File(filename, "w") as h5_file:
            version = h5_file.create_group("version")
            version.create_dataset("major", data=6)
            version.create_dataset("minor", data=6)
            version.create_dataset("patch", data=0)

            structure = h5_file.create_group("structure")
            positions = structure.create_group("positions")
            positions.create_dataset("direct_coordinates", data=1)
            positions.create_dataset("ion_sha256", data=np.array([b"dummy_sha"], dtype="S64"))
            positions.create_dataset("ion_types", data=np.array([b"H"], dtype="S2"))
            positions.create_dataset("lattice_vectors", data=np.diag([2.0, 3.0, 4.0]))
            positions.create_dataset("number_ion_types", data=[2])
            positions.create_dataset("position_ions", data=[[0.25, 0.5, 0.75], [0.75, 0.5, 0.25]])
            positions.create_dataset("scale", data=1.0)
            positions.create_dataset("system", data=np.bytes_("minimal_vaspwave"))

            charge = h5_file.create_group("charge")
            charge.create_dataset("grid", data=[2, 3, 4])
            charge.create_dataset("charge", data=np.arange(24, dtype=float).reshape(4, 3, 2)[None, ...])

            locpot = h5_file.create_group("locpot")
            locpot.create_dataset("grid", data=[2, 3, 4])
            locpot.create_dataset("total", data=(100.0 + np.arange(24, dtype=float)).reshape(4, 3, 2)[None, ...])

            wave = h5_file.create_group("wave")
            wave.create_dataset("rispin", data=1.0)
            wave.create_dataset("rnkpts", data=1.0)
            wave.create_dataset("rnb_tot", data=2.0)
            wave.create_dataset("enmax", data=20.0)
            wave.create_dataset("efermi", data=5.5)
            wave.create_dataset("amat", data=np.diag([2.0, 3.0, 4.0]))

            spin_1 = wave.create_group("spin_1")
            kpoint_1 = spin_1.create_group("kpoint_1")
            kpoint_1.create_dataset("num_planewaves", data=3)
            kpoint_1.create_dataset("vkpt", data=[0.0, 0.0, 0.0])
            kpoint_1.create_dataset("fertot", data=[1.0, 0.0])
            kpoint_1.create_dataset("celtot", data=[[1.5, 0.0], [2.5, -0.25]])
            wave_data = kpoint_1.create_dataset(
                "wave",
                data=np.array(
                    [
                        [[1.0, 0.0], [0.5, 0.25], [0.0, 0.0]],
                        [[0.0, 1.0], [0.25, -0.5], [0.0, 0.0]],
                    ],
                    dtype=np.float32,
                ),
            )
            wave_data.attrs["dtype"] = "complex"

    @contextmanager
    def _mutate_vaspwave_h5(self, filename: str | Path):
        with h5py.File(filename, "a") as h5_file:
            yield h5_file

    def _edit_vaspwave_h5(self, filename: str | Path, mutator) -> None:
        with self._mutate_vaspwave_h5(filename) as h5_file:
            mutator(h5_file)

    def _remove_vaspwave_structure(self, filename: str | Path) -> None:
        self._edit_vaspwave_h5(filename, lambda h5_file: h5_file.__delitem__("structure"))

    def _remove_vaspwave_wave_group(self, filename: str | Path) -> None:
        self._edit_vaspwave_h5(filename, lambda h5_file: h5_file.__delitem__("wave"))

    def _move_vaspwave_structure_to_locpot(self, filename: str | Path) -> None:
        def mutator(h5_file) -> None:
            locpot_position = h5_file["locpot"].create_group("position")
            for key, value in h5_file["structure"]["positions"].items():
                locpot_position.copy(value, key)
            del h5_file["structure"]

        self._edit_vaspwave_h5(filename, mutator)

    def _make_locpot_structure_inconsistent(self, filename: str | Path) -> None:
        def mutator(h5_file) -> None:
            locpot_position = h5_file["locpot"].create_group("position")
            for key, value in h5_file["structure"]["positions"].items():
                locpot_position.copy(value, key)
            locpot_position["position_ions"][0, 0] = 0.125

        self._edit_vaspwave_h5(filename, mutator)

    @staticmethod
    def _write_vaspwave_h5_from_wavecar(
        filename: str | Path,
        wavecar: Wavecar,
        structure: Structure,
    ) -> None:
        """Write a minimal Vaspwave-compatible HDF5 file from Wavecar data.

        This helper is intentionally limited to gamma-only and standard
        Wavecar fixtures used in tests. It writes canonical coefficients for
        ``std`` files and serialized gamma rows for ``gam`` files so the
        resulting HDF5 can be parsed back by ``Vaspwave``.
        """
        if wavecar.vasp_type not in {"gam", "std"}:
            raise NotImplementedError(
                "Runtime Vaspwave fixtures currently support only gamma-only and std Wavecar data."
            )
        if wavecar.spin != 1:
            raise NotImplementedError("Runtime Vaspwave fixtures currently support only spin-unpolarized Wavecar data.")

        lattice = np.array(structure.lattice.matrix, dtype=float)
        species = [str(sp) for sp in structure.composition]
        counts = [int(structure.composition[sp]) for sp in structure.composition]

        with h5py.File(filename, "w") as h5_file:
            version = h5_file.create_group("version")
            version.create_dataset("major", data=6)
            version.create_dataset("minor", data=6)
            version.create_dataset("patch", data=0)

            structure_group = h5_file.create_group("structure")
            positions = structure_group.create_group("positions")
            positions.create_dataset("direct_coordinates", data=1)
            positions.create_dataset("ion_sha256", data=np.array([b"synthetic"], dtype="S64"))
            positions.create_dataset("ion_types", data=np.array(species, dtype="S8"))
            positions.create_dataset("lattice_vectors", data=lattice)
            positions.create_dataset("number_ion_types", data=counts)
            positions.create_dataset("position_ions", data=np.array(structure.frac_coords, dtype=float))
            positions.create_dataset("scale", data=1.0)
            positions.create_dataset("system", data=np.bytes_("wavecar_runtime_fixture"))

            wave = h5_file.create_group("wave")
            wave.create_dataset("rispin", data=float(wavecar.spin))
            wave.create_dataset("rnkpts", data=float(wavecar.nk))
            wave.create_dataset("rnb_tot", data=float(wavecar.nb))
            wave.create_dataset("enmax", data=float(wavecar.encut))
            wave.create_dataset("efermi", data=float(wavecar.efermi))
            wave.create_dataset("amat", data=np.array(wavecar.a, dtype=float))

            spin_group = wave.create_group("spin_1")
            for ik in range(wavecar.nk):
                kpoint_group = spin_group.create_group(f"kpoint_{ik + 1}")
                if wavecar.vasp_type == "gam":
                    nplane = int((len(wavecar.Gpoints[ik]) + 1) // 2)
                    coeff_rows = np.array(wavecar.coeffs[ik], dtype=np.complex128)[:, :nplane].copy()
                    gamma_gpoints, _, extra_coeff_inds = wavecar._generate_G_points(wavecar.kpoints[ik], gamma=True)
                    if len(gamma_gpoints) != nplane:
                        raise ValueError("Gamma-only serialization helper generated an unexpected plane-wave count.")
                    for band_idx in range(wavecar.nb):
                        for g_ind in extra_coeff_inds:
                            coeff_rows[band_idx, g_ind] *= np.sqrt(2)
                else:
                    nplane = len(wavecar.Gpoints[ik])
                    coeff_rows = np.array(wavecar.coeffs[ik], dtype=np.complex128)

                kpoint_group.create_dataset("num_planewaves", data=nplane)
                kpoint_group.create_dataset("vkpt", data=np.array(wavecar.kpoints[ik], dtype=float))
                kpoint_group.create_dataset("fertot", data=np.array(wavecar.band_energy[ik][:, 2], dtype=float))
                kpoint_group.create_dataset("celtot", data=np.array(wavecar.band_energy[ik][:, :2], dtype=float))
                wave_data = np.stack((coeff_rows.real, coeff_rows.imag), axis=-1).astype(np.float32)
                dataset = kpoint_group.create_dataset("wave", data=wave_data)
                dataset.attrs["dtype"] = "complex"

    def test_class_available(self):
        assert Vaspwave is not None

    def test_parse_minimal_vaspwave_h5(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)

        vaspwave = Vaspwave(filename)

        assert vaspwave.spin == 1
        assert vaspwave.nk == 1
        assert vaspwave.nb == 2
        assert vaspwave.encut == approx(20.0)
        assert vaspwave.efermi == approx(5.5)
        assert_allclose(vaspwave.a, np.diag([2.0, 3.0, 4.0]))
        assert vaspwave.vol == approx(24.0)
        assert_allclose(vaspwave.b, np.diag([np.pi, 2 * np.pi / 3, np.pi / 2]))
        assert len(vaspwave.kpoints) == 1
        assert_allclose(vaspwave.kpoints[0], [0.0, 0.0, 0.0])
        assert len(vaspwave.band_energy) == 1
        assert_allclose(vaspwave.band_energy[0], [[1.5, 0.0, 1.0], [2.5, -0.25, 0.0]])
        assert vaspwave.num_planewaves == [3]
        assert vaspwave.structure.reduced_formula == "H2"
        assert_allclose(vaspwave.structure.lattice.matrix, np.diag([2.0, 3.0, 4.0]))
        assert_allclose(vaspwave.structure.frac_coords, [[0.25, 0.5, 0.75], [0.75, 0.5, 0.25]])
        assert vaspwave._gamma_only is True
        assert vaspwave.vasp_type == "gam"
        assert vaspwave.version == {"major": 6, "minor": 6, "patch": 0}

    def test_parse_minimal_vaspwave_h5_without_structure(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        self._remove_vaspwave_structure(filename)

        vaspwave = Vaspwave(filename)

        assert vaspwave.structure is None
        assert vaspwave.spin == 1
        assert vaspwave.nk == 1
        assert vaspwave.nb == 2
        assert_allclose(vaspwave.a, np.diag([2.0, 3.0, 4.0]))

    def test_parse_minimal_vaspwave_h5_without_wave_group(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        self._remove_vaspwave_wave_group(filename)

        vaspwave = Vaspwave(filename)

        assert not vaspwave._has_wavefunction_data
        assert vaspwave.structure.reduced_formula == "H2"
        assert vaspwave.version == {"major": 6, "minor": 6, "patch": 0}

        chgcar = vaspwave.get_chgcar()
        locpot = vaspwave.get_locpot()

        assert chgcar.structure == vaspwave.structure
        assert chgcar.dim == (2, 3, 4)
        assert locpot.structure == vaspwave.structure
        assert locpot.dim == (2, 3, 4)

        with pytest.raises(ValueError, match="does not contain wavefunction data"):
            vaspwave.get_band_coeffs(0, 0, 0)
        with pytest.raises(ValueError, match="does not contain wavefunction data"):
            vaspwave.fft_mesh(0, 0)
        with pytest.raises(ValueError, match="does not contain wavefunction data"):
            vaspwave.evaluate_wavefunc(0, 0, np.array([0.0, 0.0, 0.0]))
        with pytest.raises(ValueError, match="does not contain wavefunction data"):
            vaspwave.get_parchg(Poscar.from_file(f"{VASP_IN_DIR}/POSCAR"), 0, 0)
        with pytest.raises(ValueError, match="does not contain wavefunction data"):
            vaspwave.write_unks(Path(self.tmp_path) / "unks_without_wave")

    def test_parse_minimal_vaspwave_h5_with_locpot_structure(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        self._move_vaspwave_structure_to_locpot(filename)

        vaspwave = Vaspwave(filename)

        assert vaspwave.structure.reduced_formula == "H2"
        assert_allclose(vaspwave.structure.lattice.matrix, np.diag([2.0, 3.0, 4.0]))
        assert_allclose(vaspwave.structure.frac_coords, [[0.25, 0.5, 0.75], [0.75, 0.5, 0.25]])

    def test_parse_minimal_vaspwave_h5_with_inconsistent_structures(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        self._make_locpot_structure_inconsistent(filename)

        with pytest.raises(ValueError, match="inconsistent structures"):
            Vaspwave(filename)

    def test_get_band_coeffs_minimal_vaspwave_h5(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)

        vaspwave = Vaspwave(filename)
        coeffs = vaspwave.get_band_coeffs(0, 0, 1)

        assert_allclose(
            coeffs,
            np.array(
                [
                    1j,
                    (0.25 - 0.5j) / np.sqrt(2),
                    0.0 + 0.0j,
                    (0.25 + 0.5j) / np.sqrt(2),
                    0.0 - 0.0j,
                ],
                dtype=np.complex128,
            ),
        )

    def test_read_raw_band_coeffs_minimal_vaspwave_h5(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)

        vaspwave = Vaspwave(filename)
        coeffs_re_im = vaspwave._read_raw_band_coeffs(0, 0, 1)

        assert coeffs_re_im.shape == (3, 2)
        assert np.issubdtype(coeffs_re_im.dtype, np.floating)
        assert_allclose(coeffs_re_im, [[0.0, 1.0], [0.25, -0.5], [0.0, 0.0]])

    def test_decode_raw_band_coeffs_minimal_vaspwave_h5(self):
        coeffs_re_im = np.array([[1.0, 0.0], [0.5, 0.25], [0.0, 0.0]])

        coeffs = Vaspwave._decode_raw_band_coeffs(coeffs_re_im)

        assert coeffs.shape == (3,)
        assert np.issubdtype(coeffs.dtype, np.complexfloating)
        assert_allclose(coeffs, [1.0 + 0.0j, 0.5 + 0.25j, 0.0 + 0.0j])

    def test_to_canonical_band_coeffs_minimal_vaspwave_h5(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)

        vaspwave = Vaspwave(filename)
        raw_coeffs = np.array([1.0 + 0.0j, 0.5 + 0.25j, 0.0 + 0.0j], dtype=np.complex128)
        coeffs = vaspwave._to_canonical_band_coeffs(raw_coeffs, 0)

        assert len(coeffs) == len(vaspwave.Gpoints[0])
        assert coeffs.dtype == np.complex128
        assert_allclose(
            coeffs,
            np.array(
                [
                    1.0 + 0.0j,
                    (0.5 + 0.25j) / np.sqrt(2),
                    0.0 + 0.0j,
                    (0.5 - 0.25j) / np.sqrt(2),
                    0.0 - 0.0j,
                ],
                dtype=np.complex128,
            ),
        )

    def test_get_band_coeffs_invalid_spin_index(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)

        with pytest.raises(IndexError, match="Spin index 1 out of range"):
            vaspwave.get_band_coeffs(1, 0, 0)

    def test_get_band_coeffs_invalid_kpoint_index(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)

        with pytest.raises(IndexError, match="Kpoint index 2 out of range"):
            vaspwave.get_band_coeffs(0, 2, 0)

    def test_get_band_coeffs_invalid_band_index(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)

        with pytest.raises(IndexError, match="Band index 4 out of range"):
            vaspwave.get_band_coeffs(0, 0, 4)

    def test_build_band_energy_array(self):
        kpoint_data = {
            "celtot": [[1.5, 0.0], [2.5, -0.25]],
            "fertot": [1.0, 0.0],
        }
        band_energy = Vaspwave._build_band_energy_array(kpoint_data)
        assert_allclose(band_energy, [[1.5, 0.0, 1.0], [2.5, -0.25, 0.0]])

    def test_parse_structure(self):
        structure = Vaspwave._parse_structure(
            {
                "direct_coordinates": 1,
                "ion_types": ["H"],
                "number_ion_types": [2],
                "lattice_vectors": np.diag([2.0, 3.0, 4.0]),
                "position_ions": [[0.25, 0.5, 0.75], [0.75, 0.5, 0.25]],
                "scale": 1.0,
            }
        )

        assert structure.reduced_formula == "H2"
        assert_allclose(structure.lattice.matrix, np.diag([2.0, 3.0, 4.0]))
        assert_allclose(structure.frac_coords, [[0.25, 0.5, 0.75], [0.75, 0.5, 0.25]])

    def test_compute_reciprocal_lattice_and_volume(self):
        a = np.diag([2.0, 3.0, 4.0])
        b, vol = Vaspwave._compute_reciprocal_lattice_and_volume(a)

        assert vol == approx(24.0)
        assert_allclose(b, np.diag([np.pi, 2 * np.pi / 3, np.pi / 2]))

    def test_is_gamma_only(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)
        vaspwave.kpoints = [np.array([1e-12, -1e-12, 0.0])]

        assert vaspwave._is_gamma_only()

    def test_fft_mesh_minimal_vaspwave_h5(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)

        mesh_shift = vaspwave.fft_mesh(0, 0)
        mesh_noshift = vaspwave.fft_mesh(0, 0, shift=False)
        gpoints = vaspwave.Gpoints[0]

        assert gpoints is not None
        assert len(gpoints) == 5
        assert mesh_shift.shape == tuple(vaspwave.ng)
        assert mesh_noshift.shape == tuple(vaspwave.ng)
        assert np.count_nonzero(mesh_shift) == 3
        assert np.count_nonzero(mesh_noshift) == 3

        center = tuple((vaspwave.ng / 2).astype(int))
        shifted_indices = [tuple(gp.astype(int) + np.array(center)) for gp in gpoints]
        expected_coeffs = np.array(
            [
                1.0 + 0.0j,
                (0.5 + 0.25j) / np.sqrt(2),
                0.0 + 0.0j,
                (0.5 - 0.25j) / np.sqrt(2),
                0.0 - 0.0j,
            ],
            dtype=np.complex128,
        )
        assert_allclose([mesh_noshift[idx] for idx in shifted_indices], expected_coeffs)
        assert_allclose(mesh_shift, np.fft.ifftshift(mesh_noshift))

    def test_evaluate_wavefunc_minimal_vaspwave_h5(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)
        r = np.array([0.0, 0.0, 0.0])

        value = vaspwave.evaluate_wavefunc(0, 0, r)

        assert value == approx((1.0 + 2 * (0.5 / np.sqrt(2))) / np.sqrt(24.0))

    def test_negative_indices_match_positive_indices(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)
        poscar = Poscar.from_file(f"{VASP_IN_DIR}/POSCAR")
        r = np.array([0.0, 0.0, 0.0])

        assert_allclose(vaspwave.get_band_coeffs(0, -1, -1), vaspwave.get_band_coeffs(0, 0, 1))
        assert_allclose(vaspwave.fft_mesh(-1, -1), vaspwave.fft_mesh(0, 1))
        assert vaspwave.evaluate_wavefunc(-1, -1, r) == approx(vaspwave.evaluate_wavefunc(0, 1, r))
        assert_allclose(
            vaspwave.get_parchg(poscar, -1, -1, phase=False).data["total"],
            vaspwave.get_parchg(poscar, 0, 1, phase=False).data["total"],
        )

    def test_gamma_only_spin_and_spinor_guards(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)
        poscar = Poscar.from_file(f"{VASP_IN_DIR}/POSCAR")

        with pytest.raises(NotImplementedError, match=re.escape("Spin-resolved vaspwave.h5")):
            vaspwave.get_parchg(poscar, 0, 0, spin=1)

        vaspwave_default = vaspwave.get_parchg(poscar, 0, 0).data["total"]

        for spinor in (0, 1):
            vaspwave_parchg = vaspwave.get_parchg(poscar, 0, 0, spinor=spinor).data["total"]
            assert_allclose(vaspwave_parchg, 0.5 * vaspwave_default)

    def test_fft_mesh_invalid_band_index(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)

        with pytest.raises(IndexError, match="Band index 5 out of range"):
            vaspwave.fft_mesh(0, 5)

    def test_unsupported_vasp_type_guard(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)
        vaspwave.vasp_type = "unknown"

        with pytest.raises(NotImplementedError, match=re.escape("Unsupported vaspwave.h5 type")):
            vaspwave.fft_mesh(0, 0)

    def test_unsupported_spin_setting_guard(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)
        vaspwave.spin = 3

        with pytest.raises(NotImplementedError, match=re.escape("Unsupported vaspwave.h5 spin setting")):
            vaspwave.get_band_coeffs(0, 0, 0)

    @pytest.mark.skipif(
        not (Path(TEST_DIR) / "outputs" / "vaspwave-H2.tar.gz").exists(),
        reason="Bundled H2 ncl vaspwave fixtures are not available.",
    )
    def test_h2_ncl_fixture_matches_wavecar(self):
        ncl_dir = self.h2_ncl_dir
        vaspwave = Vaspwave(ncl_dir / "vaspwave.h5")
        wavecar = Wavecar(ncl_dir / "WAVECAR")

        assert vaspwave.vasp_type == wavecar.vasp_type == "ncl"
        assert vaspwave.spin == wavecar.spin == 1
        assert vaspwave.nk == wavecar.nk
        assert vaspwave.nb == wavecar.nb
        assert_allclose(vaspwave.kpoints[0], wavecar.kpoints[0])
        assert len(vaspwave.Gpoints[0]) == len(wavecar.Gpoints[0])
        assert_allclose(vaspwave.Gpoints[0], wavecar.Gpoints[0])

        coeffs_h5 = vaspwave.get_band_coeffs(0, 0, 0)
        coeffs_wavecar = wavecar.coeffs[0][0]
        assert coeffs_h5.shape == coeffs_wavecar.shape == (2, len(wavecar.Gpoints[0]))
        for spinor in (0, 1):
            phase = np.vdot(coeffs_wavecar[spinor], coeffs_h5[spinor]) / np.vdot(
                coeffs_wavecar[spinor], coeffs_wavecar[spinor]
            )
            rel_resid = np.linalg.norm(coeffs_h5[spinor] - phase * coeffs_wavecar[spinor]) / np.linalg.norm(
                coeffs_wavecar[spinor]
            )
            if spinor == 0:
                assert np.linalg.norm(coeffs_h5[spinor] - phase * coeffs_wavecar[spinor]) < 2e-5
            else:
                assert rel_resid < 1e-3

        for spinor in (0, 1):
            coeff_phase = np.vdot(coeffs_wavecar[spinor], coeffs_h5[spinor]) / np.vdot(
                coeffs_wavecar[spinor], coeffs_wavecar[spinor]
            )
            mesh_h5 = vaspwave.fft_mesh(0, 0, spinor=spinor)
            mesh_wavecar = wavecar.fft_mesh(0, 0, spinor=spinor)
            mesh_rel_resid = np.linalg.norm(mesh_h5 - coeff_phase * mesh_wavecar) / np.linalg.norm(mesh_wavecar)
            if spinor == 0:
                assert np.linalg.norm(mesh_h5 - coeff_phase * mesh_wavecar) < 2e-5
            else:
                assert mesh_rel_resid < 1e-3

            assert abs(vaspwave.evaluate_wavefunc(0, 0, np.array([0.0, 0.0, 0.0]), spinor=spinor)) == approx(
                abs(wavecar.evaluate_wavefunc(0, 0, np.array([0.0, 0.0, 0.0]), spinor=spinor)),
                rel=0.2,
                abs=1e-6,
            )

    @pytest.mark.skipif(
        not (Path(TEST_DIR) / "outputs" / "vaspwave-H2.tar.gz").exists(),
        reason="Bundled H2 ncl vaspwave fixtures are not available.",
    )
    def test_h2_ncl_fixture_parchg_and_unk_match_wavecar(self):
        ncl_dir = self.h2_ncl_dir
        vaspwave = Vaspwave(ncl_dir / "vaspwave.h5")
        wavecar = Wavecar(ncl_dir / "WAVECAR")
        poscar = Chgcar.from_file(ncl_dir / "CHGCAR").poscar

        vaspwave_parchg = vaspwave.get_parchg(poscar, 0, 0, phase=False, scale=1)
        wavecar_parchg = wavecar.get_parchg(poscar, 0, 0, phase=False, scale=1)
        total_rel_err = np.linalg.norm(vaspwave_parchg.data["total"] - wavecar_parchg.data["total"]) / np.linalg.norm(
            wavecar_parchg.data["total"]
        )
        assert total_rel_err < 0.02

        vaspwave_parchg_spinor = vaspwave.get_parchg(poscar, 0, 0, spinor=1, phase=False, scale=1)
        wavecar_parchg_spinor = wavecar.get_parchg(poscar, 0, 0, spinor=1, phase=False, scale=1)
        spinor_rel_err = np.linalg.norm(
            vaspwave_parchg_spinor.data["total"] - wavecar_parchg_spinor.data["total"]
        ) / np.linalg.norm(wavecar_parchg_spinor.data["total"])
        assert spinor_rel_err < 0.02

        vaspwave_parchg_spinor0 = vaspwave.get_parchg(poscar, 0, 0, spinor=0, phase=False, scale=1)
        wavecar_parchg_spinor0 = wavecar.get_parchg(poscar, 0, 0, spinor=0, phase=False, scale=1)
        spinor0_rel_err = np.linalg.norm(
            vaspwave_parchg_spinor0.data["total"] - wavecar_parchg_spinor0.data["total"]
        ) / np.linalg.norm(wavecar_parchg_spinor0.data["total"])
        assert spinor0_rel_err < 0.02

        vaspwave_dir = Path(self.tmp_path) / "h2_ncl_vaspwave_unk"
        wavecar_dir = Path(self.tmp_path) / "h2_ncl_wavecar_unk"
        vaspwave.write_unks(vaspwave_dir)
        wavecar.write_unks(wavecar_dir)
        unk_h5 = Unk.from_file(vaspwave_dir / "UNK00001.NC")
        unk_wavecar = Unk.from_file(wavecar_dir / "UNK00001.NC")
        assert unk_h5.data.shape == unk_wavecar.data.shape == (vaspwave.nb, 2, *vaspwave.ng)
        assert unk_h5.data.dtype == unk_wavecar.data.dtype == np.complex128

    def test_get_parchg_minimal_vaspwave_h5(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)

        chgcar = vaspwave.get_parchg(Poscar.from_file(f"{VASP_IN_DIR}/POSCAR"), 0, 0, phase=False)

        assert "total" in chgcar.data
        assert "diff" not in chgcar.data
        assert chgcar.data["total"].shape == tuple(vaspwave.ng * 2)
        assert np.all(chgcar.data["total"] >= 0.0)

    def test_get_parchg_phase_minimal_vaspwave_h5(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)

        chgcar = vaspwave.get_parchg(Poscar.from_file(f"{VASP_IN_DIR}/POSCAR"), 0, 0, phase=True)
        chgcar_no_phase = vaspwave.get_parchg(Poscar.from_file(f"{VASP_IN_DIR}/POSCAR"), 0, 0, phase=False)

        assert "total" in chgcar.data
        assert chgcar.data["total"].shape == tuple(vaspwave.ng * 2)
        assert_allclose(np.abs(chgcar.data["total"]), chgcar_no_phase.data["total"])

    def test_write_unks_minimal_vaspwave_h5(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)

        out_dir = Path(self.tmp_path) / "unks"
        vaspwave.write_unks(out_dir)

        unk = Unk.from_file(out_dir / "UNK00001.1")
        assert unk.data.shape == (vaspwave.nb, *vaspwave.ng)
        assert unk.data.dtype == np.complex128

    def test_get_chgcar_minimal_vaspwave_h5(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)

        chgcar = vaspwave.get_chgcar()

        assert isinstance(chgcar, Chgcar)
        assert chgcar.structure == vaspwave.structure
        assert chgcar.dim == (2, 3, 4)
        assert_allclose(chgcar.data["total"], np.transpose(np.arange(24, dtype=float).reshape(4, 3, 2), (2, 1, 0)))

    def test_get_chgcar_minimal_vaspwave_h5_without_structure(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        structure = Vaspwave(filename).structure
        self._remove_vaspwave_structure(filename)
        vaspwave = Vaspwave(filename)

        with pytest.raises(ValueError, match="does not contain structure data"):
            vaspwave.get_chgcar()

        vaspwave.structure = structure
        chgcar = vaspwave.get_chgcar()

        assert isinstance(chgcar, Chgcar)
        assert chgcar.structure == structure
        assert chgcar.dim == (2, 3, 4)
        assert_allclose(chgcar.data["total"], np.transpose(np.arange(24, dtype=float).reshape(4, 3, 2), (2, 1, 0)))

    def test_get_locpot_minimal_vaspwave_h5_without_structure(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        structure = Vaspwave(filename).structure
        self._remove_vaspwave_structure(filename)
        vaspwave = Vaspwave(filename)

        with pytest.raises(ValueError, match="does not contain structure data"):
            vaspwave.get_locpot()

        vaspwave.structure = structure
        locpot = vaspwave.get_locpot()

        assert isinstance(locpot, Locpot)
        assert locpot.structure == structure
        assert locpot.dim == (2, 3, 4)

    def test_get_locpot_minimal_vaspwave_h5(self):
        filename = Path(self.tmp_path) / "vaspwave.h5"
        self._write_minimal_vaspwave_h5(filename)
        vaspwave = Vaspwave(filename)

        locpot = vaspwave.get_locpot()

        assert isinstance(locpot, Locpot)
        assert locpot.structure == vaspwave.structure
        assert locpot.dim == (2, 3, 4)
        assert_allclose(
            locpot.data["total"],
            np.transpose((100.0 + np.arange(24, dtype=float)).reshape(4, 3, 2), (2, 1, 0)),
        )

    def test_validate_volumetric_dataset_invalid_ndim(self):
        grid = np.array([2, 3, 4])
        data = np.zeros((4, 3, 2))

        with pytest.raises(ValueError, match="Expected /charge/charge to have 4 dimensions"):
            Vaspwave._validate_volumetric_dataset(grid, data, "/charge/charge")

    def test_validate_volumetric_dataset_spin_polarized_components(self):
        grid = np.array([2, 3, 4])
        data = np.zeros((2, 4, 3, 2))
        data[0] = np.arange(24, dtype=float).reshape(4, 3, 2)
        data[1] = 100.0 + np.arange(24, dtype=float).reshape(4, 3, 2)

        validated = Vaspwave._validate_volumetric_dataset(grid, data, "/charge/charge")

        assert set(validated) == {"total", "diff"}
        assert_allclose(validated["total"], np.transpose(data[0], (2, 1, 0)))
        assert_allclose(validated["diff"], np.transpose(data[1], (2, 1, 0)))

    def test_validate_volumetric_dataset_soc_components(self):
        grid = np.array([2, 3, 4])
        data = np.zeros((4, 4, 3, 2))
        data[0] = np.arange(24, dtype=float).reshape(4, 3, 2)
        data[1] = 1.0
        data[2] = 2.0
        data[3] = 3.0

        validated = Vaspwave._validate_volumetric_dataset(grid, data, "/charge/charge")

        assert set(validated) == {"total", "diff_x", "diff_y", "diff_z", "diff"}
        assert_allclose(validated["total"], np.transpose(data[0], (2, 1, 0)))
        assert_allclose(validated["diff_x"], np.transpose(data[1], (2, 1, 0)))
        assert_allclose(validated["diff_y"], np.transpose(data[2], (2, 1, 0)))
        assert_allclose(validated["diff_z"], np.transpose(data[3], (2, 1, 0)))
        assert np.all(validated["diff"] > 0)

    def test_validate_volumetric_dataset_grid_shape_mismatch(self):
        grid = np.array([2, 3, 5])
        data = np.zeros((1, 4, 3, 2))

        with pytest.raises(
            ValueError,
            match="/charge/grid \\(2, 3, 5\\) does not match /charge/charge shape \\(4, 3, 2\\)",
        ):
            Vaspwave._validate_volumetric_dataset(grid, data, "/charge/charge")

    @pytest.mark.skipif(
        not Path(f"{VASP_OUT_DIR}/WAVECAR.H2_low_symm.gamma").exists(),
        reason="Gamma-only Wavecar fixture is not available.",
    )
    def test_gamma_only_runtime_fixture_matches_wavecar(self):
        wavecar = Wavecar(f"{VASP_OUT_DIR}/WAVECAR.H2_low_symm.gamma")
        filename = Path(self.tmp_path) / "gamma_runtime_vaspwave.h5"
        self._write_vaspwave_h5_from_wavecar(filename, wavecar, Poscar.from_file(f"{VASP_IN_DIR}/POSCAR").structure)
        vaspwave = Vaspwave(filename)

        assert vaspwave.spin == wavecar.spin
        assert vaspwave.nk == wavecar.nk
        assert vaspwave.nb == wavecar.nb
        assert vaspwave.encut == approx(wavecar.encut)
        assert vaspwave.efermi == approx(wavecar.efermi)
        assert_allclose(vaspwave.a, wavecar.a)
        assert_allclose(vaspwave.b, wavecar.b)
        assert vaspwave.vol == approx(wavecar.vol)
        assert_allclose(vaspwave.kpoints[0], wavecar.kpoints[0])

        gpoints = vaspwave.Gpoints[0]
        assert gpoints is not None
        assert_allclose(gpoints, wavecar.Gpoints[0])
        assert vaspwave.num_planewaves[0] == len(wavecar._generate_G_points(wavecar.kpoints[0], gamma=True)[0])

        for band in (0, min(1, vaspwave.nb - 1)):
            assert_allclose(vaspwave.get_band_coeffs(0, 0, band), wavecar.coeffs[0][band])
            assert_allclose(vaspwave.fft_mesh(0, band), wavecar.fft_mesh(0, band))

        for band, r in ((0, np.array([0.0, 0.0, 0.0])), (0, np.array([0.1, 0.2, 0.3]))):
            assert vaspwave.evaluate_wavefunc(0, band, r) == approx(wavecar.evaluate_wavefunc(0, band, r))

    @pytest.mark.skipif(
        not Path(f"{VASP_OUT_DIR}/WAVECAR.H2_low_symm.gamma").exists(),
        reason="Gamma-only Wavecar fixture is not available.",
    )
    def test_gamma_only_runtime_fixture_parchg_and_unk_match_wavecar(self):
        wavecar = Wavecar(f"{VASP_OUT_DIR}/WAVECAR.H2_low_symm.gamma")
        poscar = Poscar.from_file(f"{VASP_IN_DIR}/POSCAR")
        filename = Path(self.tmp_path) / "gamma_runtime_vaspwave.h5"
        self._write_vaspwave_h5_from_wavecar(filename, wavecar, poscar.structure)
        vaspwave = Vaspwave(filename)

        vaspwave_parchg = vaspwave.get_parchg(poscar, 0, 0, phase=False, scale=1)
        wavecar_parchg = wavecar.get_parchg(poscar, 0, 0, spin=0, phase=False, scale=1)
        assert_allclose(vaspwave_parchg.data["total"], wavecar_parchg.data["total"])

        vaspwave_dir = Path(self.tmp_path) / "vaspwave_unk"
        wavecar_dir = Path(self.tmp_path) / "wavecar_unk"
        vaspwave.write_unks(vaspwave_dir)
        wavecar.write_unks(wavecar_dir)

        assert Unk.from_file(vaspwave_dir / "UNK00001.1") == Unk.from_file(wavecar_dir / "UNK00001.1")

    @pytest.mark.skipif(
        not Path(f"{VASP_OUT_DIR}/WAVECAR.N2").exists(),
        reason="Std Wavecar fixture is not available.",
    )
    def test_std_runtime_fixture_matches_wavecar(self):
        wavecar = Wavecar(f"{VASP_OUT_DIR}/WAVECAR.N2")
        filename = Path(self.tmp_path) / "std_runtime_vaspwave.h5"
        self._write_vaspwave_h5_from_wavecar(filename, wavecar, Poscar.from_file(f"{VASP_IN_DIR}/POSCAR").structure)
        vaspwave = Vaspwave(filename)

        assert vaspwave.vasp_type == "std"
        assert vaspwave.spin == wavecar.spin
        assert vaspwave.nk == wavecar.nk
        assert vaspwave.nb == wavecar.nb
        assert vaspwave.encut == approx(wavecar.encut)
        assert vaspwave.efermi == approx(wavecar.efermi)
        assert_allclose(vaspwave.a, wavecar.a)
        assert_allclose(vaspwave.b, wavecar.b)
        assert vaspwave.vol == approx(wavecar.vol)
        assert_allclose(vaspwave.kpoints[0], wavecar.kpoints[0])

        gpoints = vaspwave.Gpoints[0]
        assert gpoints is not None
        assert vaspwave.num_planewaves[0] == len(wavecar.Gpoints[0])
        assert_allclose(gpoints, wavecar.Gpoints[0])

        for band in (0, min(1, vaspwave.nb - 1)):
            assert_allclose(vaspwave.get_band_coeffs(0, 0, band), wavecar.coeffs[0][band])
            assert_allclose(vaspwave.fft_mesh(0, band), wavecar.fft_mesh(0, band))

        for band, r in ((0, np.array([0.0, 0.0, 0.0])), (1, np.array([0.1, 0.2, 0.3]))):
            assert vaspwave.evaluate_wavefunc(0, band, r) == approx(wavecar.evaluate_wavefunc(0, band, r))

    @pytest.mark.skipif(
        not Path(f"{VASP_OUT_DIR}/WAVECAR.N2").exists(),
        reason="Std Wavecar fixture is not available.",
    )
    def test_std_runtime_fixture_parchg_and_unk_match_wavecar(self):
        wavecar = Wavecar(f"{VASP_OUT_DIR}/WAVECAR.N2")
        poscar = Poscar.from_file(f"{VASP_IN_DIR}/POSCAR")
        filename = Path(self.tmp_path) / "std_runtime_vaspwave.h5"
        self._write_vaspwave_h5_from_wavecar(filename, wavecar, poscar.structure)
        vaspwave = Vaspwave(filename)

        vaspwave_parchg = vaspwave.get_parchg(poscar, 0, 0, phase=False, scale=1)
        wavecar_parchg = wavecar.get_parchg(poscar, 0, 0, spin=0, phase=False, scale=1)
        assert_allclose(vaspwave_parchg.data["total"], wavecar_parchg.data["total"])

        vaspwave_dir = Path(self.tmp_path) / "std_vaspwave_unk"
        wavecar_dir = Path(self.tmp_path) / "std_wavecar_unk"
        vaspwave.write_unks(vaspwave_dir)
        wavecar.write_unks(wavecar_dir)

        assert Unk.from_file(vaspwave_dir / "UNK00001.1") == Unk.from_file(wavecar_dir / "UNK00001.1")

    def test_std_spin_and_spinor_guards(self):
        wavecar = Wavecar(f"{VASP_OUT_DIR}/WAVECAR.N2")
        filename = Path(self.tmp_path) / "std_runtime_vaspwave.h5"
        poscar = Poscar.from_file(f"{VASP_IN_DIR}/POSCAR")
        self._write_vaspwave_h5_from_wavecar(filename, wavecar, poscar.structure)
        vaspwave = Vaspwave(filename)

        with pytest.raises(NotImplementedError, match=re.escape("Spin-resolved vaspwave.h5")):
            vaspwave.get_parchg(poscar, 0, 0, spin=1)

        vaspwave_default = vaspwave.get_parchg(poscar, 0, 0)
        wavecar_default = wavecar.get_parchg(poscar, 0, 0)
        assert_allclose(vaspwave_default.data["total"], wavecar_default.data["total"])

        for spinor in (0, 1):
            vaspwave_parchg = vaspwave.get_parchg(poscar, 0, 0, spinor=spinor)
            wavecar_parchg = wavecar.get_parchg(poscar, 0, 0, spinor=spinor)
            assert_allclose(vaspwave_parchg.data["total"], 0.5 * vaspwave_default.data["total"])
            assert_allclose(vaspwave_parchg.data["total"], wavecar_parchg.data["total"])

    @pytest.mark.skipif(
        not (Path(TEST_DIR) / "outputs" / "vaspwave-H2.tar.gz").exists(),
        reason="Bundled H2 std vaspwave fixtures are not available.",
    )
    def test_h2_ispin2_std_fixture_matches_wavecar(self):
        vaspwave = Vaspwave(self.ispin2_std_dir / "vaspwave.h5")
        wavecar = Wavecar(self.ispin2_std_dir / "WAVECAR")

        assert vaspwave.vasp_type == "std"
        assert vaspwave.spin == wavecar.spin == 2
        assert vaspwave.nk == wavecar.nk
        assert vaspwave.nb == wavecar.nb
        for ik in range(vaspwave.nk):
            assert_allclose(vaspwave.kpoints[ik], wavecar.kpoints[ik])
            assert_allclose(vaspwave.Gpoints[ik], wavecar.Gpoints[ik])
            assert vaspwave.num_planewaves[ik] == len(wavecar.Gpoints[ik])

        for spin in (0, 1):
            for ik in range(vaspwave.nk):
                for band in range(vaspwave.nb):
                    coeffs_h5 = vaspwave.get_band_coeffs(spin, ik, band)
                    coeffs_wavecar = wavecar.coeffs[spin][ik][band]
                    phase = np.vdot(coeffs_wavecar, coeffs_h5) / np.vdot(coeffs_wavecar, coeffs_wavecar)
                    assert_allclose(coeffs_h5, phase * coeffs_wavecar, atol=1e-6, rtol=1e-6)

                    mesh_h5 = vaspwave.fft_mesh(ik, band, spin=spin)
                    mesh_wavecar = wavecar.fft_mesh(ik, band, spin=spin)
                    assert_allclose(mesh_h5, phase * mesh_wavecar, atol=1e-6, rtol=1e-6)

        for spin in (0, 1):
            for ik in range(vaspwave.nk):
                for band, r in ((0, np.array([0.0, 0.0, 0.0])), (min(1, vaspwave.nb - 1), np.array([0.1, 0.2, 0.3]))):
                    value_h5 = vaspwave.evaluate_wavefunc(ik, band, r, spin=spin)
                    value_wavecar = wavecar.evaluate_wavefunc(ik, band, r, spin=spin)
                    coeffs_h5 = vaspwave.get_band_coeffs(spin, ik, band)
                    coeffs_wavecar = wavecar.coeffs[spin][ik][band]
                    phase = np.vdot(coeffs_wavecar, coeffs_h5) / np.vdot(coeffs_wavecar, coeffs_wavecar)
                    assert value_h5 == approx(phase * value_wavecar, rel=1e-6, abs=1e-6)

    @pytest.mark.skipif(
        not (Path(TEST_DIR) / "outputs" / "vaspwave-H2.tar.gz").exists(),
        reason="Bundled H2 std vaspwave fixtures are not available.",
    )
    def test_h2_ispin2_std_band_energy_matches_wavecar(self):
        vaspwave = Vaspwave(self.ispin2_std_dir / "vaspwave.h5")

        assert len(vaspwave.band_energy) == 2
        for spin in (0, 1):
            assert len(vaspwave.band_energy[spin]) == vaspwave.nk
            kpoint_data = vaspwave._get_kpoint_metadata(spin, 0)
            assert_allclose(vaspwave.band_energy[spin][0], vaspwave._build_band_energy_array(kpoint_data))

        assert not np.allclose(vaspwave.band_energy[0][0], vaspwave.band_energy[1][0])

    @pytest.mark.skipif(
        not (Path(TEST_DIR) / "outputs" / "vaspwave-H2.tar.gz").exists(),
        reason="Bundled H2 std vaspwave fixtures are not available.",
    )
    def test_h2_ispin2_std_fixture_parchg_and_unk_match_wavecar(self):
        std_dir = self.ispin2_std_dir
        vaspwave = Vaspwave(std_dir / "vaspwave.h5")
        wavecar = Wavecar(std_dir / "WAVECAR")
        poscar = Chgcar.from_file(std_dir / "CHGCAR").poscar

        vaspwave_parchg = vaspwave.get_parchg(poscar, 0, 0, phase=False, scale=1)
        wavecar_parchg = wavecar.get_parchg(poscar, 0, 0, phase=False, scale=1)
        total_rel_err = np.linalg.norm(vaspwave_parchg.data["total"] - wavecar_parchg.data["total"]) / np.linalg.norm(
            wavecar_parchg.data["total"]
        )
        diff_rel_err = np.linalg.norm(vaspwave_parchg.data["diff"] - wavecar_parchg.data["diff"]) / np.linalg.norm(
            wavecar_parchg.data["diff"]
        )
        assert total_rel_err < 0.05
        assert diff_rel_err < 0.12

        vaspwave_parchg_spin = vaspwave.get_parchg(poscar, 0, 0, spin=1, phase=False, scale=1)
        wavecar_parchg_spin = wavecar.get_parchg(poscar, 0, 0, spin=1, phase=False, scale=1)
        spin_rel_err = np.linalg.norm(
            vaspwave_parchg_spin.data["total"] - wavecar_parchg_spin.data["total"]
        ) / np.linalg.norm(wavecar_parchg_spin.data["total"])
        assert spin_rel_err < 0.12

        vaspwave_dir = Path(self.tmp_path) / "ispin2_vaspwave_unk"
        wavecar_dir = Path(self.tmp_path) / "ispin2_wavecar_unk"
        vaspwave.write_unks(vaspwave_dir)
        wavecar.write_unks(wavecar_dir)

        unk_h5_up = Unk.from_file(vaspwave_dir / "UNK00001.1")
        unk_h5_dn = Unk.from_file(vaspwave_dir / "UNK00001.2")
        unk_wavecar_up = Unk.from_file(wavecar_dir / "UNK00001.1")
        unk_wavecar_dn = Unk.from_file(wavecar_dir / "UNK00001.2")

        assert unk_h5_up.data.shape == unk_wavecar_up.data.shape == (vaspwave.nb, *vaspwave.ng)
        assert unk_h5_dn.data.shape == unk_wavecar_dn.data.shape == (vaspwave.nb, *vaspwave.ng)
        assert unk_h5_up.data.dtype == unk_wavecar_up.data.dtype == np.complex128
        assert unk_h5_dn.data.dtype == unk_wavecar_dn.data.dtype == np.complex128
        for band in range(vaspwave.nb):
            phase_up = np.vdot(unk_wavecar_up.data[band], unk_h5_up.data[band]) / np.vdot(
                unk_wavecar_up.data[band], unk_wavecar_up.data[band]
            )
            phase_dn = np.vdot(unk_wavecar_dn.data[band], unk_h5_dn.data[band]) / np.vdot(
                unk_wavecar_dn.data[band], unk_wavecar_dn.data[band]
            )
            assert_allclose(unk_h5_up.data[band], phase_up * unk_wavecar_up.data[band], atol=1e-6, rtol=1e-6)
            assert_allclose(unk_h5_dn.data[band], phase_dn * unk_wavecar_dn.data[band], atol=1e-6, rtol=1e-6)

    @pytest.mark.skipif(
        not (Path(TEST_DIR) / "outputs" / "vaspwave-H2.tar.gz").exists(),
        reason="Bundled H2 std vaspwave fixtures are not available.",
    )
    def test_h2_ispin2_std_fixture_get_chgcar_matches_chgcar(self):
        vaspwave = Vaspwave(self.ispin2_std_dir / "vaspwave.h5")
        chgcar_h5 = vaspwave.get_chgcar()
        chgcar = Chgcar.from_file(self.ispin2_std_dir / "CHGCAR")

        assert chgcar_h5.structure == chgcar.structure
        assert chgcar_h5.dim == chgcar.dim
        assert chgcar_h5.is_spin_polarized
        assert not chgcar_h5.is_soc
        assert set(chgcar_h5.data) == {"total", "diff"}
        assert_allclose(chgcar_h5.data["total"], chgcar.data["total"], atol=1e-6)
        assert_allclose(chgcar_h5.data["diff"], chgcar.data["diff"], atol=1e-6)
        assert chgcar_h5.data_aug.keys() == chgcar.data_aug.keys()
        for data_key, aug_data in chgcar.data_aug.items():
            assert chgcar_h5.data_aug[data_key].keys() == aug_data.keys()
            for ion_idx, values in aug_data.items():
                assert_allclose(chgcar_h5.data_aug[data_key][ion_idx], values, atol=1e-6)

    @pytest.mark.skipif(
        not (Path(TEST_DIR) / "outputs" / "vaspwave-H2.tar.gz").exists(),
        reason="Bundled H2 std vaspwave fixtures are not available.",
    )
    def test_h2_ispin2_std_fixture_get_locpot_matches_locpot(self):
        vaspwave = Vaspwave(self.ispin2_std_dir / "vaspwave.h5")
        locpot_h5 = vaspwave.get_locpot()
        locpot = Locpot.from_file(self.ispin2_std_dir / "LOCPOT")

        assert locpot_h5.structure == locpot.structure
        assert locpot_h5.dim == locpot.dim
        assert locpot_h5.is_spin_polarized
        assert not locpot_h5.is_soc
        assert set(locpot_h5.data) == {"spin_up", "spin_down"}
        assert_allclose(locpot_h5.data["spin_up"], locpot.data["spin_up"], atol=8e-5)
        assert_allclose(locpot_h5.data["spin_down"], locpot.data["spin_down"], atol=8e-5)

    @pytest.mark.skipif(
        not (Path(TEST_DIR) / "outputs" / "vaspwave-H2.tar.gz").exists(),
        reason="Bundled H2 ncl vaspwave fixtures are not available.",
    )
    def test_h2_ncl_fixture_get_chgcar_matches_chgcar(self):
        vaspwave = Vaspwave(self.h2_ncl_dir / "vaspwave.h5")
        chgcar_h5 = vaspwave.get_chgcar()
        chgcar = Chgcar.from_file(self.h2_ncl_dir / "CHGCAR")

        assert chgcar_h5.structure == chgcar.structure
        assert chgcar_h5.dim == chgcar.dim
        assert chgcar_h5.is_soc
        total_rel_err = np.linalg.norm(chgcar_h5.data["total"] - chgcar.data["total"]) / np.linalg.norm(
            chgcar.data["total"]
        )
        diff_x_rel_err = np.linalg.norm(chgcar_h5.data["diff_x"] - chgcar.data["diff_x"]) / np.linalg.norm(
            chgcar.data["diff_x"]
        )
        diff_y_rel_err = np.linalg.norm(chgcar_h5.data["diff_y"] - chgcar.data["diff_y"]) / np.linalg.norm(
            chgcar.data["diff_y"]
        )
        diff_z_rel_err = np.linalg.norm(chgcar_h5.data["diff_z"] - chgcar.data["diff_z"]) / np.linalg.norm(
            chgcar.data["diff_z"]
        )
        diff_rel_err = np.linalg.norm(chgcar_h5.data["diff"] - chgcar.data["diff"]) / np.linalg.norm(
            chgcar.data["diff"]
        )
        assert total_rel_err < 1e-6
        assert diff_x_rel_err < 2e-4
        assert diff_y_rel_err < 2e-4
        assert diff_z_rel_err < 1e-3
        assert diff_rel_err < 2e-4
        assert chgcar_h5.data_aug.keys() == chgcar.data_aug.keys()
        for data_key, aug_data in chgcar.data_aug.items():
            assert chgcar_h5.data_aug[data_key].keys() == aug_data.keys()
            for ion_idx, values in aug_data.items():
                assert_allclose(chgcar_h5.data_aug[data_key][ion_idx], values.real, atol=1e-6)

    @pytest.mark.skipif(
        not (Path(TEST_DIR) / "outputs" / "vaspwave-H2.tar.gz").exists(),
        reason="Bundled H2 ncl vaspwave fixtures are not available.",
    )
    def test_h2_ncl_fixture_locpot_maps_soc_components(self):
        vaspwave = Vaspwave(self.h2_ncl_dir / "vaspwave.h5")
        locpot = vaspwave.get_locpot()
        reference = Locpot.from_file(self.h2_ncl_dir / "LOCPOT")

        assert locpot.structure == reference.structure
        assert locpot.dim == reference.dim
        assert locpot.is_soc
        assert set(locpot.data) == {"total", "diff_x", "diff_y", "diff_z", "diff"}
        assert_allclose(locpot.data["total"], reference.data["total"], atol=8e-5)
        assert_allclose(locpot.data["diff_x"], reference.data["diff_x"], atol=8e-5)
        assert_allclose(locpot.data["diff_y"], reference.data["diff_y"], atol=8e-5)
        assert_allclose(locpot.data["diff_z"], reference.data["diff_z"], atol=8e-5)

        # The scalar ``diff`` value is derived from the SOC vector components
        # using a reference-direction sign convention. Close to zero, the sign
        # can flip even when the underlying vector field still matches, so we
        # compare the reconstructed magnitude instead of requiring pointwise
        # scalar-sign agreement.
        locpot_diff_mag = np.sqrt(locpot.data["diff_x"] ** 2 + locpot.data["diff_y"] ** 2 + locpot.data["diff_z"] ** 2)
        reference_diff_mag = np.sqrt(
            reference.data["diff_x"] ** 2 + reference.data["diff_y"] ** 2 + reference.data["diff_z"] ** 2
        )
        assert_allclose(locpot_diff_mag, reference_diff_mag, atol=9e-5)
