"""
Multi Signals
=============
Define the class implementing support for multi-continuous signals:
- :class:`colour.continuous.MultiSignals`
"""
from __future__ import annotations
import typing
from collections.abc import Iterator, KeysView, Mapping, ValuesView
import numpy as np
from colour.constants import DTYPE_FLOAT_DEFAULT
from colour.continuous import AbstractContinuousFunction, Signal
if typing.TYPE_CHECKING:
from colour.hints import (
Any,
Dict,
DTypeFloat,
List,
Literal,
NDArrayFloat,
ProtocolExtrapolator,
ProtocolInterpolator,
Real,
Self,
Sequence,
Type,
)
from colour.hints import ArrayLike, Callable, Sequence, cast
from colour.utilities import (
as_float_array,
attest,
first_item,
int_digest,
is_iterable,
is_pandas_installed,
multiline_repr,
optional,
required,
tsplit,
tstack,
validate_method,
)
from colour.utilities.documentation import is_documentation_building
if typing.TYPE_CHECKING or is_pandas_installed():
from pandas import DataFrame, Series # pragma: no cover
else: # pragma: no cover
from unittest import mock
DataFrame = mock.MagicMock()
Series = mock.MagicMock()
__author__ = "Colour Developers"
__copyright__ = "Copyright 2013 Colour Developers"
__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
__maintainer__ = "Colour Developers"
__email__ = "colour-developers@colour-science.org"
__status__ = "Production"
__all__ = [
"MultiSignals",
]
[docs]
class MultiSignals(AbstractContinuousFunction):
"""
Define the base class for multi-continuous signals, a container for
multiple :class:`colour.continuous.Signal` sub-class instances.
.. important::
Specific documentation about getting, setting, indexing and slicing the
multi-continuous signals values is available in the
:ref:`spectral-representation-and-continuous-signal` section.
Parameters
----------
data
Data to be stored in the multi-continuous signals.
domain
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
Names to use for the :class:`colour.continuous.Signal` sub-class
instances.
Other Parameters
----------------
dtype
float point data type.
extrapolator
Extrapolator class type to use as extrapolating function for the
:class:`colour.continuous.Signal` sub-class instances.
extrapolator_kwargs
Arguments to use when instantiating the extrapolating function
of the :class:`colour.continuous.Signal` sub-class instances.
interpolator
Interpolator class type to use as interpolating function for the
:class:`colour.continuous.Signal` sub-class instances.
interpolator_kwargs
Arguments to use when instantiating the interpolating function
of the :class:`colour.continuous.Signal` sub-class instances.
name
multi-continuous signals name.
signal_type
The :class:`colour.continuous.Signal` sub-class type used for
instances.
Attributes
----------
- :attr:`~colour.continuous.MultiSignals.dtype`
- :attr:`~colour.continuous.MultiSignals.domain`
- :attr:`~colour.continuous.MultiSignals.range`
- :attr:`~colour.continuous.MultiSignals.interpolator`
- :attr:`~colour.continuous.MultiSignals.interpolator_kwargs`
- :attr:`~colour.continuous.MultiSignals.extrapolator`
- :attr:`~colour.continuous.MultiSignals.extrapolator_kwargs`
- :attr:`~colour.continuous.MultiSignals.function`
- :attr:`~colour.continuous.MultiSignals.signals`
- :attr:`~colour.continuous.MultiSignals.labels`
- :attr:`~colour.continuous.MultiSignals.signal_type`
Methods
-------
- :meth:`~colour.continuous.MultiSignals.__init__`
- :meth:`~colour.continuous.MultiSignals.__str__`
- :meth:`~colour.continuous.MultiSignals.__repr__`
- :meth:`~colour.continuous.MultiSignals.__hash__`
- :meth:`~colour.continuous.MultiSignals.__getitem__`
- :meth:`~colour.continuous.MultiSignals.__setitem__`
- :meth:`~colour.continuous.MultiSignals.__contains__`
- :meth:`~colour.continuous.MultiSignals.__eq__`
- :meth:`~colour.continuous.MultiSignals.__ne__`
- :meth:`~colour.continuous.MultiSignals.arithmetical_operation`
- :meth:`~colour.continuous.MultiSignals.multi_signals_unpack_data`
- :meth:`~colour.continuous.MultiSignals.fill_nan`
- :meth:`~colour.continuous.MultiSignals.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* :class:`pandas.DataFrame`:
>>> if is_pandas_installed():
... from pandas import DataFrame
...
... data = dict(zip(["a", "b", "c"], tsplit(range_)))
... print(MultiSignals(DataFrame(data, domain))) # doctest: +SKIP
[[ 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...]])
"""
[docs]
def __init__(
self,
data: (
ArrayLike
| DataFrame
| dict
| Self
| Sequence
| Series
| Signal
| ValuesView
| None
) = None,
domain: ArrayLike | KeysView | None = None,
labels: Sequence | None = None,
**kwargs: Any,
) -> None:
super().__init__(kwargs.get("name"))
self._signal_type: Type[Signal] = kwargs.get("signal_type", Signal)
self._signals: Dict[str, Signal] = self.multi_signals_unpack_data(
data, domain, labels, **kwargs
)
@property
def dtype(self) -> Type[DTypeFloat]:
"""
Getter and setter property for the continuous signal dtype.
Parameters
----------
value
Value to set the continuous signal dtype with.
Returns
-------
Type[DTypeFloat]
Continuous signal dtype.
"""
return first_item(self._signals.values()).dtype
@dtype.setter
def dtype(self, value: Type[DTypeFloat]) -> None:
"""Setter for the **self.dtype** property."""
for signal in self._signals.values():
signal.dtype = value
@property
def domain(self) -> NDArrayFloat:
"""
Getter and setter property for the :class:`colour.continuous.Signal`
sub-class instances independent domain variable :math:`x`.
Parameters
----------
value
Value to set the :class:`colour.continuous.Signal` sub-class
instances independent domain variable :math:`x` with.
Returns
-------
:class:`numpy.ndarray`
:class:`colour.continuous.Signal` sub-class instances independent
domain variable :math:`x`.
"""
return first_item(self._signals.values()).domain
@domain.setter
def domain(self, value: ArrayLike) -> None:
"""Setter for the **self.domain** property."""
for signal in self._signals.values():
signal.domain = as_float_array(value, self.dtype)
@property
def range(self) -> NDArrayFloat:
"""
Getter and setter property for the :class:`colour.continuous.Signal`
sub-class instances corresponding range variable :math:`y`.
Parameters
----------
value
Value to set the :class:`colour.continuous.Signal` sub-class
instances corresponding range variable :math:`y` with.
Returns
-------
:class:`numpy.ndarray`
:class:`colour.continuous.Signal` sub-class instances corresponding
range variable :math:`y`.
"""
return tstack([signal.range for signal in self._signals.values()])
@range.setter
def range(self, value: ArrayLike) -> None:
"""Setter for the **self.range** property."""
value = as_float_array(value)
if value.ndim in (0, 1):
for signal in self._signals.values():
signal.range = value
else:
attest(
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), strict=True):
signal.range = y
@property
def interpolator(self) -> Type[ProtocolInterpolator]:
"""
Getter and setter property for the :class:`colour.continuous.Signal`
sub-class instances interpolator type.
Parameters
----------
value
Value to set the :class:`colour.continuous.Signal` sub-class
instances interpolator type with.
Returns
-------
Type[ProtocolInterpolator]
:class:`colour.continuous.Signal` sub-class instances interpolator
type.
"""
return first_item(self._signals.values()).interpolator
@interpolator.setter
def interpolator(self, value: Type[ProtocolInterpolator]) -> None:
"""Setter for the **self.interpolator** property."""
if value is not None:
for signal in self._signals.values():
signal.interpolator = value
@property
def interpolator_kwargs(self) -> dict:
"""
Getter and setter property for the :class:`colour.continuous.Signal`
sub-class instances interpolator instantiation time arguments.
Parameters
----------
value
Value to set the :class:`colour.continuous.Signal` sub-class
instances interpolator instantiation time arguments to.
Returns
-------
:class:`dict`
:class:`colour.continuous.Signal` sub-class instances interpolator
instantiation time arguments.
"""
return first_item(self._signals.values()).interpolator_kwargs
@interpolator_kwargs.setter
def interpolator_kwargs(self, value: dict) -> None:
"""Setter for the **self.interpolator_kwargs** property."""
for signal in self._signals.values():
signal.interpolator_kwargs = value
@property
def extrapolator(self) -> Type[ProtocolExtrapolator]:
"""
Getter and setter property for the :class:`colour.continuous.Signal`
sub-class instances extrapolator type.
Parameters
----------
value
Value to set the :class:`colour.continuous.Signal` sub-class
instances extrapolator type with.
Returns
-------
Type[ProtocolExtrapolator]
:class:`colour.continuous.Signal` sub-class instances extrapolator
type.
"""
return first_item(self._signals.values()).extrapolator
@extrapolator.setter
def extrapolator(self, value: Type[ProtocolExtrapolator]) -> None:
"""Setter for the **self.extrapolator** property."""
for signal in self._signals.values():
signal.extrapolator = value
@property
def extrapolator_kwargs(self) -> dict:
"""
Getter and setter property for the :class:`colour.continuous.Signal`
sub-class instances extrapolator instantiation time arguments.
Parameters
----------
value
Value to set the :class:`colour.continuous.Signal` sub-class
instances extrapolator instantiation time arguments to.
Returns
-------
:class:`dict`
:class:`colour.continuous.Signal` sub-class instances extrapolator
instantiation time arguments.
"""
return first_item(self._signals.values()).extrapolator_kwargs
@extrapolator_kwargs.setter
def extrapolator_kwargs(self, value: dict) -> None:
"""Setter for the **self.extrapolator_kwargs** property."""
for signal in self._signals.values():
signal.extrapolator_kwargs = value
@property
def function(self) -> Callable:
"""
Getter property for the :class:`colour.continuous.Signal` sub-class
instances callable.
Returns
-------
Callable
:class:`colour.continuous.Signal` sub-class instances callable.
"""
return first_item(self._signals.values()).function
@property
def signals(self) -> Dict[str, Signal]:
"""
Getter and setter property for the :class:`colour.continuous.Signal`
sub-class instances.
Parameters
----------
value
Attribute value.
Returns
-------
:class:`dict`
:class:`colour.continuous.Signal` sub-class instances.
"""
return self._signals
@signals.setter
def signals(
self,
value: ArrayLike | DataFrame | dict | Self | Series | Signal | None,
) -> None:
"""Setter for the **self.signals** property."""
self._signals = self.multi_signals_unpack_data(
value, signal_type=self._signal_type
)
@property
def labels(self) -> List[str]:
"""
Getter and setter property for the :class:`colour.continuous.Signal`
sub-class instance names.
Parameters
----------
value
Value to set the :class:`colour.continuous.Signal` sub-class
instance names.
Returns
-------
:class:`list`
:class:`colour.continuous.Signal` sub-class instance names.
"""
return [str(key) for key in self._signals]
@labels.setter
def labels(self, value: Sequence) -> None:
"""Setter for the **self.labels** property."""
attest(
is_iterable(value),
f'"labels" property: "{value}" is not an "iterable" like object!',
)
attest(
len(set(value)) == len(value),
'"labels" property: values must be unique!',
)
attest(
len(value) == len(self.labels),
f'"labels" property: length must be "{len(self._signals)}"!',
)
self._signals = {
str(value[i]): signal for i, signal in enumerate(self._signals.values())
}
@property
def signal_type(self) -> Type[Signal]:
"""
Getter property for the :class:`colour.continuous.Signal` sub-class
instances type.
Returns
-------
Type[Signal]
:class:`colour.continuous.Signal` sub-class instances type.
"""
return self._signal_type
[docs]
def __str__(self) -> str:
"""
Return a formatted string representation of the multi-continuous
signals.
Returns
-------
:class:`str`
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.]]
"""
return str(np.hstack([self.domain[:, None], self.range]))
[docs]
def __repr__(self) -> str:
"""
Return an evaluable string representation of the multi-continuous
signals.
Returns
-------
:class:`str`
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_)
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.]],
['0', '1', '2'],
KernelInterpolator,
{},
Extrapolator,
{'method': 'Constant', 'left': nan, 'right': nan})
"""
if is_documentation_building(): # pragma: no cover
return f"{self.__class__.__name__}(name='{self.name}', ...)"
return multiline_repr(
self,
[
{
"formatter": lambda x: repr( # noqa: ARG005
np.hstack([self.domain[:, None], self.range])
),
},
{"name": "labels"},
{
"name": "interpolator",
"formatter": lambda x: ( # noqa: ARG005
self.interpolator.__name__
),
},
{"name": "interpolator_kwargs"},
{
"name": "extrapolator",
"formatter": lambda x: ( # noqa: ARG005
self.extrapolator.__name__
),
},
{"name": "extrapolator_kwargs"},
],
)
[docs]
def __hash__(self) -> int:
"""
Return the abstract continuous function hash.
Returns
-------
:class:`int`
Object hash.
"""
return hash(
(
int_digest(self.domain.tobytes()),
*[hash(signal) for signal in self._signals.values()],
self.interpolator.__name__,
repr(self.interpolator_kwargs),
self.extrapolator.__name__,
repr(self.extrapolator_kwargs),
)
)
[docs]
def __getitem__(self, x: ArrayLike | slice) -> NDArrayFloat:
"""
Return the corresponding range variable :math:`y` for independent
domain variable :math:`x`.
Parameters
----------
x
Independent domain variable :math:`x`.
Returns
-------
:class:`numpy.ndarray`
Variable :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[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. ...]])
>>> multi_signals[0:3]
array([[ 10., 20., 30.],
[ 20., 30., 40.],
[ 30., 40., 50.]])
>>> multi_signals[:, 0:2]
array([[ 10., 20.],
[ 20., 30.],
[ 30., 40.],
[ 40., 50.],
[ 50., 60.],
[ 60., 70.],
[ 70., 80.],
[ 80., 90.],
[ 90., 100.],
[ 100., 110.]])
"""
x_r, x_c = (x[0], x[1]) if isinstance(x, tuple) else (x, slice(None))
values = tstack([signal[x_r] for signal in self._signals.values()])
return values[..., x_c] # pyright: ignore
[docs]
def __setitem__(self, x: ArrayLike | slice, y: ArrayLike) -> None:
"""
Set the corresponding range variable :math:`y` for independent domain
variable :math:`x`.
Parameters
----------
x
Independent domain variable :math:`x`.
y
Corresponding range variable :math:`y`.
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[np.linspace(0, 5, 5)] = 50
>>> print(multi_signals)
[[ 0. 50. 50. 50. ]
[ 1. 30. 30. 30. ]
[ 1.25 50. 50. 50. ]
[ 2. 30. 30. 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. ]]
>>> 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.reshape(np.arange(1, 10, 1), (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. ]]
>>> multi_signals[0:3] = 40
>>> multi_signals[0:3]
array([[ 40., 40., 40.],
[ 40., 40., 40.],
[ 40., 40., 40.]])
>>> multi_signals[:, 0:2] = 50
>>> print(multi_signals)
[[ 0. 50. 50. 40. ]
[ 1. 50. 50. 40. ]
[ 1.25 50. 50. 40. ]
[ 2. 50. 50. 9. ]
[ 2.5 50. 50. 50. ]
[ 3. 50. 50. 60. ]
[ 3.75 50. 50. 50. ]
[ 4. 50. 50. 70. ]
[ 5. 50. 50. 50. ]
[ 6. 50. 50. 90. ]
[ 7. 50. 50. 100. ]
[ 8. 50. 50. 110. ]
[ 9. 50. 50. 120. ]]
"""
y = as_float_array(y)
x_r, x_c = (x[0], x[1]) if isinstance(x, tuple) else (x, slice(None))
attest(
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[None, :]
attest(
y.shape[-1] == len(self._signals),
'Corresponding "y" variable columns must have same count than '
'underlying "Signal" components!',
)
values = list(zip(self._signals.values(), tsplit(y), strict=True))
for signal, y in values[x_c]: # pyright: ignore
signal[x_r] = y
[docs]
def __contains__(self, x: ArrayLike | slice) -> bool:
"""
Return whether the multi-continuous signals contains given independent
domain variable :math:`x`.
Parameters
----------
x
Independent domain variable :math:`x`.
Returns
-------
:class:`bool`
Whether :math:`x` domain value is 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
"""
return x in first_item(self._signals.values())
[docs]
def __eq__(self, other: object) -> bool:
"""
Return whether the multi-continuous signals is equal to given other
object.
Parameters
----------
other
Object to test whether it is equal to the multi-continuous signals.
Returns
-------
:class:`bool`
Whether given object is 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
"""
# NOTE: Comparing "interpolator_kwargs" and "extrapolator_kwargs" using
# their string representation because of presence of NaNs.
if isinstance(other, MultiSignals):
return all(
[
np.array_equal(self.domain, other.domain),
np.array_equal(self.range, other.range),
self.interpolator is other.interpolator,
str(self.interpolator_kwargs) == str(other.interpolator_kwargs),
self.extrapolator is other.extrapolator,
str(self.extrapolator_kwargs) == str(other.extrapolator_kwargs),
self.labels == other.labels,
]
)
return False
[docs]
def __ne__(self, other: object) -> bool:
"""
Return whether the multi-continuous signals is not equal to given
other object.
Parameters
----------
other
Object to test whether it is not equal to the multi-continuous
signals.
Returns
-------
:class:`bool`
Whether given object is 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)
[docs]
def arithmetical_operation(
self,
a: ArrayLike | AbstractContinuousFunction,
operation: Literal["+", "-", "*", "/", "**"],
in_place: bool = False,
) -> MultiSignals:
"""
Perform given arithmetical operation with operand :math:`a`, the
operation can be either performed on a copy or in-place.
Parameters
----------
a
Operand :math:`a`.
operation
Operation to perform.
in_place
Operation happens in place.
Returns
-------
:class:`colour.continuous.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 `ArrayLike` 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.reshape(np.arange(0, 30, 1), (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):
attest(
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(), strict=True
):
signal_a.arithmetical_operation(signal_b, operation, True)
else:
a = as_float_array(cast(ArrayLike, a))
attest(
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:
attest(
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), strict=True
):
signal.arithmetical_operation(y, operation, True)
return multi_signals
[docs]
@staticmethod
def multi_signals_unpack_data(
data: (
ArrayLike
| DataFrame
| dict
| MultiSignals
| Sequence
| Series
| Signal
| ValuesView
| None
) = None,
domain: ArrayLike | KeysView | None = None,
labels: Sequence | None = None,
dtype: Type[DTypeFloat] | None = None,
signal_type: Type[Signal] = Signal,
**kwargs: Any,
) -> Dict[str, Signal]:
"""
Unpack given data for multi-continuous signals instantiation.
Parameters
----------
data
Data to unpack for multi-continuous signals instantiation.
domain
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` property.
labels
Names to use for the :class:`colour.continuous.Signal` sub-class
instances.
dtype
float point data type.
signal_type
A :class:`colour.continuous.Signal` sub-class type.
Other Parameters
----------------
extrapolator
Extrapolator class type to use as extrapolating function for the
:class:`colour.continuous.Signal` sub-class instances.
extrapolator_kwargs
Arguments to use when instantiating the extrapolating function
of the :class:`colour.continuous.Signal` sub-class instances.
interpolator
Interpolator class type to use as interpolating function for the
:class:`colour.continuous.Signal` sub-class instances.
interpolator_kwargs
Arguments to use when instantiating the interpolating function
of the :class:`colour.continuous.Signal` sub-class instances.
name
multi-continuous signals name.
Returns
-------
:class:`dict`
Mapping of labeled :class:`colour.continuous.Signal` sub-class
instances.
Examples
--------
Unpacking using implicit *domain* and data for 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 data for 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 data for 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 a sequence of *Signal* instances, note how the keys
are :class:`str` instances because the *Signal* names are used:
>>> signals = MultiSignals.multi_signals_unpack_data(
... dict(zip(domain, range_))
... ).values()
>>> 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 *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* :class:`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.]]
"""
dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)
settings = {}
settings.update(kwargs)
settings.update({"dtype": dtype})
# domain_unpacked, range_unpacked, signals = (
# np.array([]), np.array([]), {})
signals = {}
if isinstance(data, Signal):
signals[data.name] = data
elif isinstance(data, MultiSignals):
signals = data.signals
elif issubclass(type(data), Sequence) or isinstance(
data, (tuple, list, np.ndarray, Iterator, ValuesView)
):
data_sequence = list(cast(Sequence, data))
is_signal = True
for i in data_sequence:
if not isinstance(i, Signal):
is_signal = False
break
if is_signal:
for signal in data_sequence:
signals[signal.name] = signal_type(
signal.range, signal.domain, **settings
)
else:
data_array = tsplit(data_sequence)
attest(
data_array.ndim in (1, 2),
'User "data" must be 1-dimensional or 2-dimensional!',
)
if data_array.ndim == 1:
data_array = data_array[None, :]
for i, range_unpacked in enumerate(data_array):
signals[str(i)] = signal_type(range_unpacked, domain, **settings)
elif issubclass(type(data), Mapping) or isinstance(data, dict):
data_mapping = dict(cast(Mapping, data))
is_signal = all(isinstance(i, Signal) for i in data_mapping.values())
if is_signal:
for label, signal in data_mapping.items():
signals[label] = signal_type(
signal.range, signal.domain, **settings
)
else:
domain_unpacked, range_unpacked = zip(
*sorted(data_mapping.items()), strict=True
)
for i, values_unpacked in enumerate(tsplit(range_unpacked)):
signals[str(i)] = signal_type(
values_unpacked, domain_unpacked, **settings
)
elif is_pandas_installed():
if isinstance(data, Series):
signals["0"] = signal_type(data, **settings)
elif isinstance(data, DataFrame):
domain_unpacked = as_float_array(data.index.values, dtype) # pyright: ignore
signals = {
label: signal_type(
data[label],
domain_unpacked,
**settings,
)
for label in data
}
if domain is not None:
if isinstance(domain, KeysView):
domain = list(domain)
domain_array = as_float_array(domain, dtype)
for signal in signals.values():
attest(
len(domain_array) == len(signal.domain),
'User "domain" length is not compatible with unpacked '
'"signals"!',
)
signal.domain = domain_array
signals = {str(label): signal for label, signal in signals.items()}
if labels is not None:
attest(
len(labels) == len(signals),
'User "labels" length is not compatible with unpacked "signals"!',
)
if len(labels) != len(set(labels)):
labels = [f"{label} - {i}" for i, label in enumerate(labels)]
signals = {
str(labels[i]): signal for i, signal in enumerate(signals.values())
}
for label in signals:
signals[label].name = label
if not signals:
signals = {"Undefined": Signal(name="Undefined")}
return signals
[docs]
def fill_nan(
self,
method: Literal["Constant", "Interpolation"] | str = "Interpolation",
default: Real = 0,
) -> MultiSignals:
"""
Fill NaNs in independent domain variable :math:`x` and corresponding
range variable :math:`y` using given method.
Parameters
----------
method
*Interpolation* method linearly interpolates through the NaNs,
*Constant* method replaces NaNs with ``default``.
default
Value to use with the *Constant* method.
Returns
-------
:class:`colour.continuous.MultiSignals`
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.]]
"""
method = validate_method(method, ("Interpolation", "Constant"))
for signal in self._signals.values():
signal.fill_nan(method, default)
return self
[docs]
@required("Pandas")
def to_dataframe(self) -> DataFrame:
"""
Convert the continuous signal to a *Pandas* :class:`pandas.DataFrame`
class instance.
Returns
-------
:class:`pandas.DataFrame`
Continuous signal as a *Pandas* :class:`pandas.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
"""
return DataFrame(
data=self.range,
index=self.domain,
columns=self.labels, # pyright: ignore
)