Coverage for src/chebpy/chebtech.py: 100%

252 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-22 21:50 +0000

1"""Implementation of Chebyshev polynomial technology for function approximation. 

2 

3This module provides the Chebtech class, which is an abstract base class for 

4representing functions using Chebyshev polynomial expansions. It serves as the 

5foundation for the Chebtech class, which uses Chebyshev points of the second kind. 

6 

7The Chebtech classes implement core functionality for working with Chebyshev 

8expansions, including: 

9- Function evaluation using Clenshaw's algorithm or barycentric interpolation 

10- Algebraic operations (addition, multiplication, etc.) 

11- Calculus operations (differentiation, integration, etc.) 

12- Rootfinding 

13- Plotting 

14 

15These classes are primarily used internally by higher-level classes like Bndfun 

16and Chebfun, rather than being used directly by end users. 

17""" 

18 

19from abc import ABC 

20from collections.abc import Callable 

21from typing import Any 

22 

23import matplotlib.pyplot as plt 

24import numpy as np 

25 

26from .algorithms import ( 

27 adaptive, 

28 bary, 

29 barywts2, 

30 chebpts2, 

31 clenshaw, 

32 coeffmult, 

33 coeffs2vals2, 

34 newtonroots, 

35 rootsunit, 

36 standard_chop, 

37 vals2coeffs2, 

38) 

39from .decorators import self_empty 

40from .plotting import plotfun, plotfuncoeffs 

41from .settings import _preferences as prefs 

42from .smoothfun import Smoothfun 

43from .utilities import Interval, coerce_list 

44 

45 

46class Chebtech(Smoothfun, ABC): 

47 """Abstract base class serving as the template for Chebtech1 and Chebtech subclasses. 

48 

49 Chebtech objects always work with first-kind coefficients, so much 

50 of the core operational functionality is defined this level. 

51 

52 The user will rarely work with these classes directly so we make 

53 several assumptions regarding input data types. 

54 """ 

55 

56 @classmethod 

57 def initconst(cls, c: Any = None, *, interval: Any = None) -> Any: 

58 """Initialise a Chebtech from a constant c.""" 

59 if not np.isscalar(c): 

60 raise ValueError(c) 

61 if isinstance(c, int): 

62 c = float(c) 

63 return cls(np.array([c]), interval=interval) 

64 

65 @classmethod 

66 def initempty(cls, *, interval: Any = None) -> "Chebtech": 

67 """Initialise an empty Chebtech.""" 

68 return cls(np.array([]), interval=interval) 

69 

70 @classmethod 

71 def initidentity(cls, *, interval: Any = None) -> "Chebtech": 

72 """Chebtech representation of f(x) = x on [-1,1].""" 

73 return cls(np.array([0, 1]), interval=interval) 

74 

75 @classmethod 

76 def initfun(cls, fun: Any = None, n: Any = None, *, interval: Any = None) -> Any: 

77 """Convenience constructor to automatically select the adaptive or fixedlen constructor. 

78 

79 This constructor automatically selects between the adaptive or fixed-length 

80 constructor based on the input arguments passed. 

81 """ 

82 if n is None: 

83 return cls.initfun_adaptive(fun, interval=interval) 

84 else: 

85 return cls.initfun_fixedlen(fun, n, interval=interval) 

86 

87 @classmethod 

88 def initfun_fixedlen(cls, fun: Any = None, n: Any = None, *, interval: Any = None) -> Any: 

89 """Initialise a Chebtech from the callable fun using n degrees of freedom. 

90 

91 This constructor creates a Chebtech representation of the function using 

92 a fixed number of degrees of freedom specified by n. 

93 """ 

94 if n is None: 

95 raise ValueError("n must be specified for fixed-length initialization") # noqa: TRY003 

96 points = cls._chebpts(int(n)) 

97 values = fun(points) 

98 coeffs = vals2coeffs2(values) 

99 return cls(coeffs, interval=interval) 

100 

101 @classmethod 

102 def initfun_adaptive(cls, fun: Any = None, *, interval: Any = None) -> Any: 

103 """Initialise a Chebtech from the callable fun utilising the adaptive constructor. 

104 

105 This constructor uses an adaptive algorithm to determine the appropriate 

106 number of degrees of freedom needed to represent the function. 

107 """ 

108 interval = interval if interval is not None else prefs.domain 

109 interval = Interval(*interval) 

110 coeffs = adaptive(cls, fun, hscale=interval.hscale) 

111 return cls(coeffs, interval=interval) 

112 

113 @classmethod 

114 def initvalues(cls, values: Any = None, *, interval: Any = None) -> Any: 

115 """Initialise a Chebtech from an array of values at Chebyshev points.""" 

116 return cls(cls._vals2coeffs(values), interval=interval) 

117 

118 def __init__(self, coeffs: Any, interval: Any = None) -> None: 

119 """Initialize a Chebtech object. 

120 

121 This method initializes a new Chebtech object with the given coefficients 

122 and interval. If no interval is provided, the default interval from 

123 preferences is used. 

124 

125 Args: 

126 coeffs (array-like): The coefficients of the Chebyshev series. 

127 interval (array-like, optional): The interval on which the function 

128 is defined. Defaults to None, which uses the default interval 

129 from preferences. 

130 """ 

131 interval = interval if interval is not None else prefs.domain 

132 self._coeffs = np.array(coeffs) 

133 self._interval = Interval(*interval) 

134 

135 def __call__(self, x: Any, how: str = "clenshaw") -> Any: 

136 """Evaluate the Chebtech at the given points. 

137 

138 Args: 

139 x: Points at which to evaluate the Chebtech. 

140 how (str, optional): Method to use for evaluation. Either "clenshaw" or "bary". 

141 Defaults to "clenshaw". 

142 

143 Returns: 

144 The values of the Chebtech at the given points. 

145 

146 Raises: 

147 ValueError: If the specified method is not supported. 

148 """ 

149 method: dict[str, Callable[[Any], Any]] = { 

150 "clenshaw": self.__call__clenshaw, 

151 "bary": self.__call__bary, 

152 } 

153 try: 

154 return method[how](x) 

155 except KeyError as err: 

156 raise ValueError(how) from err 

157 

158 def __call__clenshaw(self, x: Any) -> Any: 

159 """Evaluate at *x* using Clenshaw recurrence on the coefficients.""" 

160 return clenshaw(x, self.coeffs) 

161 

162 def __call__bary(self, x: Any) -> Any: 

163 """Evaluate at *x* using the barycentric interpolation formula.""" 

164 fk = self.values() 

165 xk = self._chebpts(fk.size) 

166 vk = self._barywts(fk.size) 

167 return bary(x, fk, xk, vk) 

168 

169 def __repr__(self) -> str: # pragma: no cover 

170 """Return a string representation of the Chebtech. 

171 

172 Returns: 

173 str: A string representation of the Chebtech. 

174 """ 

175 out = f"<{self.__class__.__name__}{{{self.size}}}>" 

176 return out 

177 

178 # ------------ 

179 # properties 

180 # ------------ 

181 @property 

182 def coeffs(self) -> np.ndarray: 

183 """Chebyshev expansion coefficients in the T_k basis.""" 

184 return self._coeffs 

185 

186 @property 

187 def interval(self) -> Interval: 

188 """Interval that Chebtech is mapped to.""" 

189 return self._interval 

190 

191 @property 

192 def size(self) -> int: 

193 """Return the size of the object.""" 

194 return self.coeffs.size 

195 

196 @property 

197 def isempty(self) -> bool: 

198 """Return True if the Chebtech is empty.""" 

199 return self.size == 0 

200 

201 @property 

202 def iscomplex(self) -> bool: 

203 """Determine whether the underlying onefun is complex or real valued.""" 

204 return self._coeffs.dtype == complex 

205 

206 @property 

207 def isconst(self) -> bool: 

208 """Return True if the Chebtech represents a constant.""" 

209 return self.size == 1 

210 

211 @property 

212 @self_empty(0.0) 

213 def vscale(self) -> float: 

214 """Estimate the vertical scale of a Chebtech.""" 

215 return float(np.abs(np.asarray(coerce_list(self.values()))).max()) 

216 

217 # ----------- 

218 # utilities 

219 # ----------- 

220 def copy(self) -> "Chebtech": 

221 """Return a deep copy of the Chebtech.""" 

222 return self.__class__(self.coeffs.copy(), interval=self.interval.copy()) 

223 

224 def imag(self) -> Any: 

225 """Return the imaginary part of the Chebtech. 

226 

227 Returns: 

228 Chebtech: A new Chebtech representing the imaginary part of this Chebtech. 

229 If this Chebtech is real-valued, returns a zero Chebtech. 

230 """ 

231 if self.iscomplex: 

232 return self.__class__(np.imag(self.coeffs), self.interval) 

233 else: 

234 return self.initconst(0, interval=self.interval) 

235 

236 def prolong(self, n: int) -> "Chebtech": 

237 """Return a Chebtech of length n. 

238 

239 Obtained either by truncating if n < self.size or zero-padding if n > self.size. 

240 In all cases a deep copy is returned. 

241 """ 

242 m = self.size 

243 ak = self.coeffs 

244 cls = self.__class__ 

245 if n - m < 0: 

246 out = cls(ak[:n].copy(), interval=self.interval) 

247 elif n - m > 0: 

248 out = cls(np.append(ak, np.zeros(n - m)), interval=self.interval) 

249 else: 

250 out = self.copy() 

251 return out 

252 

253 def real(self) -> "Chebtech": 

254 """Return the real part of the Chebtech. 

255 

256 Returns: 

257 Chebtech: A new Chebtech representing the real part of this Chebtech. 

258 If this Chebtech is already real-valued, returns self. 

259 """ 

260 if self.iscomplex: 

261 return self.__class__(np.real(self.coeffs), self.interval) 

262 else: 

263 return self 

264 

265 def simplify(self) -> "Chebtech": 

266 """Call standard_chop on the coefficients of self. 

267 

268 Returns a Chebtech comprised of a copy of the truncated coefficients. 

269 """ 

270 # coefficients 

271 oldlen = len(self.coeffs) 

272 longself = self.prolong(max(17, oldlen)) 

273 cfs = longself.coeffs 

274 # scale (decrease) tolerance by hscale 

275 tol = prefs.eps * max(self.interval.hscale, 1) 

276 # chop 

277 npts = standard_chop(cfs, tol=tol) 

278 npts = min(oldlen, npts) 

279 # construct 

280 return self.__class__(cfs[:npts].copy(), interval=self.interval) 

281 

282 def values(self) -> np.ndarray: 

283 """Function values at Chebyshev points.""" 

284 return coeffs2vals2(self.coeffs) 

285 

286 # --------- 

287 # algebra 

288 # --------- 

289 @self_empty() 

290 def __add__(self, f: Any) -> Any: 

291 """Add a scalar or another Chebtech to this Chebtech. 

292 

293 Args: 

294 f: A scalar or another Chebtech to add to this Chebtech. 

295 

296 Returns: 

297 Chebtech: A new Chebtech representing the sum. 

298 """ 

299 cls = self.__class__ 

300 if np.isscalar(f): 

301 if np.iscomplexobj(f): 

302 dtype: Any = complex 

303 else: 

304 dtype = self.coeffs.dtype 

305 cfs = np.array(self.coeffs, dtype=dtype) 

306 cfs[0] += f 

307 return cls(cfs, interval=self.interval) 

308 else: 

309 # TODO: is a more general decorator approach better here? 

310 # TODO: for constant Chebtech, convert to constant and call __add__ again 

311 if f.isempty: 

312 return f.copy() 

313 g = self 

314 n, m = g.size, f.size 

315 if n < m: 

316 g = g.prolong(m) 

317 elif m < n: 

318 f = f.prolong(n) 

319 cfs = f.coeffs + g.coeffs 

320 

321 # check for zero output 

322 eps = prefs.eps 

323 tol = 0.5 * eps * max([f.vscale, g.vscale]) 

324 if all(abs(cfs) < tol): 

325 return cls.initconst(0.0, interval=self.interval) 

326 else: 

327 return cls(cfs, interval=self.interval) 

328 

329 @self_empty() 

330 def __div__(self, f: Any) -> Any: 

331 """Divide this Chebtech by a scalar or another Chebtech. 

332 

333 Args: 

334 f: A scalar or another Chebtech to divide this Chebtech by. 

335 

336 Returns: 

337 Chebtech: A new Chebtech representing the quotient. 

338 """ 

339 cls = self.__class__ 

340 if np.isscalar(f): 

341 cfs = 1.0 / np.asarray(f) * self.coeffs 

342 return cls(cfs, interval=self.interval) 

343 else: 

344 # TODO: review with reference to __add__ 

345 if f.isempty: 

346 return f.copy() 

347 return cls.initfun_adaptive(lambda x: self(x) / f(x), interval=self.interval) 

348 

349 __truediv__ = __div__ 

350 

351 @self_empty() 

352 def __mul__(self, g: Any) -> Any: 

353 """Multiply this Chebtech by a scalar or another Chebtech. 

354 

355 Args: 

356 g: A scalar or another Chebtech to multiply this Chebtech by. 

357 

358 Returns: 

359 Chebtech: A new Chebtech representing the product. 

360 """ 

361 cls = self.__class__ 

362 if np.isscalar(g): 

363 cfs = g * self.coeffs 

364 return cls(cfs, interval=self.interval) 

365 else: 

366 # TODO: review with reference to __add__ 

367 if g.isempty: 

368 return g.copy() 

369 f = self 

370 n = f.size + g.size - 1 

371 f = f.prolong(n) 

372 g = g.prolong(n) 

373 cfs = coeffmult(f.coeffs, g.coeffs) 

374 out = cls(cfs, interval=self.interval) 

375 return out 

376 

377 def __neg__(self) -> "Chebtech": 

378 """Return the negative of this Chebtech. 

379 

380 Returns: 

381 Chebtech: A new Chebtech representing the negative of this Chebtech. 

382 """ 

383 coeffs = -self.coeffs 

384 return self.__class__(coeffs, interval=self.interval) 

385 

386 def __pos__(self) -> "Chebtech": 

387 """Return this Chebtech (unary positive). 

388 

389 Returns: 

390 Chebtech: This Chebtech (self). 

391 """ 

392 return self 

393 

394 @self_empty() 

395 def __pow__(self, f: Any) -> Any: 

396 """Raise this Chebtech to a power. 

397 

398 Args: 

399 f: The exponent, which can be a scalar or another Chebtech. 

400 

401 Returns: 

402 Chebtech: A new Chebtech representing this Chebtech raised to the power f. 

403 """ 

404 

405 def powfun(fn: Any, x: Any) -> Any: 

406 if np.isscalar(fn): 

407 return fn 

408 else: 

409 return fn(x) 

410 

411 return self.__class__.initfun_adaptive(lambda x: np.power(self(x), powfun(f, x)), interval=self.interval) 

412 

413 def __rdiv__(self, f: Any) -> Any: 

414 """Divide a scalar by this Chebtech. 

415 

416 This is called when f / self is executed and f is not a Chebtech. 

417 

418 Args: 

419 f: A scalar to be divided by this Chebtech. 

420 

421 Returns: 

422 Chebtech: A new Chebtech representing f divided by this Chebtech. 

423 """ 

424 

425 # Executed when __div__(f, self) fails, which is to say whenever f 

426 # is not a Chebtech. We proceeed on the assumption f is a scalar. 

427 def constfun(x: Any) -> Any: 

428 return 0.0 * x + f 

429 

430 return self.__class__.initfun_adaptive(lambda x: constfun(x) / self(x), interval=self.interval) 

431 

432 __radd__ = __add__ 

433 

434 def __rsub__(self, f: Any) -> Any: 

435 """Subtract this Chebtech from a scalar. 

436 

437 This is called when f - self is executed and f is not a Chebtech. 

438 

439 Args: 

440 f: A scalar from which to subtract this Chebtech. 

441 

442 Returns: 

443 Chebtech: A new Chebtech representing f minus this Chebtech. 

444 """ 

445 return -(self - f) 

446 

447 @self_empty() 

448 def __rpow__(self, f: Any) -> Any: 

449 """Raise a scalar to the power of this Chebtech. 

450 

451 This is called when f ** self is executed and f is not a Chebtech. 

452 

453 Args: 

454 f: A scalar to be raised to the power of this Chebtech. 

455 

456 Returns: 

457 Chebtech: A new Chebtech representing f raised to the power of this Chebtech. 

458 """ 

459 return self.__class__.initfun_adaptive(lambda x: np.power(f, self(x)), interval=self.interval) 

460 

461 __rtruediv__ = __rdiv__ 

462 __rmul__ = __mul__ 

463 

464 def __sub__(self, f: Any) -> Any: 

465 """Subtract a scalar or another Chebtech from this Chebtech. 

466 

467 Args: 

468 f: A scalar or another Chebtech to subtract from this Chebtech. 

469 

470 Returns: 

471 Chebtech: A new Chebtech representing the difference. 

472 """ 

473 return self + (-f) 

474 

475 # ------- 

476 # roots 

477 # ------- 

478 def roots(self, sort: bool | None = None) -> np.ndarray: 

479 """Compute the roots of the Chebtech on [-1,1]. 

480 

481 Uses the coefficients in the associated Chebyshev series approximation. 

482 """ 

483 sort = sort if sort is not None else prefs.sortroots 

484 rts = rootsunit(self.coeffs) 

485 rts = newtonroots(self, rts) 

486 # fix problems with newton for roots that are numerically very close 

487 rts = np.clip(rts, -1, 1) # if newton roots are just outside [-1,1] 

488 rts = rts if not sort else np.sort(rts) 

489 return rts 

490 

491 # ---------- 

492 # calculus 

493 # ---------- 

494 # Note that function returns 0 for an empty Chebtech object; this is 

495 # consistent with numpy, which returns zero for the sum of an empty array 

496 @self_empty(resultif=0.0) 

497 def sum(self) -> Any: 

498 """Definite integral of a Chebtech on the interval [-1,1].""" 

499 if self.isconst: 

500 out = 2.0 * self(0.0) 

501 else: 

502 ak = self.coeffs.copy() 

503 ak[1::2] = 0 

504 kk = np.arange(2, ak.size) 

505 ii = np.append([2, 0], 2 / (1 - kk**2)) 

506 out = (ak * ii).sum() 

507 return out 

508 

509 @self_empty() 

510 def cumsum(self) -> "Chebtech": 

511 """Return a Chebtech object representing the indefinite integral. 

512 

513 Computes the indefinite integral of a Chebtech on the interval [-1,1]. 

514 The constant term is chosen such that F(-1) = 0. 

515 """ 

516 n = self.size 

517 ak = np.append(self.coeffs, [0, 0]) 

518 bk = np.zeros(n + 1, dtype=self.coeffs.dtype) 

519 rk = np.arange(2, n + 1) 

520 bk[2:] = 0.5 * (ak[1:n] - ak[3:]) / rk 

521 bk[1] = ak[0] - 0.5 * ak[2] 

522 vk = np.ones(n) 

523 vk[1::2] = -1 

524 bk[0] = (vk * bk[1:]).sum() 

525 out = self.__class__(bk, interval=self.interval) 

526 return out 

527 

528 @self_empty() 

529 def diff(self) -> "Chebtech": 

530 """Return a Chebtech object representing the derivative. 

531 

532 Computes the derivative of a Chebtech on the interval [-1,1]. 

533 """ 

534 if self.isconst: 

535 out = self.__class__(np.array([0.0]), interval=self.interval) 

536 else: 

537 n = self.size 

538 ak = self.coeffs 

539 zk = np.zeros(n - 1, dtype=self.coeffs.dtype) 

540 wk = 2 * np.arange(1, n) 

541 vk = wk * ak[1:] 

542 zk[-1::-2] = vk[-1::-2].cumsum() 

543 zk[-2::-2] = vk[-2::-2].cumsum() 

544 zk[0] = 0.5 * zk[0] 

545 out = self.__class__(zk, interval=self.interval) 

546 return out 

547 

548 @staticmethod 

549 def _chebpts(n: int) -> np.ndarray: 

550 """Return n Chebyshev points of the second-kind.""" 

551 return chebpts2(n) 

552 

553 @staticmethod 

554 def _barywts(n: int) -> np.ndarray: 

555 """Barycentric weights for Chebyshev points of 2nd kind.""" 

556 return barywts2(n) 

557 

558 @staticmethod 

559 def _vals2coeffs(vals: Any) -> np.ndarray: 

560 """Map function values at Chebyshev points of 2nd kind. 

561 

562 Converts values at Chebyshev points of 2nd kind to first-kind Chebyshev polynomial coefficients. 

563 """ 

564 return vals2coeffs2(vals) 

565 

566 @staticmethod 

567 def _coeffs2vals(coeffs: Any) -> np.ndarray: 

568 """Map first-kind Chebyshev polynomial coefficients. 

569 

570 Converts first-kind Chebyshev polynomial coefficients to function values at Chebyshev points of 2nd kind. 

571 """ 

572 return coeffs2vals2(coeffs) 

573 

574 # ---------- 

575 # plotting 

576 # ---------- 

577 def plot(self, ax: Any = None, **kwargs: Any) -> Any: 

578 """Plot the Chebtech on the interval [-1, 1]. 

579 

580 Args: 

581 ax (matplotlib.axes.Axes, optional): The axes on which to plot. Defaults to None. 

582 **kwargs: Additional keyword arguments to pass to the plot function. 

583 

584 Returns: 

585 matplotlib.lines.Line2D: The line object created by the plot. 

586 """ 

587 return plotfun(self, (-1, 1), ax=ax, **kwargs) 

588 

589 def plotcoeffs(self, ax: Any = None, **kwargs: Any) -> Any: 

590 """Plot the absolute values of the Chebyshev coefficients. 

591 

592 Args: 

593 ax (matplotlib.axes.Axes, optional): The axes on which to plot. Defaults to None. 

594 **kwargs: Additional keyword arguments to pass to the plot function. 

595 

596 Returns: 

597 matplotlib.lines.Line2D: The line object created by the plot. 

598 """ 

599 ax = ax or plt.gca() 

600 return plotfuncoeffs(abs(self.coeffs), ax=ax, **kwargs)