import unittest

import numpy as np

from dosma.core.fitting import (
    CurveFitter,
    MonoExponentialFit,
    PolyFitter,
    curve_fit,
    monoexponential,
    polyfit,
)
from dosma.core.med_volume import MedicalVolume

from .. import util


def _generate_monoexp_data(shape=None, x=None, a=1.0, b=None):
    """Generate sample monoexponetial data.
    ``a=1.0``, ``b`` is randomly generated in interval [0.1, 1.1).

    The equation is :math:`y =  a * \\exp (b*x)`.
    """
    if b is None:
        b = np.random.rand(*shape) + 0.1
    else:
        shape = b.shape
    if x is None:
        x = np.asarray([0.5, 1.0, 2.0, 4.0])
    y = [MedicalVolume(monoexponential(t, a, b), affine=np.eye(4)) for t in x]
    return x, y, b


def _generate_affine(shape=None, x=None, a=None, b=1.0, as_med_vol=False):
    """Generate data of the form :math:`y = a*x + b`."""
    if a is None:
        a = np.random.rand(*shape) + 0.1
    else:
        shape = a.shape
    if x is None:
        x = np.asarray([0.5, 1.0, 2.0, 4.0])
    if b is None:
        b = np.random.rand(*shape)

    if as_med_vol:
        y = [MedicalVolume(a * t + b, affine=np.eye(4)) for t in x]
    else:
        y = [a * t + b for t in x]
    return x, y, a, b


def _linear(x, a):
    return a * x


def _generate_linear_data(shape=None, x=None, a=None):
    """Generate sample linear data.
    ``a`` is randomly generated in interval [0.1, 1.1).
    """
    if a is None:
        a = np.random.rand(*shape) + 0.1
    else:
        shape = a.shape
    if x is None:
        x = np.asarray([0.5, 1.0, 2.0, 4.0])
    y = [MedicalVolume(_linear(t, a), affine=np.eye(4)) for t in x]
    return x, y, a


class TestCurveFit(unittest.TestCase):
    def test_multiple_workers(self):
        x = np.asarray([1, 2, 3, 4])
        ys = np.stack(
            [monoexponential(x, np.random.random(), np.random.random()) for _ in range(1000)],
            axis=-1,
        )
        popt, _ = curve_fit(monoexponential, x, ys)
        popt_mw, _ = curve_fit(monoexponential, x, ys, num_workers=util.num_workers())
        assert np.allclose(popt, popt_mw)

        popt_mw, _ = curve_fit(
            monoexponential, x, ys, num_workers=util.num_workers(), show_pbar=True
        )
        assert np.allclose(popt, popt_mw)

    def test_p0(self):
        """Test different p0 configurations."""
        x = np.asarray([1, 2, 3, 4])
        num = 50
        ys = np.stack(
            [monoexponential(x, np.random.random(), np.random.random()) for _ in range(num)],
            axis=-1,
        )

        # Expected
        popt_1_1, _ = curve_fit(monoexponential, x, ys)
        popt_1_50, _ = curve_fit(monoexponential, x, ys, p0=(1.0, 50.0))

        # Scalar or empty value for each argument
        popt_seq, _ = curve_fit(monoexponential, x, ys, p0=(1.0, 1.0))
        assert np.allclose(popt_seq, popt_1_1)
        popt_seq, _ = curve_fit(monoexponential, x, ys, p0=(None, 1.0))
        assert np.allclose(popt_seq, popt_1_1)
        popt_seq, _ = curve_fit(monoexponential, x, ys, p0=(1.0, None))
        assert np.allclose(popt_seq, popt_1_1)

        # Dictionary of scalar values
        popt_dict, _ = curve_fit(monoexponential, x, ys, p0={"a": 1.0, "b": 1.0})
        assert np.allclose(popt_dict, popt_1_1)

        popt_dict, _ = curve_fit(monoexponential, x, ys, p0={"b": 50.0})
        assert np.allclose(popt_dict, popt_1_50)

        # Numpy array
        popt_arr, _ = curve_fit(monoexponential, x, ys, p0=np.ones((num, 2)))
        assert np.allclose(popt_arr, popt_1_1)

        p0 = np.stack([np.ones(num), 50 * np.ones(num)], axis=-1)
        popt_arr, _ = curve_fit(monoexponential, x, ys, p0=p0)
        assert np.allclose(popt_arr, popt_1_50)

        # Mix of array and scalar
        popt_arr, _ = curve_fit(monoexponential, x, ys, p0=[np.ones(num), 50])
        assert np.allclose(popt_arr, popt_1_50)

        # Multiprocessing
        popt_mw, _ = curve_fit(
            monoexponential, x, ys, p0=[np.ones(num), 50], num_workers=util.num_workers()
        )
        assert np.allclose(popt_mw, popt_1_50)

        popt_mw, _ = curve_fit(
            monoexponential,
            x,
            ys,
            p0=[np.ones(num), 50],
            num_workers=util.num_workers(),
            show_pbar=True,
        )
        assert np.allclose(popt_mw, popt_1_50)


class TestPolyFit(unittest.TestCase):
    """Test :func:`dosma.utils.fits.polyfit`."""

    def test_joint_optimization(self):
        x, y, a, b = _generate_affine((1000,), a=None, b=None, as_med_vol=False)
        popt_expected = np.polyfit(x, y, deg=1)
        popt, _ = polyfit(x, y, deg=1, num_workers=None)
        assert np.allclose(popt.T, popt_expected)
        assert np.allclose(popt[..., 0], a)
        assert np.allclose(popt[..., 1], b)

    def test_multiple_workers(self):
        x, y, _, _ = _generate_affine((1000,), a=None, b=None, as_med_vol=False)

        popt, _ = polyfit(x, y, deg=1, num_workers=0)
        popt_mw, _ = polyfit(x, y, deg=1, num_workers=util.num_workers())
        assert np.allclose(popt, popt_mw)

        popt_mw, _ = polyfit(x, y, deg=1, num_workers=util.num_workers(), show_pbar=True)
        assert np.allclose(popt, popt_mw)

    def test_polyfit_args(self):
        """Test that standard :func:`np.polyfit` args work."""
        x, y, _, _ = _generate_affine((1000,), a=None, b=None, as_med_vol=False)

        popt_exp, residuals_exp, rank_exp, singular_values_exp, rcond_exp = np.polyfit(
            x, y, deg=1, full=True
        )
        popt, _, residuals, rank, singular_values, rcond = polyfit(x, y, deg=1, full=True)

        assert np.allclose(popt, popt_exp.T)
        assert np.allclose(residuals, residuals_exp)
        assert np.allclose(rank, rank_exp)
        assert np.allclose(singular_values, singular_values_exp)
        assert np.allclose(rcond, rcond_exp)

        popt_exp, V_exp = np.polyfit(x, y, deg=1, cov=True)
        popt, _, V = polyfit(x, y, deg=1, cov=True)

        assert np.allclose(popt, popt_exp.T)
        assert np.allclose(V, V_exp)

    # @util.requires_packages("cupy")
    # def test_cupy(self):
    #     # TODO: Uncomment when cupy 9.0.0 is available.
    #     import cupy as cp

    #     x, y, _, _ = _generate_affine((1000,), a=None, b=None, as_med_vol=False)
    #     x_gpu = cp.asarray(x)
    #     y_gpu = cp.asarray(y)

    #     popt, _ = polyfit(x_gpu, y_gpu, deg=1)
    #     popt_expected = cp.polyfit(x_gpu, y_gpu, deg=1)
    #     assert cp.allclose(popt, popt_expected.T)


class TestMonoExponentialFit(unittest.TestCase):
    def test_basic(self):
        x, y, b = _generate_monoexp_data((10, 10, 20))
        t = 1 / np.abs(b)

        fitter = MonoExponentialFit(decimal_precision=8)
        t_hat = fitter.fit(x, y)[0]
        assert np.allclose(t_hat.volume, t)

        with self.assertWarns(UserWarning):
            fitter = MonoExponentialFit(x, y, decimal_precision=8)
        t_hat = fitter.fit(x, y)[0]
        assert np.allclose(t_hat.volume, t)

        with self.assertRaises(ValueError):
            fitter = MonoExponentialFit(list(x) + [5], y)
        with self.assertRaises(TypeError):
            fitter = MonoExponentialFit(x, [_y.A for _y in y])
        with self.assertRaises(ValueError):
            fitter = MonoExponentialFit(x, y, tc0="a value")

    def test_headers(self):
        x, y, b = _generate_monoexp_data((10, 10, 20))
        t = 1 / np.abs(b)
        for idx, _y in enumerate(y):
            _y._headers = util.build_dummy_headers(
                (1, 1) + _y.shape[2:],
                fields={"StudyDescription": "Sample study", "EchoNumbers": idx},
            )

        fitter = MonoExponentialFit(decimal_precision=8)
        t_hat = fitter.fit(x, y)[0]

        assert np.allclose(t_hat.volume, t)
        assert t_hat.headers() is not None
        assert t_hat.headers().shape == (1, 1, 20)
        for h in t_hat.headers().flatten():
            assert h.get("StudyDescription") == "Sample study"

    def test_mask(self):
        x, y, b = _generate_monoexp_data((10, 10, 20))
        mask_arr = np.random.rand(*y[0].shape) > 0.5
        t = 1 / np.abs(b)

        mask = MedicalVolume(mask_arr, np.eye(4))
        fitter = MonoExponentialFit(decimal_precision=8)
        t_hat = fitter.fit(x, y, mask)[0]

        mask = mask.volume
        assert np.allclose(t_hat.volume[mask != 0], t[mask != 0])

        fitter2 = MonoExponentialFit(decimal_precision=8)
        t_hat2 = fitter2.fit(x, y, mask_arr)[0]
        assert np.allclose(t_hat2.volume, t_hat.volume)

        with self.assertWarns(UserWarning):
            fitter3 = MonoExponentialFit(mask=mask, decimal_precision=8)
        t_hat3 = fitter3.fit(x, y)[0]
        assert np.allclose(t_hat3.volume, t_hat.volume)

    def test_polyfit_initialization(self):
        x, y, b = _generate_monoexp_data((10, 10, 20))
        t = 1 / np.abs(b)

        fitter = MonoExponentialFit(tc0="polyfit", decimal_precision=8)
        t_hat = fitter.fit(x, y)[0]
        assert np.allclose(t_hat.volume, t)

        # Test fitting still works even if some values are 0.
        # The values will not be accurate, but other pixel values should be.
        x, y, b = _generate_monoexp_data((10, 10, 20))
        t = 1 / np.abs(b)
        mask_arr = np.zeros(y[0].shape, dtype=np.bool)
        mask_arr[:5, :5] = 1
        y[0][mask_arr] = 0

        fitter = MonoExponentialFit(tc0="polyfit", decimal_precision=8)
        t_hat = fitter.fit(x, y)[0]
        assert np.allclose(t_hat.volume[mask_arr == 0], t[mask_arr == 0])


class TestCurveFitter(unittest.TestCase):
    """Tests for ``dosma.utils.fits.CurveFitter``."""

    def test_basic(self):
        x, y, b = _generate_monoexp_data((10, 10, 20))
        fitter = CurveFitter(monoexponential)
        popt, r2 = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]

        assert np.allclose(a_hat.volume, 1.0)
        assert np.allclose(b_hat.volume, b)

        assert np.all(popt.affine == y[0].affine)
        assert np.all(r2.affine == y[0].affine)

    def test_mask(self):
        x, y, b = _generate_monoexp_data((10, 10, 20))
        mask_arr = np.random.rand(*y[0].shape) > 0.5
        mask = MedicalVolume(mask_arr, y[0].affine)

        fitter = CurveFitter(monoexponential)
        popt = fitter.fit(x, y, mask=mask)[0]
        a_hat, b_hat = popt[..., 0], popt[..., 1]

        assert np.allclose(a_hat.volume[mask_arr != 0], 1.0)
        assert np.allclose(b_hat.volume[mask_arr != 0], b[mask_arr != 0])
        assert np.all(np.isnan(a_hat.volume[mask_arr == 0]))
        assert np.all(np.isnan(b_hat.volume[mask_arr == 0]))

        fitter = CurveFitter(monoexponential)
        popt = fitter.fit(x, y, mask=mask_arr)[0]
        a_hat, b_hat = popt[..., 0], popt[..., 1]

        assert np.allclose(a_hat.volume[mask_arr != 0], 1.0)
        assert np.allclose(b_hat.volume[mask_arr != 0], b[mask_arr != 0])

        with self.assertRaises(TypeError):
            fitter = CurveFitter(monoexponential)
            popt = fitter.fit(x, y, mask="foo")[0]

        with self.assertRaises(RuntimeError):
            mask_incorrect_shape = np.random.rand(5, 5, 5) > 0.5
            fitter = CurveFitter(monoexponential)
            popt = fitter.fit(x, y, mask=mask_incorrect_shape)[0]

    def test_bounds(self):
        shape = (10, 10, 20)
        a = np.ones(shape)
        a[5:] = 1.5
        b = np.random.rand(*shape) + 0.1
        b[:5] = 1.5

        x, y, _ = _generate_monoexp_data(a=a, b=b)

        # Bounds for all parameters
        out_bounds = (0, 1.2)
        fitter = CurveFitter(monoexponential, out_bounds=out_bounds)
        popt, _ = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat[:5].volume, 1.0) and np.all(np.isnan(a_hat[5:].volume))
        assert np.allclose(b_hat[5:].volume, b[5:]) and np.all(np.isnan(b_hat[:5].volume))

        # Bounds only for second parameter
        out_bounds = [(-np.inf, np.inf), (0, 1.2)]
        fitter = CurveFitter(monoexponential, out_bounds=out_bounds)
        popt, _ = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat.volume, a)
        assert np.allclose(b_hat[5:].volume, b[5:]) and np.all(np.isnan(b_hat[:5].volume))

        # Bounds only for first parameter
        out_bounds = [(0, 1.2)]
        fitter = CurveFitter(monoexponential, out_bounds=out_bounds)
        popt, _ = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat[:5].volume, 1.0) and np.all(np.isnan(a_hat[5:].volume))
        assert np.allclose(b_hat.volume, b)

        # Bounds should only be lower and upper.
        with self.assertRaises(ValueError):
            fitter = CurveFitter(monoexponential, out_bounds=[(0, 0.5, 1.0)])

        # Check lower bound < upper bound
        with self.assertRaises(ValueError):
            fitter = CurveFitter(monoexponential, out_bounds=[(1.2, 0)])

    def test_out_ufuncs(self):
        shape = (10, 10, 20)
        a = -1
        b = np.random.rand(*shape) - 1.1  # all negative values
        x, y, _ = _generate_monoexp_data(a=a, b=b)

        ufunc = lambda x: 2 * np.abs(x) + 5  # noqa: E731

        fitter = CurveFitter(monoexponential, out_ufuncs=ufunc)
        popt, _ = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat.volume, ufunc(a))
        assert np.allclose(b_hat.volume, ufunc(b))

        fitter = CurveFitter(monoexponential, out_ufuncs=[None, ufunc])
        popt, _ = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat.volume, a)
        assert np.allclose(b_hat.volume, ufunc(b))

        fitter = CurveFitter(monoexponential, out_ufuncs=[ufunc])
        popt, _ = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat.volume, ufunc(a))
        assert np.allclose(b_hat.volume, b)

        with self.assertRaises(TypeError):
            fitter = CurveFitter(monoexponential, out_ufuncs=[None, 5])

        with self.assertWarns(UserWarning):
            fitter = CurveFitter(monoexponential, out_ufuncs=[None, ufunc, ufunc])

    def test_nan_to_num(self):
        shape = (10, 10, 20)
        a = np.ones(shape)
        a[5:] = 1.5
        b = np.random.rand(*shape) + 0.1
        b[:5] = 1.5

        x, y, _ = _generate_monoexp_data(a=a, b=b)

        out_bounds = (0, 1.2)
        fitter = CurveFitter(monoexponential, out_bounds=out_bounds, nan_to_num=0.0)
        popt, _ = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat[:5].volume, 1.0) and np.allclose(a_hat[5:].volume, 0.0)
        assert np.allclose(b_hat[5:].volume, b[5:]) and np.allclose(b_hat[:5].volume, 0.0)

    def test_matches_monoexponential_fit(self):
        """Match functionality of ``MonoexponentialFit`` using ``CurveFitter``."""
        x, y, _ = _generate_monoexp_data((10, 10, 20))

        fitter = MonoExponentialFit(tc0=30.0, bounds=(0, 100), decimal_precision=8)
        t_hat_mef = fitter.fit(x, y)[0]

        fitter = CurveFitter(
            monoexponential,
            p0=(1.0, -1 / 30),
            out_ufuncs=[None, lambda x: 1 / np.abs(x)],
            out_bounds=(0, 100),
            nan_to_num=0,
        )
        t_hat_cf = fitter.fit(x, y)[0][..., 1]
        t_hat_cf = np.round(t_hat_cf, decimals=8)

        assert np.allclose(t_hat_mef.volume, t_hat_cf.volume)

    def test_headers(self):
        """Test curve fitter does not fail with volumes with headers."""
        x, y, b = _generate_monoexp_data((10, 10, 20, 4))
        for idx, _y in enumerate(y):
            _y._headers = util.build_dummy_headers(
                (1, 1) + _y.shape[2:], fields={"EchoNumbers": idx}
            )

        fitter = CurveFitter(monoexponential)
        popt, _ = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]

        assert np.allclose(a_hat.volume, 1.0)
        assert np.allclose(b_hat.volume, b)
        assert b_hat.headers() is not None
        assert b_hat.headers().shape == (1, 1, 20, 4)

        # Test header with single parameter to fit
        x, y, a = _generate_linear_data((10, 10, 20, 4))
        for idx, _y in enumerate(y):
            _y._headers = util.build_dummy_headers(
                (1, 1) + _y.shape[2:], fields={"EchoNumbers": idx}
            )

        fitter = CurveFitter(_linear)
        popt, _ = fitter.fit(x, y)
        a_hat = popt[..., 0]

        assert np.allclose(a_hat.volume, a)

        # Test not copying headers
        x, y, b = _generate_monoexp_data((10, 10, 20, 4))
        for idx, _y in enumerate(y):
            _y._headers = util.build_dummy_headers(
                (1, 1) + _y.shape[2:], fields={"EchoNumbers": idx}
            )

        fitter = CurveFitter(monoexponential)
        popt, _ = fitter.fit(x, y, copy_headers=False)
        a_hat, b_hat = popt[..., 0], popt[..., 1]

        assert np.allclose(a_hat.volume, 1.0)
        assert np.allclose(b_hat.volume, b)
        assert a_hat.headers() is None
        assert b_hat.headers() is None

    def test_p0(self):
        x, y, b = _generate_monoexp_data((10, 10, 20))

        fitter = CurveFitter(monoexponential, p0=(1.0, b))
        popt, _ = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat.volume, 1.0)
        assert np.allclose(b_hat.volume, b)

        fitter = CurveFitter(monoexponential, p0={"a": 1.0, "b": b})
        popt, _ = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat.volume, 1.0)
        assert np.allclose(b_hat.volume, b)

        fitter = CurveFitter(
            monoexponential, p0={"a": 1.0, "b": MedicalVolume(b, affine=y[0].affine)}
        )
        popt, _ = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat.volume, 1.0)
        assert np.allclose(b_hat.volume, b)

        fitter = CurveFitter(monoexponential)
        popt, _ = fitter.fit(x, y, p0=(1.0, b))
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat.volume, 1.0)
        assert np.allclose(b_hat.volume, b)

        fitter = CurveFitter(monoexponential)
        popt, _ = fitter.fit(x, y, p0={"a": 1.0, "b": b})
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat.volume, 1.0)
        assert np.allclose(b_hat.volume, b)

        fitter = CurveFitter(monoexponential)
        popt, _ = fitter.fit(x, y, p0={"a": 1.0, "b": MedicalVolume(b, affine=y[0].affine)})
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat.volume, 1.0)
        assert np.allclose(b_hat.volume, b)

        fitter = CurveFitter(monoexponential)
        p0 = np.stack(
            [
                MedicalVolume(np.ones(b.shape), affine=y[0].affine),
                MedicalVolume(b, affine=y[0].affine),
            ],
            axis=-1,
        )
        popt, _ = fitter.fit(x, y, p0=p0)
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat.volume, 1.0)
        assert np.allclose(b_hat.volume, b)

        # Test combination of setting p0 with a volume and using a mask.
        mask_arr = np.random.rand(*y[0].shape) > 0.5
        mask = MedicalVolume(mask_arr, y[0].affine)

        fitter = CurveFitter(monoexponential)
        popt, _ = fitter.fit(
            x,
            y,
            p0={"a": 1.0, "b": MedicalVolume(b, affine=y[0].affine)},
            mask=mask,
        )
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat.volume[mask_arr != 0], 1.0)
        assert np.allclose(b_hat.volume[mask_arr != 0], b[mask_arr != 0])
        assert np.all(np.isnan(a_hat.volume[mask_arr == 0]))
        assert np.all(np.isnan(b_hat.volume[mask_arr == 0]))

        fitter = CurveFitter(monoexponential)
        popt, _ = fitter.fit(
            x,
            y,
            p0=(1.0, b),
            mask=mask_arr,
        )
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat.volume[mask_arr != 0], 1.0)
        assert np.allclose(b_hat.volume[mask_arr != 0], b[mask_arr != 0])
        assert np.all(np.isnan(a_hat.volume[mask_arr == 0]))
        assert np.all(np.isnan(b_hat.volume[mask_arr == 0]))

    def test_str(self):
        fitter = CurveFitter(
            monoexponential,
            p0=(1.0, -1 / 30),
            out_ufuncs=[None, lambda x: 1 / np.abs(x)],
            out_bounds=(0, 100),
            nan_to_num=0,
        )
        _ = str(fitter)


class TestPolyFitter(unittest.TestCase):
    """Tests for ``dosma.utils.fits.PolyFitter``."""

    def test_basic(self):
        x, y, a, b = _generate_affine((10, 10, 20), as_med_vol=True)
        fitter = PolyFitter(deg=1)
        popt, r2 = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]

        assert np.allclose(a_hat.volume, a)
        assert np.allclose(b_hat.volume, b)

        assert np.all(popt.affine == y[0].affine)
        assert np.all(r2.affine == y[0].affine)

    def test_mask(self):
        x, y, a, b = _generate_affine((10, 10, 20), as_med_vol=True)
        mask_arr = np.random.rand(*y[0].shape) > 0.5
        mask = MedicalVolume(mask_arr, y[0].affine)

        fitter = PolyFitter(deg=1)
        popt = fitter.fit(x, y, mask=mask)[0]
        a_hat, b_hat = popt[..., 0], popt[..., 1]

        assert np.allclose(a_hat.volume[mask_arr != 0], a[mask_arr != 0])
        assert np.allclose(b_hat.volume[mask_arr != 0], b)

        fitter = PolyFitter(deg=1)
        popt = fitter.fit(x, y, mask=mask_arr)[0]
        a_hat, b_hat = popt[..., 0], popt[..., 1]

        assert np.allclose(a_hat.volume[mask_arr != 0], a[mask_arr != 0])
        assert np.allclose(b_hat.volume[mask_arr != 0], b)

    def test_headers(self):
        """Test curve fitter does not fail with volumes with headers."""
        x, y, a, b = _generate_affine((10, 10, 20, 4), as_med_vol=True)
        for idx, _y in enumerate(y):
            _y._headers = util.build_dummy_headers(
                (1, 1) + _y.shape[2:], fields={"EchoNumbers": idx}
            )

        fitter = PolyFitter(deg=1)
        popt, _ = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]

        assert np.allclose(a_hat.volume, a)
        assert np.allclose(b_hat.volume, b)

        # Test header with single parameter to fit
        x, y, a, _ = _generate_affine((10, 10, 20, 4), b=0.0, as_med_vol=True)

        fitter = PolyFitter(deg=1)
        popt, _ = fitter.fit(x, y)
        a_hat = popt[..., 0]

        assert np.allclose(a_hat.volume, a)

        # Test not copying headers
        x, y, b = _generate_monoexp_data((10, 10, 20, 4))
        for idx, _y in enumerate(y):
            _y._headers = util.build_dummy_headers(
                (1, 1) + _y.shape[2:], fields={"EchoNumbers": idx}
            )

        fitter = CurveFitter(monoexponential)
        popt, _ = fitter.fit(x, y, copy_headers=False)
        a_hat, b_hat = popt[..., 0], popt[..., 1]

        assert np.allclose(a_hat.volume, 1.0)
        assert np.allclose(b_hat.volume, b)
        assert a_hat.headers() is None
        assert b_hat.headers() is None

    def test_nan_to_num(self):
        shape = (10, 10, 20)
        a = np.ones(shape)
        a[5:] = 1.5
        b = np.random.rand(*shape) + 0.1
        b[:5] = 1.5

        x, y, _, _ = _generate_affine(a=a, b=b, as_med_vol=True)

        out_bounds = (0, 1.2)
        fitter = PolyFitter(deg=1, out_bounds=out_bounds, nan_to_num=0.0)
        popt, _ = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat[:5].volume, 1.0) and np.allclose(a_hat[5:].volume, 0.0)
        assert np.allclose(b_hat[5:].volume, b[5:]) and np.allclose(b_hat[:5].volume, 0.0)

    def test_out_ufuncs(self):
        x, y, a, b = _generate_affine((10, 10, 20, 4), as_med_vol=True)
        fitter = PolyFitter(deg=1, out_ufuncs=[lambda x: x + 1, lambda x: x + 2])
        popt, _ = fitter.fit(x, y)
        a_hat, b_hat = popt[..., 0], popt[..., 1]
        assert np.allclose(a_hat.A, a + 1)
        assert np.allclose(b_hat.A, b + 2)

    def test_str(self):
        fitter = PolyFitter(deg=2, rcond=0.5, y_bounds=(0, 200), r2_threshold=0.9, chunksize=1000)
        _ = str(fitter)


if __name__ == "__main__":
    unittest.main()
