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

320 statements  

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

1"""Trigonometric (Fourier) technology for periodic function approximation. 

2 

3This module provides the Trigtech class, which represents smooth periodic functions 

4on [-1, 1] using truncated Fourier series. It is the trigonometric analogue of 

5Chebtech and sits in the same class hierarchy: 

6 

7 Onefun → Smoothfun → Trigtech 

8 

9Coefficient storage convention (NumPy-native / FFT order) 

10---------------------------------------------------------- 

11Given n equispaced sample points x_j = -1 + 2j/n (j = 0, …, n-1), the stored 

12coefficients are 

13 

14 coeffs[k] = (1/n) * sum_j f(x_j) * exp(-2*pi*i*j*k/n) 

15 = (numpy.fft.fft(values) / n)[k] 

16 

17This is exactly the output of ``numpy.fft.fft(values) / n``, i.e. NumPy-native 

18(FFT) ordering: DC at index 0, positive frequencies 1 … n//2, then negative 

19frequencies -(n//2)+1 … -1. 

20 

21Use ``_coeffs_to_plotorder()`` to obtain the human-readable DC-centred ordering 

22(equivalent to ``numpy.fft.fftshift``). 

23 

24Evaluation 

25---------- 

26Any point x ∈ [-1, 1] is evaluated via the DFT summation formula: 

27 

28 f(x) = Σ_k coeffs[k] * exp(i*π*ω_k*(x+1)) 

29 

30where ω_k = numpy.fft.fftfreq(n)*n gives the integer frequencies in FFT order. 

31 

32References: 

33---------- 

34* Trefethen, "Spectral Methods in MATLAB" (SIAM 2000) 

35* Chebfun @trigtech (github.com/chebfun/chebfun) 

36""" 

37 

38import warnings 

39from abc import ABC 

40from typing import Any, cast 

41 

42import matplotlib.pyplot as plt 

43import numpy as np 

44 

45from .decorators import self_empty 

46from .plotting import plotfun, plotfuncoeffs 

47from .settings import _preferences as prefs 

48from .smoothfun import Smoothfun 

49from .utilities import Interval, coerce_list 

50 

51 

52def _trig_adaptive( 

53 cls: Any, 

54 fun: Any, 

55 hscale: float = 1, 

56 maxpow2: int | None = None, 

57) -> np.ndarray: 

58 """Adaptively determine the Fourier coefficients needed to represent *fun*. 

59 

60 Uses successively finer equispaced grids (sizes 2**k) until the 

61 high-frequency Fourier modes decay below tolerance. Convergence is 

62 assessed via the one-sided symmetric maximum of the DC-centred coefficient 

63 magnitudes: ``abs_sym[k] = max(|c_k|, |c_{-k}|) / vscale``. The series 

64 is considered converged when the Nyquist/highest-frequency mode 

65 ``abs_sym[-1]`` falls below *tol*. 

66 

67 Args: 

68 cls: Trigtech class (provides ``_trigpts`` and ``_vals2coeffs``). 

69 fun: Callable to approximate. 

70 hscale: Horizontal scale for tolerance adjustment. 

71 maxpow2: Maximum power of 2 to try (defaults to ``prefs.maxpow2``). 

72 

73 Returns: 

74 numpy.ndarray: Fourier coefficients in NumPy FFT order. 

75 """ 

76 minpow2 = 3 # start at n = 8 

77 maxpow2 = maxpow2 if maxpow2 is not None else prefs.maxpow2 

78 tol = prefs.eps * max(hscale, 1) 

79 coeffs: np.ndarray = np.array([]) 

80 for k in range(minpow2, max(minpow2, maxpow2) + 1): 

81 n = 2**k 

82 points = cls._trigpts(n) 

83 values = fun(points) 

84 coeffs = cls._vals2coeffs(values) 

85 vscale = float(np.max(np.abs(values))) 

86 if vscale <= tol: 

87 return np.array([0.0]) 

88 

89 # Build one-sided symmetric maximum: 

90 # abs_sym[ki] = max(|c_{ki}|, |c_{-ki}|) / vscale for ki = 0…n//2 

91 centered = np.fft.fftshift(coeffs) 

92 dc_idx = n // 2 

93 abs_sym = np.zeros(dc_idx + 1) 

94 for ki in range(dc_idx + 1): 

95 p = centered[dc_idx + ki] if dc_idx + ki < n else 0.0 

96 q = centered[dc_idx - ki] 

97 abs_sym[ki] = max(abs(p), abs(q)) / vscale 

98 

99 # Convergence: the Nyquist/highest-frequency mode is negligible. 

100 if abs_sym[-1] <= tol: 

101 above = np.where(abs_sym > tol)[0] 

102 if len(above) == 0: # pragma: no cover 

103 # Defensive: the normalised peak coefficient is >= 1/n >> tol 

104 # whenever vscale > tol, so 'above' is never empty here. 

105 return np.array([0.0]) 

106 max_k = int(above[-1]) # highest significant frequency index 

107 start = dc_idx - max_k 

108 end = dc_idx + max_k + 1 

109 return np.fft.ifftshift(centered[start:end]) 

110 

111 if k == maxpow2: 

112 warnings.warn( 

113 f"The {cls.__name__} constructor did not converge: using {n} points", 

114 stacklevel=3, 

115 ) 

116 break 

117 return coeffs 

118 

119 

120class Trigtech(Smoothfun, ABC): 

121 """Trigonometric (Fourier) function approximation on [-1, 1]. 

122 

123 Represents a smooth periodic function f: [-1, 1] -> R (or C) as a 

124 truncated Fourier series. Coefficients are stored in NumPy FFT order; 

125 see module docstring for the precise convention. 

126 

127 This class is ``ABC`` so that it cannot be instantiated directly—exactly 

128 mirroring Chebtech, which is also abstract (concrete only through the 

129 ``Chebtech`` name used everywhere). In practice ``Trigtech`` is both the 

130 abstract base and the concrete class: it is not further subclassed, but 

131 the ABC marker prevents accidental bare construction without going through 

132 a named constructor. 

133 """ 

134 

135 # ------------------------------------------------------------------ 

136 # alternative constructors 

137 # ------------------------------------------------------------------ 

138 

139 @classmethod 

140 def initconst(cls, c: Any = None, *, interval: Any = None) -> "Trigtech": 

141 """Initialise a Trigtech from a constant *c*.""" 

142 if not np.isscalar(c): 

143 raise ValueError(c) 

144 if isinstance(c, int): 

145 c = float(c) 

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

147 

148 @classmethod 

149 def initempty(cls, *, interval: Any = None) -> "Trigtech": 

150 """Initialise an empty Trigtech.""" 

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

152 

153 @classmethod 

154 def initidentity(cls, *, interval: Any = None) -> "Trigtech": 

155 """Trigtech approximation of the identity f(x) = x on [-1, 1]. 

156 

157 Note: f(x) = x is *not* periodic on [-1, 1], so this will not converge 

158 to machine precision. It is provided for interface compatibility with 

159 Chebtech; in practice ``Classicfun.initidentity`` is used instead. 

160 """ 

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

162 return cls.initfun_adaptive(lambda x: x, interval=interval) 

163 

164 @classmethod 

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

166 """Convenience constructor: adaptive if *n* is None, fixed-length otherwise.""" 

167 if n is None: 

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

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

170 

171 @classmethod 

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

173 """Initialise a Trigtech from callable *fun* using *n* equispaced points.""" 

174 if n is None: 

175 raise ValueError("initfun_fixedlen requires the n parameter to be specified") # noqa: TRY003 

176 points = cls._trigpts(int(n)) 

177 values = fun(points) 

178 coeffs = cls._vals2coeffs(values) 

179 return cls(coeffs, interval=interval) 

180 

181 @classmethod 

182 def initfun_adaptive(cls, fun: Any = None, *, interval: Any = None) -> "Trigtech": 

183 """Initialise a Trigtech from callable *fun* using the adaptive constructor.""" 

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

185 interval = Interval(*interval) 

186 coeffs = _trig_adaptive(cls, fun, hscale=interval.hscale) 

187 return cls(coeffs, interval=interval) 

188 

189 @classmethod 

190 def initvalues(cls, values: Any = None, *, interval: Any = None) -> "Trigtech": 

191 """Initialise a Trigtech from function values at equispaced points.""" 

192 return cls(cls._vals2coeffs(np.asarray(values)), interval=interval) 

193 

194 # ------------------------------------------------------------------ 

195 # core dunder methods 

196 # ------------------------------------------------------------------ 

197 

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

199 """Initialise a Trigtech with FFT-order *coeffs* on *interval*. 

200 

201 Coefficients are always stored as complex128. The :attr:`iscomplex` 

202 property returns True only when the function *values* are complex 

203 (i.e., the coefficients do **not** satisfy the conjugate-symmetry 

204 condition C_{n-k} ≈ conj(C_k)). 

205 

206 Args: 

207 coeffs: 1-D array of Fourier coefficients in NumPy FFT order. 

208 interval: Two-element interval [a, b]. Defaults to ``prefs.domain``. 

209 """ 

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

211 self._coeffs = np.array(coeffs, dtype=complex) 

212 self._interval = Interval(*interval) 

213 

214 def __call__(self, x: Any, how: str = "fft") -> Any: # noqa: ARG002 (how kept for Chebtech interface parity) 

215 """Evaluate the Trigtech at points *x* via the DFT summation formula. 

216 

217 f(x) = Σ_k coeffs[k] * exp(i*π*ω_k*(x+1)) 

218 

219 where ω_k = fftfreq(n)*n gives integer frequencies in FFT order. 

220 For real-valued functions the imaginary part of the result is discarded. 

221 

222 Args: 

223 x: Evaluation points in [-1, 1]. 

224 how: Ignored; present for interface compatibility with Chebtech. 

225 """ 

226 if self.isempty: 

227 return np.array([]) 

228 scalar = np.isscalar(x) 

229 x = np.atleast_1d(np.asarray(x, dtype=float)).ravel() 

230 

231 if self.isconst: 

232 c0 = self._coeffs[0].real if not self.iscomplex else self._coeffs[0] 

233 out = c0 * np.ones(x.size) 

234 return float(out[0]) if scalar else out 

235 

236 n = self.size 

237 freqs = np.fft.fftfreq(n) * n # [0, 1, …, n//2, -(n//2)+1, …, -1] 

238 # shape: (len(x), n) @ (n,) → (len(x),) 

239 phases = np.exp(1j * np.pi * np.outer(x + 1.0, freqs)) 

240 result = phases @ self._coeffs 

241 if not self.iscomplex: 

242 result = result.real 

243 return float(result[0]) if scalar else result 

244 

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

246 """Return a concise string representation.""" 

247 return f"<{self.__class__.__name__}{{{self.size}}}>" 

248 

249 # ------------------------------------------------------------------ 

250 # properties 

251 # ------------------------------------------------------------------ 

252 

253 @property 

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

255 """Fourier coefficients in NumPy FFT order (always complex128).""" 

256 return self._coeffs 

257 

258 @property 

259 def interval(self) -> Interval: 

260 """Interval that the Trigtech is mapped to.""" 

261 return self._interval 

262 

263 @property 

264 def size(self) -> int: 

265 """Number of stored Fourier coefficients.""" 

266 return self._coeffs.size 

267 

268 @property 

269 def isempty(self) -> bool: 

270 """True if the Trigtech has no coefficients.""" 

271 return self.size == 0 

272 

273 @property 

274 def iscomplex(self) -> bool: 

275 """True if the function is complex-valued (values have a non-negligible imaginary part). 

276 

277 This is determined by checking whether the Fourier coefficients violate 

278 the conjugate-symmetry condition C_{n-k} ≈ conj(C_k) that holds for 

279 every real-valued periodic function. 

280 """ 

281 n = self.size 

282 if n <= 1: 

283 return bool(np.any(np.abs(np.imag(self._coeffs)) > 0)) 

284 abs_max = float(np.max(np.abs(self._coeffs))) 

285 if abs_max == 0.0: 

286 return False 

287 tol = 1e-8 * abs_max 

288 # mirror[k-1] = conj(C_{n-k}) for k = 1,...,n-1 

289 mirror = np.conj(self._coeffs[-1:0:-1]) 

290 return bool(np.any(np.abs(self._coeffs[1:] - mirror) > tol)) 

291 

292 @property 

293 def isconst(self) -> bool: 

294 """True if the Trigtech represents a constant (single coefficient).""" 

295 return self.size == 1 

296 

297 @property 

298 def isperiodic(self) -> bool: 

299 """Always True: Trigtech always represents a periodic function.""" 

300 return True 

301 

302 @property 

303 @self_empty(0.0) 

304 def vscale(self) -> float: 

305 """Estimate the vertical scale (max |f|).""" 

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

307 

308 # ------------------------------------------------------------------ 

309 # utilities 

310 # ------------------------------------------------------------------ 

311 

312 def copy(self) -> "Trigtech": 

313 """Return a deep copy.""" 

314 return self.__class__(self._coeffs.copy(), interval=self._interval.copy()) 

315 

316 def imag(self) -> "Trigtech": 

317 """Return the imaginary part of the function as a real-valued Trigtech. 

318 

319 For a complex function f(x) = g(x) + i·h(x), the Fourier coefficients 

320 of h(x) are H[k] = (D[k] - conj(D[n-k])) / (2i) for k ≥ 1, 

321 and H[0] = Im(D[0]). 

322 """ 

323 if not self.iscomplex: 

324 return self.initconst(0.0, interval=self._interval) 

325 n = self.size 

326 c = self._coeffs 

327 imag_c = np.zeros(n, dtype=complex) 

328 imag_c[0] = np.imag(c[0]) 

329 if n > 1: 

330 mirror = np.conj(c[-1:0:-1]) # conj(c[n-1]), ..., conj(c[1]) 

331 imag_c[1:] = (c[1:] - mirror) / (2j) 

332 return self.__class__(imag_c, self._interval) 

333 

334 def prolong(self, n: int) -> "Trigtech": 

335 """Return a Trigtech of length *n* (truncate or zero-pad in frequency space). 

336 

337 The operation aligns DC components of the source and target DC-centred 

338 representations, then either pads with zeros (n > m) or slices (n < m). 

339 This correctly handles the asymmetry between even- and odd-length arrays. 

340 """ 

341 m = self.size 

342 if n == m: 

343 return self.copy() 

344 

345 centered = np.fft.fftshift(self._coeffs) 

346 dc_src = m // 2 

347 dc_tgt = n // 2 

348 

349 if n > m: 

350 padded = np.zeros(n, dtype=centered.dtype) 

351 start = dc_tgt - dc_src 

352 padded[start : start + m] = centered 

353 return self.__class__(np.fft.ifftshift(padded), interval=self._interval) 

354 else: 

355 start = dc_src - dc_tgt 

356 truncated = centered[start : start + n] 

357 return self.__class__(np.fft.ifftshift(truncated), interval=self._interval) 

358 

359 def real(self) -> "Trigtech": 

360 """Return the real part of the function as a real-valued Trigtech. 

361 

362 For a complex function f(x) = g(x) + i·h(x), the Fourier coefficients 

363 of g(x) are G[k] = (D[k] + conj(D[n-k])) / 2 for k ≥ 1, 

364 and G[0] = Re(D[0]). 

365 """ 

366 if not self.iscomplex: 

367 return self 

368 n = self.size 

369 c = self._coeffs 

370 real_c = np.zeros(n, dtype=complex) 

371 real_c[0] = np.real(c[0]) 

372 if n > 1: 

373 mirror = np.conj(c[-1:0:-1]) # conj(c[n-1]), ..., conj(c[1]) 

374 real_c[1:] = (c[1:] + mirror) / 2 

375 return self.__class__(real_c, self._interval) 

376 

377 def simplify(self) -> "Trigtech": 

378 """Truncate high-frequency Fourier coefficients that are below tolerance. 

379 

380 Uses the same one-sided symmetric-maximum criterion as the adaptive 

381 constructor: the highest-frequency mode retained is the one where 

382 ``max(|c_k|, |c_{-k}|) / vscale > tol``. 

383 """ 

384 oldlen = len(self._coeffs) 

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

386 n = longself.size 

387 tol = prefs.eps * max(self._interval.hscale, 1) 

388 

389 centered = np.fft.fftshift(longself._coeffs) 

390 dc_idx = n // 2 

391 abs_max = float(np.max(np.abs(centered))) 

392 if abs_max == 0.0: 

393 return self.initconst(0.0, interval=self._interval) 

394 

395 abs_sym = np.zeros(dc_idx + 1) 

396 for ki in range(dc_idx + 1): 

397 p = centered[dc_idx + ki] if dc_idx + ki < n else 0.0 

398 q = centered[dc_idx - ki] 

399 abs_sym[ki] = max(abs(p), abs(q)) / abs_max 

400 

401 above = np.where(abs_sym > tol)[0] 

402 if len(above) == 0: # pragma: no cover 

403 # Defensive: with abs_max > 0 the normalised peak equals 1 > tol, 

404 # so 'above' always contains at least the peak index. 

405 return self.initconst(0.0, interval=self._interval) 

406 max_k = int(above[-1]) 

407 max_k = min(max_k, oldlen // 2) # don't exceed original size 

408 

409 start = dc_idx - max_k 

410 end = dc_idx + max_k + 1 

411 return self.__class__(np.fft.ifftshift(centered[start:end]), interval=self._interval) 

412 

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

414 """Function values at the n equispaced points x_j = -1 + 2j/n.""" 

415 return self._coeffs2vals(self._coeffs) 

416 

417 def _coeffs_to_plotorder(self) -> np.ndarray: 

418 """Return coefficients in DC-centred (human-readable) order. 

419 

420 Equivalent to ``numpy.fft.fftshift(self.coeffs)``: 

421 ordering is [c_{-n//2}, …, c_{-1}, c_0, c_1, …, c_{n//2-1}]. 

422 """ 

423 return np.fft.fftshift(self._coeffs) 

424 

425 # ------------------------------------------------------------------ 

426 # algebra 

427 # ------------------------------------------------------------------ 

428 

429 @self_empty() 

430 def __add__(self, f: Any) -> "Trigtech": 

431 """Add a scalar or another Trigtech.""" 

432 cls = self.__class__ 

433 if np.isscalar(f): 

434 dtype: Any = complex if np.iscomplexobj(f) else self._coeffs.dtype 

435 cfs = np.array(self._coeffs, dtype=dtype) 

436 cfs[0] += f # add to DC component 

437 return cls(cfs, interval=self._interval) 

438 if f.isempty: 

439 return cast("Trigtech", f.copy()) 

440 g = self 

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

442 if n < m: 

443 g = g.prolong(m) 

444 elif m < n: 

445 f = f.prolong(n) 

446 cfs = f.coeffs + g.coeffs 

447 eps = prefs.eps 

448 tol = 0.5 * eps * max(f.vscale, g.vscale) 

449 if np.all(np.abs(cfs) < tol): 

450 return cls.initconst(0.0, interval=self._interval) 

451 return cls(cfs, interval=self._interval) 

452 

453 @self_empty() 

454 def __div__(self, f: Any) -> "Trigtech": 

455 """Divide this Trigtech by a scalar or another Trigtech.""" 

456 cls = self.__class__ 

457 if np.isscalar(f): 

458 return cls(self._coeffs / np.asarray(f), interval=self._interval) 

459 if f.isempty: 

460 return cast("Trigtech", f.copy()) 

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

462 

463 __truediv__ = __div__ 

464 

465 @self_empty() 

466 def __mul__(self, g: Any) -> "Trigtech": 

467 """Multiply this Trigtech by a scalar or another Trigtech. 

468 

469 Trig-polynomial multiplication is circular convolution in frequency 

470 space. We implement this cleanly by evaluating both on a grid of 

471 size n1 + n2 (sufficient to avoid aliasing), multiplying pointwise, 

472 and taking the FFT. 

473 """ 

474 cls = self.__class__ 

475 if np.isscalar(g): 

476 return cls(g * self._coeffs, interval=self._interval) 

477 if g.isempty: 

478 return cast("Trigtech", g.copy()) 

479 n = self.size + g.size 

480 f_vals = self.prolong(n).values() 

481 g_vals = g.prolong(n).values() 

482 return cls(cls._vals2coeffs(f_vals * g_vals), interval=self._interval) 

483 

484 def __neg__(self) -> "Trigtech": 

485 """Return the negation.""" 

486 return self.__class__(-self._coeffs, interval=self._interval) 

487 

488 def __pos__(self) -> "Trigtech": 

489 """Return self (unary plus).""" 

490 return self 

491 

492 @self_empty() 

493 def __pow__(self, f: Any) -> "Trigtech": 

494 """Raise this Trigtech to a power *f* (scalar or Trigtech).""" 

495 

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

497 return fn if np.isscalar(fn) else fn(x) 

498 

499 return self.__class__.initfun_adaptive( 

500 lambda x: np.power(self(x), powfun(f, x)), 

501 interval=self._interval, 

502 ) 

503 

504 def __rdiv__(self, f: Any) -> "Trigtech": 

505 """Compute f / self where *f* is a scalar.""" 

506 return self.__class__.initfun_adaptive( 

507 lambda x: (0.0 * x + f) / self(x), 

508 interval=self._interval, 

509 ) 

510 

511 __radd__ = __add__ 

512 __rmul__ = __mul__ 

513 __rtruediv__ = __rdiv__ 

514 

515 def __rsub__(self, f: Any) -> "Trigtech": 

516 """Compute f - self.""" 

517 return cast("Trigtech", -(self - f)) 

518 

519 @self_empty() 

520 def __rpow__(self, f: Any) -> "Trigtech": 

521 """Compute f ** self.""" 

522 return self.__class__.initfun_adaptive( 

523 lambda x: np.power(f, self(x)), 

524 interval=self._interval, 

525 ) 

526 

527 def __sub__(self, f: Any) -> "Trigtech": 

528 """Subtract *f* (scalar or Trigtech) from this Trigtech.""" 

529 return cast("Trigtech", self + (-f)) 

530 

531 # ------------------------------------------------------------------ 

532 # rootfinding 

533 # ------------------------------------------------------------------ 

534 

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

536 """Find the roots of this Trigtech on [-1, 1]. 

537 

538 Converts to a Chebyshev representation via re-sampling on Chebyshev 

539 points and delegates to the Chebtech colleague-matrix root-finder. 

540 

541 Args: 

542 sort: If True, sort the roots in ascending order. Defaults to 

543 ``prefs.sortroots``. 

544 """ 

545 from .algorithms import newtonroots, rootsunit 

546 from .chebtech import Chebtech 

547 

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

549 

550 if self.isempty: 

551 return np.array([]) 

552 

553 # Sample on a Chebyshev grid and fit a Chebtech of the same resolution 

554 n = max(2 * self.size + 1, 33) 

555 cheb_pts = Chebtech._chebpts(n) 

556 vals = self(cheb_pts) 

557 ct = Chebtech(Chebtech._vals2coeffs(vals)) 

558 rts = rootsunit(ct.coeffs) 

559 rts = newtonroots(ct, rts) 

560 rts = np.clip(rts, -1.0, 1.0) 

561 return np.sort(rts) if sort else rts 

562 

563 # ------------------------------------------------------------------ 

564 # calculus 

565 # ------------------------------------------------------------------ 

566 

567 @self_empty(resultif=0.0) 

568 def sum(self) -> Any: 

569 """Definite integral of the Trigtech over [-1, 1]. 

570 

571 Only the DC coefficient contributes: 

572 ∫_{-1}^{1} exp(i*π*k*(x+1)) dx = 0 for k ≠ 0 

573 ∫_{-1}^{1} 1 dx = 2 for k = 0 

574 """ 

575 return 2.0 * float(np.real(self._coeffs[0])) 

576 

577 @self_empty() 

578 def cumsum(self) -> "Trigtech": 

579 """Indefinite integral, zero at x = -1, in Fourier coefficient space. 

580 

581 For mode k ≠ 0: antiderivative coefficient = c_k / (i*π*ω_k) 

582 For mode k = 0: set to the constant needed so that F(-1) = 0. 

583 

584 Note: if the DC component (self.coeffs[0]) is non-zero the true 

585 antiderivative contains a linear trend and is not periodic. We still 

586 return a Trigtech representing the *periodic* part, adjusted so that 

587 the result evaluates to 0 at x = -1. 

588 """ 

589 n = self.size 

590 c = self._coeffs.copy() 

591 freqs = np.fft.fftfreq(n) * n # FFT-order integer frequencies 

592 

593 int_c = np.zeros(n, dtype=complex) 

594 mask = freqs != 0 

595 int_c[mask] = c[mask] / (1j * np.pi * freqs[mask]) 

596 

597 # Enforce F(-1) = 0. 

598 # F(x) = Σ_k int_c[k] * exp(i*π*ω_k*(x+1)) 

599 # At x = -1: exp(i*π*ω_k*0) = 1 for all k, so F(-1) = Σ int_c 

600 # Set int_c[0] so that sum(int_c) = 0. 

601 int_c[0] = -np.sum(int_c[1:]) 

602 return self.__class__(int_c, interval=self._interval) 

603 

604 @self_empty() 

605 def diff(self) -> "Trigtech": 

606 """Derivative via the Fourier multiplier i*π*ω_k. 

607 

608 d/dx [c_k * exp(i*π*ω_k*(x+1))] = i*π*ω_k * c_k * exp(i*π*ω_k*(x+1)) 

609 """ 

610 if self.isconst: 

611 return self.__class__(np.array([0.0 + 0.0j]), interval=self._interval) 

612 n = self.size 

613 freqs = np.fft.fftfreq(n) * n 

614 d_coeffs = (1j * np.pi * freqs) * self._coeffs 

615 return self.__class__(d_coeffs, interval=self._interval) 

616 

617 # ------------------------------------------------------------------ 

618 # static helpers (FFT ↔ values) 

619 # ------------------------------------------------------------------ 

620 

621 @staticmethod 

622 def _trigpts(n: int) -> np.ndarray: 

623 """Return *n* equispaced points on [-1, 1).""" 

624 if n == 0: 

625 return np.array([]) 

626 return -1.0 + 2.0 * np.arange(n) / n 

627 

628 @staticmethod 

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

630 """Convert values at equispaced points to FFT coefficients (divided by n). 

631 

632 Always returns complex128, even for real-valued inputs, because Fourier 

633 coefficients for functions such as sin are purely imaginary and would be 

634 discarded if forced to real. 

635 

636 Inverse of ``_coeffs2vals``. 

637 """ 

638 vals = np.asarray(vals) 

639 n = vals.size 

640 if n == 0: 

641 return np.array([], dtype=complex) 

642 return cast(np.ndarray, np.fft.fft(vals) / n) 

643 

644 @staticmethod 

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

646 """Convert FFT coefficients (divided by n) to values at equispaced points. 

647 

648 Inverse of ``_vals2coeffs``. 

649 """ 

650 coeffs = np.asarray(coeffs, dtype=complex) 

651 n = coeffs.size 

652 if n == 0: 

653 return np.array([], dtype=float) 

654 vals = n * np.fft.ifft(coeffs) 

655 # Discard negligible imaginary parts for conjugate-symmetric coefficients 

656 max_real = float(np.max(np.abs(np.real(vals)))) 

657 if float(np.max(np.abs(np.imag(vals)))) < 1e-10 * max(max_real, 1.0): 

658 return np.real(vals) 

659 return cast(np.ndarray, vals) 

660 

661 # ------------------------------------------------------------------ 

662 # plotting 

663 # ------------------------------------------------------------------ 

664 

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

666 """Plot the Trigtech over [-1, 1]. 

667 

668 Args: 

669 ax: Matplotlib axes. If None, uses the current axes. 

670 **kwargs: Forwarded to matplotlib. 

671 

672 Returns: 

673 The axes on which the plot was drawn. 

674 """ 

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

676 

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

678 """Plot the absolute Fourier coefficient magnitudes in DC-centred order. 

679 

680 Uses ``_coeffs_to_plotorder()`` so the horizontal axis runs from 

681 the most-negative frequency on the left to the most-positive on 

682 the right, with DC in the centre. 

683 

684 Args: 

685 ax: Matplotlib axes. If None, uses the current axes. 

686 **kwargs: Forwarded to matplotlib. 

687 

688 Returns: 

689 The axes on which the plot was drawn. 

690 """ 

691 ax = ax or plt.gca() 

692 return plotfuncoeffs(np.abs(self._coeffs_to_plotorder()), ax=ax, **kwargs)