예제 #1
0
def correlations_multiple(data, correlations, periodic_boundary=True, cutoff=None):
    """Calculate 2-point stats for a multiple auto/cross correlation

    Args:
      data: the discretized data (n_samples,n_x,n_y,n_correlation)
      correlation_pair: the correlation pairs
      periodic_boundary: whether to assume a periodic boudnary (default is true)
      cutoff: the subarray of the 2 point stats to keep

    Returns:
      the 2-points stats array

    >>> data = np.arange(18).reshape(1, 3, 3, 2)
    >>> out = correlations_multiple(data, [[0, 1], [1, 1]])
    >>> out
    dask.array<stack, shape=(1, 3, 3, 2), dtype=float64, chunksize=(1, 3, 3, 1)>
    >>> answer = np.array([[[58, 62, 58], [94, 98, 94], [58, 62, 58]]]) + 1. / 3.
    >>> assert(out.compute()[...,0], answer)
    """

    return pipe(
        range(data.shape[-1]),
        map_(lambda x: (0, x)),
        lambda x: correlations if correlations else x,
        map_(
            lambda x: two_point_stats(
                data[..., x[0]],
                data[..., x[1]],
                periodic_boundary=periodic_boundary,
                cutoff=cutoff,
            )
        ),
        list,
        lambda x: da.stack(x, axis=-1),
    )
예제 #2
0
def solve(x_data,
          elastic_modulus,
          poissons_ratio,
          macro_strain=1.0,
          delta_x=1.0):
    """Solve the elasticity problem

    Args:
      x_data: microstructure with shape (n_samples, n_x, ...)
      elastic_modulus: the elastic modulus in each phase
      poissons_ration: the poissons ratio for each phase
      macro_strain: the macro strain
      delta_x: the grid spacing

    Returns:
      a dictionary of strain, displacement and stress with stress and
      strain of shape (n_samples, n_x, ..., 3) and displacement shape
      of (n_samples, n_x + 1, ..., 2)

    """
    def solve_one_sample(property_array):
        return pipe(
            get_fields(property_array.shape[:-1], delta_x),
            lambda x: get_problem(x, property_array, delta_x, macro_strain),
            lambda x: (x, x.solve()),
            lambda x: get_data(property_array.shape[:-1], *x),
        )

    convert = lambda x: _convert_properties(
        len(x.shape) - 1, elastic_modulus, poissons_ratio)[x]

    solve_multiple_samples = sequence(
        do(_check(len(elastic_modulus), len(poissons_ratio))),
        convert,
        map_(solve_one_sample),
        lambda x: zip(*x),
        map_(np.array),
        lambda x: zip(("strain", "displacement", "stress"), tuple(x)),
        dict,
    )

    shape = lambda x: (x.shape[0], ) + x.shape[1:] + (3, )
    dis_shape = lambda x: (x.shape[0], ) + tuple(y + 1
                                                 for y in x.shape[1:]) + (2, )

    if isinstance(x_data, np.ndarray):
        return solve_multiple_samples(x_data)

    return apply_dict_func(
        solve_multiple_samples,
        x_data,
        dict(strain=shape(x_data),
             stress=shape(x_data),
             displacement=dis_shape(x_data)),
    )
예제 #3
0
def _convert_properties(dim, elastic_modulus, poissons_ratio):
    """Convert from elastic modulus and Poisson's ratio to the Lame
    parameter and shear modulus

    Args:
      dim: whether 2D or 3D
      elastic_modulus: the elastic modulus in each phase
      poissons_ration: the poissons ratio for each phase

    Returns:
      array of shape (n_phases, 2) where for example [1, 0], gives the
      Lame parameter in the second phase


    """
    return pipe(
        zip(elastic_modulus, poissons_ratio),
        map_(
            lambda x: pipe(
                ElasticConstants(young=x[0], poisson=x[1]),
                lambda y: (y.lam, dim / 3.0 * y.mu),
            )
        ),
        list,
        np.array,
    )
예제 #4
0
def get_bc(max_x_func, domain, dim, bc_dict_func):
    """Get the periodic boundary condition

    Args:
      max_x_func: function for finding the maximum value of x
      domain: the Sfepy domain
      dim: the x, y or z direction
      bc_dict_func: function to generate the bc dict

    Returns:
      the boundary condition and the sfepy function
    """
    dim_dict = lambda x: [
        ("x", per.match_x_plane),
        ("y", per.match_y_plane),
        ("z", per.match_z_plane),
    ][dim][x]
    return pipe(
        domain.get_mesh_bounding_box(),
        lambda x: PeriodicBC(
            "periodic_{0}".format(dim_dict(0)),
            list(
                map_(
                    get_region_func(max_x_func(x), dim_dict(0), domain),
                    zip(("plus", "minus"), x[:, dim][::-1]),
                )
            ),
            bc_dict_func(x),
            match="match_{0}_plane".format(dim_dict(0)),
        ),
        lambda x: (x, Function("match_{0}_plane".format(dim_dict(0)), dim_dict(1))),
    )
예제 #5
0
def center_slice(x_data, cutoff):
    """Calculate region of interest around the center voxel upto the
    cutoff length

    Args:
      x_data: the data array (n_samples,n_x,n_y), first index is left unchanged
      cutoff: cutoff size

    Returns:
      reduced size array

    >>> a = np.arange(7).reshape(1, 7)
    >>> print(center_slice(a, 2))
    [[1 2 3 4 5]]

    >>> a = np.arange(49).reshape(1, 7, 7)
    >>> print(center_slice(a, 1).shape)
    (1, 3, 3)

    >>> center_slice(np.arange(5), 1)
    Traceback (most recent call last):
    ...
    RuntimeError: Data should be greater than 1D

    ``center_slice`` can take a tuple of values

    >>> center_slice(np.arange(9).reshape(1, 3, 3), (1, 0)).shape
    (1, 3, 1)

    ``cutoff`` must have the correct shape

    >>> center_slice(np.arange(9).reshape(1, 3, 3), (1,)).shape
    Traceback (most recent call last):
    ...
    RuntimeError: cutoff should have length (x_data.ndim - 1)

    """
    if x_data.ndim <= 1:
        raise RuntimeError("Data should be greater than 1D")

    if cutoff is None:
        return x_data

    if isinstance(cutoff, int):
        cutoff = (cutoff, ) * (x_data.ndim - 1)

    if x_data.ndim != len(cutoff) + 1:
        raise RuntimeError("cutoff should have length (x_data.ndim - 1)")

    return pipe(
        range(len(x_data.shape) - 1),
        map_(lambda x: slice(
            x_data.shape[1:][x] // 2 - cutoff[x],
            x_data.shape[1:][x] // 2 + cutoff[x] + 1,
        )),
        tuple,
        lambda x: (slice(len(x_data)), ) + x,
        lambda x: x_data[x],
    )
예제 #6
0
def calc_chem_d2f(eta):
    """Calculate the second derivative of the chemical free energy
    """

    def calc_term(j):
        """Calculate a singe term in the free energy sum
        """
        return a_j(j) * j * (j - 1) * eta ** (j - 2)

    return 0.1 * sum(map_(calc_term, range(2, 11)))
예제 #7
0
def calc_bulk_f(eta):  # pragma: no cover
    """Calculate the bulk free energy
    """

    def calc_term(j):
        """Calculate a single term in the bulk free energy
        """
        return a_j(j) * eta ** j

    return 0.1 * sum(map_(calc_term, range(2, 11)))
예제 #8
0
 def _func(coords, domain=None):  # pylint: disable=unused-argument
     return pipe(
         (x_points, y_points, z_points),
         enumerate,
         map_(lambda x: flag_it(x[1], coords, x[0])),
         list,
         curry(fold)(lambda x, y: x & y),
         lambda x: (x & (coords[:, 0] < (max_x - eps(coords))))
         if max_x is not None else x,
         np.where,
         first,
     )
예제 #9
0
def _solve_fe(x_data,
              elastic_modulus,
              poissons_ratio,
              macro_strain=1.0,
              delta_x=1.0):
    def solve_one_sample(property_array):
        return pipe(
            get_fields(property_array.shape[:-1], delta_x),
            lambda x: get_problem(x, property_array, delta_x, macro_strain),
            lambda x: (x, x.solve()),
            lambda x: get_data(property_array.shape[:-1], *x),
        )

    convert = lambda x: _convert_properties(
        len(x.shape) - 1, elastic_modulus, poissons_ratio)[x]

    solve_multiple_samples = sequence(
        do(_check(len(elastic_modulus), len(poissons_ratio))),
        convert,
        map_(solve_one_sample),
        lambda x: zip(*x),
        map_(np.array),
        lambda x: zip(("strain", "displacement", "stress"), tuple(x)),
        dict,
    )

    shape = lambda x: (x.shape[0], ) + x.shape[1:] + (3, )
    dis_shape = lambda x: (x.shape[0], ) + tuple(y + 1
                                                 for y in x.shape[1:]) + (2, )

    if isinstance(x_data, np.ndarray):
        return solve_multiple_samples(x_data)

    return apply_dict_func(
        solve_multiple_samples,
        x_data,
        dict(strain=shape(x_data),
             stress=shape(x_data),
             displacement=dis_shape(x_data)),
    )
예제 #10
0
def two_point_stats(arr1, arr2, periodic_boundary=True, cutoff=None):
    """Calculate the 2-points stats for two arrays

    Args:
      arr1: array used to calculate cross-correlations (n_samples,n_x,n_y)
      arr2: array used to calculate cross-correlations (n_samples,n_x,n_y)
      periodic_boundary: whether to assume a periodic boundary (default is true)
      cutoff: the subarray of the 2 point stats to keep

    Returns:
      the snipped 2-points stats

    >>> two_point_stats(
    ...     da.from_array(np.arange(10).reshape(2, 5), chunks=(2, 5)),
    ...     da.from_array(np.arange(10).reshape(2, 5), chunks=(2, 5)),
    ... ).shape
    (2, 5)

    """
    cutoff_ = int((np.min(arr1.shape[1:]) - 1) / 2)
    if cutoff is None:
        cutoff = cutoff_
    cutoff = min(cutoff, cutoff_)

    nonperiodic_padder = sequence(
        dapad(
            pad_width=[(0, 0)] + [(cutoff, cutoff)] * (arr1.ndim - 1),
            mode="constant",
            constant_values=0,
        ),
        lambda x: da.rechunk(x, (x.chunks[0], ) + x.shape[1:]),
    )

    padder = identity if periodic_boundary else nonperiodic_padder

    nonperiodic_normalize = lambda x: x / auto_correlation(
        padder(np.ones_like(arr1)))

    normalize = identity if periodic_boundary else nonperiodic_normalize

    return sequence(
        map_(padder),
        list,
        star(cross_correlation),
        normalize,
        center_slice(cutoff=cutoff),
    )([arr1, arr2])
예제 #11
0
    def func(min_xyz, max_xyz):
        def shift_(_, coors, __):
            return np.ones_like(coors[:, 0]) * macro_strain * (max_xyz[0] - min_xyz[0])

        return pipe(
            [("plus", max_xyz[0]), ("minus", min_xyz[0])],
            map_(get_region_func(None, "x", domain)),
            list,
            lambda x: LinearCombinationBC(
                "lcbc",
                x,
                {"u.0": "u.0"},
                Function("match_x_plane", per.match_x_plane),
                "shifted_periodic",
                arguments=(Function("shift", shift_),),
            ),
            lambda x: Conditions([x]),
        )
예제 #12
0
def get_periodic_bcs(domain):
    """Get the periodic boundary conditions

    Args:
      domain: the Sfepy domain

    Returns:
      the boundary conditions and sfepy functions
    """
    zipped = lambda x, f: pipe(
        range(x, domain.get_mesh_bounding_box().shape[1]),
        map_(f(domain)),
        lambda x: zip(*x),
        list,
    )

    return pipe(
        (zipped(0, get_periodic_bc_yz), zipped(1, get_periodic_bc_x)),
        lambda x: (Conditions(x[0][0] + x[1][0]), Functions(x[0][1] + x[1][1])),
    )
예제 #13
0
def center_slice(x_data, cutoff):
    """Calculate region of interest around the center voxel upto the
    cutoff length

    Args:
      x_data: the data array (n_samples,n_x,n_y), first index is left unchanged
      cutoff: cutoff size

    Returns:
      reduced size array

    >>> a = np.arange(7).reshape(1, 7)
    >>> print(center_slice(a, 2))
    [[1 2 3 4 5]]

    >>> a = np.arange(49).reshape(1, 7, 7)
    >>> print(center_slice(a, 1).shape)
    (1, 3, 3)

    >>> center_slice(np.arange(5), 1)
    Traceback (most recent call last):
    ...
    RuntimeError: Data should be greater than 1D

    """
    if x_data.ndim <= 1:
        raise RuntimeError("Data should be greater than 1D")

    make_slice = sequence(
        lambda x: x_data.shape[1:][x] // 2, lambda x: slice(x - cutoff, x + cutoff + 1)
    )

    return pipe(
        range(len(x_data.shape) - 1),
        map_(make_slice),
        tuple,
        lambda x: (slice(len(x_data)),) + x,
        lambda x: x_data[x],
    )
예제 #14
0
def _convert_properties(dim, elastic_modulus, poissons_ratio):
    """Convert from elastic modulus and Poisson's ratio to the Lame
    parameter and shear modulus

    Args:
      dim: whether 2D or 3D
      elastic_modulus: the elastic modulus in each phase
      poissons_ration: the poissons ratio for each phase

    Returns:
      array of shape (n_phases, 2) where for example [1, 0], gives the
      Lame parameter in the second phase

    >>> assert(np.allclose(
    ...     _convert_properties(
    ...          dim=2, elastic_modulus=(1., 2.), poissons_ratio=(1., 1.)
    ...     ),
    ...     np.array([[-0.5, 1. / 6.], [-1., 1. / 3.]])
    ... ))

    Test case with 3 phases.

    >>> X2D = np.array([[[0, 1, 2, 1],
    ...                  [2, 1, 0, 0],
    ...                  [1, 0, 2, 2]]])
    >>> X2D_property = _convert_properties(
    ...     dim=2, elastic_modulus=(1., 2., 3.), poissons_ratio=(1., 1., 1.)
    ... )[X2D]
    >>> lame = lame0, lame1, lame2 = -0.5, -1., -1.5
    >>> mu = mu0, mu1, mu2 = 1. / 6, 1. / 3, 1. / 2
    >>> lm = list(zip(lame, mu))
    >>> assert(np.allclose(X2D_property,
    ...                    [[lm[0], lm[1], lm[2], lm[1]],
    ...                     [lm[2], lm[1], lm[0], lm[0]],
    ...                     [lm[1], lm[0], lm[2], lm[2]]]))

    Test case with 2 phases.

    >>> X3D = np.array([[[0, 1],
    ...                  [0, 0]],
    ...                 [[1, 1],
    ...                  [0, 1]]])
    >>> X3D_property = _convert_properties(
    ...     dim=2, elastic_modulus=(1., 2.), poissons_ratio=(1., 1.)
    ... )[X3D]

    >>> assert(np.allclose(
    ...     X3D_property,
    ...     [[[lm[0], lm[1]],
    ...       [lm[0], lm[0]]],
    ...      [[lm[1], lm[1]],
    ...       [lm[0], lm[1]]]]
    ... ))

    """
    return pipe(
        zip(elastic_modulus, poissons_ratio),
        map_(lambda x: pipe(
            ElasticConstants(young=x[0], poisson=x[1]),
            lambda y: (y.lam, dim / 3.0 * y.mu),
        )),
        list,
        np.array,
    )
예제 #15
0
        step_counter=int(data["step_counter"]),
        params=params,
        wall_time=time.time(),
    )


def sequence(*args):
    """Reverse compose
    """
    return compose(*args[::-1])


# pylint: disable=invalid-name
add_options = sequence(
    lambda x: x.items(),
    map_(lambda x: click.option("--" + x[0], default=x[1])),
    lambda x: compose(*x),
)


@click.command()
@click.option("--folder", default="data", help="name of data directory")
@add_options(get_params())
def main(folder, **params):  # pragma: no cover
    """Run the calculation

    Args:
      params: the list of parameters
      iterations: the number of iterations

    Returns:
예제 #16
0
 def flag_it(points, coords, index):
     close = lambda x: (coords[:, index] < (x + eps(coords))) & (
         coords[:, index] > (x - eps(coords))
     )
     return pipe(points, map_(close), list, np_or, lambda x: (len(points) == 0) | x)
예제 #17
0
def two_point_stats(arr1,
                    arr2,
                    periodic_boundary=True,
                    cutoff=None,
                    mask=None):
    r"""Calculate the 2-points stats for two arrays

    The discretized two point statistics are given by

    .. math::

       f[r \; \vert \; l, l'] = \frac{1}{S} \sum_s m[s, l] m[s + r, l']

    where :math:`f[r \; \vert \; l, l']` is the conditional
    probability of finding the local states :math:`l` and :math:`l` at
    a distance and orientation away from each other defined by the
    vector :math:`r`. `See this paper for more details on the
    notation. <https://doi.org/10.1007/s40192-017-0089-0>`_

    The array ``arr1[i]`` (state :math:`l`) is correlated with
    ``arr2[i]`` (state :math:`l'`) for each sample ``i``. Both arrays
    must have the same number of samples and nominal states (integer
    value) or continuous variables.

    To calculate multiple different correlations for each sample, see
    :func:`~pymks.correlations_multiple`.

    To use ``two_point_stats`` as part of a Scikit-learn pipeline, see
    :class:`~pymks.TwoPointCorrelation`.

    Args:
      arr1: array used to calculate cross-correlations, shape
        ``(n_samples,n_x,n_y)``
      arr2: array used to calculate cross-correlations, shape
        ``(n_samples,n_x,n_y)``
      periodic_boundary: whether to assume a periodic boundary
        (default is ``True``)
      cutoff: the subarray of the 2 point stats to keep
      mask: array specifying confidence in the measurement at a pixel,
        shape ``(n_samples,n_x,n_y)``. In range [0,1].

    Returns:
      the snipped 2-points stats

    If both arrays are Dask arrays then a Dask array is returned.

    >>> out = two_point_stats(
    ...     da.from_array(np.arange(10).reshape(2, 5), chunks=(2, 5)),
    ...     da.from_array(np.arange(10).reshape(2, 5), chunks=(2, 5)),
    ... )
    >>> out.chunks
    ((2,), (5,))
    >>> out.shape
    (2, 5)

    If either of the arrays are Numpy then a Numpy array is returned.

    >>> two_point_stats(
    ...     np.arange(10).reshape(2, 5),
    ...     np.arange(10).reshape(2, 5),
    ... )
    array([[ 3.,  4.,  6.,  4.,  3.],
           [48., 49., 51., 49., 48.]])

    Test masking

    >>> array = da.array([[[1, 0 ,0], [0, 1, 1], [1, 1, 0]]])
    >>> mask = da.array([[[1, 1, 1], [1, 1, 1], [1, 0, 0]]])
    >>> norm_mask = da.array([[[2, 4, 3], [4, 7, 4], [3, 4, 2]]])
    >>> expected = da.array([[[1, 0, 1], [1, 4, 1], [1, 0, 1]]]) / norm_mask
    >>> assert np.allclose(
    ...     two_point_stats(array, array, mask=mask, periodic_boundary=False)[:, 1:-1, 1:-1],
    ...     expected
    ... )

    The mask must be in the range 0 to 1.

    >>> array = da.array([[[1, 0], [0, 1]]])
    >>> mask =  da.array([[[2, 0], [0, 1]]])
    >>> two_point_stats(array, array, mask=mask)
    Traceback (most recent call last):
    ...
    RuntimeError: Mask must be in range [0,1]

    """  # noqa: #501

    n_is_even = 1 - np.array(arr1.shape[1:]) % 2
    padding = np.array(arr1.shape[1:]) // 2

    nonperiodic_padder = sequence(
        dapad(
            pad_width=[(0, 0)] + list(zip(padding, padding + n_is_even)),
            mode="constant",
            constant_values=0,
        ),
        lambda x: da.rechunk(x, (x.chunks[0], ) + x.shape[1:]),
    )

    padder = identity if periodic_boundary else nonperiodic_padder

    if mask is not None:
        if da.max(mask).compute() > 1.0 or da.min(mask).compute() < 0.0:
            raise RuntimeError("Mask must be in range [0,1]")

        mask_array = lambda arr: arr * mask

        normalize = lambda x: x / auto_correlation(padder(mask))
    else:
        mask_array = identity

        if periodic_boundary:
            # The periodic normalization could always be the
            # auto_correlation of the mask. But for the sake of
            # efficiency, we specify the periodic normalization in the
            # case there is no mask.
            normalize = sequence(
                lambda x: x / arr1[0].size,
                dapad(
                    pad_width=[(0, 0)] + list(zip(0 * n_is_even, n_is_even)),
                    mode="wrap",
                ),
                lambda x: da.rechunk(x, (x.chunks[0], ) + x.shape[1:]),
            )
        else:
            normalize = lambda x: x / auto_correlation(
                padder(np.ones_like(arr1)))

    return sequence(
        map_(mask_array),
        map_(padder),
        list,
        star(cross_correlation),
        normalize,
        center_slice(cutoff=cutoff),
    )([arr1, arr2])
예제 #18
0
def correlations_multiple(data,
                          correlations,
                          periodic_boundary=True,
                          cutoff=None):
    r"""Calculate 2-point stats for a multiple auto/cross correlation

    The discretized two point statistics are given by

    .. math::

       f[r \; \vert \; l, l'] = \frac{1}{S} \sum_s m[s, l] m[s + r, l']

    where :math:`f[r \; \vert \; l, l']` is the conditional
    probability of finding the local states :math:`l` and :math:`l'`
    at a distance and orientation away from each other defined by the
    vector :math:`r`. `See this paper for more details on the
    notation. <https://doi.org/10.1007/s40192-017-0089-0>`_

    The correlations are calulated based on pairs given in
    ``correlations`` for each sample.

    To calculate a single correlation for two arrays, see
    :func:`~pymks.two_point_stats`.

    To use ``correlations_multiple`` as part of a Scikit-learn
    pipeline, see :class:`~pymks.TwoPointCorrelation`.

    Args:
      data: the discretized data with shape ``(n_samples, n_x, n_y, n_state)``
      correlations: the correlation pairs, ``[[i0, j0], [i1, j1], ...]``
      periodic_boundary: whether to assume a periodic boundary (default is true)
      cutoff: the subarray of the 2 point stats to keep

    Returns:
      the 2-points stats array

    If ``data`` is a Numpy array then ``correlations_multiple`` will
    return a Numpy array.

    >>> data = np.arange(18).reshape(1, 3, 3, 2)
    >>> out_np = correlations_multiple(data, [[0, 1], [1, 1]])
    >>> out_np.shape
    (1, 3, 3, 2)
    >>> answer = np.array([[[58, 62, 58], [94, 98, 94], [58, 62, 58]]]) + 2. / 3.
    >>> assert np.allclose(out_np[..., 0], answer)

    However, if ``data`` is a Dask array then a Dask array is
    returned.

    >>> data = da.from_array(data, chunks=(1, 3, 3, 2))
    >>> out = correlations_multiple(data, [[0, 1], [1, 1]])
    >>> out.shape
    (1, 3, 3, 2)
    >>> out.chunks
    ((1,), (3,), (3,), (2,))
    >>> assert np.allclose(out[..., 0], answer)

    """

    return pipe(
        range(data.shape[-1]),
        map_(lambda x: (0, x)),
        lambda x: correlations if correlations else x,
        map_(lambda x: two_point_stats(
            data[..., x[0]],
            data[..., x[1]],
            periodic_boundary=periodic_boundary,
            cutoff=cutoff,
        )),
        list,
        lambda x: da.stack(x, axis=-1),
        lambda x: da.rechunk(x, x.chunks[:-1] + (-1, )),
    )
예제 #19
0
def two_point_stats(arr1,
                    arr2,
                    mask=None,
                    periodic_boundary=True,
                    cutoff=None):
    """Calculate the 2-points stats for two arrays

    Args:
      arr1: array used to calculate cross-correlations (n_samples,n_x,n_y)
      arr2: array used to calculate cross-correlations (n_samples,n_x,n_y)
      mask: array specifying confidence in the measurement at a pixel
        (n_samples,n_x,n_y).  In range [0,1].
      periodic_boundary: whether to assume a periodic boundary (default is true)
      cutoff: the subarray of the 2 point stats to keep

    Returns:
      the snipped 2-points stats

    >>> two_point_stats(
    ...     da.from_array(np.arange(10).reshape(2, 5), chunks=(2, 5)),
    ...     da.from_array(np.arange(10).reshape(2, 5), chunks=(2, 5)),
    ... ).shape
    (2, 5)

    Test masking

    >>> array = da.array([[[1, 0 ,0], [0, 1, 1], [1, 1, 0]]])
    >>> mask = da.array([[[1, 1, 1], [1, 1, 1], [1, 0, 0]]])
    >>> norm_mask = da.array([[[2, 4, 3], [4, 7, 4], [3, 4, 2]]])
    >>> expected = da.array([[[1, 0, 1], [1, 4, 1], [1, 0, 1]]]) / norm_mask
    >>> assert np.allclose(
    ...     two_point_stats(array, array, mask=mask, periodic_boundary=False),
    ...     expected
    ... )

    The mask must be in the range 0 to 1.

    >>> array = da.array([[[1, 0], [0, 1]]])
    >>> mask =  da.array([[[2, 0], [0, 1]]])
    >>> two_point_stats(array, array, mask)
    Traceback (most recent call last):
    ...
    RuntimeError: Mask must be in range [0,1]
    """

    cutoff_ = int((np.min(arr1.shape[1:]) - 1) / 2)
    if cutoff is None:
        cutoff = cutoff_
    cutoff = min(cutoff, cutoff_)

    nonperiodic_padder = sequence(
        dapad(
            pad_width=[(0, 0)] + [(cutoff, cutoff)] * (arr1.ndim - 1),
            mode="constant",
            constant_values=0,
        ),
        lambda x: da.rechunk(x, (x.chunks[0], ) + x.shape[1:]),
    )

    padder = identity if periodic_boundary else nonperiodic_padder

    if mask is not None:
        if da.max(mask).compute() > 1.0 or da.min(mask).compute() < 0.0:
            raise RuntimeError("Mask must be in range [0,1]")

        mask_array = lambda arr: arr * mask

        normalize = lambda x: x / auto_correlation(padder(mask))
    else:
        mask_array = identity

        if periodic_boundary:
            # The periodic normalization could always be the
            # auto_correlation of the mask. But for the sake of
            # efficiency, we specify the periodic normalization in the
            # case there is no mask.
            normalize = lambda x: x / arr1[0].size
        else:
            normalize = lambda x: x / auto_correlation(
                padder(np.ones_like(arr1)))

    return sequence(
        map_(mask_array),
        map_(padder),
        list,
        star(cross_correlation),
        normalize,
        center_slice(cutoff=cutoff),
    )([arr1, arr2])