'''Statistical tests for NDVars

Common Attributes
-----------------

The following attributes are always present. For ANOVA, they are lists with the
corresponding items for different effects.

t/f/... : NDVar
    Map of the statistical parameter.
p_uncorrected : NDVar
    Map of uncorrected p values.
p : NDVar | None
    Map of corrected p values (None if no correct was applied).
clusters : Dataset | None
    Table of all the clusters found (None if no clusters were found, or if no
    clustering was performed).
n_samples : None | int
    The actual number of permutations. If ``samples = -1``, i.e. a complete set
    or permutations is performed, then ``n_samples`` indicates the actual
    number of permutations that constitute the complete set.
'''
from datetime import datetime, timedelta
from functools import reduce, partial
from itertools import chain, repeat
from math import ceil
from multiprocessing.sharedctypes import RawArray
import logging
import operator
import os
import re
import socket
from time import time as current_time
from typing import Union

import numpy as np
import scipy.stats
from scipy import ndimage

from .. import fmtxt, _info, _text
from ..fmtxt import FMText
from .._celltable import Celltable
from .._config import CONFIG, mpc
from .._data_obj import (
    CategorialArg, CellArg, IndexArg, ModelArg, NDVarArg, VarArg,
    Dataset, Var, Factor, Interaction, NestedEffect,
    NDVar, Categorial, UTS,
    ascategorial, asmodel, asndvar, asvar, assub,
    cellname, combine, dataobj_repr, longname)
from .._exceptions import OldVersionError, WrongDimension, ZeroVariance
from .._utils import LazyProperty, user_activity, restore_main_spec
from .._utils.numpy_utils import FULL_AXIS_SLICE
from .._utils.notebooks import trange
from . import opt, stats, vector
from .connectivity import Connectivity, find_peaks
from .connectivity_opt import merge_labels, tfce_increment
from .glm import _nd_anova
from .permutation import (
    _resample_params, permute_order, permute_sign_flip, random_seeds,
    rand_rotation_matrices)
from .t_contrast import TContrastSpec
from .test import star, star_factor, _independent_measures_args, _related_measures_args


__test__ = False


def check_for_vector_dim(y: NDVar) -> None:
    for dim in y.dims:
        if dim._connectivity_type == 'vector':
            raise WrongDimension(f"{dim}: mass-univariate methods are not suitable for vectors. Consider using vector norm as test statistic, or using a testnd.Vector test function.")


def check_variance(x):
    if x.ndim != 2:
        x = x.reshape((len(x), -1))
    if opt.has_zero_variance(x):
        raise ZeroVariance("y contains data column with zero variance")


class NDTest:
    """Baseclass for testnd test results

    Attributes
    ----------
    p : NDVar | None
        Map of p-values corrected for multiple comparison (or None if no
        correction was performed).
    tfce_map : NDVar | None
        Map of the test statistic processed with the threshold-free cluster
        enhancement algorithm (or None if no TFCE was performed).
    """
    _state_common = ('y', 'match', 'sub', 'samples', 'tfce', 'pmin', '_cdist',
                     'tstart', 'tstop', '_dims')
    _state_specific = ()
    _statistic = None
    _statistic_tail = 0

    @property
    def _attributes(self):
        return self._state_common + self._state_specific

    def __init__(self, y, match, sub, samples, tfce, pmin, cdist, tstart, tstop):
        self.y = dataobj_repr(y)
        self.match = dataobj_repr(match, True)
        self.sub = sub
        self.samples = samples
        self.tfce = tfce
        self.pmin = pmin
        self._cdist = cdist
        self.tstart = tstart
        self.tstop = tstop
        self._dims = y.dims[1:]

    def __getstate__(self):
        return {name: getattr(self, name) for name in self._attributes}

    def __setstate__(self, state):
        # backwards compatibility:
        if 'Y' in state:
            state['y'] = state.pop('Y')
        if 'X' in state:
            state['x'] = state.pop('X')

        for name in self._attributes:
            setattr(self, name, state.get(name))

        # backwards compatibility:
        if 'tstart' not in state:
            cdist = self._first_cdist
            self.tstart = cdist.tstart
            self.tstop = cdist.tstop
        if '_dims' not in state:  # 0.17
            if 't' in state:
                self._dims = state['t'].dims
            elif 'r' in state:
                self._dims = state['r'].dims
            elif 'f' in state:
                self._dims = state['f'][0].dims
            else:
                raise RuntimeError("Error recovering old test results dims")

        self._expand_state()

    def __repr__(self):
        args = self._repr_test_args()
        if self.sub is not None:
            if isinstance(self.sub, np.ndarray):
                sub_repr = '<array>'
            else:
                sub_repr = repr(self.sub)
            args.append(f'sub={sub_repr}')
        if self._cdist:
            args += self._repr_cdist()
        else:
            args.append('samples=0')

        return f"<{self.__class__.__name__} {', '.join(args)}>"

    def _repr_test_args(self):
        """List of strings describing parameters unique to the test

        Will be joined with ``", ".join(repr_args)``
        """
        raise NotImplementedError()

    def _repr_cdist(self):
        """List of results (override for MultiEffectResult)"""
        return (self._cdist._repr_test_args(self.pmin) +
                self._cdist._repr_clusters())

    def _expand_state(self):
        "Override to create secondary results"
        cdist = self._cdist
        if cdist is None:
            self.tfce_map = None
            self.p = None
            self._kind = None
        else:
            self.tfce_map = cdist.tfce_map
            self.p = cdist.probability_map
            self._kind = cdist.kind

    def _desc_samples(self):
        if self.samples == -1:
            return f"a complete set of {self.n_samples} permutations"
        elif self.samples is None:
            return "no permutations"
        else:
            return f"{self.n_samples} random permutations"

    def _desc_timewindow(self):
        tstart = self._time_dim.tmin if self.tstart is None else self.tstart
        tstop = self._time_dim.tstop if self.tstop is None else self.tstop
        return f"{_text.ms(tstart)} - {_text.ms(tstop)} ms"

    def _asfmtext(self, **_):
        p = self.p.min()
        max_stat = self._max_statistic()
        return FMText((fmtxt.eq(self._statistic, max_stat, 'max', stars=p), ', ', fmtxt.peq(p)))

    def _default_plot_obj(self):
        raise NotImplementedError

    def _iter_cdists(self):
        yield None, self._cdist

    @property
    def _first_cdist(self):
        return self._cdist

    def _plot_model(self):
        "Determine x for plotting categories"
        return None

    def _plot_sub(self):
        if isinstance(self.sub, str) and self.sub == "<unsaved array>":
            raise RuntimeError("The sub parameter was not saved for previous "
                               "versions of Eelbrain. Please recompute this "
                               "result with the current version.")
        return self.sub

    def _assert_has_cdist(self):
        if self._cdist is None:
            raise RuntimeError("This method only applies to results of tests "
                               "with threshold-based clustering and tests with "
                               "a permutation distribution (samples > 0)")

    def masked_parameter_map(self, pmin=0.05, **sub):
        """Create a copy of the parameter map masked by significance

        Parameters
        ----------
        pmin : scalar
            Threshold p-value for masking (default 0.05). For threshold-based
            cluster tests, ``pmin=1`` includes all clusters regardless of their
            p-value.

        Returns
        -------
        masked_map : NDVar
            NDVar with data from the original parameter map wherever p <= pmin
            and 0 everywhere else.
        """
        self._assert_has_cdist()
        return self._cdist.masked_parameter_map(pmin, **sub)

    def cluster(self, cluster_id):
        """Retrieve a specific cluster as NDVar

        Parameters
        ----------
        cluster_id : int
            Cluster id.

        Returns
        -------
        cluster : NDVar
            NDVar of the cluster, 0 outside the cluster.

        Notes
        -----
        Clusters only have stable ids for thresholded cluster distributions.
        """
        self._assert_has_cdist()
        return self._cdist.cluster(cluster_id)

    @LazyProperty
    def clusters(self):
        if self._cdist is None:
            return None
        else:
            return self.find_clusters(None, True)

    def find_clusters(self, pmin=None, maps=False, **sub):
        """Find significant regions or clusters

        Parameters
        ----------
        pmin : None | scalar, 1 >= p  >= 0
            Threshold p-value. For threshold-based tests, all clusters with a
            p-value smaller than ``pmin`` are included (default 1);
            for other tests, find contiguous regions with ``p ≤ pmin`` (default
            0.05).
        maps : bool
            Include in the output a map of every cluster (can be memory
            intensive if there are large statistical maps and/or many
            clusters; default ``False``).

        Returns
        -------
        ds : Dataset
            Dataset with information about the clusters.
        """
        self._assert_has_cdist()
        return self._cdist.clusters(pmin, maps, **sub)

    def find_peaks(self):
        """Find peaks in a threshold-free cluster distribution

        Returns
        -------
        ds : Dataset
            Dataset with information about the peaks.
        """
        self._assert_has_cdist()
        return self._cdist.find_peaks()

    def compute_probability_map(self, **sub):
        """Compute a probability map

        Returns
        -------
        probability : NDVar
            Map of p-values.
        """
        self._assert_has_cdist()
        return self._cdist.compute_probability_map(**sub)

    def info_list(self, computation=True):
        "List with information about the test"
        out = fmtxt.List("Mass-univariate statistics:")
        out.add_item(self._name())
        dimnames = [dim.name for dim in self._dims]
        dimlist = out.add_sublist(f"Over {_text.enumeration(dimnames)}")
        if 'time' in dimnames:
            dimlist.add_item(f"Time interval: {self._desc_timewindow()}.")

        cdist = self._first_cdist
        if cdist is None:
            out.add_item("No inferential statistics")
            return out

        # inference
        l = out.add_sublist("Inference:")
        if cdist.kind == 'raw':
            l.add_item("Based on maximum statistic")
        elif cdist.kind == 'tfce':
            l.add_item("Based on maximum statistic with threshold-"
                       "free cluster enhancement (Smith & Nichols, 2009)")
        elif cdist.kind == 'cluster':
            l.add_item("Based on maximum cluster mass statistic")
            sl = l.add_sublist("Cluster criteria:")
            for dim in dimnames:
                if dim == 'time':
                    sl.add_item(f"Minimum cluster duration {_text.ms(cdist.criteria.get('mintime', 0))} ms")
                elif dim == 'source':
                    sl.add_item(f"At least {cdist.criteria.get('minsource', 0)} contiguous sources.")
                elif dim == 'sensor':
                    sl.add_item(f"At least {cdist.criteria.get('minsensor', 0)} contiguous sensors.")
                else:
                    value = cdist.criteria.get(f'min{dim}', 0)
                    sl.add_item(f"Minimum number of contiguous elements in {dim}: {value}")
        # n samples
        l.add_item(f"In {self._desc_samples()}")

        # computation
        if computation:
            out.add_item(cdist.info_list())

        return out

    @property
    def _statistic_map(self):
        return getattr(self, self._statistic)

    def _max_statistic(self):
        tail = getattr(self, 'tail', self._statistic_tail)
        return self._max_statistic_from_map(self._statistic_map, self.p, tail)

    @staticmethod
    def _max_statistic_from_map(stat_map: NDVar, p_map: NDVar, tail: int):
        if tail == 0:
            func = stat_map.extrema
        elif tail == 1:
            func = stat_map.max
        else:
            func = stat_map.min

        if p_map is None:
            mask = None
        else:
            mask = p_map <= .05 if p_map.min() <= .05 else None

        return func() if mask is None else func(mask)

    @property
    def n_samples(self):
        if self.samples == -1:
            return self._first_cdist.samples
        else:
            return self.samples

    @property
    def _time_dim(self):
        for dim in self._first_cdist.dims:
            if isinstance(dim, UTS):
                return dim
        return None


class TContrastRelated(NDTest):
    """Mass-univariate contrast based on t-values

    Parameters
    ----------
    y : NDVar
        Dependent variable.
    x : categorial
        Model containing the cells which are compared with the contrast.
    contrast : str
        Contrast specification: see Notes.
    match : Factor
        Match cases for a repeated measures test.
    sub : index
        Perform the test with a subset of the data.
    ds : Dataset
        If a Dataset is specified, all data-objects can be specified as
        names of Dataset variables.
    tail : 0 | 1 | -1
        Which tail of the t-distribution to consider:
        0: both (two-tailed);
        1: upper tail (one-tailed);
        -1: lower tail (one-tailed).
    samples : int
        Number of samples for permutation test (default 10,000).
    pmin : None | scalar (0 < pmin < 1)
        Threshold for forming clusters:  use a t-value equivalent to an
        uncorrected p-value for a related samples t-test (with df =
        len(match.cells) - 1).
    tmin : scalar
        Threshold for forming clusters as t-value.
    tfce : bool | scalar
        Use threshold-free cluster enhancement. Use a scalar to specify the
        step of TFCE levels (for ``tfce is True``, 0.1 is used).
    tstart : scalar
        Start of the time window for the permutation test (default is the
        beginning of ``y``).
    tstop : scalar
        Stop of the time window for the permutation test (default is the
        end of ``y``).
    parc : str
        Collect permutation statistics for all regions of the parcellation of
        this dimension. For threshold-based test, the regions are disconnected.
    force_permutation: bool
        Conduct permutations regardless of whether there are any clusters.
    min...
        Minimum cluster size criteria: ``min`` followed by the simension name,
        for example:
        ``mintime=0.050`` for minimum duration of 50 ms;
        ``minsource=10`` to require at least 10 sources;
        ``minsensor=10`` to requre at least 10 sensors).

    See Also
    --------
    testnd : Information on the different permutation methods

    Notes
    -----
    A contrast specifies the steps to calculate a map based on *t*-values.
    Contrast definitions can contain:

    - Comparisons using ``>`` or ``<`` and data cells to compute *t*-maps.
      For example, ``"cell1 > cell0"`` will compute a *t*-map of the comparison
      if ``cell1`` and ``cell0``, being positive where ``cell1`` is greater than
      ``cell0`` and negative where ``cell0`` is greater than ``cell1``.
      If the data is defined based on an interaction, cells are specified with
      ``|``, e.g. ``"a1 | b1 > a0 | b0"``. Cells can contain ``*`` to average
      multiple cells. Thus, if the second factor in the model has cells ``b1``
      and ``b0``, ``"a1 | * > a0 | *"`` would compare ``a1`` to ``a0``
      while averaging ``b1`` and ``b0`` within ``a1`` and ``a0``.
    - Unary numpy functions ``abs`` and ``negative``, e.g.
      ``"abs(cell1 > cell0)"``.
    - Binary numpy functions ``subtract`` and ``add``, e.g.
      ``"add(a>b, a>c)"``.
    - Numpy functions for multiple arrays ``min``, ``max`` and ``sum``,
      e.g. ``min(a>d, b>d, c>d)``.

    Cases with zero variance are set to t=0.

    Examples
    --------
    To find cluster where both of two pairwise comparisons are reliable,
    i.e. an intersection of two effects, one could use
    ``"min(a > c, b > c)"``.

    To find a specific kind of interaction, where a is greater than b, and
    this difference is greater than the difference between c and d, one
    could use ``"(a > b) - abs(c > d)"``.
    """
    _state_specific = ('x', 'contrast', 't', 'tail')
    _statistic = 't'

    @user_activity
    def __init__(
            self,
            y: NDVarArg,
            x: CategorialArg,
            contrast: str,
            match: CategorialArg = None,
            sub: CategorialArg = None,
            ds: Dataset = None,
            tail: int = 0,
            samples: int = 10000,
            pmin: float = None,
            tmin: float = None,
            tfce: Union[float, bool] = False,
            tstart: float = None,
            tstop: float = None,
            parc: str = None,
            force_permutation: bool = False,
            **criteria):
        if match is None:
            raise TypeError("The `match` parameter needs to be specified for TContrastRelated")
        ct = Celltable(y, x, match, sub, ds=ds, coercion=asndvar, dtype=np.float64)
        check_for_vector_dim(ct.y)
        check_variance(ct.y.x)

        # setup contrast
        t_contrast = TContrastSpec(contrast, ct.cells, ct.data_indexes)

        # original data
        tmap = t_contrast.map(ct.y.x)

        n_threshold_params = sum((pmin is not None, tmin is not None, bool(tfce)))
        if n_threshold_params == 0 and not samples:
            threshold = cdist = None
        elif n_threshold_params > 1:
            raise ValueError("Only one of pmin, tmin and tfce can be specified")
        else:
            if pmin is not None:
                df = len(ct.match.cells) - 1
                threshold = stats.ttest_t(pmin, df, tail)
            elif tmin is not None:
                threshold = abs(tmin)
            else:
                threshold = None

            cdist = NDPermutationDistribution(
                ct.y, samples, threshold, tfce, tail, 't', "t-contrast",
                tstart, tstop, criteria, parc, force_permutation)
            cdist.add_original(tmap)
            if cdist.do_permutation:
                iterator = permute_order(len(ct.y), samples, unit=ct.match)
                run_permutation(t_contrast, cdist, iterator)

        # NDVar map of t-values
        info = _info.for_stat_map('t', threshold, tail=tail, old=ct.y.info)
        t = NDVar(tmap, ct.y.dims[1:], 't', info)

        # store attributes
        NDTest.__init__(self, ct.y, ct.match, sub, samples, tfce, pmin, cdist,
                        tstart, tstop)
        self.x = ('%'.join(ct.x.base_names) if isinstance(ct.x, Interaction) else
                  ct.x.name)
        self.contrast = contrast
        self.tail = tail
        self.tmin = tmin
        self.t = t

        self._expand_state()

    def _name(self):
        if self.y:
            return "T-Contrast:  %s ~ %s" % (self.y, self.contrast)
        else:
            return "T-Contrast:  %s" % self.contrast

    def _plot_model(self):
        return self.x

    def _repr_test_args(self):
        args = [repr(self.y), repr(self.x), repr(self.contrast)]
        if self.tail:
            args.append("tail=%r" % self.tail)
        if self.match:
            args.append('match=%r' % self.match)
        return args


class Correlation(NDTest):
    """Mass-univariate correlation

    Parameters
    ----------
    y : NDVar
        Dependent variable.
    x : continuous
        The continuous predictor variable.
    norm : None | categorial
        Categories in which to normalize (z-score) x.
    sub : index
        Perform the test with a subset of the data.
    ds : Dataset
        If a Dataset is specified, all data-objects can be specified as
        names of Dataset variables.
    samples : int
        Number of samples for permutation test (default 10,000).
    pmin : None | scalar (0 < pmin < 1)
        Threshold for forming clusters:  use an r-value equivalent to an
        uncorrected p-value.
    rmin : None | scalar
        Threshold for forming clusters.
    tfce : bool | scalar
        Use threshold-free cluster enhancement. Use a scalar to specify the
        step of TFCE levels (for ``tfce is True``, 0.1 is used).
    tstart : scalar
        Start of the time window for the permutation test (default is the
        beginning of ``y``).
    tstop : scalar
        Stop of the time window for the permutation test (default is the
        end of ``y``).
    match : None | categorial
        When permuting data, only shuffle the cases within the categories
        of match.
    parc : str
        Collect permutation statistics for all regions of the parcellation of
        this dimension. For threshold-based test, the regions are disconnected.
    mintime : scalar
        Minimum duration for clusters (in seconds).
    minsource : int
        Minimum number of sources per cluster.

    Attributes
    ----------
    clusters : None | Dataset
        For cluster-based tests, a table of all clusters. Otherwise a table of
        all significant regions (or ``None`` if permutations were omitted).
        See also the :meth:`.find_clusters` method.
    p : NDVar | None
        Map of p-values corrected for multiple comparison (or None if no
        correction was performed).
    p_uncorrected : NDVar
        Map of p-values uncorrected for multiple comparison.
    r : NDVar
        Map of correlation values (with threshold contours).
    tfce_map : NDVar | None
        Map of the test statistic processed with the threshold-free cluster
        enhancement algorithm (or None if no TFCE was performed).

    See Also
    --------
    testnd : Information on the different permutation methods
    """
    _state_specific = ('x', 'norm', 'n', 'df', 'r')
    _statistic = 'r'

    @user_activity
    def __init__(
            self,
            y: NDVarArg,
            x: VarArg,
            norm: CategorialArg = None,
            sub: IndexArg = None,
            ds: Dataset = None,
            samples: int = 10000,
            pmin: float = None,
            rmin: float = None,
            tfce: Union[float, bool] = False,
            tstart: float = None,
            tstop: float = None,
            match: CategorialArg = None,
            parc: str = None,
            **criteria):
        sub, n = assub(sub, ds, True)
        y, n = asndvar(y, sub, ds, n, np.float64, True)
        check_for_vector_dim(y)
        if not y.has_case:
            raise ValueError("Dependent variable needs case dimension")
        x = asvar(x, sub, ds, n)
        if norm is not None:
            norm = ascategorial(norm, sub, ds, n)
        if match is not None:
            match = ascategorial(match, sub, ds, n)

        self.x = x.name
        name = "%s corr %s" % (y.name, x.name)

        # Normalize by z-scoring the data for each subject
        # normalization is done before the permutation b/c we are interested in
        # the variance associated with each subject for the z-scoring.
        y = y.copy()
        if norm is not None:
            for cell in norm.cells:
                idx = (norm == cell)
                y.x[idx] = scipy.stats.zscore(y.x[idx], None)

        # subtract the mean from y and x so that this can be omitted during
        # permutation
        y -= y.summary('case')
        x = x - x.mean()

        n = len(y)
        df = n - 2

        rmap = stats.corr(y.x, x.x)

        n_threshold_params = sum((pmin is not None, rmin is not None, bool(tfce)))
        if n_threshold_params == 0 and not samples:
            threshold = cdist = None
        elif n_threshold_params > 1:
            raise ValueError("Only one of pmin, rmin and tfce can be specified")
        else:
            if pmin is not None:
                threshold = stats.rtest_r(pmin, df)
            elif rmin is not None:
                threshold = abs(rmin)
            else:
                threshold = None

            cdist = NDPermutationDistribution(
                y, samples, threshold, tfce, 0, 'r', name,
                tstart, tstop, criteria, parc)
            cdist.add_original(rmap)
            if cdist.do_permutation:
                iterator = permute_order(n, samples, unit=match)
                run_permutation(stats.corr, cdist, iterator, x.x)

        # compile results
        info = _info.for_stat_map('r', threshold)
        r = NDVar(rmap, y.dims[1:], name, info)

        # store attributes
        NDTest.__init__(self, y, match, sub, samples, tfce, pmin, cdist, tstart, tstop)
        self.norm = None if norm is None else norm.name
        self.rmin = rmin
        self.n = n
        self.df = df
        self.r = r

        self._expand_state()

    def _expand_state(self):
        NDTest._expand_state(self)

        r = self.r

        # uncorrected probability
        pmap = stats.rtest_p(r.x, self.df)
        info = _info.for_p_map()
        p_uncorrected = NDVar(pmap, r.dims, 'p_uncorrected', info)
        self.p_uncorrected = p_uncorrected
        self.r_p = [[r, self.p]] if self.samples else None

    def _name(self):
        if self.y and self.x:
            return "Correlation:  %s ~ %s" % (self.y, self.x)
        else:
            return "Correlation"

    def _repr_test_args(self):
        args = [repr(self.y), repr(self.x)]
        if self.norm:
            args.append('norm=%r' % self.norm)
        return args

    def _default_plot_obj(self):
        if self.samples:
            return self.masked_parameter_map()
        else:
            return self.r


class NDDifferenceTest(NDTest):

    difference = None

    @staticmethod
    def _difference_name(diff):
        long_name = longname(diff, True)
        if longname is None or len(long_name) > 80:
            return 'difference'
        else:
            return long_name

    def _get_mask(self, p=0.05):
        self._assert_has_cdist()
        if not 1 >= p > 0:
            raise ValueError(f"p={p}: needs to be between 1 and 0")
        if p == 1:
            if self._cdist.kind != 'cluster':
                raise ValueError(f"p=1 is only a valid mask for threshold-based cluster tests")
            mask = self._cdist.cluster_map == 0
        else:
            mask = self.p > p
        return self._cdist.uncrop(mask, self.difference, True)

    def masked_difference(self, p=0.05, name=None):
        """Difference map masked by significance

        Parameters
        ----------
        p : scalar
            Threshold p-value for masking (default 0.05). For threshold-based
            cluster tests, ``pmin=1`` includes all clusters regardless of their
            p-value.
        name : str
            Name of the output NDVar.
        """
        mask = self._get_mask(p)
        return self.difference.mask(mask, name=name)


class NDMaskedC1Mixin:

    def masked_c1(self, p=0.05):
        """``c1`` map masked by significance of the ``c1``-``c0`` difference

        Parameters
        ----------
        p : scalar
            Threshold p-value for masking (default 0.05). For threshold-based
            cluster tests, ``pmin=1`` includes all clusters regardless of their
            p-value.
        """
        mask = self._get_mask(p)
        return self.c1_mean.mask(mask)


class TTestOneSample(NDDifferenceTest):
    """Mass-univariate one sample t-test

    Parameters
    ----------
    y : NDVar
        Dependent variable.
    popmean : scalar
        Value to compare y against (default is 0).
    match : None | categorial
        Combine data for these categories before testing.
    sub : index
        Perform test with a subset of the data.
    ds : Dataset
        If a Dataset is specified, all data-objects can be specified as
        names of Dataset variables
    tail : 0 | 1 | -1
        Which tail of the t-distribution to consider:
        0: both (two-tailed);
        1: upper tail (one-tailed);
        -1: lower tail (one-tailed).
    samples : int
        Number of samples for permutation test (default 10,000).
    pmin : None | scalar (0 < pmin < 1)
        Threshold for forming clusters:  use a t-value equivalent to an
        uncorrected p-value.
    tmin : scalar
        Threshold for forming clusters as t-value.
    tfce : bool | scalar
        Use threshold-free cluster enhancement. Use a scalar to specify the
        step of TFCE levels (for ``tfce is True``, 0.1 is used).
    tstart : scalar
        Start of the time window for the permutation test (default is the
        beginning of ``y``).
    tstop : scalar
        Stop of the time window for the permutation test (default is the
        end of ``y``).
    parc : str
        Collect permutation statistics for all regions of the parcellation of
        this dimension. For threshold-based test, the regions are disconnected.
    force_permutation: bool
        Conduct permutations regardless of whether there are any clusters.
    mintime : scalar
        Minimum duration for clusters (in seconds).
    minsource : int
        Minimum number of sources per cluster.

    Attributes
    ----------
    clusters : None | Dataset
        For cluster-based tests, a table of all clusters. Otherwise a table of
        all significant regions (or ``None`` if permutations were omitted).
        See also the :meth:`.find_clusters` method.
    difference : NDVar
        The difference value entering the test (``y`` if popmean is 0).
    n : int
        Number of cases.
    p : NDVar | None
        Map of p-values corrected for multiple comparison (or None if no
        correction was performed).
    p_uncorrected : NDVar
        Map of p-values uncorrected for multiple comparison.
    t : NDVar
        Map of t-values.
    tfce_map : NDVar | None
        Map of the test statistic processed with the threshold-free cluster
        enhancement algorithm (or None if no TFCE was performed).

    See Also
    --------
    testnd : Information on the different permutation methods

    Notes
    -----
    Data points with zero variance are set to t=0.
    """
    _state_specific = ('popmean', 'tail', 'n', 'df', 't', 'difference')
    _statistic = 't'

    @user_activity
    def __init__(
            self,
            y: NDVarArg,
            popmean: float = 0,
            match: CategorialArg = None,
            sub: IndexArg = None,
            ds: Dataset = None,
            tail: int = 0,
            samples: int = 10000,
            pmin: float = None,
            tmin: float = None,
            tfce: Union[float, bool] = False,
            tstart: float = None,
            tstop: float = None,
            parc: str = None,
            force_permutation: bool = False,
            **criteria):
        ct = Celltable(y, match=match, sub=sub, ds=ds, coercion=asndvar, dtype=np.float64)
        check_for_vector_dim(ct.y)

        n = len(ct.y)
        df = n - 1
        y = ct.y.summary()
        tmap = stats.t_1samp(ct.y.x)
        if popmean:
            raise NotImplementedError("popmean != 0")
            diff = y - popmean
            if np.any(diff < 0):
                diff.info['cmap'] = 'xpolar'
            diff.name = self._difference_name(diff)
        else:
            diff = y

        n_threshold_params = sum((pmin is not None, tmin is not None, bool(tfce)))
        if n_threshold_params == 0 and not samples:
            threshold = cdist = None
        elif n_threshold_params > 1:
            raise ValueError("Only one of pmin, tmin and tfce can be specified")
        else:
            if pmin is not None:
                threshold = stats.ttest_t(pmin, df, tail)
            elif tmin is not None:
                threshold = abs(tmin)
            else:
                threshold = None

            if popmean:
                y_perm = ct.y - popmean
            else:
                y_perm = ct.y
            n_samples, samples = _resample_params(len(y_perm), samples)
            cdist = NDPermutationDistribution(
                y_perm, n_samples, threshold, tfce, tail, 't', '1-Sample t-Test',
                tstart, tstop, criteria, parc, force_permutation)
            cdist.add_original(tmap)
            if cdist.do_permutation:
                iterator = permute_sign_flip(n, samples)
                run_permutation(opt.t_1samp_perm, cdist, iterator)

        # NDVar map of t-values
        info = _info.for_stat_map('t', threshold, tail=tail, old=ct.y.info)
        t = NDVar(tmap, ct.y.dims[1:], 't', info)

        # store attributes
        NDDifferenceTest.__init__(self, ct.y, ct.match, sub, samples, tfce, pmin, cdist, tstart, tstop)
        self.popmean = popmean
        self.n = n
        self.df = df
        self.tail = tail
        self.t = t
        self.tmin = tmin
        self.difference = diff
        self._expand_state()

    def __setstate__(self, state):
        if 'diff' in state:
            state['difference'] = state.pop('diff')
        NDTest.__setstate__(self, state)

    def _expand_state(self):
        NDTest._expand_state(self)

        t = self.t
        pmap = stats.ttest_p(t.x, self.df, self.tail)
        info = _info.for_p_map(t.info)
        p_uncorr = NDVar(pmap, t.dims, 'p', info)
        self.p_uncorrected = p_uncorr

    def _name(self):
        if self.y:
            return "One-Sample T-Test:  %s" % self.y
        else:
            return "One-Sample T-Test"

    def _repr_test_args(self):
        args = [repr(self.y)]
        if self.popmean:
            args.append(repr(self.popmean))
        if self.match:
            args.append('match=%r' % self.match)
        if self.tail:
            args.append("tail=%i" % self.tail)
        return args

    def _default_plot_obj(self):
        if self.samples:
            return self.masked_difference()
        else:
            return self.difference


class TTestIndependent(NDDifferenceTest):
    """Mass-univariate independent samples t-test

    The test data can be specified in two forms:

     - In long form, with ``y`` supplying the data, ``x`` specifying condition
       for each case.
     - With ``y`` and ``x`` supplying data for the two conditions.

    Parameters
    ----------
    y : NDVar
        Dependent variable.
    x : categorial | NDVar
        Model containing the cells which should be compared, or NDVar to which
        ``y`` should be compared. In the latter case, the next three parameters
        are ignored.
    c1 : str | tuple | None
        Test condition (cell of ``x``). ``c1`` and ``c0`` can be omitted if
        ``x`` only contains two cells, in which case cells will be used in
        alphabetical order.
    c0 : str | tuple | None
        Control condition (cell of ``x``).
    match : categorial
        Combine cases with the same cell on ``x % match``.
    sub : index
        Perform the test with a subset of the data.
    ds : Dataset
        If a Dataset is specified, all data-objects can be specified as
        names of Dataset variables.
    tail : 0 | 1 | -1
        Which tail of the t-distribution to consider:
        0: both (two-tailed);
        1: upper tail (one-tailed);
        -1: lower tail (one-tailed).
    samples : int
        Number of samples for permutation test (default 10,000).
    pmin : None | scalar (0 < pmin < 1)
        Threshold p value for forming clusters. None for threshold-free
        cluster enhancement.
    tmin : scalar
        Threshold for forming clusters as t-value.
    tfce : bool | scalar
        Use threshold-free cluster enhancement. Use a scalar to specify the
        step of TFCE levels (for ``tfce is True``, 0.1 is used).
    tstart : scalar
        Start of the time window for the permutation test (default is the
        beginning of ``y``).
    tstop : scalar
        Stop of the time window for the permutation test (default is the
        end of ``y``).
    parc : str
        Collect permutation statistics for all regions of the parcellation of
        this dimension. For threshold-based test, the regions are disconnected.
    force_permutation: bool
        Conduct permutations regardless of whether there are any clusters.
    mintime : scalar
        Minimum duration for clusters (in seconds).
    minsource : int
        Minimum number of sources per cluster.

    Attributes
    ----------
    c1_mean : NDVar
        Mean in the c1 condition.
    c0_mean : NDVar
        Mean in the c0 condition.
    clusters : None | Dataset
        For cluster-based tests, a table of all clusters. Otherwise a table of
        all significant regions (or ``None`` if permutations were omitted).
        See also the :meth:`.find_clusters` method.
    difference : NDVar
        Difference between the mean in condition c1 and condition c0.
    p : NDVar | None
        Map of p-values corrected for multiple comparison (or None if no
        correction was performed).
    p_uncorrected : NDVar
        Map of p-values uncorrected for multiple comparison.
    t : NDVar
        Map of t-values.
    tfce_map : NDVar | None
        Map of the test statistic processed with the threshold-free cluster
        enhancement algorithm (or None if no TFCE was performed).

    See Also
    --------
    testnd : Information on the different permutation methods

    Notes
    -----
    Cases with zero variance are set to t=0.
    """
    _state_specific = ('x', 'c1', 'c0', 'tail', 't', 'n1', 'n0', 'df', 'c1_mean',
                       'c0_mean')
    _statistic = 't'

    @user_activity
    def __init__(
            self,
            y: NDVarArg,
            x: Union[CategorialArg, NDVarArg],
            c1: CellArg = None,
            c0: CellArg = None,
            match: CategorialArg = None,
            sub: IndexArg = None,
            ds: Dataset = None,
            tail: int = 0,
            samples: int = 10000,
            pmin: float = None,
            tmin: float = None,
            tfce: Union[float, bool] = False,
            tstart: float = None,
            tstop: float = None,
            parc: str = None,
            force_permutation: bool = False,
            **criteria):
        y, y1, y0, c1, c0, match, x_name, c1_name, c0_name = _independent_measures_args(y, x, c1, c0, match, ds, sub, True)
        check_for_vector_dim(y)

        n1 = len(y1)
        n = len(y)
        n0 = n - n1
        df = n - 2
        groups = np.arange(n) < n1
        groups.dtype = np.int8
        tmap = stats.t_ind(y.x, groups)

        n_threshold_params = sum((pmin is not None, tmin is not None, bool(tfce)))
        if n_threshold_params == 0 and not samples:
            threshold = cdist = None
        elif n_threshold_params > 1:
            raise ValueError("Only one of pmin, tmin and tfce can be specified")
        else:
            if pmin is not None:
                threshold = stats.ttest_t(pmin, df, tail)
            elif tmin is not None:
                threshold = abs(tmin)
            else:
                threshold = None

            cdist = NDPermutationDistribution(y, samples, threshold, tfce, tail, 't', 'Independent Samples t-Test', tstart, tstop, criteria, parc, force_permutation)
            cdist.add_original(tmap)
            if cdist.do_permutation:
                iterator = permute_order(n, samples)
                run_permutation(stats.t_ind, cdist, iterator, groups)

        # store attributes
        NDDifferenceTest.__init__(self, y, match, sub, samples, tfce, pmin, cdist, tstart, tstop)
        self.x = x_name
        self.c0 = c0
        self.c1 = c1
        self.n1 = n1
        self.n0 = n0
        self.df = df
        self.tail = tail
        info = _info.for_stat_map('t', threshold, tail=tail, old=y.info)
        self.t = NDVar(tmap, y.dims[1:], 't', info)
        self.tmin = tmin
        self.c1_mean = y1.mean('case', name=cellname(c1_name))
        self.c0_mean = y0.mean('case', name=cellname(c0_name))
        self._expand_state()

    def _expand_state(self):
        NDTest._expand_state(self)

        # difference
        diff = self.c1_mean - self.c0_mean
        if np.any(diff.x < 0):
            diff.info['cmap'] = 'xpolar'
        diff.name = self._difference_name(diff)
        self.difference = diff

        # uncorrected p
        pmap = stats.ttest_p(self.t.x, self.df, self.tail)
        info = _info.for_p_map(self.t.info)
        p_uncorr = NDVar(pmap, self.t.dims, 'p', info)
        self.p_uncorrected = p_uncorr

        # composites
        if self.samples:
            diff_p = self.masked_difference()
        else:
            diff_p = self.difference
        self.all = [self.c1_mean, self.c0_mean, diff_p]

    def _name(self):
        cmp = '=><'[self.tail]
        desc = f"{self.c1} {cmp} {self.c0}"
        if self.y:
            desc = f"{self.y} ~ {desc}"
        return f"Independent-Samples T-Test:  {desc}"

    def _plot_model(self):
        return self.x

    def _plot_sub(self):
        return "(%s).isin(%s)" % (self.x, (self.c1, self.c0))

    def _repr_test_args(self):
        if self.c1 is None:
            args = [f'{self.y!r} (n={self.n1})', f'{self.x!r} (n={self.n0})']
        else:
            args = [f'{self.y!r}', f'{self.x!r}', f'{self.c1!r} (n={self.n1})', f'{self.c0!r} (n={self.n0})']
        if self.match:
            args.append(f'match{self.match!r}')
        if self.tail:
            args.append(f'tail={self.tail}')
        return args

    def _default_plot_obj(self):
        if self.samples:
            diff = self.masked_difference()
        else:
            diff = self.difference
        return [self.c1_mean, self.c0_mean, diff]


class TTestRelated(NDMaskedC1Mixin, NDDifferenceTest):
    """Mass-univariate related samples t-test

    The test data can be specified in two forms:

     - In long form, with ``y`` supplying the data, ``x`` specifying condition
       for each case and ``match`` determining which cases are related.
     - In wide/repeated measures form, with ``y`` and ``x`` both supplying data
       with matching case order.

    Parameters
    ----------
    y : NDVar
        Dependent variable.
    x : categorial | NDVar
        Model containing the cells which should be compared, or NDVar to which
        ``y`` should be compared. In the latter case, the next three parameters
        are ignored.
    c1 : str | tuple | None
        Test condition (cell of ``x``). ``c1`` and ``c0`` can be omitted if
        ``x`` only contains two cells, in which case cells will be used in
        alphabetical order.
    c0 : str | tuple | None
        Control condition (cell of ``x``).
    match : categorial
        Units within which measurements are related (e.g. 'subject' in a
        within-subject comparison).
    sub : index
        Perform the test with a subset of the data.
    ds : Dataset
        If a Dataset is specified, all data-objects can be specified as
        names of Dataset variables.
    tail : 0 | 1 | -1
        Which tail of the t-distribution to consider:
        0: both (two-tailed, default);
        1: upper tail (one-tailed);
        -1: lower tail (one-tailed).
    samples : int
        Number of samples for permutation test (default 10,000).
    pmin : None | scalar (0 < pmin < 1)
        Threshold for forming clusters:  use a t-value equivalent to an
        uncorrected p-value.
    tmin : scalar
        Threshold for forming clusters as t-value.
    tfce : bool | scalar
        Use threshold-free cluster enhancement. Use a scalar to specify the
        step of TFCE levels (for ``tfce is True``, 0.1 is used).
    tstart : scalar
        Start of the time window for the permutation test (default is the
        beginning of ``y``).
    tstop : scalar
        Stop of the time window for the permutation test (default is the
        end of ``y``).
    parc : str
        Collect permutation statistics for all regions of the parcellation of
        this dimension. For threshold-based test, the regions are disconnected.
    force_permutation: bool
        Conduct permutations regardless of whether there are any clusters.
    mintime : scalar
        Minimum duration for clusters (in seconds).
    minsource : int
        Minimum number of sources per cluster.

    Attributes
    ----------
    c1_mean : NDVar
        Mean in the c1 condition.
    c0_mean : NDVar
        Mean in the c0 condition.
    clusters : None | Dataset
        For cluster-based tests, a table of all clusters. Otherwise a table of
        all significant regions (or ``None`` if permutations were omitted).
        See also the :meth:`.find_clusters` method.
    difference : NDVar
        Difference between the mean in condition c1 and condition c0.
    p : NDVar | None
        Map of p-values corrected for multiple comparison (or None if no
        correction was performed).
    p_uncorrected : NDVar
        Map of p-values uncorrected for multiple comparison.
    t : NDVar
        Map of t-values.
    tfce_map : NDVar | None
        Map of the test statistic processed with the threshold-free cluster
        enhancement algorithm (or None if no TFCE was performed).
    n : int
        Number of cases.

    See Also
    --------
    testnd : Information on the different permutation methods

    Notes
    -----
    Also known as dependent t-test, paired t-test or repeated measures t-test.
    In the permutation cluster test, permutations are done within the
    categories of ``match``.
    Cases with zero variance are set to t=0.
    """
    _state_specific = ('x', 'c1', 'c0', 'tail', 't', 'n', 'df', 'c1_mean',
                       'c0_mean')
    _statistic = 't'

    @user_activity
    def __init__(
            self,
            y: NDVarArg,
            x: Union[CategorialArg, NDVarArg],
            c1: CellArg = None,
            c0: CellArg = None,
            match: CategorialArg = None,
            sub: IndexArg = None,
            ds: Dataset = None,
            tail: int = 0,
            samples: int = 10000,
            pmin: float = None,
            tmin: float = None,
            tfce: Union[float, bool] = False,
            tstart: float = None,
            tstop: float = None,
            parc: str = None,
            force_permutation: bool = False,
            **criteria):
        y1, y0, c1, c0, match, n, x_name, c1_name, c0_name = _related_measures_args(y, x, c1, c0, match, ds, sub, True)
        check_for_vector_dim(y1)

        if n <= 2:
            raise ValueError("Not enough observations for t-test (n=%i)" % n)
        df = n - 1
        diff = y1 - y0
        tmap = stats.t_1samp(diff.x)

        n_threshold_params = sum((pmin is not None, tmin is not None, bool(tfce)))
        if n_threshold_params == 0 and not samples:
            threshold = cdist = None
        elif n_threshold_params > 1:
            raise ValueError("Only one of pmin, tmin and tfce can be specified")
        else:
            if pmin is not None:
                threshold = stats.ttest_t(pmin, df, tail)
            elif tmin is not None:
                threshold = abs(tmin)
            else:
                threshold = None

            n_samples, samples = _resample_params(len(diff), samples)
            cdist = NDPermutationDistribution(
                diff, n_samples, threshold, tfce, tail, 't', 'Related Samples t-Test',
                tstart, tstop, criteria, parc, force_permutation)
            cdist.add_original(tmap)
            if cdist.do_permutation:
                iterator = permute_sign_flip(n, samples)
                run_permutation(opt.t_1samp_perm, cdist, iterator)

        # NDVar map of t-values
        info = _info.for_stat_map('t', threshold, tail=tail, old=y1.info)
        t = NDVar(tmap, y1.dims[1:], 't', info)

        # store attributes
        NDDifferenceTest.__init__(self, y1, match, sub, samples, tfce, pmin, cdist, tstart, tstop)
        self.x = x_name
        self.c0 = c0
        self.c1 = c1

        self.n = n
        self.df = df
        self.tail = tail
        self.t = t
        self.tmin = tmin

        self.c1_mean = y1.mean('case', name=cellname(c1_name))
        self.c0_mean = y0.mean('case', name=cellname(c0_name))

        self._expand_state()

    def _expand_state(self):
        NDTest._expand_state(self)

        cdist = self._cdist
        t = self.t

        # difference
        diff = self.c1_mean - self.c0_mean
        if np.any(diff.x < 0):
            diff.info['cmap'] = 'xpolar'
        diff.name = self._difference_name(diff)
        self.difference = diff

        # uncorrected p
        pmap = stats.ttest_p(t.x, self.df, self.tail)
        info = _info.for_p_map()
        self.p_uncorrected = NDVar(pmap, t.dims, 'p', info)

        # composites
        if self.samples:
            diff_p = self.masked_difference()
        else:
            diff_p = self.difference
        self.all = [self.c1_mean, self.c0_mean, diff_p]

    def _name(self):
        if self.tail == 0:
            comp = "%s == %s" % (self.c1, self.c0)
        elif self.tail > 0:
            comp = "%s > %s" % (self.c1, self.c0)
        else:
            comp = "%s < %s" % (self.c1, self.c0)

        if self.y:
            return "Related-Samples T-Test:  %s ~ %s" % (self.y, comp)
        else:
            return "Related-Samples T-Test:  %s" % comp

    def _plot_model(self):
        return self.x

    def _plot_sub(self):
        return "(%s).isin(%s)" % (self.x, (self.c1, self.c0))

    def _repr_test_args(self):
        args = [repr(self.y), repr(self.x)]
        if self.c1 is not None:
            args.extend((repr(self.c1), repr(self.c0), repr(self.match)))
        args[-1] += " (n=%i)" % self.n
        if self.tail:
            args.append("tail=%i" % self.tail)
        return args

    def _default_plot_obj(self):
        if self.samples:
            diff = self.masked_difference()
        else:
            diff = self.difference
        return [self.c1_mean, self.c0_mean, diff]


class MultiEffectNDTest(NDTest):

    def _repr_test_args(self):
        args = [repr(self.y), repr(self.x)]
        if self.match is not None:
            args.append('match=%r' % self.match)
        return args

    def _repr_cdist(self):
        args = self._cdist[0]._repr_test_args(self.pmin)
        for cdist in self._cdist:
            effect_args = cdist._repr_clusters()
            args.append("%r: %s" % (cdist.name, ', '.join(effect_args)))
        return args

    def _asfmtext(self, **_):
        table = fmtxt.Table('llll')
        table.cells('Effect', fmtxt.symbol(self._statistic, 'max'), fmtxt.symbol('p'), 'sig')
        table.midrule()
        for i, effect in enumerate(self.effects):
            table.cell(effect)
            table.cell(fmtxt.stat(self._max_statistic(i)))
            pmin = self.p[i].min()
            table.cell(fmtxt.p(pmin))
            table.cell(star(pmin))
        return table

    def _expand_state(self):
        self.effects = tuple(e.name for e in self._effects)

        # clusters
        cdists = self._cdist
        if cdists is None:
            self._kind = None
        else:
            self.tfce_maps = [cdist.tfce_map for cdist in cdists]
            self.p = [cdist.probability_map for cdist in cdists]
            self._kind = cdists[0].kind

    def _effect_index(self, effect: Union[int, str]):
        if isinstance(effect, str):
            return self.effects.index(effect)
        else:
            return effect

    def _iter_cdists(self):
        for cdist in self._cdist:
            yield cdist.name.capitalize(), cdist

    @property
    def _first_cdist(self):
        if self._cdist is None:
            return None
        else:
            return self._cdist[0]

    def _max_statistic(self, effect: Union[str, int]):
        i = self._effect_index(effect)
        stat_map = self._statistic_map[i]
        tail = getattr(self, 'tail', self._statistic_tail)
        return self._max_statistic_from_map(stat_map, self.p[i], tail)

    def cluster(self, cluster_id, effect=0):
        """Retrieve a specific cluster as NDVar

        Parameters
        ----------
        cluster_id : int
            Cluster id.
        effect : int | str
            Index or name of the effect from which to retrieve a cluster
            (default is the first effect).

        Returns
        -------
        cluster : NDVar
            NDVar of the cluster, 0 outside the cluster.

        Notes
        -----
        Clusters only have stable ids for thresholded cluster distributions.
        """
        self._assert_has_cdist()
        i = self._effect_index(effect)
        return self._cdist[i].cluster(cluster_id)

    def compute_probability_map(self, effect=0, **sub):
        """Compute a probability map

        Parameters
        ----------
        effect : int | str
            Index or name of the effect from which to use the parameter map
            (default is the first effect).

        Returns
        -------
        probability : NDVar
            Map of p-values.
        """
        self._assert_has_cdist()
        i = self._effect_index(effect)
        return self._cdist[i].compute_probability_map(**sub)

    def masked_parameter_map(self, effect=0, pmin=0.05, **sub):
        """Create a copy of the parameter map masked by significance

        Parameters
        ----------
        effect : int | str
            Index or name of the effect from which to use the parameter map.
        pmin : scalar
            Threshold p-value for masking (default 0.05). For threshold-based
            cluster tests, ``pmin=1`` includes all clusters regardless of their
            p-value.

        Returns
        -------
        masked_map : NDVar
            NDVar with data from the original parameter map wherever p <= pmin
            and 0 everywhere else.
        """
        self._assert_has_cdist()
        i = self._effect_index(effect)
        return self._cdist[i].masked_parameter_map(pmin, **sub)

    def find_clusters(self, pmin=None, maps=False, effect=None, **sub):
        """Find significant regions or clusters

        Parameters
        ----------
        pmin : None | scalar, 1 >= p  >= 0
            Threshold p-value. For threshold-based tests, all clusters with a
            p-value smaller than ``pmin`` are included (default 1);
            for other tests, find contiguous regions with ``p ≤ pmin`` (default
            0.05).
        maps : bool
            Include in the output a map of every cluster (can be memory
            intensive if there are large statistical maps and/or many
            clusters; default ``False``).
        effect : int | str
            Index or name of the effect from which to find clusters (default is
            all effects).

        Returns
        -------
        ds : Dataset
            Dataset with information about the clusters.
        """
        self._assert_has_cdist()
        if effect is not None:
            i = self._effect_index(effect)
            cdist = self._cdist[i]
            ds = cdist.clusters(pmin, maps, **sub)
            ds[:, 'effect'] = cdist.name
            return ds
        dss = [self.find_clusters(pmin, maps, i, **sub) for i in range(len(self.effects))]
        info = {}
        for ds, cdist in zip(dss, self._cdist):
            if 'clusters' in ds.info:
                info[f'{cdist.name} clusters'] = ds.info.pop('clusters')
        out = combine(dss)
        out.info.update(info)
        return out

    def find_peaks(self):
        """Find peaks in a TFCE distribution

        Returns
        -------
        ds : Dataset
            Dataset with information about the peaks.
        """
        self._assert_has_cdist()
        dss = []
        for cdist in self._cdist:
            ds = cdist.find_peaks()
            ds[:, 'effect'] = cdist.name
            dss.append(ds)
        return combine(dss)


class ANOVA(MultiEffectNDTest):
    """Mass-univariate ANOVA

    Parameters
    ----------
    y : NDVar
        Dependent variable.
    x : Model
        Independent variables.
    sub : index
        Perform the test with a subset of the data.
    ds : Dataset
        If a Dataset is specified, all data-objects can be specified as
        names of Dataset variables.
    samples : int
        Number of samples for permutation test (default 10,000).
    pmin : None | scalar (0 < pmin < 1)
        Threshold for forming clusters:  use an f-value equivalent to an
        uncorrected p-value.
    fmin : scalar
        Threshold for forming clusters as f-value.
    tfce : bool | scalar
        Use threshold-free cluster enhancement. Use a scalar to specify the
        step of TFCE levels (for ``tfce is True``, 0.1 is used).
    tstart : scalar
        Start of the time window for the permutation test (default is the
        beginning of ``y``).
    tstop : scalar
        Stop of the time window for the permutation test (default is the
        end of ``y``).
    match : categorial | False
        When permuting data, only shuffle the cases within the categories
        of match. By default, ``match`` is determined automatically based on
        the random efects structure of ``x``.
    parc : str
        Collect permutation statistics for all regions of the parcellation of
        this dimension. For threshold-based test, the regions are disconnected.
    force_permutation: bool
        Conduct permutations regardless of whether there are any clusters.
    mintime : scalar
        Minimum duration for clusters (in seconds).
    minsource : int
        Minimum number of sources per cluster.

    Attributes
    ----------
    effects : tuple of str
        Names of the tested effects, in the same order as in other attributes.
    clusters : None | Dataset
        For cluster-based tests, a table of all clusters. Otherwise a table of
        all significant regions (or ``None`` if permutations were omitted).
        See also the :meth:`.find_clusters` method.
    f : list of NDVar
        Maps of F values.
    p : list of NDVar | None
        Maps of p-values corrected for multiple comparison (or None if no
        correction was performed).
    p_uncorrected : list of NDVar
        Maps of p-values uncorrected for multiple comparison.
    tfce_maps : list of NDVar | None
        Maps of the test statistic processed with the threshold-free cluster
        enhancement algorithm (or None if no TFCE was performed).

    See Also
    --------
    testnd : Information on the different permutation methods

    Examples
    --------
    For information on model specification see the univariate
    :class:`~eelbrain.test.ANOVA` examples.
    """
    _state_specific = ('x', 'pmin', '_effects', '_dfs_denom', 'f')
    _statistic = 'f'
    _statistic_tail = 1

    @user_activity
    def __init__(
            self,
            y: NDVarArg,
            x: ModelArg,
            sub: IndexArg = None,
            ds: Dataset = None,
            samples: int = 10000,
            pmin: float = None,
            fmin: float = None,
            tfce: Union[float, bool] = False,
            tstart: float = None,
            tstop: float = None,
            match: Union[CategorialArg, bool] = None,
            parc: str = None,
            force_permutation: bool = False,
            **criteria):
        x_arg = x
        sub_arg = sub
        sub, n = assub(sub, ds, True)
        y, n = asndvar(y, sub, ds, n, np.float64, True)
        check_for_vector_dim(y)
        x = asmodel(x, sub, ds, n, require_names=True)
        if match is None:
            random_effects = [e for e in x.effects if e.random]
            if not random_effects:
                match = None
            elif len(random_effects) > 1:
                raise NotImplementedError("Automatic match parameter for model with more than one random effect. Set match manually.")
            else:
                match = random_effects[0]
        elif match is not False:
            match = ascategorial(match, sub, ds, n)

        lm = _nd_anova(x)
        effects = lm.effects
        dfs_denom = lm.dfs_denom
        fmaps = lm.map(y.x)

        n_threshold_params = sum((pmin is not None, fmin is not None, bool(tfce)))
        if n_threshold_params == 0 and not samples:
            cdists = None
            thresholds = tuple(repeat(None, len(effects)))
        elif n_threshold_params > 1:
            raise ValueError("Only one of pmin, fmin and tfce can be specified")
        else:
            if pmin is not None:
                thresholds = tuple(stats.ftest_f(pmin, e.df, df_den) for e, df_den in zip(effects, dfs_denom))
            elif fmin is not None:
                thresholds = tuple(repeat(abs(fmin), len(effects)))
            else:
                thresholds = tuple(repeat(None, len(effects)))

            cdists = [
                NDPermutationDistribution(
                    y, samples, thresh, tfce, 1, 'f', e.name,
                    tstart, tstop, criteria, parc, force_permutation)
                for e, thresh in zip(effects, thresholds)]

            # Find clusters in the actual data
            do_permutation = 0
            for cdist, fmap in zip(cdists, fmaps):
                cdist.add_original(fmap)
                do_permutation += cdist.do_permutation

            if do_permutation:
                iterator = permute_order(len(y), samples, unit=match)
                run_permutation_me(lm, cdists, iterator)

        # create ndvars
        dims = y.dims[1:]
        f = []
        for e, fmap, df_den, f_threshold in zip(effects, fmaps, dfs_denom, thresholds):
            info = _info.for_stat_map('f', f_threshold, tail=1, old=y.info)
            f.append(NDVar(fmap, dims, e.name, info))

        # store attributes
        MultiEffectNDTest.__init__(self, y, match, sub_arg, samples, tfce, pmin,
                                   cdists, tstart, tstop)
        self.x = x_arg if isinstance(x_arg, str) else x.name
        self._effects = effects
        self._dfs_denom = dfs_denom
        self.f = f

        self._expand_state()

    def _expand_state(self):
        # backwards compatibility
        if hasattr(self, 'effects'):
            self._effects = self.effects

        MultiEffectNDTest._expand_state(self)

        # backwards compatibility
        if hasattr(self, 'df_den'):
            df_den_temp = {e.name: df for e, df in self.df_den.items()}
            del self.df_den
            self._dfs_denom = tuple(df_den_temp[e] for e in self.effects)

        # f-maps with clusters
        pmin = self.pmin or 0.05
        if self.samples:
            f_and_clusters = []
            for e, fmap, df_den, cdist in zip(self._effects, self.f,
                                               self._dfs_denom, self._cdist):
                # create f-map with cluster threshold
                f0 = stats.ftest_f(pmin, e.df, df_den)
                info = _info.for_stat_map('f', f0)
                f_ = NDVar(fmap.x, fmap.dims, e.name, info)
                # add overlay with cluster
                if cdist.probability_map is not None:
                    f_and_clusters.append([f_, cdist.probability_map])
                else:
                    f_and_clusters.append([f_])
            self.f_probability = f_and_clusters

        # uncorrected probability
        p_uncorr = []
        for e, f, df_den in zip(self._effects, self.f, self._dfs_denom):
            info = _info.for_p_map()
            pmap = stats.ftest_p(f.x, e.df, df_den)
            p_ = NDVar(pmap, f.dims, e.name, info)
            p_uncorr.append(p_)
        self.p_uncorrected = p_uncorr

    def _name(self):
        if self.y:
            return "ANOVA:  %s ~ %s" % (self.y, self.x)
        else:
            return "ANOVA:  %s" % self.x

    def _plot_model(self):
        return '%'.join(e.name for e in self._effects if isinstance(e, Factor) or
                        (isinstance(e, NestedEffect) and isinstance(e.effect, Factor)))

    def _plot_sub(self):
        return super(ANOVA, self)._plot_sub()

    def _default_plot_obj(self):
        if self.samples:
            return [self.masked_parameter_map(e) for e in self.effects]
        else:
            return self._statistic_map

    def table(self, title=None, caption=None, clusters=False):
        """Table listing all effects and corresponding smallest p-values

        Parameters
        ----------
        title : text
            Title for the table.
        caption : text
            Caption for the table.
        clusters : bool | float
            Include properties of all significant clusters (default ``False``;
            use float to include clusters with p ≤ ``clusters``).

        Returns
        -------
        table : eelbrain.fmtxt.Table
            ANOVA table.
        """
        # table columns
        columns = ['#', 'Effect']
        alignment = ['r', 'l']
        if clusters:
            cluster_properties = self._first_cdist._cluster_property_labels()
            columns.extend(cluster_properties)
            alignment.extend('l' * len(cluster_properties))
        else:
            cluster_properties = []
        columns.append('f_max')
        alignment.append('r')
        if self.p is not None:
            columns.extend(['p', 'sig'])
            alignment.extend(['r', 'l'])
        table = fmtxt.Table(alignment, title=title, caption=caption)
        table.cells(*columns)
        table.midrule()

        cluster_pmin = None if clusters is True else clusters
        show_map_stats = True
        for i, effect in enumerate(self.effects):
            table.cell(i)
            table.cell(effect)
            if self.p is not None:
                if clusters:
                    cluster_table = self.find_clusters(cluster_pmin, True, effect=effect)
                    show_map_stats = cluster_table.n_cases == 0
                    for ci in range(cluster_table.n_cases):
                        if ci:
                            table.cells('', '')
                        # properties
                        for key in cluster_properties:
                            table.cell(cluster_table[ci, key])
                        # f_max, p, sig
                        table.cell(fmtxt.stat(cluster_table[ci, 'cluster'].max()))
                        table.cell(fmtxt.p(cluster_table[ci, 'p']))
                        table.cell(star(cluster_table[ci, 'p']))
                    if show_map_stats:
                        for _ in cluster_properties:
                            table.cell('')
            if show_map_stats:
                table.cell(fmtxt.stat(self.f[i].max()))
                if self.p is not None:
                    pmin = self.p[i].min()
                    table.cell(fmtxt.p(pmin))
                    table.cell(star(pmin))
        return table


class Vector(NDDifferenceTest):
    """Test a vector field for vectors with non-random direction

    Parameters
    ----------
    y : NDVar
        Dependent variable (needs to include one vector dimension).
    match : None | categorial
        Combine data for these categories before testing.
    sub : index
        Perform test with a subset of the data.
    ds : Dataset
        If a Dataset is specified, all data-objects can be specified as
        names of Dataset variables
    samples : int
        Number of samples for permutation test (default 10000).
    tmin : scalar
        Threshold value for forming clusters.
    tfce : bool | scalar
        Use threshold-free cluster enhancement. Use a scalar to specify the
        step of TFCE levels (for ``tfce is True``, 0.1 is used).
    tstart : scalar
        Start of the time window for the permutation test (default is the
        beginning of ``y``).
    tstop : scalar
        Stop of the time window for the permutation test (default is the
        end of ``y``).
    parc : str
        Collect permutation statistics for all regions of the parcellation of
        this dimension. For threshold-based test, the regions are disconnected.
    force_permutation: bool
        Conduct permutations regardless of whether there are any clusters.
    norm : bool
        Use the vector norm as univariate test statistic (instead of Hotelling’s
        T-Square statistic).
    mintime : scalar
        Minimum duration for clusters (in seconds).
    minsource : int
        Minimum number of sources per cluster.

    Attributes
    ----------
    n : int
        Number of cases.
    difference : NDVar
        The vector field averaged across cases.
    t2 : NDVar | None
        Hotelling T-Square map; ``None`` if the test used ``norm=True``.
    p : NDVar | None
        Map of p-values corrected for multiple comparison (or ``None`` if no
        correction was performed).
    tfce_map : NDVar | None
        Map of the test statistic processed with the threshold-free cluster
        enhancement algorithm (or None if no TFCE was performed).
    clusters : None | Dataset
        For cluster-based tests, a table of all clusters. Otherwise a table of
        all significant regions (or ``None`` if permutations were omitted).
        See also the :meth:`.find_clusters` method.

    See Also
    --------
    testnd : Information on the different permutation methods

    Notes
    -----
    Vector tests are based on the Hotelling T-Square statistic. Computation of
    the T-Square statistic relies on [1]_.

    References
    ----------
    .. [1] Kopp, J. (2008). Efficient numerical diagonalization of hermitian 3 x
        3 matrices. International Journal of Modern Physics C, 19(3), 523-548.
        `10.1142/S0129183108012303 <https://doi.org/10.1142/S0129183108012303>`_
    """
    _state_specific = ('difference', 'n', '_v_dim', 't2')

    @user_activity
    def __init__(
            self,
            y: NDVarArg,
            match: CategorialArg = None,
            sub: IndexArg = None,
            ds: Dataset = None,
            samples: int = 10000,
            tmin: float = None,
            tfce: Union[float, bool] = False,
            tstart: float = None,
            tstop: float = None,
            parc: str = None,
            force_permutation: bool = False,
            norm: bool = False,
            **criteria):
        use_norm = bool(norm)
        ct = Celltable(y, match=match, sub=sub, ds=ds, coercion=asndvar, dtype=np.float64)

        n = len(ct.y)
        cdist = NDPermutationDistribution(ct.y, samples, tmin, tfce, 1, 'norm', 'Vector test', tstart, tstop, criteria, parc, force_permutation)

        v_dim = ct.y.dimnames[cdist._vector_ax + 1]
        v_mean = ct.y.mean('case')
        v_mean_norm = v_mean.norm(v_dim)
        if not use_norm:
            t2_map = self._vector_t2_map(ct.y)
            cdist.add_original(t2_map.x if v_mean.ndim > 1 else t2_map)
            if v_mean.ndim == 1:
                self.t2 = t2_map
            else:
                self.t2 = NDVar(t2_map, v_mean_norm.dims, 't2', _info.for_stat_map('t2'))
        else:
            cdist.add_original(v_mean_norm.x if v_mean.ndim > 1 else v_mean_norm)
            self.t2 = None

        if cdist.do_permutation:
            iterator = random_seeds(samples)
            vector_perm = partial(self._vector_perm, use_norm=use_norm)
            run_permutation(vector_perm, cdist, iterator)

        # store attributes
        NDTest.__init__(self, ct.y, ct.match, sub, samples, tfce, None, cdist, tstart, tstop)
        self.difference = v_mean
        self._v_dim = v_dim
        self.n = n

        self._expand_state()

    def __setstate__(self, state):
        if 'diff' in state:
            state['difference'] = state.pop('diff')
        NDTest.__setstate__(self, state)

    @property
    def _statistic(self):
        return 'norm' if self.t2 is None else 't2'

    def _name(self):
        if self.y:
            return f"Vector test:  {self.y}"
        else:
            return "Vector test"

    def _repr_test_args(self):
        args = []
        if self.y:
            args.append(repr(self.y))
        if self.match:
            args.append(f'match={self.match!r}')
        return args

    @staticmethod
    def _vector_perm(y, out, seed, use_norm):
        n_cases, n_dims, n_tests = y.shape
        assert n_dims == 3
        rotation = rand_rotation_matrices(n_cases, seed)
        if use_norm:
            return vector.mean_norm_rotated(y, rotation, out)
        else:
            return vector.t2_stat_rotated(y, rotation, out)

    @staticmethod
    def _vector_t2_map(y):
        dimnames = y.get_dimnames(first=('case', 'space'))
        x = y.get_data(dimnames)
        t2_map = stats.t2_1samp(x)
        if y.ndim == 2:
            return np.float64(t2_map)
        else:
            dims = y.get_dims(dimnames[2:])
            return NDVar(t2_map, dims)


class VectorDifferenceIndependent(Vector):
    """Test difference between two vector fields for non-random direction

    Parameters
    ----------
    y : NDVar
        Dependent variable.
    x : categorial | NDVar
        Model containing the cells which should be compared, or NDVar to which
        ``y`` should be compared. In the latter case, the next three parameters
        are ignored.
    c1 : str | tuple | None
        Test condition (cell of ``x``). ``c1`` and ``c0`` can be omitted if
        ``x`` only contains two cells, in which case cells will be used in
        alphabetical order.
    c0 : str | tuple | None
        Control condition (cell of ``x``).
    match : categorial
        Combine cases with the same cell on ``x % match``.
    sub : index
        Perform the test with a subset of the data.
    ds : Dataset
        If a Dataset is specified, all data-objects can be specified as
        names of Dataset variables.
    samples : int
        Number of samples for permutation test (default 10000).
    tmin : scalar
        Threshold value for forming clusters.
    tfce : bool | scalar
        Use threshold-free cluster enhancement. Use a scalar to specify the
        step of TFCE levels (for ``tfce is True``, 0.1 is used).
    tstart : scalar
        Start of the time window for the permutation test (default is the
        beginning of ``y``).
    tstop : scalar
        Stop of the time window for the permutation test (default is the
        end of ``y``).
    parc : str
        Collect permutation statistics for all regions of the parcellation of
        this dimension. For threshold-based test, the regions are disconnected.
    force_permutation: bool
        Conduct permutations regardless of whether there are any clusters.
    norm : bool
        Use the vector norm as univariate test statistic (instead of Hotelling’s
        T-Square statistic).
    mintime : scalar
        Minimum duration for clusters (in seconds).
    minsource : int
        Minimum number of sources per cluster.

    Attributes
    ----------
    n : int
        Total number of cases.
    n1 : int
        Number of cases in ``c1``.
    n0 : int
        Number of cases in ``c0``.
    c1_mean : NDVar
        Mean in the c1 condition.
    c0_mean : NDVar
        Mean in the c0 condition.
    difference : NDVar
        Difference between the mean in condition c1 and condition c0.
    t2 : NDVar | None
        Hotelling T-Square map; ``None`` if the test used ``norm=True``.
    p : NDVar | None
        Map of p-values corrected for multiple comparison (or None if no
        correction was performed).
    tfce_map : NDVar | None
        Map of the test statistic processed with the threshold-free cluster
        enhancement algorithm (or None if no TFCE was performed).
    clusters : None | Dataset
        For cluster-based tests, a table of all clusters. Otherwise a table of
        all significant regions (or ``None`` if permutations were omitted).
        See also the :meth:`.find_clusters` method.

    See Also
    --------
    testnd : Information on the different permutation methods
    """
    _state_specific = ('difference', 'c1_mean', 'c0_mean', 'n', '_v_dim', 't2')
    _statistic = 'norm'

    @user_activity
    def __init__(
            self,
            y: NDVarArg,
            x: Union[CategorialArg, NDVarArg],
            c1: str = None,
            c0: str = None,
            match: CategorialArg = None,
            sub: IndexArg = None,
            ds: Dataset = None,
            samples: int = 10000,
            tmin: float = None,
            tfce: bool = False,
            tstart: float = None,
            tstop: float = None,
            parc: str = None,
            force_permutation: bool = False,
            norm: bool = False,
            **criteria):
        use_norm = bool(norm)
        y, y1, y0, c1, c0, match, x_name, c1_name, c0_name = _independent_measures_args(y, x, c1, c0, match, ds, sub, True)
        self.n1 = len(y1)
        self.n0 = len(y0)
        self.n = len(y)

        cdist = NDPermutationDistribution(y, samples, tmin, tfce, 1, 'norm', 'Vector test (independent)', tstart, tstop, criteria, parc, force_permutation)

        self._v_dim = v_dim = y.dimnames[cdist._vector_ax + 1]
        self.c1_mean = y1.mean('case', name=cellname(c1_name))
        self.c0_mean = y0.mean('case', name=cellname(c0_name))
        self.difference = self.c1_mean - self.c0_mean
        self.difference.name = self._difference_name(self.difference)
        v_mean_norm = self.difference.norm(v_dim)
        if not use_norm:
            raise NotImplementedError("t2 statistic not implemented for VectorDifferenceIndependent")
        else:
            cdist.add_original(v_mean_norm.x if self.difference.ndim > 1 else v_mean_norm)
            self.t2 = None

        if cdist.do_permutation:
            iterator = random_seeds(samples)
            vector_perm = partial(self._vector_perm, use_norm=use_norm)
            run_permutation(vector_perm, cdist, iterator, self.n1)

        NDTest.__init__(self, y, match, sub, samples, tfce, None, cdist, tstart, tstop)
        self._expand_state()

    def _name(self):
        if self.y:
            return f"Vector test (independent):  {self.y}"
        else:
            return "Vector test (independent)"

    @staticmethod
    def _vector_perm(y, n1, out, seed, use_norm):
        assert use_norm
        n_cases, n_dims, n_tests = y.shape
        assert n_dims == 3
        # randomize directions
        rotation = rand_rotation_matrices(n_cases, seed)
        # randomize groups
        cases = np.arange(n_cases)
        np.random.shuffle(cases)
        # group 1
        mean_1 = np.zeros((n_dims, n_tests))
        for case in cases[:n1]:
            mean_1 += np.tensordot(rotation[case], y[case], ((1,), (0,)))
        mean_1 /= n1
        # group 0
        mean_0 = np.zeros((n_dims, n_tests))
        for case in cases[n1:]:
            mean_0 += np.tensordot(rotation[case], y[case], ((1,), (0,)))
        mean_0 /= (n_cases - n1)
        # difference
        mean_1 -= mean_0
        norm = scipy.linalg.norm(mean_1, 2, axis=0)
        if out is not None:
            out[:] = norm
        return norm


class VectorDifferenceRelated(NDMaskedC1Mixin, Vector):
    """Test difference between two vector fields for non-random direction

    Parameters
    ----------
    y : NDVar
        Dependent variable.
    x : categorial | NDVar
        Model containing the cells which should be compared, or NDVar to which
        ``y`` should be compared. In the latter case, the next three parameters
        are ignored.
    c1 : str | tuple | None
        Test condition (cell of ``x``). ``c1`` and ``c0`` can be omitted if
        ``x`` only contains two cells, in which case cells will be used in
        alphabetical order.
    c0 : str | tuple | None
        Control condition (cell of ``x``).
    match : categorial
        Units within which measurements are related (e.g. 'subject' in a
        within-subject comparison).
    sub : index
        Perform the test with a subset of the data.
    ds : Dataset
        If a Dataset is specified, all data-objects can be specified as
        names of Dataset variables.
    samples : int
        Number of samples for permutation test (default 10000).
    tmin : scalar
        Threshold value for forming clusters.
    tfce : bool | scalar
        Use threshold-free cluster enhancement. Use a scalar to specify the
        step of TFCE levels (for ``tfce is True``, 0.1 is used).
    tstart : scalar
        Start of the time window for the permutation test (default is the
        beginning of ``y``).
    tstop : scalar
        Stop of the time window for the permutation test (default is the
        end of ``y``).
    parc : str
        Collect permutation statistics for all regions of the parcellation of
        this dimension. For threshold-based test, the regions are disconnected.
    force_permutation: bool
        Conduct permutations regardless of whether there are any clusters.
    norm : bool
        Use the vector norm as univariate test statistic (instead of Hotelling’s
        T-Square statistic).
    mintime : scalar
        Minimum duration for clusters (in seconds).
    minsource : int
        Minimum number of sources per cluster.

    Attributes
    ----------
    n : int
        Number of cases.
    c1_mean : NDVar
        Mean in the ``c1`` condition.
    c0_mean : NDVar
        Mean in the ``c0`` condition.
    difference : NDVar
        Difference between the mean in condition ``c1`` and condition ``c0``.
    t2 : NDVar | None
        Hotelling T-Square map; ``None`` if the test used ``norm=True``.
    p : NDVar | None
        Map of p-values corrected for multiple comparison (or ``None`` if no
        correction was performed).
    tfce_map : NDVar | None
        Map of the test statistic processed with the threshold-free cluster
        enhancement algorithm (or None if no TFCE was performed).
    clusters : None | Dataset
        For cluster-based tests, a table of all clusters. Otherwise a table of
        all significant regions (or ``None`` if permutations were omitted).
        See also the :meth:`.find_clusters` method.

    See Also
    --------
    Vector : One-sample vector test, notes on vector test implementation
    testnd : Information on the different permutation methods
    """
    _state_specific = ('x', 'c1', 'c0', 'difference', 'c1_mean', 'c0_mean', 'n', '_v_dim', 't2')

    @user_activity
    def __init__(
            self,
            y: NDVarArg,
            x: Union[CategorialArg, NDVarArg],
            c1: str = None,
            c0: str = None,
            match: CategorialArg = None,
            sub: IndexArg = None,
            ds: Dataset = None,
            samples: int = 10000,
            tmin: float = None,
            tfce: bool = False,
            tstart: float = None,
            tstop: float = None,
            parc: str = None,
            force_permutation: bool = False,
            norm: bool = False,
            **criteria):
        use_norm = bool(norm)
        y1, y0, c1, c0, match, n, x_name, c1_name, c0_name = _related_measures_args(y, x, c1, c0, match, ds, sub, True)
        difference = y1 - y0
        difference.name = 'difference'

        n_samples, samples = _resample_params(n, samples)
        cdist = NDPermutationDistribution(difference, n_samples, tmin, tfce, 1, 'norm', 'Vector test (related)', tstart, tstop, criteria, parc, force_permutation)

        v_dim = difference.dimnames[cdist._vector_ax + 1]
        v_mean = difference.mean('case')
        v_mean_norm = v_mean.norm(v_dim)
        if not use_norm:
            t2_map = self._vector_t2_map(difference)
            cdist.add_original(t2_map.x if v_mean.ndim > 1 else t2_map)
            if v_mean.ndim == 1:
                self.t2 = t2_map
            else:
                self.t2 = NDVar(t2_map, v_mean_norm.dims, 't2', _info.for_stat_map('t2'))
        else:
            cdist.add_original(v_mean_norm.x if v_mean.ndim > 1 else v_mean_norm)
            self.t2 = None

        if cdist.do_permutation:
            iterator = random_seeds(n_samples)
            vector_perm = partial(self._vector_perm, use_norm=use_norm)
            run_permutation(vector_perm, cdist, iterator)

        # store attributes
        NDTest.__init__(self, difference, match, sub, samples, tfce, None, cdist, tstart, tstop)
        self.difference = v_mean
        self.c1_mean = y1.mean('case', name=cellname(c1_name))
        self.c0_mean = y0.mean('case', name=cellname(c0_name))
        self._v_dim = v_dim
        self.n = n
        self.x = x_name
        self.c0 = c0
        self.c1 = c1
        self._expand_state()

    def _name(self):
        if self.y:
            return f"Vector test (related):  {self.y}"
        else:
            return "Vector test (related)"

    def _repr_test_args(self):
        args = [repr(self.y), repr(self.x)]
        if self.c1 is not None:
            args.extend((repr(self.c1), repr(self.c0), repr(self.match)))
        args[-1] += " (n=%i)" % self.n
        return args

    def __setstate__(self, state):
        if 'x' not in state:
            state['x'] = None
            state['c1'] = state['c1_mean'].name
            state['c0'] = state['c0_mean'].name
        Vector.__setstate__(self, state)


def flatten(array, connectivity):
    """Reshape SPM buffer array to 2-dimensional map for connectivity processing

    Parameters
    ----------
    array : ndarray
        N-dimensional array (with non-adjacent dimension at first position).
    connectivity : Connectivity
        N-dimensional connectivity.

    Returns
    -------
    flat_array : ndarray
        The input array reshaped if necessary, making sure that input and output
        arrays share the same underlying data buffer.
    """
    if array.ndim == 2 or not connectivity.custom:
        return array
    else:
        out = array.reshape((array.shape[0], -1))
        assert out.base is array
        return out


def flatten_1d(array):
    if array.ndim == 1:
        return array
    else:
        out = array.ravel()
        assert out.base is array
        return out


def label_clusters(stat_map, threshold, tail, connectivity, criteria):
    """Label clusters

    Parameters
    ----------
    stat_map : array
        Statistical parameter map (non-adjacent dimension on the first
        axis).

    Returns
    -------
    cmap : np.ndarray of uint32
        Array with clusters labelled as integers.
    cluster_ids : np.ndarray of uint32
        Identifiers of the clusters that survive the minimum duration
        criterion.
    """
    cmap = np.empty(stat_map.shape, np.uint32)
    bin_buff = np.empty(stat_map.shape, np.bool8)
    cmap_flat = flatten(cmap, connectivity)

    if tail == 0:
        int_buff = np.empty(stat_map.shape, np.uint32)
        int_buff_flat = flatten(int_buff, connectivity)
    else:
        int_buff = int_buff_flat = None

    cids = _label_clusters(stat_map, threshold, tail, connectivity, criteria,
                           cmap, cmap_flat, bin_buff, int_buff, int_buff_flat)
    return cmap, cids


def _label_clusters(stat_map, threshold, tail, conn, criteria, cmap, cmap_flat,
                    bin_buff, int_buff, int_buff_flat):
    """Find clusters on a statistical parameter map

    Parameters
    ----------
    stat_map : array
        Statistical parameter map (non-adjacent dimension on the first
        axis).
    cmap : array of int
        Buffer for the cluster id map (will be modified).

    Returns
    -------
    cluster_ids : np.ndarray of uint32
        Identifiers of the clusters that survive the minimum duration
        criterion.
    """
    # compute clusters
    if tail >= 0:
        bin_map_above = np.greater(stat_map, threshold, bin_buff)
        cids = _label_clusters_binary(bin_map_above, cmap, cmap_flat, conn,
                                      criteria)

    if tail <= 0:
        bin_map_below = np.less(stat_map, -threshold, bin_buff)
        if tail < 0:
            cids = _label_clusters_binary(bin_map_below, cmap, cmap_flat, conn,
                                          criteria)
        else:
            cids_l = _label_clusters_binary(bin_map_below, int_buff,
                                            int_buff_flat, conn, criteria)
            x = cmap.max()
            int_buff[bin_map_below] += x
            cids_l += x
            cmap += int_buff
            cids = np.concatenate((cids, cids_l))

    return cids


def label_clusters_binary(bin_map, connectivity, criteria=None):
    """Label clusters in a boolean map

    Parameters
    ----------
    bin_map : numpy.ndarray
        Binary map.
    connectivity : Connectivity
        Connectivity corresponding to ``bin_map``.
    criteria : dict
        Cluster criteria.

    Returns
    -------
    cmap : numpy.ndarray of uint32
        Array with clusters labelled as integers.
    cluster_ids : numpy.ndarray of uint32
        Sorted identifiers of the clusters that survive the selection criteria.
    """
    cmap = np.empty(bin_map.shape, np.uint32)
    cmap_flat = flatten(cmap, connectivity)
    cids = _label_clusters_binary(bin_map, cmap, cmap_flat, connectivity, criteria)
    return cmap, cids


def _label_clusters_binary(bin_map, cmap, cmap_flat, connectivity, criteria):
    """Label clusters in a binary array

    Parameters
    ----------
    bin_map : np.ndarray
        Binary map of where the parameter map exceeds the threshold for a
        cluster (non-adjacent dimension on the first axis).
    cmap : np.ndarray
        Array in which to label the clusters.
    cmap_flat : np.ndarray
        Flat copy of cmap (ndim=2, only used when all_adjacent==False)
    connectivity : Connectivity
        Connectivity.
    criteria : None | list
        Cluster size criteria, list of (axes, v) tuples. Collapse over axes
        and apply v minimum length).

    Returns
    -------
    cluster_ids : np.ndarray of uint32
        Sorted identifiers of the clusters that survive the selection criteria.
    """
    # find clusters
    n = ndimage.label(bin_map, connectivity.struct, cmap)
    if n <= 1:
        # in older versions, n is 1 even when no cluster is found
        if n == 0 or cmap.max() == 0:
            return np.array((), np.uint32)
        else:
            cids = np.array((1,), np.uint32)
    elif connectivity.custom:
        cids = merge_labels(cmap_flat, n, *connectivity.custom[0])
    else:
        cids = np.arange(1, n + 1, 1, np.uint32)

    # apply minimum cluster size criteria
    if criteria and cids.size:
        for axes, v in criteria:
            cids = np.setdiff1d(cids,
                                [i for i in cids if np.count_nonzero(np.equal(cmap, i).any(axes)) < v],
                                True)
            if cids.size == 0:
                break

    return cids


def tfce(stat_map, tail, connectivity, dh=0.1):
    tfce_im = np.empty(stat_map.shape, np.float64)
    tfce_im_1d = flatten_1d(tfce_im)
    bin_buff = np.empty(stat_map.shape, np.bool8)
    int_buff = np.empty(stat_map.shape, np.uint32)
    int_buff_flat = flatten(int_buff, connectivity)
    int_buff_1d = flatten_1d(int_buff)
    return _tfce(stat_map, tail, connectivity, tfce_im, tfce_im_1d, bin_buff, int_buff,
                 int_buff_flat, int_buff_1d, dh)


def _tfce(stat_map, tail, conn, out, out_1d, bin_buff, int_buff,
          int_buff_flat, int_buff_1d, dh=0.1, e=0.5, h=2.0):
    "Threshold-free cluster enhancement"
    out.fill(0)

    # determine slices
    if tail == 0:
        hs = chain(np.arange(-dh, stat_map.min(), -dh),
                   np.arange(dh, stat_map.max(), dh))
    elif tail < 0:
        hs = np.arange(-dh, stat_map.min(), -dh)
    else:
        hs = np.arange(dh, stat_map.max(), dh)

    # label clusters in slices at different heights
    # fill each cluster with total section value
    # each point's value is the vertical sum
    for h_ in hs:
        if h_ > 0:
            np.greater_equal(stat_map, h_, bin_buff)
            h_factor = h_ ** h
        else:
            np.less_equal(stat_map, h_, bin_buff)
            h_factor = (-h_) ** h

        c_ids = _label_clusters_binary(bin_buff, int_buff, int_buff_flat, conn, None)
        tfce_increment(c_ids, int_buff_1d, out_1d, e, h_factor)

    return out


class StatMapProcessor:

    def __init__(self, tail, max_axes, parc):
        """Reduce a statistical map to the relevant maximum statistic"""
        self.tail = tail
        self.max_axes = max_axes
        self.parc = parc

    def max_stat(self, stat_map):
        if self.tail == 0:
            v = np.abs(stat_map, stat_map).max(self.max_axes)
        elif self.tail > 0:
            v = stat_map.max(self.max_axes)
        else:
            v = -stat_map.min(self.max_axes)

        if self.parc is None:
            return v
        else:
            return [v[idx].max() for idx in self.parc]


class TFCEProcessor(StatMapProcessor):

    def __init__(self, tail, max_axes, parc, shape, connectivity, dh):
        StatMapProcessor.__init__(self, tail, max_axes, parc)
        self.shape = shape
        self.connectivity = connectivity
        self.dh = dh

        # Pre-allocate memory buffers used for cluster processing
        self._bin_buff = np.empty(shape, np.bool8)
        self._int_buff = np.empty(shape, np.uint32)
        self._tfce_im = np.empty(shape, np.float64)
        self._tfce_im_1d = flatten_1d(self._tfce_im)
        self._int_buff_flat = flatten(self._int_buff, connectivity)
        self._int_buff_1d = flatten_1d(self._int_buff)

    def max_stat(self, stat_map):
        v = _tfce(
            stat_map, self.tail, self.connectivity, self._tfce_im, self._tfce_im_1d,
            self._bin_buff, self._int_buff, self._int_buff_flat, self._int_buff_1d,
            self.dh,
        ).max(self.max_axes)
        if self.parc is None:
            return v
        else:
            return [v[idx].max() for idx in self.parc]


class ClusterProcessor(StatMapProcessor):

    def __init__(self, tail, max_axes, parc, shape, connectivity, threshold,
                 criteria):
        StatMapProcessor.__init__(self, tail, max_axes, parc)
        self.shape = shape
        self.connectivity = connectivity
        self.threshold = threshold
        self.criteria = criteria

        # Pre-allocate memory buffers used for cluster processing
        self._bin_buff = np.empty(shape, np.bool8)

        self._cmap = np.empty(shape, np.uint32)
        self._cmap_flat = flatten(self._cmap, connectivity)

        if tail == 0:
            self._int_buff = np.empty(shape, np.uint32)
            self._int_buff_flat = flatten(self._int_buff, connectivity)
        else:
            self._int_buff = self._int_buff_flat = None

    def max_stat(self, stat_map, threshold=None):
        if threshold is None:
            threshold = self.threshold
        cmap = self._cmap
        cids = _label_clusters(stat_map, threshold, self.tail, self.connectivity,
                               self.criteria, cmap, self._cmap_flat,
                               self._bin_buff, self._int_buff,
                               self._int_buff_flat)
        if self.parc is not None:
            v = []
            for idx in self.parc:
                clusters_v = ndimage.sum(stat_map[idx], cmap[idx], cids)
                if len(clusters_v):
                    if self.tail <= 0:
                        np.abs(clusters_v, clusters_v)
                    v.append(clusters_v.max())
                else:
                    v.append(0)
            return v
        elif len(cids):
            clusters_v = ndimage.sum(stat_map, cmap, cids)
            if self.tail <= 0:
                np.abs(clusters_v, clusters_v)
            return clusters_v.max()
        else:
            return 0


def get_map_processor(kind, *args):
    if kind == 'tfce':
        return TFCEProcessor(*args)
    elif kind == 'cluster':
        return ClusterProcessor(*args)
    elif kind == 'raw':
        return StatMapProcessor(*args)
    else:
        raise ValueError("kind=%s" % repr(kind))


class NDPermutationDistribution:
    """Accumulate information on a cluster statistic.

    Parameters
    ----------
    y : NDVar
        Dependent variable.
    samples : int
        Number of permutations.
    threshold : scalar > 0
        Threshold-based clustering.
    tfce : bool | scalar
        Threshold-free cluster enhancement.
    tail : 1 | 0 | -1
        Which tail(s) of the distribution to consider. 0 is two-tailed,
        whereas 1 only considers positive values and -1 only considers
        negative values.
    meas : str
        Label for the parameter measurement (e.g., 't' for t-values).
    name : None | str
        Name for the comparison.
    tstart, tstop : None | scalar
        Restrict the time window for finding clusters (None: use the whole
        epoch).
    criteria : dict
        Dictionary with threshold criteria for cluster size: 'mintime'
        (seconds) and 'minsource' (n_sources).
    parc : str
        Collect permutation statistics for all regions of the parcellation of
        this dimension. For threshold-based test, the regions are disconnected.
    force_permutation : bool
        Conduct permutations regardless of whether there are any clusters.


    Notes
    -----
    Use of the NDPermutationDistribution proceeds in 3 steps:

    - initialize the NDPermutationDistribution object: ``cdist = NDPermutationDistribution(...)``
    - use a copy of y cropped to the time window of interest:
      ``y = cdist.Y_perm``
    - add the actual statistical map with ``cdist.add_original(pmap)``
    - if any clusters are found (``if cdist.n_clusters``):

      - proceed to add statistical maps from permuted data with
        ``cdist.add_perm(pmap)``.


    Permutation data shape: case, [vector, ][non-adjacent, ] ...
    internal shape: [non-adjacent, ] ...
    """
    tfce_warning = None

    def __init__(self, y, samples, threshold, tfce=False, tail=0, meas='?', name=None,
                 tstart=None, tstop=None, criteria={}, parc=None, force_permutation=False):
        assert y.has_case
        assert parc is None or isinstance(parc, str)
        if tfce and threshold:
            raise RuntimeError(f"threshold={threshold!r}, tfce={tfce!r}: mutually exclusive parameters")
        elif tfce:
            if tfce is not True:
                tfce = abs(tfce)
            kind = 'tfce'
        elif threshold:
            threshold = float(threshold)
            kind = 'cluster'
            assert threshold > 0
        else:
            kind = 'raw'

        # vector: will be removed for stat_map
        vector = [d._connectivity_type == 'vector' for d in y.dims[1:]]
        has_vector_ax = any(vector)
        if has_vector_ax:
            vector_ax = vector.index(True)
        else:
            vector_ax = None

        # prepare temporal cropping
        if (tstart is None) and (tstop is None):
            y_perm = y
            self._crop_for_permutation = False
            self._crop_idx = None
        else:
            t_ax = y.get_axis('time') - 1
            y_perm = y.sub(time=(tstart, tstop))
            # for stat-maps
            if vector_ax is not None and vector_ax < t_ax:
                t_ax -= 1
            t_slice = y.time._array_index(slice(tstart, tstop))
            self._crop_for_permutation = True
            self._crop_idx = FULL_AXIS_SLICE * t_ax + (t_slice,)

        dims = list(y_perm.dims[1:])
        if has_vector_ax:
            del dims[vector_ax]

        # custom connectivity: move non-adjacent connectivity to first axis
        custom = [d._connectivity_type == 'custom' for d in dims]
        n_custom = sum(custom)
        if n_custom > 1:
            raise NotImplementedError("More than one axis with custom connectivity")
        nad_ax = None if n_custom == 0 else custom.index(True)
        if nad_ax:
            swapped_dims = list(dims)
            swapped_dims[0], swapped_dims[nad_ax] = dims[nad_ax], dims[0]
        else:
            swapped_dims = dims
        connectivity = Connectivity(swapped_dims, parc)
        assert connectivity.vector is None

        # cluster map properties
        ndim = len(dims)

        # prepare cluster minimum size criteria
        if criteria:
            if kind != 'cluster':
                raise ValueError("Can not use cluster size criteria when doing threshold free cluster evaluation")
            criteria_ = []
            for k, v in criteria.items():
                m = re.match(r'min(\w+)', k)
                if m:
                    dimname = m.group(1)
                    if not y.has_dim(dimname):
                        raise TypeError(
                            "%r is an invalid keyword argument for this testnd "
                            "function (no dimension named %r)" % (k, dimname))
                    ax = y.get_axis(dimname) - 1
                    if dimname == 'time':
                        v = int(ceil(v / y.time.tstep))
                else:
                    raise TypeError("%r is an invalid keyword argument for this testnd function" % (k,))

                if nad_ax:
                    if ax == 0:
                        ax = nad_ax
                    elif ax == nad_ax:
                        ax = 0

                axes = tuple(i for i in range(ndim) if i != ax)
                criteria_.append((axes, v))
        else:
            criteria_ = None

        # prepare distribution
        samples = int(samples)
        if parc:
            for parc_ax, parc_dim in enumerate(swapped_dims):
                if parc_dim.name == parc:
                    break
            else:
                raise ValueError("parc=%r (no dimension named %r)" % (parc, parc))

            if parc_dim._connectivity_type == 'none':
                parc_indexes = np.arange(len(parc_dim))
            elif kind == 'tfce':
                raise NotImplementedError(
                    f"TFCE for parc={parc!r} ({parc_dim.__class__.__name__} dimension)")
            elif parc_dim._connectivity_type == 'custom':
                if not hasattr(parc_dim, 'parc'):
                    raise NotImplementedError(f"parc={parc!r}: dimension has no parcellation")
                parc_indexes = tuple(np.flatnonzero(parc_dim.parc == cell) for
                                     cell in parc_dim.parc.cells)
                parc_dim = Categorial(parc, parc_dim.parc.cells)
            else:
                raise NotImplementedError(f"parc={parc!r}")
            dist_shape = (samples, len(parc_dim))
            dist_dims = ('case', parc_dim)
            max_axes = tuple(chain(range(parc_ax), range(parc_ax + 1, ndim)))
        else:
            dist_shape = (samples,)
            dist_dims = None
            max_axes = None
            parc_indexes = None

        # arguments for the map processor
        shape = tuple(map(len, swapped_dims))
        if kind == 'raw':
            map_args = (kind, tail, max_axes, parc_indexes)
        elif kind == 'tfce':
            dh = 0.1 if tfce is True else tfce
            map_args = (kind, tail, max_axes, parc_indexes, shape, connectivity, dh)
        else:
            map_args = (kind, tail, max_axes, parc_indexes, shape, connectivity, threshold, criteria_)

        self.kind = kind
        self.y_perm = y_perm
        self.dims = tuple(dims)  # external stat map dims (cropped time)
        self.shape = shape  # internal stat map shape
        self._connectivity = connectivity
        self.samples = samples
        self.dist_shape = dist_shape
        self._dist_dims = dist_dims
        self._max_axes = max_axes
        self.dist = None
        self.threshold = threshold
        self.tfce = tfce
        self.tail = tail
        self._nad_ax = nad_ax
        self._vector_ax = vector_ax
        self.tstart = tstart
        self.tstop = tstop
        self.parc = parc
        self.meas = meas
        self.name = name
        self._criteria = criteria_
        self.criteria = criteria
        self.map_args = map_args
        self.has_original = False
        self.do_permutation = False
        self.dt_perm = None
        self._finalized = False
        self._init_time = current_time()
        self._host = socket.gethostname()
        self.force_permutation = force_permutation

        from .. import __version__
        self._version = __version__

    def _crop(self, im):
        "Crop an original stat_map"
        if self._crop_for_permutation:
            return im[self._crop_idx]
        else:
            return im

    def uncrop(
            self,
            ndvar: NDVar,  # NDVar to uncrop
            to: NDVar,  # NDVar that has the target time dimensions
            default: float = 0,  # value to fill in uncropped area
    ):
        if self.tstart is None and self.tstop is None:
            return ndvar
        target_time = to.get_dim('time')
        t_ax = ndvar.get_axis('time')
        dims = list(ndvar.dims)
        dims[t_ax] = target_time
        shape = list(ndvar.shape)
        shape[t_ax] = len(target_time)
        t_slice = target_time._array_index(slice(self.tstart, self.tstop))
        x = np.empty(shape, ndvar.x.dtype)
        x.fill(default)
        x[FULL_AXIS_SLICE * t_ax + (t_slice,)] = ndvar.x
        return NDVar(x, dims, ndvar.name, ndvar.info)

    def add_original(self, stat_map):
        """Add the original statistical parameter map.

        Parameters
        ----------
        stat_map : array
            Parameter map of the statistic of interest (uncropped).
        """
        if self.has_original:
            raise RuntimeError("Original pmap already added")
        logger = logging.getLogger(__name__)
        logger.debug("Adding original parameter map...")

        # crop/reshape stat_map
        stat_map = self._crop(stat_map)
        if self._nad_ax:
            stat_map = stat_map.swapaxes(0, self._nad_ax)

        # process map
        if self.kind == 'tfce':
            dh = 0.1 if self.tfce is True else self.tfce
            n_steps = max(0, stat_map.max()) // dh + max(0, -stat_map.min()) // dh
            if n_steps > 10000:
                raise RuntimeError(f"TFCE requested with {n_steps:.0f} steps; currently 10000 is set as limit to avoid excessive computation times. Consider setting the tfce parameter to a larger step size.")
            self.tfce_warning = n_steps < 1
            cmap = tfce(stat_map, self.tail, self._connectivity, dh)
            cids = None
            n_clusters = True
        elif self.kind == 'cluster':
            cmap, cids = label_clusters(stat_map, self.threshold, self.tail,
                                        self._connectivity, self._criteria)
            n_clusters = len(cids)
            # clean original cluster map
            idx = np.in1d(cmap, cids, invert=True).reshape(self.shape)
            cmap[idx] = 0
        else:
            cmap = stat_map
            cids = None
            n_clusters = True

        self._t0 = current_time()
        self._original_cluster_map = cmap
        self._cids = cids
        self.n_clusters = n_clusters
        self.has_original = True
        self.dt_original = self._t0 - self._init_time
        self._original_param_map = stat_map

        if self.force_permutation or (self.samples and n_clusters):
            self._create_dist()
            self.do_permutation = True
        else:
            self.dist_array = None
            self.finalize()

    def _create_dist(self):
        "Create the distribution container"
        if CONFIG['n_workers']:
            n = reduce(operator.mul, self.dist_shape)
            dist_array = RawArray('d', n)
            dist = np.frombuffer(dist_array, np.float64, n)
            dist.shape = self.dist_shape
        else:
            dist_array = None
            dist = np.zeros(self.dist_shape)

        self.dist_array = dist_array
        self.dist = dist

    def _aggregate_dist(self, **sub):
        """Aggregate permutation distribution to one value per permutation

        Parameters
        ----------
        [dimname] : index
            Limit the data for the distribution.

        Returns
        -------
        dist : array, shape = (samples,)
            Maximum value for each permutation in the given region.
        """
        dist = self.dist

        if sub:
            if self._dist_dims is None:
                raise TypeError("NDPermutationDistribution does not have parcellation")
            dist_ = NDVar(dist, self._dist_dims)
            dist_sub = dist_.sub(**sub)
            dist = dist_sub.x

        if dist.ndim > 1:
            axes = tuple(range(1, dist.ndim))
            dist = dist.max(axes)

        return dist

    def __repr__(self):
        items = [self.kind]
        if self.has_original:
            if self.kind == 'cluster':
                items.append(f"{self.n_clusters} clusters")
        else:
            items.append("no data")
        return f"<NDPermutationDistribution: {', '.join(items)}>"

    def __getstate__(self):
        if not self._finalized:
            raise RuntimeError("Cannot pickle cluster distribution before all permutations have been added.")
        state = {
            name: getattr(self, name) for name in (
                'name', 'meas', '_version', '_host', '_init_time',
                # settings ...
                'kind', 'threshold', 'tfce', 'tail', 'criteria', 'samples', 'tstart', 'tstop', 'parc',
                # data properties ...
                'dims', 'shape', '_nad_ax', '_vector_ax', '_criteria', '_connectivity',
                # results ...
                'dt_original', 'dt_perm', 'n_clusters', '_dist_dims', 'dist', '_original_param_map', '_original_cluster_map', '_cids',
            )}
        state['version'] = 3
        return state

    def __setstate__(self, state):
        # backwards compatibility
        version = state.pop('version', 0)
        if version == 0:
            if '_connectivity_src' in state:
                del state['_connectivity_src']
                del state['_connectivity_dst']
            if '_connectivity' in state:
                del state['_connectivity']
            if 'N' in state:
                state['samples'] = state.pop('N')
            if '_version' not in state:
                state['_version'] = '< 0.11'
            if '_host' not in state:
                state['_host'] = 'unknown'
            if '_init_time' not in state:
                state['_init_time'] = None
            if 'parc' not in state:
                if state['_dist_dims'] is None:
                    state['parc'] = None
                else:
                    raise OldVersionError("This pickled file is from a previous version of Eelbrain and is not compatible anymore. Please recompute this test.")
            elif isinstance(state['parc'], tuple):
                if len(state['parc']) == 0:
                    state['parc'] = None
                elif len(state['parc']) == 1:
                    state['parc'] = state['parc'][0]
                else:
                    raise OldVersionError("This pickled file is from a previous version of Eelbrain and is not compatible anymore. Please recompute this test.")

            nad_ax = state['_nad_ax']
            state['dims'] = dims = state['dims'][1:]
            state['_connectivity'] = Connectivity(
                (dims[nad_ax],) + dims[:nad_ax] + dims[nad_ax + 1:],
                state['parc'])
        if version < 2:
            state['_vector_ax'] = None
        if version < 3:
            state['tfce'] = ['kind'] == 'tfce'

        for k, v in state.items():
            setattr(self, k, v)
        self.has_original = True
        self.finalize()

    def _repr_test_args(self, pmin):
        "Argument representation for TestResult repr"
        args = [f'samples={self.samples}']
        if pmin is not None:
            args.append(f"pmin={pmin!r}")
        elif self.kind == 'tfce':
            arg = f"tfce={self.tfce!r}"
            if self.tfce_warning:
                arg = f"{arg} [WARNING: The TFCE step is larger than the largest value in the data]"
            args.append(arg)
        if self.tstart is not None:
            args.append(f"tstart={self.tstart!r}")
        if self.tstop is not None:
            args.append(f"tstop={self.tstop!r}")
        for k, v in self.criteria.items():
            args.append(f"{k}={v!r}")
        return args

    def _repr_clusters(self):
        info = []
        if self.kind == 'cluster':
            if self.n_clusters == 0:
                info.append("no clusters")
            else:
                info.append(f"{self.n_clusters} clusters")

        if self.n_clusters and self.samples:
            info.append(f"{fmtxt.peq(self.probability_map.min())}")

        return info

    def _package_ndvar(self, x, info=None, external_shape=False):
        "Generate NDVar from map with internal shape"
        if not self.dims:
            if isinstance(x, np.ndarray):
                return x.item()
            return x
        if not external_shape and self._nad_ax:
            x = x.swapaxes(0, self._nad_ax)
        return NDVar(x, self.dims, self.name, info)

    def finalize(self):
        "Package results and delete temporary data"
        if self.dt_perm is None:
            self.dt_perm = current_time() - self._t0

        # original parameter map
        param_contours = {}
        if self.kind == 'cluster':
            if self.tail >= 0:
                param_contours[self.threshold] = (0.7, 0.7, 0)
            if self.tail <= 0:
                param_contours[-self.threshold] = (0.7, 0, 0.7)
        info = _info.for_stat_map(self.meas, contours=param_contours)
        self.parameter_map = self._package_ndvar(self._original_param_map, info)

        # TFCE map
        if self.kind == 'tfce':
            self.tfce_map = self._package_ndvar(self._original_cluster_map)
        else:
            self.tfce_map = None

        # cluster map
        if self.kind == 'cluster':
            self.cluster_map = self._package_ndvar(self._original_cluster_map)
        else:
            self.cluster_map = None

        self._finalized = True

    def data_for_permutation(self, raw=True):
        """Retrieve data flattened for permutation

        Parameters
        ----------
        raw : bool
            Return a RawArray and a shape tuple instead of a numpy array.
        """
        # get data in the right shape
        x = self.y_perm.x
        if self._vector_ax:
            x = np.moveaxis(x, self._vector_ax + 1, 1)
        if self._nad_ax is not None:
            dst = 1
            src = 1 + self._nad_ax
            if self._vector_ax is not None:
                dst += 1
                if self._vector_ax > self._nad_ax:
                    src += 1
            if dst != src:
                x = x.swapaxes(dst, src)
        # flat y shape
        ndims = 1 + (self._vector_ax is not None)
        n_flat = 1 if x.ndim == ndims else reduce(operator.mul, x.shape[ndims:])
        y_flat_shape = x.shape[:ndims] + (n_flat,)

        if not raw:
            return x.reshape(y_flat_shape)

        n = reduce(operator.mul, y_flat_shape)
        ra = RawArray('d', n)
        ra[:] = x.ravel()  # OPT: don't copy data
        return ra, y_flat_shape, x.shape[ndims:]

    @staticmethod
    def _cluster_properties(cluster_map, cids):
        """Create a Dataset with cluster properties

        Parameters
        ----------
        cluster_map : NDVar
            NDVar in which clusters are marked by bearing the same number.
        cids : array_like of int
            Numbers specifying the clusters (must occur in cluster_map) which
            should be analyzed.

        Returns
        -------
        cluster_properties : Dataset
            Cluster properties. Which properties are included depends on the
            dimensions.
        """
        ndim = cluster_map.ndim
        n_clusters = len(cids)

        # setup compression
        compression = []
        for ax, dim in enumerate(cluster_map.dims):
            extents = np.empty((n_clusters, len(dim)), dtype=np.bool_)
            axes = tuple(i for i in range(ndim) if i != ax)
            compression.append((ax, dim, axes, extents))

        # find extents for all clusters
        c_mask = np.empty(cluster_map.shape, np.bool_)
        for i, cid in enumerate(cids):
            np.equal(cluster_map, cid, c_mask)
            for ax, dim, axes, extents in compression:
                np.any(c_mask, axes, extents[i])

        # prepare Dataset
        ds = Dataset()
        ds['id'] = Var(cids)

        for ax, dim, axes, extents in compression:
            properties = dim._cluster_properties(extents)
            if properties is not None:
                ds.update(properties)

        return ds

    def _cluster_property_labels(self):
        return [l for dim in self.dims for l in dim._cluster_property_labels()]

    def cluster(self, cluster_id):
        """Retrieve a specific cluster as NDVar

        Parameters
        ----------
        cluster_id : int
            Cluster id.

        Returns
        -------
        cluster : NDVar
            NDVar of the cluster, 0 outside the cluster.

        Notes
        -----
        Clusters only have stable ids for thresholded cluster distributions.
        """
        if self.kind != 'cluster':
            raise RuntimeError(
                f'Only cluster-based tests have clusters with stable ids, this '
                f'is a {self.kind} distribution. Use the .find_clusters() '
                f'method instead with maps=True.')
        elif cluster_id not in self._cids:
            raise ValueError(f'No cluster with id {cluster_id!r}')

        out = self.parameter_map * (self.cluster_map == cluster_id)
        properties = self._cluster_properties(self.cluster_map, (cluster_id,))
        for k in properties:
            out.info[k] = properties[0, k]
        return out

    def clusters(self, pmin=None, maps=True, **sub):
        """Find significant clusters

        Parameters
        ----------
        pmin : None | scalar, 1 >= p  >= 0
            Threshold p-value for clusters (for thresholded cluster tests the
            default is 1, for others 0.05).
        maps : bool
            Include in the output a map of every cluster (can be memory
            intensive if there are large statistical maps and/or many
            clusters; default True).
        [dimname] : index
            Limit the data for the distribution.

        Returns
        -------
        ds : Dataset
            Dataset with information about the clusters.
        """
        if pmin is None:
            if self.samples > 0 and self.kind != 'cluster':
                pmin = 0.05
        elif self.samples == 0:
            raise ValueError(f"pmin={pmin!r}: Can not determine p values in distribution without permutations")

        if sub:
            param_map = self.parameter_map.sub(**sub)
        else:
            param_map = self.parameter_map

        if self.kind == 'cluster':
            if sub:
                cluster_map = self.cluster_map.sub(**sub)
                cids = np.setdiff1d(cluster_map.x, [0])
            else:
                cluster_map = self.cluster_map
                cids = np.array(self._cids)

            if len(cids):
                # measure original clusters
                cluster_v = ndimage.sum(param_map.x, cluster_map.x, cids)

                # p-values
                if self.samples:
                    # p-values: "the proportion of random partitions that
                    # resulted in a larger test statistic than the observed
                    # one" (179)
                    dist = self._aggregate_dist(**sub)
                    n_larger = np.sum(dist >= np.abs(cluster_v[:, None]), 1)
                    cluster_p = n_larger / self.samples

                    # select clusters
                    if pmin is not None:
                        idx = cluster_p <= pmin
                        cids = cids[idx]
                        cluster_p = cluster_p[idx]
                        cluster_v = cluster_v[idx]

                    # p-value corrected across parc
                    if sub:
                        dist = self._aggregate_dist()
                        n_larger = np.sum(dist > np.abs(cluster_v[:, None]), 1)
                        cluster_p_corr = n_larger / self.samples
            else:
                cluster_v = cluster_p = cluster_p_corr = []

            ds = self._cluster_properties(cluster_map, cids)
            ds['v'] = Var(cluster_v)
            if self.samples:
                ds['p'] = Var(cluster_p)
                if sub:
                    ds['p_parc'] = Var(cluster_p_corr)

            threshold = self.threshold
        else:
            p_map = self.compute_probability_map(**sub)
            bin_map = np.less_equal(p_map.x, pmin)

            # threshold for maps
            if maps:
                values = np.abs(param_map.x)[bin_map]
                if len(values):
                    threshold = values.min() / 2
                else:
                    threshold = 1.

            # find clusters (reshape to internal shape for labelling)
            if self._nad_ax:
                bin_map = bin_map.swapaxes(0, self._nad_ax)
            if sub:
                raise NotImplementedError("sub")
                # need to subset connectivity!
            c_map, cids = label_clusters_binary(bin_map, self._connectivity)
            if self._nad_ax:
                c_map = c_map.swapaxes(0, self._nad_ax)

            # Dataset with cluster info
            cluster_map = NDVar(c_map, p_map.dims, "clusters")
            ds = self._cluster_properties(cluster_map, cids)
            ds.info['clusters'] = cluster_map
            min_pos = ndimage.minimum_position(p_map.x, c_map, cids)
            ds['p'] = Var([p_map.x[pos] for pos in min_pos])

        if 'p' in ds:
            ds['sig'] = star_factor(ds['p'])

        # expand clusters
        if maps:
            shape = (ds.n_cases,) + param_map.shape
            c_maps = np.empty(shape, dtype=param_map.x.dtype)
            c_mask = np.empty(param_map.shape, dtype=np.bool_)
            for i, cid in enumerate(cids):
                np.equal(cluster_map.x, cid, c_mask)
                np.multiply(param_map.x, c_mask, c_maps[i])

            # package ndvar
            dims = ('case',) + param_map.dims
            param_contours = {}
            if self.tail >= 0:
                param_contours[threshold] = (0.7, 0.7, 0)
            if self.tail <= 0:
                param_contours[-threshold] = (0.7, 0, 0.7)
            info = _info.for_stat_map(self.meas, contours=param_contours)
            info['summary_func'] = np.sum
            ds['cluster'] = NDVar(c_maps, dims, info=info)
        else:
            ds.info['clusters'] = self.cluster_map

        return ds

    def find_peaks(self):
        """Find peaks in a TFCE distribution

        Returns
        -------
        ds : Dataset
            Dataset with information about the peaks.
        """
        if self.kind == 'cluster':
            raise RuntimeError("Not a threshold-free distribution")

        param_map = self._original_param_map
        probability_map = self.probability_map.x
        if self._nad_ax:
            probability_map = probability_map.swapaxes(0, self._nad_ax)

        peaks = find_peaks(self._original_cluster_map, self._connectivity)
        peak_map, peak_ids = label_clusters_binary(peaks, self._connectivity)

        ds = Dataset()
        ds['id'] = Var(peak_ids)
        v = ds.add_empty_var('v')
        if self.samples:
            p = ds.add_empty_var('p')

        bin_buff = np.empty(peak_map.shape, np.bool8)
        for i, id_ in enumerate(peak_ids):
            idx = np.equal(peak_map, id_, bin_buff)
            v[i] = param_map[idx][0]
            if self.samples:
                p[i] = probability_map[idx][0]

        return ds

    def compute_probability_map(self, **sub):
        """Compute a probability map

        Parameters
        ----------
        [dimname] : index
            Limit the data for the distribution.

        Returns
        -------
        probability : NDVar
            Map of p-values.
        """
        if not self.samples:
            raise RuntimeError("Can't compute probability without permutations")

        if self.kind == 'cluster':
            cpmap = np.ones(self.shape)
            if self.n_clusters:
                cids = self._cids
                dist = self._aggregate_dist(**sub)
                cluster_map = self._original_cluster_map
                param_map = self._original_param_map

                # measure clusters
                cluster_v = ndimage.sum(param_map, cluster_map, cids)

                # p-values: "the proportion of random partitions that resulted
                # in a larger test statistic than the observed one" (179)
                n_larger = np.sum(dist >= np.abs(cluster_v[:, None]), 1)
                cluster_p = n_larger / self.samples

                c_mask = np.empty(self.shape, dtype=np.bool8)
                for i, cid in enumerate(cids):
                    np.equal(cluster_map, cid, c_mask)
                    cpmap[c_mask] = cluster_p[i]
            # revert to original shape
            if self._nad_ax:
                cpmap = cpmap.swapaxes(0, self._nad_ax)

            dims = self.dims
        else:
            if self.kind == 'tfce':
                stat_map = self.tfce_map
            else:
                if self.tail == 0:
                    stat_map = self.parameter_map.abs()
                elif self.tail < 0:
                    stat_map = -self.parameter_map
                else:
                    stat_map = self.parameter_map

            if sub:
                stat_map = stat_map.sub(**sub)
            dims = stat_map.dims if isinstance(stat_map, NDVar) else None

            cpmap = np.zeros(stat_map.shape) if dims else 0.
            if self.dist is None:  # flat stat-map
                cpmap += 1
            else:
                dist = self._aggregate_dist(**sub)
                idx = np.empty(stat_map.shape, dtype=np.bool8)
                actual = stat_map.x if self.dims else stat_map
                for v in dist:
                    cpmap += np.greater_equal(v, actual, idx)
                cpmap /= self.samples

        if dims:
            return NDVar(cpmap, dims, self.name, _info.for_cluster_pmap())
        else:
            return cpmap

    def masked_parameter_map(self, pmin=0.05, name=None, **sub):
        """Parameter map masked by significance

        Parameters
        ----------
        pmin : scalar
            Threshold p-value for masking (default 0.05). For threshold-based
            cluster tests, ``pmin=1`` includes all clusters regardless of their
            p-value.

        Returns
        -------
        masked_map : NDVar
            NDVar with data from the original parameter map, masked with
            p <= pmin.
        """
        if not 1 >= pmin > 0:
            raise ValueError(f"pmin={pmin}: needs to be between 1 and 0")

        if name is None:
            name = self.parameter_map.name

        if sub:
            param_map = self.parameter_map.sub(**sub)
        else:
            param_map = self.parameter_map

        if pmin == 1:
            if self.kind != 'cluster':
                raise ValueError(f"pmin=1 is only a valid mask for threshold-based cluster tests")
            mask = self.cluster_map == 0
        else:
            probability_map = self.compute_probability_map(**sub)
            mask = probability_map > pmin
        return param_map.mask(mask, name)

    @LazyProperty
    def probability_map(self):
        if self.samples:
            return self.compute_probability_map()
        else:
            return None

    @LazyProperty
    def _default_plot_obj(self):
        if self.samples:
            return [[self.parameter_map, self.probability_map]]
        else:
            return [[self.parameter_map]]

    def info_list(self, title="Computation Info"):
        "List with information on computation"
        l = fmtxt.List(title)
        l.add_item("Eelbrain version:  %s" % self._version)
        l.add_item("Host Computer:  %s" % self._host)
        if self._init_time is not None:
            l.add_item("Created:  %s" % datetime.fromtimestamp(self._init_time)
                       .strftime('%y-%m-%d %H:%M'))
        l.add_item("Original time:  %s" % timedelta(seconds=round(self.dt_original)))
        l.add_item("Permutation time:  %s" % timedelta(seconds=round(self.dt_perm)))
        return l


class _MergedTemporalClusterDist:
    """Merge permutation distributions from multiple tests"""

    def __init__(self, cdists):
        if isinstance(cdists[0], list):
            self.effects = [d.name for d in cdists[0]]
            self.samples = cdists[0][0].samples
            dist = {}
            for i, effect in enumerate(self.effects):
                if any(d[i].n_clusters for d in cdists):
                    dist[effect] = np.column_stack([d[i].dist for d in cdists if d[i].dist is not None])
            if len(dist):
                dist = {c: d.max(1) for c, d in dist.items()}
        else:
            self.samples = cdists[0].samples
            if any(d.n_clusters for d in cdists):
                dist = np.column_stack([d.dist for d in cdists if d.dist is not None])
                dist = dist.max(1)
            else:
                dist = None

        self.dist = dist

    def correct_cluster_p(self, res):
        clusters = res.find_clusters()
        keys = list(clusters.keys())

        if not clusters.n_cases:
            return clusters
        if isinstance(res, MultiEffectNDTest):
            keys.insert(-1, 'p_parc')
            cluster_p_corr = []
            for cl in clusters.itercases():
                n_larger = np.sum(self.dist[cl['effect']] > np.abs(cl['v']))
                cluster_p_corr.append(float(n_larger) / self.samples)
        else:
            keys.append('p_parc')
            vs = np.array(clusters['v'])
            n_larger = np.sum(self.dist > np.abs(vs[:, None]), 1)
            cluster_p_corr = n_larger / self.samples
        clusters['p_parc'] = Var(cluster_p_corr)
        clusters = clusters[keys]

        return clusters


def distribution_worker(dist_array, dist_shape, in_queue, kill_beacon):
    "Worker that accumulates values and places them into the distribution"
    n = reduce(operator.mul, dist_shape)
    dist = np.frombuffer(dist_array, np.float64, n)
    dist.shape = dist_shape
    samples = dist_shape[0]
    for i in trange(samples, desc="Permutation test", unit=' permutations',
                    disable=CONFIG['tqdm']):
        dist[i] = in_queue.get()
        if kill_beacon.is_set():
            return


def permutation_worker(in_queue, out_queue, y, y_flat_shape, stat_map_shape,
                       test_func, args, map_args, kill_beacon):
    "Worker for 1 sample t-test"
    if CONFIG['nice']:
        os.nice(CONFIG['nice'])

    n = reduce(operator.mul, y_flat_shape)
    y = np.frombuffer(y, np.float64, n).reshape(y_flat_shape)
    stat_map = np.empty(stat_map_shape)
    stat_map_flat = stat_map.ravel()
    map_processor = get_map_processor(*map_args)
    while not kill_beacon.is_set():
        perm = in_queue.get()
        if perm is None:
            break
        test_func(y, *args, stat_map_flat, perm)
        max_v = map_processor.max_stat(stat_map)
        out_queue.put(max_v)


def run_permutation(test_func, dist, iterator, *args):
    if CONFIG['n_workers']:
        workers, out_queue, kill_beacon = setup_workers(test_func, dist, args)

        try:
            for perm in iterator:
                out_queue.put(perm)

            for _ in range(len(workers) - 1):
                out_queue.put(None)

            logger = logging.getLogger(__name__)
            for w in workers:
                w.join()
                logger.debug("worker joined")
        except KeyboardInterrupt:
            kill_beacon.set()
            raise
    else:
        y = dist.data_for_permutation(False)
        map_processor = get_map_processor(*dist.map_args)
        stat_map = np.empty(dist.shape)
        stat_map_flat = stat_map.ravel()
        for i, perm in enumerate(iterator):
            test_func(y, *args, stat_map_flat, perm)
            dist.dist[i] = map_processor.max_stat(stat_map)
    dist.finalize()


def setup_workers(test_func, dist, func_args):
    "Initialize workers for permutation tests"
    logger = logging.getLogger(__name__)
    logger.debug("Setting up %i worker processes..." % CONFIG['n_workers'])
    permutation_queue = mpc.SimpleQueue()
    dist_queue = mpc.SimpleQueue()
    kill_beacon = mpc.Event()

    restore_main_spec()

    # permutation workers
    y, y_flat_shape, stat_map_shape = dist.data_for_permutation()
    args = (permutation_queue, dist_queue, y, y_flat_shape, stat_map_shape,
            test_func, func_args, dist.map_args, kill_beacon)
    workers = []
    for _ in range(CONFIG['n_workers']):
        w = mpc.Process(target=permutation_worker, args=args)
        w.start()
        workers.append(w)

    # distribution worker
    args = (dist.dist_array, dist.dist_shape, dist_queue, kill_beacon)
    w = mpc.Process(target=distribution_worker, args=args)
    w.start()
    workers.append(w)

    return workers, permutation_queue, kill_beacon


def run_permutation_me(test, dists, iterator):
    dist = dists[0]
    if dist.kind == 'cluster':
        thresholds = tuple(d.threshold for d in dists)
    else:
        thresholds = None

    if CONFIG['n_workers']:
        workers, out_queue, kill_beacon = setup_workers_me(test, dists, thresholds)

        try:
            for perm in iterator:
                out_queue.put(perm)

            for _ in range(len(workers) - 1):
                out_queue.put(None)

            logger = logging.getLogger(__name__)
            for w in workers:
                w.join()
                logger.debug("worker joined")
        except KeyboardInterrupt:
            kill_beacon.set()
            raise
    else:
        y = dist.data_for_permutation(False)
        map_processor = get_map_processor(*dist.map_args)

        stat_maps = test.preallocate(dist.shape)
        if thresholds:
            stat_maps_iter = tuple(zip(stat_maps, thresholds, dists))
        else:
            stat_maps_iter = tuple(zip(stat_maps, dists))

        for i, perm in enumerate(iterator):
            test.map(y, perm)
            if thresholds:
                for m, t, d in stat_maps_iter:
                    if d.do_permutation:
                        d.dist[i] = map_processor.max_stat(m, t)
            else:
                for m, d in stat_maps_iter:
                    if d.do_permutation:
                        d.dist[i] = map_processor.max_stat(m)

    for d in dists:
        if d.do_permutation:
            d.finalize()


def setup_workers_me(test_func, dists, thresholds):
    "Initialize workers for permutation tests"
    logger = logging.getLogger(__name__)
    logger.debug("Setting up %i worker processes..." % CONFIG['n_workers'])
    permutation_queue = mpc.SimpleQueue()
    dist_queue = mpc.SimpleQueue()
    kill_beacon = mpc.Event()

    restore_main_spec()

    # permutation workers
    dist = dists[0]
    y, y_flat_shape, stat_map_shape = dist.data_for_permutation()
    args = (permutation_queue, dist_queue, y, y_flat_shape, stat_map_shape,
            test_func, dist.map_args, thresholds, kill_beacon)
    workers = []
    for _ in range(CONFIG['n_workers']):
        w = mpc.Process(target=permutation_worker_me, args=args)
        w.start()
        workers.append(w)

    # distribution worker
    args = ([d.dist_array for d in dists], dist.dist_shape, dist_queue, kill_beacon)
    w = mpc.Process(target=distribution_worker_me, args=args)
    w.start()
    workers.append(w)

    return workers, permutation_queue, kill_beacon


def permutation_worker_me(in_queue, out_queue, y, y_flat_shape, stat_map_shape,
                          test, map_args, thresholds, kill_beacon):
    if CONFIG['nice']:
        os.nice(CONFIG['nice'])

    n = reduce(operator.mul, y_flat_shape)
    y = np.frombuffer(y, np.float64, n).reshape(y_flat_shape)
    iterator = test.preallocate(stat_map_shape)
    if thresholds:
        iterator = tuple(zip(iterator, thresholds))
    else:
        iterator = tuple(iterator)
    map_processor = get_map_processor(*map_args)
    while not kill_beacon.is_set():
        perm = in_queue.get()
        if perm is None:
            break
        test.map(y, perm)

        if thresholds:
            max_v = [map_processor.max_stat(m, t) for m, t in iterator]
        else:
            max_v = [map_processor.max_stat(m) for m in iterator]
        out_queue.put(max_v)


def distribution_worker_me(dist_arrays, dist_shape, in_queue, kill_beacon):
    "Worker that accumulates values and places them into the distribution"
    n = reduce(operator.mul, dist_shape)
    dists = [d if d is None else np.frombuffer(d, np.float64, n).reshape(dist_shape)
             for d in dist_arrays]
    samples = dist_shape[0]
    for i in trange(samples, desc="Permutation test", unit=' permutations',
                    disable=CONFIG['tqdm']):
        for dist, v in zip(dists, in_queue.get()):
            if dist is not None:
                dist[i] = v
        if kill_beacon.is_set():
            return


# Backwards compatibility for pickling
_ClusterDist = NDPermutationDistribution
corr = Correlation
ttest_1samp = TTestOneSample
ttest_ind = TTestIndependent
ttest_rel = TTestRelated
t_contrast_rel = TContrastRelated
anova = ANOVA
