Coverage for continuous/signal.py: 61%

1"""

2 Signal

3======

4

5 Define support for continuous signal representation and manipulation.

6

7 This module provides the :class:`colour.continuous.Signal` class for

8 representing and operating on continuous signals with the specified domain and

9 range values, supporting interpolation and extrapolation operations.

10

11- :class:`colour.continuous.Signal`

12"""

13

14 from __future__ import annotations

15

16 import typing

17 from collections.abc import Iterator, KeysView, Mapping, Sequence, ValuesView

18 from operator import pow # noqa: A004

19 from operator import add, iadd, imul, ipow, isub, itruediv, mul, sub, truediv

20

21 import numpy as np

22

23 from colour.algebra import Extrapolator, KernelInterpolator

24 from colour.constants import DTYPE_FLOAT_DEFAULT

25 from colour.continuous import AbstractContinuousFunction

26

27 if 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 )

39

40 from colour.hints import Callable, DTypeFloat, cast

41 from 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)

57 from colour.utilities.common import int_digest

58 from colour.utilities.documentation import is_documentation_building

59

60 if typing.TYPE_CHECKING or is_pandas_installed():

61 from pandas import Series # pragma: no cover

62 else: # pragma: no cover

63 from unittest import mock

64

65 Series = mock.MagicMock()

66

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"

73

74 __all__ = [

75 "Signal",

76]

77

78

79 class Signal(AbstractContinuousFunction):

80 """

81 Define the base class for a continuous signal.

82

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.

91

92 .. important::

93

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.

97

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.

107

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.

122

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`

133

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`

149

150 Examples

151 --------

152 Instantiation with implicit *domain*:

153

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.]]

166

167 Instantiation with explicit *domain*:

168

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.]]

181

182 Instantiation with a *dict*:

183

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.]]

195

196 Instantiation with a *Pandas* :class:`pandas.Series`:

197

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.]]

212

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

214

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])

222

223 Using an alternative interpolating function:

224

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 """

237

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"))

245

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 }

257

258 self.range, self.domain = self.signal_unpack_data(data, domain)[::-1]

259

260 self.dtype = kwargs.get("dtype", self._dtype)

261

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 )

270

271 self._function: Callable | None = None

272

273 @property

274 def dtype(self) -> Type[DTypeFloat]:

275 """

276 Getter and setter for the continuous signal dtype.

277

278 Parameters

279 ----------

280 value

281 Value to set the continuous signal dtype with.

282

283 Returns

284 -------

285 Type[DTypeFloat]

286 Continuous signal dtype.

287 """

288

289 return self._dtype

290

291 @dtype.setter

292 def dtype(self, value: Type[DTypeFloat]) -> None:

293 """Setter for the **self.dtype** property."""

294

295 attest(

296 value in DTypeFloat.__args__,

297 f'"dtype" must be one of the following types: {DTypeFloat.__args__}',

298 )

299

300 self._dtype = value

301

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

307

308 @property

309 def domain(self) -> NDArrayFloat:

310 """

311 Getter and setter for the continuous signal's independent

312 domain variable :math:`x`.

313

314 Parameters

315 ----------

316 value

317 Value to set the continuous signal independent domain

318 variable :math:`x` with.

319

320 Returns

321 -------

322 :class:`numpy.ndarray`

323 Continuous signal independent domain variable

324 :math:`x`.

325 """

326

327 return ndarray_copy(self._domain)

328

329 @domain.setter

330 def domain(self, value: ArrayLike) -> None:

331 """Setter for the **self.domain** property."""

332

333 value = as_float_array(value, self.dtype)

334

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 )

345

346 if value.size != self._range.size:

347 self._range = np.resize(self._range, value.shape)

348

349 self._domain = value

350 self._function = None # Invalidate the underlying continuous function.

351

352 @property

353 def range(self) -> NDArrayFloat:

354 """

355 Getter and setter for the continuous signal's range

356 variable :math:`y`.

357

358 Parameters

359 ----------

360 value

361 Value to set the continuous signal's range variable

362 :math:`y` with.

363

364 Returns

365 -------

366 :class:`numpy.ndarray`

367 Continuous signal's range variable :math:`y`.

368 """

369

370 return ndarray_copy(self._range)

371

372 @range.setter

373 def range(self, value: ArrayLike) -> None:

374 """Setter for the **self.range** property."""

375

376 value = as_float_array(value, self.dtype)

377

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 )

383

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 )

389

390 self._range = value

391 self._function = None # Invalidate the underlying continuous function.

392

393 @property

394 def interpolator(self) -> Type[ProtocolInterpolator]:

395 """

396 Getter and setter for the continuous signal interpolator

397 type.

398

399 Parameters

400 ----------

401 value

402 Value to set the continuous signal interpolator type

403 with.

404

405 Returns

406 -------

407 Type[ProtocolInterpolator]

408 Continuous signal interpolator type.

409 """

410

411 return self._interpolator

412

413 @interpolator.setter

414 def interpolator(self, value: Type[ProtocolInterpolator]) -> None:

415 """Setter for the **self.interpolator** property."""

416

417 # TODO: Check for interpolator compatibility.

418 self._interpolator = value

419 self._function = None # Invalidate the underlying continuous function.

420

421 @property

422 def interpolator_kwargs(self) -> dict:

423 """

424 Getter and setter for the interpolator instantiation time arguments.

425

426 Parameters

427 ----------

428 value

429 Value to set the continuous signal interpolator

430 instantiation time arguments to.

431

432 Returns

433 -------

434 :class:`dict`

435 Continuous signal interpolator instantiation time

436 arguments.

437 """

438

439 return self._interpolator_kwargs

440

441 @interpolator_kwargs.setter

442 def interpolator_kwargs(self, value: dict) -> None:

443 """Setter for the **self.interpolator_kwargs** property."""

444

445 attest(

446 isinstance(value, dict),

447 f'"interpolator_kwargs" property: "{value}" type is not "dict"!',

448 )

449

450 self._interpolator_kwargs = value

451 self._function = None # Invalidate the underlying continuous function.

452

453 @property

454 def extrapolator(self) -> Type[ProtocolExtrapolator]:

455 """

456 Getter and setter for the continuous signal extrapolator type.

457

458 Parameters

459 ----------

460 value

461 Value to set the continuous signal extrapolator type with.

462

463 Returns

464 -------

465 Type[ProtocolExtrapolator]

466 Continuous signal extrapolator type.

467 """

468

469 return self._extrapolator

470

471 @extrapolator.setter

472 def extrapolator(self, value: Type[ProtocolExtrapolator]) -> None:

473 """Setter for the **self.extrapolator** property."""

474

475 # TODO: Check for extrapolator compatibility.

476 self._extrapolator = value

477 self._function = None # Invalidate the underlying continuous function.

478

479 @property

480 def extrapolator_kwargs(self) -> dict:

481 """

482 Getter and setter for the continuous signal extrapolator

483 instantiation time arguments.

484

485 Parameters

486 ----------

487 value

488 Value to set the continuous signal extrapolator

489 instantiation time arguments to.

490

491 Returns

492 -------

493 :class:`dict`

494 Continuous signal extrapolator instantiation time

495 arguments.

496 """

497

498 return self._extrapolator_kwargs

499

500 @extrapolator_kwargs.setter

501 def extrapolator_kwargs(self, value: dict) -> None:

502 """Setter for the **self.extrapolator_kwargs** property."""

503

504 attest(

505 isinstance(value, dict),

506 f'"extrapolator_kwargs" property: "{value}" type is not "dict"!',

507 )

508

509 self._extrapolator_kwargs = value

510 self._function = None # Invalidate the underlying continuous function.

511

512 @property

513 @ndarray_copy_enable(False)

514 def function(self) -> Callable:

515 """

516 Getter for the continuous signal callable.

517

518 Returns

519 -------

520 Callable

521 Continuous signal callable.

522 """

523

524 if self._function is None:

525 # Create the underlying continuous function.

526

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:

535

536 def _undefined_function(

537 *args: Any, # noqa: ARG001

538 **kwargs: Any, # noqa: ARG001

539 ) -> None:

540 """

541 Raise a :class:`ValueError` exception.

542

543 Other Parameters

544 ----------------

545 args

546 Arguments.

547 kwargs

548 Keywords arguments.

549

550 Raises

551 ------

552 ValueError

553 """

554

555 error = (

556 "Underlying signal interpolator function does not "

557 'exists, please ensure that both "domain" and "range" '

558 "variables are defined!"

559 )

560

561 raise ValueError(error)

562

563 self._function = cast("Callable", _undefined_function)

564

565 return cast("Callable", self._function)

566

567 @ndarray_copy_enable(False)

568 def __str__(self) -> str:

569 """

570 Return a formatted string representation of the continuous signal.

571

572 Returns

573 -------

574 :class:`str`

575 Formatted string representation.

576

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 """

592

593 return str(tstack([self._domain, self._range]))

594

595 @ndarray_copy_enable(False)

596 def __repr__(self) -> str:

597 """

598 Return an evaluable string representation of the continuous signal.

599

600 Returns

601 -------

602 :class:`str`

603 Evaluable string representation.

604

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 """

624

625 if is_documentation_building(): # pragma: no cover

626 return f"{self.__class__.__name__}(name='{self.name}', ...)"

627

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 )

648

649 @ndarray_copy_enable(False)

650 def __hash__(self) -> int:

651 """

652 Compute the hash of the continuous signal.

653

654 Returns

655 -------

656 :class:`int`

657 Object hash.

658 """

659

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 )

670

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`.

675

676 Parameters

677 ----------

678 x

679 Independent domain variable :math:`x`.

680

681 Returns

682 -------

683 :class:`numpy.ndarray`

684 Variable :math:`y` range value.

685

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..., \

709 47.5535392..., 60. ])

710 """

711

712 if isinstance(x, slice):

713 return self._range[x]

714

715 return self.function(x)

716

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`.

721

722 Parameters

723 ----------

724 x

725 Independent domain variable :math:`x`.

726 y

727 Corresponding range variable :math:`y`.

728

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 """

784

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)

790

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]

796

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])

804

805 self._function = None # Invalidate the underlying continuous function.

806

807 def __contains__(self, x: ArrayLike | slice) -> bool:

808 """

809 Determine whether the continuous signal contains the specified

810 independent domain variable :math:`x`.

811

812 Parameters

813 ----------

814 x

815 Independent domain variable :math:`x`.

816

817 Returns

818 -------

819 :class:`bool`

820 Whether :math:`x` domain value is contained.

821

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 """

833

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 )

846

847 @ndarray_copy_enable(False)

848 def __eq__(self, other: object) -> bool:

849 """

850 Determine whether the continuous signal equals the specified object.

851

852 Parameters

853 ----------

854 other

855 Object to determine for equality with the continuous signal.

856

857 Returns

858 -------

859 :class:`bool`

860 Whether the specified object is equal to the continuous signal.

861

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 """

880

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 )

894

895 return False

896

897 def __ne__(self, other: object) -> bool:

898 """

899 Determine whether the continuous signal is not equal to the specified

900 other object.

901

902 Parameters

903 ----------

904 other

905 Object to determine whether it is not equal to the continuous signal.

906

907 Returns

908 -------

909 :class:`bool`

910 Whether the specified object is not equal to the continuous

911 signal.

912

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 """

931

932 return not (self == other)

933

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.

943

944 This private method modifies the domain values in-place, replacing NaN

945 values according to the chosen filling strategy.

946

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 """

956

957 self.domain = fill_nan(self._domain, method, default)

958

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.

968

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.

976

977 Returns

978 -------

979 :class:`colour.continuous.Signal`

980 NaNs filled continuous signal in corresponding range :math:`y`

981 variable.

982 """

983

984 self.range = fill_nan(self._range, method, default)

985

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`.

995

996 The operation can be performed either on a copy of the signal or

997 in-place.

998

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``.

1011

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.

1018

1019 Examples

1020 --------

1021 Adding a single *numeric* variable:

1022

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.]]

1047

1048 Adding an `ArrayLike` variable:

1049

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.]]

1062

1063 Adding a :class:`colour.continuous.Signal` class:

1064

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 """

1078

1079 operator, ioperator = {

1080 "+": (add, iadd),

1081 "-": (sub, isub),

1082 "*": (mul, imul),

1083 "/": (truediv, itruediv),

1084 "**": (pow, ipow),

1085 }[operation]

1086

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)

1094

1095 return self

1096

1097 return ioperator(self.copy(), a)

1098

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.

1108

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.

1120

1121 Returns

1122 -------

1123 :class:`tuple`

1124 Independent domain variable :math:`x` and corresponding range

1125 variable :math:`y` unpacked for continuous signal instantiation.

1126

1127 Examples

1128 --------

1129 Unpacking using implicit *domain*:

1130

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.]

1137

1138 Unpacking using explicit *domain*:

1139

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.]

1146

1147 Unpacking using a *dict*:

1148

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.]

1154

1155 Unpacking using a *Pandas* :class:`pandas.Series`:

1156

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.]

1168

1169 Unpacking using a :class:`colour.continuous.Signal` class:

1170

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 """

1177

1178 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

1179

1180 domain_unpacked: NDArrayFloat = np.array([])

1181 range_unpacked: NDArrayFloat = np.array([])

1182

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 )

1194

1195 attest(data_array.ndim == 1, 'User "data" must be 1-dimensional!')

1196

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)

1208

1209 if domain is not None:

1210 if isinstance(domain, KeysView):

1211 domain = list(domain)

1212

1213 domain_array = as_float_array(domain, dtype)

1214

1215 attest(

1216 len(domain_array) == len(range_unpacked),

1217 'User "domain" length is not compatible with unpacked "range"!',

1218 )

1219

1220 domain_unpacked = domain_array

1221

1222 if range_unpacked is not None:

1223 range_unpacked = as_float_array(range_unpacked, dtype)

1224

1225 return ndarray_copy(domain_unpacked), ndarray_copy(range_unpacked)

1226

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.

1235

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.

1243

1244 Returns

1245 -------

1246 :class:`colour.continuous.Signal`

1247 Continuous signal with NaN values filled.

1248

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 """

1289

1290 method = validate_method(method, ("Interpolation", "Constant"))

1291

1292 self._fill_domain_nan(method, default)

1293 self._fill_range_nan(method, default)

1294

1295 return self

1296

1297 @required("Pandas")

1298 def to_series(self) -> Series:

1299 """

1300 Convert the continuous signal to a *Pandas* :class:`pandas.Series`

1301 class instance.

1302

1303 Returns

1304 -------

1305 :class:`pandas.Series`

1306 Continuous signal as a *Pandas* :class:`pandas.Series` class

1307 instance.

1308

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 """

1327

1328 return Series(data=self._range, index=self._domain, name=self.name)