Coverage for continuous/signal.py: 61%
202 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-16 22:49 +1300
1"""
2Signal
3======
5Define support for continuous signal representation and manipulation.
7This module provides the :class:`colour.continuous.Signal` class for
8representing and operating on continuous signals with the specified domain and
9range values, supporting interpolation and extrapolation operations.
11- :class:`colour.continuous.Signal`
12"""
14from __future__ import annotations
16import typing
17from collections.abc import Iterator, KeysView, Mapping, Sequence, ValuesView
18from operator import pow # noqa: A004
19from operator import add, iadd, imul, ipow, isub, itruediv, mul, sub, truediv
21import numpy as np
23from colour.algebra import Extrapolator, KernelInterpolator
24from colour.constants import DTYPE_FLOAT_DEFAULT
25from colour.continuous import AbstractContinuousFunction
27if typing.TYPE_CHECKING:
28 from colour.hints import (
29 Any,
30 ArrayLike,
31 Literal,
32 NDArrayFloat,
33 ProtocolExtrapolator,
34 ProtocolInterpolator,
35 Real,
36 Self,
37 Type,
38 )
40from colour.hints import Callable, DTypeFloat, cast
41from colour.utilities import (
42 as_float_array,
43 attest,
44 fill_nan,
45 full,
46 is_pandas_installed,
47 multiline_repr,
48 ndarray_copy,
49 ndarray_copy_enable,
50 optional,
51 required,
52 runtime_warning,
53 tsplit,
54 tstack,
55 validate_method,
56)
57from colour.utilities.common import int_digest
58from colour.utilities.documentation import is_documentation_building
60if typing.TYPE_CHECKING or is_pandas_installed():
61 from pandas import Series # pragma: no cover
62else: # pragma: no cover
63 from unittest import mock
65 Series = mock.MagicMock()
67__author__ = "Colour Developers"
68__copyright__ = "Copyright 2013 Colour Developers"
69__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
70__maintainer__ = "Colour Developers"
71__email__ = "colour-developers@colour-science.org"
72__status__ = "Production"
74__all__ = [
75 "Signal",
76]
79class Signal(AbstractContinuousFunction):
80 """
81 Define the base class for a continuous signal.
83 The class implements the :meth:`Signal.function` method so that evaluating
84 the function for any independent domain variable :math:`x \\in\\mathbb{R}`
85 returns a corresponding range variable :math:`y \\in\\mathbb{R}`. It adopts
86 an interpolating function encapsulated inside an extrapolating function.
87 The resulting function independent domain, stored as discrete values in
88 the :attr:`colour.continuous.Signal.domain` property corresponds with the
89 function dependent and already known range stored in the
90 :attr:`colour.continuous.Signal.range` property.
92 .. important::
94 Specific documentation about getting, setting, indexing and slicing
95 the continuous signal values is available in the
96 :ref:`spectral-representation-and-continuous-signal` section.
98 Parameters
99 ----------
100 data
101 Data to be stored in the continuous signal.
102 domain
103 Values to initialise the :attr:`colour.continuous.Signal.domain`
104 attribute with. If both ``data`` and ``domain`` arguments are
105 defined, the latter will be used to initialise the
106 :attr:`colour.continuous.Signal.domain` property.
108 Other Parameters
109 ----------------
110 dtype
111 Floating point data type.
112 extrapolator
113 Extrapolator class type to use as extrapolating function.
114 extrapolator_kwargs
115 Arguments to use when instantiating the extrapolating function.
116 interpolator
117 Interpolator class type to use as interpolating function.
118 interpolator_kwargs
119 Arguments to use when instantiating the interpolating function.
120 name
121 Continuous signal name.
123 Attributes
124 ----------
125 - :attr:`~colour.continuous.Signal.dtype`
126 - :attr:`~colour.continuous.Signal.domain`
127 - :attr:`~colour.continuous.Signal.range`
128 - :attr:`~colour.continuous.Signal.interpolator`
129 - :attr:`~colour.continuous.Signal.interpolator_kwargs`
130 - :attr:`~colour.continuous.Signal.extrapolator`
131 - :attr:`~colour.continuous.Signal.extrapolator_kwargs`
132 - :attr:`~colour.continuous.Signal.function`
134 Methods
135 -------
136 - :meth:`~colour.continuous.Signal.__init__`
137 - :meth:`~colour.continuous.Signal.__str__`
138 - :meth:`~colour.continuous.Signal.__repr__`
139 - :meth:`~colour.continuous.Signal.__hash__`
140 - :meth:`~colour.continuous.Signal.__getitem__`
141 - :meth:`~colour.continuous.Signal.__setitem__`
142 - :meth:`~colour.continuous.Signal.__contains__`
143 - :meth:`~colour.continuous.Signal.__eq__`
144 - :meth:`~colour.continuous.Signal.__ne__`
145 - :meth:`~colour.continuous.Signal.arithmetical_operation`
146 - :meth:`~colour.continuous.Signal.signal_unpack_data`
147 - :meth:`~colour.continuous.Signal.fill_nan`
148 - :meth:`~colour.continuous.Signal.to_series`
150 Examples
151 --------
152 Instantiation with implicit *domain*:
154 >>> range_ = np.linspace(10, 100, 10)
155 >>> print(Signal(range_))
156 [[ 0. 10.]
157 [ 1. 20.]
158 [ 2. 30.]
159 [ 3. 40.]
160 [ 4. 50.]
161 [ 5. 60.]
162 [ 6. 70.]
163 [ 7. 80.]
164 [ 8. 90.]
165 [ 9. 100.]]
167 Instantiation with explicit *domain*:
169 >>> domain = np.arange(100, 1100, 100)
170 >>> print(Signal(range_, domain))
171 [[ 100. 10.]
172 [ 200. 20.]
173 [ 300. 30.]
174 [ 400. 40.]
175 [ 500. 50.]
176 [ 600. 60.]
177 [ 700. 70.]
178 [ 800. 80.]
179 [ 900. 90.]
180 [ 1000. 100.]]
182 Instantiation with a *dict*:
184 >>> print(Signal(dict(zip(domain, range_))))
185 [[ 100. 10.]
186 [ 200. 20.]
187 [ 300. 30.]
188 [ 400. 40.]
189 [ 500. 50.]
190 [ 600. 60.]
191 [ 700. 70.]
192 [ 800. 80.]
193 [ 900. 90.]
194 [ 1000. 100.]]
196 Instantiation with a *Pandas* :class:`pandas.Series`:
198 >>> if is_pandas_installed():
199 ... from pandas import Series
200 ...
201 ... print(Signal(Series(dict(zip(domain, range_))))) # doctest: +SKIP
202 [[ 100. 10.]
203 [ 200. 20.]
204 [ 300. 30.]
205 [ 400. 40.]
206 [ 500. 50.]
207 [ 600. 60.]
208 [ 700. 70.]
209 [ 800. 80.]
210 [ 900. 90.]
211 [ 1000. 100.]]
213 Retrieving domain *y* variable for arbitrary range *x* variable:
215 >>> x = 150
216 >>> range_ = np.sin(np.linspace(0, 1, 10))
217 >>> Signal(range_, domain)[x] # doctest: +ELLIPSIS
218 0.0359701...
219 >>> x = np.linspace(100, 1000, 3)
220 >>> Signal(range_, domain)[x] # doctest: +ELLIPSIS
221 array([ ..., 4.7669395...e-01, 8.4147098...e-01])
223 Using an alternative interpolating function:
225 >>> x = 150
226 >>> from colour.algebra import CubicSplineInterpolator
227 >>> Signal(range_, domain, interpolator=CubicSplineInterpolator)[
228 ... x
229 ... ] # doctest: +ELLIPSIS
230 0.0555274...
231 >>> x = np.linspace(100, 1000, 3)
232 >>> Signal(range_, domain, interpolator=CubicSplineInterpolator)[
233 ... x
234 ... ] # doctest: +ELLIPSIS
235 array([ 0. , 0.4794253..., 0.8414709...])
236 """
238 def __init__(
239 self,
240 data: ArrayLike | dict | Self | Series | ValuesView | None = None,
241 domain: ArrayLike | KeysView | None = None,
242 **kwargs: Any,
243 ) -> None:
244 super().__init__(kwargs.get("name"))
246 self._dtype: Type[DTypeFloat] = DTYPE_FLOAT_DEFAULT
247 self._domain: NDArrayFloat = np.array([])
248 self._range: NDArrayFloat = np.array([])
249 self._interpolator: Type[ProtocolInterpolator] = KernelInterpolator
250 self._interpolator_kwargs: dict = {}
251 self._extrapolator: Type[ProtocolExtrapolator] = Extrapolator
252 self._extrapolator_kwargs: dict = {
253 "method": "Constant",
254 "left": np.nan,
255 "right": np.nan,
256 }
258 self.range, self.domain = self.signal_unpack_data(data, domain)[::-1]
260 self.dtype = kwargs.get("dtype", self._dtype)
262 self.interpolator = kwargs.get("interpolator", self._interpolator)
263 self.interpolator_kwargs = kwargs.get(
264 "interpolator_kwargs", self._interpolator_kwargs
265 )
266 self.extrapolator = kwargs.get("extrapolator", self._extrapolator)
267 self.extrapolator_kwargs = kwargs.get(
268 "extrapolator_kwargs", self._extrapolator_kwargs
269 )
271 self._function: Callable | None = None
273 @property
274 def dtype(self) -> Type[DTypeFloat]:
275 """
276 Getter and setter for the continuous signal dtype.
278 Parameters
279 ----------
280 value
281 Value to set the continuous signal dtype with.
283 Returns
284 -------
285 Type[DTypeFloat]
286 Continuous signal dtype.
287 """
289 return self._dtype
291 @dtype.setter
292 def dtype(self, value: Type[DTypeFloat]) -> None:
293 """Setter for the **self.dtype** property."""
295 attest(
296 value in DTypeFloat.__args__,
297 f'"dtype" must be one of the following types: {DTypeFloat.__args__}',
298 )
300 self._dtype = value
302 # The following self-assignments are written as intended and
303 # triggers the rebuild of the underlying function.
304 if self.domain.dtype != value or self.range.dtype != value:
305 self.domain = self.domain
306 self.range = self.range
308 @property
309 def domain(self) -> NDArrayFloat:
310 """
311 Getter and setter for the continuous signal's independent
312 domain variable :math:`x`.
314 Parameters
315 ----------
316 value
317 Value to set the continuous signal independent domain
318 variable :math:`x` with.
320 Returns
321 -------
322 :class:`numpy.ndarray`
323 Continuous signal independent domain variable
324 :math:`x`.
325 """
327 return ndarray_copy(self._domain)
329 @domain.setter
330 def domain(self, value: ArrayLike) -> None:
331 """Setter for the **self.domain** property."""
333 value = as_float_array(value, self.dtype)
335 if not np.all(np.isfinite(value)):
336 runtime_warning(
337 f'"{self.name}" new "domain" variable is not finite: {value}, '
338 f"unpredictable results may occur!"
339 )
340 else:
341 attest(
342 np.all(value[:-1] <= value[1:]),
343 "The new domain value is not monotonic! ",
344 )
346 if value.size != self._range.size:
347 self._range = np.resize(self._range, value.shape)
349 self._domain = value
350 self._function = None # Invalidate the underlying continuous function.
352 @property
353 def range(self) -> NDArrayFloat:
354 """
355 Getter and setter for the continuous signal's range
356 variable :math:`y`.
358 Parameters
359 ----------
360 value
361 Value to set the continuous signal's range variable
362 :math:`y` with.
364 Returns
365 -------
366 :class:`numpy.ndarray`
367 Continuous signal's range variable :math:`y`.
368 """
370 return ndarray_copy(self._range)
372 @range.setter
373 def range(self, value: ArrayLike) -> None:
374 """Setter for the **self.range** property."""
376 value = as_float_array(value, self.dtype)
378 if not np.all(np.isfinite(value)):
379 runtime_warning(
380 f'"{self.name}" new "range" variable is not finite: {value}, '
381 f"unpredictable results may occur!"
382 )
384 # Empty domain occurs during __init__ because range is set before domain
385 attest(
386 self._domain.size in (0, self._domain.size),
387 '"domain" and "range" variables must have same size!',
388 )
390 self._range = value
391 self._function = None # Invalidate the underlying continuous function.
393 @property
394 def interpolator(self) -> Type[ProtocolInterpolator]:
395 """
396 Getter and setter for the continuous signal interpolator
397 type.
399 Parameters
400 ----------
401 value
402 Value to set the continuous signal interpolator type
403 with.
405 Returns
406 -------
407 Type[ProtocolInterpolator]
408 Continuous signal interpolator type.
409 """
411 return self._interpolator
413 @interpolator.setter
414 def interpolator(self, value: Type[ProtocolInterpolator]) -> None:
415 """Setter for the **self.interpolator** property."""
417 # TODO: Check for interpolator compatibility.
418 self._interpolator = value
419 self._function = None # Invalidate the underlying continuous function.
421 @property
422 def interpolator_kwargs(self) -> dict:
423 """
424 Getter and setter for the interpolator instantiation time arguments.
426 Parameters
427 ----------
428 value
429 Value to set the continuous signal interpolator
430 instantiation time arguments to.
432 Returns
433 -------
434 :class:`dict`
435 Continuous signal interpolator instantiation time
436 arguments.
437 """
439 return self._interpolator_kwargs
441 @interpolator_kwargs.setter
442 def interpolator_kwargs(self, value: dict) -> None:
443 """Setter for the **self.interpolator_kwargs** property."""
445 attest(
446 isinstance(value, dict),
447 f'"interpolator_kwargs" property: "{value}" type is not "dict"!',
448 )
450 self._interpolator_kwargs = value
451 self._function = None # Invalidate the underlying continuous function.
453 @property
454 def extrapolator(self) -> Type[ProtocolExtrapolator]:
455 """
456 Getter and setter for the continuous signal extrapolator type.
458 Parameters
459 ----------
460 value
461 Value to set the continuous signal extrapolator type with.
463 Returns
464 -------
465 Type[ProtocolExtrapolator]
466 Continuous signal extrapolator type.
467 """
469 return self._extrapolator
471 @extrapolator.setter
472 def extrapolator(self, value: Type[ProtocolExtrapolator]) -> None:
473 """Setter for the **self.extrapolator** property."""
475 # TODO: Check for extrapolator compatibility.
476 self._extrapolator = value
477 self._function = None # Invalidate the underlying continuous function.
479 @property
480 def extrapolator_kwargs(self) -> dict:
481 """
482 Getter and setter for the continuous signal extrapolator
483 instantiation time arguments.
485 Parameters
486 ----------
487 value
488 Value to set the continuous signal extrapolator
489 instantiation time arguments to.
491 Returns
492 -------
493 :class:`dict`
494 Continuous signal extrapolator instantiation time
495 arguments.
496 """
498 return self._extrapolator_kwargs
500 @extrapolator_kwargs.setter
501 def extrapolator_kwargs(self, value: dict) -> None:
502 """Setter for the **self.extrapolator_kwargs** property."""
504 attest(
505 isinstance(value, dict),
506 f'"extrapolator_kwargs" property: "{value}" type is not "dict"!',
507 )
509 self._extrapolator_kwargs = value
510 self._function = None # Invalidate the underlying continuous function.
512 @property
513 @ndarray_copy_enable(False)
514 def function(self) -> Callable:
515 """
516 Getter for the continuous signal callable.
518 Returns
519 -------
520 Callable
521 Continuous signal callable.
522 """
524 if self._function is None:
525 # Create the underlying continuous function.
527 if self._domain.size != 0 and self._range.size != 0:
528 self._function = self._extrapolator(
529 self._interpolator(
530 self._domain, self._range, **self._interpolator_kwargs
531 ),
532 **self._extrapolator_kwargs,
533 )
534 else:
536 def _undefined_function(
537 *args: Any, # noqa: ARG001
538 **kwargs: Any, # noqa: ARG001
539 ) -> None:
540 """
541 Raise a :class:`ValueError` exception.
543 Other Parameters
544 ----------------
545 args
546 Arguments.
547 kwargs
548 Keywords arguments.
550 Raises
551 ------
552 ValueError
553 """
555 error = (
556 "Underlying signal interpolator function does not "
557 'exists, please ensure that both "domain" and "range" '
558 "variables are defined!"
559 )
561 raise ValueError(error)
563 self._function = cast("Callable", _undefined_function)
565 return cast("Callable", self._function)
567 @ndarray_copy_enable(False)
568 def __str__(self) -> str:
569 """
570 Return a formatted string representation of the continuous signal.
572 Returns
573 -------
574 :class:`str`
575 Formatted string representation.
577 Examples
578 --------
579 >>> range_ = np.linspace(10, 100, 10)
580 >>> print(Signal(range_))
581 [[ 0. 10.]
582 [ 1. 20.]
583 [ 2. 30.]
584 [ 3. 40.]
585 [ 4. 50.]
586 [ 5. 60.]
587 [ 6. 70.]
588 [ 7. 80.]
589 [ 8. 90.]
590 [ 9. 100.]]
591 """
593 return str(tstack([self._domain, self._range]))
595 @ndarray_copy_enable(False)
596 def __repr__(self) -> str:
597 """
598 Return an evaluable string representation of the continuous signal.
600 Returns
601 -------
602 :class:`str`
603 Evaluable string representation.
605 Examples
606 --------
607 >>> range_ = np.linspace(10, 100, 10)
608 >>> Signal(range_)
609 Signal([[ 0., 10.],
610 [ 1., 20.],
611 [ 2., 30.],
612 [ 3., 40.],
613 [ 4., 50.],
614 [ 5., 60.],
615 [ 6., 70.],
616 [ 7., 80.],
617 [ 8., 90.],
618 [ 9., 100.]],
619 KernelInterpolator,
620 {},
621 Extrapolator,
622 {'method': 'Constant', 'left': nan, 'right': nan})
623 """
625 if is_documentation_building(): # pragma: no cover
626 return f"{self.__class__.__name__}(name='{self.name}', ...)"
628 return multiline_repr(
629 self,
630 [
631 {
632 "formatter": lambda x: repr( # noqa: ARG005
633 tstack([self._domain, self._range])
634 ),
635 },
636 {
637 "name": "interpolator",
638 "formatter": lambda x: self._interpolator.__name__, # noqa: ARG005
639 },
640 {"name": "interpolator_kwargs"},
641 {
642 "name": "extrapolator",
643 "formatter": lambda x: self._extrapolator.__name__, # noqa: ARG005
644 },
645 {"name": "extrapolator_kwargs"},
646 ],
647 )
649 @ndarray_copy_enable(False)
650 def __hash__(self) -> int:
651 """
652 Compute the hash of the continuous signal.
654 Returns
655 -------
656 :class:`int`
657 Object hash.
658 """
660 return hash(
661 (
662 int_digest(self._domain.tobytes()),
663 int_digest(self._range.tobytes()),
664 self.interpolator.__name__,
665 repr(self.interpolator_kwargs),
666 self.extrapolator.__name__,
667 repr(self.extrapolator_kwargs),
668 )
669 )
671 def __getitem__(self, x: ArrayLike | slice) -> NDArrayFloat:
672 """
673 Return the corresponding range variable :math:`y` for the specified
674 independent domain variable :math:`x`.
676 Parameters
677 ----------
678 x
679 Independent domain variable :math:`x`.
681 Returns
682 -------
683 :class:`numpy.ndarray`
684 Variable :math:`y` range value.
686 Examples
687 --------
688 >>> range_ = np.linspace(10, 100, 10)
689 >>> signal = Signal(range_)
690 >>> print(signal)
691 [[ 0. 10.]
692 [ 1. 20.]
693 [ 2. 30.]
694 [ 3. 40.]
695 [ 4. 50.]
696 [ 5. 60.]
697 [ 6. 70.]
698 [ 7. 80.]
699 [ 8. 90.]
700 [ 9. 100.]]
701 >>> signal[0]
702 10.0
703 >>> signal[np.array([0, 1, 2])]
704 array([ 10., 20., 30.])
705 >>> signal[0:3]
706 array([ 10., 20., 30.])
707 >>> signal[np.linspace(0, 5, 5)] # doctest: +ELLIPSIS
708 array([ 10. , 22.8348902..., 34.8004492..., \
70947.5535392..., 60. ])
710 """
712 if isinstance(x, slice):
713 return self._range[x]
715 return self.function(x)
717 def __setitem__(self, x: ArrayLike | slice, y: ArrayLike) -> None:
718 """
719 Set the corresponding range variable :math:`y` for the specified
720 independent domain variable :math:`x`.
722 Parameters
723 ----------
724 x
725 Independent domain variable :math:`x`.
726 y
727 Corresponding range variable :math:`y`.
729 Examples
730 --------
731 >>> range_ = np.linspace(10, 100, 10)
732 >>> signal = Signal(range_)
733 >>> print(signal)
734 [[ 0. 10.]
735 [ 1. 20.]
736 [ 2. 30.]
737 [ 3. 40.]
738 [ 4. 50.]
739 [ 5. 60.]
740 [ 6. 70.]
741 [ 7. 80.]
742 [ 8. 90.]
743 [ 9. 100.]]
744 >>> signal[0] = 20
745 >>> signal[0]
746 20.0
747 >>> signal[np.array([0, 1, 2])] = 30
748 >>> signal[np.array([0, 1, 2])]
749 array([ 30., 30., 30.])
750 >>> signal[0:3] = 40
751 >>> signal[0:3]
752 array([ 40., 40., 40.])
753 >>> signal[np.linspace(0, 5, 5)] = 50
754 >>> print(signal)
755 [[ 0. 50. ]
756 [ 1. 40. ]
757 [ 1.25 50. ]
758 [ 2. 40. ]
759 [ 2.5 50. ]
760 [ 3. 40. ]
761 [ 3.75 50. ]
762 [ 4. 50. ]
763 [ 5. 50. ]
764 [ 6. 70. ]
765 [ 7. 80. ]
766 [ 8. 90. ]
767 [ 9. 100. ]]
768 >>> signal[np.array([0, 1, 2])] = np.array([10, 20, 30])
769 >>> print(signal)
770 [[ 0. 10. ]
771 [ 1. 20. ]
772 [ 1.25 50. ]
773 [ 2. 30. ]
774 [ 2.5 50. ]
775 [ 3. 40. ]
776 [ 3.75 50. ]
777 [ 4. 50. ]
778 [ 5. 50. ]
779 [ 6. 70. ]
780 [ 7. 80. ]
781 [ 8. 90. ]
782 [ 9. 100. ]]
783 """
785 if isinstance(x, slice):
786 self._range[x] = y
787 else:
788 x = np.atleast_1d(x).astype(self.dtype)
789 y = np.resize(y, x.shape)
791 # Matching domain, updating existing `self._range` values.
792 mask = np.isin(x, self._domain)
793 x_m = x[mask]
794 indexes = np.searchsorted(self._domain, x_m)
795 self._range[indexes] = y[mask]
797 # Non matching domain, inserting into existing `self.domain`
798 # and `self.range`.
799 x_nm = x[~mask]
800 indexes = np.searchsorted(self._domain, x_nm)
801 if indexes.size != 0:
802 self._domain = np.insert(self._domain, indexes, x_nm)
803 self._range = np.insert(self._range, indexes, y[~mask])
805 self._function = None # Invalidate the underlying continuous function.
807 def __contains__(self, x: ArrayLike | slice) -> bool:
808 """
809 Determine whether the continuous signal contains the specified
810 independent domain variable :math:`x`.
812 Parameters
813 ----------
814 x
815 Independent domain variable :math:`x`.
817 Returns
818 -------
819 :class:`bool`
820 Whether :math:`x` domain value is contained.
822 Examples
823 --------
824 >>> range_ = np.linspace(10, 100, 10)
825 >>> signal = Signal(range_)
826 >>> 0 in signal
827 True
828 >>> 0.5 in signal
829 True
830 >>> 1000 in signal
831 False
832 """
834 return bool(
835 np.all(
836 np.where(
837 np.logical_and(
838 x >= np.min(self._domain), # pyright: ignore
839 x <= np.max(self._domain), # pyright: ignore
840 ),
841 True,
842 False,
843 )
844 )
845 )
847 @ndarray_copy_enable(False)
848 def __eq__(self, other: object) -> bool:
849 """
850 Determine whether the continuous signal equals the specified object.
852 Parameters
853 ----------
854 other
855 Object to determine for equality with the continuous signal.
857 Returns
858 -------
859 :class:`bool`
860 Whether the specified object is equal to the continuous signal.
862 Examples
863 --------
864 >>> range_ = np.linspace(10, 100, 10)
865 >>> signal_1 = Signal(range_)
866 >>> signal_2 = Signal(range_)
867 >>> signal_1 == signal_2
868 True
869 >>> signal_2[0] = 20
870 >>> signal_1 == signal_2
871 False
872 >>> signal_2[0] = 10
873 >>> signal_1 == signal_2
874 True
875 >>> from colour.algebra import CubicSplineInterpolator
876 >>> signal_2.interpolator = CubicSplineInterpolator
877 >>> signal_1 == signal_2
878 False
879 """
881 # NOTE: Comparing "interpolator_kwargs" and "extrapolator_kwargs" using
882 # their string representation because of presence of NaNs.
883 if isinstance(other, Signal):
884 return all(
885 [
886 np.array_equal(self._domain, other.domain),
887 np.array_equal(self._range, other.range),
888 self._interpolator is other.interpolator,
889 repr(self._interpolator_kwargs) == repr(other.interpolator_kwargs),
890 self._extrapolator is other.extrapolator,
891 repr(self._extrapolator_kwargs) == repr(other.extrapolator_kwargs),
892 ]
893 )
895 return False
897 def __ne__(self, other: object) -> bool:
898 """
899 Determine whether the continuous signal is not equal to the specified
900 other object.
902 Parameters
903 ----------
904 other
905 Object to determine whether it is not equal to the continuous signal.
907 Returns
908 -------
909 :class:`bool`
910 Whether the specified object is not equal to the continuous
911 signal.
913 Examples
914 --------
915 >>> range_ = np.linspace(10, 100, 10)
916 >>> signal_1 = Signal(range_)
917 >>> signal_2 = Signal(range_)
918 >>> signal_1 != signal_2
919 False
920 >>> signal_2[0] = 20
921 >>> signal_1 != signal_2
922 True
923 >>> signal_2[0] = 10
924 >>> signal_1 != signal_2
925 False
926 >>> from colour.algebra import CubicSplineInterpolator
927 >>> signal_2.interpolator = CubicSplineInterpolator
928 >>> signal_1 != signal_2
929 True
930 """
932 return not (self == other)
934 @ndarray_copy_enable(False)
935 def _fill_domain_nan(
936 self,
937 method: Literal["Constant", "Interpolation"] | str = "Interpolation",
938 default: Real = 0,
939 ) -> None:
940 """
941 Fill NaNs in the signal's independent domain variable :math:`x` using the
942 specified method.
944 This private method modifies the domain values in-place, replacing NaN
945 values according to the chosen filling strategy.
947 Parameters
948 ----------
949 method
950 Filling method to apply. *Interpolation* linearly interpolates
951 through the NaN values, while *Constant* replaces NaN values with
952 the specified ``default`` value.
953 default
954 Value to use when ``method`` is *Constant*.
955 """
957 self.domain = fill_nan(self._domain, method, default)
959 @ndarray_copy_enable(False)
960 def _fill_range_nan(
961 self,
962 method: Literal["Constant", "Interpolation"] | str = "Interpolation",
963 default: Real = 0,
964 ) -> None:
965 """
966 Fill NaNs in the continuous signal's range variable :math:`y` using
967 the specified method.
969 Parameters
970 ----------
971 method
972 *Interpolation* method linearly interpolates through the NaNs,
973 *Constant* method replaces NaNs with ``default``.
974 default
975 Value to use with the *Constant* method.
977 Returns
978 -------
979 :class:`colour.continuous.Signal`
980 NaNs filled continuous signal in corresponding range :math:`y`
981 variable.
982 """
984 self.range = fill_nan(self._range, method, default)
986 @ndarray_copy_enable(False)
987 def arithmetical_operation(
988 self,
989 a: ArrayLike | AbstractContinuousFunction,
990 operation: Literal["+", "-", "*", "/", "**"],
991 in_place: bool = False,
992 ) -> AbstractContinuousFunction:
993 """
994 Perform the specified arithmetical operation with operand :math:`a`.
996 The operation can be performed either on a copy of the signal or
997 in-place.
999 Parameters
1000 ----------
1001 a
1002 Operand :math:`a`. Can be a numeric value, array-like object, or
1003 another continuous function instance.
1004 operation
1005 Arithmetical operation to perform. Supported operations are
1006 addition (``"+"``), subtraction (``"-"``), multiplication
1007 (``"*"``), division (``"/"``), and exponentiation (``"**"``).
1008 in_place
1009 Whether the operation is performed in-place on the current
1010 signal instance. Default is ``False``.
1012 Returns
1013 -------
1014 :class:`colour.continuous.Signal`
1015 Continuous signal after the arithmetical operation. If
1016 ``in_place`` is ``True``, returns the modified instance;
1017 otherwise returns a new instance.
1019 Examples
1020 --------
1021 Adding a single *numeric* variable:
1023 >>> range_ = np.linspace(10, 100, 10)
1024 >>> signal_1 = Signal(range_)
1025 >>> print(signal_1)
1026 [[ 0. 10.]
1027 [ 1. 20.]
1028 [ 2. 30.]
1029 [ 3. 40.]
1030 [ 4. 50.]
1031 [ 5. 60.]
1032 [ 6. 70.]
1033 [ 7. 80.]
1034 [ 8. 90.]
1035 [ 9. 100.]]
1036 >>> print(signal_1.arithmetical_operation(10, "+", True))
1037 [[ 0. 20.]
1038 [ 1. 30.]
1039 [ 2. 40.]
1040 [ 3. 50.]
1041 [ 4. 60.]
1042 [ 5. 70.]
1043 [ 6. 80.]
1044 [ 7. 90.]
1045 [ 8. 100.]
1046 [ 9. 110.]]
1048 Adding an `ArrayLike` variable:
1050 >>> a = np.linspace(10, 100, 10)
1051 >>> print(signal_1.arithmetical_operation(a, "+", True))
1052 [[ 0. 30.]
1053 [ 1. 50.]
1054 [ 2. 70.]
1055 [ 3. 90.]
1056 [ 4. 110.]
1057 [ 5. 130.]
1058 [ 6. 150.]
1059 [ 7. 170.]
1060 [ 8. 190.]
1061 [ 9. 210.]]
1063 Adding a :class:`colour.continuous.Signal` class:
1065 >>> signal_2 = Signal(range_)
1066 >>> print(signal_1.arithmetical_operation(signal_2, "+", True))
1067 [[ 0. 40.]
1068 [ 1. 70.]
1069 [ 2. 100.]
1070 [ 3. 130.]
1071 [ 4. 160.]
1072 [ 5. 190.]
1073 [ 6. 220.]
1074 [ 7. 250.]
1075 [ 8. 280.]
1076 [ 9. 310.]]
1077 """
1079 operator, ioperator = {
1080 "+": (add, iadd),
1081 "-": (sub, isub),
1082 "*": (mul, imul),
1083 "/": (truediv, itruediv),
1084 "**": (pow, ipow),
1085 }[operation]
1087 if in_place:
1088 if isinstance(a, Signal):
1089 self[self._domain] = operator(self._range, a[self._domain])
1090 exclusive_or = np.setxor1d(self._domain, a.domain)
1091 self[exclusive_or] = full(exclusive_or.shape, np.nan)
1092 else:
1093 self.range = ioperator(self._range, a)
1095 return self
1097 return ioperator(self.copy(), a)
1099 @staticmethod
1100 @ndarray_copy_enable(True)
1101 def signal_unpack_data(
1102 data: ArrayLike | dict | Series | Signal | ValuesView | None,
1103 domain: ArrayLike | KeysView | None = None,
1104 dtype: Type[DTypeFloat] | None = None,
1105 ) -> tuple:
1106 """
1107 Unpack specified data for continuous signal instantiation.
1109 Parameters
1110 ----------
1111 data
1112 Data to unpack for continuous signal instantiation.
1113 domain
1114 Values to initialise the :attr:`colour.continuous.Signal.domain`
1115 attribute with. If both ``data`` and ``domain`` arguments are
1116 defined, the latter will be used to initialise the
1117 :attr:`colour.continuous.Signal.domain` property.
1118 dtype
1119 Floating point data type.
1121 Returns
1122 -------
1123 :class:`tuple`
1124 Independent domain variable :math:`x` and corresponding range
1125 variable :math:`y` unpacked for continuous signal instantiation.
1127 Examples
1128 --------
1129 Unpacking using implicit *domain*:
1131 >>> range_ = np.linspace(10, 100, 10)
1132 >>> domain, range_ = Signal.signal_unpack_data(range_)
1133 >>> print(domain)
1134 [ 0. 1. 2. 3. 4. 5. 6. 7. 8. 9.]
1135 >>> print(range_)
1136 [ 10. 20. 30. 40. 50. 60. 70. 80. 90. 100.]
1138 Unpacking using explicit *domain*:
1140 >>> domain = np.arange(100, 1100, 100)
1141 >>> domain, range = Signal.signal_unpack_data(range_, domain)
1142 >>> print(domain)
1143 [ 100. 200. 300. 400. 500. 600. 700. 800. 900. 1000.]
1144 >>> print(range_)
1145 [ 10. 20. 30. 40. 50. 60. 70. 80. 90. 100.]
1147 Unpacking using a *dict*:
1149 >>> domain, range_ = Signal.signal_unpack_data(dict(zip(domain, range_)))
1150 >>> print(domain)
1151 [ 100. 200. 300. 400. 500. 600. 700. 800. 900. 1000.]
1152 >>> print(range_)
1153 [ 10. 20. 30. 40. 50. 60. 70. 80. 90. 100.]
1155 Unpacking using a *Pandas* :class:`pandas.Series`:
1157 >>> if is_pandas_installed():
1158 ... from pandas import Series
1159 ...
1160 ... domain, range = Signal.signal_unpack_data(
1161 ... Series(dict(zip(domain, range_)))
1162 ... )
1163 ... # doctest: +ELLIPSIS
1164 >>> print(domain) # doctest: +SKIP
1165 [ 100. 200. 300. 400. 500. 600. 700. 800. 900. 1000.]
1166 >>> print(range_) # doctest: +SKIP
1167 [ 10. 20. 30. 40. 50. 60. 70. 80. 90. 100.]
1169 Unpacking using a :class:`colour.continuous.Signal` class:
1171 >>> domain, range_ = Signal.signal_unpack_data(Signal(range_, domain))
1172 >>> print(domain)
1173 [ 100. 200. 300. 400. 500. 600. 700. 800. 900. 1000.]
1174 >>> print(range_)
1175 [ 10. 20. 30. 40. 50. 60. 70. 80. 90. 100.]
1176 """
1178 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)
1180 domain_unpacked: NDArrayFloat = np.array([])
1181 range_unpacked: NDArrayFloat = np.array([])
1183 if isinstance(data, Signal):
1184 domain_unpacked = data.domain
1185 range_unpacked = data.range
1186 elif issubclass(type(data), Sequence) or isinstance(
1187 data, (tuple, list, np.ndarray, Iterator, ValuesView)
1188 ):
1189 data_array = (
1190 tsplit(list(cast("Sequence", data)))
1191 if not isinstance(data, np.ndarray)
1192 else data
1193 )
1195 attest(data_array.ndim == 1, 'User "data" must be 1-dimensional!')
1197 domain_unpacked, range_unpacked = (
1198 np.arange(0, data_array.size, dtype=dtype),
1199 data_array,
1200 )
1201 elif issubclass(type(data), Mapping) or isinstance(data, dict):
1202 domain_unpacked, range_unpacked = tsplit(
1203 sorted(cast("Mapping", data).items())
1204 )
1205 elif is_pandas_installed() and isinstance(data, Series):
1206 domain_unpacked = as_float_array(data.index.values, dtype) # pyright: ignore
1207 range_unpacked = as_float_array(data.values, dtype)
1209 if domain is not None:
1210 if isinstance(domain, KeysView):
1211 domain = list(domain)
1213 domain_array = as_float_array(domain, dtype)
1215 attest(
1216 len(domain_array) == len(range_unpacked),
1217 'User "domain" length is not compatible with unpacked "range"!',
1218 )
1220 domain_unpacked = domain_array
1222 if range_unpacked is not None:
1223 range_unpacked = as_float_array(range_unpacked, dtype)
1225 return ndarray_copy(domain_unpacked), ndarray_copy(range_unpacked)
1227 def fill_nan(
1228 self,
1229 method: Literal["Constant", "Interpolation"] | str = "Interpolation",
1230 default: Real = 0,
1231 ) -> Signal:
1232 """
1233 Fill NaNs in independent domain variable :math:`x` and corresponding
1234 range variable :math:`y` using the specified method.
1236 Parameters
1237 ----------
1238 method
1239 *Interpolation* method linearly interpolates through the NaNs,
1240 *Constant* method replaces NaNs with ``default``.
1241 default
1242 Value to use with the *Constant* method.
1244 Returns
1245 -------
1246 :class:`colour.continuous.Signal`
1247 Continuous signal with NaN values filled.
1249 Examples
1250 --------
1251 >>> range_ = np.linspace(10, 100, 10)
1252 >>> signal = Signal(range_)
1253 >>> signal[3:7] = np.nan
1254 >>> print(signal)
1255 [[ 0. 10.]
1256 [ 1. 20.]
1257 [ 2. 30.]
1258 [ 3. nan]
1259 [ 4. nan]
1260 [ 5. nan]
1261 [ 6. nan]
1262 [ 7. 80.]
1263 [ 8. 90.]
1264 [ 9. 100.]]
1265 >>> print(signal.fill_nan())
1266 [[ 0. 10.]
1267 [ 1. 20.]
1268 [ 2. 30.]
1269 [ 3. 40.]
1270 [ 4. 50.]
1271 [ 5. 60.]
1272 [ 6. 70.]
1273 [ 7. 80.]
1274 [ 8. 90.]
1275 [ 9. 100.]]
1276 >>> signal[3:7] = np.nan
1277 >>> print(signal.fill_nan(method="Constant"))
1278 [[ 0. 10.]
1279 [ 1. 20.]
1280 [ 2. 30.]
1281 [ 3. 0.]
1282 [ 4. 0.]
1283 [ 5. 0.]
1284 [ 6. 0.]
1285 [ 7. 80.]
1286 [ 8. 90.]
1287 [ 9. 100.]]
1288 """
1290 method = validate_method(method, ("Interpolation", "Constant"))
1292 self._fill_domain_nan(method, default)
1293 self._fill_range_nan(method, default)
1295 return self
1297 @required("Pandas")
1298 def to_series(self) -> Series:
1299 """
1300 Convert the continuous signal to a *Pandas* :class:`pandas.Series`
1301 class instance.
1303 Returns
1304 -------
1305 :class:`pandas.Series`
1306 Continuous signal as a *Pandas* :class:`pandas.Series` class
1307 instance.
1309 Examples
1310 --------
1311 >>> if is_pandas_installed():
1312 ... range_ = np.linspace(10, 100, 10)
1313 ... signal = Signal(range_)
1314 ... print(signal.to_series()) # doctest: +SKIP
1315 0.0 10.0
1316 1.0 20.0
1317 2.0 30.0
1318 3.0 40.0
1319 4.0 50.0
1320 5.0 60.0
1321 6.0 70.0
1322 7.0 80.0
1323 8.0 90.0
1324 9.0 100.0
1325 Name: Signal (...), dtype: float64
1326 """
1328 return Series(data=self._range, index=self._domain, name=self.name)