Repository: materialsproject/pymatgen

Issue:
Timeout in MagneticStructureEnumerator (and EnumlibAdaptor) does not work
### Python version

3.11.5

### Pymatgen version

2024.11.13

### Operating system version

_No response_

### Current behavior

`enum = MagneticStructureEnumerator(structure)` should stop after 5 minutes by default,
but it does not. Passing a different timeout does not change anything. I'm now making sure that the timeout arg is passed to the EnumlibAdaptor (see bug in #4184).

I did some tests but I could not figure out why the enum.x process does not get killed.
The main logic from https://github.com/materialsproject/pymatgen/blob/31f1e1fb8cc1bb517d59ea8e484965bb641c42a0/src/pymatgen/command_line/enumlib_caller.py#L281 is here:

```
    def _run_multienum(self):
        with subprocess.Popen([enum_cmd], stdout=subprocess.PIPE, stdin=subprocess.PIPE, close_fds=True) as process:
            if self.timeout:
                timed_out = False
                timer = Timer(self.timeout*60, lambda p: p.kill(), [process])

                try:
                    timer.start()
                    output = process.communicate()[0].decode("utf-8")
                finally:
                    if not timer.is_alive():
                        timed_out = True
                    timer.cancel()

                if timed_out:
                    raise TimeoutError("Enumeration took too long")

            else:
                output = process.communicate()[0].decode("utf-8")
```

It seems the the process stops communicating. I tried to use the timeout argument
of the `process.communicate()` method but it does not work neither.

One thing that worked correctly is the same code above with 
enum_cmd="/usr/bin/sleep 6000". This process gets killed as it should.

I have the same behavior both in my laptop and on perlmutter.

Any thoughts about what's causing this?



### Expected Behavior

`enum = MagneticStructureEnumerator(structure)` should stop after 5 minutes by default.

### Minimal example

_No response_

### Relevant files to reproduce this bug

_No response_

Hints from issue comments:

Solution patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 90c412d32d..fd952fe254 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -8,7 +8,7 @@ ci:
 
 repos:
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.9.2
+    rev: v0.9.4
     hooks:
       - id: ruff
         args: [--fix, --unsafe-fixes]
@@ -27,7 +27,7 @@ repos:
       - id: mypy
 
   - repo: https://github.com/codespell-project/codespell
-    rev: v2.3.0
+    rev: v2.4.1
     hooks:
       - id: codespell
         stages: [pre-commit, commit-msg]
@@ -48,7 +48,7 @@ repos:
       - id: blacken-docs
 
   - repo: https://github.com/igorshubovych/markdownlint-cli
-    rev: v0.43.0
+    rev: v0.44.0
     hooks:
       - id: markdownlint
         # MD013: line too long
@@ -65,6 +65,6 @@ repos:
         args: [--drop-empty-cells, --keep-output]
 
   - repo: https://github.com/RobertCraigie/pyright-python
-    rev: v1.1.391
+    rev: v1.1.393
     hooks:
       - id: pyright
diff --git a/pyproject.toml b/pyproject.toml
index 4bef5c2d6b..bfc03d3f96 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -60,7 +60,7 @@ dependencies = [
     "networkx>=2.7",  # PR4116
     "palettable>=3.3.3",
     "pandas>=2",
-    "plotly>=4.5.0,<6.0.0",
+    "plotly>=5.0.0",
     "pybtex>=0.24.0",
     "requests>=2.32",
     "ruamel.yaml>=0.17.0",
diff --git a/src/pymatgen/analysis/eos.py b/src/pymatgen/analysis/eos.py
index f25844eaec..67f171eba4 100644
--- a/src/pymatgen/analysis/eos.py
+++ b/src/pymatgen/analysis/eos.py
@@ -24,7 +24,8 @@ from pymatgen.core.units import FloatWithUnit
 from pymatgen.util.plotting import add_fig_kwargs, get_ax_fig, pretty_plot
 
 if TYPE_CHECKING:
-    from typing import ClassVar
+    from collections.abc import Sequence
+    from typing import Any, ClassVar
 
     import matplotlib.pyplot as plt
 
@@ -40,7 +41,11 @@ class EOSBase(ABC):
     implementations.
     """
 
-    def __init__(self, volumes, energies):
+    def __init__(
+        self,
+        volumes: Sequence[float],
+        energies: Sequence[float],
+    ) -> None:
         """
         Args:
             volumes (Sequence[float]): in Ang^3.
@@ -50,18 +55,28 @@ class EOSBase(ABC):
         self.energies = np.array(energies)
         # minimum energy(e0), buk modulus(b0),
         # derivative of bulk modulus w.r.t. pressure(b1), minimum volume(v0)
-        self._params = None
+        self._params: Sequence | None = None
         # the eos function parameters. It is the same as _params except for
         # equation of states that uses polynomial fits(delta_factor and
         # numerical_eos)
-        self.eos_params = None
+        self.eos_params: Sequence | None = None
 
-    def _initial_guess(self):
+    def __call__(self, volume: float) -> float:
+        """
+        Args:
+            volume (float | list[float]): volume(s) in Ang^3.
+
+        Returns:
+            Compute EOS with this volume.
+        """
+        return self.func(volume)
+
+    def _initial_guess(self) -> tuple[float, float, float, float]:
         """
         Quadratic fit to get an initial guess for the parameters.
 
         Returns:
-            tuple: 4 floats for (e0, b0, b1, v0)
+            tuple[float, float, float, float]: e0, b0, b1, v0
         """
         a, b, c = np.polyfit(self.volumes, self.energies, 2)
         self.eos_params = [a, b, c]
@@ -78,7 +93,7 @@ class EOSBase(ABC):
 
         return e0, b0, b1, v0
 
-    def fit(self):
+    def fit(self) -> None:
         """
         Do the fitting. Does least square fitting. If you want to use custom
         fitting, must override this.
@@ -120,24 +135,20 @@ class EOSBase(ABC):
         """
         return self._func(np.array(volume), self.eos_params)
 
-    def __call__(self, volume: float) -> float:
-        """
-        Args:
-            volume (float | list[float]): volume(s) in Ang^3.
-
-        Returns:
-            Compute EOS with this volume.
-        """
-        return self.func(volume)
-
     @property
     def e0(self) -> float:
         """The min energy."""
+        if self._params is None:
+            raise RuntimeError("params have not be initialized.")
+
         return self._params[0]
 
     @property
     def b0(self) -> float:
         """The bulk modulus in units of energy/unit of volume^3."""
+        if self._params is None:
+            raise RuntimeError("params have not be initialized.")
+
         return self._params[1]
 
     @property
@@ -156,11 +167,18 @@ class EOSBase(ABC):
         return self._params[3]
 
     @property
-    def results(self):
+    def results(self) -> dict[str, Any]:
         """A summary dict."""
         return {"e0": self.e0, "b0": self.b0, "b1": self.b1, "v0": self.v0}
 
-    def plot(self, width=8, height=None, ax: plt.Axes = None, dpi=None, **kwargs):
+    def plot(
+        self,
+        width: float = 8,
+        height: float | None = None,
+        ax: plt.Axes = None,
+        dpi: float | None = None,
+        **kwargs,
+    ) -> plt.Axes:
         """
         Plot the equation of state.
 
@@ -170,7 +188,7 @@ class EOSBase(ABC):
                 golden ratio.
             ax (plt.Axes): If supplied, changes will be made to the existing Axes.
                 Otherwise, new Axes will be created.
-            dpi:
+            dpi (float): DPI.
             kwargs (dict): additional args fed to pyplot.plot.
                 supported keys: style, color, text, label
 
@@ -211,16 +229,18 @@ class EOSBase(ABC):
         return ax
 
     @add_fig_kwargs
-    def plot_ax(self, ax: plt.Axes = None, fontsize=12, **kwargs):
+    def plot_ax(
+        self,
+        ax: plt.Axes | None = None,
+        fontsize: float = 12,
+        **kwargs,
+    ) -> plt.Figure:
         """
         Plot the equation of state on axis `ax`.
 
         Args:
             ax: matplotlib Axes or None if a new figure should be created.
             fontsize: Legend fontsize.
-            color (str): plot color.
-            label (str): Plot label
-            text (str): Legend text (options)
 
         Returns:
             plt.Figure: matplotlib figure.
@@ -270,7 +290,7 @@ class EOSBase(ABC):
 class Murnaghan(EOSBase):
     """Murnaghan EOS."""
 
-    def _func(self, volume, params):
+    def _func(self, volume, params: tuple[float, float, float, float]):
         """From PRB 28,5480 (1983)."""
         e0, b0, b1, v0 = tuple(params)
         return e0 + b0 * volume / b1 * (((v0 / volume) ** b1) / (b1 - 1.0) + 1.0) - v0 * b0 / (b1 - 1.0)
@@ -279,7 +299,7 @@ class Murnaghan(EOSBase):
 class Birch(EOSBase):
     """Birch EOS."""
 
-    def _func(self, volume, params):
+    def _func(self, volume, params: tuple[float, float, float, float]):
         """From Intermetallic compounds: Principles and Practice, Vol. I:
         Principles Chapter 9 pages 195-210 by M. Mehl. B. Klein,
         D. Papaconstantopoulos.
@@ -296,7 +316,7 @@ class Birch(EOSBase):
 class BirchMurnaghan(EOSBase):
     """BirchMurnaghan EOS."""
 
-    def _func(self, volume, params):
+    def _func(self, volume, params: tuple[float, float, float, float]):
         """BirchMurnaghan equation from PRB 70, 224107."""
         e0, b0, b1, v0 = tuple(params)
         eta = (v0 / volume) ** (1 / 3)
@@ -306,7 +326,7 @@ class BirchMurnaghan(EOSBase):
 class PourierTarantola(EOSBase):
     """Pourier-Tarantola EOS."""
 
-    def _func(self, volume, params):
+    def _func(self, volume, params: tuple[float, float, float, float]):
         """Pourier-Tarantola equation from PRB 70, 224107."""
         e0, b0, b1, v0 = tuple(params)
         eta = (volume / v0) ** (1 / 3)
@@ -317,7 +337,7 @@ class PourierTarantola(EOSBase):
 class Vinet(EOSBase):
     """Vinet EOS."""
 
-    def _func(self, volume, params):
+    def _func(self, volume, params: tuple[float, float, float, float]):
         """Vinet equation from PRB 70, 224107."""
         e0, b0, b1, v0 = tuple(params)
         eta = (volume / v0) ** (1 / 3)
@@ -335,7 +355,7 @@ class PolynomialEOS(EOSBase):
     def _func(self, volume, params):
         return np.poly1d(list(params))(volume)
 
-    def fit(self, order):
+    def fit(self, order: int) -> None:
         """
         Do polynomial fitting and set the parameters. Uses numpy polyfit.
 
@@ -345,7 +365,7 @@ class PolynomialEOS(EOSBase):
         self.eos_params = np.polyfit(self.volumes, self.energies, order)
         self._set_params()
 
-    def _set_params(self):
+    def _set_params(self) -> None:
         """
         Use the fit polynomial to compute the parameter e0, b0, b1 and v0
         and set to the _params attribute.
@@ -372,7 +392,7 @@ class DeltaFactor(PolynomialEOS):
         x = volume ** (-2 / 3.0)
         return np.poly1d(list(params))(x)
 
-    def fit(self, order=3):
+    def fit(self, order: int = 3) -> None:
         """Overridden since this eos works with volume**(2/3) instead of volume."""
         x = self.volumes ** (-2 / 3.0)
         self.eos_params = np.polyfit(x, self.energies, order)
@@ -407,7 +427,12 @@ class DeltaFactor(PolynomialEOS):
 class NumericalEOS(PolynomialEOS):
     """A numerical EOS."""
 
-    def fit(self, min_ndata_factor=3, max_poly_order_factor=5, min_poly_order=2):
+    def fit(
+        self,
+        min_ndata_factor: int = 3,
+        max_poly_order_factor: int = 5,
+        min_poly_order: int = 2,
+    ) -> None:
         """Fit the input data to the 'numerical eos', the equation of state employed
         in the quasiharmonic Debye model described in the paper:
         10.1103/PhysRevB.90.174107.
@@ -539,7 +564,7 @@ class EOS:
        eos_fit.plot()
     """
 
-    MODELS: ClassVar = {
+    MODELS: ClassVar[dict[str, Any]] = {
         "murnaghan": Murnaghan,
         "birch": Birch,
         "birch_murnaghan": BirchMurnaghan,
@@ -549,7 +574,7 @@ class EOS:
         "numerical_eos": NumericalEOS,
     }
 
-    def __init__(self, eos_name="murnaghan"):
+    def __init__(self, eos_name: str = "murnaghan") -> None:
         """
         Args:
             eos_name (str): Type of EOS to fit.
@@ -562,7 +587,7 @@ class EOS:
         self._eos_name = eos_name
         self.model = self.MODELS[eos_name]
 
-    def fit(self, volumes, energies):
+    def fit(self, volumes: Sequence[float], energies: Sequence[float]) -> EOSBase:
         """Fit energies as function of volumes.
 
         Args:
diff --git a/src/pymatgen/analysis/prototypes.py b/src/pymatgen/analysis/prototypes.py
index e08e6adaca..5cf9df533d 100644
--- a/src/pymatgen/analysis/prototypes.py
+++ b/src/pymatgen/analysis/prototypes.py
@@ -48,22 +48,27 @@ class AflowPrototypeMatcher:
     https://doi.org/10.1016/j.commatsci.2017.01.017
     """
 
-    def __init__(self, initial_ltol=0.2, initial_stol=0.3, initial_angle_tol=5):
+    def __init__(
+        self,
+        initial_ltol: float = 0.2,
+        initial_stol: float = 0.3,
+        initial_angle_tol: float = 5,
+    ) -> None:
         """
         Tolerances as defined in StructureMatcher. Tolerances will be
         gradually decreased until only a single match is found (if possible).
 
         Args:
-            initial_ltol: fractional length tolerance
-            initial_stol: site tolerance
-            initial_angle_tol: angle tolerance
+            initial_ltol (float): fractional length tolerance.
+            initial_stol (float): site tolerance.
+            initial_angle_tol (float): angle tolerance.
         """
         self.initial_ltol = initial_ltol
         self.initial_stol = initial_stol
         self.initial_angle_tol = initial_angle_tol
 
         # Preprocess AFLOW prototypes
-        self._aflow_prototype_library = []
+        self._aflow_prototype_library: list[tuple[Structure, dict]] = []
         for dct in AFLOW_PROTOTYPE_LIBRARY:
             structure: Structure = dct["snl"].structure
             reduced_structure = self._preprocess_structure(structure)
@@ -73,7 +78,11 @@ class AflowPrototypeMatcher:
     def _preprocess_structure(structure: Structure) -> Structure:
         return structure.get_reduced_structure(reduction_algo="niggli").get_primitive_structure()
 
-    def _match_prototype(self, structure_matcher: StructureMatcher, reduced_structure: Structure):
+    def _match_prototype(
+        self,
+        structure_matcher: StructureMatcher,
+        reduced_structure: Structure,
+    ) -> list[dict]:
         tags = []
         for aflow_reduced_structure, dct in self._aflow_prototype_library:
             # Since both structures are already reduced, we can skip the structure reduction step
@@ -84,7 +93,7 @@ class AflowPrototypeMatcher:
                 tags.append(dct)
         return tags
 
-    def _match_single_prototype(self, structure: Structure):
+    def _match_single_prototype(self, structure: Structure) -> list[dict]:
         sm = StructureMatcher(
             ltol=self.initial_ltol,
             stol=self.initial_stol,
@@ -102,23 +111,23 @@ class AflowPrototypeMatcher:
                 break
         return tags
 
-    def get_prototypes(self, structure: Structure) -> list | None:
+    def get_prototypes(self, structure: Structure) -> list[dict] | None:
         """Get prototype(s) structures for a given input structure. If you use this method in
         your work, please cite the appropriate AFLOW publication:
 
-        Mehl, M. J., Hicks, D., Toher, C., Levy, O., Hanson, R. M., Hart, G., & Curtarolo,
-        S. (2017). The AFLOW library of crystallographic prototypes: part 1. Computational
-        Materials Science, 136, S1-S828. https://doi.org/10.1016/j.commatsci.2017.01.017
+            Mehl, M. J., Hicks, D., Toher, C., Levy, O., Hanson, R. M., Hart, G., & Curtarolo,
+            S. (2017). The AFLOW library of crystallographic prototypes: part 1. Computational
+            Materials Science, 136, S1-S828. https://doi.org/10.1016/j.commatsci.2017.01.017
 
         Args:
-            structure: structure to match
+            structure (Structure): structure to match
 
         Returns:
-            list | None: A list of dicts with keys 'snl' for the matched prototype and
-                'tags', a dict of tags ('mineral', 'strukturbericht' and 'aflow') of that
+            list[dict] | None: A list of dicts with keys "snl" for the matched prototype and
+                "tags", a dict of tags ("mineral", "strukturbericht" and "aflow") of that
                 prototype. This should be a list containing just a single entry, but it is
                 possible a material can match multiple prototypes.
         """
-        tags = self._match_single_prototype(structure)
+        tags: list[dict] = self._match_single_prototype(structure)
 
         return tags or None
diff --git a/src/pymatgen/command_line/enumlib_caller.py b/src/pymatgen/command_line/enumlib_caller.py
index 02ccc61953..9ef37c9520 100644
--- a/src/pymatgen/command_line/enumlib_caller.py
+++ b/src/pymatgen/command_line/enumlib_caller.py
@@ -8,19 +8,19 @@ compile these two executables accordingly.
 
 If you use this module, please cite:
 
-Gus L. W. Hart and Rodney W. Forcade, "Algorithm for generating derivative
-structures," Phys. Rev. B 77 224115 (26 June 2008)
+    - Gus L. W. Hart and Rodney W. Forcade, "Algorithm for generating derivative
+    structures," Phys. Rev. B 77 224115 (26 June 2008)
 
-Gus L. W. Hart and Rodney W. Forcade, "Generating derivative structures from
-multilattices: Application to hcp alloys," Phys. Rev. B 80 014120 (July 2009)
+    - Gus L. W. Hart and Rodney W. Forcade, "Generating derivative structures from
+    multilattices: Application to hcp alloys," Phys. Rev. B 80 014120 (July 2009)
 
-Gus L. W. Hart, Lance J. Nelson, and Rodney W. Forcade, "Generating
-derivative structures at a fixed concentration," Comp. Mat. Sci. 59
-101-107 (March 2012)
+    - Gus L. W. Hart, Lance J. Nelson, and Rodney W. Forcade, "Generating
+    derivative structures at a fixed concentration," Comp. Mat. Sci. 59
+    101-107 (March 2012)
 
-Wiley S. Morgan, Gus L. W. Hart, Rodney W. Forcade, "Generating derivative
-superstructures for systems with high configurational freedom," Comp. Mat.
-Sci. 136 144-149 (May 2017)
+    - Wiley S. Morgan, Gus L. W. Hart, Rodney W. Forcade, "Generating derivative
+    superstructures for systems with high configurational freedom," Comp. Mat.
+    Sci. 136 144-149 (May 2017)
 """
 
 from __future__ import annotations
@@ -33,7 +33,7 @@ import re
 import subprocess
 from glob import glob
 from shutil import which
-from threading import Timer
+from typing import TYPE_CHECKING
 
 import numpy as np
 from monty.dev import requires
@@ -44,17 +44,19 @@ from pymatgen.core import DummySpecies, PeriodicSite, Structure
 from pymatgen.io.vasp.inputs import Poscar
 from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
 
+if TYPE_CHECKING:
+    from typing import ClassVar
+
 logger = logging.getLogger(__name__)
 
-# Favor the use of the newer "enum.x" by Gus Hart instead of the older
-# "multienum.x"
-enum_cmd = which("enum.x") or which("multienum.x")
-# prefer makestr.x at present
-makestr_cmd = which("makestr.x") or which("makeStr.x") or which("makeStr.py")
+# Favor the use of the newer "enum.x" by Gus Hart over "multienum.x"
+ENUM_CMD = which("enum.x") or which("multienum.x")
+# Prefer makestr.x at present
+MAKESTR_CMD = which("makestr.x") or which("makeStr.x") or which("makeStr.py")
 
 
 @requires(
-    enum_cmd and makestr_cmd,
+    ENUM_CMD and MAKESTR_CMD,
     "EnumlibAdaptor requires the executables 'enum.x' or 'multienum.x' "
     "and 'makestr.x' or 'makeStr.py' to be in the path. Please download the "
     "library at https://github.com/msg-byu/enumlib and follow the instructions "
@@ -64,26 +66,26 @@ class EnumlibAdaptor:
     """An adaptor for enumlib.
 
     Attributes:
-        structures (list): all enumerated structures.
+        structures (list[Structure]): all enumerated structures.
     """
 
-    amount_tol = 1e-5
+    amount_tol: ClassVar[float] = 1e-5
 
     def __init__(
         self,
-        structure,
-        min_cell_size=1,
-        max_cell_size=1,
-        symm_prec=0.1,
-        enum_precision_parameter=0.001,
-        refine_structure=False,
-        check_ordered_symmetry=True,
-        timeout=None,
-    ):
+        structure: Structure,
+        min_cell_size: int = 1,
+        max_cell_size: int = 1,
+        symm_prec: float = 0.1,
+        enum_precision_parameter: float = 0.001,
+        refine_structure: bool = False,
+        check_ordered_symmetry: bool = True,
+        timeout: float | None = None,
+    ) -> None:
         """Initialize the adapter with a structure and some parameters.
 
         Args:
-            structure: An input structure.
+            structure (Structure): An input structure.
             min_cell_size (int): The minimum cell size wanted. Defaults to 1.
             max_cell_size (int): The maximum cell size wanted. Defaults to 1.
             symm_prec (float): Symmetry precision. Defaults to 0.1.
@@ -117,6 +119,7 @@ class EnumlibAdaptor:
             self.structure = finder.get_refined_structure()
         else:
             self.structure = structure
+
         self.min_cell_size = min_cell_size
         self.max_cell_size = max_cell_size
         self.symm_prec = symm_prec
@@ -124,46 +127,49 @@ class EnumlibAdaptor:
         self.check_ordered_symmetry = check_ordered_symmetry
         self.timeout = timeout
 
-    def run(self):
+    def run(self) -> None:
         """Run the enumeration."""
-        # Create a temporary directory for working.
+        # Work in a temporary directory
         with ScratchDir(".") as tmp_dir:
             logger.debug(f"Temp dir : {tmp_dir}")
             # Generate input files
             self._gen_input_file()
+
             # Perform the actual enumeration
             num_structs = self._run_multienum()
+
             # Read in the enumeration output as structures.
             if num_structs > 0:
                 self.structures = self._get_structures(num_structs)
             else:
                 raise EnumError("Unable to enumerate structure.")
 
-    def _gen_input_file(self):
+    def _gen_input_file(self) -> None:
         """Generate the necessary struct_enum.in file for enumlib. See enumlib
         documentation for details.
         """
         coord_format = "{:.6f} {:.6f} {:.6f}"
-        # Using symmetry finder, get the symmetrically distinct sites.
+        # Use symmetry finder to get the symmetrically distinct sites
         fitter = SpacegroupAnalyzer(self.structure, self.symm_prec)
         symmetrized_structure = fitter.get_symmetrized_structure()
         logger.debug(
             f"Spacegroup {fitter.get_space_group_symbol()} ({fitter.get_space_group_number()}) "
             f"with {len(symmetrized_structure.equivalent_sites)} distinct sites"
         )
-        """Enumlib doesn"t work when the number of species get too large. To
-        simplify matters, we generate the input file only with disordered sites
-        and exclude the ordered sites from the enumeration. The fact that
-        different disordered sites with the exact same species may belong to
-        different equivalent sites is dealt with by having determined the
-        spacegroup earlier and labelling the species differently.
-        """
-        # index_species and index_amounts store mappings between the indices
+
+        # Enumlib doesn"t work when the number of species get too large. To
+        # simplify matters, we generate the input file only with disordered sites
+        # and exclude the ordered sites from the enumeration. The fact that
+        # different disordered sites with the exact same species may belong to
+        # different equivalent sites is dealt with by having determined the
+        # spacegroup earlier and labelling the species differently.
+
+        # `index_species` and `index_amounts` store mappings between the indices
         # used in the enum input file, and the actual species and amounts.
         index_species = []
         index_amounts = []
 
-        # Stores the ordered sites, which are not enumerated.
+        # Store the ordered sites, which are not enumerated.
         ordered_sites = []
         disordered_sites = []
         coord_str = []
@@ -171,7 +177,7 @@ class EnumlibAdaptor:
             if sites[0].is_ordered:
                 ordered_sites.append(sites)
             else:
-                sp_label = []
+                _sp_label: list[int] = []
                 species = dict(sites[0].species.items())
                 if sum(species.values()) < 1 - EnumlibAdaptor.amount_tol:
                     # Let us first make add a dummy element for every single
@@ -180,42 +186,41 @@ class EnumlibAdaptor:
                 for sp, amt in species.items():
                     if sp not in index_species:
                         index_species.append(sp)
-                        sp_label.append(len(index_species) - 1)
+                        _sp_label.append(len(index_species) - 1)
                         index_amounts.append(amt * len(sites))
                     else:
                         ind = index_species.index(sp)
-                        sp_label.append(ind)
+                        _sp_label.append(ind)
                         index_amounts[ind] += amt * len(sites)
-                sp_label = "/".join(f"{i}" for i in sorted(sp_label))
+                sp_label: str = "/".join(f"{i}" for i in sorted(_sp_label))
                 for site in sites:
                     coord_str.append(f"{coord_format.format(*site.coords)} {sp_label}")
                 disordered_sites.append(sites)
 
-        def get_sg_info(ss):
+        def get_sg_number(ss) -> int:
             finder = SpacegroupAnalyzer(Structure.from_sites(ss), self.symm_prec)
             return finder.get_space_group_number()
 
-        target_sg_num = get_sg_info(list(symmetrized_structure))
+        target_sg_num = get_sg_number(list(symmetrized_structure))
         curr_sites = list(itertools.chain.from_iterable(disordered_sites))
-        sg_num = get_sg_info(curr_sites)
+        sg_num = get_sg_number(curr_sites)
         ordered_sites = sorted(ordered_sites, key=len)
         logger.debug(f"Disordered sites has sg # {sg_num}")
         self.ordered_sites = []
 
-        # progressively add ordered sites to our disordered sites
+        # Progressively add ordered sites to our disordered sites
         # until we match the symmetry of our input structure
         if self.check_ordered_symmetry:
             while sg_num != target_sg_num and len(ordered_sites) > 0:
                 sites = ordered_sites.pop(0)
                 temp_sites = list(curr_sites) + sites
-                new_sg_num = get_sg_info(temp_sites)
+                new_sg_num = get_sg_number(temp_sites)
                 if sg_num != new_sg_num:
                     logger.debug(f"Adding {sites[0].specie} in enum. New sg # {new_sg_num}")
                     index_species.append(sites[0].specie)
                     index_amounts.append(len(sites))
-                    sp_label = len(index_species) - 1
                     for site in sites:
-                        coord_str.append(f"{coord_format.format(*site.coords)} {sp_label}")
+                        coord_str.append(f"{coord_format.format(*site.coords)} {len(index_species) - 1}")
                     disordered_sites.append(sites)
                     curr_sites = temp_sites
                     sg_num = new_sg_num
@@ -274,29 +279,29 @@ class EnumlibAdaptor:
                 min_conc = math.floor(conc * base)
                 output.append(f"{min_conc - 1} {min_conc + 1} {base}")
         output.append("")
+
         logger.debug("Generated input file:\n" + "\n".join(output))
         with open("struct_enum.in", mode="w", encoding="utf-8") as file:
             file.write("\n".join(output))
 
-    def _run_multienum(self):
-        with subprocess.Popen([enum_cmd], stdout=subprocess.PIPE, stdin=subprocess.PIPE, close_fds=True) as process:
-            if self.timeout:
-                timed_out = False
-                timer = Timer(self.timeout * 60, lambda p: p.kill(), [process])
+    def _run_multienum(self) -> int:
+        """Run enumlib to get multiple structure.
 
-                try:
-                    timer.start()
-                    output = process.communicate()[0].decode("utf-8")
-                finally:
-                    if not timer.is_alive():
-                        timed_out = True
-                    timer.cancel()
+        Returns:
+            int: number of structures.
+        """
+        if ENUM_CMD is None:
+            raise RuntimeError("enumlib is not available")
 
-                if timed_out:
-                    raise TimeoutError("Enumeration took too long")
+        with subprocess.Popen([ENUM_CMD], stdout=subprocess.PIPE, stdin=subprocess.PIPE, close_fds=True) as process:
+            timeout = self.timeout * 60 if self.timeout is not None else None
 
-            else:
-                output = process.communicate()[0].decode("utf-8")
+            try:
+                output = process.communicate(timeout=timeout)[0].decode("utf-8")
+            except subprocess.TimeoutExpired as exc:
+                process.kill()
+                process.wait()
+                raise TimeoutError(f"Enumeration took more than timeout {self.timeout} minutes") from exc
 
         count = 0
         start_count = False
@@ -308,37 +313,41 @@ class EnumlibAdaptor:
         logger.debug(f"Enumeration resulted in {count} structures")
         return count
 
-    def _get_structures(self, num_structs):
-        structs = []
+    def _get_structures(self, num_structs: int) -> list[Structure]:
+        if MAKESTR_CMD is None:
+            raise RuntimeError("makestr.x is not available")
+
+        structs: list[Structure] = []
 
-        if ".py" in makestr_cmd:
-            options = ["-input", "struct_enum.out", str(1), str(num_structs)]
+        if ".py" in MAKESTR_CMD:
+            options: tuple[str, ...] = ("-input", "struct_enum.out", str(1), str(num_structs))
         else:
-            options = ["struct_enum.out", str(0), str(num_structs - 1)]
+            options = ("struct_enum.out", str(0), str(num_structs - 1))
 
         with subprocess.Popen(
-            [makestr_cmd, *options],
+            [MAKESTR_CMD, *options],
             stdout=subprocess.PIPE,
             stdin=subprocess.PIPE,
             close_fds=True,
         ) as rs:
             _stdout, stderr = rs.communicate()
+
         if stderr:
             logger.warning(stderr.decode())
 
-        # sites retrieved from enumlib will lack site properties
+        # Sites retrieved from enumlib will lack site properties
         # to ensure consistency, we keep track of what site properties
         # are missing and set them to None
         # TODO: improve this by mapping ordered structure to original
         # disordered structure, and retrieving correct site properties
-        disordered_site_properties = {}
+        disordered_site_properties: dict = {}
 
         if len(self.ordered_sites) > 0:
             original_latt = self.ordered_sites[0].lattice
             # Need to strip sites of site_properties, which would otherwise
             # result in an index error. Hence Structure is reconstructed in
             # the next step.
-            site_properties = {}
+            site_properties: dict = {}
             for site in self.ordered_sites:
                 for k, v in site.properties.items():
                     disordered_site_properties[k] = None
@@ -357,8 +366,8 @@ class EnumlibAdaptor:
             ordered_structure = inv_org_latt = None
 
         for file in glob("vasp.*"):
-            with open(file, encoding="utf-8") as file:
-                data = file.read()
+            with open(file, encoding="utf-8") as _file:
+                data = _file.read()
                 data = re.sub(r"scale factor", "1", data)
                 data = re.sub(r"(\d+)-(\d+)", r"\1 -\2", data)
                 poscar = Poscar.from_str(data, self.index_species)
diff --git a/src/pymatgen/core/structure.py b/src/pymatgen/core/structure.py
index 99f532dbe1..7193952d03 100644
--- a/src/pymatgen/core/structure.py
+++ b/src/pymatgen/core/structure.py
@@ -4673,9 +4673,8 @@ class Structure(IStructure, collections.abc.MutableSequence):
         the structure in place.
 
         Args:
-            indices (list): List of site indices on which to perform the
-                translation.
-            theta (float): Angle in radians
+            indices (list): Site indices on which to perform the rotation.
+            theta (float): Angle in radians.
             axis (3x1 array): Rotation axis vector.
             anchor (3x1 array): Point of rotation.
             to_unit_cell (bool): Whether new sites are transformed to unit cell
@@ -5294,9 +5293,8 @@ class Molecule(IMolecule, collections.abc.MutableSequence):
         """Rotate specific sites by some angle around vector at anchor.
 
         Args:
-            indices (list): List of site indices on which to perform the
-                translation.
-            theta (float): Angle in radians
+            indices (list): Site indices on which to perform the rotation.
+            theta (float): Angle in radians.
             axis (3x1 array): Rotation axis vector.
             anchor (3x1 array): Point of rotation.
 
diff --git a/src/pymatgen/core/units.py b/src/pymatgen/core/units.py
index 2237cfe461..c34b4fd43d 100644
--- a/src/pymatgen/core/units.py
+++ b/src/pymatgen/core/units.py
@@ -12,7 +12,6 @@ from __future__ import annotations
 
 import collections
 import re
-import warnings
 from collections import defaultdict
 from functools import partial
 from numbers import Number
@@ -310,25 +309,6 @@ class FloatWithUnit(float):
             unit (str | Unit): A unit. e.g. "C".
             unit_type (str): A type of unit. e.g. "charge"
         """
-        # Check deprecated memory unit
-        # TODO: remove after 2025-01-01
-        if unit_type == "memory" and str(unit) in {
-            "Kb",
-            "kb",
-            "Mb",
-            "mb",
-            "Gb",
-            "gb",
-            "Tb",
-            "tb",
-        }:
-            warnings.warn(
-                f"Unit {unit!s} is deprecated, please use {str(unit).upper()} instead",
-                DeprecationWarning,
-                stacklevel=2,
-            )
-            unit = str(unit).upper()
-
         if unit_type is not None and str(unit) not in ALL_UNITS[unit_type]:
             raise UnitError(f"{unit} is not a supported unit for {unit_type}")
 
diff --git a/src/pymatgen/io/vasp/sets.py b/src/pymatgen/io/vasp/sets.py
index d23a861ca3..3958efdd99 100644
--- a/src/pymatgen/io/vasp/sets.py
+++ b/src/pymatgen/io/vasp/sets.py
@@ -2599,23 +2599,91 @@ class MVLRelax52Set(VaspInputSet):
     _valid_potcars: Sequence[str] | None = ("PBE_52", "PBE_54", "PBE_64")
 
 
-class MITNEBSet(VaspInputSet):
+@dataclass
+class MITMDSet(VaspInputSet):
+    """Write a VASP MD run. This DOES NOT do multiple stage runs.
+
+    Args:
+        structure (Structure): Input structure.
+        start_temp (float): Starting temperature.
+        end_temp (float): Final temperature.
+        nsteps (int): Number of time steps for simulations. NSW parameter.
+        time_step (float): The time step for the simulation. The POTIM
+            parameter. Defaults to 2fs.
+        spin_polarized (bool): Whether to do spin polarized calculations.
+            The ISPIN parameter. Defaults to False.
+        **kwargs: Other kwargs supported by VaspInputSet.
+    """
+
+    structure: Structure | None = None
+    start_temp: float = 0.0
+    end_temp: float = 300.0
+    nsteps: int = 1000
+    time_step: float = 2
+    spin_polarized: bool = False
+    CONFIG = MITRelaxSet.CONFIG
+
+    @property
+    def incar_updates(self) -> dict[str, Any]:
+        """Updates to the INCAR config for this calculation type."""
+        # MD default settings
+        return {
+            "TEBEG": self.start_temp,
+            "TEEND": self.end_temp,
+            "NSW": self.nsteps,
+            "EDIFF_PER_ATOM": 0.000001,
+            "LSCALU": False,
+            "LCHARG": False,
+            "LPLANE": False,
+            "LWAVE": True,
+            "ISMEAR": 0,
+            "NELMIN": 4,
+            "LREAL": True,
+            "BMIX": 1,
+            "MAXMIX": 20,
+            "NELM": 500,
+            "NSIM": 4,
+            "ISYM": 0,
+            "ISIF": 0,
+            "IBRION": 0,
+            "NBLOCK": 1,
+            "KBLOCK": 100,
+            "SMASS": 0,
+            "POTIM": self.time_step,
+            "PREC": "Low",
+            "ISPIN": 2 if self.spin_polarized else 1,
+            "LDAU": False,
+            "ENCUT": None,
+        }
+
+    @property
+    def kpoints_updates(self) -> Kpoints:
+        """Updates to the kpoints configuration for this calculation type."""
+        return Kpoints.gamma_automatic()
+
+
+class NEBSet(VaspInputSet):
     """Write NEB inputs.
 
     Note that EDIFF is not on a per atom basis for this input set.
     """
 
-    def __init__(self, structures: list[Structure], unset_encut: bool = False, **kwargs) -> None:
+    def __init__(
+        self, structures: list[Structure], unset_encut: bool = False, parent_set="MPRelaxSet", **kwargs
+    ) -> None:
         """
         Args:
             structures: List of Structure objects.
             unset_encut (bool): Whether to unset ENCUT.
+            parent_set (str): The parent input set to inherit from. Defaults to MPRelaxSet. This should be a string
+                name to support MSONable.
             **kwargs: Other kwargs supported by VaspInputSet.
         """
         if len(structures) < 3:
             raise ValueError(f"You need at least 3 structures for an NEB, got {len(structures)}")
         kwargs["sort_structure"] = False
-        super().__init__(structures[0], MITRelaxSet.CONFIG, **kwargs)
+        self.parent = globals()[parent_set]
+        super().__init__(structures[0], self.parent.CONFIG, **kwargs)
         self.structures = self._process_structures(structures)
 
         self.unset_encut = False
@@ -2634,6 +2702,7 @@ class MITNEBSet(VaspInputSet):
             "LDAU": False,
         }
         self._config_dict["INCAR"].update(defaults)
+        self.parent_set = parent_set
 
     @property
     def poscar(self) -> Poscar:
@@ -2698,7 +2767,7 @@ class MITNEBSet(VaspInputSet):
             if write_cif:
                 poscar.structure.to(filename=str(d / f"{idx}.cif"))
         if write_endpoint_inputs:
-            end_point_param = MITRelaxSet(self.structures[0], user_incar_settings=self.user_incar_settings)
+            end_point_param = self.parent(self.structures[0], user_incar_settings=self.user_incar_settings)
 
             for image in ("00", str(len(self.structures) - 1).zfill(2)):
                 end_point_param.incar.write_file(str(output_dir / image / "INCAR"))
@@ -2715,67 +2784,59 @@ class MITNEBSet(VaspInputSet):
             neb_path.to(filename=f"{output_dir}/path.cif")
 
 
-@dataclass
-class MITMDSet(VaspInputSet):
-    """Write a VASP MD run. This DOES NOT do multiple stage runs.
-
-    Args:
-        structure (Structure): Input structure.
-        start_temp (float): Starting temperature.
-        end_temp (float): Final temperature.
-        nsteps (int): Number of time steps for simulations. NSW parameter.
-        time_step (float): The time step for the simulation. The POTIM
-            parameter. Defaults to 2fs.
-        spin_polarized (bool): Whether to do spin polarized calculations.
-            The ISPIN parameter. Defaults to False.
-        **kwargs: Other kwargs supported by VaspInputSet.
+class CINEBSet(NEBSet):
+    """
+    MAVRL-tested settings for CI-NEB calculations. Note that these parameters
+    requires the VTST modification of VASP from the Henkelman group. See
+    http://theory.cm.utexas.edu/vtsttools/.
     """
 
-    structure: Structure | None = None
-    start_temp: float = 0.0
-    end_temp: float = 300.0
-    nsteps: int = 1000
-    time_step: float = 2
-    spin_polarized: bool = False
-    CONFIG = MITRelaxSet.CONFIG
+    def __init__(self, structures: list[Structure], **kwargs) -> None:
+        r"""
+        Args:
+            structures: Input structures.
+            **kwargs: Keyword args supported by VaspInputSets.
+        """
+        user_incar_settings = kwargs.get("user_incar_settings", {})
 
-    @property
-    def incar_updates(self) -> dict[str, Any]:
-        """Updates to the INCAR config for this calculation type."""
-        # MD default settings
-        return {
-            "TEBEG": self.start_temp,
-            "TEEND": self.end_temp,
-            "NSW": self.nsteps,
-            "EDIFF_PER_ATOM": 0.000001,
-            "LSCALU": False,
-            "LCHARG": False,
-            "LPLANE": False,
-            "LWAVE": True,
+        # CI-NEB settings
+        defaults = {
+            "EDIFF": 5e-5,
+            "EDIFFG": -0.02,
+            "IBRION": 3,
+            "ICHAIN": 0,
+            "IOPT": 1,
+            "ISIF": 2,
             "ISMEAR": 0,
-            "NELMIN": 4,
-            "LREAL": True,
-            "BMIX": 1,
-            "MAXMIX": 20,
-            "NELM": 500,
-            "NSIM": 4,
-            "ISYM": 0,
-            "ISIF": 0,
-            "IBRION": 0,
-            "NBLOCK": 1,
-            "KBLOCK": 100,
-            "SMASS": 0,
-            "POTIM": self.time_step,
-            "PREC": "Low",
-            "ISPIN": 2 if self.spin_polarized else 1,
+            "ISPIN": 2,
+            "LCHARG": False,
+            "LCLIMB": True,
             "LDAU": False,
-            "ENCUT": None,
+            "LORBIT": 0,
+            "NSW": 200,
+            "POTIM": 0,
+            "SPRING": -5,
         }
+        if user_incar_settings != {}:
+            defaults.update(user_incar_settings)
 
-    @property
-    def kpoints_updates(self) -> Kpoints:
-        """Updates to the kpoints configuration for this calculation type."""
-        return Kpoints.gamma_automatic()
+        kwargs["user_incar_settings"] = defaults
+
+        super().__init__(structures, **kwargs)
+
+
+class MITNEBSet(NEBSet):
+    """
+    NEBSet using MITRelaxSet as parent. Retained for compatibility.
+    """
+
+    def __init__(self, structures: list[Structure], **kwargs) -> None:
+        """
+        Args:
+            structures: List of Structure objects.
+            **kwargs: Other kwargs supported by VaspInputSet.
+        """
+        super().__init__(structures, parent_set="MITRelaxSet", **kwargs)
 
 
 @dataclass
diff --git a/src/pymatgen/util/plotly_chempot_layouts.json b/src/pymatgen/util/plotly_chempot_layouts.json
index 92d9032a49..d9efedadad 100644
--- a/src/pymatgen/util/plotly_chempot_layouts.json
+++ b/src/pymatgen/util/plotly_chempot_layouts.json
@@ -55,7 +55,7 @@
     "yanchor": "middle"
   },
   "default_2d_axis_layout": {
-    "titlefont": {
+    "title.font": {
       "size": 24
     },
     "gridcolor": "#dbdbdb",
@@ -68,7 +68,7 @@
     "showline": true
   },
   "default_3d_axis_layout": {
-    "titlefont": {
+    "title.font": {
       "size": 18
     },
     "gridcolor": "#dbdbdb",
diff --git a/src/pymatgen/util/plotly_interface_rxn_layouts.json b/src/pymatgen/util/plotly_interface_rxn_layouts.json
index a1d1fd58a1..72d74c376d 100644
--- a/src/pymatgen/util/plotly_interface_rxn_layouts.json
+++ b/src/pymatgen/util/plotly_interface_rxn_layouts.json
@@ -13,7 +13,7 @@
         "size": 16.0
       },
       "ticks": "inside",
-      "titlefont": {
+      "title.font": {
         "color": "#000000",
         "size": 20.0
       },
@@ -32,7 +32,7 @@
         "size": 16.0
       },
       "ticks": "inside",
-      "titlefont": {
+      "title.font": {
         "color": "#000000",
         "size": 20.0
       },
diff --git a/src/pymatgen/util/plotly_pd_layouts.json b/src/pymatgen/util/plotly_pd_layouts.json
index 8cf5c4bac2..c1e5bb11d2 100644
--- a/src/pymatgen/util/plotly_pd_layouts.json
+++ b/src/pymatgen/util/plotly_pd_layouts.json
@@ -21,7 +21,7 @@
         "size": 16.0
       },
       "ticks": "inside",
-      "titlefont": {
+      "title.font": {
         "color": "#000000",
         "size": 20.0
       },
@@ -54,7 +54,7 @@
         "size": 16.0
       },
       "ticks": "inside",
-      "titlefont": {
+      "title.font": {
         "color": "#000000",
         "size": 20.0
       },
@@ -73,7 +73,7 @@
         "size": 16.0
       },
       "ticks": "inside",
-      "titlefont": {
+      "title.font": {
         "color": "#000000",
         "size": 20.0
       },
diff --git a/src/pymatgen/util/plotting.py b/src/pymatgen/util/plotting.py
index fd983af9e8..0e29b38390 100644
--- a/src/pymatgen/util/plotting.py
+++ b/src/pymatgen/util/plotting.py
@@ -268,8 +268,8 @@ def periodic_table_heatmap(
                 kwargs.setdefault("color_bar", {}).setdefault("title", cbar_label)
                 print('cbar_label is deprecated, use color_bar={"title": cbar_label} instead')
             if cbar_label_size != 14:
-                kwargs.setdefault("color_bar", {}).setdefault("titlefont", {}).setdefault("size", cbar_label_size)
-                print('cbar_label_size is deprecated, use color_bar={"titlefont": {"size": cbar_label_size}} instead')
+                kwargs.setdefault("color_bar", {}).setdefault("title.font", {}).setdefault("size", cbar_label_size)
+                print('cbar_label_size is deprecated, use color_bar={"title.font": {"size": cbar_label_size}} instead')
             if cmap:
                 kwargs.setdefault("colorscale", cmap)
                 print("cmap is deprecated, use colorscale=cmap instead")
diff --git a/src/pymatgen/util/provenance.py b/src/pymatgen/util/provenance.py
index 69220bd52c..7efbec48e6 100644
--- a/src/pymatgen/util/provenance.py
+++ b/src/pymatgen/util/provenance.py
@@ -21,6 +21,7 @@ except ImportError:
 
 if TYPE_CHECKING:
     from collections.abc import Sequence
+    from typing import Any
 
     from typing_extensions import Self
 
@@ -29,23 +30,23 @@ __author__ = "Anubhav Jain, Shyue Ping Ong"
 __credits__ = "Dan Gunter"
 
 
-MAX_HNODE_SIZE = 64_000  # maximum size (bytes) of SNL HistoryNode
-MAX_DATA_SIZE = 256_000  # maximum size (bytes) of SNL data field
-MAX_HNODES = 100  # maximum number of HistoryNodes in SNL file
-MAX_BIBTEX_CHARS = 20_000  # maximum number of characters for BibTeX reference
+MAX_HNODE_SIZE: int = 64_000  # maximum size (bytes) of SNL HistoryNode
+MAX_DATA_SIZE: int = 256_000  # maximum size (bytes) of SNL data field
+MAX_HNODES: int = 100  # maximum number of HistoryNodes in SNL file
+MAX_BIBTEX_CHARS: int = 20_000  # maximum number of characters for BibTeX reference
 
 
 def is_valid_bibtex(reference: str) -> bool:
     """Use pybtex to validate that a reference is in proper BibTeX format.
 
     Args:
-        reference: A String reference in BibTeX format.
+        reference (str): Reference in BibTeX format.
 
     Returns:
-        bool: True if reference is valid bibtex.
+        bool: True if reference is valid BibTeX.
     """
-    # str is necessary since pybtex seems to have an issue with unicode. The
-    # filter expression removes all non-ASCII characters.
+    # str is necessary since pybtex seems to have an issue with unicode.
+    # The filter expression removes all non-ASCII characters.
     str_io = StringIO(reference.encode("ascii", "ignore").decode("ascii"))
     parser = bibtex.Parser()
     errors.set_strict_mode(enable=False)
@@ -62,8 +63,6 @@ class HistoryNode(NamedTuple):
     a custom description of how that code was applied (e.g. a site removal
     Transformation was applied).
 
-    A HistoryNode contains three fields:
-
     Attributes:
         name (str): The name of a code or resource that this Structure encountered in its history.
         url (str): The URL of that code/resource.
@@ -115,16 +114,16 @@ class Author(NamedTuple):
     name: str
     email: str
 
-    def __str__(self):
+    def __str__(self) -> str:
         """String representation of an Author."""
         return f"{self.name} <{self.email}>"
 
-    def as_dict(self):
+    def as_dict(self) -> dict[str, str]:
         """Get MSONable dict."""
         return {"name": self.name, "email": self.email}
 
     @classmethod
-    def from_dict(cls, dct: dict) -> Self:
+    def from_dict(cls, dct: dict[str, str]) -> Self:
         """
         Args:
             dct (dict): Dict representation.
@@ -135,12 +134,17 @@ class Author(NamedTuple):
         return cls(dct["name"], dct["email"])
 
     @classmethod
-    def parse_author(cls, author) -> Self:
+    def parse_author(
+        cls,
+        author: str | dict[str, str] | tuple[str, str],
+    ) -> Self:
         """Parse an Author object from either a String, dict, or tuple.
 
         Args:
-            author: A String formatted as "NAME <email@domain.com>",
-                (name, email) tuple, or a dict with name and email keys.
+            author: in one of the three accepted formats:
+                - A string formatted as "NAME <email@domain.com>".
+                - A (name, email) tuple.
+                - A dict with name and email as keys.
 
         Returns:
             An Author object.
@@ -152,15 +156,18 @@ class Author(NamedTuple):
             if not match or match.start() != 0 or match.end() != len(author):
                 raise ValueError(f"Invalid author format! {author}")
             return cls(match.groups()[0], match.groups()[1])
+
         if isinstance(author, dict):
             return cls.from_dict(author)
+
         if len(author) != 2:
             raise ValueError(f"Invalid author, should be String or (name, email) tuple: {author}")
+
         return cls(author[0], author[1])
 
 
 class StructureNL:
-    """The Structure Notation Language (SNL, pronounced 'snail') is a container for a pymatgen
+    """The Structure Notation Language (SNL, pronounced "snail") is a container for a pymatgen
     Structure/Molecule object with some additional fields for enhanced provenance.
 
     It is meant to be imported/exported in a JSON file format with the following structure:
@@ -178,45 +185,48 @@ class StructureNL:
 
     def __init__(
         self,
-        struct_or_mol,
-        authors,
-        projects=None,
-        references="",
-        remarks=None,
-        data=None,
-        history=None,
-        created_at=None,
-    ):
+        struct_or_mol: Structure | Molecule,
+        authors: str | list[dict] | list[str],
+        projects: list[str] | str | None = None,
+        references: str = "",
+        remarks: list[str] | str | None = None,
+        data: dict | None = None,
+        history: list[dict] | None = None,
+        created_at: datetime | None = None,
+    ) -> None:
         """
         Args:
             struct_or_mol: A pymatgen Structure/Molecule object
-            authors: *List* of {"name":'', "email":''} dicts,
-                *list* of Strings as 'John Doe <johndoe@gmail.com>',
-                or a single String with commas separating authors
-            projects: List of Strings ['Project A', 'Project B']
-            references: A String in BibTeX format
-            remarks: List of Strings ['Remark A', 'Remark B']
-            data: A free form dict. Namespaced at the root level with an
+            authors (list | str):
+                - List of {"name":"", "email":""} dicts.
+                - List of strings as "John Doe <johndoe@gmail.com>".
+                - A single String with commas separating authors.
+            projects (list[str] | str): e.g. ["Project A", "Project B"]
+            references (str): A string in BibTeX format
+            remarks (list[str]): e.g. ["Remark A", "Remark B"]
+            data (dict): A free form dict. Namespaced at the root level with an
                 underscore, e.g. {"_materialsproject": <custom data>}
-            history: List of dicts - [{'name':'', 'url':'', 'description':{}}]
-            created_at: A datetime object.
+            history (list[dict]): e.g. [{"name":"", "url":"", "description":{}}]
+            created_at (datetime): Creation date.
         """
-        # initialize root-level structure keys
+        # Initialize root-level structure keys
         self.structure = struct_or_mol
 
-        # turn authors into list of Author objects
+        # Turn `authors` into list of `Author` objects
         authors = authors.split(",") if isinstance(authors, str) else authors
         self.authors = [Author.parse_author(a) for a in authors]
 
-        # turn projects into list of Strings
+        # Turn `projects` into list of strings
         projects = projects or []
-        self.projects = [projects] if isinstance(projects, str) else projects
+        self.projects: list[str] = [projects] if isinstance(projects, str) else projects
 
-        # check that references are valid BibTeX
+        # Check that references are valid BibTeX
         if not isinstance(references, str):
             raise TypeError("Invalid format for SNL reference! Should be empty string or BibTeX string.")
+
         if references and not is_valid_bibtex(references):
             raise ValueError("Invalid format for SNL reference! Should be BibTeX string.")
+
         if len(references) > MAX_BIBTEX_CHARS:
             raise ValueError(
                 f"The BibTeX string must be fewer than {MAX_BIBTEX_CHARS} chars, you have {len(references)}"
@@ -224,16 +234,16 @@ class StructureNL:
 
         self.references = references
 
-        # turn remarks into list of Strings
+        # Turn `remarks` into list of strings
         remarks = remarks or []
-        self.remarks = [remarks] if isinstance(remarks, str) else remarks
+        self.remarks: list[str] = [remarks] if isinstance(remarks, str) else remarks
 
-        # check remarks limit
+        # Check `remarks` length limit
         for remark in self.remarks:
             if len(remark) > 140:
                 raise ValueError(f"The remark exceeds the maximum size of 140 characters: {len(remark)}")
 
-        # check data limit
+        # Check data length limit
         self.data = data or {}
         if not sys.getsizeof(self.data) < MAX_DATA_SIZE:
             raise ValueError(
@@ -248,7 +258,7 @@ class StructureNL:
                     f"{key=} does not start with an underscore."
                 )
 
-        # check for valid history nodes
+        # Check for valid history nodes
         history = history or []  # initialize null fields
         if len(history) > MAX_HNODES:
             raise ValueError(f"A maximum of {MAX_HNODES} History nodes are supported, you have {len(history)}!")
@@ -258,7 +268,42 @@ class StructureNL:
 
         self.created_at = created_at or f"{datetime.now(tz=timezone.utc):%Y-%m-%d %H:%M:%S.%f%z}"
 
-    def as_dict(self):
+    def __str__(self) -> str:
+        return "\n".join(
+            [
+                f"{key}\n{getattr(self, key)}"
+                for key in (
+                    "structure",
+                    "authors",
+                    "projects",
+                    "references",
+                    "remarks",
+                    "data",
+                    "history",
+                    "created_at",
+                )
+            ]
+        )
+
+    def __eq__(self, other: object) -> bool:
+        """Check for equality between two StructureNL objects."""
+        needed_attrs = (
+            "structure",
+            "authors",
+            "projects",
+            "references",
+            "remarks",
+            "data",
+            "history",
+            "created_at",
+        )
+
+        if not all(hasattr(other, attr) for attr in needed_attrs):
+            return NotImplemented
+
+        return all(getattr(self, attr) == getattr(other, attr) for attr in needed_attrs)
+
+    def as_dict(self) -> dict[str, Any]:
         """Get MSONable dict."""
         dct = self.structure.as_dict()
         dct["@module"] = type(self).__module__
@@ -275,7 +320,7 @@ class StructureNL:
         return dct
 
     @classmethod
-    def from_dict(cls, dct: dict) -> Self:
+    def from_dict(cls, dct: dict[str, Any]) -> Self:
         """
         Args:
             dct (dict): Dict representation.
@@ -305,36 +350,36 @@ class StructureNL:
     def from_structures(
         cls,
         structures: Sequence[Structure],
-        authors: Sequence[dict[str, str]],
-        projects=None,
-        references="",
-        remarks=None,
-        data=None,
-        histories=None,
-        created_at=None,
+        authors,
+        projects: list[str] | None = None,
+        references: str = "",
+        remarks: list[str] | None = None,
+        data: list[dict] | None = None,
+        histories: list[list[dict]] | None = None,
+        created_at: datetime | None = None,
     ) -> list[Self]:
         """A convenience method for getting a list of StructureNL objects by
         specifying structures and metadata separately. Some of the metadata
         is applied to all of the structures for ease of use.
 
         Args:
-            structures: A list of Structure objects
+            structures (Sequence[Structure]): Structure objects
             authors: *List* of {"name":'', "email":''} dicts,
                 *list* of Strings as 'John Doe <johndoe@gmail.com>',
                 or a single String with commas separating authors
-            projects: List of Strings ['Project A', 'Project B']. This
+            projects (list[str]): e.g. ["Project A", "Project B"]. This
                 applies to all structures.
-            references: A String in BibTeX format. Again, this applies to all
+            references (str): A String in BibTeX format. This applies to all
                 structures.
-            remarks: List of Strings ['Remark A', 'Remark B']
-            data: A list of free form dict. Namespaced at the root level
+            remarks (list[str]): e.g. ["Remark A", "Remark B"].
+            data (list[dict]): Free form dicts. Namespaced at the root level
                 with an underscore, e.g. {"_materialsproject":<custom data>}
                 . The length of data should be the same as the list of
                 structures if not None.
-            histories: List of list of dicts - [[{'name':'', 'url':'',
-                'description':{}}], ...] The length of histories should be the
+            histories(list[list[dict]]): e.g. [[{"name":"", "url":"",
+                "description":{}}], ...] The length of histories should be the
                 same as the list of structures if not None.
-            created_at: A datetime object
+            created_at (datetime): Creation date.
         """
         data = [{}] * len(structures) if data is None else data
         histories = [[]] * len(structures) if histories is None else histories
@@ -354,38 +399,3 @@ class StructureNL:
             snl_list.append(snl)
 
         return snl_list
-
-    def __str__(self):
-        return "\n".join(
-            [
-                f"{key}\n{getattr(self, key)}"
-                for key in (
-                    "structure",
-                    "authors",
-                    "projects",
-                    "references",
-                    "remarks",
-                    "data",
-                    "history",
-                    "created_at",
-                )
-            ]
-        )
-
-    def __eq__(self, other: object) -> bool:
-        """Check for equality between two StructureNL objects."""
-        needed_attrs = (
-            "structure",
-            "authors",
-            "projects",
-            "references",
-            "remarks",
-            "data",
-            "history",
-            "created_at",
-        )
-
-        if not all(hasattr(other, attr) for attr in needed_attrs):
-            return NotImplemented
-
-        return all(getattr(self, attr) == getattr(other, attr) for attr in needed_attrs)


Test patch:
diff --git a/tests/command_line/test_enumlib_caller.py b/tests/command_line/test_enumlib_caller.py
index 010bb0bd1b..609253817f 100644
--- a/tests/command_line/test_enumlib_caller.py
+++ b/tests/command_line/test_enumlib_caller.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import unittest
 from shutil import which
 
 import numpy as np
@@ -18,6 +17,8 @@ enum_cmd = which("enum.x") or which("multienum.x")
 makestr_cmd = which("makestr.x") or which("makeStr.x") or which("makeStr.py")
 enumlib_present = enum_cmd and makestr_cmd
 
+ENUMLIB_TEST_FILES_DIR: str = f"{TEST_FILES_DIR}/command_line/enumlib"
+
 
 @pytest.mark.skipif(not enumlib_present, reason="enum_lib not present.")
 class TestEnumlibAdaptor(PymatgenTest):
@@ -36,7 +37,7 @@ class TestEnumlibAdaptor(PymatgenTest):
         assert len(structures) == 52
 
         sub_trans = SubstitutionTransformation({"Li": {"Li": 0.25}})
-        adaptor = EnumlibAdaptor(sub_trans.apply_transformation(struct), 1, 1, refine_structure=True)
+        adaptor = EnumlibAdaptor(sub_trans.apply_transformation(struct), refine_structure=True)
         adaptor.run()
         structures = adaptor.structures
         assert len(structures) == 1
@@ -56,7 +57,7 @@ class TestEnumlibAdaptor(PymatgenTest):
         # REmove some ordered sites to break symmetry.
         remove_trans = RemoveSitesTransformation([4, 7])
         struct_trafo = remove_trans.apply_transformation(struct_trafo)
-        adaptor = EnumlibAdaptor(struct_trafo, 1, 1, enum_precision_parameter=0.01)
+        adaptor = EnumlibAdaptor(struct_trafo, enum_precision_parameter=0.01)
         adaptor.run()
         structures = adaptor.structures
         assert len(structures) == 4
@@ -71,8 +72,8 @@ class TestEnumlibAdaptor(PymatgenTest):
         structures = adaptor.structures
         assert len(structures) == 10
 
-        struct = Structure.from_file(f"{TEST_FILES_DIR}/command_line/enumlib/EnumerateTest.json")
-        adaptor = EnumlibAdaptor(struct, 1, 1)
+        struct = Structure.from_file(f"{ENUMLIB_TEST_FILES_DIR}/EnumerateTest.json.gz")
+        adaptor = EnumlibAdaptor(struct)
         adaptor.run()
         structures = adaptor.structures
         assert len(structures) == 2
@@ -95,7 +96,7 @@ class TestEnumlibAdaptor(PymatgenTest):
         prim = spga.find_primitive()
         struct = prim.copy()
         struct["Al3+"] = {"Al3+": 0.5, "Ga3+": 0.5}
-        adaptor = EnumlibAdaptor(struct, 1, 1, enum_precision_parameter=0.01)
+        adaptor = EnumlibAdaptor(struct, enum_precision_parameter=0.01)
         adaptor.run()
         structures = adaptor.structures
         assert len(structures) == 7
@@ -103,7 +104,7 @@ class TestEnumlibAdaptor(PymatgenTest):
             assert struct.formula == "Ca12 Al4 Ga4 Si12 O48"
         struct = prim.copy()
         struct["Ca2+"] = {"Ca2+": 1 / 3, "Mg2+": 2 / 3}
-        adaptor = EnumlibAdaptor(struct, 1, 1, enum_precision_parameter=0.01)
+        adaptor = EnumlibAdaptor(struct, enum_precision_parameter=0.01)
         adaptor.run()
         structures = adaptor.structures
         assert len(structures) == 20
@@ -112,18 +113,17 @@ class TestEnumlibAdaptor(PymatgenTest):
 
         struct = prim.copy()
         struct["Si4+"] = {"Si4+": 1 / 3, "Ge4+": 2 / 3}
-        adaptor = EnumlibAdaptor(struct, 1, 1, enum_precision_parameter=0.01)
+        adaptor = EnumlibAdaptor(struct, enum_precision_parameter=0.01)
         adaptor.run()
         structures = adaptor.structures
         assert len(structures) == 18
         for struct in structures:
             assert struct.formula == "Ca12 Al8 Si4 Ge8 O48"
 
-    @unittest.skip("Fails seemingly at random.")
     def test_timeout(self):
-        struct = Structure.from_file(filename=f"{TEST_FILES_DIR}/cif/garnet.cif")
-        SpacegroupAnalyzer(struct, 0.1)
-        struct["Al3+"] = {"Al3+": 0.5, "Ga3+": 0.5}
-        adaptor = EnumlibAdaptor(struct, 1, 1, enum_precision_parameter=0.01, timeout=0.0000000000001)
-        with pytest.raises(TimeoutError, match="Enumeration took too long"):
-            adaptor._run_multienum()
+        struct = Structure.from_file(f"{ENUMLIB_TEST_FILES_DIR}/test_timeout.json.gz")
+
+        adaptor = EnumlibAdaptor(struct, max_cell_size=10, timeout=0.05)  # timeout in minute
+
+        with pytest.raises(TimeoutError, match="Enumeration took more than timeout 0.05 minutes"):
+            adaptor.run()
diff --git a/tests/core/test_units.py b/tests/core/test_units.py
index c0e2f4ccd4..f4e95e36cd 100644
--- a/tests/core/test_units.py
+++ b/tests/core/test_units.py
@@ -1,7 +1,5 @@
 from __future__ import annotations
 
-import warnings
-
 import pytest
 from numpy.testing import assert_array_equal
 from pytest import approx
@@ -109,17 +107,6 @@ class TestFloatWithUnit(PymatgenTest):
         mega_3 = Memory.from_str("+1.0 MB")
         assert mega_0 == mega_1 == mega_2 == mega_3
 
-    def test_deprecated_memory(self):
-        # TODO: remove after 2025-01-01
-        for unit in ("Kb", "kb", "Mb", "mb", "Gb", "gb", "Tb", "tb"):
-            with pytest.warns(DeprecationWarning, match=f"Unit {unit} is deprecated"):
-                Memory(1, unit)
-
-        with warnings.catch_warnings():
-            warnings.simplefilter("error")
-            for unit in ("KB", "MB", "GB", "TB"):
-                Memory(1, unit)
-
     def test_unitized(self):
         @unitized("eV")
         def func1():
diff --git a/tests/files/command_line/enumlib/EnumerateTest.json b/tests/files/command_line/enumlib/EnumerateTest.json
deleted file mode 100644
index 7e1f225198..0000000000
--- a/tests/files/command_line/enumlib/EnumerateTest.json
+++ /dev/null
@@ -1 +0,0 @@
-{"lattice": {"a": 5.0, "gamma": 90.0, "c": 5.0, "b": 5.0, "matrix": [[-5.0, 0.0, -3.061616997868383e-16], [3.061616997868383e-16, -5.0, -3.061616997868383e-16], [0.0, 0.0, -5.0]], "volume": 125.0, "alpha": 90.0, "beta": 90.0}, "sites": [{"properties": {}, "abc": [0.75, 0.75, 0.75], "xyz": [-3.7499999999999996, -3.75, -3.7500000000000004], "species": [{"occu": 1.0, "element": "Li"}], "label": "Li"}, {"properties": {}, "abc": [0.75, 0.25, 0.25], "xyz": [-3.75, -1.25, -1.2500000000000002], "species": [{"occu": 1.0, "element": "Li"}], "label": "Li"}, {"properties": {}, "abc": [0.25, 0.75, 0.25], "xyz": [-1.2499999999999998, -3.75, -1.2500000000000002], "species": [{"occu": 1.0, "element": "Li"}], "label": "Li"}, {"properties": {}, "abc": [0.25, 0.25, 0.75], "xyz": [-1.25, -1.25, -3.75], "species": [{"occu": 1.0, "element": "Li"}], "label": "Li"}, {"properties": {}, "abc": [0.25, 0.25, 0.25], "xyz": [-1.25, -1.25, -1.2500000000000002], "species": [{"occu": 1.0, "element": "Li"}], "label": "Li"}, {"properties": {}, "abc": [0.25, 0.75, 0.75], "xyz": [-1.2499999999999998, -3.75, -3.7500000000000004], "species": [{"occu": 1.0, "element": "Li"}], "label": "Li"}, {"properties": {}, "abc": [0.75, 0.25, 0.75], "xyz": [-3.75, -1.25, -3.7500000000000004], "species": [{"occu": 1.0, "element": "Li"}], "label": "Li"}, {"properties": {}, "abc": [0.75, 0.75, 0.25], "xyz": [-3.7499999999999996, -3.75, -1.2500000000000004], "species": [{"occu": 1.0, "element": "Li"}], "label": "Li"}, {"properties": {}, "abc": [0.5, 0.5, 0.5], "xyz": [-2.5, -2.5, -2.5000000000000004], "species": [{"occu": 1.0, "element": "Li"}], "label": "Li"}, {"properties": {}, "abc": [0.0, 0.0, 0.0], "xyz": [0.0, 0.0, 0.0], "species": [{"occu": 0.75, "element": "H"}, {"occu": 0.25, "element": "He"}], "label": "He:0.250, H:0.750"}, {"properties": {}, "abc": [0.0, 0.5, 0.5], "xyz": [1.5308084989341916e-16, -2.5, -2.5], "species": [{"occu": 0.75, "element": "H"}, {"occu": 0.25, "element": "He"}], "label": "He:0.250, H:0.750"}, {"properties": {}, "abc": [0.5, 0.0, 0.5], "xyz": [-2.5, 0.0, -2.5], "species": [{"occu": 0.75, "element": "H"}, {"occu": 0.25, "element": "He"}], "label": "He:0.250, H:0.750"}, {"properties": {}, "abc": [0.5, 0.5, 0.0], "xyz": [-2.5, -2.5, -3.061616997868383e-16], "species": [{"occu": 0.75, "element": "H"}, {"occu": 0.25, "element": "He"}], "label": "He:0.250, H:0.750"}], "@class": "Structure", "@module": "pymatgen.core.structure"}
\ No newline at end of file
diff --git a/tests/io/vasp/test_sets.py b/tests/io/vasp/test_sets.py
index 0dab414043..ecbd6ae454 100644
--- a/tests/io/vasp/test_sets.py
+++ b/tests/io/vasp/test_sets.py
@@ -22,6 +22,7 @@ from pymatgen.io.vasp.outputs import Vasprun
 from pymatgen.io.vasp.sets import (
     MODULE_DIR,
     BadInputSetWarning,
+    CINEBSet,
     DictSet,
     LobsterSet,
     MatPESStaticSet,
@@ -49,6 +50,7 @@ from pymatgen.io.vasp.sets import (
     MVLRelax52Set,
     MVLScanRelaxSet,
     MVLSlabSet,
+    NEBSet,
     VaspInputGenerator,
     VaspInputSet,
     batch_write_input,
@@ -1311,40 +1313,49 @@ class TestMPMDSet(PymatgenTest):
         assert v.incar["NSW"] == 1000
 
 
-class TestMITNEBSet(PymatgenTest):
+class TestNEBSet(PymatgenTest):
     def setUp(self):
         c1 = [[0.5] * 3, [0.9] * 3]
         c2 = [[0.5] * 3, [0.9, 0.1, 0.1]]
         s1 = Structure(Lattice.cubic(5), ["Si", "Si"], c1)
         s2 = Structure(Lattice.cubic(5), ["Si", "Si"], c2)
         self.structures = [Structure.from_sites(s.sites, to_unit_cell=True) for s in s1.interpolate(s2, 3, pbc=True)]
-        self.vis = MITNEBSet(self.structures)
+        self.vis = NEBSet(self.structures)
+        self.vis_MIT = MITNEBSet(self.structures)
+        self.vis_cineb = CINEBSet(self.structures)
 
     def test_potcar_symbols(self):
         syms = self.vis.potcar_symbols
         assert syms == ["Si"]
 
     def test_unset_encut(self):
-        vis = MITNEBSet(self.structures, unset_encut=True)
+        vis = NEBSet(self.structures, unset_encut=True)
         assert "ENCUT" not in vis.incar
         n_structs = 2
         with pytest.raises(
             ValueError,
             match=f"You need at least 3 structures for an NEB, got {n_structs}",
         ):
-            _ = MITNEBSet(self.structures[:n_structs], unset_encut=True)
+            _ = NEBSet(self.structures[:n_structs], unset_encut=True)
 
     def test_incar(self):
         incar = self.vis.incar
         assert "LDAUU" not in incar
-        assert incar["EDIFF"] == approx(0.00001)
+        assert incar["EDIFF"] == approx(0.00005)
+        assert self.vis_MIT.incar["EDIFF"] == approx(0.00001)
+        assert "LCLIMB" in self.vis_cineb.incar
 
     def test_kpoints(self):
         kpoints = self.vis.kpoints
-        assert kpoints.kpts == [(25,)]
-        assert kpoints.style == Kpoints.supported_modes.Automatic
+        assert kpoints.kpts == [(5, 5, 5)]
+        assert kpoints.style == Kpoints.supported_modes.Gamma
+        assert self.vis_MIT.kpoints.kpts == [(25,)]
+        assert self.vis_MIT.kpoints.style == Kpoints.supported_modes.Automatic
 
     def test_as_from_dict(self):
+        dct = self.vis_MIT.as_dict()
+        v = MontyDecoder().process_decoded(dct)
+        assert v.incar["IMAGES"] == 2
         dct = self.vis.as_dict()
         v = MontyDecoder().process_decoded(dct)
         assert v.incar["IMAGES"] == 2