Coverage for algebra/extrapolation.py: 58%

79 statements  

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

1""" 

2Extrapolation 

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

4 

5Define classes for extrapolating one-dimensional functions beyond their 

6original domain. 

7 

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

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

10 

11References 

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 

21from __future__ import annotations 

22 

23import typing 

24 

25import numpy as np 

26 

27from colour.algebra import NullInterpolator, sdiv, sdiv_mode 

28from colour.constants import DTYPE_FLOAT_DEFAULT 

29 

30if 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 

42from 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 

63class 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