# -*- coding: utf-8 -*-
"""
Multi Signals
=============

Defines the class implementing support for multi-continuous signals:

-   :class:`colour.continuous.MultiSignals`
"""

from __future__ import division, unicode_literals

import numpy as np

# Python 3 compatibility.
try:
    from operator import div, idiv
except ImportError:
    from operator import truediv, itruediv

    div = truediv
    idiv = itruediv
from collections import OrderedDict
try:  # pragma: no cover
    from collections import Iterator, Mapping, Sequence
except ImportError:  # pragma: no cover
    from collections.abc import Iterator, Mapping, Sequence

from colour.constants import DEFAULT_FLOAT_DTYPE
from colour.continuous import AbstractContinuousFunction, Signal
from colour.utilities import (as_float_array, first_item, is_pandas_installed,
                              tsplit, tstack)

__author__ = 'Colour Developers'
__copyright__ = 'Copyright (C) 2013-2020 - Colour Developers'
__license__ = 'New BSD License - https://opensource.org/licenses/BSD-3-Clause'
__maintainer__ = 'Colour Developers'
__email__ = 'colour-developers@colour-science.org'
__status__ = 'Production'

__all__ = ['MultiSignals']


class MultiSignals(AbstractContinuousFunction):
    """
    Defines the base class for multi-continuous signals, a container for
    multiple :class:`colour.continuous.Signal` sub-class instances.

    Parameters
    ----------
    data : Series or Dataframe or Signal or MultiSignals or array_like or \
dict_like, optional
        Data to be stored in the multi-continuous signals.
    domain : array_like, optional
        Values to initialise the multiple :class:`colour.continuous.Signal`
        sub-class instances :attr:`colour.continuous.Signal.domain` attribute
        with. If both ``data`` and ``domain`` arguments are defined, the latter
        will be used to initialise the :attr:`colour.continuous.Signal.domain`
        attribute.
    labels : array_like, optional
        Names to use for the :class:`colour.continuous.Signal` sub-class
        instances.

    Other Parameters
    ----------------
    name : unicode, optional
        multi-continuous signals name.
    dtype : type, optional
        **{np.float16, np.float32, np.float64, np.float128}**,
        Floating point data type.
    interpolator : object, optional
        Interpolator class type to use as interpolating function for the
        :class:`colour.continuous.Signal` sub-class instances.
    interpolator_args : dict_like, optional
        Arguments to use when instantiating the interpolating function
        of the :class:`colour.continuous.Signal` sub-class instances.
    extrapolator : object, optional
        Extrapolator class type to use as extrapolating function for the
        :class:`colour.continuous.Signal` sub-class instances.
    extrapolator_args : dict_like, optional
        Arguments to use when instantiating the extrapolating function
        of the :class:`colour.continuous.Signal` sub-class instances.
    signal_type : type, optional
        The :class:`colour.continuous.Signal` sub-class type used for
        instances.

    Attributes
    ----------
    dtype
    domain
    range
    interpolator
    interpolator_args
    extrapolator
    extrapolator_args
    function
    signals
    labels
    signal_type

    Methods
    -------
    __str__
    __repr__
    __hash__
    __getitem__
    __setitem__
    __contains__
    __eq__
    __ne__
    arithmetical_operation
    multi_signals_unpack_data
    fill_nan
    to_dataframe

    Examples
    --------
    Instantiation with implicit *domain* and a single signal:

    >>> range_ = np.linspace(10, 100, 10)
    >>> print(MultiSignals(range_))
    [[   0.   10.]
     [   1.   20.]
     [   2.   30.]
     [   3.   40.]
     [   4.   50.]
     [   5.   60.]
     [   6.   70.]
     [   7.   80.]
     [   8.   90.]
     [   9.  100.]]

    Instantiation with explicit *domain* and a single signal:

    >>> domain = np.arange(100, 1100, 100)
    >>> print(MultiSignals(range_, domain))
    [[  100.    10.]
     [  200.    20.]
     [  300.    30.]
     [  400.    40.]
     [  500.    50.]
     [  600.    60.]
     [  700.    70.]
     [  800.    80.]
     [  900.    90.]
     [ 1000.   100.]]

    Instantiation with multiple signals:

    >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
    >>> range_ += np.array([0, 10, 20])
    >>> print(MultiSignals(range_, domain))
    [[  100.    10.    20.    30.]
     [  200.    20.    30.    40.]
     [  300.    30.    40.    50.]
     [  400.    40.    50.    60.]
     [  500.    50.    60.    70.]
     [  600.    60.    70.    80.]
     [  700.    70.    80.    90.]
     [  800.    80.    90.   100.]
     [  900.    90.   100.   110.]
     [ 1000.   100.   110.   120.]]

    Instantiation with a *dict*:

    >>> print(MultiSignals(dict(zip(domain, range_))))
    [[  100.    10.    20.    30.]
     [  200.    20.    30.    40.]
     [  300.    30.    40.    50.]
     [  400.    40.    50.    60.]
     [  500.    50.    60.    70.]
     [  600.    60.    70.    80.]
     [  700.    70.    80.    90.]
     [  800.    80.    90.   100.]
     [  900.    90.   100.   110.]
     [ 1000.   100.   110.   120.]]

    Instantiation using a *Signal* sub-class:

    >>> class NotSignal(Signal):
    ...     pass

    >>> multi_signals = MultiSignals(range_, domain, signal_type=NotSignal)
    >>> print(multi_signals)
    [[  100.    10.    20.    30.]
     [  200.    20.    30.    40.]
     [  300.    30.    40.    50.]
     [  400.    40.    50.    60.]
     [  500.    50.    60.    70.]
     [  600.    60.    70.    80.]
     [  700.    70.    80.    90.]
     [  800.    80.    90.   100.]
     [  900.    90.   100.   110.]
     [ 1000.   100.   110.   120.]]
     >>> type(multi_signals.signals[0])  # doctest: +SKIP
     <class 'multi_signals.NotSignal'>

    Instantiation with a *Pandas* *Series*:

    >>> if is_pandas_installed():
    ...     from pandas import Series
    ...     print(MultiSignals(  # doctest: +SKIP
    ...         Series(dict(zip(domain, np.linspace(10, 100, 10))))))
    [[  100.    10.]
     [  200.    20.]
     [  300.    30.]
     [  400.    40.]
     [  500.    50.]
     [  600.    60.]
     [  700.    70.]
     [  800.    80.]
     [  900.    90.]
     [ 1000.   100.]]

    Instantiation with a *Pandas* *Dataframe*:

    >>> if is_pandas_installed():
    ...     from pandas import DataFrame
    ...     data = dict(zip(['a', 'b', 'c'], tsplit(range_)))
    ...     print(MultiSignals(  # doctest: +SKIP
    ...         DataFrame(data, domain)))
    [[  100.    10.    20.    30.]
     [  200.    20.    30.    40.]
     [  300.    30.    40.    50.]
     [  400.    40.    50.    60.]
     [  500.    50.    60.    70.]
     [  600.    60.    70.    80.]
     [  700.    70.    80.    90.]
     [  800.    80.    90.   100.]
     [  900.    90.   100.   110.]
     [ 1000.   100.   110.   120.]]

    Retrieving domain *y* variable for arbitrary range *x* variable:

    >>> x = 150
    >>> range_ = tstack([np.sin(np.linspace(0, 1, 10))] * 3)
    >>> range_ += np.array([0.0, 0.25, 0.5])
    >>> MultiSignals(range_, domain)[x]  # doctest: +ELLIPSIS
    array([ 0.0359701...,  0.2845447...,  0.5331193...])
    >>> x = np.linspace(100, 1000, 3)
    >>> MultiSignals(range_, domain)[x]  # doctest: +ELLIPSIS
    array([[  4.4085384...e-20,   2.5000000...e-01,   5.0000000...e-01],
           [  4.7669395...e-01,   7.2526859...e-01,   9.7384323...e-01],
           [  8.4147098...e-01,   1.0914709...e+00,   1.3414709...e+00]])

    Using an alternative interpolating function:

    >>> x = 150
    >>> from colour.algebra import CubicSplineInterpolator
    >>> MultiSignals(
    ...     range_,
    ...     domain,
    ...     interpolator=CubicSplineInterpolator)[x]  # doctest: +ELLIPSIS
    array([ 0.0555274...,  0.3055274...,  0.5555274...])
    >>> x = np.linspace(100, 1000, 3)
    >>> MultiSignals(
    ...     range_,
    ...     domain,
    ...     interpolator=CubicSplineInterpolator)[x]  # doctest: +ELLIPSIS
    array([[ 0.       ...,  0.25     ...,  0.5      ...],
           [ 0.4794253...,  0.7294253...,  0.9794253...],
           [ 0.8414709...,  1.0914709...,  1.3414709...]])
    """

    def __init__(self, data=None, domain=None, labels=None, **kwargs):
        super(MultiSignals, self).__init__(kwargs.get('name'))

        self._signal_type = kwargs.get('signal_type', Signal)

        self._signals = self.multi_signals_unpack_data(data, domain, labels,
                                                       **kwargs)

    @property
    def dtype(self):
        """
        Getter and setter property for the continuous signal dtype.

        Parameters
        ----------
        value : type
            Value to set the continuous signal dtype with.

        Returns
        -------
        type
            Continuous signal dtype.
        """

        if self._signals:
            return first_item(self._signals.values()).dtype

    @dtype.setter
    def dtype(self, value):
        """
        Setter for **self.dtype** property.
        """

        if value is not None:
            for signal in self._signals.values():
                signal.dtype = value

    @property
    def domain(self):
        """
        Getter and setter property for the :class:`colour.continuous.Signal`
        sub-class instances independent domain :math:`x` variable.

        Parameters
        ----------
        value : array_like
            Value to set the :class:`colour.continuous.Signal` sub-class
            instances independent domain :math:`x` variable with.

        Returns
        -------
        ndarray
            :class:`colour.continuous.Signal` sub-class instances independent
            domain :math:`x` variable.
        """

        if self._signals:
            return first_item(self._signals.values()).domain

    @domain.setter
    def domain(self, value):
        """
        Setter for the **self.domain** property.
        """

        if value is not None:
            for signal in self._signals.values():
                signal.domain = value

    @property
    def range(self):
        """
        Getter and setter property for the :class:`colour.continuous.Signal`
        sub-class instances corresponding range :math:`y` variable.

        Parameters
        ----------
        value : array_like
            Value to set the :class:`colour.continuous.Signal` sub-class
            instances corresponding range :math:`y` variable with.

        Returns
        -------
        ndarray
            :class:`colour.continuous.Signal` sub-class instances corresponding
            range :math:`y` variable.
        """

        if self._signals:
            return tstack([signal.range for signal in self._signals.values()])

    @range.setter
    def range(self, value):
        """
        Setter for the **self.range** property.
        """

        if value is not None:
            value = as_float_array(value)

            if value.ndim in (0, 1):
                for signal in self._signals.values():
                    signal.range = value
            else:
                assert value.shape[-1] == len(self._signals), (
                    'Corresponding "y" variable columns must have '
                    'same count than underlying "Signal" components!')

                for signal, y in zip(self._signals.values(), tsplit(value)):
                    signal.range = y

    @property
    def interpolator(self):
        """
        Getter and setter property for the :class:`colour.continuous.Signal`
        sub-class instances interpolator type.

        Parameters
        ----------
        value : type
            Value to set the :class:`colour.continuous.Signal` sub-class
            instances interpolator type with.

        Returns
        -------
        type
            :class:`colour.continuous.Signal` sub-class instances interpolator
            type.
        """

        if self._signals:
            return first_item(self._signals.values()).interpolator

    @interpolator.setter
    def interpolator(self, value):
        """
        Setter for the **self.interpolator** property.
        """

        if value is not None:
            for signal in self._signals.values():
                signal.interpolator = value

    @property
    def interpolator_args(self):
        """
        Getter and setter property for the :class:`colour.continuous.Signal`
        sub-class instances interpolator instantiation time arguments.

        Parameters
        ----------
        value : dict
            Value to set the :class:`colour.continuous.Signal` sub-class
            instances interpolator instantiation time arguments to.

        Returns
        -------
        dict
            :class:`colour.continuous.Signal` sub-class instances interpolator
            instantiation time arguments.
        """

        if self._signals:
            return first_item(self._signals.values()).interpolator_args

    @interpolator_args.setter
    def interpolator_args(self, value):
        """
        Setter for the **self.interpolator_args** property.
        """

        if value is not None:
            for signal in self._signals.values():
                signal.interpolator_args = value

    @property
    def extrapolator(self):
        """
        Getter and setter property for the :class:`colour.continuous.Signal`
        sub-class instances extrapolator type.

        Parameters
        ----------
        value : type
            Value to set the :class:`colour.continuous.Signal` sub-class
            instances extrapolator type with.

        Returns
        -------
        type
            :class:`colour.continuous.Signal` sub-class instances extrapolator
            type.
        """

        if self._signals:
            return first_item(self._signals.values()).extrapolator

    @extrapolator.setter
    def extrapolator(self, value):
        """
        Setter for the **self.extrapolator** property.
        """

        if value is not None:
            for signal in self._signals.values():
                signal.extrapolator = value

    @property
    def extrapolator_args(self):
        """
        Getter and setter property for the :class:`colour.continuous.Signal`
        sub-class instances extrapolator instantiation time arguments.

        Parameters
        ----------
        value : dict
            Value to set the :class:`colour.continuous.Signal` sub-class
            instances extrapolator instantiation time arguments to.

        Returns
        -------
        dict
            :class:`colour.continuous.Signal` sub-class instances extrapolator
            instantiation time arguments.
        """

        if self._signals:
            return first_item(self._signals.values()).extrapolator_args

    @extrapolator_args.setter
    def extrapolator_args(self, value):
        """
        Setter for the **self.extrapolator_args** property.
        """

        if value is not None:
            for signal in self._signals.values():
                signal.extrapolator_args = value

    @property
    def function(self):
        """
        Getter and setter property for the :class:`colour.continuous.Signal`
        sub-class instances callable.

        Parameters
        ----------
        value : object
            Attribute value.

        Returns
        -------
        callable
            :class:`colour.continuous.Signal` sub-class instances callable.

        Notes
        -----
        -   This property is read only.
        """

        if self._signals:
            return first_item(self._signals.values()).function

    @property
    def signals(self):
        """
        Getter and setter property for the :class:`colour.continuous.Signal`
        sub-class instances.

        Parameters
        ----------
        value : Series or Dataframe or Signal or MultiSignals or array_like \
or dict_like
            Attribute value.

        Returns
        -------
        OrderedDict
            :class:`colour.continuous.Signal` sub-class instances.
        """

        return self._signals

    @signals.setter
    def signals(self, value):
        """
        Setter for the **self.signals** property.
        """

        if value is not None:
            self._signals = self.multi_signals_unpack_data(
                value, signal_type=self._signal_type)

    @property
    def labels(self):
        """
        Getter and setter property for the :class:`colour.continuous.Signal`
        sub-class instances name.

        Parameters
        ----------
        value : array_like
            Value to set the :class:`colour.continuous.Signal` sub-class
            instances name.

        Returns
        -------
        dict
            :class:`colour.continuous.Signal` sub-class instance name.
        """

        if self._signals:
            return list(self._signals.keys())

    @labels.setter
    def labels(self, value):
        """
        Setter for the **self.labels** property.
        """

        if value is not None:
            assert len(value) == len(self._signals), (
                '"labels" length does not match "signals" length!')
            self._signals = OrderedDict(
                [(value[i], signal)
                 for i, (_key, signal) in enumerate(self._signals.items())])

    @property
    def signal_type(self):
        """
        Getter and setter property for the :class:`colour.continuous.Signal`
        sub-class instances type.

        Returns
        -------
        type
            :class:`colour.continuous.Signal` sub-class instances type.

        Notes
        -----
        -   This property is read only.
        """

        return self._signal_type

    def __str__(self):
        """
        Returns a formatted string representation of the multi-continuous
        signals.

        Returns
        -------
        unicode
            Formatted string representation.

        Examples
        --------
        >>> domain = np.arange(0, 10, 1)
        >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
        >>> range_ += np.array([0, 10, 20])
        >>> print(MultiSignals(range_))
        [[   0.   10.   20.   30.]
         [   1.   20.   30.   40.]
         [   2.   30.   40.   50.]
         [   3.   40.   50.   60.]
         [   4.   50.   60.   70.]
         [   5.   60.   70.   80.]
         [   6.   70.   80.   90.]
         [   7.   80.   90.  100.]
         [   8.   90.  100.  110.]
         [   9.  100.  110.  120.]]
        """

        try:
            return str(np.hstack([self.domain[:, np.newaxis], self.range]))
        except TypeError:
            return super(MultiSignals, self).__str__()

    def __repr__(self):
        """
        Returns an evaluable string representation of the multi-continuous
        signals.

        Returns
        -------
        unicode
            Evaluable string representation.

        Examples
        --------
        >>> domain = np.arange(0, 10, 1)
        >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
        >>> range_ += np.array([0, 10, 20])
        >>> MultiSignals(range_)  # doctest: +ELLIPSIS
        MultiSignals([[   0.,   10.,   20.,   30.],
                      [   1.,   20.,   30.,   40.],
                      [   2.,   30.,   40.,   50.],
                      [   3.,   40.,   50.,   60.],
                      [   4.,   50.,   60.,   70.],
                      [   5.,   60.,   70.,   80.],
                      [   6.,   70.,   80.,   90.],
                      [   7.,   80.,   90.,  100.],
                      [   8.,   90.,  100.,  110.],
                      [   9.,  100.,  110.,  120.]],
                     labels=[0, 1, 2],
                     interpolator=KernelInterpolator,
                     interpolator_args={},
                     extrapolator=Extrapolator,
                     extrapolator_args={...)
        """

        try:
            representation = repr(
                np.hstack([self.domain[:, np.newaxis], self.range]))
            representation = representation.replace('array',
                                                    self.__class__.__name__)
            representation = representation.replace(
                '       [',
                '{0}['.format(' ' * (len(self.__class__.__name__) + 2)))
            representation = ('{0},\n'
                              '{1}labels={2},\n'
                              '{1}interpolator={3},\n'
                              '{1}interpolator_args={4},\n'
                              '{1}extrapolator={5},\n'
                              '{1}extrapolator_args={6})').format(
                                  representation[:-1],
                                  ' ' * (len(self.__class__.__name__) + 1),
                                  repr(self.labels), self.interpolator.__name__
                                  if self.interpolator is not None else
                                  self.interpolator,
                                  repr(self.interpolator_args),
                                  self.extrapolator.__name__
                                  if self.extrapolator is not None else
                                  self.extrapolator,
                                  repr(self.extrapolator_args))

            return representation
        except TypeError:
            return super(MultiSignals, self).__repr__()

    def __hash__(self):
        """
        Returns the abstract continuous function hash.

        Returns
        -------
        int
            Object hash.
        """

        return hash(repr(self))

    def __getitem__(self, x):
        """
        Returns the corresponding range :math:`y` variable for independent
        domain :math:`x` variable.

        Parameters
        ----------
        x : numeric, array_like or slice
            Independent domain :math:`x` variable.

        Returns
        -------
        numeric or ndarray
            math:`y` range value.

        Examples
        --------
        >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
        >>> range_ += np.array([0, 10, 20])
        >>> multi_signals = MultiSignals(range_)
        >>> print(multi_signals)
        [[   0.   10.   20.   30.]
         [   1.   20.   30.   40.]
         [   2.   30.   40.   50.]
         [   3.   40.   50.   60.]
         [   4.   50.   60.   70.]
         [   5.   60.   70.   80.]
         [   6.   70.   80.   90.]
         [   7.   80.   90.  100.]
         [   8.   90.  100.  110.]
         [   9.  100.  110.  120.]]
        >>> multi_signals[0]
        array([ 10.,  20.,  30.])
        >>> multi_signals[np.array([0, 1, 2])]
        array([[ 10.,  20.,  30.],
               [ 20.,  30.,  40.],
               [ 30.,  40.,  50.]])
        >>> multi_signals[0:3]
        array([[ 10.,  20.,  30.],
               [ 20.,  30.,  40.],
               [ 30.,  40.,  50.]])
        >>> multi_signals[np.linspace(0, 5, 5)]  # doctest: +ELLIPSIS
        array([[ 10.       ...,  20.       ...,  30.       ...],
               [ 22.8348902...,  32.8046056...,  42.774321 ...],
               [ 34.8004492...,  44.7434347...,  54.6864201...],
               [ 47.5535392...,  57.5232546...,  67.4929700...],
               [ 60.       ...,  70.       ...,  80.       ...]])
        """

        if self._signals:
            return tstack([signal[x] for signal in self._signals.values()])
        else:
            raise RuntimeError('No underlying "Signal" defined!')

    def __setitem__(self, x, y):
        """
        Sets the corresponding range :math:`y` variable for independent domain
        :math:`x` variable.

        Parameters
        ----------
        x : numeric, array_like or slice
            Independent domain :math:`x` variable.
        y : numeric or ndarray
            Corresponding range :math:`y` variable.

        Examples
        --------
        >>> domain = np.arange(0, 10, 1)
        >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
        >>> range_ += np.array([0, 10, 20])
        >>> multi_signals = MultiSignals(range_)
        >>> print(multi_signals)
        [[   0.   10.   20.   30.]
         [   1.   20.   30.   40.]
         [   2.   30.   40.   50.]
         [   3.   40.   50.   60.]
         [   4.   50.   60.   70.]
         [   5.   60.   70.   80.]
         [   6.   70.   80.   90.]
         [   7.   80.   90.  100.]
         [   8.   90.  100.  110.]
         [   9.  100.  110.  120.]]
        >>> multi_signals[0] = 20
        >>> multi_signals[0]
        array([ 20.,  20.,  20.])
        >>> multi_signals[np.array([0, 1, 2])] = 30
        >>> multi_signals[np.array([0, 1, 2])]
        array([[ 30.,  30.,  30.],
               [ 30.,  30.,  30.],
               [ 30.,  30.,  30.]])
        >>> multi_signals[0:3] = 40
        >>> multi_signals[0:3]
        array([[ 40.,  40.,  40.],
               [ 40.,  40.,  40.],
               [ 40.,  40.,  40.]])
        >>> multi_signals[np.linspace(0, 5, 5)] = 50
        >>> print(multi_signals)
        [[   0.     50.     50.     50.  ]
         [   1.     40.     40.     40.  ]
         [   1.25   50.     50.     50.  ]
         [   2.     40.     40.     40.  ]
         [   2.5    50.     50.     50.  ]
         [   3.     40.     50.     60.  ]
         [   3.75   50.     50.     50.  ]
         [   4.     50.     60.     70.  ]
         [   5.     50.     50.     50.  ]
         [   6.     70.     80.     90.  ]
         [   7.     80.     90.    100.  ]
         [   8.     90.    100.    110.  ]
         [   9.    100.    110.    120.  ]]
        >>> multi_signals[np.array([0, 1, 2])] = np.array([10, 20, 30])
        >>> print(multi_signals)
        [[   0.     10.     20.     30.  ]
         [   1.     10.     20.     30.  ]
         [   1.25   50.     50.     50.  ]
         [   2.     10.     20.     30.  ]
         [   2.5    50.     50.     50.  ]
         [   3.     40.     50.     60.  ]
         [   3.75   50.     50.     50.  ]
         [   4.     50.     60.     70.  ]
         [   5.     50.     50.     50.  ]
         [   6.     70.     80.     90.  ]
         [   7.     80.     90.    100.  ]
         [   8.     90.    100.    110.  ]
         [   9.    100.    110.    120.  ]]
        >>> y = np.arange(1, 10, 1).reshape(3, 3)
        >>> multi_signals[np.array([0, 1, 2])] = y
        >>> print(multi_signals)
        [[   0.      1.      2.      3.  ]
         [   1.      4.      5.      6.  ]
         [   1.25   50.     50.     50.  ]
         [   2.      7.      8.      9.  ]
         [   2.5    50.     50.     50.  ]
         [   3.     40.     50.     60.  ]
         [   3.75   50.     50.     50.  ]
         [   4.     50.     60.     70.  ]
         [   5.     50.     50.     50.  ]
         [   6.     70.     80.     90.  ]
         [   7.     80.     90.    100.  ]
         [   8.     90.    100.    110.  ]
         [   9.    100.    110.    120.  ]]
        """

        y = as_float_array(y)

        assert y.ndim in range(3), (
            'Corresponding "y" variable must be a numeric or a 1-dimensional '
            'or 2-dimensional array!')

        if y.ndim == 0:
            y = np.tile(y, len(self._signals))
        elif y.ndim == 1:
            y = y[np.newaxis, :]

        assert y.shape[-1] == len(self._signals), (
            'Corresponding "y" variable columns must have same count than '
            'underlying "Signal" components!')

        for signal, y in zip(self._signals.values(), tsplit(y)):
            signal[x] = y

    def __contains__(self, x):
        """
        Returns whether the multi-continuous signals contains given independent
        domain :math:`x` variable.

        Parameters
        ----------
        x : numeric, array_like or slice
            Independent domain :math:`x` variable.

        Returns
        -------
        bool
            Is :math:`x` domain value contained.

        Examples
        --------
        >>> range_ = np.linspace(10, 100, 10)
        >>> multi_signals = MultiSignals(range_)
        >>> 0 in multi_signals
        True
        >>> 0.5 in multi_signals
        True
        >>> 1000 in multi_signals
        False
        """

        if self._signals:
            return x in first_item(self._signals.values())
        else:
            raise RuntimeError('No underlying "Signal" defined!')

    def __eq__(self, other):
        """
        Returns whether the multi-continuous signals is equal to given other
        object.

        Parameters
        ----------
        other : object
            Object to test whether it is equal to the multi-continuous signals.

        Returns
        -------
        bool
            Is given object equal to the multi-continuous signals.

        Examples
        --------
        >>> range_ = np.linspace(10, 100, 10)
        >>> multi_signals_1 = MultiSignals(range_)
        >>> multi_signals_2 = MultiSignals(range_)
        >>> multi_signals_1 == multi_signals_2
        True
        >>> multi_signals_2[0] = 20
        >>> multi_signals_1 == multi_signals_2
        False
        >>> multi_signals_2[0] = 10
        >>> multi_signals_1 == multi_signals_2
        True
        >>> from colour.algebra import CubicSplineInterpolator
        >>> multi_signals_2.interpolator = CubicSplineInterpolator
        >>> multi_signals_1 == multi_signals_2
        False
        """

        if isinstance(other, MultiSignals):
            if all([
                    np.array_equal(self.domain, other.domain),
                    np.array_equal(
                        self.range,
                        other.range), self.interpolator is other.interpolator,
                    self.interpolator_args == other.interpolator_args,
                    self.extrapolator is other.extrapolator,
                    self.extrapolator_args == other.extrapolator_args,
                    self.labels == other.labels
            ]):
                return True

        return False

    def __ne__(self, other):
        """
        Returns whether the multi-continuous signals is not equal to given
        other object.

        Parameters
        ----------
        other : object
            Object to test whether it is not equal to the multi-continuous
            signals.

        Returns
        -------
        bool
            Is given object not equal to the multi-continuous signals.

        Examples
        --------
        >>> range_ = np.linspace(10, 100, 10)
        >>> multi_signals_1 = MultiSignals(range_)
        >>> multi_signals_2 = MultiSignals(range_)
        >>> multi_signals_1 != multi_signals_2
        False
        >>> multi_signals_2[0] = 20
        >>> multi_signals_1 != multi_signals_2
        True
        >>> multi_signals_2[0] = 10
        >>> multi_signals_1 != multi_signals_2
        False
        >>> from colour.algebra import CubicSplineInterpolator
        >>> multi_signals_2.interpolator = CubicSplineInterpolator
        >>> multi_signals_1 != multi_signals_2
        True
        """

        return not (self == other)

    def arithmetical_operation(self, a, operation, in_place=False):
        """
        Performs given arithmetical operation with :math:`a` operand, the
        operation can be either performed on a copy or in-place.

        Parameters
        ----------
        a : numeric or ndarray or Signal
            Operand.
        operation : object
            Operation to perform.
        in_place : bool, optional
            Operation happens in place.

        Returns
        -------
        MultiSignals
            multi-continuous signals.

        Examples
        --------
        Adding a single *numeric* variable:

        >>> domain = np.arange(0, 10, 1)
        >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
        >>> range_ += np.array([0, 10, 20])
        >>> multi_signals_1 = MultiSignals(range_)
        >>> print(multi_signals_1)
        [[   0.   10.   20.   30.]
         [   1.   20.   30.   40.]
         [   2.   30.   40.   50.]
         [   3.   40.   50.   60.]
         [   4.   50.   60.   70.]
         [   5.   60.   70.   80.]
         [   6.   70.   80.   90.]
         [   7.   80.   90.  100.]
         [   8.   90.  100.  110.]
         [   9.  100.  110.  120.]]
        >>> print(multi_signals_1.arithmetical_operation(10, '+', True))
        [[   0.   20.   30.   40.]
         [   1.   30.   40.   50.]
         [   2.   40.   50.   60.]
         [   3.   50.   60.   70.]
         [   4.   60.   70.   80.]
         [   5.   70.   80.   90.]
         [   6.   80.   90.  100.]
         [   7.   90.  100.  110.]
         [   8.  100.  110.  120.]
         [   9.  110.  120.  130.]]

        Adding an *array_like* variable:

        >>> a = np.linspace(10, 100, 10)
        >>> print(multi_signals_1.arithmetical_operation(a, '+', True))
        [[   0.   30.   40.   50.]
         [   1.   50.   60.   70.]
         [   2.   70.   80.   90.]
         [   3.   90.  100.  110.]
         [   4.  110.  120.  130.]
         [   5.  130.  140.  150.]
         [   6.  150.  160.  170.]
         [   7.  170.  180.  190.]
         [   8.  190.  200.  210.]
         [   9.  210.  220.  230.]]

        >>> a = np.array([[10, 20, 30]])
        >>> print(multi_signals_1.arithmetical_operation(a, '+', True))
        [[   0.   40.   60.   80.]
         [   1.   60.   80.  100.]
         [   2.   80.  100.  120.]
         [   3.  100.  120.  140.]
         [   4.  120.  140.  160.]
         [   5.  140.  160.  180.]
         [   6.  160.  180.  200.]
         [   7.  180.  200.  220.]
         [   8.  200.  220.  240.]
         [   9.  220.  240.  260.]]

        >>> a = np.arange(0, 30, 1).reshape([10, 3])
        >>> print(multi_signals_1.arithmetical_operation(a, '+', True))
        [[   0.   40.   61.   82.]
         [   1.   63.   84.  105.]
         [   2.   86.  107.  128.]
         [   3.  109.  130.  151.]
         [   4.  132.  153.  174.]
         [   5.  155.  176.  197.]
         [   6.  178.  199.  220.]
         [   7.  201.  222.  243.]
         [   8.  224.  245.  266.]
         [   9.  247.  268.  289.]]

        Adding a :class:`colour.continuous.Signal` sub-class:

        >>> multi_signals_2 = MultiSignals(range_)
        >>> print(multi_signals_1.arithmetical_operation(
        ...     multi_signals_2, '+', True))
        [[   0.   50.   81.  112.]
         [   1.   83.  114.  145.]
         [   2.  116.  147.  178.]
         [   3.  149.  180.  211.]
         [   4.  182.  213.  244.]
         [   5.  215.  246.  277.]
         [   6.  248.  279.  310.]
         [   7.  281.  312.  343.]
         [   8.  314.  345.  376.]
         [   9.  347.  378.  409.]]
        """

        multi_signals = self if in_place else self.copy()

        if isinstance(a, MultiSignals):
            assert len(self.signals) == len(a.signals), (
                '"MultiSignals" operands must have same count than '
                'underlying "Signal" components!')
            for signal_a, signal_b in zip(multi_signals.signals.values(),
                                          a.signals.values()):
                signal_a.arithmetical_operation(signal_b, operation, True)
        else:
            a = as_float_array(a)

            assert a.ndim in range(3), (
                'Operand "a" variable must be a numeric or a 1-dimensional or '
                '2-dimensional array!')

            if a.ndim in (0, 1):
                for signal in multi_signals.signals.values():
                    signal.arithmetical_operation(a, operation, True)
            else:
                assert a.shape[-1] == len(multi_signals.signals), (
                    'Operand "a" variable columns must have same count than '
                    'underlying "Signal" components!')

                for signal, y in zip(multi_signals.signals.values(),
                                     tsplit(a)):
                    signal.arithmetical_operation(y, operation, True)

        return multi_signals

    @staticmethod
    def multi_signals_unpack_data(data=None,
                                  domain=None,
                                  labels=None,
                                  dtype=DEFAULT_FLOAT_DTYPE,
                                  signal_type=Signal,
                                  **kwargs):
        """
        Unpack given data for multi-continuous signals instantiation.

        Parameters
        ----------
        data : Series or Dataframe or Signal or MultiSignals or array_like or \
dict_like, optional
            Data to unpack for multi-continuous signals instantiation.
        domain : array_like, optional
            Values to initialise the multiple :class:`colour.continuous.Signal`
            sub-class instances :attr:`colour.continuous.Signal.domain`
            attribute with. If both ``data`` and ``domain`` arguments are
            defined, the latter will be used to initialise the
            :attr:`colour.continuous.Signal.domain` attribute.
        dtype : type, optional
            **{np.float16, np.float32, np.float64, np.float128}**,
            Floating point data type.
        signal_type : type, optional
            A :class:`colour.continuous.Signal` sub-class type.

        Other Parameters
        ----------------
        name : unicode, optional
            multi-continuous signals name.
        interpolator : object, optional
            Interpolator class type to use as interpolating function for the
            :class:`colour.continuous.Signal` sub-class instances.
        interpolator_args : dict_like, optional
            Arguments to use when instantiating the interpolating function
            of the :class:`colour.continuous.Signal` sub-class instances.
        extrapolator : object, optional
            Extrapolator class type to use as extrapolating function for the
            :class:`colour.continuous.Signal` sub-class instances.
        extrapolator_args : dict_like, optional
            Arguments to use when instantiating the extrapolating function
            of the :class:`colour.continuous.Signal` sub-class instances.

        Returns
        -------
        dict
            Mapping of labeled :class:`colour.continuous.Signal` sub-class
            instances.

        Examples
        --------
        Unpacking using implicit *domain* and a single signal:

        >>> range_ = np.linspace(10, 100, 10)
        >>> signals = MultiSignals.multi_signals_unpack_data(range_)
        >>> list(signals.keys())
        [0]
        >>> print(signals[0])
        [[   0.   10.]
         [   1.   20.]
         [   2.   30.]
         [   3.   40.]
         [   4.   50.]
         [   5.   60.]
         [   6.   70.]
         [   7.   80.]
         [   8.   90.]
         [   9.  100.]]

        Unpacking using explicit *domain* and a single signal:

        >>> domain = np.arange(100, 1100, 100)
        >>> signals = MultiSignals.multi_signals_unpack_data(range_, domain)
        >>> list(signals.keys())
        [0]
        >>> print(signals[0])
        [[  100.    10.]
         [  200.    20.]
         [  300.    30.]
         [  400.    40.]
         [  500.    50.]
         [  600.    60.]
         [  700.    70.]
         [  800.    80.]
         [  900.    90.]
         [ 1000.   100.]]

        Unpacking using multiple signals:

        >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
        >>> range_ += np.array([0, 10, 20])
        >>> signals = MultiSignals.multi_signals_unpack_data(range_, domain)
        >>> list(signals.keys())
        [0, 1, 2]
        >>> print(signals[2])
        [[  100.    30.]
         [  200.    40.]
         [  300.    50.]
         [  400.    60.]
         [  500.    70.]
         [  600.    80.]
         [  700.    90.]
         [  800.   100.]
         [  900.   110.]
         [ 1000.   120.]]

        Unpacking using a *dict*:

        >>> signals = MultiSignals.multi_signals_unpack_data(
        ...     dict(zip(domain, range_)))
        >>> list(signals.keys())
        [0, 1, 2]
        >>> print(signals[2])
        [[  100.    30.]
         [  200.    40.]
         [  300.    50.]
         [  400.    60.]
         [  500.    70.]
         [  600.    80.]
         [  700.    90.]
         [  800.   100.]
         [  900.   110.]
         [ 1000.   120.]]

        Unpacking using *MultiSignals.multi_signals_unpack_data* method output:

        >>> signals = MultiSignals.multi_signals_unpack_data(
        ...     dict(zip(domain, range_)))
        >>> signals = MultiSignals.multi_signals_unpack_data(signals)
        >>> list(signals.keys())
        [0, 1, 2]
        >>> print(signals[2])
        [[  100.    30.]
         [  200.    40.]
         [  300.    50.]
         [  400.    60.]
         [  500.    70.]
         [  600.    80.]
         [  700.    90.]
         [  800.   100.]
         [  900.   110.]
         [ 1000.   120.]]

        Unpacking using a *Pandas* *Series*:

        >>> if is_pandas_installed():
        ...     from pandas import Series
        ...     signals = MultiSignals.multi_signals_unpack_data(
        ...         Series(dict(zip(domain, np.linspace(10, 100, 10)))))
        ...     print(signals[0])  # doctest: +SKIP
        [[  100.    10.]
         [  200.    20.]
         [  300.    30.]
         [  400.    40.]
         [  500.    50.]
         [  600.    60.]
         [  700.    70.]
         [  800.    80.]
         [  900.    90.]
         [ 1000.   100.]]

        Unpacking using a *Pandas* *Dataframe*:

        >>> if is_pandas_installed():
        ...     from pandas import DataFrame
        ...     data = dict(zip(['a', 'b', 'c'], tsplit(range_)))
        ...     signals = MultiSignals.multi_signals_unpack_data(
        ...         DataFrame(data, domain))
        ...     print(signals['c'])  # doctest: +SKIP
        [[  100.    30.]
         [  200.    40.]
         [  300.    50.]
         [  400.    60.]
         [  500.    70.]
         [  600.    80.]
         [  700.    90.]
         [  800.   100.]
         [  900.   110.]
         [ 1000.   120.]]
        """

        assert dtype in np.sctypes['float'], (
            '"dtype" must be one of the following types: {0}'.format(
                np.sctypes['float']))

        domain_u, range_u, signals = None, None, None
        signals = OrderedDict()
        # TODO: Implement support for Signal class passing.
        if isinstance(data, MultiSignals):
            signals = data.signals
        elif (issubclass(type(data), Sequence) or
              isinstance(data, (tuple, list, np.ndarray, Iterator))):
            data = tsplit(list(data) if isinstance(data, Iterator) else data)
            assert data.ndim in (1, 2), (
                'User "data" must be 1-dimensional or 2-dimensional!')
            if data.ndim == 1:
                data = data[np.newaxis, :]
            for i, range_u in enumerate(data):
                signals[i] = signal_type(range_u, domain, **kwargs)
        elif (issubclass(type(data), Mapping) or
              isinstance(data, (dict, OrderedDict))):

            # Handling `MultiSignals.multi_signals_unpack_data` method output
            # used as argument to `MultiSignals.multi_signals_unpack_data`
            # method.
            is_signal = all([
                True if isinstance(i, Signal) else False
                for i in data.values()
            ])

            if is_signal:
                for label, signal in data.items():
                    signals[label] = signal_type(signal.range, signal.domain,
                                                 **kwargs)
            else:
                domain_u, range_u = zip(*sorted(data.items()))
                for i, range_u in enumerate(tsplit(range_u)):
                    signals[i] = signal_type(range_u, domain_u, **kwargs)
        elif is_pandas_installed():
            from pandas import DataFrame, Series

            if isinstance(data, Series):
                signals[0] = signal_type(data, **kwargs)
            elif isinstance(data, DataFrame):
                domain_u = data.index.values
                signals = OrderedDict(((label,
                                        signal_type(
                                            data[label],
                                            domain_u,
                                            name=label,
                                            **kwargs)) for label in data))

        if domain is not None and signals is not None:
            for signal in signals.values():
                assert len(domain) == len(signal.domain), (
                    'User "domain" is not compatible with unpacked signals!')
                signal.domain = domain

        if labels is not None and signals is not None:
            assert len(labels) == len(signals), (
                'User "labels" is not compatible with unpacked signals!')
            signals = OrderedDict(
                [(labels[i], signal)
                 for i, (_key, signal) in enumerate(signals.items())])

        return signals

    def fill_nan(self, method='Interpolation', default=0):
        """
        Fill NaNs in independent domain :math:`x` variable and corresponding
        range :math:`y` variable using given method.

        Parameters
        ----------
        method : unicode, optional
            **{'Interpolation', 'Constant'}**,
            *Interpolation* method linearly interpolates through the NaNs,
            *Constant* method replaces NaNs with ``default``.
        default : numeric, optional
            Value to use with the *Constant* method.

        Returns
        -------
        Signal
            NaNs filled multi-continuous signals.

        >>> domain = np.arange(0, 10, 1)
        >>> range_ = tstack([np.linspace(10, 100, 10)] * 3)
        >>> range_ += np.array([0, 10, 20])
        >>> multi_signals = MultiSignals(range_)
        >>> multi_signals[3:7] = np.nan
        >>> print(multi_signals)
        [[   0.   10.   20.   30.]
         [   1.   20.   30.   40.]
         [   2.   30.   40.   50.]
         [   3.   nan   nan   nan]
         [   4.   nan   nan   nan]
         [   5.   nan   nan   nan]
         [   6.   nan   nan   nan]
         [   7.   80.   90.  100.]
         [   8.   90.  100.  110.]
         [   9.  100.  110.  120.]]
        >>> print(multi_signals.fill_nan())
        [[   0.   10.   20.   30.]
         [   1.   20.   30.   40.]
         [   2.   30.   40.   50.]
         [   3.   40.   50.   60.]
         [   4.   50.   60.   70.]
         [   5.   60.   70.   80.]
         [   6.   70.   80.   90.]
         [   7.   80.   90.  100.]
         [   8.   90.  100.  110.]
         [   9.  100.  110.  120.]]
        >>> multi_signals[3:7] = np.nan
        >>> print(multi_signals.fill_nan(method='Constant'))
        [[   0.   10.   20.   30.]
         [   1.   20.   30.   40.]
         [   2.   30.   40.   50.]
         [   3.    0.    0.    0.]
         [   4.    0.    0.    0.]
         [   5.    0.    0.    0.]
         [   6.    0.    0.    0.]
         [   7.   80.   90.  100.]
         [   8.   90.  100.  110.]
         [   9.  100.  110.  120.]]
        """

        for signal in self._signals.values():
            signal.fill_nan(method, default)

        return self

    def to_dataframe(self):
        """
        Converts the continuous signal to a *Pandas* :class:`DataFrame` class
        instance.

        Returns
        -------
        DataFrame
            Continuous signal as a *Pandas* :class:`DataFrame` class instance.

        Examples
        --------
        >>> if is_pandas_installed():
        ...     domain = np.arange(0, 10, 1)
        ...     range_ = tstack([np.linspace(10, 100, 10)] * 3)
        ...     range_ += np.array([0, 10, 20])
        ...     multi_signals = MultiSignals(range_)
        ...     print(multi_signals.to_dataframe())  # doctest: +SKIP
                 0      1      2
        0.0   10.0   20.0   30.0
        1.0   20.0   30.0   40.0
        2.0   30.0   40.0   50.0
        3.0   40.0   50.0   60.0
        4.0   50.0   60.0   70.0
        5.0   60.0   70.0   80.0
        6.0   70.0   80.0   90.0
        7.0   80.0   90.0  100.0
        8.0   90.0  100.0  110.0
        9.0  100.0  110.0  120.0
        """

        if is_pandas_installed():
            from pandas import DataFrame

            return DataFrame(
                data=self.range, index=self.domain, columns=self.labels)
