Coverage for continuous/signal.py: 61%

202 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-16 22:49 +1300

1""" 

2Signal 

3====== 

4 

5Define support for continuous signal representation and manipulation. 

6 

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. 

10 

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

12""" 

13 

14from __future__ import annotations 

15 

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 

20 

21import numpy as np 

22 

23from colour.algebra import Extrapolator, KernelInterpolator 

24from colour.constants import DTYPE_FLOAT_DEFAULT 

25from colour.continuous import AbstractContinuousFunction 

26 

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 ) 

39 

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 

59 

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 

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 

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

70947.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)