# Functions needed to run Excalibur
# Data munging in separate file
from os.path import basename
import numpy as np
from astropy.time import Time
from scipy import interpolate, optimize
from tqdm.auto import trange

import warnings
warnings.simplefilter('ignore', np.RankWarning)

###########################################################
# PCA Patching
###########################################################

def pcaPatch(x_values, mask, K=2, num_iters=50):
    """
    Iterative PCA patching, where bad values are replaced
    with denoised values.

    Parameters
    ----------
    x_values : 2D array
        List of values we want to denoise
    mask : 2D array
        Mask for x_values that is true for values that
        we would like to denoise
    K : int, optional (default: 2)
        Number of principal components used for denoising
    num_iters : int, optional (default: 50)
        Number of iterations to run iterative PCA patching

    Returns
    -------
    x_values : 2D ndarray
        x_values with bad values replaced by denoised values
    mean_x_values : 2D ndarray
        Mean of x_values over all exposures.
        Used as fiducial model of line positions
        PCA is done over deviations from this fiducial model
    denoised_xs : 2D ndarray
        Denoised x values from PCA reconstruction
    uu, ss, vv : ndarrays
        Arrays from single value decomposition.  Used to
        reconstruct principal components and their corresponding
        coefficients
    """
    K = int(K)

    for i in range(num_iters):
        # There should be no more NaN values in x_values
        assert np.sum(np.isnan(x_values)) == 0
        # Redefine mean
        mean_x_values = np.mean(x_values,axis=0)

        # Run PCA
        uu,ss,vv = np.linalg.svd(x_values-mean_x_values, full_matrices=False)

        # Repatch bad data with K PCA reconstruction
        denoised_xs = mean_x_values + np.dot((uu*ss)[:,:K],vv[:K])
        x_values[mask] = denoised_xs[mask]

    return x_values, mean_x_values, denoised_xs, uu, ss, vv

def patchAndDenoise(x_values, orders, waves,
                    x_errors=None, times=None, file_list=None,
                    K=2, num_iters=50, running_window=9,
                    line_cutoff=0.5, file_cutoff=0.5,
                    outlier_cut=0, verbose=False):
    """
    - Vet for bad lines/exposures
    - Initial patch of bad data with running mean
    - Iterative patch with PCA for specified number of iterations
    - Optional second round of interative PCA patching to catch outliers

    Parameters
    ----------
    x_values, x_errors : 2D ndarray
        Array of line positions for all lines for each exposure and errors
    orders : 1D ndarray
        Array of orders for each line
    waves : 1D ndarray
        Array of wavelengths for each line
    times : 1D ndarray, optional
        Time stamps for each exposure
        Just written into the returned patch dictionary
        (not explicitely used for this code, but helps with evalWaveSol)
    K : int, optional (default: 2)
        Number of principal components used for denoising
    num_iters : int, optional (default: 50)
        Number of iterations to run iterative PCA patching
    running_window ; int, optional (default: 9)
        Window size of running mean used to initialize pixel values
        for lines missing measured pixel values
    line_cutoff, file_cutoff : float [0,1], optional (default: 0.5)
        Cutoff for bad lines or files, respectively.
        i.e. defaults cut lines that show up in less than 50% of exposure
        and files that contain less than 50% of all lines
    outlier_cut : float, optional (default: 0)
        Sigma cut used to identify outliers following first round of
        iterative PCA.
        Note: 0 means this process isn't done.

    Returns
    -------
    patch : dict
        Dictionary containing all the useful information from this process
        (among, very many uselss information!)
        Needed for evalWaveSol function
    """
    # Arrays that aren't needed, but are helpful to have in returned dictionary
    if times is None:
        times = np.zeros_like(file_list)
    if x_errors is None:
        x_errors = np.zeros_like(x_errors)
    if file_list is None:
        file_list = np.zeros_like(times)

    ### Vetting
    # Find where there is no line information
    x_values[np.nan_to_num(x_values) < 1] = np.nan

    # Mask out of order lines
    out_of_order = np.zeros_like(x_values,dtype=bool)
    for m in np.unique(orders):
        I = orders==m
        wave_sort = np.argsort(waves[I])
        for i, exp in enumerate(x_values):
            exp_sort = exp[I][wave_sort]
            exp_diff = np.diff(exp_sort)
            left_diff = np.insert(exp_diff<0,0,False)
            right_diff = np.append(exp_diff<0,False)
            exp_mask = np.logical_or(left_diff,right_diff)
            out_of_order[i,I] = exp_mask.copy()
    x_values[out_of_order] = np.nan
    if verbose:
        num_bad = np.sum(out_of_order)
        num_total = out_of_order.size
        print('{:.3}% of lines masked'.format(
             (num_bad)/num_total*100))

    # Get rid of bad lines
    good_lines = np.mean(np.isnan(x_values),axis=0) < line_cutoff
    # Trim everything
    orders = orders[good_lines]
    waves  = waves[good_lines]
    x_values = x_values[:,good_lines]
    x_errors = x_errors[:,good_lines]
    if verbose:
        num_good = np.sum(good_lines)
        num_total = good_lines.size
        print('{} of {} lines cut ({:.3}%)'.format(
            (num_total - num_good),num_total,
            (num_total - num_good)/num_total*100))

    # Get rid of bad files
    good_files = np.mean(np.isnan(x_values),axis=1) < file_cutoff
    # Trim everything
    x_values = x_values[good_files]
    x_errors = x_errors[good_files]
    file_names = file_list[good_files]
    file_times = times[good_files]
    if verbose:
        num_good = np.sum(good_files)
        num_total = good_files.size
        print('{} of {} files cut ({:.3}%)'.format(
            (num_total - num_good),num_total,
            (num_total - num_good)/num_total*100))
        print('Files that were cut:')
        print(file_list[~good_files])

    ### Patching
    # Initial patch of bad data with mean
    bad_mask = np.isnan(x_values) # mask to identify patched x_values
    if running_window > 0:
        half_size = int(running_window//2)
        counter = 6
        while np.sum(np.isnan(x_values)) > 0:
            for i in range(x_values.shape[0]):
                # Identify files in window
                file_range = [max((i-half_size,0)), min((i+half_size+1,x_values.shape[1]))]
                # Find mean of non-NaN values
                run_med = np.nanmean(x_values[file_range[0]:file_range[1],:],axis=0)
                # Patch NaN values with mean for center file
                x_values[i][bad_mask[i,:]] = run_med[bad_mask[i,:]]
            counter -= 1
            if counter < 0:
                print("Persistant NaNs with running mean.")
                print("Replacing remaining NaNs with global mean.")
                tot_mean = np.nanmean(x_values,axis=0)[None,...]*np.ones_like(x_values)
                x_values[np.isnan(x_values)] = tot_mean[np.isnan(x_values)]
                break
    else: # don't bother with running mean
        mean_values = np.nanmean(x_values,axis=0)
        mean_patch = np.array([mean_values for _ in range(x_values.shape[0])])
        x_values[bad_mask] = mean_patch[bad_mask]

    # Iterative PCA
    pca_results = pcaPatch(x_values, bad_mask, K=K, num_iters=num_iters)
    x_values, mean_x_values, denoised_xs, uu, ss, vv = pca_results

    # Mask line center outliers
    if outlier_cut > 0:
        x_resids  = x_values-denoised_xs
        out_mask  = abs(x_resids-np.mean(x_resids)) > (outlier_cut*np.nanstd(x_resids))
        if verbose:
            num_out = np.sum(out_mask)
            num_total = out_mask.size
            num_bad = np.sum(np.logical_and(out_mask,bad_mask))
            print('{:.3}% of lines marked as Outliers'.format(
                 (num_out)/num_total*100))
            print('{:.3}% of lines marked as Outliers that were PCA Patched'.format(
                 (num_bad)/num_total*100))
        pca_results = pcaPatch(x_values, np.logical_or(bad_mask,out_mask),
                               K=K, num_iters=num_iters)
        x_values, mean_x_values, denoised_xs, uu, ss, vv = pca_results

    # Load in all relevant information into dictionary
    patch_dict = {}
    patch_dict['K'] = K
    # Exposure Information
    patch_dict['files']  = file_names.copy()
    patch_dict['times']  = file_times.copy()
    min_date = Time(file_times.min(),format='mjd').isot.split('T')[0]
    yr, mn, dy = min_date.split('-')
    patch_dict['min_date'] = yr[2:]+mn+dy
    min_date = Time(file_times.max(),format='mjd').isot.split('T')[0]
    yr, mn, dy = min_date.split('-')
    patch_dict['max_date'] = yr[2:]+mn+dy
    # Line Information
    patch_dict['orders'] = orders.copy()
    patch_dict['waves']  = waves.copy()
    # Line Measurement Information
    patch_dict['x_values'] = x_values.copy()
    patch_dict['x_errors'] = x_errors.copy()
    patch_dict['denoised_xs'] = denoised_xs.copy()
    patch_dict['mean_xs']  = mean_x_values.copy()
    patch_dict['bad_mask'] = bad_mask.copy()
    # PCA Information
    patch_dict['u'] = uu.copy()
    patch_dict['s'] = ss.copy()
    patch_dict['v'] = vv.copy()
    patch_dict['ec'] = (uu*ss)[:,:K]
    # Outlier Information
    if outlier_cut > 0:
        patch_dict['out_mask'] = out_mask.copy()

    return patch_dict

# Functions for recovering the date of an exposure
def isot2date(isot_time):
    yr, mn, dy = isot_time.split('T')[0].split('-')
    return str(int(yr[2:]+mn+dy))

def mjds2dates(times):
    return np.array([isot2date(Time(t, format='mjd').isot) for t in times]).astype(str)

def files2dates(files):
    dates = []
    for file_name in files:
        date = basename(file_name).split('_')[-1].split('.')[0]
        dates.append(date)
    return np.array(dates).astype(str)

def interpPCA(new_interps, patch_dict, intp_deg=1, interp_key='times'):
    """
    Interpolate eigen coefficients with respect to chosen interp_key.

    Parameters
    ----------
    new_interps : 1D array or float
        New values for which we want principal component coefficients
    patch_dict : dictionary
        Result of patchAndDenoise function
    intp_deg : int, optional (default: 1)
        Degree of inteprolation
    interp_key : str, optional (default: 'times')
        Key in patch_dict of value we want to interpolate the principal
        component coeficients with respect to.

    Returns
    -------
    denoised_xs : 1D ndarray
        Pixel positions for calibration lines defined by order and
        wavelength in the provided patch.
    """
    try:
        len(new_interps)
        unravel = False
    except TypeError:
        new_interps = [new_interps]
        unravel = True
    K  = patch_dict['K']
    vv = patch_dict['v']

    # Set up nightly code if needed
    if intp_deg=='poly':
        new_dates = mjds2dates(new_interps)
        cal_dates = files2dates(patch_dict['files'])

    # Interpolate eigen coefficients
    new_ecs = np.empty((len(new_interps),K),dtype=float)
    for i in range(K):
        if intp_deg=='poly':
            for date in np.unique(new_dates):
                new_date_mask = new_dates==date
                cal_date_mask = cal_dates==date
                if np.sum(cal_date_mask) < 5:
                    continue

                z = np.polyfit(patch_dict['times'][cal_date_mask][3:],
                               patch_dict['ec'][cal_date_mask][3:,i],3)
                new_ecs[new_date_mask,i] = np.poly1d(z)(new_interps[new_date_mask])

        elif intp_deg==0:
            # Find nearest time for each time
            # IS THERE A WAY TO DO THIS NOT ONE BY ONE???
            for i_idx, i in enumerate(new_interps):
                idx = np.abs(patch_dict[interp_key]-i).argmin()
                new_ecs[i_idx,i] = patch_dict['ec'][idx,i]

        elif intp_deg==1: # Default
            f = interpolate.interp1d(patch_dict[interp_key],patch_dict['ec'][:,i],kind='linear',
                                 bounds_error=False,fill_value=np.nan)
            new_ecs[:,i] = f(new_interps)

        elif intp_deg==3:
            f = interpolate.interp1d(patch_dict[interp_key],patch_dict['ec'][:,i],kind='cubic',
                                 bounds_error=False,fill_value=np.nan)
            new_ecs[:,i] = f(new_interps)

        else:
            tck = interpolate.splrep(patch_dict[interp_key],patch_dict['ec'][:,i],k=interp_deg)
            new_ecs[:,i] = interpolate.splev(new_interps,tck)

    # Construct x values for that period of time
    denoised_xs = np.dot(new_ecs,vv[:K]) + patch_dict['mean_xs']

    if unravel:
        return denoised_xs[0]
    else:
        return denoised_xs

###########################################################
# Interpolation Code
###########################################################

def interpWaveSol(newx, newm, x, m, data, interp_deg='pchip'):
    """
    Interpolate from known lines to construct a wavelength solution
    over new pixels and orders.  Proceeds order by order.

    Parameters
    ----------
    newx, newm : 1D arrays
        Line centers and matching orders for which to return wavelengths
    x, m, data : 1D arrays
        Line centers, matching orders, and known wavelengths for
        calibration lines
    interp_deg : str or int (1), optional (default: 'pchip')
        Specifies the type of interpolation
        - 'pchip', default, cubic spline with inforced monotonicity
        - 'inverse', fit wave vs. line position with a cubic spline
          (the idea was the constrain the cubic to be a function,
           but pchip does a better job and does it faster)
        - 1, linear interplation (I forget why this is still an option)
        - any other int, spline interpolation of specified degree

    Returns
    -------
    prediction : 1D ndarray
        Wavelengths for the input newx and newm
    """

    # Make all the searches match-able
    newm = np.array(newm,dtype=int)
    m = np.array(m,dtype=int)

    # Initialize prediction array
    prediction = np.zeros_like(newx)
    prediction[:] = np.nan
    # Go order by order
    for r in np.unique(m):
        Inew = newm == r
        I = m==r
        if (np.sum(Inew)>0) and (np.sum(I)>0):
            # Sort lines in increasing wavelength
            wave_sort = np.argsort(data[I])
            ord_xs = x[I][wave_sort]
            ord_data = data[I][wave_sort]
            # Make sure the lines are in order
            assert np.all(np.diff(ord_xs) > 0.),print(r,ord_xs[1:][np.argmin(np.diff(ord_xs))])

            # Interpolate
            if interp_deg==1: # linear interpolation
                prediction[Inew] = np.interp(newx[Inew], ord_xs, ord_data,
                                             left=np.nan,right=np.nan,k=interp_deg)
            elif interp_deg == 'pchip': # PCHIP interpolator
                f = interpolate.PchipInterpolator(ord_xs,ord_data,extrapolate=False)
                prediction[Inew] = f(newx[Inew])
            elif interp_deg == 'inverse': # Interpolating wave vs. pixel
                f = interpolate.interp1d(ord_data, ord_xs, kind='cubic',
                                         bounds_error=False,fill_value=0)
                inv_f = lambda x, a: f(x)-a

                f0 = interpolate.UnivariateSpline(ord_xs,ord_data,ext=1)

                predict = np.zeros(np.sum(Inew),dtype=float)
                for i,pix in enumerate(newx[Inew]):
                    if (pix <= ord_xs.min()) or (pix >= ord_xs.max()): # No extrapolation
                        predict[i] = np.nan
                    else:
                        try:
                            x0 = f0(pix)
                            predict[i] = optimize.newton(inv_f,x0,args=(pix,))
                        except RuntimeError:
                            predict[i] = np.nan
                prediction[Inew] = predict
            else: # spline interpolation
                tck = interpolate.splrep(ord_xs, ord_data, k=interp_deg)
                predict = interpolate.splev(newx[Inew],tck,ext=1)
                predict[predict==0] = np.nan
                prediction[Inew] = predict
    return prediction


import json
import logging
import os
import traceback
from itertools import compress

import numpy as np
import pymbar
import pytraj as pt
from openff.units import unit
from pymbar import timeseries
from scipy.interpolate import Akima1DInterpolator

from paprika.io import PaprikaDecoder, PaprikaEncoder
from paprika.utils import check_unit

logger = logging.getLogger(__name__)

class fe_calc(object):
    """
    Computes the free energy for an APR transformation. After calling ``compute_free_energy()``, the
    results are stored in a dictionary called ``results`` in units of kcal/mol.

    To get the binding free energy at standard state (ΔG°), we have used the following sign convention:

    .. math ::
        ΔG° = ΔG_{attach} + ΔG_{pull} - ΔG_{release} + ΔG_{reference}

    .. note ::
        It would be great to add unit support here from ``pint``.

    .. warning ::
        This class really ought to be split into a few smaller classes that sublcass a ``BaseAnalysis`` class. There
        could be separate ``MBARAnalysis`` and ``TIAnalysis`` classes, for example. This would make it much more modular
        and easy to test where TI and MBAR disagree. This could also be used to benchmark autocorrelation-based and
        blocking-analysis-based methods for evaluating the statistical inefficiency.

    """

    @property
    def temperature(self):
        """float or unit.Quantity: The temperature used during the simulation. This will update β (1/kT) as well."""
        return self._temperature

    @temperature.setter
    def temperature(self, new_temperature):
        """Update temperature and β with a new temperature."""
        self._temperature = check_unit(new_temperature, base_unit=unit.kelvin)
        self.beta = 1 / (self.k_B * self._temperature)

    @property
    def topology(self):
        """
        os.PathLike: The topology (prmtop or pdb) file used for the analysis.
        This should match the topology used for the simulation.
        """
        return self._topology

    @topology.setter
    def topology(self, value):
        self._topology = value

    @property
    def trajectory(self):
        """
        os.PathLike: File name of the trajectories to be analyzed in each window (can include a wildcard).
        """
        return self._trajectory

    @trajectory.setter
    def trajectory(self, value):
        self._trajectory = value

    @property
    def path(self):
        """
        os.PathLike: The parent directory that contains the simulation windows.
        """
        return self._path

    @path.setter
    def path(self, value):
        self._path = value

    @property
    def restraint_list(self):
        """
        list: The list of restraints to be used for analysis.
        """
        return self._restraint_list

    @restraint_list.setter
    def restraint_list(self, value):
        self._restraint_list = value

    @property
    def changing_restraints(self):
        """
        dict: Dictionary containing which restraints change during which phase of the calculation.

        .. note ::
            This should probably be a private attribute, as this property is determined automatically.

        """
        return self._changing_restraints

    @changing_restraints.setter
    def changing_restraints(self, value):
        self._changing_restraints = value

    @property
    def orders(self):
        """
        The sorted order of windows for analysis. In principle, windows could be out-of-order if subsequent additional
        sampling was requested.

        .. note ::
            As far as I know, we have not tested analysis on windows out of order. We imagined that one could add additional
            λ windows through multiple runs by modifying existing restraints (e.g., restraint values of [0, 0.5, 1.0, 0.8])
            and this module would be able to correctly sort the simulation directories and restraint targets.

        .. note ::
            This should probably be a private attribute, as this property is determined automatically.

        """
        return self._orders

    @orders.setter
    def orders(self, value):
        self._orders = value

    @property
    def simulation_data(self):
        """
        dict: Dictionary containing collected trajectory values for the relevant restraints and windows

        .. note ::
            This should probably be a private attribute, as this property is determined automatically.

        """
        return self._simulation_data

    @simulation_data.setter
    def simulation_data(self, value):
        self._simulation_data = value

    @property
    def methods(self):
        """
        list: List of analysis methods to be performed. This is a combination of free energy method (e.g., MBAR, TI, ...)
        and de-correlation method (blocking, autocorrelation, ...).

        Implemented methods are:

            - ``mbar-autoc``
            - ``mbar-block``
            - ``ti-block``

        .. note ::
            This is really fragile. We should definitely use something like an ``ENUM`` here to check for the few combinations
            that we support.

        .. todo ::
            - Add ``ti-autoc``.
            - Add ``wham-block``.
            - Add ``wham-autoc``.
            - Clarify naming.
            - Look into using ``pymbar`` for decorrelation.

        """
        return self._methods

    @methods.setter
    def methods(self, value):
        self._methods = value

    @property
    def conservative_subsample(self):
        """
        bool: Whether the statistical inefficiency is rounded up to the nearest integer. If ``False``, a non-integer
        value
        is used.
        """
        return self._conservative_subsample

    @conservative_subsample.setter
    def conservative_subsample(self, value):
        self._conservative_subsample = value

    @property
    def bootcycles(self) -> int:
        """
        int: The number of bootstrap iterations for the TI methods.

        **Default**: ``1000``
        """
        return self._bootcycles

    @bootcycles.setter
    def bootcycles(self, value):
        self._bootcycles = value

    @property
    def compute_roi(self) -> bool:
        """
        bool: Whether to compute the return on investment (ROI) value for each window. The more negative the ROI, the
        better the return on investment of computing more frames for this particular window.
        """
        return self._compute_roi

    @compute_roi.setter
    def compute_roi(self, value):
        self._compute_roi = value

    @property
    def compute_largest_neighbor(self):
        """
        bool: Whether to find and store the maximum SEM to the neighbor windows. This is useful if using the "scale_w"
        approach which is described in Equation (1) in: https://pubs.acs.org/doi/10.1021/acs.jctc.9b00748.
        """
        return self._compute_largest_neighbor

    @compute_largest_neighbor.setter
    def compute_largest_neighbor(self, value):
        self._compute_largest_neighbor = value

    @property
    def ti_matrix(self):
        """
        str: If ``full``, the TI mean and SEM free energy is computed between all windows (i.e., the energy differences between
        all windows and all other windows). If ``diagonal``, the mean and SEM of the free energy is computed between the
        first window and all other windows, as well as between all neighboring windows. If ``endpoints``, the mean and
        SEM free energy is computed between only the first and the last window.

        For most cases, ``diagonal`` should be sufficient and ``full`` is overkill.

        .. note ::
            This is fragile and we should be checking for the valid strings supported.

        """
        return self._ti_matrix

    @ti_matrix.setter
    def ti_matrix(self, value):

        if value not in ["full", "diagonal", "endpoints"]:
            raise ValueError(f"{value} is not a supported integration scheme.")

        self._ti_matrix = value

    @property
    def exact_sem_each_ti_fraction(self):
        """
        bool: Whether the SEM is computed once for the full data set and then the SEM for each fraction is estimated
        based on the total SEM and the fractional number of uncorrelated data points. If ``True``, the SEM will be recomputed
        each fraction using just that fraction of the raw data.
        """
        return self._exact_sem_each_ti_fraction

    @exact_sem_each_ti_fraction.setter
    def exact_sem_each_ti_fraction(self, value):
        self._exact_sem_each_ti_fraction = value

    @property
    def fractions(self) -> list:
        """
        list: A list of fractions of the total data for which a free energy will be computed. Default is [1.0].
        """
        return self._fractions

    @fractions.setter
    def fractions(self, value):

        for fraction in value:
            if fraction < 0.0 or fraction > 1.0:
                raise ValueError(
                    f"Unable to calculation fraction of the data: {fraction}."
                )

        self._fractions = value

    @property
    def results(self):
        """
        dict: A dictionary containing the results. The results dictionary is indexed first by phase, and then by
        method. That is, ``results["attach"]["mbar-block"]`` will contain the results from analyzing the attach
        phase with the MBAR free energy estimator and blocking analysis used to estimate the SEM.

        The final free energy and uncertainty estimate is specified in the ``fe`` and ``sem`` keys.

        If multiple fractions of the data are specified, the free energy and SEM from each fraction are stored in a nested
        dictionary under the keys ``fraction_fe`` and ``fractrion_sem``. The number of frames analyzed for each fraciton
        is stored under ``fraction_n_frames``.

        The full free energy matrix (window-to-window free energy differences) is stored in the ``fe_matrix`` entry.
        Likewise, the full SEM matrix (window-to-window free energy SEMs) is stored in the ``sem_matrix`` entry.

        The work to release the guest to standard concentration is stored under ``ref_state_work``, and is
        negative by convention.

        The format of a typical dictionary will resemble this (where I have a omitted the values in the matrices for
        clarity):

        ::

            {'attach': {'mbar-block': {'fe': 13.019994367423227,
                                       'fe_matrix': array(),
                                       'fraction_fe': {'1.0': 13.019994367423227},
                                       'fraction_fe_matrix': {'1.0': array()},
                                       'fraction_n_frames': {'1.0': 2280000},
                                       'fraction_sem': {'1.0': 0.12783177525365627},
                                       'fraction_sem_matrix': {'1.0': array()},
                                       'n_frames': 2280000,
                                       'sem': 0.12783177525365627,
                                       'sem_matrix': array()},
                        'window_order': array([ 0,  1,  2, ..., 12, 13, 14])},
             'pull': {'mbar-block': {'fe': 5.367885925413715,
                                     'fe_matrix': array(),
                                     'fraction_fe': {'1.0': 5.367885925413715},
                                     'fraction_fe_matrix': {'1.0': array()},
                                     'fraction_n_frames': {'1.0': 2900000},
                                     'fraction_sem': {'1.0': 0.21231171453084993},
                                     'fraction_sem_matrix': {'1.0': array()},
                                     'n_frames': 2900000,
                                     'sem': 0.21231171453084993,
                                     'sem_matrix': array()},
                      'window_order': array([ 0,  1,  2, ..., 43, 44, 45])},
             'ref_state_work': -7.141514582862005}

        .. note ::
            This will be automatically populated with the outcome of the analysis. Do not directly modify these values.

        """
        return self._results

    @results.setter
    def results(self, value):
        self._results = value

    @property
    def energy_unit(self):
        """pint.unit: The based unit for energy."""
        return self._energy_unit

    @energy_unit.setter
    def energy_unit(self, value: unit.Quantity):
        self._energy_unit = value

    @property
    def distance_unit(self):
        """pint.unit: The base unit for distance."""
        return self._distance_unit

    @distance_unit.setter
    def distance_unit(self, value: unit.Quantity):
        self._distance_unit = value

    @property
    def angle_unit(self):
        """pint.unit: The base unit for angles."""
        return self._angle_unit

    @angle_unit.setter
    def angle_unit(self, value: unit.Quantity):
        self._angle_unit = value

    @property
    def temperature_unit(self):
        """pint.unit: The base unit for temperature."""
        return self._temperature_unit

    @temperature_unit.setter
    def temperature_unit(self, value: unit.Quantity):
        self._temperature_unit = value

    def __init__(self):

        self._energy_unit = unit.kcal / unit.mole
        self._distance_unit = unit.angstrom
        self._angle_unit = unit.degrees
        self._temperature_unit = unit.kelvin

        self._temperature = 298.15 * self._temperature_unit
        self.k_B = 1.987204118e-3 * self._energy_unit / self._temperature_unit
        self.beta = 1 / (self.k_B * self._temperature)
        self._topology = None
        self._trajectory = None
        self._path = None
        self._restraint_list = []
        self._changing_restraints = None
        self._orders = None
        self._simulation_data = None
        self._methods = ["mbar-block"]
        self._conservative_subsample = False
        self._bootcycles = 1000
        self._compute_roi = False
        self._compute_largest_neighbor = False
        self._ti_matrix = "full"
        self._exact_sem_each_ti_fraction = False
        self._fractions = [1.0]
        self._results = {}

    def collect_data(self, single_topology=False):
        """
        Gather simulation data on the distance, angle, and torsion restraints that change during the simulation.

        Parameters
        ----------
        single_topology: bool
            Whether a single `topology` file is read for all windows
        """

        self.changing_restraints = self.identify_changing_restraints()
        self.orders = self.determine_window_order()
        self.simulation_data = self.read_trajectories(single_topology=single_topology)

    def collect_data_from_json(self, filepath):
        """
        Read in simulation data from a JSON file.

        Parameters
        ----------
        filepath: os.PathLike
            The name of the JSON file.
        """
        with open(filepath, "r") as f:
            json_data = f.read()
            data = json.loads(json_data, cls=PaprikaDecoder)

        self.changing_restraints = data["changing_restraints"]
        self.orders = data["orders"]
        self.simulation_data = data["simulation_data"]

    def identify_changing_restraints(self):
        """Figure out which restraints change during each phase of the calculation.

        Returns
        -------
        changing_restraints : dict
            A dictionary containing which restraints change during which phase of the calculation
        """

        changing_restraints = {"attach": [], "pull": [], "release": []}

        for phase in ["attach", "pull", "release"]:
            if phase == "attach" or phase == "release":
                changing_parameter = "force_constants"
            else:
                changing_parameter = "targets"
            for restraint in self.restraint_list:
                if restraint.phase[phase][changing_parameter] is not None:
                    static = all(
                        np.isclose(x, restraint.phase[phase][changing_parameter][0])
                        for x in restraint.phase[phase][changing_parameter]
                    )
                else:
                    static = True

                changing_restraints[phase].append(not static)

        return changing_restraints

    def determine_window_order(self):
        """Order the trajectories (i.e., simulation windows) in terms of increasing force constants and
        targets for each restraint.

        Returns
        -------
        orders : dict
            The sorted order of windows for analysis
        """

        orders = {"attach": [], "pull": [], "release": []}
        active_attach_restraints = list(
            compress(self.restraint_list, self.changing_restraints["attach"])
        )
        active_pull_restraints = list(
            compress(self.restraint_list, self.changing_restraints["pull"])
        )
        active_release_restraints = list(
            compress(self.restraint_list, self.changing_restraints["release"])
        )

        attach_orders = []
        pull_orders = []
        release_orders = []

        for restraint in active_attach_restraints:
            attach_orders.append(
                np.argsort(restraint.phase["attach"]["force_constants"])
            )
        if not all([np.array_equal(attach_orders[0], i) for i in attach_orders]):
            raise Exception(
                "The order of increasing force constants is not the same in all restraints."
            )
        elif attach_orders:
            orders["attach"] = attach_orders[0]
        else:
            orders["attach"] = np.empty(0)

        for restraint in active_pull_restraints:
            pull_orders.append(np.argsort(restraint.phase["pull"]["targets"]))
        if not all([np.array_equal(pull_orders[0], i) for i in pull_orders]):
            raise Exception(
                "The order of increasing target distances is not the same in all restraints."
            )
        elif pull_orders:
            orders["pull"] = pull_orders[0]
        else:
            orders["pull"] = np.empty(0)

        for restraint in active_release_restraints:
            release_orders.append(
                np.argsort(restraint.phase["release"]["force_constants"])
            )
        if not all([np.array_equal(release_orders[0], i) for i in release_orders]):
            raise Exception(
                "The order of increasing force constants is not the same in all restraints."
            )
        elif release_orders:
            orders["release"] = release_orders[0]
        else:
            orders["release"] = np.empty(0)

        return orders

    def read_trajectories(self, single_topology=False):
        """For each each phase and window, and for each non-static restraint, parse the trajectories to
        get the restraint values.

        Parameters
        ----------
        single_topology : bool
            Whether a single `topology` file is read for all windows

        Returns
        -------
        data : dict
            Dictionary containing restraint values for analysis
        """

        data = {"attach": [], "pull": [], "release": []}

        ordered_attach_windows = [
            os.path.join(self.path, "a{:03d}".format(i))
            for i in self.orders["attach"]
            if i is not None
        ]
        ordered_pull_windows = [
            os.path.join(self.path, "p{:03d}".format(i))
            for i in self.orders["pull"]
            if i is not None
        ]
        ordered_release_windows = [
            os.path.join(self.path, "r{:03d}".format(i))
            for i in self.orders["release"]
            if i is not None
        ]

        active_attach_restraints = np.asarray(self.restraint_list)[
            self.changing_restraints["attach"]
        ]
        active_pull_restraints = np.asarray(self.restraint_list)[
            self.changing_restraints["pull"]
        ]
        active_release_restraints = np.asarray(self.restraint_list)[
            self.changing_restraints["release"]
        ]

        # Niel: I'm just checking if *one* restraint is `continuous_apr`,
        # which should be the same value for all restraints.
        if len(active_attach_restraints) > 0:
            if (
                active_attach_restraints[0].continuous_apr
                and self.orders["attach"].size > 0
                and self.orders["pull"].size > 0
            ):
                logger.debug(
                    "Replacing {} with {} in {} for `continuous_apr`...".format(
                        ordered_attach_windows[-1],
                        ordered_pull_windows[0],
                        ordered_attach_windows,
                    )
                )
                ordered_attach_windows[-1] = ordered_pull_windows[0]

        if len(active_release_restraints) > 0:
            if (
                active_release_restraints[0].continuous_apr
                and self.orders["release"].size > 0
                and self.orders["pull"].size > 0
            ):
                logger.debug(
                    "Replacing {} with {} in {} for `continuous_apr`...".format(
                        ordered_release_windows[-1],
                        ordered_pull_windows[-1],
                        ordered_release_windows,
                    )
                )
                ordered_release_windows[-1] = ordered_pull_windows[-1]

        for window_index, window in enumerate(ordered_attach_windows):
            phase = "attach"
            data[phase].append([])
            traj = load_trajectory(
                window, self.trajectory, self.topology, single_topology
            )
            for restraint_index, restraint in enumerate(active_attach_restraints):
                data[phase][window_index].append([])
                data[phase][window_index][restraint_index] = read_restraint_data(
                    traj, restraint
                )

        for window_index, window in enumerate(ordered_pull_windows):
            phase = "pull"
            data[phase].append([])
            traj = load_trajectory(
                window, self.trajectory, self.topology, single_topology
            )
            for restraint_index, restraint in enumerate(active_pull_restraints):
                data[phase][window_index].append([])
                data[phase][window_index][restraint_index] = read_restraint_data(
                    traj, restraint
                )

        for window_index, window in enumerate(ordered_release_windows):
            phase = "release"
            data[phase].append([])
            traj = load_trajectory(
                window, self.trajectory, self.topology, single_topology
            )
            for restraint_index, restraint in enumerate(active_release_restraints):
                data[phase][window_index].append([])
                data[phase][window_index][restraint_index] = read_restraint_data(
                    traj, restraint
                )

        return data

    def prepare_data(self, phase):
        number_of_windows = len(self.simulation_data[phase])
        data_points = [len(np.asarray(x).T) for x in self.simulation_data[phase]]
        max_data_points = max(data_points)
        active_restraints = list(
            compress(self.restraint_list, self.changing_restraints[phase])
        )
        force_constants = [
            np.copy(i.phase[phase]["force_constants"]) for i in active_restraints
        ]
        targets = [i.phase[phase]["targets"] for i in active_restraints]

        ordered_force_constants = [i[self.orders[phase]] for i in force_constants]
        ordered_targets = [i[self.orders[phase]] for i in targets]

        return (
            number_of_windows,
            data_points,
            max_data_points,
            active_restraints,
            ordered_force_constants,
            ordered_targets,
            self.simulation_data[phase],
        )

    def run_mbar(self, phase, prepared_data, method, verbose=False):
        """
        Compute the free energy matrix for a series of windows. We'll follow the pymbar nomenclature for data structures.

        Parameters
        ----------
        phase: str
            The phase of the calculation to analyze.
        prepared_data: :class:`np.array`
            The list of "prepared data" including the number of windows, data points, which restraints are changing,
            their force constants and targets, and well as the order of the windows. This probably ought to be
            redesigned.
        method: str
            The method used to calculate the SEM.
        verbose: bool, optional
            Whether to set the `verbose` option on pyMBAR.
        """

        # Unpack the prepared data
        (
            num_win,
            data_points,
            max_data_points,
            active_rest,
            force_constants,
            targets,
            ordered_values,
        ) = prepared_data

        # Number of data points in each restraint value array
        N_k = np.array(data_points)

        # Setup the reduced potential energy array. ie, the potential of each window's
        # coordinates in each window's potential function
        u_kln = np.zeros([num_win, num_win, max_data_points], np.float64)

        # Transpose force_constants and targets into "per window" format, instead of
        # the "per restraint" format.
        target_units = np.array([targets[r][0].units for r in range(len(active_rest))])
        force_units = np.array(
            [force_constants[r][0].units for r in range(len(active_rest))]
        )

        force_constants_T = np.asarray(force_constants).T * force_units
        targets_T = np.asarray(targets).T * target_units

        # Note, the organization of k = coordinate windows, l = potential windows
        # seems to be opposite of the documentation. But I got wrong numbers
        # the other way around.
        for k in range(num_win):  # Coordinate windows
            for l in range(num_win):  # Potential Windows

                for r, rest in enumerate(active_rest):  # Restraints
                    # If this is a dihedral, we need to shift around restraint value
                    # on the periodic axis to make sure the lowest potential is
                    # used.
                    if rest.mask3 is not None and rest.mask4 is not None:
                        target = targets_T[l, r]
                        # Coords from coord window, k
                        bool_list = ordered_values[k][r] < target - 180.0 * unit.degrees
                        ordered_values[k][r][bool_list] += 360.0 * unit.degrees
                        bool_list = ordered_values[k][r] > target + 180.0 * unit.degrees
                        ordered_values[k][r][bool_list] -= 360.0 * unit.degrees

                # Compute the potential ... for each frame, sum the contributions for each restraint
                # Note, we multiply by beta, and do some extra [l,:,None] to
                # get the math operation correct.
                u_kln[k, l, 0 : N_k[k]] = sum(
                    [
                        self.beta * k * (val - eq) ** 2
                        for k, val, eq in zip(
                            force_constants_T[l], ordered_values[k], targets_T[l]
                        )
                    ]
                ).magnitude

        g_k = np.ones([num_win], np.float64)
        # Should I subsample based on the restraint coordinate values? Here I'm
        # doing it on the potential.  Should be pretty close ....
        if method == "mbar-block":
            # We want to use all possible data to get the free energy estimates Deltaf_ij,
            # but for uncertainty estimates we'll subsample to create
            # uncorrelated data.
            for k in range(num_win):
                l = k
                # If the potential is zero everywhere, we can't estimate the uncertainty, so
                # check the next *potential* window which probably had non-zero
                # force constants
                while not u_kln[k, l, 0 : N_k[k]].any():
                    l += 1
                # Now compute statistical inefficiency: g = N*(SEM**2)/variance
                nearest_max = get_nearest_max(N_k[k])
                sem = get_block_sem(u_kln[k, l, 0:nearest_max])
                variance = np.var(u_kln[k, l, 0 : N_k[k]])
                g_k[k] = N_k[k] * (sem ** 2) / variance

        if method == "mbar-autoc":
            for k in range(num_win):
                [t0, g_k[k], Neff_max] = timeseries.detectEquilibration(
                    N_k[k]
                )  # compute indices of uncorrelated
                # timeseries

        # Create subsampled indices and count their lengths. If g=1, ie no correlation,
        # then subsampling will return identical indices to original
        # (hopefully)
        ss_indices = []
        N_ss = np.zeros([num_win], np.int32)  # N_subsample
        for k in range(num_win):
            ss_indices.append(
                get_subsampled_indices(
                    N_k[k], g_k[k], conservative=self.conservative_subsample
                )
            )
            N_ss[k] = len(ss_indices[k])

        self.results[phase][method]["fraction_fe_matrix"] = {}
        self.results[phase][method]["fraction_sem_matrix"] = {}
        self.results[phase][method]["fraction_fe_Neffective"] = {}
        self.results[phase][method]["fraction_sem_Neffective"] = {}

        for fraction in self.fractions:
            # Setup mbar calc, and get matrix of free energies, uncertainties
            # To estimate the free energy, we won't do subsampling.  We'll do
            # another MBAR calculation later with subsampling to estimate the
            # uncertainty.
            frac_N_k = np.array([int(fraction * n) for n in N_k], dtype=np.int32)

            mbar = pymbar.MBAR(u_kln, frac_N_k, verbose=verbose)
            mbar_results = mbar.getFreeEnergyDifferences(
                compute_uncertainty=True, return_dict=True
            )

            Deltaf_ij = mbar_results["Delta_f"]
            dDeltaf_ij = mbar_results["dDelta_f"]

            Deltaf_ij_N_eff = mbar.computeEffectiveSampleNumber()

            if method == "mbar-block" or "mbar-autoc":
                # Create subsampled indices and count their lengths
                frac_N_ss = np.array([int(fraction * n) for n in N_ss], dtype=np.int32)

                # Create a new potential array for the uncertainty calculation
                # (are we using too much memory?)
                u_kln_err = np.zeros([num_win, num_win, np.max(frac_N_ss)], np.float64)

                # Populate the subsampled array, drawing the appropriate
                # fraction of subsamples from the original
                for k in range(num_win):
                    for l in range(num_win):
                        u_kln_err[k, l, 0 : frac_N_ss[k]] = u_kln[
                            k, l, ss_indices[k][0 : frac_N_ss[k]]
                        ]

                # We toss junk_Deltaf_ij, because we got a better estimate for it from above using all data.
                # But dDeltaf_ij will replace the previous, because it correctly accounts for the
                # correlation in the data.
                mbar = pymbar.MBAR(u_kln_err, frac_N_ss, verbose=verbose)
                mbar_results = mbar.getFreeEnergyDifferences(
                    compute_uncertainty=True, return_dict=True
                )
                dDeltaf_ij = mbar_results["dDelta_f"]
                dDeltaf_ij_N_eff = mbar.computeEffectiveSampleNumber()

            # Put back into kcal/mol
            Deltaf_ij /= self.beta
            dDeltaf_ij /= self.beta

            self.results[phase][method]["fraction_fe_matrix"][fraction] = Deltaf_ij
            self.results[phase][method]["fraction_fe_Neffective"][
                fraction
            ] = Deltaf_ij_N_eff
            self.results[phase][method]["fraction_sem_matrix"][fraction] = dDeltaf_ij
            self.results[phase][method]["fraction_sem_Neffective"][
                fraction
            ] = dDeltaf_ij_N_eff

    def run_ti(self, phase, prepared_data, method):
        """
        Compute the free energy using the TI method.

        We compute the partial derivative of the potential (i.e., forces), for each frame, with respect to the
        changing parameter, either a lambda or target value. The force constants are scaled by the λ parameter which
        controls their strength: ``0`` to ``fc_max``.

        Potential:
          U = λ × fc_max × (values - target)²
        Forces during attach:
          dU/dλ = fc_max × (values - target)²
        Forces during pull:
          dU/d(target) = 2 × λ × fc_max × (values - target)
        Forces during release:
          (same as attach)

        Then we integrate over the interval covered by λ or target.

        Parameters
        ----------
        phase: str
            The phase of the calculation to analyze.
        prepared_data: :class:`np.array`
            The list of "prepared data" including the number of windows, data points, which restraints are changing,
            their force constants and targets, and well as the order of the windows. This probably ought to be
            redesigned.
        method: str
            The method used to calculate the SEM.

        .. note ::
            ``phase`` and ``method`` should be `enum` types and tied to class attributes.

        .. warning ::
            We have only considered whether this will work for the case where the pull phase is a single distance
            restraint with a changing target value. This has not been tested for a changing angle or dihedral.

        """

        # Unpack the prepared data
        (
            num_win,
            data_points,
            max_data_points,
            active_rest,
            force_constants,
            targets,
            ordered_values,
        ) = prepared_data

        # Number of data points in each restraint value array
        N_k = np.array(data_points)

        # The dU array to store the partial derivative of the potential with respect lambda or target,
        # depending on the whether attach/release or pull. Data stored for each frame.  This just a
        # temporary storage space.
        dU = np.zeros([num_win, max_data_points], np.float64)

        # The mean, SEM, standard deviation, and number of uncorrelated dU values for each window.
        dU_avgs = np.zeros([num_win], np.float64)
        dU_sems = np.zeros([num_win], np.float64)
        dU_stdv = np.zeros([num_win], np.float64)
        dU_Nunc = np.zeros([num_win], np.float64)
        # The statistical inefficiency
        g = np.zeros([num_win], np.float64)

        # Array for values of the changing coordinate (x-axis), either lambda or target.
        # I'll name them dl_vals for dlambda values.
        dl_vals = np.zeros([num_win], np.float64)

        # Setup interpolation array for the dLambda (dl) coordinate. We're gonna create
        # this progressively by appending ...
        dl_intp = np.zeros([0], np.float64)

        # Get units
        target_units = np.array([targets[r][0].units for r in range(len(active_rest))])
        force_units = np.array(
            [force_constants[r][0].units for r in range(len(active_rest))]
        )

        # Store the max force constant value for each restraint.
        max_force_constants = (
            np.array(
                [np.max(force_constants[r]).magnitude for r in range(len(active_rest))]
            )
            * force_units
        )

        # Transpose force_constants and targets into "per window" format, instead of
        # the "per restraint" format.
        # print(targets)
        force_constants_T = np.asarray(force_constants).T * force_units
        targets_T = np.asarray(targets).T * target_units

        # For each window: do dihedral wrapping, compute forces, append dl_intp
        for k in range(num_win):  # Coordinate windows

            # Wrap dihedrals so we get the right potential
            for r, rest in enumerate(active_rest):  # Restraints
                # If this is a dihedral, we need to shift around restraint value
                # on the periodic axis to make sure the lowest potential is used.

                if rest.mask3 is not None and rest.mask4 is not None:
                    target = targets_T[k, r]
                    bool_list = ordered_values[k][r] < target - 180.0 * unit.degrees
                    ordered_values[k][r][bool_list] += 360.0 * unit.degrees
                    bool_list = ordered_values[k][r] > target + 180.0 * unit.degrees
                    ordered_values[k][r][bool_list] -= 360.0 * unit.degrees

            # Compute forces and store the values of the changing coordinate,
            # either lambda or target
            if phase == "attach" or phase == "release":
                dU[k, 0 : N_k[k]] = (
                    sum(
                        [
                            (k * (val - eq) ** 2)
                            for k, val, eq in zip(
                                max_force_constants, ordered_values[k], targets_T[k]
                            )
                        ]
                    )
                    .to(self.energy_unit)
                    .magnitude
                )

                # this is lambda. assume the same scaling for all restraints
                dl_vals[k] = force_constants_T[k, 0] / max_force_constants[0]
            else:
                dU[k, 0 : N_k[k]] = (
                    sum(
                        [
                            2.0 * k * (val - eq)
                            for k, val, eq in zip(
                                max_force_constants, ordered_values[k], targets_T[k]
                            )
                        ]
                    )
                    .to(self.energy_unit / self.distance_unit)
                    .magnitude
                )

                # Currently assuming a single distance restraint
                dl_vals[k] = targets_T[k, 0].to(self.distance_unit).magnitude

            # Compute standard deviations and SEMs, unless we're going to do
            # exact_sem_each_ti_fraction
            dU_avgs[k] = np.mean(dU[k, 0 : N_k[k]])
            dU_stdv[k] = np.std(dU[k, 0 : N_k[k]])
            if method == "ti-block":
                nearest_max = get_nearest_max(N_k[k])
                dU_sems[k] = get_block_sem(dU[k, 0:nearest_max])
                # Rearrange SEM = StdDev/sqrt(N) to get N_uncorrelated
                dU_Nunc[k] = (dU_stdv[k] / dU_sems[k]) ** 2
            elif method == "ti-nocor":
                dU_sems[k] = dU_stdv[k] / np.sqrt(N_k[k])
                dU_Nunc[k] = N_k[k]
            g[k] = N_k[k] / dU_Nunc[k]

            # Create the interpolation by appending 100 points between each window.
            # Start with k=1 so we don't double count.
            if k > 0:
                dl_intp = np.append(
                    dl_intp,
                    np.linspace(dl_vals[k - 1], dl_vals[k], num=100, endpoint=False),
                )

        # Tack on the final value to the dl interpolation
        dl_intp = np.append(dl_intp, dl_vals[-1])

        logger.debug("Running bootstrap calculations...")

        # Setup fractions. For simplicity, we'll always do this, even
        # if we're doing the total data, ie self.fractions=[1.0].
        self.results[phase][method]["fraction_fe_matrix"] = {}
        self.results[phase][method]["fraction_sem_matrix"] = {}

        for fraction in self.fractions:

            logger.debug("Working on fraction ... {}".format(fraction))

            # Compute means for this fraction.
            frac_dU_avgs = np.array(
                [np.mean(dU[k, 0 : int(fraction * n)]) for k, n in enumerate(N_k)]
            )

            # If self.exact_sem_each_ti_fraction, we're gonna recompute the SEM for each fraction
            # rather than estimating it from the standard deviation (dU_stdv) and number of
            # uncorrelated data points (dU_Nunc) from the total data set.
            if method == "ti-block" and self.exact_sem_each_ti_fraction:
                frac_dU_sems = np.zero([k], np.float64)
                for k in range(num_win):
                    nearest_max = get_nearest_max(int(fraction * N_k[k]))
                    frac_dU_sems[k] = get_block_sem(dU[k, 0:nearest_max])
            elif method == "ti-nocor" and self.exact_sem_each_ti_fraction:
                frac_dU_sems = np.zero([k], np.float64)
                for k in range(num_win):
                    frac_dU_sems[k] = np.std(
                        dU[k, 0 : int(fraction * N_k[k])]
                    ) / np.sqrt(int(fraction * N_k[k]))
            else:
                frac_dU_sems = dU_stdv / np.sqrt(fraction * dU_Nunc)

            dU_samples = np.random.normal(
                frac_dU_avgs, frac_dU_sems, size=(self.bootcycles, frac_dU_avgs.size)
            )

            # Run bootstraps
            (
                self.results[phase][method]["fraction_fe_matrix"][fraction],
                self.results[phase][method]["fraction_sem_matrix"][fraction],
            ) = integrate_bootstraps(
                dl_vals, dU_samples, x_intp=dl_intp, matrix=self.ti_matrix
            )

            # Put units back on
            self.results[phase][method]["fraction_fe_matrix"][
                fraction
            ] *= self.energy_unit
            self.results[phase][method]["fraction_sem_matrix"][
                fraction
            ] *= self.energy_unit

            # The attach/release work (integration) yields appropriately positive work, but
            # the pull work needs a negative multiplier. Think W = −Force × distance type thing.
            if phase == "pull":
                self.results[phase][method]["fraction_fe_matrix"][fraction] *= -1.0

        if self.compute_roi:
            logger.info(phase + ": computing ROI for " + method)
            # Do ROI calc
            max_fraction = np.max(self.fractions)
            # If we didn't compute fe/sem for fraction 1.0 already, do it now
            dU_samples = np.random.normal(
                dU_avgs, dU_sems, size=(self.bootcycles, dU_avgs.size)
            )
            if not np.isclose(max_fraction, 1.0):
                junk_fe, total_sem_matrix = integrate_bootstraps(
                    dl_vals, dU_samples, x_intp=dl_intp, matrix=self.ti_matrix
                )
            else:
                total_sem_matrix = self.results[phase][method]["fraction_sem_matrix"][
                    max_fraction
                ].magnitude
            self.results[phase][method]["roi"] = np.zeros([num_win], np.float64)

            for k in range(num_win):
                # Compute overall integrated SEM with 10% smaller SEM for dU[k]
                cnvg_dU_samples = np.array(dU_samples)
                cnvg_dU_samples[:, k] = np.random.normal(
                    dU_avgs[k], 0.9 * dU_sems[k], self.bootcycles
                )
                junk_fe, cnvg_sem_matrix = integrate_bootstraps(
                    dl_vals, cnvg_dU_samples, x_intp=dl_intp, matrix=self.ti_matrix
                )

                #         d( dG_sem )      d( dUdl_sem )
                # ROI = --------------- * ---------------
                #        d( dUdl_sem )     d( n_frames )
                #
                # Deriv1----^---^---^        ^---^---^----Deriv2

                # Deriv1:
                deriv1 = (cnvg_sem_matrix[0, -1] - total_sem_matrix[0, -1]) / (
                    -0.1 * dU_sems[k]
                )

                # Deriv2:
                #
                # dUdl_sem = dUdl_stddev / sqrt(n_frames/g)
                #
                # d( dUdl_sem )              dUdl_stddev
                # -------------- =  - --------------------------
                # d( n_frames )          2g * (n_frames/g)**3/2

                deriv2 = (
                    -1.0 * dU_stdv[k] / (2.0 * g[k] * (N_k[k] / g[k]) ** (3.0 / 2.0))
                )

                # ROI
                self.results[phase][method]["roi"][k] = deriv1 * deriv2

    def compute_free_energy(self, phases=["attach", "pull", "release"], seed=None):
        """
        Compute the free energy of binding from a simulation. This function populates the ``results`` dictionary
        of the :class:`fe_calc` object.

        Parameters
        ----------
        phases: list
            Which phases of the calculation to analyze.
        seed: int
            Random number seed.
        """

        for fraction in self.fractions:
            if fraction <= 0.0 or fraction > 1.0:
                raise Exception(
                    "The fraction of data to analyze must be 0 < fraction ≤ 1.0."
                )

        for phase in phases:
            self.results[phase] = {}
            self.results[phase]["window_order"] = self.orders[phase]

            for method in self.methods:
                if seed is not None:
                    np.random.seed(seed)
                    logger.debug(f"Setting random number seed = {seed}")

                self.results[phase][method] = {}

                # Prepare data
                if sum(self.changing_restraints[phase]) == 0:
                    logger.debug("Skipping free energy calculation for %s" % phase)
                    continue
                prepared_data = self.prepare_data(phase)
                self.results[phase][method]["n_frames"] = np.sum(prepared_data[1])

                logger.debug(
                    "Running {} analysis on {} phase ...".format(method, phase)
                )

                if method == "mbar-block" or method == "mbar-autoc":
                    self.run_mbar(phase, prepared_data, method)
                elif method == "ti-block":
                    self.run_ti(phase, prepared_data, method)
                else:
                    raise NotImplementedError(
                        f"Method ({method}) is not implemented yet."
                    )

                # Store endpoint free energy and SEM for each fraction
                self.results[phase][method]["fraction_n_frames"] = {}
                self.results[phase][method]["fraction_fe"] = {}
                self.results[phase][method]["fraction_sem"] = {}

                for fraction in self.fractions:
                    self.results[phase][method]["fraction_n_frames"][fraction] = int(
                        fraction * self.results[phase][method]["n_frames"]
                    )

                    self.results[phase][method]["fraction_fe"][fraction] = self.results[
                        phase
                    ][method]["fraction_fe_matrix"][fraction][0, -1]

                    self.results[phase][method]["fraction_sem"][
                        fraction
                    ] = self.results[phase][method]["fraction_sem_matrix"][fraction][
                        0, -1
                    ]

                # Set these higher level (total) values, which will be slightly
                # easier to access
                max_fraction = np.max(self.fractions)
                self.results[phase][method]["fe_matrix"] = self.results[phase][method][
                    "fraction_fe_matrix"
                ][max_fraction]
                self.results[phase][method]["sem_matrix"] = self.results[phase][method][
                    "fraction_sem_matrix"
                ][max_fraction]
                self.results[phase][method]["fe"] = self.results[phase][method][
                    "fe_matrix"
                ][0, -1]
                self.results[phase][method]["sem"] = self.results[phase][method][
                    "sem_matrix"
                ][0, -1]

                if self.compute_largest_neighbor:
                    # Store convergence values, which are helpful for running
                    # simulations
                    windows = len(self.results[phase][method]["sem_matrix"])
                    self.results[phase][method]["largest_neighbor"] = unit.Quantity(
                        np.ones([windows], np.float64) * -1.0,
                        units=self.energy_unit,
                    )
                    logger.info(f"{phase}: computing largest_neighbor for {method}...")

                    for i in range(windows):
                        if i == 0:
                            self.results[phase][method]["largest_neighbor"][
                                i
                            ] = self.results[phase][method]["sem_matrix"][i][i + 1]
                        elif i == windows - 1:
                            self.results[phase][method]["largest_neighbor"][
                                i
                            ] = self.results[phase][method]["sem_matrix"][i][i - 1]
                        else:
                            left = self.results[phase][method]["sem_matrix"][i][i - 1]
                            right = self.results[phase][method]["sem_matrix"][i][i + 1]
                            if left > right:
                                max_val = left
                            elif right > left:
                                max_val = right
                            else:
                                max_val = right
                            self.results[phase][method]["largest_neighbor"][i] = max_val

    def compute_ref_state_work(self, restraints):
        """
        Compute the work to place a molecule at standard reference state conditions
        starting from a state defined by up to six restraints. These are Boresch-style restraints.

        Parameters
        ----------
        restraints : list
            A list of :class:`paprika.restraints.DAT_restraint` objects in order of the six translational and
            orientational restraints needed to describe the configuration of one molecule
            relative to another. The six restraints are: r, theta, phi, alpha, beta, gamma and they should be passed to
            this function in that order. If any of these coordinates is not being restrained, use a `None` in place of a
            :class:`paprika.restraints.DAT_restraint` object.

            See :meth:`paprika.analysis.ref_state_work` for details on the calculation.
        """

        if not restraints or restraints[0] is None:
            raise ValueError(
                "At minimum, a single distance restraint is necesarry to compute the work of releasing"
                " the guest to standard state."
            )

        fcs = []
        targs = []

        for restraint in restraints:
            if restraint is None:
                fcs.append(None)
                targs.append(None)
            elif restraint.phase["release"]["force_constants"] is not None:
                fcs.append(np.sort(restraint.phase["release"]["force_constants"])[-1])
                targs.append(np.sort(restraint.phase["release"]["targets"])[-1])
            elif restraint.phase["pull"]["force_constants"] is not None:
                fcs.append(np.sort(restraint.phase["pull"]["force_constants"])[-1])
                targs.append(np.sort(restraint.phase["pull"]["targets"])[-1])
            else:
                raise ValueError(
                    "Restraints should have pull or release values initialized in order to compute_ref_state_work"
                )

        self.results["ref_state_work"] = ref_state_work(
            self.temperature,
            fcs[0],
            targs[0],
            fcs[1],
            targs[1],
            fcs[2],
            targs[2],
            fcs[3],
            targs[3],
            fcs[4],
            targs[4],
            fcs[5],
            targs[5],
        )

    def save_results(self, filepath="results.json", overwrite=False):
        """
        Save the analysis results to a JSON file.

        Parameters
        ----------
        filepath: os.PathLike
            The name of the JSON file to write to.
        overwrite: bool
            Option to whether overwrite file if already exist.
        """
        if overwrite and os.path.isfile(filepath):
            raise FileExistsError(f"File `{filepath}` exists, will not overwrite.")

        with open(filepath, "w") as f:
            dumped = json.dumps(self.results, cls=PaprikaEncoder)
            f.write(dumped)

    @staticmethod
    def load_results(filepath):
        """
        Read in a JSON file for the results.

        Parameters
        ----------
        filepath: os.PathLike
            The name of the JSON file to read.
        """
        with open(filepath, "r") as f:
            data = f.read()

        return json.loads(data, cls=PaprikaDecoder)

    def save_data(self, filepath="simulation_data.json", overwrite=False):
        """
        Save the simulation data (DAT values) to a JSON file.

        Parameters
        ----------
        filepath: os.PathLike
            The name of the JSON file to write to.
        overwrite: bool
            Option to whether overwrite file if already exist.
        """
        if overwrite and os.path.isfile(filepath):
            raise FileExistsError(f"File `{filepath}` exists, will not overwrite.")

        with open(filepath, "w") as f:
            dumped = json.dumps(
                {
                    "simulation_data": self.simulation_data,
                    "changing_restraints": self.changing_restraints,
                    "orders": self.orders,
                },
                cls=PaprikaEncoder,
            )
            f.write(dumped)

def get_factors(n):
    """
    Return a list of integer factors for a number.

    Parameters
    ----------
    n: int or float
        Number to factor

    Returns
    -------
    sorted: list
        A list of sorted factors.

    """
    factors = []
    sqrt_n = int(round(np.sqrt(n) + 0.5))
    i = 1
    while i <= sqrt_n:
        if n % i == 0:
            factors.append(int(i))
            j = n / i
            if j != i:
                factors.append(int(j))
        i += 1
    return sorted(factors, key=int)

def get_nearest_max(n):
    """
    Return the number with the largest number of factors between n − 100 and n.

    Parameters
    ----------
    n: int
        Desired number to factor.

    Returns
    -------
    most_factors: int
        The number with the most factors.

    """
    max_factors = 0
    if n % 2 == 0:
        beg = n - 100
        end = n
    else:
        beg = n - 101
        end = n - 1
    if beg < 0:
        beg = 0
    for i in range(beg, end + 2, 2):
        num_factors = len(get_factors(i))
        if num_factors >= max_factors:
            max_factors = num_factors
            most_factors = i
    return most_factors

def get_block_sem(data_array):
    """
    Compute the standard error of the mean (SEM) using the blocking method.

    Note
    ----
        This is a conservative approach. Here, we report the maximum SEM determined from blocking analysis (cf. the
        "plateau" on the blocking curve).

    Parameters
    ----------
    data_array: :class:`np.array`
        Array containing data values.

    Returns
    -------
    np.max(sems): float
        The maximum SEM obtained from te blocking curve.

    """
    # Get the integer factors for the number of data points. These
    # are equivalent to the block sizes we will check.
    block_sizes = get_factors(len(data_array))

    # An array to store means for each block ... make it bigger than we need.
    block_means = np.zeros([block_sizes[-1]], np.float64)

    # Store the SEM for each block size, except the last two size for which
    # there will only be two or one blocks total and thus very noisy.
    sems = np.zeros([len(block_sizes) - 2], np.float64)

    # Check each block size except the last two.
    for size_idx in range(len(block_sizes) - 2):
        # Check each block, the number of which is conveniently found as
        # the other number of the factor pair in block_sizes
        num_blocks = block_sizes[-size_idx - 1]
        for blk_idx in range(num_blocks):
            # Find the index for beg and end of data points for each block
            data_beg_idx = blk_idx * block_sizes[size_idx]
            data_end_idx = (blk_idx + 1) * block_sizes[size_idx]
            # Compute the mean of this block and store in array
            block_means[blk_idx] = np.mean(data_array[data_beg_idx:data_end_idx])
        # Compute the standard deviation across all blocks, devide by
        # num_blocks-1 for SEM
        sems[size_idx] = np.std(block_means[0:num_blocks], ddof=0) / np.sqrt(
            num_blocks - 1
        )
        # Hmm or should ddof=1? I think 0, see Flyvbjerg -----^

    return np.max(sems)

def get_subsampled_indices(N, g, conservative=False):
    """Get the indices of independent (subsampled) frames. This is adapted from the implementation in `pymbar`.

    Parameters
    ----------
    N: int
        The length of the array to be indexed.
    g: int
        The statistical inefficiency of the data.
    conservative: bool, optional, default=False
        Whether `g` should be rounded up to the nearest integer.

    Returns
    -------
    indices: list
        A list of indices that can be used to pull out de-correlated frames from a time series.

    """

    # g should not be less than 1.0
    if g < 1.0:
        g = 1.0

    # if conservative, assume integer g and round up
    if conservative:
        g = np.ceil(g)

    # initialize
    indices = [0]
    g_idx = 1.0
    int_step = int(np.round(g_idx * g))

    while int_step < N:
        indices.append(int_step)
        g_idx += 1.0
        int_step = int(np.round(g_idx * g))

    return indices

def load_trajectory(window, trajectory, topology, single_topology=False):
    """Load a trajectory (or trajectories) and return a pytraj ``trajectory`` object.

    Parameters
    ----------
    window: str
        The simulation window to analyze
    trajectory: str or list
        The name or names of the trajectory
    topology: str or :class:`parmed.Structure`
        The topology the simulation
    single_topology: bool
        Whether a single topology is read for all windows

    Returns
    -------
    traj: pytraj.trajectory
        The trajectory of stored as a pytraj object.
    """

    logger.debug("Load trajectories from {}/{}...".format(window, trajectory))
    if isinstance(trajectory, str):
        trajectory_path = os.path.join(window, trajectory)
    elif isinstance(trajectory, list):
        trajectory_path = [os.path.join(window, i) for i in trajectory]
        logger.debug("Received list of trajectories: {}".format(trajectory_path))
    else:
        raise RuntimeError("Trajectory path should be a `str` or `list`.")

    if isinstance(topology, str) and not single_topology:
        if not os.path.isfile(os.path.join(window, topology)):
            raise FileNotFoundError(
                f"Cannot find `topology` file: {os.path.join(window, topology)}"
            )
        logger.debug(f"Loading {os.path.join(window, topology)} and {trajectory_path}")
        try:
            traj = pt.iterload(trajectory_path, os.path.join(window, topology))
        except ValueError as e:
            formatted_exception = traceback.format_exception(None, e, e.__traceback__)
            logger.info(
                f"Failed trying to load {os.path.join(window, topology)} and {trajectory_path}: "
                f"{formatted_exception}"
            )
    elif isinstance(topology, str) and single_topology:
        traj = pt.iterload(trajectory_path, os.path.join(topology))
    else:
        try:
            traj = pt.iterload(trajectory_path, topology)
        except BaseException:
            raise Exception("Tried to load `topology` object directly and failed.")

    logger.debug("Loaded {} frames...".format(traj.n_frames))

    return traj

def read_restraint_data(traj, restraint):
    """Given a trajectory and restraint, read the restraint and return the DAT values.

    Parameters
    ----------
    traj: :class:`pytraj.trajectory`
        A trajectory, probably loaded by load_trajectory
    restraint: :class:`DAT_restraint`
        The restraint to analyze

    Returns
    -------
    data: :class:`np.array`
        The values for this restraint in this window
    """

    if (
        restraint.mask1
        and restraint.mask2
        and not restraint.mask3
        and not restraint.mask4
    ):
        data = unit.Quantity(
            pt.distance(traj, " ".join([restraint.mask1, restraint.mask2]), image=True),
            units=unit.angstrom,
        )

    elif (
        restraint.mask1 and restraint.mask2 and restraint.mask3 and not restraint.mask4
    ):
        data = unit.Quantity(
            pt.angle(
                traj, " ".join([restraint.mask1, restraint.mask2, restraint.mask3])
            ),
            units=unit.degrees,
        )

    elif restraint.mask1 and restraint.mask2 and restraint.mask3 and restraint.mask4:
        data = unit.Quantity(
            pt.dihedral(
                traj,
                " ".join(
                    [restraint.mask1, restraint.mask2, restraint.mask3, restraint.mask4]
                ),
            ),
            units=unit.degrees,
        )

    return data

def ref_state_work(
    temperature,
    r_fc,
    r_tg,
    th_fc,
    th_tg,
    ph_fc,
    ph_tg,
    a_fc,
    a_tg,
    b_fc,
    b_tg,
    g_fc,
    g_tg,
):
    """
    Computes the free energy to release a molecule from some restrained translational
    and orientational configuration (relative to another molecule or lab frame) into
    the reference configuration: standard concentration (1.0/1660.5392 Å³) and
    unrestrained orientational freedom (8π²).

    The Wikipedia entry on Euler angles is useful.

    Assume two molecules (H and G). Three translational and three orientational degrees
    of freedom define their relative configuration. In order to match experimentally
    reported free energies, we often need to compute the work (free energy) of moving
    a molecule (G) from a restrained configuration relative to H into the experimental
    reference state (usually defined as 1 M, or 1 molecule per 1660.5 Å³)

    ::

        H3
          \        [a1]                [a2]
           H2-------H1-------<d1>-------G1-------G2
              {t1}           {t2}           {t3}   \
                                                   G3

        Degrees of Freedom
        -----------------------------------------------------------------
         id   atoms        type, spherical coordinate/Euler angle
        -----------------------------------------------------------------
        <d1>: H1-G1         distance, r
        [a1]: H2-H1-G1      angle, theta
        {t1}: H3-H2-H1-G1   torsion, phi
        {t2}: H2-H1-G1-G2   torsion, alpha
        [a2]: H1-G1-G2      angle, beta
        {t3}: H1-G1-G2-G3   torsion, gamma

    Parameters
    ----------
    temperature : unit.Quantity
        The temperature (in Kelvin) at which the reference state calculation will take place.
    r_fc: unit.Quantity
        The distance, :math:`r`, restraint force constant (kcal/mol-Å²).
    r_tg : unit.Quantity
        The distance, :math:`r`, restraint target values (Å). The target range is 0 to infinity.
    th_fc : unit.Quantity
        The angle, :math:`θ`, restraint force constant (kcal/mol-radian²).
    th_tg : unit.Quantity
        The angle, :math:`θ`, restraint target values (radian). The target range is 0 to π.
    ph_fc : unit.Quantity
        The torsion, :math:`\\phi`, restraint force constant (kcal/mol-radian²).
    ph_tg : unit.Quantity
        The torsion, :math:`\\phi`, restraint target values (radian). The target range is 0 to 2π.
    a_fc : unit.Quantity
        The torsion, :math:`α`, restraint force constant (kcal/mol-radian²).
    a_tg: unit.Quantity
        The torsion, :math:`α`, restraint target values (radian). The target range is 0 to 2π.
    b_fc : unit.Quantity
        The angle, :math:`β`, restraint force (kcal/mol-radian²).
    b_tg : unit.Quantity
        The angle, :math:`β`, restraint target values (radian). The target range is 0 to π.
    g_fc: unit.Quantity
        The angle, :math:`γ`, restraint force (kcal/mol-radian²).
    g_tg: unit.Quantity
        The angle, :math:`γ`, restraint target values (radian). The target range is 0 to 2π.

    Returns
    -------
    RT * np.log(trans * orient): float
        The free energy associated with releasing the restraints (in kcal/mol Pint units).
    """

    R = 1.987204118e-3 * unit.kcal / unit.mole / unit.kelvin
    RT = R * temperature

    # Distance Integration Function
    def dist_int(RT, fc, targ):
        def potential(arange, RT, fc, targ):
            return (arange ** 2) * np.exp((-1.0 / RT) * fc * (arange - targ) ** 2)

        arange = np.arange(0.0, 100.0, 0.0001) * unit.angstrom
        return np.trapz(potential(arange, RT, fc, targ), arange)

    # Angle Integration Function
    def ang_int(RT, fc, targ):
        def potential(arange, RT, fc, targ):
            return np.sin(arange) * np.exp((-1.0 / RT) * fc * (arange - targ) ** 2)

        arange = np.arange(0.0, np.pi, 0.00005) * unit.radians
        return np.trapz(potential(arange, RT, fc, targ), arange)

    # Torsion Integration Function
    def tors_int(RT, fc, targ):
        def potential(arange, RT, fc, targ):
            return np.exp((-1.0 / RT) * fc * (arange - targ) ** 2)

        # Note, because of periodicity, I'm gonna wrap +/- pi around target for integration.
        arange = np.arange(targ - np.pi, targ + np.pi, 0.00005) * unit.radians
        return np.trapz(potential(arange, RT, fc, targ), arange)

    # Distance restraint, r
    if None in [r_fc, r_tg]:
        raise Exception("Distance restraint info (r_fc, r_tg) must be specified")
    else:
        r_int = dist_int(RT, r_fc, r_tg)

    # Angle restraint, theta
    if None in [th_fc, th_tg]:
        th_int = 2.0
    else:
        th_int = ang_int(RT, th_fc, th_tg)

    # Torsion restraint, phi
    if None in [ph_fc, ph_tg]:
        ph_int = 2.0 * np.pi * unit.radians
    else:
        ph_int = tors_int(RT, ph_fc, ph_tg)

    # Torsion restraint, alpha
    if None in [a_fc, a_tg]:
        a_int = 2.0 * np.pi * unit.radians
    else:
        a_int = tors_int(RT, a_fc, a_tg)

    # Angle restraint, beta
    if None in [b_fc, b_tg]:
        b_int = 2.0
    else:
        b_int = ang_int(RT, b_fc, b_tg)

    # Torsion restraint, gamma
    if None in [g_fc, g_tg]:
        g_int = 2.0 * np.pi * unit.radians
    else:
        g_int = tors_int(RT, g_fc, g_tg)

    # Concentration term
    V0 = 1660.5392 * unit.angstrom ** 3
    translational = r_int * th_int * ph_int * (1.0 / V0)  # C^o = 1/V^o

    # Orientational term
    rotational_volume = 8.0 * np.pi ** 2
    orientational = a_int * b_int * g_int / rotational_volume

    # Return the free energy
    return RT * np.log(translational * orientational)

def integrate_bootstraps(x, ys, x_intp=None, matrix="full"):
    """
    Integrate splines created via bootstrapping.

    Parameters
    ----------
    x: :class:`np.array`
        The x coordinate of the curve to be integrated.
    ys: :class:`np.array`
        Two dimensional array in which the first dimension is bootcycles and the second
        dimension contains the arrays of y values which correspond to the x values and will
        be used for integration. The shape of this is :code:`(bootcycles, len(x))`.
    x_intp: :class:`np.array`, optional, default=None
        An array which finely interpolates the x values. If not provided, it will be generated
        by adding 100 evenly spaced points between each x value. Default: None.
    matrix: str, optional, default='full`
        If ``full``, the mean and SEM integration is computed between x values. If ``diagonal``,
        the mean and SEM integration is computed between the first value and all other values,
        as well as the neighboring values to each value. If ``endpoints``, the integration
        is computed between only the first and last x value.

    Returns
    -------
    avg_matrix: :class:`np.array`
        Matrix of the integration mean between each x value (as specified by 'matrix')
    sem_matrix: :class:`np.array`
        Matrix of the uncertainty (SEM) between each x value (as specified by 'matrix')

    """

    num_x = len(x)

    # Prepare to store the index location of the x values in the x_intp array
    x_idxs = np.zeros([num_x], np.int32)

    # If not provided, generate x interpolation with 100 inpolated points between
    # each x value. Store the index locations of the x values in the x_intp
    # array.
    if x_intp is None:
        x_intp = np.zeros([0], np.float64)
        for i in range(1, num_x):
            x_intp = np.append(
                x_intp, np.linspace(x[i - 1], x[i], num=100, endpoint=False)
            )
            x_idxs = len(x_intp)
        # Tack on the final value onto the interpolation
        x_intp = np.append(x_intp, x[-1])
    # If x_intp is provided, find the locations of x values in x_intp
    else:
        i = 0
        for j in range(len(x_intp)):
            if np.isclose(x[i], x_intp[j]):
                x_idxs[i] = j
                i += 1
        if i != num_x:
            raise Exception(
                "One or more x values seem to be missing in the x_intp array,"
                + " or one of the lists is not monotonically increasing!"
            )

    cycles = len(ys)

    # Setup array to store integration bootstraps
    int_matrix = np.zeros([num_x, num_x, cycles], np.float64)

    # Do the integration bootstraps. Originally, I had matrix=endpoints in the loop
    # below with everthing else, but I'll split it out here in case that's faster
    # due to avoiding the if statements.
    if matrix == "endpoints":
        for cycle in range(cycles):
            intp_func = Akima1DInterpolator(x, ys[cycle])
            y_intp = intp_func(x_intp)
            #            for i in range(0, num_x):
            #                for j in range(i+1, num_x):
            #                    int_matrix[i, j, cycle] = np.trapz( y_intp, x_intp )
            int_matrix[0, num_x - 1, cycle] = np.trapz(y_intp, x_intp)
    else:
        for cycle in range(cycles):
            intp_func = Akima1DInterpolator(x, ys[cycle])
            y_intp = intp_func(x_intp)
            for i in range(0, num_x):
                for j in range(i + 1, num_x):
                    if matrix == "diagonal" and i != 0 and j - i > 1:
                        continue
                    beg = x_idxs[i]
                    end = x_idxs[j]
                    int_matrix[i, j, cycle] = np.trapz(y_intp[beg:end], x_intp[beg:end])

    # Setup matrices to store the average/sem values.
    # Is it bad that the default is 0.0 rather than None?
    avg_matrix = np.zeros([num_x, num_x], np.float64)
    sem_matrix = np.zeros([num_x, num_x], np.float64)

    # Second pass to compute the mean and standard deviation.
    for i in range(0, num_x):
        for j in range(i + 1, num_x):
            # If quick_ti_matrix, only populate first row and neighbors in
            # matrix
            if matrix == "diagonal" and i != 0 and j - i > 1:
                continue
            if matrix == "endpoints" and i != 0 and j != num_x - 1:
                continue
            avg_matrix[i, j] = np.mean(int_matrix[i, j])
            avg_matrix[j, i] = -1.0 * avg_matrix[i, j]
            sem_matrix[i, j] = np.std(int_matrix[i, j])
            sem_matrix[j, i] = sem_matrix[i, j]

    return avg_matrix, sem_matrix