Coverage for src / chebpy / chebyshev.py: 100%
134 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-22 21:33 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-22 21:33 +0000
1"""Immutable representation of Chebyshev polynomials.
3This module provides a class for the immutable representation of Chebyshev
4polynomials and various factory functions to construct such polynomials.
5"""
7import warnings
8from collections.abc import Callable
9from typing import Any, TypeAlias
11import matplotlib.pyplot as plt
12import numpy as np
13import numpy.polynomial.chebyshev as cheb
14from matplotlib.axes import Axes
16from .algorithms import coeffs2vals2, standard_chop, vals2coeffs2
17from .settings import _preferences as prefs
19# Type aliases
20ArrayLike: TypeAlias = list[float] | tuple[float, ...] | np.ndarray
21DomainLike: TypeAlias = tuple[float, float] | list[float] | np.ndarray
22ScalarLike: TypeAlias = int | float | complex
25class ChebyshevPolynomial(cheb.Chebyshev):
26 """Immutable representation of a Chebyshev polynomial.
28 This class represents a Chebyshev polynomial using its coefficients in the
29 Chebyshev basis. The polynomial is defined on a specific domain.
31 Attributes:
32 coef (np.ndarray): The coefficients of the Chebyshev polynomial.
33 domain (np.ndarray): The domain on which the polynomial is defined.
34 window (np.ndarray): The window on which the polynomial is mapped. Please use [-1, +1]
35 symbol (str): Symbol used to represent the independent variable.
36 """
38 def __init__(
39 self, coef: ArrayLike, domain: DomainLike | None = None, window: DomainLike | None = None, symbol: str = "x"
40 ) -> None:
41 """Initialize a ChebyshevPolynomial object.
43 Args:
44 coef: Chebyshev coefficients in order of increasing degree.
45 domain: Domain to use. The interval [domain[0], domain[1]] is mapped
46 to the interval [window[0], window[1]] by shifting and scaling.
47 If None, the default domain [-1, 1] is used.
48 window: Window to use. The interval [domain[0], domain[1]] is mapped
49 to the interval [window[0], window[1]] by shifting and scaling.
50 If None, the default window [-1, 1] is used.
51 symbol: Symbol used to represent the independent variable in string
52 representations of the polynomial expression. Default is 'x'.
54 Examples:
55 >>> import numpy as np
56 >>> p = ChebyshevPolynomial([1, 2, 3])
57 >>> p.coef.tolist()
58 [1.0, 2.0, 3.0]
59 >>> p.domain.tolist()
60 [-1.0, 1.0]
61 """
62 if window is None:
63 window = np.array([-1, 1])
65 super().__init__(coef, domain, window=window, symbol=symbol)
67 def copy(self) -> "ChebyshevPolynomial":
68 """Create a copy of the ChebyshevPolynomial object.
70 Returns:
71 A new ChebyshevPolynomial object with the same attributes.
72 """
73 return ChebyshevPolynomial(coef=self.coef.copy(), domain=self.domain.copy(), symbol=self.symbol)
75 def real(self) -> "ChebyshevPolynomial":
76 """Return the real part of the polynomial.
78 Returns:
79 ChebyshevPolynomial: A new polynomial with the real part of the coefficients
80 if the polynomial is complex, otherwise the original polynomial.
81 """
82 if self.iscomplex:
83 return ChebyshevPolynomial(coef=np.real(self.coef), domain=self.domain, symbol=f"{self.symbol}")
84 else:
85 return self
87 def imag(self) -> "ChebyshevPolynomial":
88 """Return the imaginary part of the polynomial.
90 Returns:
91 ChebyshevPolynomial: A new polynomial with the imaginary part of the coefficients
92 if the polynomial is complex, otherwise the original polynomial.
93 """
94 if self.iscomplex:
95 return ChebyshevPolynomial(coef=np.imag(self.coef), domain=self.domain, symbol=f"{self.symbol}")
96 else:
97 return self
99 def __call__(self, arg: ScalarLike | ArrayLike) -> ScalarLike | np.ndarray:
100 """Evaluate the polynomial at points x.
102 Args:
103 arg: Points at which to evaluate the polynomial. Can be a scalar or array-like.
105 Returns:
106 If arg is a scalar, returns a scalar value.
107 If arg is an array, returns an array of values.
109 Examples:
110 >>> import numpy as np
111 >>> p = ChebyshevPolynomial([1])
112 >>> float(p(0))
113 1.0
114 >>> float(p(1))
115 1.0
116 """
117 # If the input is a scalar, directly evaluate the polynomial
118 if np.isscalar(arg):
119 # Map the input to the window
120 mapped_arg = np.asarray(arg)
121 mapped_arg = (mapped_arg - self.domain[0]) / (self.domain[1] - self.domain[0]) * (
122 self.window[1] - self.window[0]
123 ) + self.window[0]
125 # Evaluate the polynomial using the chebval function
126 return cheb.chebval(mapped_arg, self.coef)
128 # For array inputs, call the parent class's __call__ method
129 return super().__call__(arg)
131 @property
132 def iscomplex(self) -> bool:
133 """Determine whether the polynomial has complex coefficients.
135 Returns:
136 bool: True if the polynomial has complex coefficients, False otherwise.
137 """
138 return np.iscomplexobj(self.coef)
140 @property
141 def size(self) -> int:
142 """Return the size of the polynomial (number of coefficients).
144 Returns:
145 int: The number of coefficients in the polynomial.
146 """
147 return self.coef.size
149 @property
150 def isempty(self) -> bool:
151 """Return True if the polynomial is empty (has no coefficients).
153 Returns:
154 bool: True if the polynomial has no coefficients, False otherwise.
155 """
156 return self.size == 0
158 @property
159 def isconst(self) -> bool:
160 """Return True if the polynomial represents a constant (has only one coefficient).
162 Returns:
163 bool: True if the polynomial has only one coefficient, False otherwise.
164 """
165 return self.size == 1
167 @property
168 def vscale(self) -> float:
169 """Estimate the vertical scale of the polynomial.
171 The vertical scale is the maximum absolute value of the polynomial
172 evaluated at Chebyshev points.
174 Returns:
175 float: The maximum absolute value of the polynomial at Chebyshev points.
176 """
177 return float(np.abs(self.values).max())
179 def sum(self) -> float:
180 """Return the definite integral of the polynomial over its domain [a, b].
182 Computes the definite integral of the polynomial over its domain using
183 numpy.polynomial.chebyshev tools with correct domain/window logic.
185 Returns:
186 float: The definite integral of the polynomial over its domain.
187 """
188 if self.isempty: # pragma: no cover
189 return 0.0
191 a, b = self.domain
192 ch = ChebyshevPolynomial(self.coef, domain=self.domain) # window = [-1, 1] by default
193 integral = ch.integ()
194 return float(integral(b) - integral(a))
196 def plot(self, ax: Axes | None = None, n: int | None = None, **kwds: Any) -> Axes:
197 """Plot the Chebyshev polynomial over its domain.
199 This method plots the Chebyshev polynomial over its domain using matplotlib.
200 For complex-valued polynomials, it plots the real part against the imaginary part.
202 Args:
203 ax: The axes on which to plot. If None, a new axes will be created.
204 n: Number of points to use for plotting. If None, uses the value from preferences.
205 **kwds: Additional keyword arguments to pass to matplotlib's plot function.
207 Returns:
208 The axes on which the plot was created.
209 """
210 ax = ax or plt.gca()
211 n = n if n is not None else prefs.N_plot
212 xx = np.linspace(self.domain[0], self.domain[1], n)
213 ff = self(xx)
214 if self.iscomplex:
215 ax.plot(np.real(ff), np.imag(ff), **kwds)
216 ax.set_xlabel(kwds.pop("ylabel", "real"))
217 ax.set_ylabel(kwds.pop("xlabel", "imag"))
218 else:
219 ax.plot(xx, ff, **kwds)
220 return ax
222 def diff(self) -> "ChebyshevPolynomial":
223 """Return the derivative as a new ChebyshevPolynomial.
225 Computes the first derivative of the polynomial with respect to its variable.
227 Returns:
228 ChebyshevPolynomial: The derivative of the polynomial.
229 """
230 # Get the coefficients of the derivative
231 deriv_coef = cheb.chebder(self.coef, m=1)
232 return ChebyshevPolynomial(coef=deriv_coef, domain=self.domain, symbol=f"{self.symbol}")
234 def cumsum(self) -> "ChebyshevPolynomial":
235 """Return the antiderivative as a new ChebyshevPolynomial.
237 Computes the first antiderivative of the polynomial with respect to its variable.
238 The antiderivative is calculated with the lower bound set to the lower bound of the domain
239 and the integration constant set to 0.
241 Returns:
242 ChebyshevPolynomial: The antiderivative of the polynomial.
243 """
244 # Get the coefficients of the antiderivative
245 integ_coef = cheb.chebint(self.coef, m=1, lbnd=self.domain[0], k=0)
246 return ChebyshevPolynomial(coef=integ_coef, domain=self.domain, symbol=f"{self.symbol}")
248 @property
249 def values(self) -> np.ndarray:
250 """Get function values at Chebyshev points.
252 Computes the values of the polynomial at Chebyshev points of the second kind
253 using the coeffs2vals2 algorithm.
255 Returns:
256 np.ndarray: Function values at Chebyshev points.
257 """
258 return coeffs2vals2(self.coef)
260 def prolong(self, n: int) -> "ChebyshevPolynomial":
261 """Return a ChebyshevPolynomial of length n.
263 Creates a new ChebyshevPolynomial with a specified number of coefficients.
265 Args:
266 n: The desired number of coefficients.
268 Returns:
269 ChebyshevPolynomial: A new polynomial with n coefficients.
271 Note:
272 If n < self.size, the result is a truncated copy.
273 If n > self.size, the result is zero-padded.
274 If n == self.size, a copy of the original polynomial is returned.
275 In all cases, a deep copy is returned.
276 """
277 m = self.size
278 ak = self.coef
280 if n < m:
281 new_coeffs = ak[:n].copy()
282 elif n > m:
283 new_coeffs = np.concatenate([ak, np.zeros(n - m, dtype=ak.dtype)])
284 else:
285 return self.copy()
287 return ChebyshevPolynomial(new_coeffs, domain=self.domain, symbol=self.symbol)
290def from_coefficients(
291 coef: ArrayLike, domain: DomainLike | None = None, window: DomainLike | None = None, symbol: str = "x"
292) -> ChebyshevPolynomial:
293 """Create a Chebyshev polynomial from its coefficients.
295 Args:
296 coef: Chebyshev coefficients in order of increasing degree.
297 domain: Domain to use. The interval [domain[0], domain[1]] is mapped
298 to the interval [window[0], window[1]] by shifting and scaling.
299 If None, the default domain [-1, 1] is used.
300 window: Window, see domain for its use. If None, the default window
301 [-1, 1] is used.
302 symbol: Symbol used to represent the independent variable in string
303 representations of the polynomial expression. Default is 'x'.
305 Returns:
306 A new Chebyshev polynomial with the given coefficients.
308 Raises:
309 ValueError: If the coefficient array is empty.
311 Examples:
312 >>> import numpy as np
313 >>> p = from_coefficients([3.14])
314 >>> p.coef.tolist()
315 [3.14]
316 >>> float(p(0))
317 3.14
318 """
319 if len(coef) == 0:
320 raise ValueError(coef)
322 return ChebyshevPolynomial(coef, domain, window, symbol)
325def from_values(
326 values: ArrayLike, domain: DomainLike | None = None, window: DomainLike | None = None, symbol: str = "x"
327) -> ChebyshevPolynomial:
328 """Create a Chebyshev polynomial from values at Chebyshev points.
330 Constructs a Chebyshev polynomial that interpolates the given values at
331 Chebyshev points of the second kind.
333 Args:
334 values: Values at Chebyshev points of the second kind.
335 domain: Domain to use. The interval [domain[0], domain[1]] is mapped
336 to the interval [window[0], window[1]] by shifting and scaling.
337 If None, the default domain [-1, 1] is used.
338 window: Window, see domain for its use. If None, the default window
339 [-1, 1] is used.
340 symbol: Symbol used to represent the independent variable in string
341 representations of the polynomial expression. Default is 'x'.
343 Returns:
344 A new Chebyshev polynomial that interpolates the given values.
346 Raises:
347 ValueError: If the values array is empty.
348 """
349 if len(values) == 0:
350 raise ValueError(values)
352 coef = vals2coeffs2(np.asarray(values))
353 return ChebyshevPolynomial(coef, domain, window, symbol)
356def from_roots(
357 roots: ArrayLike, domain: DomainLike | None = None, window: DomainLike | None = None, symbol: str = "x"
358) -> ChebyshevPolynomial:
359 """Create a Chebyshev polynomial from its roots.
361 Constructs a Chebyshev polynomial that has the specified roots.
363 Args:
364 roots: Sequence of root values.
365 domain: Domain to use. The interval [domain[0], domain[1]] is mapped
366 to the interval [window[0], window[1]] by shifting and scaling.
367 If None, the default domain [-1, 1] is used.
368 window: Window, see domain for its use. If None, the default window
369 [-1, 1] is used.
370 symbol: Symbol used to represent the independent variable in string
371 representations of the polynomial expression. Default is 'x'.
373 Returns:
374 A new Chebyshev polynomial with the specified roots.
376 Raises:
377 ValueError: If the roots array is empty.
378 """
379 if len(roots) == 0:
380 raise ValueError(roots)
382 coef = cheb.chebfromroots(roots)
383 return ChebyshevPolynomial(coef, domain, window, symbol)
386def from_constant(
387 c: ScalarLike, domain: DomainLike | None = None, window: DomainLike | None = None, symbol: str = "x"
388) -> ChebyshevPolynomial:
389 """Create a Chebyshev polynomial representing a constant value.
391 Constructs a Chebyshev polynomial of degree 0 that represents a constant value.
393 Args:
394 c: The constant value (must be a scalar).
395 domain: Domain to use. The interval [domain[0], domain[1]] is mapped
396 to the interval [window[0], window[1]] by shifting and scaling.
397 If None, the default domain [-1, 1] is used.
398 window: Window, see domain for its use. If None, the default window
399 [-1, 1] is used.
400 symbol: Symbol used to represent the independent variable in string
401 representations of the polynomial expression. Default is 'x'.
403 Returns:
404 A new Chebyshev polynomial representing the constant value.
406 Raises:
407 ValueError: If the input is not a scalar value.
409 Examples:
410 >>> import numpy as np
411 >>> p = from_constant(3.14)
412 >>> float(p(0))
413 3.14
414 >>> float(p(1))
415 3.14
416 """
417 if not np.isscalar(c):
418 raise ValueError(c)
420 # Convert integer to float to match behavior in other parts of the codebase
421 if isinstance(c, int):
422 c = float(c)
424 return ChebyshevPolynomial([c], domain, window, symbol)
427def from_function(
428 fun: Callable[..., Any],
429 domain: DomainLike | None = None,
430 window: DomainLike | None = None,
431 symbol: str = "x",
432 n: int | None = None,
433) -> ChebyshevPolynomial:
434 """Create a Chebyshev polynomial from a callable function.
436 Constructs a Chebyshev polynomial that approximates the given function.
437 If n is provided, uses a fixed number of degrees of freedom.
438 If n is None, uses an adaptive algorithm to determine the appropriate
439 number of degrees of freedom.
441 Args:
442 fun: Callable function to approximate.
443 domain: Domain to use. The interval [domain[0], domain[1]] is mapped
444 to the interval [window[0], window[1]] by shifting and scaling.
445 If None, the default domain [-1, 1] is used.
446 window: Window, see domain for its use. If None, the default window
447 [-1, 1] is used.
448 symbol: Symbol used to represent the independent variable in string
449 representations of the polynomial expression. Default is 'x'.
450 n: Number of degrees of freedom to use. If None, uses an adaptive algorithm.
452 Returns:
453 A new Chebyshev polynomial that approximates the given function.
454 """
455 from .algorithms import chebpts2, vals2coeffs2
457 domain_arr = np.array([-1, 1]) if domain is None else np.array(domain)
459 # Create a wrapper function that maps points from [-1, 1] to the custom domain
460 def mapped_fun(x: np.ndarray) -> np.ndarray:
461 # Map x from [-1, 1] to the custom domain
462 a, b = domain_arr
463 mapped_x = 0.5 * (b - a) * (x + 1) + a
464 return np.asarray(fun(mapped_x))
466 if n is None:
467 # Use adaptive algorithm
468 hscale = (domain_arr[1] - domain_arr[0]) / 2
469 coeffs = __adaptive(ChebyshevPolynomial, mapped_fun, hscale=hscale)
470 else:
471 # Use fixed number of degrees of freedom
472 points = chebpts2(n)
473 values = mapped_fun(points)
474 coeffs = vals2coeffs2(values)
476 return ChebyshevPolynomial(coeffs, domain, window, symbol)
479def __adaptive(cls: type, fun: Callable[..., Any], hscale: float = 1, maxpow2: int | None = None) -> np.ndarray:
480 """Adaptively determine the number of points needed to represent a function.
482 This function implements an adaptive algorithm to determine the appropriate
483 number of points needed to represent a function to a specified tolerance.
484 It cycles over powers of two, evaluating the function at Chebyshev points
485 and checking if the resulting coefficients can be truncated.
487 Args:
488 cls: The class that provides the _chebpts and _vals2coeffs methods.
489 fun (callable): The function to be approximated.
490 hscale (float, optional): Scale factor for the tolerance. Defaults to 1.
491 maxpow2 (int, optional): Maximum power of 2 to try. If None, uses the
492 value from preferences.
494 Returns:
495 numpy.ndarray: Coefficients of the Chebyshev series representing the function.
497 Warns:
498 UserWarning: If the constructor does not converge within the maximum
499 number of iterations.
500 """
501 minpow2 = 4 # 17 points
502 maxpow2 = maxpow2 if maxpow2 is not None else prefs.maxpow2
503 for k in range(minpow2, max(minpow2, maxpow2) + 1):
504 n = 2**k + 1
505 points = cheb.chebpts2(n)
506 values = fun(points)
507 coeffs = vals2coeffs2(values)
508 eps = prefs.eps
509 tol = eps * max(hscale, 1) # scale (decrease) tolerance by hscale
510 chplen = standard_chop(coeffs, tol=tol)
511 if chplen < coeffs.size:
512 coeffs = coeffs[:chplen]
513 break
514 if k == maxpow2:
515 warnings.warn(f"The {cls.__name__} constructor did not converge: using {n} points", stacklevel=2)
516 break
517 return coeffs