"""
Class interface to the model calculator.

Calling a model is somewhat non-trivial since the functions called depend
on the data type.  For 1D data the *Iq* kernel needs to be called, for
2D data the *Iqxy* kernel needs to be called, and for SESANS data the
*Iq* kernel needs to be called followed by a Hankel transform.  Before
the kernel is called an appropriate *q* calculation vector needs to be
constructed.  This is not the simple *q* vector where you have measured
the data since the resolution calculation will require values beyond the
range of the measured data.  After the calculation the resolution calculator
must be called to return the predicted value for each measured data point.

:class:`DirectModel` is a callable object that takes *parameter=value*
keyword arguments and returns the appropriate theory values for the data.

:class:`DataMixin` does the real work of interpreting the data and calling
the model calculator.  This is used by :class:`DirectModel`, which uses
direct parameter values and by :class:`bumps_model.Experiment` which wraps
the parameter values in boxes so that the user can set fitting ranges, etc.
on the individual parameters and send the model to the Bumps optimizers.
"""
from __future__ import print_function

import numpy as np  # type: ignore

# TODO: fix sesans module
from . import sesans  # type: ignore
from . import weights
from . import resolution
from . import resolution2d
from .details import make_kernel_args, dispersion_mesh
from .product import RADIUS_MODE_ID

# pylint: disable=unused-import
try:
    from typing import Optional, Dict, Tuple
except ImportError:
    pass
else:
    from .data import Data
    from .kernel import Kernel, KernelModel
    from .modelinfo import Parameter, ParameterSet
# pylint: enable=unused-import

def call_kernel(calculator, pars, cutoff=0., mono=False):
    # type: (Kernel, ParameterSet, float, bool) -> np.ndarray
    """
    Call *kernel* returned from *model.make_kernel* with parameters *pars*.

    *cutoff* is the limiting value for the product of dispersion weights used
    to perform the multidimensional dispersion calculation more quickly at a
    slight cost to accuracy. The default value of *cutoff=0* integrates over
    the entire dispersion cube.  Using *cutoff=1e-5* can be 50% faster, but
    with an error of about 1%, which is usually less than the measurement
    uncertainty.

    *mono* is True if polydispersity should be set to none on all parameters.
    """
    mesh = get_mesh(calculator.info, pars, dim=calculator.dim, mono=mono)
    #print("in call_kernel: pars:", list(zip(*mesh))[0])
    call_details, values, is_magnetic = make_kernel_args(calculator, mesh)
    #print("in call_kernel: values:", values)
    return calculator(call_details, values, cutoff, is_magnetic)

def call_Fq(calculator, pars, cutoff=0., mono=False):
    # type: (Kernel, ParameterSet, float, bool) -> np.ndarray
    """
    Like :func:`call_kernel`, but returning F, F^2, R_eff, V_shell, V_form/V_shell.

    For solid objects V_shell is equal to V_form and the volume ratio is 1.

    Use parameter *radius_effective_mode* to select the effective radius
    calculation to use amongst the *radius_effective_modes* list given in the
    model.
    """
    R_eff_type = int(pars.pop(RADIUS_MODE_ID, 1.0))
    mesh = get_mesh(calculator.info, pars, dim=calculator.dim, mono=mono)
    #print("in call_Fq: pars", list(zip(*mesh))[0])
    call_details, values, is_magnetic = make_kernel_args(calculator, mesh)
    #print("in call_Fq: values:", values)
    return calculator.Fq(call_details, values, cutoff, is_magnetic, R_eff_type)

def call_profile(model_info, pars=None):
    # type: (ModelInfo, ParameterSet) -> Tuple[np.ndarray, np.ndarray, Tuple[str, str]]
    """
    Returns the profile *x, y, (xlabel, ylabel)* representing the model.
    """
    if pars is None:
        pars = {}
    args = {}
    for p in model_info.parameters.kernel_parameters:
        if p.length > 1:
            value = np.array([pars.get(p.id+str(j), p.default)
                              for j in range(1, p.length+1)])
        else:
            value = pars.get(p.id, p.default)
        args[p.id] = value
    x, y = model_info.profile(**args)
    return x, y, model_info.profile_axes

def get_mesh(model_info, values, dim='1d', mono=False):
    # type: (ModelInfo, Dict[str, float], str, bool) -> List[Tuple[float, np.ndarray, np.ndarry]]
    """
    Retrieve the dispersity mesh described by the parameter set.

    Returns a list of *(value, dispersity, weights)* with one tuple for each
    parameter in the model call parameters.  Inactive parameters return the
    default value with a weight of 1.0.
    """
    parameters = model_info.parameters
    if mono:
        active = lambda name: False
    elif dim == '1d':
        active = lambda name: name in parameters.pd_1d
    elif dim == '2d':
        active = lambda name: name in parameters.pd_2d
    else:
        active = lambda name: True

    #print("in get_mesh: pars:",[p.id for p in parameters.call_parameters])
    mesh = [_get_par_weights(p, values, active(p.name))
            for p in parameters.call_parameters]
    return mesh

def _get_par_weights(parameter, values, active=True):
    # type: (Parameter, Dict[str, float]) -> Tuple[float, np.ndarray, np.ndarray]
    """
    Generate the distribution for parameter *name* given the parameter values
    in *pars*.

    Uses "name", "name_pd", "name_pd_type", "name_pd_n", "name_pd_sigma"
    from the *pars* dictionary for parameter value and parameter dispersion.
    """
    value = float(values.get(parameter.name, parameter.default))
    npts = values.get(parameter.name+'_pd_n', 0)
    width = values.get(parameter.name+'_pd', 0.0)
    relative = parameter.relative_pd
    if npts == 0 or width == 0.0 or not active:
        # Note: orientation parameters have the viewing angle as the parameter
        # value and the jitter in the distribution, so be sure to set the
        # empty pd for orientation parameters to 0.
        pd = [value if relative or not parameter.polydisperse else 0.0], [1.0]
    else:
        limits = parameter.limits
        disperser = values.get(parameter.name+'_pd_type', 'gaussian')
        nsigma = values.get(parameter.name+'_pd_nsigma', 3.0)
        pd = weights.get_weights(disperser, npts, width, nsigma,
                                 value, limits, relative)
    return value, pd[0], pd[1]

def _vol_pars(model_info, values):
    # type: (ModelInfo, ParameterSet) -> Tuple[np.ndarray, np.ndarray]
    vol_pars = [_get_par_weights(p, values)
                for p in model_info.parameters.call_parameters
                if p.type == 'volume']
    #import pylab; pylab.plot(vol_pars[0][0],vol_pars[0][1]); pylab.show()
    dispersity, weight = dispersion_mesh(model_info, vol_pars)
    return dispersity, weight

def _make_sesans_transform(data):
    from sas.sascalc.data_util.nxsunit import Converter

    # Pre-compute the Hankel matrix (H)
    SElength = Converter(data._xunit)(data.x, "A")

    theta_max = Converter("radians")(data.sample.zacceptance)[0]
    q_max = 2 * np.pi / np.max(data.source.wavelength) * np.sin(theta_max)
    zaccept = Converter("1/A")(q_max, "1/" + data.source.wavelength_unit),

    Rmax = 10000000
    hankel = sesans.SesansTransform(data.x, SElength,
                                    data.source.wavelength,
                                    zaccept, Rmax)
    return hankel

class DataMixin(object):
    """
    DataMixin captures the common aspects of evaluating a SAS model for a
    particular data set, including calculating Iq and evaluating the
    resolution function.  It is used in particular by :class:`DirectModel`,
    which evaluates a SAS model parameters as key word arguments to the
    calculator method, and by :class:`bumps_model.Experiment`, which wraps the
    model and data for use with the Bumps fitting engine.  It is not
    currently used by :class:`sasview_model.SasviewModel` since this will
    require a number of changes to SasView before we can do it.

    :meth:`_interpret_data` initializes the data structures necessary
    to manage the calculations.  This sets attributes in the child class
    such as *data_type* and *resolution*.

    :meth:`_calc_theory` evaluates the model at the given control values.

    :meth:`_set_data` sets the intensity data in the data object,
    possibly with random noise added.  This is useful for simulating a
    dataset with the results from :meth:`_calc_theory`.
    """
    def _interpret_data(self, data, model):
        # type: (Data, KernelModel) -> None
        # pylint: disable=attribute-defined-outside-init

        self._data = data
        self._model = model

        # interpret data
        if hasattr(data, 'isSesans') and data.isSesans:
            self.data_type = 'sesans'
        elif hasattr(data, 'qx_data'):
            self.data_type = 'Iqxy'
        elif getattr(data, 'oriented', False):
            self.data_type = 'Iq-oriented'
        else:
            self.data_type = 'Iq'

        if self.data_type == 'sesans':
            res = _make_sesans_transform(data)
            index = slice(None, None)
            if data.y is not None:
                Iq, dIq = data.y, data.dy
            else:
                Iq, dIq = None, None
        elif self.data_type == 'Iqxy':
            #if not model.info.parameters.has_2d:
            #    raise ValueError("not 2D without orientation or magnetic parameters")
            q = np.sqrt(data.qx_data**2 + data.qy_data**2)
            qmin = getattr(data, 'qmin', 1e-16)
            qmax = getattr(data, 'qmax', np.inf)
            accuracy = getattr(data, 'accuracy', 'Low')
            index = (data.mask == 0) & (q >= qmin) & (q <= qmax)
            if data.data is not None:
                index &= ~np.isnan(data.data)
                Iq = data.data[index]
                dIq = data.err_data[index]
            else:
                Iq, dIq = None, None
            res = resolution2d.Pinhole2D(data=data, index=index,
                                         nsigma=3.0, accuracy=accuracy)
        elif self.data_type == 'Iq':
            index = (data.x >= data.qmin) & (data.x <= data.qmax)
            mask = getattr(data, 'mask', None)
            if mask is not None:
                index &= (mask == 0)
            if data.y is not None:
                index &= ~np.isnan(data.y)
                Iq = data.y[index]
                dIq = data.dy[index]
            else:
                Iq, dIq = None, None
            if getattr(data, 'dx', None) is not None:
                q, dq = data.x[index], data.dx[index]
                if (dq > 0).any():
                    res = resolution.Pinhole1D(q, dq)
                else:
                    res = resolution.Perfect1D(q)
            elif (getattr(data, 'dxl', None) is not None
                  and getattr(data, 'dxw', None) is not None):
                res = resolution.Slit1D(data.x[index],
                                        qx_width=data.dxl[index],
                                        qy_width=data.dxw[index])
            else:
                res = resolution.Perfect1D(data.x[index])
        elif self.data_type == 'Iq-oriented':
            index = (data.x >= data.qmin) & (data.x <= data.qmax)
            if data.y is not None:
                index &= ~np.isnan(data.y)
                Iq = data.y[index]
                dIq = data.dy[index]
            else:
                Iq, dIq = None, None
            if (getattr(data, 'dxl', None) is None
                    or getattr(data, 'dxw', None) is None):
                raise ValueError("oriented sample with 1D data needs slit resolution")

            res = resolution2d.Slit2D(data.x[index],
                                      qx_width=data.dxw[index],
                                      qy_width=data.dxl[index])
        else:
            raise ValueError("Unknown data type") # never gets here

        # Remember function inputs so we can delay loading the function and
        # so we can save/restore state
        self._kernel = None
        self.Iq, self.dIq, self.index = Iq, dIq, index
        self.resolution = res
        self.results = None  # type: Optional[Callable[[], OrderedDict]

    def _set_data(self, Iq, noise=None):
        # type: (np.ndarray, Optional[float]) -> None
        # pylint: disable=attribute-defined-outside-init
        if noise is not None:
            self.dIq = Iq*noise*0.01
        dy = self.dIq
        y = Iq + np.random.randn(*dy.shape) * dy
        self.Iq = y
        if self.data_type in ('Iq', 'Iq-oriented'):
            if self._data.y is None:
                self._data.y = np.empty(len(self._data.x), 'd')
            if self._data.dy is None:
                self._data.dy = np.empty(len(self._data.x), 'd')
            self._data.dy[self.index] = dy
            self._data.y[self.index] = y
        elif self.data_type == 'Iqxy':
            if self._data.data is None:
                self._data.data = np.empty_like(self._data.qx_data, 'd')
            if self._data.err_data is None:
                self._data.err_data = np.empty_like(self._data.qx_data, 'd')
            self._data.data[self.index] = y
            self._data.err_data[self.index] = dy
        elif self.data_type == 'sesans':
            if self._data.y is None:
                self._data.y = np.empty(len(self._data.x), 'd')
            self._data.y[self.index] = y
        else:
            raise ValueError("Unknown model")

    def _calc_theory(self, pars, cutoff=0.0):
        # type: (ParameterSet, float) -> np.ndarray
        if self._kernel is None:
            # TODO: change interfaces so that resolution returns kernel inputs
            # Maybe have resolution always return a tuple, or maybe have
            # make_kernel accept either an ndarray or a pair of ndarrays.
            kernel_inputs = self.resolution.q_calc
            if isinstance(kernel_inputs, np.ndarray):
                kernel_inputs = (kernel_inputs,)
            self._kernel = self._model.make_kernel(kernel_inputs)

        # Need to pull background out of resolution for multiple scattering
        default_background = self._model.info.parameters.common_parameters[1].default
        background = pars.get('background', default_background)
        pars = pars.copy()
        pars['background'] = 0.

        Iq_calc = call_kernel(self._kernel, pars, cutoff=cutoff)
        self.results = getattr(self._kernel, 'results', None)
        # Storing the calculated Iq values so that they can be plotted.
        # Only applies to oriented USANS data for now.
        # TODO: extend plotting of calculate Iq to other measurement types
        # TODO: refactor so we don't store the result in the model
        self.Iq_calc = Iq_calc
        result = self.resolution.apply(Iq_calc)
        if hasattr(self.resolution, 'nx'):
            self.Iq_calc = (
                self.resolution.qx_calc, self.resolution.qy_calc,
                np.reshape(Iq_calc, (self.resolution.ny, self.resolution.nx))
            )
        return result + background

class DirectModel(DataMixin):
    """
    Create a calculator object for a model.

    *data* is 1D SAS, 2D SAS or SESANS data

    *model* is a model calculator return from :func:`generate.load_model`

    *cutoff* is the polydispersity weight cutoff.
    """
    def __init__(self, data, model, cutoff=1e-5):
        # type: (Data, KernelModel, float) -> None
        self.model = model
        self.cutoff = cutoff
        # Note: _interpret_data defines the model attributes
        self._interpret_data(data, model)

    def __call__(self, **pars):
        # type: (**float) -> np.ndarray
        return self._calc_theory(pars, cutoff=self.cutoff)

    def simulate_data(self, noise=None, **pars):
        # type: (Optional[float], **float) -> None
        """
        Generate simulated data for the model.
        """
        Iq = self.__call__(**pars)
        self._set_data(Iq, noise=noise)

    def profile(self, **pars):
        # type: (**float) -> None
        """
        Generate a plottable profile.
        """
        return call_profile(self.model.info, pars)

def main():
    # type: () -> None
    """
    Program to evaluate a particular model at a set of q values.
    """
    import sys
    from .data import empty_data1D, empty_data2D
    from .core import load_model_info, build_model

    if len(sys.argv) < 3:
        print("usage: python -m sasmodels.direct_model modelname (q|qx,qy) par=val ...")
        sys.exit(1)
    model_name = sys.argv[1]
    call = sys.argv[2].upper()
    try:
        values = [float(v) for v in call.split(',')]
    except ValueError:
        values = []
    if len(values) == 1:
        q, = values
        data = empty_data1D([q])
    elif len(values) == 2:
        qx, qy = values
        data = empty_data2D([qx], [qy])
    else:
        print("use q or qx,qy")
        sys.exit(1)

    model_info = load_model_info(model_name)
    model = build_model(model_info)
    calculator = DirectModel(data, model)
    pars = dict((k, (float(v) if not k.endswith("_pd_type") else v))
                for pair in sys.argv[3:]
                for k, v in [pair.split('=')])
    Iq = calculator(**pars)
    print(Iq[0])

if __name__ == "__main__":
    main()


#!/usr/bin/python

# imports
import sys
import os
sys.path.append(os.path.join('..', 'core'))
sys.path.append(os.path.join('..', 'utils'))
import numpy as np
from datetime import datetime
# import my classes
from project import Project
#from dataReaderATS import DataReaderATS
#from dataReaderSpam import DataReaderSPAM
#from dataReaderInternal import DataReaderInternal
from calibrator import Calibrator
from decimationParameters import DecimationParams
from windowParameters import WindowParams
from decimator import Decimator
from windower import Windower
from spectrumCalculator import SpectrumCalculator
from spectrumWriter import SpectrumWriter
# utilities
from utilsPlotter import *
from utilsProcess import *

# calculate spectra for the project
# the philosophy here is that spectra is calculated out for all data
# and then later limited using statistics and time constraints
def projectSpecCalc(proj, **kwargs):
	generalPrint("ProjectSpecCalc", "Calculating spectra for project with options: {}".format(kwargs))
	# default options
	options = parseKeywords(getDefaultOptions(proj), kwargs)

	# calculate the spectra
	# get the reference time
	datetimeRef = proj.getRefTime()
	# prepare the calibrator
	cal = Calibrator(proj.getCalDataPath())
	if options["calibrate"]:
		cal.printInfo()

	# loop over sites
	for s in options["sites"]:
		# print site info
		proj.printSiteInfo(s)

		# get measurement directories for site s
		timeMeas = proj.getSiteTimeFiles(s)

		# loop over measurement folders and calculate spectra for each one
		for meas in timeMeas:
			# get measurement sample frequency
			fs = proj.getMeasSampleFreq(s, meas)
			# check to see if in given frequency list
			if int(fs) not in options["freqs"]:
				continue

			# print measurement info
			proj.printMeasInfo(s, meas)

			# get measurement start and end times
			datetimeStart = proj.getMeasStartTime(s, meas)
			datetimeEnd = proj.getMeasEndTime(s, meas)

			# get data, sensor info, serial info, chopper info for calibration
			reader = proj.getMeasDataReader(s, meas)
			# get data start and end times - these may not be equal to startDate and endDate
			# there is the issue of ats data recording end time as one sample late
			# hence get the actual end time
			dataStartTime, dataEndTime = reader.getDataTimes(datetimeStart, datetimeEnd)
			dataChans = reader.getChannels()
			if len(options["chans"]) > 0:
				dataChans = options["chans"]
			# alternatively, could simply do getPhysicalSamples() and get all data that way
			data = reader.getPhysicalData(dataStartTime, dataEndTime, chans=dataChans)

			if options["calibrate"]:
				# do the calibration here
				sensors = reader.getSensors(dataChans)
				serials = reader.getSerials(dataChans)
				choppers = reader.getChoppers(dataChans)
				data = cal.calibrate(data, fs, sensors, serials, choppers)

			# notch filter if required
			for n in options["notch"]:
				for c in data:
					data[c] = notchFilter(data[c], fs, n, n/5.0)

			# define decimation parameters
			decParams = DecimationParams(fs)
			if len(options["evalfreq"]) == 0:
				decParams.setDecimationParams(options["declevels"], options["freqlevel"])
			else:
				decParams.setFrequencyParams(options["evalfreq"], options["declevels"], options["freqlevel"])
			decParams.printInfo()
			numLevels = decParams.getNumLevels()

			# now do window parameters
			winParams = WindowParams(decParams)
			# winParams.printInfo()

			# create the decimator
			dec = Decimator(data, fs, decParams)
			# dec.printInfo()

			# loop through decimation levels
			for iDec in xrange(0, numLevels):
				# get the data for the current level
				check = dec.incrementLevel()
				if not check:
					break # not enough data
				#dec.printInfo()
				data = dec.getData()

				# create the windower and give it window parameters for current level
				fsDec = dec.getSampleFreq()
				win = Windower(datetimeRef, dataStartTime, data, fsDec, winParams.getWindowSize(iDec), winParams.getOverlap(iDec))
				numWindows = win.getNumWindows()
				# win.printInfo()
				if numWindows < 2:
					break # do no more decimation

				# create the spectrum calculator and statistics calculators
				specCalc = SpectrumCalculator(fsDec, winParams.getWindowSize(iDec))

				# get ready a file to save the spectra
				specWrite = SpectrumWriter(proj.getSpecDataPathMeas(s, meas), datetimeRef)
				specWrite.openBinaryForWriting("spectra", iDec, fsDec, winParams.getWindowSize(iDec),
					winParams.getOverlap(iDec), win.getGlobalWindowOffset(), numWindows, dataChans)

				# loop though windows, calculate spectra and save
				for iW in xrange(0, numWindows):
					# get the window data
					winData = win.getData(iW)

					# calculate spectra
					f, specData = specCalc.calcFourierCoeff(winData)

					# write out spectra
					specWrite.writeBinary(specData, iW)

				# close spectra and stat files
				specWrite.closeFile()

def getDefaultOptions(proj):
	# default options
	default = {}
	default["sites"] = proj.getAllSites()
	default["freqs"] = proj.getAllSampleFreq()
	default["chans"] = []
	default["evalfreq"] = []
	default["declevels"] = 7
	default["freqlevel"] = 6
	default["calibrate"] = True
	default["notch"] = []
	return default

def parseKeywords(default, keywords):
	# check user options
	for w in default:
		if w in keywords:
			default[w] = keywords[w]
	return default


import os
import ctypes as ct
import numpy as np
from gsl_wrappers import GSLSpline, GSLSpline2d, BICUBIC, BILINEAR
import sys

c_dbl_array = np.ctypeslib.ndpointer(dtype=np.float64, ndim=1, flags="C_CONTIGUOUS")
c_int_array = np.ctypeslib.ndpointer(dtype=np.int, ndim=1, flags="C_CONTIGUOUS")

if sys.version_info[0] < 3:
    ascii_string = ct.c_char_p
else:
    class ascii_string(object):
        @classmethod
        def from_param(cls, value):
            if isinstance(value, bytes):
                return value
            else:
                return value.encode('ascii')

class c_limber_config(ct.Structure):
    _fields_ = [
        ("xlog", ct.c_bool),
        ("ylog", ct.c_bool),
        ("n_ell", ct.c_int),
        ("ell", ct.POINTER(ct.c_double)),
        ("prefactor", ct.c_double),
        ("status", ct.c_int),
       #("absolute_tolerance", ct.c_double),
        #("relative_tolerance", ct.c_double),
]

LIMBER_STATUS_OK =  0
LIMBER_STATUS_ZERO =  1
LIMBER_STATUS_NEGATIVE =  2
LIMBER_STATUS_ERROR =  3

c_gsl_spline = ct.c_void_p

dirname = os.path.split(__file__)[0]
#lib = ct.cdll.LoadLibrary(os.path.join(dirname, "../../shear/spectra/interface.so"))
lib = ct.cdll.LoadLibrary(os.path.join(dirname, "src/spec_tools.so"))
lib.get_named_w_spline.restype = c_gsl_spline
lib.get_named_w_spline.argtypes = [ct.c_size_t, ascii_string, ct.c_int, c_dbl_array, ct.c_double, ct.c_void_p]

lib.get_named_w2_spline.restype = c_gsl_spline #Assign a ctypes type to specify the result type of the foreign function. Use None for void, a function not returning anything.
lib.get_named_w2_spline.argtypes = [ct.c_size_t, ascii_string, ct.c_int, c_dbl_array, ct.c_double, ct.c_void_p] #Assign a tuple of ctypes types to specify the argument types that the function accepts

lib.get_named_w3_spline.restype = c_gsl_spline #Assign a ctypes type to specify the result type of the foreign function. Use None for void, a function not returning anything.
lib.get_named_w3_spline.argtypes = [ct.c_size_t, ascii_string, ct.c_int, c_dbl_array, ct.c_double, ct.c_void_p] #Assign a tuple of ctypes types to specify the argument types that the function accepts

lib.get_named_nchi_spline.restype = c_gsl_spline
lib.get_named_nchi_spline.argtypes = [ct.c_size_t, ascii_string, ct.c_int, c_dbl_array, ct.c_void_p, ct.c_void_p]

#lib.get_named_Dchi_spline.restype = c_gsl_spline
#lib.get_named_Dchi_spline.argtypes = [ct.c_size_t, ascii_string, ct.c_int, c_dbl_array, ct.c_void_p, ct.c_void_p]

#lib.get_reduced_kernel.restype = c_gsl_spline
#lib.get_reduced_kernel.argtypes = [ct.c_void_p, ct.c_void_p]

lib.cmb_wl_kappa_kernel.restype = c_gsl_spline
lib.cmb_wl_kappa_kernel.argtypes = [ct.c_double, ct.c_double, c_gsl_spline]

lib.get_kernel_peak.restype = ct.c_double
lib.get_kernel_peak.argtypes = [ct.c_void_p, ct.c_void_p, ct.c_int]

#lib.limber_integral.restype = ct.c_int
#lib.limber_integral.argtypes = [ct.POINTER(c_limber_config), ct.c_void_p, ct.c_void_p,
#                                ct.c_void_p, ct.c_void_p, ct.c_int, c_dbl_array]

lib.limber_integral.restype = c_gsl_spline
#lib.limber_integral.restype = ct.c_int
#for f(k,z)
#lib.limber_integral.argtypes = [ct.POINTER(c_limber_config), ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p]
# for D(k,z) and f(k,z)
#lib.limber_integral.argtypes = [ct.POINTER(c_limber_config), ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p]
# for D(k,z) and f(k,z) and b(k,z)
#lib.limber_integral.argtypes = [ct.POINTER(c_limber_config), ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p]
lib.limber_integral.argtypes = [ct.POINTER(c_limber_config), ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p]

#lib.limber_integral_rsd.restype = ct.c_int
#lib.limber_integral_rsd.argtypes = [ct.POINTER(c_limber_config), ct.c_void_p, ct.c_void_p,
#                                    ct.c_void_p, ct.c_void_p, ct.c_void_p, ct.c_void_p,
#                                    ct.c_int, ct.c_int, c_dbl_array]

lib.load_interpolator_chi.restype = ct.c_void_p
lib.load_interpolator_chi.argtypes = [ct.c_size_t, ct.c_void_p, ascii_string, ascii_string, ascii_string, ascii_string]

#for f(k,z)

lib.load_interpolator_chi2.restype = ct.c_void_p
lib.load_interpolator_chi2.argtypes = [ct.c_size_t, ct.c_void_p, ct.c_void_p, ascii_string, ascii_string, ascii_string, ascii_string]

#for D(k,z)

lib.load_interpolator_chi3.restype = ct.c_void_p
lib.load_interpolator_chi3.argtypes = [ct.c_size_t, ct.c_void_p, ct.c_void_p, ascii_string, ascii_string, ascii_string, ascii_string]

#for b(k,z)

lib.load_interpolator_chi4.restype = ct.c_void_p
lib.load_interpolator_chi4.argtypes = [ct.c_size_t, ct.c_void_p, ct.c_void_p, ascii_string, ascii_string, ascii_string, ascii_string]

c_power_scaling_function = ct.CFUNCTYPE(ct.c_double, ct.c_double,
    ct.c_double, ct.c_double, ct.c_voidp)

lib.load_interpolator_chi_function.restype = ct.c_void_p
lib.load_interpolator_chi_function.argtypes = [ct.c_size_t, ct.c_void_p,
ascii_string, ascii_string, ascii_string, ascii_string, c_power_scaling_function, ct.c_void_p]

#for f(k,z)

lib.load_interpolator_chi_function2.restype = ct.c_void_p
lib.load_interpolator_chi_function2.argtypes = [ct.c_size_t, ct.c_void_p, ct.c_void_p,
ascii_string, ascii_string, ascii_string, ascii_string, c_power_scaling_function, ct.c_void_p]

#for D(k,z)

lib.load_interpolator_chi_function3.restype = ct.c_void_p
lib.load_interpolator_chi_function3.argtypes = [ct.c_size_t, ct.c_void_p, ct.c_void_p,
ascii_string, ascii_string, ascii_string, ascii_string, c_power_scaling_function, ct.c_void_p]

#for b(k,z)

lib.load_interpolator_chi_function4.restype = ct.c_void_p
lib.load_interpolator_chi_function4.argtypes = [ct.c_size_t, ct.c_void_p, ct.c_void_p,
ascii_string, ascii_string, ascii_string, ascii_string, c_power_scaling_function, ct.c_void_p]

lib.interp_2d.restype = ct.c_double
lib.interp_2d.argtypes = [ct.c_double, ct.c_double, ct.c_void_p]

lib.destroy_interp_2d.restype = None
lib.destroy_interp_2d.argtypes = [ct.c_void_p]

def get_cmb_kappa_spline(chi_max, chi_star, a_of_chi):
    "Compute the CMB WL kernel W_cmb(chi) spline"
    return GSLSpline(lib.cmb_wl_kappa_kernel(chi_max, chi_star, a_of_chi))

def evaluate_power(power, k, z):
    return lib.interp_2d(k,z,power)

def free_power(power):
    try:
        lib.destroy_interp_2d(power)
    except ct.ArgumentError as e:
        power.__del__()

def get_named_nchi_spline(block, section, nbin, z, a_of_chi, chi_of_z):
    return GSLSpline(lib.get_named_nchi_spline(block._ptr, section, nbin, z, a_of_chi, chi_of_z))

#def get_named_Dchi_spline(block, section, nbin, z, a_of_chi, chi_of_z):
#    return GSLSpline(lib.get_named_Dchi_spline(block._ptr, section, nbin, z, a_of_chi, chi_of_z))

def get_named_w_spline(block, section, bin, z, chi_max, a_of_chi):
    "Compute a galcl kernel W(chi) spline"
    return GSLSpline(lib.get_named_w_spline(block._ptr, section, bin, z, chi_max, a_of_chi))

def get_named_w2_spline(block, section, bin, z, chi_max, a_of_chi):
    "Compute a galcl kernel W2(chi) spline"
    return GSLSpline(lib.get_named_w2_spline(block._ptr, section, bin, z, chi_max, a_of_chi))

def get_named_w3_spline(block, section, bin, z, chi_max, a_of_chi):
    "Compute a galcl kernel W3(chi) spline"
    return GSLSpline(lib.get_named_w3_spline(block._ptr, section, bin, z, chi_max, a_of_chi))

def load_power_chi(block, chi_of_z, section, k_name, z_name, p_name):
    "Load P(k,z) and convert z -> chi"
    r = lib.load_interpolator_chi(block._ptr, chi_of_z, section, k_name, z_name, p_name)
    if not r:
        raise ValueError("Could not load power spectrum from section {0} (k:{1} z:{2} p:{3})".format(section, k_name, z_name, p_name))
    return r

def load_power_chi2(block, chi_of_z, z_of_chi, section, k_name, z_name, t_name):
    "Load T(k,z) and convert z -> chi -> z(chi)"
    rr = lib.load_interpolator_chi2(block._ptr, chi_of_z, z_of_chi, section, k_name, z_name, t_name)
    if not rr:
        raise ValueError("Could not load growth rate f from section {0} (k:{1} z:{2} p:{3})".format(section, k_name, z_name, t_name))
    return rr

def load_power_chi3(block, chi_of_z, z_of_chi, section, k_name, z_name, p_name):
    "Load p(k,z) and convert z -> chi -> z(chi)"
    rrr = lib.load_interpolator_chi3(block._ptr, chi_of_z, z_of_chi, section, k_name, z_name, p_name)
    if not rrr:
        raise ValueError("Could not load growth factor D from section {0} (k:{1} z:{2} p:{3})".format(section, k_name, z_name, p_name))
    return rrr

def load_power_chi4(block, chi_of_z, z_of_chi, section, k_name, z_name, p_name):
    "Load p(k,z) and convert z -> chi -> z(chi)"
    rrrr = lib.load_interpolator_chi4(block._ptr, chi_of_z, z_of_chi, section, k_name, z_name, p_name)
    if not rrrr:
        raise ValueError("Could not load galaxy bias b from section {0} (k:{1} z:{2} p:{3})".format(section, k_name, z_name, p_name))
    return rrrr

def load_power_chi_function(block, chi_of_z, section, k_name, z_name, p_name, function, args):
    "Load P(k,z) and convert z -> chi and scale P->f(k,z)*P"
    r = lib.load_interpolator_chi_function(block._ptr, chi_of_z, section, k_name, z_name, p_name, function, args)
    if not r:
        raise ValueError("Could not load scaled power spectrum from section {0} (k:{1} z:{2} p:{3})".format(section, k_name, z_name, p_name))
    return r

def load_power_chi_function2(block, chi_of_z, z_of_chi, section, k_name, z_name, t_name, function, args):
    "Load T(k,z) and convert z -> chi and scale T->f(k,z)*T"
    rr = lib.load_interpolator_chi_function2(block._ptr, chi_of_z, z_of_chi, section, k_name, z_name, t_name, function, args)
    if not rr:
        raise ValueError("Could not load scaled growth rate f from section {0} (k:{1} z:{2} p:{3})".format(section, k_name, z_name, t_name))
    return rr

def load_power_chi_function3(block, chi_of_z, z_of_chi, section, k_name, z_name, p_name, function, args):
    "Load pnl(k,z) and convert z -> chi and scale pnl->f(k,z)*pnl"
    rrr = lib.load_interpolator_chi_function3(block._ptr, chi_of_z, z_of_chi, section, k_name, z_name, p_name, function, args)
    if not rrr:
        raise ValueError("Could not load scaled growth factor D from section {0} (k:{1} z:{2} p:{3})".format(section, k_name, z_name, p_name))
    return rrr

def load_power_chi_function4(block, chi_of_z, z_of_chi, section, k_name, z_name, p_name, function, args):
    "Load pnl(k,z) and convert z -> chi and scale pnl->f(k,z)*pnl"
    rrrr = lib.load_interpolator_chi_function4(block._ptr, chi_of_z, z_of_chi, section, k_name, z_name, p_name, function, args)
    if not rrrr:
        raise ValueError("Could not load scaled galaxy bias b from section {0} (k:{1} z:{2} p:{3})".format(section, k_name, z_name, p_name))
    return rrrr

def get_kernel_peak(WbX, WbY, nchi=500): #changed but not used
    "Get chi of maximum of kernel"
    return lib.get_kernel_peak(WbX, WbY, nchi)

def limber(WbX, WfX, WmX, WbY, WfY, WmY, P, fk, Dk, Bk, xlog, ylog, ell, prefactor):
    config = c_limber_config()
    config.xlog = xlog
    config.ylog = ylog
    config.n_ell = len(ell)
    config.ell = np.ctypeslib.as_ctypes(ell)
    config.prefactor = prefactor
    config.status = 0
    spline_ptr = lib.limber_integral(ct.byref(config), WbX, WfX, WmX, WbY, WfY, WmY, P, fk, Dk, Bk)
    if config.status == LIMBER_STATUS_ZERO:
        ylog = False
    elif config.status == LIMBER_STATUS_NEGATIVE:
        ylog = False
    spline = GSLSpline(spline_ptr, xlog=xlog, ylog=ylog)
    return spline


#!/usr/bin/env python3

"""Module dedicated to classifying molecules into point groups."""

from __future__ import annotations

__all__ = ["find_point_group", "symmetry_number"]

import logging
import re

import numpy as np
from scipy.cluster.hierarchy import fcluster, linkage
from scipy.spatial import cKDTree as KDTree
from scipy.spatial.distance import pdist, squareform
from scipy.spatial.transform import Rotation

import overreact as rx
from overreact import _constants as constants

logger = logging.getLogger(__name__)

# TODO(schneiderfelipe): alpha should depend on temperature?
def get_molecular_volume(
    atomnos,
    atomcoords,
    full_output=False,
    environment="water",
    method="garza",
    temperature=298.15,
    pressure=constants.atm,
    alpha=1.2,
    num=250,
    trials=3,
):
    """Calculate van der Waals volumes.

    Volume estimation is done through Quasi-Monte Carlo integration. As such,
    the computed volume is accurate to about two significant figures. This is
    sufficient to most applications.

    Parameters
    ----------
    atomnos : array-like
    atomcoords : array-like
    full_output : bool, optional
        If True, return an estimate of the cavity volume and an estimate on the
        error as well.
    method : str, optional
        This is a placeholder for future functionality.
        There are plans to implement more sophisticated methods for calculating
        entropies such as in
        [*Phys. Chem. Chem. Phys.*, **2019**, 21, 18920-18929](https://doi.org/10.1039/C9CP03226F)
        and
        [*J. Chem. Theory Comput.* **2019**, 15, 5, 3204–3214](https://doi.org/10.1021/acs.jctc.9b00214).
        Head over to the
        [discussions](https://github.com/geem-lab/overreact/discussions) if
        you're interested and would like to contribute.
        Leave this as "standard" for now.
    environment : str, optional
    temperature : array-like, optional
        Absolute temperature in Kelvin.
    pressure : array-like, optional
        Reference gas pressure.
    alpha : float, optional
    num, trials : int, optional

    Returns
    -------
    vdw_volume : float
    cav_volume, err : float, optional
        Volumes returned are in Å³ per molecule.

    Raises
    ------
    ValueError
        If `method` is not recognized.

    Notes
    -----
    For "izato", see equation 3 of DOI:10.1039/C9CP03226F for the conceptual
    details. There is theoretical support for the equation in the work of
    Eyring (DOI:10.1021/j150380a007).

    Examples
    --------
    >>> from overreact import _datasets as datasets

    >>> data = datasets.logfiles["symmetries"]["dihydrogen"]
    >>> get_molecular_volume(data.atomnos, data.atomcoords)
    8.4
    >>> get_molecular_volume(data.atomnos, data.atomcoords, method="izato",
    ...                      full_output=True)
    (8.4, 13.7, 0.1)
    >>> get_molecular_volume(data.atomnos, data.atomcoords, full_output=True)
    (8.4, 61., 0.1)

    >>> data = datasets.logfiles["symmetries"]["water"]
    >>> get_molecular_volume(data.atomnos, data.atomcoords)
    18.
    >>> get_molecular_volume(data.atomnos, data.atomcoords, method="izato",
    ...                      full_output=True)
    (18., 29., 0.1)
    >>> get_molecular_volume(data.atomnos, data.atomcoords, full_output=True)
    (18., 92., 0.1)
    >>> get_molecular_volume(data.atomnos, data.atomcoords, full_output=True,
    ...                      environment="benzene")
    (18., 301., 0.1)

    >>> data = datasets.logfiles["symmetries"]["benzene"]
    >>> get_molecular_volume(data.atomnos, data.atomcoords)
    80.
    >>> get_molecular_volume(data.atomnos, data.atomcoords, method="izato",
    ...                      full_output=True)
    (80., 115., 0.1)
    >>> get_molecular_volume(data.atomnos, data.atomcoords, full_output=True)
    (80., 240., 0.1)
    >>> get_molecular_volume(data.atomnos, data.atomcoords, full_output=True,
    ...                      environment="benzene")
    (80., 593., 0.1)
    """
    atomnos = np.atleast_1d(atomnos)
    _, _, atomcoords = inertia(np.ones_like(atomnos), atomcoords)
    vdw_radii = constants.vdw_radius(atomnos)

    v1 = atomcoords.min(axis=0) - alpha * vdw_radii.max()
    v2 = atomcoords.max(axis=0) + alpha * vdw_radii.max()
    box_volume = np.prod(v2 - v1)
    n = int(num * box_volume)

    vdw_volumes = []
    if full_output and method == "izato":
        cav_volumes = []
    for _ in range(trials):
        points = rx._misc.halton(n, 3)
        points = v1 + points * (v2 - v1)
        tree = KDTree(points)

        within_vdw = set()
        if full_output and method == "izato":
            within_cav = set()
        for i, atomcoord in enumerate(atomcoords):
            within_vdw.update(tree.query_ball_point(atomcoord, vdw_radii[i]))
            if full_output and method == "izato":
                within_cav.update(
                    tree.query_ball_point(atomcoord, alpha * vdw_radii[i])
                )

        vdw_volumes.append((len(within_vdw) / n) * box_volume)
        if full_output and method == "izato":
            cav_volumes.append((len(within_cav) / n) * box_volume)

    vdw_volume = np.mean(vdw_volumes)
    vdw_err = np.std(vdw_volumes)
    logger.info(f"van der Waals volume = {vdw_volume} ± {vdw_err} Å³")
    if full_output:
        if method == "izato":
            cav_volume = np.mean(cav_volumes)
            cav_err = np.std(cav_volumes)
            logger.debug(f"Izato cavity volume = {cav_volume} ± {cav_err} Å³")
            return (vdw_volume, cav_volume, max(vdw_err, cav_err))
        elif method == "garza":
            # TODO(schneiderfelipe): test for the following solvents: water,
            # pentane, hexane, heptane and octane.

            cav_volume = _garza(
                vdw_volume, environment, temperature=temperature, pressure=pressure
            )
            logger.debug(f"Garza cavity volume = {cav_volume} Å³")
            return (vdw_volume, cav_volume, vdw_err)
        else:
            raise ValueError(f"unavailable method: '{method}'")
    return vdw_volume

def _garza(
    vdw_volume,
    environment="water",
    full_output=False,
    temperature=298.15,
    pressure=constants.atm,
):
    """Calculate cavity attributes according to A. Garza.

    This is mainly a helper function for calculating solvation entropy
    according to DOI:10.1021/acs.jctc.9b00214.

    Parameters
    ----------
    vdw_volume : float
    environment : str, optional
    full_output : bool, optional
        If True, return all model estimates.
    temperature : array-like, optional
        Absolute temperature in Kelvin.
    pressure : array-like, optional
        Reference gas pressure.

    Returns
    -------
    cav_volume : float
    N_cav : float, optional
    ratio : float, optional

    Examples
    --------
    >>> _garza(1.0)
    24.3195400621484
    >>> _garza(1.0, full_output=True)
    (24.3195400621484, 1.81525614083525, 0.3507458151874175)
    >>> _garza(10.0)
    66.51277879776
    >>> _garza(10.0, full_output=True)
    (66.51277879776, 1.0, 0.755658951623284)
    >>> _garza(100.0)
    279.576566152594
    >>> _garza(100.0, full_output=True)
    (279.576566152594, 1.0, 1.628017859210329)

    >>> _garza(1.0, environment="benzene")
    131.
    >>> _garza(1.0, full_output=True, environment="benzene")
    (131., 3.35, 0.2317882509934295)
    >>> _garza(10.0, environment="benzene")
    243.
    >>> _garza(10.0, full_output=True, environment="benzene")
    (243., 3.29, 0.499372648682062)
    >>> _garza(100.0, environment="benzene")
    665.
    >>> _garza(100.0, full_output=True, environment="benzene")
    (665., 1.0, 1.07586575757374)
    """
    solvent = rx._misc._get_chemical(environment, temperature, pressure)

    # TODO(schneiderfelipe): things to do:
    # 1. check correctness of this function,
    # 2. check it is called correctly everywhere,
    # 3. transfer the following commented code to get_chemical (it will become
    # a complete abstraction of the solvent/molecular properties):
    #
    # data_S = datasets.logfiles[solvent.name]
    # solvent_volume = coords.get_molecular_volume(data_S.atomnos,
    #                                              data_S.atomcoords)
    solvent_volume = solvent.Van_der_Waals_volume / (
        constants.angstrom ** 3 * constants.N_A
    )
    r_free = np.cbrt(
        solvent.Vm / (constants.angstrom ** 3 * constants.N_A) - solvent_volume
    )
    r_M = np.cbrt(vdw_volume)

    cav_volume = (r_M + r_free) ** 3
    if not full_output:
        return cav_volume
    r_S = np.cbrt(solvent_volume)
    ratio = r_M / r_S

    area_free = r_free ** 2
    area_S_total = r_S ** 2 + area_free

    x = max(area_free - r_M ** 2, 0.0) / area_S_total
    if np.isclose(x, 0.0):
        return cav_volume, 1.0, ratio

    N_x = 4.0 * np.cbrt(cav_volume) ** 2 / area_S_total
    return cav_volume, 1.0 + N_x * x / (1.0 - x), ratio

def symmetry_number(point_group):
    """Return rotational symmetry number for point group.

    This function has a set of the most common point groups precomputed, but is
    able to calculate the symmetry number if it is not found in known tables.
    Most precomputed values are from
    [*Theor Chem Account* **2007** 118, 813–826](https://doi.org/10.1007/s00214-007-0328-0).

    Parameters
    ----------
    point_group : str
        Point group symbol.

    Returns
    -------
    int
        Rotational symmetry number.

    Raises
    ------
    ValueError
        If point group is not found in precomputed values.

    Examples
    --------
    >>> symmetry_number("C4")
    4
    >>> symmetry_number("C4") == symmetry_number("C4h")
    True
    >>> symmetry_number("C6")
    6
    >>> symmetry_number("C6") == symmetry_number("C6v")
    True
    >>> symmetry_number("C6") == symmetry_number("C6h")
    True
    >>> symmetry_number("D2") == symmetry_number("Vh")
    True
    >>> symmetry_number("D4")
    8
    >>> symmetry_number("D6")
    12
    >>> symmetry_number("D6") == symmetry_number("D6d")
    True
    >>> symmetry_number("S6")
    3
    >>> symmetry_number("T")
    12
    """
    point_group = point_group.strip().lower()

    if point_group in {"c1", "ci", "cs", "c∞v", "k", "r3"}:
        symmetry_number = 1
    elif point_group in {"c2", "c2v", "c2h", "d∞h", "s4"}:
        symmetry_number = 2
    elif point_group in {"c4", "c4v", "c4h", "d2", "d2d", "d2h", "s8", "vh"}:
        symmetry_number = 4
    elif point_group in {"c12", "c12v", "c12h", "d6", "d6d", "d6h", "s24", "t", "td"}:
        symmetry_number = 12
    elif point_group in {"c24", "c24v", "c24h", "d12", "d12d", "d12h", "s48", "oh"}:
        symmetry_number = 24
    elif point_group in {"c60", "c60v", "c60h", "d30", "d30d", "d30h", "s120", "ih"}:
        symmetry_number = 60
    else:
        pieces = re.match(
            r"(?P<letter>[^\s]+)(?P<number>\d+)(?P<type>[^\s]+)?", point_group
        ).groupdict()

        if pieces["letter"] == "c":
            symmetry_number = int(pieces["number"])
        elif pieces["letter"] == "d":
            symmetry_number = 2 * int(pieces["number"])
        elif pieces["letter"] == "s":
            symmetry_number = int(pieces["number"]) // 2
        else:
            raise ValueError(f"unknown point group: '{point_group}'")

    logger.info(f"symmetry number = {symmetry_number}")
    return symmetry_number

def find_point_group(atommasses, atomcoords, proper_axes=None, rtol=0.0, atol=1.0e-2):
    """Determine point group of structure.

    Parameters
    ----------
    atommasses : array-like
        Atomic masses in atomic mass units (amu).
    atomcoords : array-like
        Atomic coordinates.
    proper_axes : sequence of tuples of int, array-like, optional
        Proper symmetry axes of rotation.
    rtol : float, optional
        The relative tolerance parameter (see `numpy.isclose`).
    atol : float, optional
        The absolute tolerance parameter (see `numpy.isclose`).

    Returns
    -------
    str
        Point group symbol.

    Examples
    --------
    >>> find_point_group([1], [[0, 0, 1]])
    'K'
    >>> find_point_group([1, 1], [[0, 0, 1], [0, 0, 0]])
    'D∞h'
    >>> find_point_group([1.008, 35.45], [[0, 0, 1], [0, 0, 0]])
    'C∞v'
    >>> find_point_group([16, 12, 16], [[1, 0, 1], [1, 0, 0], [1, 0, -1]])
    'D∞h'
    >>> find_point_group([16, 12, 32], [[1, 1, 1], [1, 1, 0], [1, 1, -1]])
    'C∞v'
    >>> find_point_group([12, 12, 12, 12], [[1, 0, 0],
    ...                                     [0, 1, 0],
    ...                                     [0, 0, 0],
    ...                                     [1, 1, 0]])
    'D4h'

    """
    if len(atommasses) == 1:  # atom
        point_group = "K"
    elif len(atommasses) == 2:  # diatomic molecule
        if atommasses[0] == atommasses[1]:
            point_group = "D∞h"
        else:
            point_group = "C∞v"
    else:
        groups = _equivalent_atoms(atommasses, atomcoords)
        moments, axes, atomcoords = inertia(atommasses, atomcoords)
        rotor_class = _classify_rotor(moments)

        if rotor_class[1] == "linear":
            point_group = _find_point_group_linear(
                atomcoords, groups, rtol=rtol, atol=atol
            )
        else:
            if proper_axes is None:
                proper_axes = _get_proper_axes(
                    atomcoords, groups, axes, rotor_class, rtol=rtol, atol=atol
                )

            if rotor_class[0] == "asymmetric" or not proper_axes:
                point_group = _find_point_group_asymmetric(
                    atomcoords,
                    groups,
                    axes,
                    rotor_class,
                    proper_axes,
                    rtol=rtol,
                    atol=atol,
                )
            elif rotor_class[0] == "spheric":
                point_group = _find_point_group_spheric(
                    atomcoords,
                    groups,
                    axes,
                    rotor_class,
                    proper_axes,
                    rtol=rtol,
                    atol=atol,
                )
            else:  # symmetric
                point_group = _find_point_group_symmetric(
                    atomcoords,
                    groups,
                    axes,
                    rotor_class,
                    proper_axes,
                    rtol=rtol,
                    atol=atol,
                )

    logger.info(f"point group = {point_group}")
    return point_group

def _find_point_group_linear(atomcoords, groups, rtol=0.0, atol=1.0e-2):
    """Find point group for linear rotors.

    Point groups searched for are: D∞h, C∞v.

    See find_point_group for information on parameters and return values.
    """
    if _has_inversion_center(atomcoords, groups, rtol=rtol, atol=atol):
        return "D∞h"
    else:
        return "C∞v"

def _find_point_group_spheric(
    atomcoords, groups, axes, rotor_class, proper_axes=None, rtol=0.0, atol=1.0e-2
):
    """Find point group for spheric tops.

    Point groups searched for are: Td, Oh, Ih.
    I might eventually search for T, Th, O in the future.

    See find_point_group for information on parameters and return values.
    """
    if not _has_inversion_center(atomcoords, groups, rtol=rtol, atol=atol):
        return "Td"

    if proper_axes is None:
        proper_axes = _get_proper_axes(
            atomcoords, groups, axes, rotor_class, rtol=rtol, atol=atol
        )

    for n, _ in proper_axes:
        if n == 5:
            return "Ih"
        elif n < 5:
            break
    return "Oh"

    # see https://www.chem.uci.edu/~lawm/9-28.pdf for more about high
    # symmetry groups
    # see too
    # http://web.mit.edu/5.03/www/readings/point_groups/point_groups.pdf

    # the following workflow is loosely inspired by some articles:
    # 1. DOI:10.1016/0097-8485(76)80004-6
    #     elif _has_3_C4(atomcoords):
    #         if _has_center_of_inversion(atomcoords):
    #             return "oh"
    #         else:
    #             return "o"
    #     elif _has_3_S4_parallel_to_C2(atomcoords):
    #         return "td"
    #     elif _has_center_of_inversion(atomcoords):
    #         return "th"
    #     else:
    #         return "t"

def _find_point_group_asymmetric(
    atomcoords, groups, axes, rotor_class, proper_axes=None, rtol=0.0, atol=1.0e-2
):
    """Find point group for asymmetric tops.

    Point groups searched for here are: C1, Ci, Cs.
    Point groups delegated are: Cn, Cnh, Cnv, Dn, Dnh, Dnd, Sn.

    See find_point_group for information on parameters and return values.
    """
    if proper_axes is None:
        proper_axes = _get_proper_axes(
            atomcoords, groups, axes, rotor_class, rtol=rtol, atol=atol
        )

    if proper_axes:
        return _find_point_group_symmetric(
            atomcoords, groups, axes, rotor_class, proper_axes, rtol=rtol, atol=atol
        )
    elif rotor_class[1] in {"regular planar", "irregular planar"} or _get_mirror_planes(
        atomcoords, groups, axes, rotor_class, proper_axes, rtol=rtol, atol=atol
    ):
        return "Cs"
    elif _has_inversion_center(atomcoords, groups, rtol=rtol, atol=atol):
        return "Ci"
    return "C1"

def _find_point_group_symmetric(
    atomcoords, groups, axes, rotor_class, proper_axes=None, rtol=0.0, atol=1.0e-2
):
    """Find point group for symmetric tops.

    Point groups delegated are: Cn, Cnh, Cnv, Dn, Dnh, Dnd, Sn.

    See find_point_group for information on parameters and return values.
    """
    if proper_axes is None:
        proper_axes = _get_proper_axes(
            atomcoords, groups, axes, rotor_class, rtol=rtol, atol=atol
        )
    n_principal = proper_axes[0][0]

    count_twofold = 0
    for n, _ in proper_axes:
        if n == 2:
            count_twofold += 1
        if n_principal == count_twofold:
            return _find_point_group_symmetric_dihedral(
                atomcoords, groups, axes, rotor_class, proper_axes, rtol=rtol, atol=atol
            )
        if n < 2:
            break
    return _find_point_group_symmetric_nondihedral(
        atomcoords, groups, axes, rotor_class, proper_axes, rtol=rtol, atol=atol
    )

    # the following workflow is loosely inspired by some articles:
    # 1. DOI:10.1016/0097-8485(76)80004-6
    # if _has_proper_ax_of_highest_order(atomcoords):
    #     if _has_proper_ax_larger_than_2_or_3_C2_perpendicular(
    #         atomcoords
    #     ):
    #         if _has_S2n_parallel_to_Cn(atomcoords):
    #             if _has_n_sigma_d(atomcoords):
    #                 return "dnd"
    #             else:
    #                 return "s2n"
    #         elif _has_nC2_perpendicular_to_Cn(atomcoords):
    #             if _has_sigma_h(atomcoords):
    #                 return "dnh"
    #             else:
    #                 return "dn"
    #         elif _has_n_sigma_v(atomcoords):
    #             return "cnv"
    #         elif _has_sigma_h(atomcoords):
    #             return "cnh"
    #         else:
    #             return "cn"

def _find_point_group_symmetric_dihedral(
    atomcoords, groups, axes, rotor_class, proper_axes=None, rtol=0.0, atol=1.0e-2
):
    """Find a dihedral point group for symmetric tops.

    Point groups searched for are: Dn, Dnh, Dnd.

    See find_point_group for information on parameters and return values.
    """
    if proper_axes is None:
        proper_axes = _get_proper_axes(
            atomcoords, groups, axes, rotor_class, rtol=rtol, atol=atol
        )
    mirror_axes = _get_mirror_planes(
        atomcoords, groups, axes, rotor_class, proper_axes, rtol=rtol, atol=atol
    )

    if mirror_axes:
        if mirror_axes[0][0] == "h":
            return f"D{proper_axes[0][0]}h"
        elif len([v for c, v in mirror_axes if c == "v"]) == proper_axes[0][0]:
            # all vertical mirror planes are dihedral for Dnd point groups
            return f"D{proper_axes[0][0]}d"
    return f"D{proper_axes[0][0]}"

def _find_point_group_symmetric_nondihedral(
    atomcoords, groups, axes, rotor_class, proper_axes=None, rtol=0.0, atol=1.0e-2
):
    """Find a nondihedral point group for symmetric tops.

    Point groups searched for are: Cn, Cnh, Cnv, Sn.

    See find_point_group for information on parameters and return values.
    """
    if proper_axes is None:
        proper_axes = _get_proper_axes(
            atomcoords, groups, axes, rotor_class, rtol=rtol, atol=atol
        )
    mirror_axes = _get_mirror_planes(
        atomcoords, groups, axes, rotor_class, proper_axes, rtol=rtol, atol=atol
    )

    if mirror_axes:
        if mirror_axes[0][0] == "h":
            return f"C{proper_axes[0][0]}h"
        elif len([v for c, v in mirror_axes if c == "v"]) == proper_axes[0][0]:
            return f"C{proper_axes[0][0]}v"

    improper_axes = _get_improper_axes(
        atomcoords, groups, axes, rotor_class, proper_axes, rtol=rtol, atol=atol
    )
    if improper_axes:
        return f"S{improper_axes[0][0]}"
    return f"C{proper_axes[0][0]}"

def _update_proper_axes(
    ax,
    axes,  # found axes
    atomcoords,
    groups,
    orders,
    rtol,
    atol,
    nondeg_axes=None,
    normalize=False,
):
    """Update axes with ax, and return it with added order (or None).

    Helper function for _get_proper_axes.
    """
    if nondeg_axes is None:
        nondeg_axes = list()

    if normalize:
        norm = np.linalg.norm(ax)
        if np.isclose(norm, 0.0, rtol=rtol, atol=atol):
            return axes, None
        ax = ax / norm

    if not all(
        np.isclose(ax @ v, 0.0, rtol=rtol, atol=atol) for v in nondeg_axes
    ) or any(np.isclose(np.abs(ax @ v), 1.0, rtol=rtol, atol=atol) for o, v in axes):
        return axes, None

    for order in orders[::-1]:
        if all(
            _is_symmetric(
                atomcoords[group],
                _operation("c", order=order, axis=ax),
                rtol=rtol,
                atol=atol,
            )
            for group in groups[::-1]
        ):
            axes.append((order, tuple(ax)))
            return axes, order

    return axes, None

def _get_proper_axes(
    atomcoords, groups, axes, rotor_class, rtol=0.0, atol=1.0e-2, slack=0.735
):
    """Get proper symmetry axes and their orders.

    Parameters
    ----------
    atomcoords : array-like
        Atomic coordinates centered at the center of mass.
    groups : sequence of sequence of int
        Groups of symmetry equivalent atoms, in ascending order of size.
    axes : array-like
        Normalized principal axes of inertia.
    rotor_class : tuple of str
        Rigid rotor classification.
    rtol : float, optional
        The relative tolerance parameter (see `numpy.isclose`).
    atol : float, optional
        The absolute tolerance parameter (see `numpy.isclose`).
    slack : float, optional
        Number to multiply rtol and atol prior comparisons.

    Returns
    -------
    sequence of tuples of int, array-like
        Ordered sequence of tuples in the format ``(order, (x, y, z))``.

    Notes
    -----
    This function has some limitations. First, no C1 axis is never returned.
    Second, an empty list is returned if the structure has a single atom. And
    third, the largest symmetry axis found has order not greater than the
    maximum number of symmetry equivalent atoms, which particularly impacts
    linear rotors (for instance, the largest axis found for the hydrogen
    molecule is twofold, while no axis is found for hydrogen chloride).
    Furthermore, linear rotors are considered symmetric prolate tops, which
    means a single axis is returned (no perpendicular axes). Either way, this
    should be of little impact, as only cases whose groups can easily be
    inferred with no or little knowledge of symmetry axis are affected.

    Examples
    --------
    >>> from overreact import _datasets as datasets

    >>> data = datasets.logfiles["symmetries"]["diborane"]
    >>> groups = _equivalent_atoms(data.atommasses, data.atomcoords)
    >>> moments, axes, atomcoords = inertia(data.atommasses, data.atomcoords)
    >>> rotor_class = _classify_rotor(moments)
    >>> _get_proper_axes(atomcoords, groups, axes, rotor_class)
    [(2, (1.0, ...)),
     (2, (...)),
     (2, (...))]
    """
    rtol, atol = slack * rtol, slack * atol

    if rotor_class[1] == "atomic" or len(atomcoords) == 1:
        return list()

    axes = np.asarray(axes)
    atomcoords = np.asarray(atomcoords)
    orders = _guess_orders(groups, rotor_class)

    found_axes = list()
    nondeg_axes = list()
    if rotor_class[0] == "symmetric prolate":
        nondeg_axes = [axes[:, 0]]
        found_axes, order = _update_proper_axes(
            axes[:, 0],
            found_axes,
            atomcoords=atomcoords,
            groups=groups,
            orders=orders,
            rtol=rtol,
            atol=atol,
        )
    elif rotor_class[0] == "symmetric oblate":
        nondeg_axes = [axes[:, 2]]
        found_axes, order = _update_proper_axes(
            axes[:, 2],
            found_axes,
            atomcoords=atomcoords,
            groups=groups,
            orders=orders,
            rtol=rtol,
            atol=atol,
        )
    elif rotor_class[0] == "asymmetric":
        for ax in axes.T:
            found_axes, order = _update_proper_axes(
                ax,
                found_axes,
                atomcoords=atomcoords,
                groups=groups,
                orders=orders,
                rtol=rtol,
                atol=atol,
            )
        return sorted(found_axes, reverse=True)

    for group in groups:
        for i, a in enumerate(group):
            through_ax = atomcoords[a]
            found_axes, order = _update_proper_axes(
                through_ax,
                found_axes,
                atomcoords=atomcoords,
                groups=groups,
                orders=orders,
                rtol=rtol,
                atol=atol,
                nondeg_axes=nondeg_axes,
                normalize=True,
            )
            if rotor_class[0] == "spheric" and order == 5:
                return sorted(found_axes, reverse=True)

            for b in group[:i]:
                midpoint_ax = atomcoords[a] + atomcoords[b]
                found_axes, order = _update_proper_axes(
                    midpoint_ax,
                    found_axes,
                    atomcoords=atomcoords,
                    groups=groups,
                    orders=orders,
                    rtol=rtol,
                    atol=atol,
                    nondeg_axes=nondeg_axes,
                    normalize=True,
                )
                if rotor_class[0] == "spheric" and order == 5:
                    return sorted(found_axes, reverse=True)

    if rotor_class[0] == "spheric":
        twofold_axes = [ax for o, ax in found_axes if o == 2]
        for i, ax_a in enumerate(twofold_axes):
            for ax_b in twofold_axes[:i]:
                ax = np.cross(ax_a, ax_b)
                found_axes, order = _update_proper_axes(
                    ax,
                    found_axes,
                    atomcoords=atomcoords,
                    groups=groups,
                    orders=orders,
                    rtol=rtol,
                    atol=atol,
                    nondeg_axes=nondeg_axes,
                    normalize=True,
                )
                if order == 5:
                    return sorted(found_axes, reverse=True)

    return sorted(found_axes, reverse=True)

def _guess_orders(groups, rotor_class):
    """Guess possible group orders based on groups.

    The guess consists of the numbers two to n, where n is the number of
    elements in the largest group. For cubic groups, n is taken to be at most
    five. The trivial order one is never guessed.

    Parameters
    ----------
    groups : sequence of sequence of int
        Groups of symmetry equivalent atoms, in ascending order of size.
    rotor_class : tuple of str
        Rigid rotor classification.

    Returns
    -------
    sequence of int

    Examples
    --------
    >>> _guess_orders([[0], [1, 2, 3, 4], [5, 6, 7, 8]],
    ...               ("symmetric prolate", "nonplanar"))
    range(2, 5)
    >>> _guess_orders([[0], [1, 2, 3, 4], [5, 6, 7, 8]],
    ...               ("spheric", "nonplanar"))
    range(2, 5)
    >>> _guess_orders([[0], [1, 2, 3, 4], [5, 6, 7, 8, 9, 10]],
    ...               ("symmetric prolate", "nonplanar"))
    range(2, 7)
    >>> _guess_orders([[0], [1, 2, 3, 4], [5, 6, 7, 8, 9, 10]],
    ...               ("spheric", "nonplanar"))
    range(2, 6)
    """
    max_order = len(groups[-1])
    if rotor_class[0] == "spheric":
        max_order = min(max_order, 5)
    return range(2, max_order + 1)

def _update_improper_axes(
    n, ax, axes, atomcoords, groups, rtol, atol, normalize=False  # found axes
):
    """Update axes with ax and return it.

    Helper function for _get_improper_axes.
    """
    if normalize:
        norm = np.linalg.norm(ax)
        if np.isclose(norm, 0.0, rtol=rtol, atol=atol):
            return axes
        ax = ax / norm

    for order in [2 * n, n]:
        if all(
            _is_symmetric(
                atomcoords[group],
                _operation("s", order=order, axis=ax),
                rtol=rtol,
                atol=atol,
            )
            for group in groups[::-1]
        ):
            axes.append((order, tuple(ax)))
            break

    return axes

def _get_improper_axes(
    atomcoords,
    groups,
    axes,
    rotor_class,
    proper_axes=None,
    rtol=0.0,
    atol=1.0e-2,
    slack=1.888,
):
    """Get improper symmetry axes and their orders.

    Parameters
    ----------
    atomcoords : array-like
        Atomic coordinates centered at the center of mass.
    groups : sequence of sequence of int
        Groups of symmetry equivalent atoms, in ascending order of size.
    axes : array-like
        Normalized principal axes of inertia.
    rotor_class : tuple of str
        Rigid rotor classification.
    proper_axes : sequence of tuples of int, array-like, optional
        Proper symmetry axes of rotation.
    rtol : float, optional
        The relative tolerance parameter (see `numpy.isclose`).
    atol : float, optional
        The absolute tolerance parameter (see `numpy.isclose`).
    slack : float, optional
        Number to multiply rtol and atol prior comparisons.

    Returns
    -------
    sequence of tuples of int, array-like

    Examples
    --------
    >>> from overreact import _datasets as datasets

    >>> data = datasets.logfiles["tanaka1996"]["methane@UMP2/cc-pVTZ"]
    >>> groups = _equivalent_atoms(data.atommasses, data.atomcoords)
    >>> moments, axes, atomcoords = inertia(data.atommasses, data.atomcoords)
    >>> rotor_class = _classify_rotor(moments)
    >>> _get_improper_axes(atomcoords, groups, axes, rotor_class)
    [(4, (0.0, 0.0, -1.0)),
     (4, (0.0, -1.0, 0.0)),
     (4, (-1.0, 0.0, 0.0))]
    """
    rtol, atol = slack * rtol, slack * atol

    if rotor_class[1] == "atomic" or len(atomcoords) == 1:
        return list()

    axes = np.asarray(axes)
    atomcoords = np.asarray(atomcoords)

    if proper_axes is None:
        proper_axes = _get_proper_axes(
            atomcoords, groups, axes, rotor_class, rtol=rtol, atol=atol
        )

    found_axes = list()
    for n, ax in proper_axes:
        found_axes = _update_improper_axes(
            n,
            ax,
            found_axes,
            atomcoords=atomcoords,
            groups=groups,
            rtol=rtol,
            atol=atol,
        )
    return sorted(found_axes, reverse=True)

def _update_mirror_axes(
    ax,
    axes,  # found axes
    atomcoords,
    groups,
    rtol,
    atol,
    proper_axes,
    nondeg_axes=None,
    normalize=False,
):
    """Update axes with ax and return it.

    Helper function for _get_mirror_planes.
    """
    if nondeg_axes is None:
        nondeg_axes = list()

    if normalize:
        norm = np.linalg.norm(ax)
        if np.isclose(norm, 0.0, rtol=rtol, atol=atol):
            return axes
        ax = ax / norm

    if not all(
        np.isclose(ax @ v, 0.0, rtol=rtol, atol=atol) for v in nondeg_axes
    ) or any(np.isclose(np.abs(ax @ v), 1.0, rtol=rtol, atol=atol) for c, v in axes):
        return axes

    if all(
        _is_symmetric(atomcoords[group], _operation("σ", axis=ax), rtol=rtol, atol=atol)
        for group in groups[::-1]
    ):
        class_ = ""
        if any(
            np.isclose(np.abs(ax @ v), 1.0, rtol=rtol, atol=atol)
            for n, v in proper_axes
            if proper_axes[0][0] == n
        ):
            class_ = "h"
        elif any(
            np.isclose(ax @ v, 0.0, rtol=rtol, atol=atol)
            for n, v in proper_axes
            if proper_axes[0][0] == n
        ):
            class_ = "v"
        axes.append((class_, tuple(ax)))

    return axes

def _get_mirror_planes(
    atomcoords,
    groups,
    axes,
    rotor_class,
    proper_axes=None,
    rtol=0.0,
    atol=1.0e-2,
    slack=2.020,
):
    """Get (and attempt to classify) mirror plane normal axes.

    Parameters
    ----------
    atomcoords : array-like
        Atomic coordinates centered at the center of mass.
    groups : sequence of sequence of int
        Groups of symmetry equivalent atoms, in ascending order of size.
    axes : array-like
        Normalized principal axes of inertia.
    rotor_class : tuple of str
        Rigid rotor classification.
    proper_axes : sequence of tuples of int, array-like, optional
        Proper symmetry axes of rotation.
    rtol : float, optional
        The relative tolerance parameter (see `numpy.isclose`).
    atol : float, optional
        The absolute tolerance parameter (see `numpy.isclose`).
    slack : float, optional
        Number to multiply rtol and atol prior comparisons.

    Returns
    -------
    sequence of tuples of str, array-like

    Notes
    -----
    This function only classifies horizontal and vertical planes. Besides,
    horizontal classification always takes precedence. Unclassified planes are
    associated with an empty string. This is all that is needed for point group
    classification.

    This function has some limitations. First, an empty list is always returned
    if the structure has a single atom. Second, an empty list is returned for
    C∞v as well. And third, since classifications are made against proper
    symmetry axes, no classification can be made for point groups having no
    proper symmetry axes (e.g., the mirror plane in Cs is not classified).

    This should be of little impact, as only cases whose groups can easily be
    inferred with no or little knowledge of mirror planes are affected.

    Examples
    --------
    >>> from overreact import _datasets as datasets

    >>> data = datasets.logfiles["symmetries"]["1-iodo-2-chloroethylene"]
    >>> groups = _equivalent_atoms(data.atommasses, data.atomcoords)
    >>> moments, axes, atomcoords = inertia(data.atommasses, data.atomcoords)
    >>> rotor_class = _classify_rotor(moments)
    >>> _get_mirror_planes(atomcoords, groups, axes, rotor_class)
    [('', (0.0, 0.0, 1.0))]
    """
    rtol, atol = slack * rtol, slack * atol

    if rotor_class[1] == "atomic" or len(atomcoords) == 1:
        return list()

    axes = np.asarray(axes)
    atomcoords = np.asarray(atomcoords)

    if proper_axes is None:
        proper_axes = _get_proper_axes(
            atomcoords, groups, axes, rotor_class, rtol=rtol, atol=atol
        )

    def _kf(x):
        """Order function for returned list."""
        c, v = x
        if c:
            return -ord(c), v
        else:
            return 0, v

    found_axes = list()
    nondeg_axes = list()
    if rotor_class[0] == "symmetric prolate":
        nondeg_axes = [axes[:, 0]]
        found_axes = _update_mirror_axes(
            axes[:, 0],
            found_axes,
            atomcoords=atomcoords,
            groups=groups,
            rtol=rtol,
            atol=atol,
            proper_axes=proper_axes,
        )
    elif rotor_class[0] == "symmetric oblate":
        nondeg_axes = [axes[:, 2]]
        found_axes = _update_mirror_axes(
            axes[:, 2],
            found_axes,
            atomcoords=atomcoords,
            groups=groups,
            rtol=rtol,
            atol=atol,
            proper_axes=proper_axes,
        )
    elif rotor_class[0] == "asymmetric":
        for ax in axes.T:
            found_axes = _update_mirror_axes(
                ax,
                found_axes,
                atomcoords=atomcoords,
                groups=groups,
                rtol=rtol,
                atol=atol,
                proper_axes=proper_axes,
            )
        return sorted(found_axes, reverse=True, key=_kf)

    for group in groups:
        for i, a in enumerate(group):
            for b in group[:i]:
                ab_ax = atomcoords[b] - atomcoords[a]
                found_axes = _update_mirror_axes(
                    ab_ax,
                    found_axes,
                    atomcoords=atomcoords,
                    groups=groups,
                    rtol=rtol,
                    atol=atol,
                    proper_axes=proper_axes,
                    nondeg_axes=nondeg_axes,
                    normalize=True,
                )

    return sorted(found_axes, reverse=True, key=_kf)

def _has_inversion_center(atomcoords, groups, rtol=0.0, atol=1.0e-2, slack=1.888):
    """Check whether the molecule has an inversion center.

    Parameters
    ----------
    atomcoords : array-like
        Atomic coordinates centered at the center of mass.
    groups : sequence of sequence of int
        Groups of symmetry equivalent atoms, in ascending order of size.
    rtol : float, optional
        The relative tolerance parameter (see `numpy.isclose`).
    atol : float, optional
        The absolute tolerance parameter (see `numpy.isclose`).
    slack : float, optional
        Number to multiply rtol and atol prior comparisons.

    Returns
    -------
    bool

    Examples
    --------
    >>> _has_inversion_center([[0, 0, -1], [0, 0, 1]], [[0, 1]])
    True
    >>> _has_inversion_center([[0, 0, -1],
    ...                        [0, 0,  1],
    ...                        [1, 1,  1]], [[0, 1], [2]])
    False
    """
    rtol, atol = slack * rtol, slack * atol

    atomcoords = np.asarray(atomcoords)
    return all(
        _is_symmetric(atomcoords[group], _operation("i"), rtol=rtol, atol=atol)
        for group in groups[::-1]
    )

def _is_symmetric(atomcoords, op, rtol=0.0, atol=1.0e-2, slack=10.256):
    """Check if structure satisfies symmetry.

    Parameters
    ----------
    atomcoords : array-like
        Atomic coordinates centered at the center of mass.
    op : array-like
        Symmetry operator matrix.
    rtol : float, optional
        The relative tolerance parameter (see `numpy.isclose`).
    atol : float, optional
        The absolute tolerance parameter (see `numpy.isclose`).
    slack : float, optional
        Number to multiply rtol and atol prior comparisons.

    Returns
    -------
    bool

    Examples
    --------
    >>> _is_symmetric([[1, 0, 0],
    ...               [0, 1, 0],
    ...               [0, 0, 0]], _operation("c", order=4, axis=[0, 0, 1]))
    False
    >>> _is_symmetric([[1, 0, 0],
    ...               [0, 1, 0],
    ...               [0, 0, 0]], _operation("c", order=2, axis=[1, 1, 0]))
    True
    >>> _is_symmetric([[1, 0, 0],
    ...               [0, 1, 0],
    ...               [0, 0, 0]], _operation("sigma", axis=[0, 0, 1]))
    True
    >>> _is_symmetric([[1, 0, 0],
    ...               [0, 1, 0],
    ...               [0, 0, 0],
    ...               [-1, 0, 0]], _operation("c", order=4, axis=[0, 0, 1]))
    False
    >>> _is_symmetric([[1, 0, 0],
    ...               [0, 1, 0],
    ...               [0, 0, 0],
    ...               [-1, 0, 0]], _operation("sigma", axis=[1, 0, 0]))
    True
    >>> _is_symmetric([[1, 0, 0],
    ...               [0, 1, 0],
    ...               [0, 0, 0],
    ...               [-1, 0, 0],
    ...               [0, -1, 0]], _operation("c", order=4, axis=[0, 0, 1]))
    True
    >>> _is_symmetric([[1, 0, 0],
    ...               [0, 1, 0],
    ...               [0, 0, 0],
    ...               [-1, 0, 0],
    ...               [0, -1, 0]], _operation("i"))
    True
    """
    rtol, atol = slack * rtol, slack * atol
    inner_slack = 1.055

    tree = KDTree(atomcoords)
    d, i = tree.query(atomcoords @ op.T)

    return (
        set(i) == set(range(len(atomcoords)))
        and np.allclose(d.mean(), 0.0, rtol=rtol, atol=atol)
        and np.allclose(d.max(), 0.0, rtol=inner_slack * rtol, atol=inner_slack * atol)
    )

def _operation(name, order=2, axis=None):
    """Calculate a symmetry _operation.

    Parameters
    ----------
    name : str
        Operation symbol (see examples below).
    order : int, optional
        Operation order.
    axis : array-like, optional
        Operation axis.

    Returns
    -------
    array-like

    Raises
    ------
    ValueError
        If the operation is not recognized.

    Examples
    --------
    >>> _operation("e")
    array([[1., 0., 0.],
           [0., 1., 0.],
           [0., 0., 1.]])
    >>> _operation("i")
    array([[-1., -0., -0.],
           [-0., -1., -0.],
           [-0., -0., -1.]])
    >>> _operation("c", order=4, axis=[0, 0, 1])
    array([[ 0., -1., 0.],
           [ 1.,  0., 0.],
           [ 0.,  0., 1.]])
    >>> _operation("σ", axis=[0, 0, 1])
    array([[ 1., 0.,  0.],
           [ 0., 1.,  0.],
           [ 0., 0., -1.]])
    >>> _operation("σ", axis=[0, 1, 0])
    array([[ 1.,  0., 0.],
           [ 0., -1., 0.],
           [ 0.,  0., 1.]])
    >>> _operation("σ", axis=[1, 0, 0])
    array([[-1., 0., 0.],
           [ 0., 1., 0.],
           [ 0., 0., 1.]])
    >>> _operation("s", order=4, axis=[0, 0, 1])
    array([[ 0., -1.,  0.],
           [ 1.,  0.,  0.],
           [ 0.,  0., -1.]])
    >>> _operation("s", order=4, axis=[0, 1, 0])
    array([[ 0.,  0., 1.],
           [ 0., -1., 0.],
           [-1.,  0., 0.]])
    >>> _operation("s", order=4, axis=[1, 0, 0])
    array([[-1., 0.,  0.],
           [ 0., 0., -1.],
           [ 0., 1.,  0.]])
    """
    if axis is None:
        axis = np.array([0, 0, 1])

    if name == "i":
        return -np.eye(3)
    elif name == "e":
        return np.eye(3)
    elif name in {"c", "σ", "sigma", "s"}:  # normalize axis
        axis = np.asarray(axis)
        axis = axis / np.linalg.norm(axis)

        if name in {"c", "s"}:
            rotation = Rotation.from_rotvec(2.0 * np.pi * axis / order).as_matrix()
        if name in {"σ", "sigma", "s"}:
            reflection = np.eye(3) - 2.0 * np.outer(axis, axis)
        if name == "c":
            return rotation
        elif name in {"σ", "sigma"}:
            return reflection
        elif name == "s":
            return rotation @ reflection
    raise ValueError(f"unknown operation: '{name}'")

def _classify_rotor(moments, rtol=0.0, atol=1.0e-2, slack=0.870):
    """Classify rotors based on moments of inertia.

    See DOI:10.1002/jcc.23493.

    Parameters
    ----------
    moments : array-like
        Primary moments of inertia in ascending order. Units are in amu·Å².
    rtol : float, optional
        The relative tolerance parameter (see `numpy.isclose`).
    atol : float, optional
        The absolute tolerance parameter (see `numpy.isclose`).
    slack : float, optional
        Number to multiply rtol and atol prior comparisons.

    Return
    ------
    top, shape : str

    Notes
    -----
    Moments are actually compared by their ratios such that the "aspect ratio"
    of the molecule is what is compared.

    Examples
    --------
    Do examples from
    <https://www.tau.ac.il/~tsirel/dump/Static/knowino.org/wiki/Classification_of_rigid_rotors.html>.

    The idea behind this function is, among other things, to help in
    classifying point groups. For instance, there are the following
    possibilities for atoms or linear molecules:

    >>> _classify_rotor([0, 0, 0])
    ('spheric', 'atomic')
    >>> _classify_rotor([0, 1, 1])
    ('symmetric prolate', 'linear')

    Spheric tops can be any cubic group:

    >>> _classify_rotor([1, 1, 1])
    ('spheric', 'nonplanar')

    Asymmetric tops can be a lot of different groups, the ones lacking proper
    axis of symmetry (C1, Ci, Cs) being exclusively found in asymmetric tops.
    Furthermore, any group found for symmetric tops can be found for asymmetric
    tops as well, which complicates things a bit.

    >>> _classify_rotor([1, 2, 4])
    ('asymmetric', 'nonplanar')

    Symmetric tops can be found in a subset of the ones found in asymmetric
    tops and they always have a proper axis of symmetry. I further classify
    them here into smaller groups (I believe that this subclassification and
    its relationship with possible point groups can be further improved). For
    instance, planar symmetric tops are always oblate and have a plane of
    symmetry:

    >>> _classify_rotor([1, 1, 2])
    ('symmetric oblate', 'regular planar')

    >>> _classify_rotor([1, 3, 4])
    ('asymmetric', 'irregular planar')

    >>> _classify_rotor([1, 1, 3])
    ('symmetric oblate', 'nonplanar')

    >>> _classify_rotor([1, 2, 2])
    ('symmetric prolate', 'nonplanar')
    """
    rtol, atol = slack * rtol, slack * atol
    inner_slack = 2.130

    if np.isclose(moments[2], 0.0, rtol=inner_slack * rtol, atol=inner_slack * atol):
        return "spheric", "atomic"
    moments = np.asarray(moments) / moments[2]

    # basic tests for tops
    is_oblate = np.isclose(
        moments[0], moments[1], rtol=inner_slack * rtol, atol=inner_slack * atol
    )
    is_spheric = np.isclose(
        moments[0], moments[2], rtol=inner_slack * rtol, atol=inner_slack * atol
    )
    is_prolate = np.isclose(
        moments[1], moments[2], rtol=inner_slack * rtol, atol=inner_slack * atol
    )

    # basic tests for shapes
    fits_line = np.isclose(
        moments[0], 0.0, rtol=inner_slack * rtol, atol=inner_slack * atol
    )
    fits_plane = np.isclose(moments[0] + moments[1], moments[2], rtol=rtol, atol=atol)

    is_spheric = is_spheric and is_oblate and is_prolate
    if is_spheric:
        top = "spheric"
    elif is_oblate:
        top = "symmetric oblate"
    elif is_prolate:
        top = "symmetric prolate"
    else:
        top = "asymmetric"

    fits_line = fits_line and is_prolate
    if fits_line:
        shape = "linear"
    elif fits_plane:
        if is_oblate:
            shape = "regular planar"
        else:
            shape = "irregular planar"
    else:
        shape = "nonplanar"

    return top, shape

def gyradius(atommasses, atomcoords, method="iupac"):
    """Calculate the radius of gyration (or gyradius) of the molecule.

    Parameters
    ----------
    atommasses : array-like
        Atomic masses in atomic mass units (amu).
    atomcoords : array-like
        Atomic coordinates.
    method : str, optional

    Returns
    -------
    array-like

    Raises
    ------
    ValueError
        If `method` is not recognized.

    Examples
    --------
    >>> from overreact import _datasets as datasets

    >>> data = datasets.logfiles["tanaka1996"]["CH3·@UMP2/cc-pVTZ"]
    >>> gyradius(data.atommasses, data.atomcoords)
    0.481
    >>> gyradius(data.atommasses, data.atomcoords, method="mean")
    0.93

    >>> data = datasets.logfiles["symmetries"]["water"]
    >>> gyradius(data.atommasses, data.atomcoords)
    0.31915597673891866
    >>> gyradius(np.ones_like(data.atommasses), data.atomcoords)
    0.6833818299241241
    >>> gyradius(np.ones_like(data.atommasses), data.atomcoords, method="mean")
    0.6833818299241241
    >>> gyradius(data.atommasses, data.atomcoords, method="mean")
    0.7637734749747612
    """
    com = np.average(atomcoords, axis=0, weights=atommasses)
    atomcoords = atomcoords - com
    if method == "iupac":
        return np.sqrt(
            np.average(np.diag(atomcoords @ atomcoords.T), weights=atommasses)
        )
    elif method == "mean":
        return np.sqrt(np.mean(np.diag(atomcoords @ atomcoords.T)))
    else:
        raise ValueError(f"unavailable method: '{method}'")

def inertia(atommasses, atomcoords, align=True):
    r"""Calculate primary moments and axes from the inertia tensor.

    Parameters
    ----------
    atommasses : array-like
        Atomic masses in atomic mass units (amu).
    atomcoords : array-like
        Atomic coordinates.
    align : bool, optional
        If true, the returned coordinates are aligned to the primary axes of
        inertia. The returned axes correspond to the aligned coordinates as
        well.

    Returns
    -------
    moments, axes : array-like
        Primary moments of inertia in ascending order and associated normalized
        axes. Axes are column vectors and always correspond to returned atomic
        coordinates. Units are in amu·Å².
    atomcoords : array-like
        Coordinates centered at the center of mass and, if align was set to
        True, rotated to the primary axes of inertia.

    Examples
    --------
    >>> atommasses = np.array([12.011,  1.008,  1.008,  1.008])  # CH3·
    >>> atomcoords = np.array([[ 0.      ,  0.      , -1.      ],
    ...                        [ 1.07883 ,  0.      , -1.      ],
    ...                        [-0.539415,  0.934294, -1.      ],
    ...                        [-0.539415, -0.934294, -1.      ]])
    >>> moments, axes, atomcoords = inertia(atommasses, atomcoords)
    >>> moments
    array([1.75977704, 1.75977774, 3.51955478])
    >>> axes
    array([[1., 0., 0.],
           [0., 1., 0.],
           [0., 0., 1.]])
    >>> atomcoords
    array([[ 0.      ,  0.      ,  0.      ],
           [ 1.07883 ,  0.      ,  0.      ],
           [-0.539415,  0.934294,  0.      ],
           [-0.539415, -0.934294,  0.      ]])

    This allows one to calculate the rotational constants in cm-1:

    >>> constants.h * constants.centi \
    ...     / (8 * np.pi ** 2 * constants.c \
    ...     * moments * constants.atomic_mass * constants.angstrom ** 2)
    array([9.5794, 9.5794, 4.7897])
    """
    atommasses = np.atleast_1d(atommasses)
    com = np.average(atomcoords, axis=0, weights=atommasses)
    atomcoords = atomcoords - com

    w_coords = np.sqrt(atommasses)[:, np.newaxis] * atomcoords
    squared_w_coords = w_coords ** 2

    i_xx = np.sum(squared_w_coords[:, 1] + squared_w_coords[:, 2])
    i_yy = np.sum(squared_w_coords[:, 0] + squared_w_coords[:, 2])
    i_zz = np.sum(squared_w_coords[:, 0] + squared_w_coords[:, 1])

    i_xy = -np.sum(w_coords[:, 0] * w_coords[:, 1])
    i_xz = -np.sum(w_coords[:, 0] * w_coords[:, 2])
    i_yz = -np.sum(w_coords[:, 1] * w_coords[:, 2])

    inertia_tensor = np.array(
        [[i_xx, i_xy, i_xz], [i_xy, i_yy, i_yz], [i_xz, i_yz, i_zz]]
    )
    moments, axes = np.linalg.eigh(inertia_tensor)
    if align:
        return inertia(atommasses, atomcoords @ axes, align=False)
    logger.debug(f"moments = {moments} amu·Å²")
    # logger.debug(f"axes = {axes} Å")
    # logger.debug(f"atomcoords = {atomcoords} Å")
    return moments, axes, atomcoords

# TODO(schneiderfelipe): this needs rework, see
# https://chemistry.stackexchange.com/questions/74639/how-to-calculate-wavenumbers-of-normal-modes-from-the-eigenvalues-of-the-cartesi/74923#74923
# Ideally, the same Eckart transformation that make this work will also work
# in calc_vibfreqs, so one thing leads to the other.
def calc_hessian(atommasses, atomcoords, vibfreqs, vibdisps):
    """Compute the Hessian matrix from normal modes and frequencies.

    This function does the inverse of what is described in
    https://gaussian.com/vib/.

    Parameters
    ----------
    atommasses : array-like
        Atomic masses in atomic mass units (amu).
    atomcoords : array-like
        Atomic coordinates.
    vibfreqs : array-like
        Frequency magnitudes in cm-1.
    vibdisps : array-like
        Normal modes in cartesian coordinates.

    Returns
    -------
    array-like
        Complete Hessian matrix in cartesian coordinates.

    Notes
    -----
    This is a work in progress!

    Examples
    --------
    >>> from overreact import _datasets as datasets

    >>> data = datasets.logfiles["symmetries"]["water"]
    >>> H = calc_hessian(data.atommasses, data.atomcoords, data.vibfreqs, data.vibdisps)
    >>> calc_vibfreqs(H, data.atommasses)  # doctest: +SKIP
    array([1619.1, 3671.7, 3769.1])
    >>> H  # this is probably incorrect
    array([[ 0.25035519,  0.17924759, -0.21923846, -0.22425124, -0.15503866,
             0.20261806, -0.02610273, -0.02420995,  0.01662027],
           [ 0.17924759,  0.39097884,  0.13979548, -0.12659242, -0.11407083,
             0.08438083, -0.05265485, -0.27690678, -0.2241771 ],
           [-0.21923846,  0.13979548,  0.52730855,  0.23475659,  0.13230333,
            -0.2460061 , -0.01551983, -0.2720957 , -0.28130313],
           [-0.22425124, -0.12659242,  0.23475659,  0.22086973,  0.13025443,
            -0.22492564,  0.00337999, -0.00366091, -0.00983096],
           [-0.15503866, -0.11407083,  0.13230333,  0.13025443,  0.10037856,
            -0.10602169,  0.02478367,  0.01369292, -0.02628153],
           [ 0.20261806,  0.08438083, -0.2460061 , -0.22492564, -0.10602169,
             0.25914009,  0.02230951,  0.02163974, -0.01313383],
           [-0.02610273, -0.05265485, -0.01551983,  0.00337999,  0.02478367,
             0.02230951,  0.02272304,  0.0278711 , -0.00678954],
           [-0.02420995, -0.27690678, -0.2720957 , -0.00366091,  0.01369292,
             0.02163974,  0.0278711 ,  0.26321199,  0.25045664],
           [ 0.01662027, -0.2241771 , -0.28130313, -0.00983096, -0.02628153,
            -0.01313383, -0.00678954,  0.25045664,  0.29443748]])
    """
    dof = 3 * len(atommasses)
    L_cart = np.asarray(vibdisps).reshape((len(vibfreqs), dof)).T
    # this function is correct until here

    L_cart = np.linalg.qr(L_cart, mode="complete")[0]

    atommasses_sqrt = np.sqrt([mass for mass in atommasses for _ in range(3)])
    D = eckart_transform(atommasses, atomcoords)
    M = np.diag(1.0 / atommasses_sqrt)
    L = np.linalg.solve(M @ D, L_cart)

    assert np.allclose(M @ D @ L, L_cart), "L_cart is not orthogonal"

    # this function is correct from here
    nu = np.asarray(vibfreqs) * constants.c / constants.centi
    eigenvalues = (
        (2.0 * np.pi * nu) ** 2
        * (constants.atomic_mass * constants.bohr ** 2)
        / constants.hartree
    )
    eigenvalues = np.block([eigenvalues, np.zeros(dof - len(eigenvalues))])

    f_int = L @ np.diag(eigenvalues) @ L.T
    f_mwc = D @ f_int @ D.T
    return f_mwc * np.outer(atommasses_sqrt, atommasses_sqrt)

# TODO(schneiderfelipe): correct this function and project out translations
# and rotations, see
# https://chemistry.stackexchange.com/questions/74639/how-to-calculate-wavenumbers-of-normal-modes-from-the-eigenvalues-of-the-cartesi/74923#74923
def calc_vibfreqs(hessian, atommasses):
    """Calculate vibrational frequencies.

    This is described in https://gaussian.com/vib/.

    Parameters
    ----------
    hessian : array-like
    atommasses : array-like
        Atomic masses in atomic mass units (amu).
    atomcoords : array-like
        Atomic coordinates.

    Returns
    -------
    vibfreqs : array-like
        Frequency magnitudes in cm-1.

    Examples
    --------
    >>> from overreact import _datasets as datasets

    >>> data = datasets.logfiles["symmetries"]["water"]
    >>> calc_vibfreqs(data.hessian, data.atommasses)
    array([1619.1, 3671.7, 3769.1])
    """
    atommasses_sqrt = np.sqrt([mass for mass in atommasses for _ in range(3)])

    # mass-weighted Hessian
    hessian = np.asarray(hessian) / np.outer(atommasses_sqrt, atommasses_sqrt)

    eigenvalues = np.linalg.eigvals(hessian)

    # TODO(schneiderfelipe): the following probably misses some linear
    # molecules and transition states.
    eigenvalues = np.real(eigenvalues[eigenvalues > 0])[::-1]
    nu = np.sqrt(
        eigenvalues * constants.hartree / (constants.atomic_mass * constants.bohr ** 2)
    ) / (2.0 * np.pi)
    return nu * constants.centi / constants.c

# TODO(schneiderfelipe): ensure this is correct
# https://chemistry.stackexchange.com/questions/74639/how-to-calculate-wavenumbers-of-normal-modes-from-the-eigenvalues-of-the-cartesi/74923#74923
def eckart_transform(atommasses, atomcoords):
    """Compute the Eckart transform.

    This transform is described in https://gaussian.com/vib/.

    Parameters
    ----------
    atommasses : array-like
        Atomic masses in atomic mass units (amu).
    atomcoords : array-like
        Atomic coordinates.

    Returns
    -------
    array-like

    Examples
    --------
    >>> from overreact import _datasets as datasets

    >>> data = datasets.logfiles["tanaka1996"]["Cl·@UMP2/cc-pVTZ"]
    >>> eckart_transform(data.atommasses, data.atomcoords)
    array([[1., 0., 0.],
           [0., 1., 0.],
           [0., 0., 1.]])
    >>> data = datasets.logfiles["symmetries"]["dihydrogen"]
    >>> eckart_transform(data.atommasses, data.atomcoords)
    array([[...]])
    >>> data = datasets.logfiles["symmetries"]["water"]
    >>> eckart_transform(data.atommasses, data.atomcoords)
    array([[-9.42386999e-01,  0.00000000e+00,  0.00000000e+00,
             2.99716727e-01, -2.86166258e-06, -7.42376895e-02,
            -1.19022276e-02,  4.33736541e-03, -1.28081683e-01],
           [-0.00000000e+00, -9.42386999e-01,  0.00000000e+00,
             1.40934586e-02, -1.34562803e-07,  1.01850683e-01,
            -1.52466204e-01, -2.78628770e-01, -2.13218735e-02],
           [-0.00000000e+00, -0.00000000e+00, -9.42386999e-01,
            -1.47912143e-01,  1.41224899e-06, -1.40724409e-01,
            -3.86450545e-02, -1.77596105e-02, -2.61565554e-01],
           [-2.36544652e-01, -0.00000000e+00, -0.00000000e+00,
            -5.97037403e-01, -6.33525274e-01,  2.70812665e-02,
            -2.34354970e-01,  8.09905642e-02,  3.52169811e-01],
           [-0.00000000e+00, -2.36544652e-01, -0.00000000e+00,
            -2.80742485e-02, -2.97900030e-02, -6.93753868e-01,
             5.78451116e-01,  2.06337502e-01,  2.89647600e-01],
           [-0.00000000e+00, -0.00000000e+00, -2.36544652e-01,
             2.94641819e-01,  3.12648820e-01, -1.12274948e-02,
            -4.19760855e-01,  1.83772848e-01,  7.41205673e-01],
           [-2.36544652e-01, -0.00000000e+00, -0.00000000e+00,
            -5.97025305e-01,  6.33536675e-01,  2.68679525e-01,
             2.81773098e-01, -9.82705016e-02,  1.58103880e-01],
           [-0.00000000e+00, -2.36544652e-01, -0.00000000e+00,
            -2.80736797e-02,  2.97905391e-02,  2.87983715e-01,
             2.89697972e-02,  9.03711399e-01, -2.04701877e-01],
           [-0.00000000e+00, -0.00000000e+00, -2.36544652e-01,
             2.94635849e-01, -3.12654446e-01,  5.71869440e-01,
             5.73721626e-01, -1.13019078e-01,  3.00863871e-01]])
    """
    atommasses = np.asarray(atommasses)
    natom = len(atommasses)
    dof = 3 * natom

    moments, axes, atomcoords = inertia(atommasses, atomcoords, align=False)

    x = np.block(
        [
            np.ones(natom)[:, np.newaxis],
            np.zeros(natom)[:, np.newaxis],
            np.zeros(natom)[:, np.newaxis],
        ]
    )
    y = np.block(
        [
            np.zeros(natom)[:, np.newaxis],
            np.ones(natom)[:, np.newaxis],
            np.zeros(natom)[:, np.newaxis],
        ]
    )
    z = np.block(
        [
            np.zeros(natom)[:, np.newaxis],
            np.zeros(natom)[:, np.newaxis],
            np.ones(natom)[:, np.newaxis],
        ]
    )
    x *= np.sqrt(atommasses[:, np.newaxis])
    y *= np.sqrt(atommasses[:, np.newaxis])
    z *= np.sqrt(atommasses[:, np.newaxis])

    D_trans = np.block([x.reshape(1, dof).T, y.reshape(1, dof).T, z.reshape(1, dof).T])
    D_rot = np.array(
        [
            np.cross((atomcoords @ axes)[i], axes[:, j]) / np.sqrt(atommasses[i])
            for i in range(natom)
            for j in range(3)
        ]
    )
    D = np.block([D_trans, D_rot])
    return np.linalg.qr(D, mode="complete")[0]

# thresh >= 0.106
def _equivalent_atoms(
    atommasses, atomcoords, method="cluster", thresh=0.106, plot=False
):
    """Generate groups of symmetry equivalent atoms.

    Parameters
    ----------
    atommasses : array-like
        Atomic masses in atomic mass units (amu).
    atomcoords : array-like
        Atomic coordinates.
    method : str, optional
        Method of partitioning: "atommass" (same atoms same groups), "cluster".
    thresh : int, optional
        Threshold to consider atom clusters.

    Returns
    -------
    groups : sequence of sequence of int
        Groups of symmetry equivalent atoms, in ascending order of size. Each
        element in the list is a sequence of indices, one list for each group
        of equivalent atoms. See examples below.

    Raises
    ------
    ValueError
        If `method` is not recognized.

    Notes
    -----
    This function works for up to ten thousand randomly placed atoms and finds
    the equivalent groups in less than a second. As such, the function performs
    sufficiently well for the current use.

    Examples
    --------
    >>> atommasses = [12.011,  1.008,  1.008,  1.008]  # CH3·
    >>> atomcoords = np.array([[ 0.      ,  0.      , -1.      ],
    ...                        [ 1.07883 ,  0.      , -1.      ],
    ...                        [-0.539415,  0.934294, -1.      ],
    ...                        [-0.539415, -0.934294, -1.      ]])
    >>> for indices in _equivalent_atoms(atommasses, atomcoords):
    ...     indices
    [0]
    [1, 2, 3]

    >>> atommasses = np.array([14.007, 1.008, 1.008, 1.008])  # ammonia
    >>> atomcoords = np.array(
    ...     [
    ...         [0.0, 0.0, 0.07878],
    ...         [0.0, 0.98569, -0.18381],
    ...         [0.85363, -0.49284, -0.18381],
    ...         [-0.85363, -0.49284, -0.18381],
    ...     ]
    ... )
    >>> for indices in _equivalent_atoms(atommasses, atomcoords):
    ...     indices
    [0]
    [1, 2, 3]

    """
    if len(atommasses) == 1:  # atom
        return [[0]]
    elif len(atommasses) == 2:  # diatomic molecule
        if atommasses[0] == atommasses[1]:
            return [[0, 1]]
        return [[0], [1]]

    groups = list()

    def _update_groups_with_condition(condition, groups):
        # condition is assumed to be an array-like of bool
        groups.append(sorted(np.nonzero(condition)[0]))
        return groups

    if method == "cluster":
        D = squareform(pdist(atomcoords))
        # mu = np.outer(atommasses, atommasses) / np.add.outer(
        #     atommasses, atommasses
        # )  # reduced masses
        # D = mu * D  # does this help?

        omega = np.mean(D, axis=0)
        sigma = np.std(D, axis=0)
        delta = np.sqrt(np.sum(D ** 2, axis=0))

        criteria = np.block([[omega], [sigma], [delta]]).T
        Z = linkage(pdist(criteria), method="single")
        clusters = fcluster(Z, thresh, criterion="distance")

        # TODO(schneiderfelipe): this was for debug and should eventually be removed.
        if plot:
            import matplotlib.pyplot as plt

            plt.clf()
            for cluster in np.unique(clusters):
                plt.scatter(
                    criteria[clusters == cluster, 0], criteria[clusters == cluster, 1]
                )
            for i, (atommass, _) in enumerate(zip(atommasses, clusters)):
                plt.annotate(atommass, (criteria[i, 0], criteria[i, 1]))
            plt.xlabel("omega")
            plt.ylabel("sigma")
            plt.show()

        for mass in np.unique(atommasses):
            mass_condition = atommasses == mass
            for cluster in np.unique(clusters[mass_condition]):
                groups = _update_groups_with_condition(
                    mass_condition & (clusters == cluster), groups
                )
    elif method == "atommass":
        for mass in np.unique(atommasses):
            groups = _update_groups_with_condition(atommasses == mass, groups)
    else:
        raise ValueError(f"unavailable method: '{method}'")

    return sorted(groups, key=len)