Coverage for algebra/extrapolation.py: 58%

1"""

2 Extrapolation

3=============

4

5 Define classes for extrapolating one-dimensional functions beyond their

6 original domain.

7

8- :class:`colour.Extrapolator`: Extrapolate 1-D functions using various

9 methods to extend function values beyond the original interpolation range.

10

11 References

12----------

13- :cite:`Sastanina` : sastanin. (n.d.). How to make scipy.interpolate give an

14 extrapolated result beyond the input range? Retrieved August 8, 2014, from

15 http://stackoverflow.com/a/2745496/931625

16- :cite:`Westland2012i` : Westland, S., Ripamonti, C., & Cheung, V. (2012).

17 Extrapolation Methods. In Computational Colour Science Using MATLAB (2nd

18 ed., p. 38). ISBN:978-0-470-66569-5

19"""

20

21 from __future__ import annotations

22

23 import typing

24

25 import numpy as np

26

27 from colour.algebra import NullInterpolator, sdiv, sdiv_mode

28 from colour.constants import DTYPE_FLOAT_DEFAULT

29

30 if typing.TYPE_CHECKING:

31 from colour.hints import (

32 Any,

33 ArrayLike,

34 DTypeReal,

35 Literal,

36 NDArrayFloat,

37 ProtocolInterpolator,

38 Real,

39 Type,

40 )

41

42 from colour.utilities import (

43 as_float,

44 as_float_array,

45 attest,

46 is_numeric,

47 optional,

48 validate_method,

49)

50

51 __author__ = "Colour Developers"

52 __copyright__ = "Copyright 2013 Colour Developers"

53 __license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"

54 __maintainer__ = "Colour Developers"

55 __email__ = "colour-developers@colour-science.org"

56 __status__ = "Production"

57

58 __all__ = [

59 "Extrapolator",

60]

61

62

63 class Extrapolator:

64 """

65 Extrapolate 1-D function values beyond the specified interpolator's

66 domain boundaries.

67

68 The :class:`colour.Extrapolator` class wraps a specified *Colour* or

69 *scipy* interpolator instance with compatible signature to provide

70 controlled extrapolation behaviour. Two extrapolation methods are

71 supported:

72

73 - *Linear*: Extrapolate values linearly using the slope defined by

74 boundary points (xi[0], xi[1]) for x < xi[0] and (xi[-1], xi[-2])

75 for x > xi[-1].

76 - *Constant*: Assign boundary values xi[0] for x < xi[0] and xi[-1]

77 for x > xi[-1].

78

79 Specifying *left* and *right* arguments overrides the chosen

80 extrapolation method, assigning these values to points outside the

81 interpolator's domain.

82

83 Parameters

84 ----------

85 interpolator

86 Interpolator object.

87 method

88 Extrapolation method.

89 left

90 Value to return for x < xi[0].

91 right

92 Value to return for x > xi[-1].

93 dtype

94 Data type used for internal conversions.

95

96 Methods

97 -------

98 - :meth:`~colour.Extrapolator.__init__`

99 - :meth:`~colour.Extrapolator.__class__`

100

101 Notes

102 -----

103 - The interpolator must define ``x`` and ``y`` properties.

104

105 References

106 ----------

107 :cite:`Sastanina`, :cite:`Westland2012i`

108

109 Examples

110 --------

111 Extrapolating a single numeric variable:

112

113 >>> from colour.algebra import LinearInterpolator

114 >>> x = np.array([3, 4, 5])

115 >>> y = np.array([1, 2, 3])

116 >>> interpolator = LinearInterpolator(x, y)

117 >>> extrapolator = Extrapolator(interpolator)

118 >>> extrapolator(1)

119 -1.0

120

121 Extrapolating an `ArrayLike` variable:

122

123 >>> extrapolator(np.array([6, 7, 8]))

124 array([ 4., 5., 6.])

125

126 Using the *Constant* extrapolation method:

127

128 >>> x = np.array([3, 4, 5])

129 >>> y = np.array([1, 2, 3])

130 >>> interpolator = LinearInterpolator(x, y)

131 >>> extrapolator = Extrapolator(interpolator, method="Constant")

132 >>> extrapolator(np.array([0.1, 0.2, 8, 9]))

133 array([ 1., 1., 3., 3.])

134

135 Using defined *left* boundary and *Constant* extrapolation method:

136

137 >>> x = np.array([3, 4, 5])

138 >>> y = np.array([1, 2, 3])

139 >>> interpolator = LinearInterpolator(x, y)

140 >>> extrapolator = Extrapolator(interpolator, method="Constant", left=0)

141 >>> extrapolator(np.array([0.1, 0.2, 8, 9]))

142 array([ 0., 0., 3., 3.])

143 """

144

145 def __init__(

146 self,

147 interpolator: ProtocolInterpolator | None = None,

148 method: Literal["Linear", "Constant"] | str = "Linear",

149 left: Real | None = None,

150 right: Real | None = None,

151 dtype: Type[DTypeReal] | None = None,

152 *args: Any, # noqa: ARG002

153 **kwargs: Any, # noqa: ARG002

154 ) -> None:

155 dtype = optional(dtype, DTYPE_FLOAT_DEFAULT)

156

157 self._interpolator: ProtocolInterpolator = NullInterpolator(

158 np.array([-np.inf, np.inf]), np.array([-np.inf, np.inf])

159 )

160 self.interpolator = optional(interpolator, self._interpolator)

161 self._method: Literal["Linear", "Constant"] | str = "Linear"

162 self.method = optional(method, self._method)

163 self._right: Real | None = None

164 self.right = right

165 self._left: Real | None = None

166 self.left = left

167

168 self._dtype: Type[DTypeReal] = dtype

169

170 @property

171 def interpolator(self) -> ProtocolInterpolator:

172 """

173 Getter and setter for the interpolator.

174

175 The interpolator must implement the interpolator protocol with an

176 `x` attribute containing the independent variable data.

177

178 Parameters

179 ----------

180 value

181 Value to set the interpolator instance implementing the required

182 protocol with an `x` attribute for wavelength or frequency values

183 with.

184

185 Returns

186 -------

187 ProtocolInterpolator

188 Interpolator instance implementing the required protocol with

189 an `x` attribute for wavelength or frequency values.

190 """

191

192 return self._interpolator

193

194 @interpolator.setter

195 def interpolator(self, value: ProtocolInterpolator) -> None:

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

197

198 attest(

199 hasattr(value, "x"),

200 f'"{value}" interpolator has no "x" attribute!',

201 )

202

203 attest(

204 hasattr(value, "y"),

205 f'"{value}" interpolator has no "y" attribute!',

206 )

207

208 self._interpolator = value

209

210 @property

211 def method(self) -> Literal["Linear", "Constant"] | str:

212 """

213 Getter and setter for the extrapolation method for the interpolator.

214

215 This property controls the behaviour of the interpolator when

216 extrapolating values outside the interpolation domain. The method

217 determines how values are computed beyond the specified boundaries.

218

219 Parameters

220 ----------

221 value

222 Value to set the extrapolation method to use, either ``'Linear'``

223 for linear extrapolation or ``'Constant'`` for constant value

224 extrapolation at the boundaries.

225

226 Returns

227 -------

228 :class:`str`

229 Extrapolation method to use.

230 """

231

232 return self._method

233

234 @method.setter

235 def method(self, value: Literal["Linear", "Constant"] | str) -> None:

236 """Setter for the **self.method** property."""

237

238 attest(

239 isinstance(value, str),

240 f'"method" property: "{value}" type is not "str"!',

241 )

242

243 value = validate_method(value, ("Linear", "Constant"))

244

245 self._method = value

246

247 @property

248 def left(self) -> Real | None:

249 """

250 Getter and setter for the left boundary value.

251

252 Specifies the value to return when evaluating the interpolant at

253 points beyond the leftmost data point ( x < xi[0]).

254

255 Parameters

256 ----------

257 value

258 Value to return for x < xi[0] for extrapolation beyond the

259 leftmost data point.

260

261 Returns

262 -------

263 Real or :py:data:`None`

264 Value to return for x < xi[0] for extrapolation beyond the

265 leftmost data point.

266 """

267

268 return self._left

269

270 @left.setter

271 def left(self, value: Real | None) -> None:

272 """Setter for the **self.left** property."""

273

274 if value is not None:

275 attest(

276 is_numeric(value),

277 f'"left" property: "{value}" is not a "number"!',

278 )

279

280 self._left = value

281

282 @property

283 def right(self) -> Real | None:

284 """

285 Getter and setter for the right boundary value.

286

287 Specifies the value to return when evaluating the interpolant at

288 points beyond the rightmost data point (x > xi[-1]).

289

290 Parameters

291 ----------

292 value

293 Value to return for x > xi[-1] for extrapolation beyond the

294 rightmost data point.

295

296 Returns

297 -------

298 :class:`numbers.Real` or :py:data:`None`

299 Value to return for x > xi[-1] for extrapolation beyond the

300 rightmost data point.

301 """

302

303 return self._right

304

305 @right.setter

306 def right(self, value: Real | None) -> None:

307 """Setter for the **self.right** property."""

308

309 if value is not None:

310 attest(

311 is_numeric(value),

312 f'"right" property: "{value}" is not a "number"!',

313 )

314

315 self._right = value

316

317 def __call__(self, x: ArrayLike) -> NDArrayFloat:

318 """

319 Evaluate the extrapolator at specified point(s).

320

321 Parameters

322 ----------

323 x

324 Point(s) to evaluate the extrapolator at.

325

326 Returns

327 -------

328 :class:`numpy.ndarray`

329 Extrapolated point value(s).

330 """

331

332 x = as_float_array(x)

333

334 xe = self._evaluate(x)

335

336 return as_float(xe)

337

338 def _evaluate(self, x: NDArrayFloat) -> NDArrayFloat:

339 """

340 Perform the extrapolating evaluation at specified points.

341

342 Parameters

343 ----------

344 x

345 Points to evaluate the extrapolator at.

346

347 Returns

348 -------

349 :class:`numpy.ndarray`

350 Extrapolated point values.

351 """

352

353 xi = self._interpolator.x

354 yi = self._interpolator.y

355

356 y = np.empty_like(x)

357

358 if self._method == "linear":

359 with sdiv_mode():

360 y[x < xi[0]] = yi[0] + (x[x < xi[0]] - xi[0]) * sdiv(

361 yi[1] - yi[0], xi[1] - xi[0]

362 )

363 y[x > xi[-1]] = yi[-1] + (x[x > xi[-1]] - xi[-1]) * sdiv(

364 yi[-1] - yi[-2], xi[-1] - xi[-2]

365 )

366 elif self._method == "constant":

367 y[x < xi[0]] = yi[0]

368 y[x > xi[-1]] = yi[-1]

369

370 if self._left is not None:

371 y[x < xi[0]] = self._left

372 if self._right is not None:

373 y[x > xi[-1]] = self._right

374

375 in_range = np.logical_and(x >= xi[0], x <= xi[-1])

376 y[in_range] = self._interpolator(x[in_range])

377

378 return y