Source code for colour.continuous.multi_signals

"""
Multi Signals
=============

Defines the class implementing support for multi-continuous signals:

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

from __future__ import annotations

from collections.abc import Iterator, Mapping, ValuesView

import numpy as np

from colour.constants import DTYPE_FLOAT_DEFAULT
from colour.continuous import AbstractContinuousFunction, Signal
from colour.hints import (
    TYPE_CHECKING,
    Any,
    ArrayLike,
    Callable,
    Dict,
    DTypeFloat,
    List,
    Literal,
    NDArrayFloat,
    ProtocolExtrapolator,
    ProtocolInterpolator,
    Real,
    Self,
    Sequence,
    Type,
    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 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 | None ) = None, domain: ArrayLike | 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]): """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): """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): """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)): 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]): """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): """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]): """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): """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, ): """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): """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): """ 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))) 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: Any) -> 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, ] ) else: return False
[docs] def __ne__(self, other: Any) -> 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() ): 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)): signal.arithmetical_operation(y, operation, True) return multi_signals
[docs] @staticmethod def multi_signals_unpack_data( data: ( ArrayLike | DataFrame | dict | MultiSignals | Sequence | Series | Signal | None ) = None, domain: ArrayLike | 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())) 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 = cast(NDArrayFloat, data.index.values) signals = { label: signal_type( data[label], domain_unpacked, **settings, ) for label in data } if domain is not None: 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)