################################################################################
#
#  CDDL HEADER START
#
#  The contents of this file are subject to the terms of the Common Development
#  and Distribution License Version 1.0 (the "License").
#
#  You can obtain a copy of the license at
#  http:# www.opensource.org/licenses/CDDL-1.0.  See the License for the
#  specific language governing permissions and limitations under the License.
#
#  When distributing Covered Code, include this CDDL HEADER in each file and
#  include the License file in a prominent location with the name LICENSE.CDDL.
#  If applicable, add the following below this CDDL HEADER, with the fields
#  enclosed by brackets "[]" replaced with your own identifying information:
#
#  Portions Copyright (c) [yyyy] [name of copyright owner]. All rights reserved.
#
#  CDDL HEADER END
#
#  Copyright (c) 2017-2019, Regents of the University of Minnesota.
#  All rights reserved.
#
#  Contributor(s):
#     Ellad B. Tadmor
#     Daniel S. Karls
#
################################################################################
"""
Helper routines for KIM Tests and Verification Checks

"""

import itertools
import logging
import math
import random
from typing import Union

import numpy as np
from ase import Atoms
from ase.calculators.calculator import Calculator
from ase.calculators.kim.kim import KIM
from ase.data import chemical_symbols
from ase.lattice.cubic import FaceCenteredCubic

logger = logging.getLogger(__name__)
logging.basicConfig(filename="kim-tools.log", level=logging.INFO, force=True)


__all__ = [
    "KIMASEError",
    "atom_outside_cell_along_nonperiodic_dim",
    "check_if_atoms_interacting_energy",
    "check_if_atoms_interacting_force",
    "check_if_atoms_interacting",
    "get_isolated_energy_per_atom",
    "get_model_energy_cutoff",
    "get_model_species_minimum_cutoff",
    "fcc_atoms_in_supercell",
    "generate_fcc_compute_energy",
    "local_edge_detection",
    "filter_good_alat",
    "find_working_configuration_FCC",
    "fractional_coords_transformation",
    "perturb_until_all_forces_sizeable",
    "randomize_positions",
    "randomize_species",
    "remove_species_not_supported_by_ASE",
    "rescale_to_get_nonzero_energy",
    "rescale_to_get_nonzero_forces",
]


################################################################################
class KIMASEError(Exception):
    def __init__(self, msg):
        # Call the base class constructor with the parameters it needs
        super(KIMASEError, self).__init__(msg)
        self.msg = msg

    def __str__(self):
        return self.msg


################################################################################
def remove_species_not_supported_by_ASE(species):
    """
    Remove any species from the 'species' list that are not supported by ASE
    """
    supported_species = chemical_symbols[1:]
    return [s for s in species if s in supported_species]


################################################################################
def randomize_species(atoms, species, seed: Union[int, None] = 13):
    """
    Given an ASE 'atoms' object, set random element for each atom selected
    from the list of available 'species' in a way that ensures all are
    represented with the same probabilities.
    """
    random.seed(seed)

    # Indefinitely iterate through species putting them at random
    # unoccupied sites until all atoms are exhausted.
    indices = list(range(len(atoms)))
    num_occupied = 0
    for element in itertools.cycle(species):
        i = random.randint(0, len(indices) - 1)
        atoms[indices[i]].symbol = element
        del indices[i]
        num_occupied += 1
        if num_occupied == len(atoms):
            break


################################################################################
def fractional_coords_transformation(cell):
    """
    Given a set of cell vectors, this will return a transformation matrix T that can be
    used to multiply any arbitrary position to get its fractional coordinates in the
    basis of those cell vectors.
    """
    simulation_cell_volume = np.linalg.det(cell)

    T = (
        np.vstack(
            (
                np.cross(cell[:, 1], cell[:, 2]),
                np.cross(cell[:, 2], cell[:, 0]),
                np.cross(cell[:, 0], cell[:, 1]),
            )
        )
        / simulation_cell_volume
    )

    return T


################################################################################
def atom_outside_cell_along_nonperiodic_dim(T, atom_coord, pbc, tol=1e-12):
    """
    Given a transformation matrix to apply to an atomic position to get its fractional
    position in the basis of some cell vectors and the corresponding boundary
    conditions, determine if the atom is outside of the cell along any non-periodic
    directions (measured using tolerance 'tol').  This is relevant when using an SM from
    a simulator such as LAMMPS that performs spatial decomposition -- atoms that leave
    the box along non-periodic dimensions are likely to become "lost."
    """
    if all(pbc):
        # Skip the actual checks
        return False

    # Calculate fractional coordinate of this atom
    atom_coord_fractional = np.dot(T, atom_coord)
    for dof in range(0, 3):
        if not pbc[dof] and (
            atom_coord_fractional[dof] < -tol or atom_coord_fractional[dof] > 1 + tol
        ):
            return True

    # If we made it to here, this atom's position is OK
    return False


################################################################################
def randomize_positions(atoms, pert_amp, seed: Union[int, None] = 13):
    """
    Given an ASE 'atoms' object, displace all atomic coordinates by a random amount in
    the range [-pert_amp, pert_amp] along *each* dimension.  Note that all atomic
    coordinates must be inside of the corresponding cell along non-periodic dimensions.
    As each atom is looped over, we continue generating perturbations until the
    displaced position is inside of the cell along any non-periodic directions
    (displacing outside the cell along periodic dimensions is allowed, although it's up
    to the calling function to wrap the positions if they need to be).
    """
    random.seed(seed)

    pbc = atoms.get_pbc()
    if all(pbc):
        # Don't need to worry about moving atoms outside of cell
        for at in range(0, len(atoms)):
            atoms[at].position += [
                pert_amp * random.uniform(-1.0, 1.0) for i in range(3)
            ]

    else:
        # Get transformation matrix to get fractional coords
        T = fractional_coords_transformation(atoms.get_cell())

        for at in range(0, len(atoms)):
            # Check if the positional coordinate of this atom is valid to begin
            # with, i.e. it's inside the box along along all non-periodic directions
            if atom_outside_cell_along_nonperiodic_dim(T, atoms[at].position, pbc):
                raise KIMASEError(
                    "ERROR: Determined that atom {} with position {} is outside of the "
                    "simulation cell ({}) along one or more non-periodic directions.  "
                    "In order to prevent atoms from being lost, they must all be "
                    "contained inside of the simulation cell along non-periodic "
                    "dimensions.".format(at, atoms[at].position, atoms.get_cell())
                )

            for dof in range(0, 3):
                coord = atoms[at].position[dof].copy()
                done = False
                while not done:
                    atoms[at].position[dof] += random.uniform(-1.0, 1.0) * pert_amp
                    if not atom_outside_cell_along_nonperiodic_dim(
                        T, atoms[at].position, pbc
                    ):
                        done = True
                    else:
                        atoms[at].position[dof] = coord


################################################################################
def get_isolated_energy_per_atom(
    model: Union[str, Calculator],
    symbol: str,
    initial_separation: float = 1.0,
    max_separation: float = 15.0,
    separation_neg_exponent: float = 4,
    quit_early_after_convergence: bool = True,
    energy_tolerance: float = 1e-12,
) -> float:
    """
    Construct a non-periodic cell containing a single atom and compute its energy.
    It tries to iteratively finetune the atomic separation for a dimer up to a
    specified precision (separation_neg_exponent). If between two successive phases
    the energy difference is less than energy_tolerance, it stops early, i.e. if
    4.0x and 4.00x are within energy_tolerance, it stops at 4.00x.
    All separations are in Angstroms.

    Args:
        model: KIM model or ASE calculator to use for calculations
        symbol: Chemical symbol
        initial_separation: Initial separation for dimer calculations
        max_separation: maximum separation for dimer calculations
        separation_neg_exponent: Number of decimal places to refine the separation
        quit_early_after_convergence: Whether to stop early if energy converges
        energy_tolerance: Energy difference tolerance for convergence check

    Returns:
        The isolated energy per atom for the requested chemical symbol
    """
    try:
        single_atom = Atoms(
            symbol,
            positions=[(0.1, 0.1, 0.1)],
            cell=(20, 20, 20),
            pbc=(False, False, False),
        )
        if isinstance(model, str):
            calc = KIM(model)
        elif isinstance(model, Calculator):
            calc = model

        single_atom.calc = calc
        energy_per_atom = single_atom.get_potential_energy()

        # Clean up KIM calculators that are able to be cleaned
        # up. This is necessary for LAMMPS ReaxFF simulators
        # encapsulated in a KIM SM,
        # as they preallocate arrays based on neighbors and
        # can crash if the lengths of neighborlists change.
        # This should only be done when a model name was passed
        # and the calculator was instantiated using KIM() in
        # this function. For example, if instead a LAMMPSLib
        # calculator was passed in directly, cleaning it
        # can break things.
        if isinstance(model, str):
            if hasattr(calc, "clean"):
                calc.clean()
            if hasattr(calc, "__del__"):
                calc.__del__()
        del single_atom

        return energy_per_atom

    except Exception:

        def _try_dimer_energy(separation):
            try:
                dimer = Atoms(
                    [symbol, symbol],
                    positions=[(0.1, 0.1, 0.1), (0.1 + separation, 0.1, 0.1)],
                    cell=(max(20, separation + 10), 20, 20),
                    pbc=(False, False, False),
                )
                if isinstance(model, str):
                    calc = KIM(model)
                elif isinstance(model, Calculator):
                    calc = model
                dimer.calc = calc

                total_energy = dimer.get_potential_energy()
                energy_per_atom = total_energy / 2.0

                if isinstance(model, str):
                    if hasattr(calc, "clean"):
                        calc.clean()
                    if hasattr(calc, "__del__"):
                        calc.__del__()
                del dimer

                return energy_per_atom

            except Exception:
                try:
                    if isinstance(model, str):
                        if hasattr(calc, "clean"):
                            calc.clean()
                        if hasattr(calc, "__del__"):
                            calc.__del__()
                    if "dimer" in locals():
                        del dimer
                except Exception:
                    pass
                return None

        # Start with integer separations: 1.0, 2.0, 3.0, 4.0, ...
        last_successful_separation = None
        last_successful_energy = None

        separation = initial_separation
        while separation <= max_separation:
            energy = _try_dimer_energy(separation)
            if energy is not None:
                last_successful_separation = separation
                last_successful_energy = energy
                separation += 1.0
            else:
                break

        if last_successful_separation is None:
            raise RuntimeError(
                f"Failed to obtain isolated energy for {symbol} - no separations worked"
            )

        # refine
        current_separation = last_successful_separation
        previous_phase_energy = last_successful_energy

        for decimal_place in range(1, separation_neg_exponent + 1):

            step_size = 10 ** (-decimal_place)

            for i in range(1, 10):
                test_sep = current_separation + step_size
                energy = _try_dimer_energy(test_sep)

                if energy is not None:
                    current_separation = test_sep
                    last_successful_energy = energy
                else:
                    break

            if quit_early_after_convergence:
                energy_diff = abs(last_successful_energy - previous_phase_energy)
                if energy_diff <= energy_tolerance:
                    return last_successful_energy

            previous_phase_energy = last_successful_energy

        return last_successful_energy


################################################################################
def rescale_to_get_nonzero_energy(atoms, isolated_energy_per_atom, etol):
    """
    If the given configuration has a potential energy, relative to the sum of the
    isolated energy corresponding to each atom present, smaller in magnitude than 'etol'
    (presumably because the distance between atoms is too large), rescale it making it
    smaller.  The 'isolated_energy_per_atom' arg should be a dict containing an entry
    for each atomic species present in the atoms object
    (additional entries are ignored).
    """
    num_atoms = len(atoms)
    if num_atoms < 2:
        # If we're only using a single atom, then we need to make sure that the cell is
        # periodic along at least one direction
        if num_atoms == 1:
            pbc = atoms.get_pbc()
            if not any(pbc):
                raise RuntimeError(
                    "ERROR: If only a single atom is present, the cell must "
                    "be periodic along at least one direction."
                )
        else:
            raise RuntimeError(
                "ERROR: Invalid configuration. Must have at least one atom"
            )

    if not isinstance(isolated_energy_per_atom, dict):
        raise ValueError(
            "Argument 'isolated_energy_per_atom' passed to "
            "rescale_to_get_nonzero_energy must be a dict containing and entry "
            "for each atomic species present in the atoms object."
        )

    # Check for any flat directions in the initial configuration and ignore them when
    # determining whether to stop rescaling
    pmin = atoms.get_positions().min(axis=0)  # minimum x,y,z coordinates
    pmax = atoms.get_positions().max(axis=0)  # maximum x,y,z coordinates
    delp = pmax - pmin  # system extent across x, y, z
    flat = [(extent <= np.finfo(extent).tiny) for extent in delp]

    # Compute the "trivial energy", i.e. the energy assuming none of the atoms interact
    # with each other at all
    species_of_each_atom = atoms.get_chemical_symbols()
    energy_trivial = 0.0
    for atom_species in species_of_each_atom:
        energy_trivial += isolated_energy_per_atom[atom_species]

    # Rescale cell and atoms
    cell = atoms.get_cell()
    energy = atoms.get_potential_energy()
    adjusted_energy = energy - energy_trivial
    if abs(adjusted_energy) <= etol:
        pmin = atoms.get_positions().min(axis=0)  # minimum x,y,z coordinates
        pmax = atoms.get_positions().max(axis=0)  # maximum x,y,z coordinates
        extent_along_nonflat_directions = [
            extent for direction, extent in enumerate(delp) if not flat[direction]
        ]
        delpmin = min(extent_along_nonflat_directions)
        while delpmin > np.finfo(delpmin).tiny:
            atoms.positions *= 0.5  # make configuration half the size
            cell *= 0.5  # make cell half the size
            atoms.set_cell(cell)  # need to adjust cell in case it's periodic
            delpmin *= 0.5
            energy = atoms.get_potential_energy()
            adjusted_energy = energy - energy_trivial
            if abs(adjusted_energy) > etol:
                return  # success!

        # Get species and write out error
        raise RuntimeError(
            "ERROR: Unable to scale configuration down to nonzero energy.  This was "
            "determined by computing the total potential energy relative to the sum of "
            "the isolated energy corresponding to each atom present and checking if "
            "the magnitude of the difference was larger than the supplied tolerance of "
            "{} eV.  This may mean that the species present in the cell ({}) do not "
            "have a non-trivial energy interaction for the  potential being used."
            "".format(etol, set(species_of_each_atom))
        )


################################################################################
def check_if_atoms_interacting_energy(model, symbols, etol):
    """
    First, get the energy of a single isolated atom of each species given in 'symbols'.
    Then, construct a dimer consisting of these two species and try to decrease its bond
    length until a discernible difference in the energy (from the sum of the isolated
    energy of each species) is detected.  The 'symbols' arg should be a list or tuple of
    length 2 indicating which species pair to check, e.g. to check if Al interacts with
    Al, one should specify ['Al', 'Al'].
    """
    if not isinstance(symbols, (list, tuple)) or len(symbols) != 2:
        raise ValueError(
            "Argument 'symbols' passed to check_if_atoms_interacting_energy "
            "must be a list of tuple of length 2 indicating the species pair to "
            "check"
        )

    isolated_energy_per_atom = {}
    isolated_energy_per_atom[symbols[0]] = get_isolated_energy_per_atom(
        model, symbols[0]
    )
    isolated_energy_per_atom[symbols[1]] = get_isolated_energy_per_atom(
        model, symbols[1]
    )

    dimer = Atoms(
        symbols,
        positions=[(0.1, 0.1, 0.1), (5.1, 0.1, 0.1)],
        cell=(20, 20, 20),
        pbc=(False, False, False),
    )
    calc = KIM(model)
    dimer.calc = calc
    try:
        rescale_to_get_nonzero_energy(dimer, isolated_energy_per_atom, etol)
        atoms_interacting = True
        return atoms_interacting
    except:  # noqa: E722
        atoms_interacting = False
        return atoms_interacting
    finally:
        if hasattr(calc, "clean"):
            calc.clean()
        if hasattr(calc, "__del__"):
            calc.__del__()
        del dimer


################################################################################
def check_if_atoms_interacting_force(model, symbols, ftol):
    """
    Construct a dimer and try to decrease its bond length until the force acting on each
    atom is larger than 'ftol' in magnitude.  The 'symbols' arg should be a list or
    tuple of length 2 indicating which species pair to check, e.g. to check if Al
    interacts with Al, one should specify ['Al', 'Al'].
    """
    if not isinstance(symbols, (list, tuple)) or len(symbols) != 2:
        raise ValueError(
            "Argument 'symbols' passed to check_if_atoms_interacting_force "
            "must be a list of tuple of length 2 indicating the species pair to "
            "check"
        )

    dimer = Atoms(
        symbols,
        positions=[(0.1, 0.1, 0.1), (5.1, 0.1, 0.1)],
        cell=(20, 20, 20),
        pbc=(False, False, False),
    )
    calc = KIM(model)
    dimer.calc = calc
    try:
        rescale_to_get_nonzero_forces(dimer, ftol)
        atoms_interacting = True
        return atoms_interacting
    except:  # noqa: E722
        atoms_interacting = False
        return atoms_interacting
    finally:
        if hasattr(calc, "clean"):
            calc.clean()
        if hasattr(calc, "__del__"):
            calc.__del__()
        del dimer


################################################################################
def check_if_atoms_interacting(
    model, symbols, check_energy=True, etol=1e-6, check_force=True, ftol=1e-3
):
    """
    Check to see whether non-trivial energy and/or forces can be detected using the
    current model.  The 'symbols' arg should be a list or tuple of length 2 indicating
    which species pair to check, e.g. to check if Al interacts with Al, one should
    specify ['Al', 'Al'].
    """
    if check_energy and not check_force:
        return check_if_atoms_interacting_energy(model, symbols, etol)
    elif not check_energy and check_force:
        return check_if_atoms_interacting_force(model, symbols, ftol)

    elif check_energy and check_force:
        atoms_interacting_energy = check_if_atoms_interacting_energy(
            model, symbols, etol
        )
        atoms_interacting_force = check_if_atoms_interacting_force(model, symbols, ftol)
        return atoms_interacting_energy, atoms_interacting_force


################################################################################
def rescale_to_get_nonzero_forces(atoms, ftol):
    """
    If the given configuration has force components which are all smaller in absolute
    value than 'ftol' (presumably because the distance between atoms is too large),
    rescale it to be smaller until the largest force component in absolute value is
    greater than or equal to 'ftol'.  In a perfect crystal, the crystal is rescaled
    until the atoms on the surface reach the minimum value (internal atoms padded with
    another atoms around them will have zero force).  Note that any periodicity is
    turned off for the rescaling and then restored at the end.
    """
    if len(atoms) < 2:
        raise KIMASEError(
            "ERROR: Invalid configuration. Must have at least 2 atoms. Number of atoms "
            "= {}".format(len(atoms))
        )

    # Check for any flat directions in the initial configuration and ignore them when
    # determining whether to stop rescaling
    pmin = atoms.get_positions().min(axis=0)  # minimum x,y,z coordinates
    pmax = atoms.get_positions().max(axis=0)  # maximum x,y,z coordinates
    delp = pmax - pmin  # system extent across x, y, z
    flat = [(extent <= np.finfo(extent).tiny) for extent in delp]

    # Temporarily turn off any periodicity
    pbc_save = atoms.get_pbc()
    cell = atoms.get_cell()
    atoms.set_pbc([False, False, False])
    # Rescale cell and atoms
    forces = atoms.get_forces()
    if np.isnan(forces).any():
        raise RuntimeError("ERROR: Computed forces include at least one nan.")
    fmax = max(abs(forces.min()), abs(forces.max()))  # find max in abs value
    if fmax < ftol:
        pmin = atoms.get_positions().min(axis=0)  # minimum x,y,z coordinates
        pmax = atoms.get_positions().max(axis=0)  # maximum x,y,z coordinates
        extent_along_nonflat_directions = [
            extent for direction, extent in enumerate(delp) if not flat[direction]
        ]
        delpmin = min(extent_along_nonflat_directions)
        while delpmin > np.finfo(delpmin).tiny:
            atoms.positions *= 0.75  # make configuration 3/4 the size
            cell *= 0.75  # make cell 3/4 the size
            delpmin *= 0.75
            forces = atoms.get_forces()  # get max force
            fmax = max(abs(forces.min()), abs(forces.max()))
            if fmax >= ftol:
                # Restore periodicity
                atoms.set_pbc(pbc_save)
                atoms.set_cell(cell)
                return  # success!
        raise KIMASEError(
            "ERROR: Unable to scale configuration down to nonzero forces."
        )
    else:
        # Restore periodicity
        atoms.set_pbc(pbc_save)


################################################################################
def perturb_until_all_forces_sizeable(
    atoms, pert_amp, minfact=0.1, maxfact=5.0, max_iter=1000
):
    """
    Keep perturbing atoms in the ASE 'atoms' object until all force components on each
    atom have an absolute value of least 'minfact' times the largest (in absolute value)
    component across all force vectors coming in.  Note that all atomic coordinates must
    be inside of the corresponding cell along non-periodic dimensions.  Perturbations
    leading to a force component on any atom that is larger than 'maxfact' times the
    largest force component coming in are rejected.  Perturbations leading to atoms
    outside of the span across x, y, and z of the atomic positions coming in or outside
    of the simulation cell along non-periodic directions are also rejected.  The process
    repeats until max_iter iterations have been reached, at which point an exception is
    raised.
    """
    pbc = atoms.get_pbc()

    # Get transformation matrix to get fractional coords
    T = fractional_coords_transformation(atoms.get_cell())

    # First, ensure that all atomic positions are inside of the cell along non-periodic
    # dimensions
    if not all(pbc):
        for at in range(0, len(atoms)):
            # Check if the positional coordinate of this atom is valid to begin
            # with, i.e. it's inside the box along along all non-periodic directions
            if atom_outside_cell_along_nonperiodic_dim(T, atoms[at].position, pbc):
                raise KIMASEError(
                    "ERROR: Determined that atom {} with position {} is outside of the "
                    "simulation cell ({}) along one or more non-periodic directions.  "
                    "In order to prevent atoms from being lost, they must all be "
                    "contained inside of the simulation cell along non-periodic "
                    "dimensions.".format(at, atoms[at].position, atoms.get_cell())
                )

    forces = atoms.get_forces()
    if np.isnan(forces).any():
        raise RuntimeError("ERROR: Computed forces include at least one nan.")
    fmax = max(abs(forces.min()), abs(forces.max()))  # find max in abs value

    if fmax <= 1e2 * np.finfo(float).eps:
        raise KIMASEError(
            "ERROR: Largest force component on configuration is "
            "less than or equal to 1e2*machine epsilon. Cannot proceed."
        )

    pmin = atoms.get_positions().min(axis=0)  # minimum x,y,z coordinates
    pmax = atoms.get_positions().max(axis=0)  # maximum x,y,z coordinates
    saved_posns = atoms.get_positions().copy()
    saved_forces = atoms.get_forces().copy()
    some_forces_too_small = True

    # Counter to enforce max iterations
    iters = 0

    while some_forces_too_small:
        for at in range(0, len(atoms)):
            for dof in range(0, 3):
                if abs(forces[at, dof]) < minfact * fmax:
                    done = False
                    coord = atoms[at].position[dof].copy()
                    while not done:
                        atoms[at].position[dof] += random.uniform(-1.0, 1.0) * pert_amp
                        if (
                            pmin[dof] <= atoms[at].position[dof] <= pmax[dof]
                        ) and not atom_outside_cell_along_nonperiodic_dim(
                            T, atoms[at].position, pbc
                        ):
                            done = True
                        else:
                            atoms[at].position[dof] = coord
        try:
            forces = atoms.get_forces()
            if np.isnan(forces).any():
                raise RuntimeError("ERROR: Computed forces include at least one nan.")
            fmax_new = max(abs(forces.min()), abs(forces.max()))
            if fmax_new > maxfact * fmax:
                # forces too large, abort perturbation
                atoms.set_positions(saved_posns)
                forces = saved_forces.copy()
                continue
            fmin_new = min(abs(forces.min()), abs(forces.max()))
            if fmin_new > minfact * fmax:
                some_forces_too_small = False
        except:  # noqa: E722
            # force calculation failed, abort perturbation
            atoms.set_positions(saved_posns)
            continue
        finally:
            iters = iters + 1
            if iters == max_iter:
                raise KIMASEError(
                    "Maximum iterations ({}) exceeded in call to "
                    "function perturb_until_all_forces_sizeable()".format(max_iter)
                )


################################################################################
def get_model_species_minimum_cutoff(
    model: Union[str, Calculator],
    species: list,
    xtol: float = 1e-8,
    etol_coarse: float = 1e-6,
    etol_fine: float = 1e-15,
    max_bisect_iters: int = 1000,
    max_upper_cutoff_bracket: float = 20.0,
) -> float:
    """
    Given a model and a list of species, construct all permutations of dimers and
    compute their energy-cutoff i.e the distance at which their interaction becomes
    non-trivial. Return the smallest such distance among all pairs of species.
    This method calls get_model_energy_cutoff
    """
    min_cutoff = np.inf
    for i in range(len(species)):
        si: str = species[i]
        Rii: float = get_model_energy_cutoff(
            model,
            [si, si],
            xtol,
            etol_coarse,
            etol_fine,
            max_bisect_iters,
            max_upper_cutoff_bracket,
        )
        min_cutoff = min(min_cutoff, Rii)
        for j in range(i + 1, len(species)):
            sj: str = species[j]
            try:
                Rij: float = get_model_energy_cutoff(
                    model,
                    [sj, si],
                    xtol,
                    etol_coarse,
                    etol_fine,
                    max_bisect_iters,
                    max_upper_cutoff_bracket,
                )
                min_cutoff = min(min_cutoff, Rij)
            except Exception:
                pass
    return min_cutoff


################################################################################
def get_model_energy_cutoff(
    model: Union[str, Calculator],
    symbols: list,
    xtol: float = 1e-8,
    etol_coarse: float = 1e-6,
    etol_fine: float = 1e-15,
    max_bisect_iters: int = 1000,
    max_upper_cutoff_bracket: float = 20.0,
) -> float:
    """
    Compute the distance at which energy interactions become non-trival for a given
    model and a species pair it supports.  This is done by constructing a dimer composed
    of these species in a large finite box, increasing the separation if necessary until
    the total potential energy is within 'etol_fine' of the sum of the corresponding
    isolated energies, and then shrinking the separation until the energy differs from
    that value by more than 'etol_coarse'.  Using these two separations to bound the
    search range, bisection is used to refine in order to locate the cutoff.  The
    'symbols' arg should be a list or tuple of length 2 indicating which species pair to
    check, e.g. to get the energy cutoff of Al with Al, one should specify ['Al', 'Al'].

    This function is based on the content of the DimerContinuityC1__VC_303890932454_002
    Verification Check in OpenKIM [1-3].

    [1] Tadmor E. Verification Check of Dimer C1 Continuity v002. OpenKIM; 2018.
        doi:10.25950/43d2c6d5

    [2] Tadmor EB, Elliott RS, Sethna JP, Miller RE, Becker CA. The potential of
        atomistic simulations and the Knowledgebase of Interatomic Models. JOM.
        2011;63(7):17. doi:10.1007/s11837-011-0102-6

    [3] Elliott RS, Tadmor EB. Knowledgebase of Interatomic Models (KIM) Application
        Programming Interface (API). OpenKIM; 2011. doi:10.25950/ff8f563a
    """
    from scipy.optimize import bisect

    def get_dimer_positions(a, large_cell_len):
        """
        Generate positions for a dimer of length 'a' centered in a finite simulation box
        with side length 'large_cell_len'
        """
        half_cell = 0.5 * large_cell_len
        positions = [
            [half_cell - 0.5 * a, half_cell, half_cell],
            [half_cell + 0.5 * a, half_cell, half_cell],
        ]
        return positions

    def energy(a, dimer, large_cell_len, einf):
        dimer.set_positions(get_dimer_positions(a, large_cell_len))
        return dimer.get_potential_energy() - einf

    def energy_cheat(a, dimer, large_cell_len, offset, einf):
        dimer.set_positions(get_dimer_positions(a, large_cell_len))
        return (dimer.get_potential_energy() - einf) + offset

    if not isinstance(symbols, (list, tuple)) or len(symbols) != 2:
        raise ValueError(
            "Argument 'symbols' passed to check_if_atoms_interacting_energy "
            "must be a list of tuple of length 2 indicating the species pair to "
            "check"
        )

    isolated_energy_per_atom = {}
    isolated_energy_per_atom[symbols[0]] = get_isolated_energy_per_atom(
        model, symbols[0]
    )
    isolated_energy_per_atom[symbols[1]] = get_isolated_energy_per_atom(
        model, symbols[1]
    )
    einf = isolated_energy_per_atom[symbols[0]] + isolated_energy_per_atom[symbols[1]]

    # First, establish the upper bracket cutoff by starting at 'b_init' Angstroms and
    # incrementing by 'db' until
    b_init = 4.0

    # Create finite box of size large_cell_len
    large_cell_len = 50
    dimer = Atoms(
        symbols,
        positions=get_dimer_positions(b_init, large_cell_len),
        cell=(large_cell_len, large_cell_len, large_cell_len),
        pbc=(False, False, False),
    )
    calc = KIM(model)
    dimer.calc = calc

    db = 2.0
    b = b_init - db
    still_interacting = True
    while still_interacting:
        b += db
        if b > max_upper_cutoff_bracket:
            if hasattr(calc, "clean"):
                calc.clean()
            if hasattr(calc, "__del__"):
                calc.__del__()

            raise KIMASEError(
                "Exceeded limit on upper bracket when determining cutoff "
                "search range"
            )
        else:
            eb = energy(b, dimer, large_cell_len, einf)
            if abs(eb) < etol_fine:
                still_interacting = False

    a = b
    da = 0.01
    not_interacting = True
    while not_interacting:
        a -= da
        if a < 0:
            if hasattr(calc, "clean"):
                calc.clean()
            if hasattr(calc, "__del__"):
                calc.__del__()

            raise RuntimeError(
                "Failed to determine lower bracket for cutoff search using etol_coarse "
                "= {}.  This may mean that the species pair provided ({}) does not "
                "have a non-trivial energy interaction for the potential being "
                "used.".format(etol_coarse, symbols)
            )
        else:
            ea = energy(a, dimer, large_cell_len, einf)
            if abs(ea) > etol_coarse:
                not_interacting = False

    # NOTE: Some Simulator Models have a history dependence due to them maintaining
    #       charges from the previous energy evaluation to use as an initial guess
    #       for the next charge equilibration.  We therefore have to treat them not
    #       as single-valued functions but as distributions, i.e.  for a given
    #       configuration you might get any of a range of energy values depending on
    #       the history of your previous energy evaluations.  This is particularly
    #       problematic for this step, where we set up a bisection problem in order
    #       to determine the cutoff radius of the model.  Our solution for this
    #       specific case is to make a very crude estimate of the variance of that
    #       distribution with a 10% factor of safety on it.
    eb_new = energy(b, dimer, large_cell_len, einf)
    eb_error = abs(eb_new - eb)

    # compute offset to ensure that energy before and after cutoff have
    # different signs
    if ea < eb:
        offset = -eb + 1.1 * eb_error + np.finfo(float).eps
    else:
        offset = -eb - 1.1 * eb_error - np.finfo(float).eps

    rcut, results = bisect(
        energy_cheat,
        a,
        b,
        args=(dimer, large_cell_len, offset, einf),
        full_output=True,
        xtol=xtol,
        maxiter=max_bisect_iters,
    )

    # General clean-up
    if hasattr(calc, "clean"):
        calc.clean()
    if hasattr(calc, "__del__"):
        calc.__del__()

    if not results.converged:
        raise RuntimeError(
            "Bisection search to find cutoff distance did not converge "
            "within {} iterations with xtol = {}".format(max_bisect_iters, xtol)
        )
    else:
        return rcut


################################################################################
def generate_fcc_compute_energy(
    model: Union[str, Calculator],
    species: list,
    alat: float,
    seed: Union[int, None] = 13,
) -> tuple[float, int]:
    """
    Construct an FCC lattice large enough to accommodate all species,
    evaluate its energy, and return the energy and size of the supercell.

    Args:
        model: The model name or calculator object.
        species: List of atomic species to be incorporated into the FCC lattice.
        alat: The lattice constant for the FCC lattice.
        seed: Optional random seed for reproducibility during species randomization.

    Returns:
        A tuple containing:
            - The total potential energy of the lattice.
            - The number of unit cells along each side of the supercell.
    """

    ncells_per_side = 2
    while True:
        atoms = FaceCenteredCubic(
            size=(ncells_per_side, ncells_per_side, ncells_per_side),
            latticeconstant=alat,
            symbol="H",
            pbc=False,
        )
        if len(atoms) < len(species):
            ncells_per_side += 1
        else:
            break
    random.seed(seed)
    randomize_species(atoms, species)
    if isinstance(model, str):
        calc = KIM(model)
    else:
        calc = model
    atoms.set_calculator(calc)

    # compute energy
    try:
        pe = atoms.get_potential_energy()
        return pe, ncells_per_side
    except Exception as e:
        raise (e)


################################################################################
def fcc_atoms_in_supercell(n_cells_per_side: int) -> int:
    """
    Compute the number of atoms in an FCC supercell.

    This function calculates the total number of atoms in a Face-Centered Cubic (FCC)
    supercell based on the number of unit cells along each side. In an FCC structure,
    each unit cell contains 4 atoms.

    Args:
        n_cells_per_side: Number of unit cells along each side of the supercell.
                                This represents the size of the supercell.

    Returns:
        The total number of atoms in the FCC supercell.
    """

    atoms_per_unit_cell = 4
    total_unit_cells = n_cells_per_side**3
    total_atoms = total_unit_cells * atoms_per_unit_cell
    return (int)(total_atoms)


################################################################################
def local_edge_detection(x: list, y: list) -> list:
    """
    Computes the Local Edge Detection (LED) values for the x-y curve.
    The algorithm helps to identify discontinuities or abrupt changes in the curve.

    Based on the paper:
    A. Gelb and E. Tadmor, "Local edge detection for non-linear signals,"
    Journal of Scientific Computing, 28:279-306, 2006.

    Args:
        x: A list of x-values representing the independent variable of the curve.
        y: A list of y-values representing the dependent variable of the curve.

    Returns:
        A list of LED values of strength of discontinuities in the x-y curve.

    Notes:
        The LED[i] corresponds to the X[i+2]

    """

    led_values = []

    fact = 1.0 / 6.0
    for j in range(2, len(y) - 3):
        # use 5-th order local difference formula
        led = fact * (
            -y[j - 2]
            + 5 * y[j - 1]
            - 10 * y[j]
            + 10 * y[j + 1]
            - 5 * y[j + 2]
            + y[j + 3]
        )
        led_values.append(led)

    return led_values


################################################################################
def filter_good_alat(
    alats: list,
    energies: list,
    ncells: list,
    leds: list,
    min_cutoff: float,
    etol: list = [5e-2, 5e2],
    led_tol: float = 1.0,
) -> dict:
    """
    Filter a good lattice constant (alat) based on the given criteria.

    This function filters out a valid alat value from the provided lists of alats,
    energies, number of cells, and LED values. The filtering is done based on several
    conditions including the energy bounds, LED tolerance, and minimum cutoff distance.

    Args:
        alats: A list of lattice parameters (alat).
        energies: A list of energy-per-atom values corresponding to each alat.
                          Used to compute the total energy of the FCC structure
                          for filtering.
        ncells: A list of the number of cells corresponding to each alat.
                        Used to compute the number of atoms for filtering by energy.
        leds: A list of LED values corresponding to each alat.
                     Note that leds[i] corresponds to alats[i+2]
        min_cutoff: The minimum cutoff distance for the model-species
                            combination. This is used to filter alats.
        etol: A list containing the minimum and maximum energy-per-atom
                               bounds for filtering. Default is [5e-2, 5e2].
        led_tol: The maximum LED value for filtering. Default is 1.0.

    Returns:
        dict: A dictionary containing the filtered alat value and related properties:
            - 'good_alat': The filtered alat value.
            - 'min_led': The minimum LED value found.
            - 'good_ncells': The number of cells corresponding to the selected alat.

    Notes:
        The following conditions are applied to filter the valid alat:
        - `0.2 * min_cutoff < alat < 0.8 * min_cutoff`
        - `etol[0] < |energy-per-atom| < etol[1]`
        - `|LED| < led_tol`
        - `0 < |LED| < min_led`

    """

    min_led = np.inf
    good_alat = None
    good_ncells: int = 0

    N = len(alats)

    for i in range(2, N - 3):
        alat = alats[i]
        energy = energies[i]
        ncell = ncells[i]
        led = leds[i - 2]  # leds[0] corresponds to alats[2]
        natoms = fcc_atoms_in_supercell(ncell)

        if alat > 0.8 * min_cutoff or alat < 0.2 * min_cutoff:
            continue
        if abs(energy) > etol[1] * natoms or abs(energy) < etol[0] * natoms:
            continue
        if abs(led) > led_tol or abs(led) > min_led:
            continue

        good_alat = alat
        good_ncells = ncell
        min_led = abs(led)

    return {"good_alat": good_alat, "min_led": min_led, "good_ncells": good_ncells}


################################################################################
def find_working_configuration_FCC(
    model: Union[str, Calculator],
    species: list,
    energy_bound: list = [5e-2, 5e2],
    led_tol: float = 1.0,
    seed: Union[int, None] = 13,
) -> dict:
    """
    Find an FCC configuration for a model and species with energy and LED constraints.

    This function constructs an FCC configuration for the specified species and model,
    and filters the configurations based on energy-per-atom and smoothness of the
    energy-alat relation. The energy values must lie within the specified bounds,
    and the local edge detection (LED) must be below the given tolerance.

    Args:
        model: The model name or calculator object used to
                                        compute the energies of the configurations.
        species: atomic species to be incorporated into the FCC structure.
        energy_bound: A list of two values specifying the minimum
                                       and maximum energy-per-atom bounds for filtering.
                                       Default is [5e-2, 5e2].
        led_tol: The tolerance for local edge detection filtering.
                                   Default is 1.0.
        seed: An optional random seed for reproducibility.

    Returns:
        dict: A dictionary containing the working FCC configuration, including the
              optimal lattice constant, number of cells per side, and the
              corresponding LED value.

    Notes:
        - The function first computes the minimum cutoff distance for the species pair.
        - It then attempts different lattice constants and filters the configurations
          based on energy-per-atom and LED values.
        - Only configurations that meet the energy and LED criteria are retained.

    """

    min_cutoff = get_model_species_minimum_cutoff(model, species)
    amax = 1.0 * min_cutoff
    amin = 0.3 * min_cutoff
    del_a = 0.01
    na = int(math.ceil((amax - amin) / del_a))

    alats = []
    energies = []
    ncells = []  # will be used for filtering by energy-per-atom

    for j in range(0, na + 1):
        a = amin + j * del_a
        try:
            val = generate_fcc_compute_energy(model, species, a, seed)
            if val is not None:
                alats.append(a)
                energies.append(val[0])  # first value is energy
                ncells.append(val[1])  # second value is number of atoms

        except Exception:
            continue

    # these LED values correspond to alats[2] ... alats[na-3]
    leds = local_edge_detection(alats, energies)
    return filter_good_alat(
        alats, energies, ncells, leds, min_cutoff, energy_bound, led_tol
    )


################################################################################

# If called directly, do nothing
if __name__ == "__main__":
    pass
