Coverage for pySDC/helpers/spectral_helper.py: 93%

797 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 20:28 +0000

1import numpy as np 

2import scipy 

3from pySDC.implementations.datatype_classes.mesh import mesh 

4from scipy.special import factorial 

5from functools import partial, wraps 

6import logging 

7 

8 

9def cache(func): 

10 """ 

11 Decorator for caching return values of functions. 

12 This is very similar to `functools.cache`, but without the memory leaks (see 

13 https://docs.astral.sh/ruff/rules/cached-instance-method/). 

14 

15 Example: 

16 

17 .. code-block:: python 

18 

19 num_calls = 0 

20 

21 @cache 

22 def increment(x): 

23 num_calls += 1 

24 return x + 1 

25 

26 increment(0) # returns 1, num_calls = 1 

27 increment(1) # returns 2, num_calls = 2 

28 increment(0) # returns 1, num_calls = 2 

29 

30 

31 Args: 

32 func (function): The function you want to cache the return value of 

33 

34 Returns: 

35 return value of func 

36 """ 

37 attr_cache = f"_{func.__name__}_cache" 

38 

39 @wraps(func) 

40 def wrapper(self, *args, **kwargs): 

41 if not hasattr(self, attr_cache): 

42 setattr(self, attr_cache, {}) 

43 

44 cache = getattr(self, attr_cache) 

45 

46 key = (args, frozenset(kwargs.items())) 

47 if key in cache: 

48 return cache[key] 

49 result = func(self, *args, **kwargs) 

50 cache[key] = result 

51 return result 

52 

53 return wrapper 

54 

55 

56class SpectralHelper1D: 

57 """ 

58 Abstract base class for 1D spectral discretizations. Defines a common interface with parameters and functions that 

59 all bases need to have. 

60 

61 When implementing new bases, please take care to use the modules that are supplied as class attributes to enable 

62 the code for GPUs. 

63 

64 Attributes: 

65 N (int): Resolution 

66 x0 (float): Coordinate of left boundary 

67 x1 (float): Coordinate of right boundary 

68 L (float): Length of the domain 

69 useGPU (bool): Whether to use GPUs 

70 

71 """ 

72 

73 fft_lib = scipy.fft 

74 sparse_lib = scipy.sparse 

75 linalg = scipy.sparse.linalg 

76 xp = np 

77 distributable = False 

78 

79 def __init__(self, N, x0=None, x1=None, useGPU=False, useFFTW=False): 

80 """ 

81 Constructor 

82 

83 Args: 

84 N (int): Resolution 

85 x0 (float): Coordinate of left boundary 

86 x1 (float): Coordinate of right boundary 

87 useGPU (bool): Whether to use GPUs 

88 useFFTW (bool): Whether to use FFTW for the transforms 

89 """ 

90 self.N = N 

91 self.x0 = x0 

92 self.x1 = x1 

93 self.L = x1 - x0 

94 self.useGPU = useGPU 

95 self.plans = {} 

96 self.logger = logging.getLogger(name=type(self).__name__) 

97 

98 if useGPU: 

99 self.setup_GPU() 

100 self.logger.debug('Set up for GPU') 

101 else: 

102 self.setup_CPU(useFFTW=useFFTW) 

103 

104 if useGPU and useFFTW: 

105 raise ValueError('Please run either on GPUs or with FFTW, not both!') 

106 

107 def setup_GPU(self): 

108 """switch to GPU modules""" 

109 import cupy as cp 

110 import cupyx.scipy.sparse as sparse_lib 

111 import cupyx.scipy.sparse.linalg as linalg 

112 import cupyx.scipy.fft as fft_lib 

113 from pySDC.implementations.datatype_classes.cupy_mesh import cupy_mesh 

114 

115 self.xp = cp 

116 self.sparse_lib = sparse_lib 

117 self.linalg = linalg 

118 self.fft_lib = fft_lib 

119 

120 @classmethod 

121 def setup_CPU(cls, useFFTW=False): 

122 """switch to CPU modules""" 

123 

124 cls.xp = np 

125 cls.sparse_lib = scipy.sparse 

126 cls.linalg = scipy.sparse.linalg 

127 

128 if useFFTW: 

129 from mpi4py_fft import fftw 

130 

131 cls.fft_backend = 'fftw' 

132 cls.fft_lib = fftw 

133 else: 

134 cls.fft_backend = 'scipy' 

135 cls.fft_lib = scipy.fft 

136 

137 cls.fft_comm_backend = 'MPI' 

138 cls.dtype = mesh 

139 

140 def get_Id(self): 

141 """ 

142 Get identity matrix 

143 

144 Returns: 

145 sparse diagonal identity matrix 

146 """ 

147 return self.sparse_lib.eye(self.N) 

148 

149 def get_zero(self): 

150 """ 

151 Get a matrix with all zeros of the correct size. 

152 

153 Returns: 

154 sparse matrix with zeros everywhere 

155 """ 

156 return 0 * self.get_Id() 

157 

158 def get_differentiation_matrix(self): 

159 raise NotImplementedError() 

160 

161 def get_integration_matrix(self): 

162 raise NotImplementedError() 

163 

164 def get_integration_weights(self): 

165 """Weights for integration across entire domain""" 

166 raise NotImplementedError() 

167 

168 def get_wavenumbers(self): 

169 """ 

170 Get the grid in spectral space 

171 """ 

172 raise NotImplementedError 

173 

174 def get_empty_operator_matrix(self, S, O): 

175 """ 

176 Return a matrix of operators to be filled with the connections between the solution components. 

177 

178 Args: 

179 S (int): Number of components in the solution 

180 O (sparse matrix): Zero matrix used for initialization 

181 

182 Returns: 

183 list of lists containing sparse zeros 

184 """ 

185 return [[O for _ in range(S)] for _ in range(S)] 

186 

187 def get_basis_change_matrix(self, *args, **kwargs): 

188 """ 

189 Some spectral discretization change the basis during differentiation. This method can be used to transfer 

190 between the various bases. 

191 

192 This method accepts arbitrary arguments that may not be used in order to provide an easy interface for multi- 

193 dimensional bases. For instance, you may combine an FFT discretization with an ultraspherical discretization. 

194 The FFT discretization will always be in the same base, but the ultraspherical discretization uses a different 

195 base for every derivative. You can then ask all bases for transfer matrices from one ultraspherical derivative 

196 base to the next. The FFT discretization will ignore this and return an identity while the ultraspherical 

197 discretization will return the desired matrix. After a Kronecker product, you get the 2D version of the matrix 

198 you want. This is what the `SpectralHelper` does when you call the method of the same name on it. 

199 

200 Returns: 

201 sparse bases change matrix 

202 """ 

203 return self.sparse_lib.eye(self.N) 

204 

205 def get_BC(self, kind): 

206 """ 

207 To facilitate boundary conditions (BCs) we use either a basis where all functions satisfy the BCs automatically, 

208 as is the case in FFT basis for periodic BCs, or boundary bordering. In boundary bordering, specific lines in 

209 the matrix are replaced by the boundary conditions as obtained by this method. 

210 

211 Args: 

212 kind (str): The type of BC you want to implement please refer to the implementations of this method in the 

213 individual 1D bases for what is implemented 

214 

215 Returns: 

216 self.xp.array: Boundary condition 

217 """ 

218 raise NotImplementedError(f'No boundary conditions of {kind=!r} implemented!') 

219 

220 def get_filter_matrix(self, kmin=0, kmax=None): 

221 """ 

222 Get a bandpass filter. 

223 

224 Args: 

225 kmin (int): Lower limit of the bandpass filter 

226 kmax (int): Upper limit of the bandpass filter 

227 

228 Returns: 

229 sparse matrix 

230 """ 

231 

232 k = abs(self.get_wavenumbers()) 

233 

234 kmax = max(k) if kmax is None else kmax 

235 

236 mask = self.xp.logical_or(k >= kmax, k < kmin) 

237 

238 if self.useGPU: 

239 Id = self.get_Id().get() 

240 else: 

241 Id = self.get_Id() 

242 F = Id.tolil() 

243 F[:, mask] = 0 

244 return F.tocsc() 

245 

246 def get_1dgrid(self): 

247 """ 

248 Get the grid in physical space 

249 

250 Returns: 

251 self.xp.array: Grid 

252 """ 

253 raise NotImplementedError 

254 

255 

256class ChebychevHelper(SpectralHelper1D): 

257 """ 

258 The Chebychev base consists of special kinds of polynomials, with the main advantage that you can easily transform 

259 between physical and spectral space by discrete cosine transform. 

260 The differentiation in the Chebychev T base is dense, but can be preconditioned to yield a differentiation operator 

261 that moves to Chebychev U basis during differentiation, which is sparse. When using this technique, problems need to 

262 be formulated in first order formulation. 

263 

264 This implementation is largely based on the Dedalus paper (https://doi.org/10.1103/PhysRevResearch.2.023068). 

265 """ 

266 

267 def __init__(self, *args, x0=-1, x1=1, **kwargs): 

268 """ 

269 Constructor. 

270 Please refer to the parent class for additional arguments. Notably, you have to supply a resolution `N` and you 

271 may choose to run on GPUs via the `useGPU` argument. 

272 

273 Args: 

274 x0 (float): Coordinate of left boundary. Note that only -1 is currently implented 

275 x1 (float): Coordinate of right boundary. Note that only +1 is currently implented 

276 """ 

277 # need linear transformation y = ax + b with a = (x1-x0)/2 and b = (x1+x0)/2 

278 self.lin_trf_fac = (x1 - x0) / 2 

279 self.lin_trf_off = (x1 + x0) / 2 

280 super().__init__(*args, x0=x0, x1=x1, **kwargs) 

281 

282 self.norm = self.get_norm() 

283 

284 def get_1dgrid(self): 

285 ''' 

286 Generates a 1D grid with Chebychev points. These are clustered at the boundary. You need this kind of grid to 

287 use discrete cosine transformation (DCT) to get the Chebychev representation. If you want a different grid, you 

288 need to do an affine transformation before any Chebychev business. 

289 

290 Returns: 

291 numpy.ndarray: 1D grid 

292 ''' 

293 return self.lin_trf_fac * self.xp.cos(np.pi / self.N * (self.xp.arange(self.N) + 0.5)) + self.lin_trf_off 

294 

295 def get_wavenumbers(self): 

296 """Get the domain in spectral space""" 

297 return self.xp.arange(self.N) 

298 

299 @cache 

300 def get_conv(self, name, N=None): 

301 ''' 

302 Get conversion matrix between different kinds of polynomials. The supported kinds are 

303 - T: Chebychev polynomials of first kind 

304 - U: Chebychev polynomials of second kind 

305 - D: Dirichlet recombination. 

306 

307 You get the desired matrix by choosing a name as ``A2B``. I.e. ``T2U`` for the conversion matrix from T to U. 

308 Once generates matrices are cached. So feel free to call the method as often as you like. 

309 

310 Args: 

311 name (str): Conversion code, e.g. 'T2U' 

312 N (int): Size of the matrix (optional) 

313 

314 Returns: 

315 scipy.sparse: Sparse conversion matrix 

316 ''' 

317 N = N if N else self.N 

318 sp = self.sparse_lib 

319 

320 def get_forward_conv(name): 

321 if name == 'T2U': 

322 mat = (sp.eye(N) - sp.eye(N, k=2)).tocsc() / 2.0 

323 mat[:, 0] *= 2 

324 elif name == 'D2T': 

325 mat = sp.eye(N) - sp.eye(N, k=2) 

326 elif name[0] == name[-1]: 

327 mat = self.sparse_lib.eye(self.N) 

328 else: 

329 raise NotImplementedError(f'Don\'t have conversion matrix {name!r}') 

330 return mat 

331 

332 try: 

333 mat = get_forward_conv(name) 

334 except NotImplementedError as E: 

335 try: 

336 fwd = get_forward_conv(name[::-1]) 

337 import scipy.sparse as sp 

338 

339 if self.sparse_lib == sp: 

340 mat = self.sparse_lib.linalg.inv(fwd.tocsc()) 

341 else: 

342 mat = self.sparse_lib.csc_matrix(sp.linalg.inv(fwd.tocsc().get())) 

343 except NotImplementedError: 

344 raise NotImplementedError from E 

345 

346 return mat 

347 

348 def get_basis_change_matrix(self, conv='T2T', **kwargs): 

349 """ 

350 As the differentiation matrix in Chebychev-T base is dense but is sparse when simultaneously changing base to 

351 Chebychev-U, you may need a basis change matrix to transfer the other matrices as well. This function returns a 

352 conversion matrix from `ChebychevHelper.get_conv`. Not that `**kwargs` are used to absorb arguments for other 

353 bases, see documentation of `SpectralHelper1D.get_basis_change_matrix`. 

354 

355 Args: 

356 conv (str): Conversion code, i.e. T2U 

357 

358 Returns: 

359 Sparse conversion matrix 

360 """ 

361 return self.get_conv(conv) 

362 

363 def get_integration_matrix(self, lbnd=0): 

364 """ 

365 Get matrix for integration 

366 

367 Args: 

368 lbnd (float): Lower bound for integration, only 0 is currently implemented 

369 

370 Returns: 

371 Sparse integration matrix 

372 """ 

373 S = self.sparse_lib.diags(1 / (self.xp.arange(self.N - 1) + 1), offsets=-1) @ self.get_conv('T2U') 

374 n = self.xp.arange(self.N) 

375 if lbnd == 0: 

376 S = S.tocsc() 

377 S[0, 1::2] = ( 

378 (n / (2 * (self.xp.arange(self.N) + 1)))[1::2] 

379 * (-1) ** (self.xp.arange(self.N // 2)) 

380 / (np.append([1], self.xp.arange(self.N // 2 - 1) + 1)) 

381 ) * self.lin_trf_fac 

382 else: 

383 raise NotImplementedError(f'This function allows to integrate only from x=0, you attempted from x={lbnd}.') 

384 return S 

385 

386 def get_integration_weights(self): 

387 """Weights for integration across entire domain""" 

388 n = self.xp.arange(self.N, dtype=float) 

389 

390 weights = (-1) ** n + 1 

391 weights[2:] /= 1 - (n**2)[2:] 

392 

393 weights /= 2 / self.L 

394 return weights 

395 

396 def get_differentiation_matrix(self, p=1): 

397 ''' 

398 Keep in mind that the T2T differentiation matrix is dense. 

399 

400 Args: 

401 p (int): Derivative you want to compute 

402 

403 Returns: 

404 numpy.ndarray: Differentiation matrix 

405 ''' 

406 D = self.xp.zeros((self.N, self.N)) 

407 for j in range(self.N): 

408 for k in range(j): 

409 D[k, j] = 2 * j * ((j - k) % 2) 

410 

411 D[0, :] /= 2 

412 return self.sparse_lib.csc_matrix(self.xp.linalg.matrix_power(D, p)) / self.lin_trf_fac**p 

413 

414 @cache 

415 def get_norm(self, N=None): 

416 ''' 

417 Get normalization for converting Chebychev coefficients and DCT 

418 

419 Args: 

420 N (int, optional): Resolution 

421 

422 Returns: 

423 self.xp.array: Normalization 

424 ''' 

425 N = self.N if N is None else N 

426 norm = self.xp.ones(N) / N 

427 norm[0] /= 2 

428 return norm 

429 

430 def transform(self, u, *args, axes=None, shape=None, **kwargs): 

431 """ 

432 DCT along axes. `kwargs` will be passed on to the FFT library. 

433 

434 Args: 

435 u: Data you want to transform 

436 axes (tuple): Axes you want to transform along 

437 

438 Returns: 

439 Data in spectral space 

440 """ 

441 axes = axes if axes else tuple(i for i in range(u.ndim)) 

442 kwargs['s'] = shape 

443 kwargs['norm'] = kwargs.get('norm', 'backward') 

444 

445 trf = self.fft_lib.dctn(u, *args, axes=axes, type=2, **kwargs) 

446 for axis in axes: 

447 

448 if self.N < trf.shape[axis]: 

449 # mpi4py-fft implements padding only for FFT, where the frequencies are sorted such that the zeros are 

450 # removed in the middle rather than the end. We need to resort this here and put the highest frequencies 

451 # in the middle. 

452 _trf = self.xp.zeros_like(trf) 

453 N = self.N 

454 N_pad = _trf.shape[axis] - N 

455 end_first_half = N // 2 + 1 

456 

457 # copy first "half" 

458 su = [slice(None)] * trf.ndim 

459 su[axis] = slice(0, end_first_half) 

460 _trf[tuple(su)] = trf[tuple(su)] 

461 

462 # copy second "half" 

463 su = [slice(None)] * u.ndim 

464 su[axis] = slice(end_first_half + N_pad, None) 

465 s_u = [slice(None)] * u.ndim 

466 s_u[axis] = slice(end_first_half, N) 

467 _trf[tuple(su)] = trf[tuple(s_u)] 

468 

469 # # copy values to be cut 

470 # su = [slice(None)] * u.ndim 

471 # su[axis] = slice(end_first_half, end_first_half + N_pad) 

472 # s_u = [slice(None)] * u.ndim 

473 # s_u[axis] = slice(-N_pad, None) 

474 # _trf[tuple(su)] = trf[tuple(s_u)] 

475 

476 trf = _trf 

477 

478 expansion = [np.newaxis for _ in u.shape] 

479 expansion[axis] = slice(0, u.shape[axis], 1) 

480 norm = self.xp.ones(trf.shape[axis]) * self.norm[-1] 

481 norm[: self.N] = self.norm 

482 trf *= norm[(*expansion,)] 

483 return trf 

484 

485 def itransform(self, u, *args, axes=None, shape=None, **kwargs): 

486 """ 

487 Inverse DCT along axis. 

488 

489 Args: 

490 u: Data you want to transform 

491 axes (tuple): Axes you want to transform along 

492 

493 Returns: 

494 Data in physical space 

495 """ 

496 axes = axes if axes else tuple(i for i in range(u.ndim)) 

497 kwargs['s'] = shape 

498 kwargs['norm'] = kwargs.get('norm', 'backward') 

499 kwargs['overwrite_x'] = kwargs.get('overwrite_x', False) 

500 

501 for axis in axes: 

502 

503 if self.N == u.shape[axis]: 

504 _u = u.copy() 

505 else: 

506 # mpi4py-fft implements padding only for FFT, where the frequencies are sorted such that the zeros are 

507 # added in the middle rather than the end. We need to resort this here and put the padding in the end. 

508 N = self.N 

509 _u = self.xp.zeros_like(u) 

510 

511 # copy first half 

512 su = [slice(None)] * u.ndim 

513 su[axis] = slice(0, N // 2 + 1) 

514 _u[tuple(su)] = u[tuple(su)] 

515 

516 # copy second half 

517 su = [slice(None)] * u.ndim 

518 su[axis] = slice(-(N // 2), None) 

519 s_u = [slice(None)] * u.ndim 

520 s_u[axis] = slice(N // 2, N // 2 + (N // 2)) 

521 _u[tuple(s_u)] = u[tuple(su)] 

522 

523 if N % 2 == 0: 

524 su = [slice(None)] * u.ndim 

525 su[axis] = N // 2 

526 _u[tuple(su)] *= 2 

527 

528 # generate norm 

529 expansion = [np.newaxis for _ in u.shape] 

530 expansion[axis] = slice(0, u.shape[axis], 1) 

531 norm = self.xp.ones(_u.shape[axis]) 

532 norm[: self.N] = self.norm 

533 norm = self.get_norm(u.shape[axis]) * _u.shape[axis] / self.N 

534 

535 _u /= norm[(*expansion,)] 

536 

537 return self.fft_lib.idctn(_u, *args, axes=axes, type=2, **kwargs) 

538 

539 def get_BC(self, kind, **kwargs): 

540 """ 

541 Get boundary condition row for boundary bordering. `kwargs` will be passed on to implementations of the BC of 

542 the kind you choose. Specifically, `x` for `'dirichlet'` boundary condition, which is the coordinate at which to 

543 set the BC. 

544 

545 Args: 

546 kind ('integral' or 'dirichlet'): Kind of boundary condition you want 

547 """ 

548 if kind.lower() == 'integral': 

549 return self.get_integ_BC_row(**kwargs) 

550 elif kind.lower() == 'dirichlet': 

551 return self.get_Dirichlet_BC_row(**kwargs) 

552 elif kind.lower() == 'neumann': 

553 return self.get_Neumann_BC_row(**kwargs) 

554 else: 

555 return super().get_BC(kind) 

556 

557 def get_integ_BC_row(self): 

558 """ 

559 Get a row for generating integral BCs with T polynomials. 

560 It returns the values of the integrals of T polynomials over the entire interval. 

561 

562 Returns: 

563 self.xp.ndarray: Row to put into a matrix 

564 """ 

565 n = self.xp.arange(self.N) + 1 

566 me = self.xp.zeros_like(n).astype(float) 

567 me[2:] = ((-1) ** n[1:-1] + 1) / (1 - n[1:-1] ** 2) 

568 me[0] = 2.0 

569 return me 

570 

571 def get_Dirichlet_BC_row(self, x): 

572 """ 

573 Get a row for generating Dirichlet BCs at x with T polynomials. 

574 It returns the values of the T polynomials at x. 

575 

576 Args: 

577 x (float): Position of the boundary condition 

578 

579 Returns: 

580 self.xp.ndarray: Row to put into a matrix 

581 """ 

582 if x == -1: 

583 return (-1) ** self.xp.arange(self.N) 

584 elif x == 1: 

585 return self.xp.ones(self.N) 

586 elif x == 0: 

587 n = (1 + (-1) ** self.xp.arange(self.N)) / 2 

588 n[2::4] *= -1 

589 return n 

590 else: 

591 raise NotImplementedError(f'Don\'t know how to generate Dirichlet BC\'s at {x=}!') 

592 

593 def get_Neumann_BC_row(self, x): 

594 """ 

595 Get a row for generating Neumann BCs at x with T polynomials. 

596 

597 Args: 

598 x (float): Position of the boundary condition 

599 

600 Returns: 

601 self.xp.ndarray: Row to put into a matrix 

602 """ 

603 n = self.xp.arange(self.N, dtype='D') 

604 nn = n**2 

605 if x == -1: 

606 me = nn 

607 me[1:] *= (-1) ** n[:-1] 

608 return me 

609 elif x == 1: 

610 return nn 

611 else: 

612 raise NotImplementedError(f'Don\'t know how to generate Neumann BC\'s at {x=}!') 

613 

614 def get_Dirichlet_recombination_matrix(self): 

615 ''' 

616 Get matrix for Dirichlet recombination, which changes the basis to have sparse boundary conditions. 

617 This makes for a good right preconditioner. 

618 

619 Returns: 

620 scipy.sparse: Sparse conversion matrix 

621 ''' 

622 N = self.N 

623 sp = self.sparse_lib 

624 

625 return sp.eye(N) - sp.eye(N, k=2) 

626 

627 

628class UltrasphericalHelper(ChebychevHelper): 

629 """ 

630 This implementation follows https://doi.org/10.1137/120865458. 

631 The ultraspherical method works in Chebychev polynomials as well, but also uses various Gegenbauer polynomials. 

632 The idea is that for every derivative of Chebychev T polynomials, there is a basis of Gegenbauer polynomials where the differentiation matrix is a single off-diagonal. 

633 There are also conversion operators from one derivative basis to the next that are sparse. 

634 

635 This basis is used like this: For every equation that you have, look for the highest derivative and bump all matrices to the correct basis. If your highest derivative is 2 and you have an identity, it needs to get bumped from 0 to 1 and from 1 to 2. If you have a first derivative as well, it needs to be bumped from 1 to 2. 

636 You don't need the same resulting basis in all equations. You just need to take care that you translate the right hand side to the correct basis as well. 

637 """ 

638 

639 def get_differentiation_matrix(self, p=1): 

640 """ 

641 Notice that while sparse, this matrix is not diagonal, which means the inversion cannot be parallelized easily. 

642 

643 Args: 

644 p (int): Order of the derivative 

645 

646 Returns: 

647 sparse differentiation matrix 

648 """ 

649 sp = self.sparse_lib 

650 xp = self.xp 

651 N = self.N 

652 l = p 

653 return 2 ** (l - 1) * factorial(l - 1) * sp.diags(xp.arange(N - l) + l, offsets=l) / self.lin_trf_fac**p 

654 

655 def get_S(self, lmbda): 

656 """ 

657 Get matrix for bumping the derivative base by one from lmbda to lmbda + 1. This is the same language as in 

658 https://doi.org/10.1137/120865458. 

659 

660 Args: 

661 lmbda (int): Ingoing derivative base 

662 

663 Returns: 

664 sparse matrix: Conversion from derivative base lmbda to lmbda + 1 

665 """ 

666 N = self.N 

667 

668 if lmbda == 0: 

669 sp = scipy.sparse 

670 mat = ((sp.eye(N) - sp.eye(N, k=2)) / 2.0).tolil() 

671 mat[:, 0] *= 2 

672 else: 

673 sp = self.sparse_lib 

674 xp = self.xp 

675 mat = sp.diags(lmbda / (lmbda + xp.arange(N))) - sp.diags( 

676 lmbda / (lmbda + 2 + xp.arange(N - 2)), offsets=+2 

677 ) 

678 

679 return self.sparse_lib.csc_matrix(mat) 

680 

681 def get_basis_change_matrix(self, p_in=0, p_out=0, **kwargs): 

682 """ 

683 Get a conversion matrix from derivative base `p_in` to `p_out`. 

684 

685 Args: 

686 p_out (int): Resulting derivative base 

687 p_in (int): Ingoing derivative base 

688 """ 

689 mat_fwd = self.sparse_lib.eye(self.N) 

690 for i in range(min([p_in, p_out]), max([p_in, p_out])): 

691 mat_fwd = self.get_S(i) @ mat_fwd 

692 

693 if p_out > p_in: 

694 return mat_fwd 

695 

696 else: 

697 # We have to invert the matrix on CPU because the GPU equivalent is not implemented in CuPy at the time of writing. 

698 import scipy.sparse as sp 

699 

700 if self.useGPU: 

701 mat_fwd = mat_fwd.get() 

702 

703 mat_bck = sp.linalg.inv(mat_fwd.tocsc()) 

704 

705 return self.sparse_lib.csc_matrix(mat_bck) 

706 

707 def get_integration_matrix(self): 

708 """ 

709 Get an integration matrix. Please use `UltrasphericalHelper.get_integration_constant` afterwards to compute the 

710 integration constant such that integration starts from x=-1. 

711 

712 Example: 

713 

714 .. code-block:: python 

715 

716 import numpy as np 

717 from pySDC.helpers.spectral_helper import UltrasphericalHelper 

718 

719 N = 4 

720 helper = UltrasphericalHelper(N) 

721 coeffs = np.random.random(N) 

722 coeffs[-1] = 0 

723 

724 poly = np.polynomial.Chebyshev(coeffs) 

725 

726 S = helper.get_integration_matrix() 

727 U_hat = S @ coeffs 

728 U_hat[0] = helper.get_integration_constant(U_hat, axis=-1) 

729 

730 assert np.allclose(poly.integ(lbnd=-1).coef[:-1], U_hat) 

731 

732 Returns: 

733 sparse integration matrix 

734 """ 

735 return ( 

736 self.sparse_lib.diags(1 / (self.xp.arange(self.N - 1) + 1), offsets=-1) 

737 @ self.get_basis_change_matrix(p_out=1, p_in=0) 

738 * self.lin_trf_fac 

739 ) 

740 

741 def get_integration_constant(self, u_hat, axis): 

742 """ 

743 Get integration constant for lower bound of -1. See documentation of `UltrasphericalHelper.get_integration_matrix` for details. 

744 

745 Args: 

746 u_hat: Solution in spectral space 

747 axis: Axis you want to integrate over 

748 

749 Returns: 

750 Integration constant, has one less dimension than `u_hat` 

751 """ 

752 slices = [ 

753 None, 

754 ] * u_hat.ndim 

755 slices[axis] = slice(1, u_hat.shape[axis]) 

756 return self.xp.sum(u_hat[(*slices,)] * (-1) ** (self.xp.arange(u_hat.shape[axis] - 1)), axis=axis) 

757 

758 

759class FFTHelper(SpectralHelper1D): 

760 distributable = True 

761 

762 def __init__(self, *args, x0=0, x1=2 * np.pi, **kwargs): 

763 """ 

764 Constructor. 

765 Please refer to the parent class for additional arguments. Notably, you have to supply a resolution `N` and you 

766 may choose to run on GPUs via the `useGPU` argument. 

767 

768 Args: 

769 x0 (float, optional): Coordinate of left boundary 

770 x1 (float, optional): Coordinate of right boundary 

771 """ 

772 super().__init__(*args, x0=x0, x1=x1, **kwargs) 

773 

774 def get_1dgrid(self): 

775 """ 

776 We use equally spaced points including the left boundary and not including the right one, which is the left boundary. 

777 """ 

778 dx = self.L / self.N 

779 return self.xp.arange(self.N) * dx + self.x0 

780 

781 def get_wavenumbers(self): 

782 """ 

783 Be careful that this ordering is very unintuitive. 

784 """ 

785 return self.xp.fft.fftfreq(self.N, 1.0 / self.N) * 2 * np.pi / self.L 

786 

787 def get_differentiation_matrix(self, p=1): 

788 """ 

789 This matrix is diagonal, allowing to invert concurrently. 

790 

791 Args: 

792 p (int): Order of the derivative 

793 

794 Returns: 

795 sparse differentiation matrix 

796 """ 

797 k = self.get_wavenumbers() 

798 

799 if self.useGPU: 

800 if p > 1: 

801 # Have to raise the matrix to power p on CPU because the GPU equivalent is not implemented in CuPy at the time of writing. 

802 from scipy.sparse.linalg import matrix_power 

803 

804 D = self.sparse_lib.diags(1j * k).get() 

805 return self.sparse_lib.csc_matrix(matrix_power(D, p)) 

806 else: 

807 return self.sparse_lib.diags(1j * k) 

808 else: 

809 return self.linalg.matrix_power(self.sparse_lib.diags(1j * k), p) 

810 

811 def get_integration_matrix(self, p=1): 

812 """ 

813 Get integration matrix to compute `p`-th integral over the entire domain. 

814 

815 Args: 

816 p (int): Order of integral you want to compute 

817 

818 Returns: 

819 sparse integration matrix 

820 """ 

821 k = self.xp.array(self.get_wavenumbers(), dtype='complex128') 

822 k[0] = 1j * self.L 

823 return self.linalg.matrix_power(self.sparse_lib.diags(1 / (1j * k)), p) 

824 

825 def get_integration_weights(self): 

826 """Weights for integration across entire domain""" 

827 weights = self.xp.zeros(self.N) 

828 weights[0] = self.L / self.N 

829 return weights 

830 

831 def get_plan(self, u, forward, *args, **kwargs): 

832 if self.fft_lib.__name__ == 'mpi4py_fft.fftw': 

833 if 'axes' in kwargs.keys(): 

834 kwargs['axes'] = tuple(kwargs['axes']) 

835 key = (forward, u.shape, args, *(me for me in kwargs.values())) 

836 if key in self.plans.keys(): 

837 return self.plans[key] 

838 else: 

839 self.logger.debug(f'Generating FFT plan for {key=}') 

840 transform = self.fft_lib.fftn(u, *args, **kwargs) if forward else self.fft_lib.ifftn(u, *args, **kwargs) 

841 self.plans[key] = transform 

842 

843 return self.plans[key] 

844 else: 

845 if forward: 

846 return partial(self.fft_lib.fftn, norm=kwargs.get('norm', 'backward')) 

847 else: 

848 return partial(self.fft_lib.ifftn, norm=kwargs.get('norm', 'forward')) 

849 

850 def transform(self, u, *args, axes=None, shape=None, **kwargs): 

851 """ 

852 FFT along axes. `kwargs` are passed on to the FFT library. 

853 

854 Args: 

855 u: Data you want to transform 

856 axes (tuple): Axes you want to transform over 

857 

858 Returns: 

859 transformed data 

860 """ 

861 axes = axes if axes else tuple(i for i in range(u.ndim)) 

862 kwargs['s'] = shape 

863 plan = self.get_plan(u, *args, forward=True, axes=axes, **kwargs) 

864 return plan(u, *args, axes=axes, **kwargs) 

865 

866 def itransform(self, u, *args, axes=None, shape=None, **kwargs): 

867 """ 

868 Inverse FFT. 

869 

870 Args: 

871 u: Data you want to transform 

872 axes (tuple): Axes over which to transform 

873 

874 Returns: 

875 transformed data 

876 """ 

877 axes = axes if axes else tuple(i for i in range(u.ndim)) 

878 kwargs['s'] = shape 

879 plan = self.get_plan(u, *args, forward=False, axes=axes, **kwargs) 

880 return plan(u, *args, axes=axes, **kwargs) / np.prod([u.shape[axis] for axis in axes]) 

881 

882 def get_BC(self, kind): 

883 """ 

884 Get a sort of boundary condition. You can use `kind=integral`, to fix the integral, or you can use `kind=Nyquist`. 

885 The latter is not really a boundary condition, but is used to set the Nyquist mode to some value, preferably zero. 

886 You should set the Nyquist mode zero when the solution in physical space is real and the resolution is even. 

887 

888 Args: 

889 kind ('integral' or 'nyquist'): Kind of BC 

890 

891 Returns: 

892 self.xp.ndarray: Boundary condition row 

893 """ 

894 if kind.lower() == 'integral': 

895 return self.get_integ_BC_row() 

896 elif kind.lower() == 'nyquist': 

897 assert ( 

898 self.N % 2 == 0 

899 ), f'Do not eliminate the Nyquist mode with odd resolution as it is fully resolved. You chose {self.N} in this axis' 

900 BC = self.xp.zeros(self.N) 

901 BC[self.get_Nyquist_mode_index()] = 1 

902 return BC 

903 else: 

904 return super().get_BC(kind) 

905 

906 def get_Nyquist_mode_index(self): 

907 """ 

908 Compute the index of the Nyquist mode, i.e. the mode with the lowest wavenumber, which doesn't have a positive 

909 counterpart for even resolution. This means real waves of this wave number cannot be properly resolved and you 

910 are best advised to set this mode zero if representing real functions on even-resolution grids is what you're 

911 after. 

912 

913 Returns: 

914 int: Index of the Nyquist mode 

915 """ 

916 k = self.get_wavenumbers() 

917 Nyquist_mode = min(k) 

918 return self.xp.where(k == Nyquist_mode)[0][0] 

919 

920 def get_integ_BC_row(self): 

921 """ 

922 Only the 0-mode has non-zero integral with FFT basis in periodic BCs 

923 """ 

924 me = self.xp.zeros(self.N) 

925 me[0] = self.L / self.N 

926 return me 

927 

928 

929class SpectralHelper: 

930 """ 

931 This class has three functions: 

932 - Easily assemble matrices containing multiple equations 

933 - Direct product of 1D bases to solve problems in more dimensions 

934 - Distribute the FFTs to facilitate concurrency. 

935 

936 Attributes: 

937 comm (mpi4py.Intracomm): MPI communicator 

938 debug (bool): Perform additional checks at extra computational cost 

939 useGPU (bool): Whether to use GPUs 

940 axes (list): List of 1D bases 

941 components (list): List of strings of the names of components in the equations 

942 full_BCs (list): List of Dictionaries containing all information about the boundary conditions 

943 BC_mat (list): List of lists of sparse matrices to put BCs into and eventually assemble the BC matrix from 

944 BCs (sparse matrix): Matrix containing only the BCs 

945 fft_cache (dict): Cache FFTs of various shapes here to facilitate padding and so on 

946 BC_rhs_mask (self.xp.ndarray): Mask values that contain boundary conditions in the right hand side 

947 BC_zero_index (self.xp.ndarray): Indeces of rows in the matrix that are replaced by BCs 

948 BC_line_zero_matrix (sparse matrix): Matrix that zeros rows where we can then add the BCs in using `BCs` 

949 rhs_BCs_hat (self.xp.ndarray): Boundary conditions in spectral space 

950 global_shape (tuple): Global shape of the solution as in `mpi4py-fft` 

951 fft_obj: When using distributed FFTs, this will be a parallel transform object from `mpi4py-fft` 

952 init (tuple): This is the same `init` that is used throughout the problem classes 

953 init_forward (tuple): This is the equivalent of `init` in spectral space 

954 """ 

955 

956 xp = np 

957 fft_lib = scipy.fft 

958 sparse_lib = scipy.sparse 

959 linalg = scipy.sparse.linalg 

960 dtype = mesh 

961 fft_backend = 'scipy' 

962 fft_comm_backend = 'MPI' 

963 

964 def setup_GPU(self): 

965 """switch to GPU modules""" 

966 import cupy as cp 

967 import cupyx.scipy.sparse as sparse_lib 

968 import cupyx.scipy.sparse.linalg as linalg 

969 import cupyx.scipy.fft as fft_lib 

970 from pySDC.implementations.datatype_classes.cupy_mesh import cupy_mesh 

971 

972 self.xp = cp 

973 self.sparse_lib = sparse_lib 

974 self.linalg = linalg 

975 

976 self.fft_lib = fft_lib 

977 self.fft_backend = 'cupyx-scipy' 

978 self.fft_comm_backend = 'NCCL' 

979 

980 self.dtype = cupy_mesh 

981 

982 @classmethod 

983 def setup_CPU(cls, useFFTW=False): 

984 """switch to CPU modules""" 

985 

986 cls.xp = np 

987 cls.sparse_lib = scipy.sparse 

988 cls.linalg = scipy.sparse.linalg 

989 

990 if useFFTW: 

991 from mpi4py_fft import fftw 

992 

993 cls.fft_backend = 'fftw' 

994 cls.fft_lib = fftw 

995 else: 

996 cls.fft_backend = 'scipy' 

997 cls.fft_lib = scipy.fft 

998 

999 cls.fft_comm_backend = 'MPI' 

1000 cls.dtype = mesh 

1001 

1002 def __init__(self, comm=None, useGPU=False, debug=False): 

1003 """ 

1004 Constructor 

1005 

1006 Args: 

1007 comm (mpi4py.Intracomm): MPI communicator 

1008 useGPU (bool): Whether to use GPUs 

1009 debug (bool): Perform additional checks at extra computational cost 

1010 """ 

1011 self.comm = comm 

1012 self.debug = debug 

1013 self.useGPU = useGPU 

1014 

1015 if useGPU: 

1016 self.setup_GPU() 

1017 else: 

1018 self.setup_CPU() 

1019 

1020 self.axes = [] 

1021 self.components = [] 

1022 

1023 self.full_BCs = [] 

1024 self.BC_mat = None 

1025 self.BCs = None 

1026 

1027 self.fft_cache = {} 

1028 

1029 self.logger = logging.getLogger(name='Spectral Discretization') 

1030 if debug: 

1031 self.logger.setLevel(logging.DEBUG) 

1032 

1033 @property 

1034 def u_init(self): 

1035 """ 

1036 Get empty data container in physical space 

1037 """ 

1038 return self.dtype(self.init) 

1039 

1040 @property 

1041 def u_init_forward(self): 

1042 """ 

1043 Get empty data container in spectral space 

1044 """ 

1045 return self.dtype(self.init_forward) 

1046 

1047 @property 

1048 def u_init_physical(self): 

1049 """ 

1050 Get empty data container in physical space 

1051 """ 

1052 return self.dtype(self.init_physical) 

1053 

1054 @property 

1055 def shape(self): 

1056 """ 

1057 Get shape of individual solution component 

1058 """ 

1059 return self.init[0][1:] 

1060 

1061 @property 

1062 def ndim(self): 

1063 return len(self.axes) 

1064 

1065 @property 

1066 def ncomponents(self): 

1067 return len(self.components) 

1068 

1069 @property 

1070 def V(self): 

1071 """ 

1072 Get domain volume 

1073 """ 

1074 return np.prod([me.L for me in self.axes]) 

1075 

1076 def add_axis(self, base, *args, **kwargs): 

1077 """ 

1078 Add an axis to the domain by deciding on suitable 1D base. 

1079 Arguments to the bases are forwarded using `*args` and `**kwargs`. Please refer to the documentation of the 1D 

1080 bases for possible arguments. 

1081 

1082 Args: 

1083 base (str): 1D spectral method 

1084 """ 

1085 kwargs['useGPU'] = self.useGPU 

1086 

1087 if base.lower() in ['chebychov', 'chebychev', 'cheby', 'chebychovhelper']: 

1088 self.axes.append(ChebychevHelper(*args, **kwargs)) 

1089 elif base.lower() in ['fft', 'fourier', 'ffthelper']: 

1090 self.axes.append(FFTHelper(*args, **kwargs)) 

1091 elif base.lower() in ['ultraspherical', 'gegenbauer']: 

1092 self.axes.append(UltrasphericalHelper(*args, **kwargs)) 

1093 else: 

1094 raise NotImplementedError(f'{base=!r} is not implemented!') 

1095 self.axes[-1].xp = self.xp 

1096 self.axes[-1].sparse_lib = self.sparse_lib 

1097 

1098 def add_component(self, name): 

1099 """ 

1100 Add solution component(s). 

1101 

1102 Args: 

1103 name (str or list of strings): Name(s) of component(s) 

1104 """ 

1105 if type(name) in [list, tuple]: 

1106 for me in name: 

1107 self.add_component(me) 

1108 elif type(name) in [str]: 

1109 if name in self.components: 

1110 raise Exception(f'{name=!r} is already added to this problem!') 

1111 self.components.append(name) 

1112 else: 

1113 raise NotImplementedError 

1114 

1115 def index(self, name): 

1116 """ 

1117 Get the index of component `name`. 

1118 

1119 Args: 

1120 name (str or list of strings): Name(s) of component(s) 

1121 

1122 Returns: 

1123 int: Index of the component 

1124 """ 

1125 if type(name) in [str, int]: 

1126 return self.components.index(name) 

1127 elif type(name) in [list, tuple]: 

1128 return (self.index(me) for me in name) 

1129 else: 

1130 raise NotImplementedError(f'Don\'t know how to compute index for {type(name)=}') 

1131 

1132 def get_empty_operator_matrix(self, diag=False): 

1133 """ 

1134 Return a matrix of operators to be filled with the connections between the solution components. 

1135 

1136 Args: 

1137 diag (bool): Whether operator is block-diagonal 

1138 

1139 Returns: 

1140 list containing sparse zeros 

1141 """ 

1142 S = len(self.components) 

1143 O = self.get_Id() * 0 

1144 if diag: 

1145 return [O for _ in range(S)] 

1146 else: 

1147 return [[O for _ in range(S)] for _ in range(S)] 

1148 

1149 def get_BC(self, axis, kind, line=-1, scalar=False, **kwargs): 

1150 """ 

1151 Use this method for boundary bordering. It gets the respective matrix row and embeds it into a matrix. 

1152 Pay attention that if you have multiple BCs in a single equation, you need to put them in different lines. 

1153 Typically, the last line that does not contain a BC is the best choice. 

1154 Forward arguments for the boundary conditions using `kwargs`. Refer to documentation of 1D bases for details. 

1155 

1156 Args: 

1157 axis (int): Axis you want to add the BC to 

1158 kind (str): kind of BC, e.g. Dirichlet 

1159 line (int): Line you want the BC to go in 

1160 scalar (bool): Put the BC in all space positions in the other direction 

1161 

1162 Returns: 

1163 sparse matrix containing the BC 

1164 """ 

1165 sp = scipy.sparse 

1166 

1167 base = self.axes[axis] 

1168 

1169 BC = sp.eye(base.N).tolil() * 0 

1170 if self.useGPU: 

1171 BC[line, :] = base.get_BC(kind=kind, **kwargs).get() 

1172 else: 

1173 BC[line, :] = base.get_BC(kind=kind, **kwargs) 

1174 

1175 ndim = len(self.axes) 

1176 if ndim == 1: 

1177 mat = self.sparse_lib.csc_matrix(BC) 

1178 elif ndim == 2: 

1179 axis2 = (axis + 1) % ndim 

1180 

1181 if scalar: 

1182 _Id = self.sparse_lib.diags(self.xp.append([1], self.xp.zeros(self.axes[axis2].N - 1))) 

1183 else: 

1184 _Id = self.axes[axis2].get_Id() 

1185 

1186 Id = self.get_local_slice_of_1D_matrix(self.axes[axis2].get_Id() @ _Id, axis=axis2) 

1187 

1188 mats = [ 

1189 None, 

1190 ] * ndim 

1191 mats[axis] = self.get_local_slice_of_1D_matrix(BC, axis=axis) 

1192 mats[axis2] = Id 

1193 mat = self.sparse_lib.csc_matrix(self.sparse_lib.kron(*mats)) 

1194 elif ndim == 3: 

1195 mats = [ 

1196 None, 

1197 ] * ndim 

1198 

1199 for ax in range(ndim): 

1200 if ax == axis: 

1201 continue 

1202 

1203 if scalar: 

1204 _Id = self.sparse_lib.diags(self.xp.append([1], self.xp.zeros(self.axes[ax].N - 1))) 

1205 else: 

1206 _Id = self.axes[ax].get_Id() 

1207 

1208 mats[ax] = self.get_local_slice_of_1D_matrix(self.axes[ax].get_Id() @ _Id, axis=ax) 

1209 

1210 mats[axis] = self.get_local_slice_of_1D_matrix(BC, axis=axis) 

1211 

1212 mat = self.sparse_lib.csc_matrix(self.sparse_lib.kron(mats[0], self.sparse_lib.kron(*mats[1:]))) 

1213 else: 

1214 raise NotImplementedError( 

1215 f'Matrix expansion for boundary conditions not implemented for {ndim} dimensions!' 

1216 ) 

1217 mat = self.eliminate_zeros(mat) 

1218 return mat 

1219 

1220 def remove_BC(self, component, equation, axis, kind, line=-1, scalar=False, **kwargs): 

1221 """ 

1222 Remove a BC from the matrix. This is useful e.g. when you add a non-scalar BC and then need to selectively 

1223 remove single BCs again, as in incompressible Navier-Stokes, for instance. 

1224 Forwards arguments for the boundary conditions using `kwargs`. Refer to documentation of 1D bases for details. 

1225 

1226 Args: 

1227 component (str): Name of the component the BC should act on 

1228 equation (str): Name of the equation for the component you want to put the BC in 

1229 axis (int): Axis you want to add the BC to 

1230 kind (str): kind of BC, e.g. Dirichlet 

1231 v: Value of the BC 

1232 line (int): Line you want the BC to go in 

1233 scalar (bool): Put the BC in all space positions in the other direction 

1234 """ 

1235 _BC = self.get_BC(axis=axis, kind=kind, line=line, scalar=scalar, **kwargs) 

1236 _BC = self.eliminate_zeros(_BC) 

1237 self.BC_mat[self.index(equation)][self.index(component)] -= _BC 

1238 

1239 if scalar: 

1240 slices = [self.index(equation)] + [ 

1241 0, 

1242 ] * self.ndim 

1243 slices[axis + 1] = line 

1244 else: 

1245 slices = ( 

1246 [self.index(equation)] 

1247 + [slice(0, self.init[0][i + 1]) for i in range(axis)] 

1248 + [line] 

1249 + [slice(0, self.init[0][i + 1]) for i in range(axis + 1, len(self.axes))] 

1250 ) 

1251 N = self.axes[axis].N 

1252 if (N + line) % N in self.xp.arange(N)[self.local_slice()[axis]]: 

1253 self.BC_rhs_mask[(*slices,)] = False 

1254 

1255 def add_BC(self, component, equation, axis, kind, v, line=-1, scalar=False, **kwargs): 

1256 """ 

1257 Add a BC to the matrix. Note that you need to convert the list of lists of BCs that this method generates to a 

1258 single sparse matrix by calling `setup_BCs` after adding/removing all BCs. 

1259 Forward arguments for the boundary conditions using `kwargs`. Refer to documentation of 1D bases for details. 

1260 

1261 Args: 

1262 component (str): Name of the component the BC should act on 

1263 equation (str): Name of the equation for the component you want to put the BC in 

1264 axis (int): Axis you want to add the BC to 

1265 kind (str): kind of BC, e.g. Dirichlet 

1266 v: Value of the BC 

1267 line (int): Line you want the BC to go in 

1268 scalar (bool): Put the BC in all space positions in the other direction 

1269 """ 

1270 _BC = self.get_BC(axis=axis, kind=kind, line=line, scalar=scalar, **kwargs) 

1271 self.BC_mat[self.index(equation)][self.index(component)] += _BC 

1272 self.full_BCs += [ 

1273 { 

1274 'component': component, 

1275 'equation': equation, 

1276 'axis': axis, 

1277 'kind': kind, 

1278 'v': v, 

1279 'line': line, 

1280 'scalar': scalar, 

1281 **kwargs, 

1282 } 

1283 ] 

1284 

1285 if scalar: 

1286 slices = [self.index(equation)] + [ 

1287 0, 

1288 ] * self.ndim 

1289 slices[axis + 1] = line 

1290 if self.comm: 

1291 if self.comm.rank == 0: 

1292 self.BC_rhs_mask[(*slices,)] = True 

1293 else: 

1294 self.BC_rhs_mask[(*slices,)] = True 

1295 else: 

1296 slices = [self.index(equation), *self.global_slice(True)] 

1297 N = self.axes[axis].N 

1298 if (N + line) % N in self.get_indices(True)[axis]: 

1299 slices[axis + 1] = (N + line) % N - self.local_slice()[axis].start 

1300 self.BC_rhs_mask[(*slices,)] = True 

1301 

1302 def setup_BCs(self): 

1303 """ 

1304 Convert the list of lists of BCs to the boundary condition operator. 

1305 Also, boundary bordering requires to zero out all other entries in the matrix in rows containing a boundary 

1306 condition. This method sets up a suitable sparse matrix to do this. 

1307 """ 

1308 sp = self.sparse_lib 

1309 self.BCs = self.convert_operator_matrix_to_operator(self.BC_mat) 

1310 self.BC_zero_index = self.xp.arange(np.prod(self.init[0]))[self.BC_rhs_mask.flatten()] 

1311 

1312 diags = self.xp.ones(self.BCs.shape[0]) 

1313 diags[self.BC_zero_index] = 0 

1314 self.BC_line_zero_matrix = sp.diags(diags).tocsc() 

1315 

1316 # prepare BCs in spectral space to easily add to the RHS 

1317 rhs_BCs = self.put_BCs_in_rhs(self.u_init) 

1318 self.rhs_BCs_hat = self.transform(rhs_BCs).view(self.xp.ndarray) 

1319 del self.BC_rhs_mask 

1320 

1321 def check_BCs(self, u): 

1322 """ 

1323 Check that the solution satisfies the boundary conditions 

1324 

1325 Args: 

1326 u: The solution you want to check 

1327 """ 

1328 assert self.ndim < 3 

1329 for axis in range(self.ndim): 

1330 BCs = [me for me in self.full_BCs if me["axis"] == axis and not me["scalar"]] 

1331 

1332 if len(BCs) > 0: 

1333 u_hat = self.transform(u, axes=(axis - self.ndim,)) 

1334 for BC in BCs: 

1335 kwargs = { 

1336 key: value 

1337 for key, value in BC.items() 

1338 if key not in ['component', 'equation', 'axis', 'v', 'line', 'scalar'] 

1339 } 

1340 

1341 if axis == 0: 

1342 get = self.axes[axis].get_BC(**kwargs) @ u_hat[self.index(BC['component'])] 

1343 elif axis == 1: 

1344 get = u_hat[self.index(BC['component'])] @ self.axes[axis].get_BC(**kwargs) 

1345 want = BC['v'] 

1346 assert self.xp.allclose( 

1347 get, want 

1348 ), f'Unexpected BC in {BC["component"]} in equation {BC["equation"]}, line {BC["line"]}! Got {get}, wanted {want}' 

1349 

1350 def put_BCs_in_matrix(self, A): 

1351 """ 

1352 Put the boundary conditions in a matrix by replacing rows with BCs. 

1353 """ 

1354 return self.BC_line_zero_matrix @ A + self.BCs 

1355 

1356 def put_BCs_in_rhs_hat(self, rhs_hat): 

1357 """ 

1358 Put the BCs in the right hand side in spectral space for solving. 

1359 This function needs no transforms and caches a mask for faster subsequent use. 

1360 

1361 Args: 

1362 rhs_hat: Right hand side in spectral space 

1363 

1364 Returns: 

1365 rhs in spectral space with BCs 

1366 """ 

1367 if not hasattr(self, '_rhs_hat_zero_mask'): 

1368 """ 

1369 Generate a mask where we need to set values in the rhs in spectral space to zero, such that can replace them 

1370 by the boundary conditions. The mask is then cached. 

1371 """ 

1372 self._rhs_hat_zero_mask = self.newDistArray(forward_output=True).astype(bool).view(self.xp.ndarray) 

1373 

1374 for axis in range(self.ndim): 

1375 for bc in self.full_BCs: 

1376 if axis == bc['axis']: 

1377 slices = [self.index(bc['equation']), *self.global_slice(True)] 

1378 N = self.axes[axis].N 

1379 line = bc['line'] 

1380 if (N + line) % N in self.get_indices(True)[axis]: 

1381 slices[axis + 1] = (N + line) % N - self.local_slice()[axis].start 

1382 self._rhs_hat_zero_mask[(*slices,)] = True 

1383 

1384 rhs_hat[self._rhs_hat_zero_mask] = 0 

1385 return rhs_hat + self.rhs_BCs_hat 

1386 

1387 def put_BCs_in_rhs(self, rhs): 

1388 """ 

1389 Put the BCs in the right hand side for solving. 

1390 This function will transform along each axis individually and add all BCs in that axis. 

1391 Consider `put_BCs_in_rhs_hat` to add BCs with no extra transforms needed. 

1392 

1393 Args: 

1394 rhs: Right hand side in physical space 

1395 

1396 Returns: 

1397 rhs in physical space with BCs 

1398 """ 

1399 assert rhs.ndim > 1, 'rhs must not be flattened here!' 

1400 

1401 ndim = self.ndim 

1402 

1403 for axis in range(ndim): 

1404 _rhs_hat = self.transform(rhs, axes=(axis - ndim,)) 

1405 

1406 for bc in self.full_BCs: 

1407 

1408 if axis == bc['axis']: 

1409 _slice = [self.index(bc['equation']), *self.global_slice(True)] 

1410 

1411 N = self.axes[axis].N 

1412 line = bc['line'] 

1413 if (N + line) % N in self.get_indices(True)[axis]: 

1414 _slice[axis + 1] = (N + line) % N - self.local_slice()[axis].start 

1415 _rhs_hat[(*_slice,)] = bc['v'] 

1416 

1417 rhs = self.itransform(_rhs_hat, axes=(axis - ndim,)) 

1418 

1419 return rhs 

1420 

1421 def add_equation_lhs(self, A, equation, relations): 

1422 """ 

1423 Add the left hand part (that you want to solve implicitly) of an equation to a list of lists of sparse matrices 

1424 that you will convert to an operator later. 

1425 

1426 Example: 

1427 Setup linear operator `L` for 1D heat equation using Chebychev method in first order form and T-to-U 

1428 preconditioning: 

1429 

1430 .. code-block:: python 

1431 helper = SpectralHelper() 

1432 

1433 helper.add_axis(base='chebychev', N=8) 

1434 helper.add_component(['u', 'ux']) 

1435 helper.setup_fft() 

1436 

1437 I = helper.get_Id() 

1438 Dx = helper.get_differentiation_matrix(axes=(0,)) 

1439 T2U = helper.get_basis_change_matrix('T2U') 

1440 

1441 L_lhs = { 

1442 'ux': {'u': -T2U @ Dx, 'ux': T2U @ I}, 

1443 'u': {'ux': -(T2U @ Dx)}, 

1444 } 

1445 

1446 operator = helper.get_empty_operator_matrix() 

1447 for line, equation in L_lhs.items(): 

1448 helper.add_equation_lhs(operator, line, equation) 

1449 

1450 L = helper.convert_operator_matrix_to_operator(operator) 

1451 

1452 Args: 

1453 A (list of lists of sparse matrices): The operator to be 

1454 equation (str): The equation of the component you want this in 

1455 relations: (dict): Relations between quantities 

1456 """ 

1457 for k, v in relations.items(): 

1458 A[self.index(equation)][self.index(k)] = v 

1459 

1460 def eliminate_zeros(self, A): 

1461 """ 

1462 Eliminate zeros from sparse matrix. This can reduce memory footprint of matrices somewhat. 

1463 Note: At the time of writing, there are memory problems in the cupy implementation of `eliminate_zeros`. 

1464 Therefore, this function copies the matrix to host, eliminates the zeros there and then copies back to GPU. 

1465 

1466 Args: 

1467 A: sparse matrix to be pruned 

1468 

1469 Returns: 

1470 CSC sparse matrix 

1471 """ 

1472 if self.useGPU: 

1473 A = A.get() 

1474 A = A.tocsc() 

1475 A.eliminate_zeros() 

1476 if self.useGPU: 

1477 A = self.sparse_lib.csc_matrix(A) 

1478 return A 

1479 

1480 def convert_operator_matrix_to_operator(self, M): 

1481 """ 

1482 Promote the list of lists of sparse matrices to a single sparse matrix that can be used as linear operator. 

1483 See documentation of `SpectralHelper.add_equation_lhs` for an example. 

1484 

1485 Args: 

1486 M (list of lists of sparse matrices): The operator to be 

1487 

1488 Returns: 

1489 sparse linear operator 

1490 """ 

1491 if len(self.components) == 1: 

1492 op = M[0][0] 

1493 else: 

1494 op = self.sparse_lib.bmat(M, format='csc') 

1495 

1496 op = self.eliminate_zeros(op) 

1497 return op 

1498 

1499 def get_wavenumbers(self): 

1500 """ 

1501 Get grid in spectral space 

1502 """ 

1503 grids = [self.axes[i].get_wavenumbers()[self.local_slice(True)[i]] for i in range(len(self.axes))] 

1504 return self.xp.meshgrid(*grids, indexing='ij') 

1505 

1506 def get_grid(self, forward_output=False): 

1507 """ 

1508 Get grid in physical space 

1509 """ 

1510 grids = [self.axes[i].get_1dgrid()[self.local_slice(forward_output)[i]] for i in range(len(self.axes))] 

1511 return self.xp.meshgrid(*grids, indexing='ij') 

1512 

1513 def get_indices(self, forward_output=True): 

1514 return [self.xp.arange(self.axes[i].N)[self.local_slice(forward_output)[i]] for i in range(len(self.axes))] 

1515 

1516 @cache 

1517 def get_pfft(self, axes=None, padding=None, grid=None): 

1518 if self.ndim == 1 or self.comm is None: 

1519 return None 

1520 from mpi4py_fft import newDistArray 

1521 

1522 from pySDC.helpers.fft_helper import PFFT 

1523 

1524 axes = tuple(i for i in range(self.ndim)) if axes is None else axes 

1525 padding = list(padding if padding else [1.0 for _ in range(self.ndim)]) 

1526 

1527 def no_transform(u, *args, **kwargs): 

1528 return u 

1529 

1530 transforms = {(i,): (no_transform, no_transform) for i in range(self.ndim)} 

1531 for i in axes: 

1532 transforms[((i + self.ndim) % self.ndim,)] = (self.axes[i].transform, self.axes[i].itransform) 

1533 

1534 # "transform" all axes to ensure consistent shapes. 

1535 # Transform non-distributable axes last to ensure they are aligned 

1536 _axes = tuple(sorted((axis + self.ndim) % self.ndim for axis in axes)) 

1537 _axes = [axis for axis in _axes if not self.axes[axis].distributable] + sorted( 

1538 [axis for axis in _axes if self.axes[axis].distributable] 

1539 + [axis for axis in range(self.ndim) if axis not in _axes] 

1540 ) 

1541 

1542 pfft = PFFT( 

1543 comm=self.comm, 

1544 shape=self.global_shape[1:], 

1545 axes=_axes, # TODO: control the order of the transforms better 

1546 dtype='D', 

1547 collapse=False, 

1548 backend=self.fft_backend, 

1549 comm_backend=self.fft_comm_backend, 

1550 padding=padding, 

1551 transforms=transforms, 

1552 grid=grid, 

1553 ) 

1554 

1555 # do a transform to do the planning 

1556 _u = newDistArray(pfft, forward_output=False) 

1557 pfft.backward(pfft.forward(_u)) 

1558 return pfft 

1559 

1560 def get_fft(self, axes=None, direction='object', padding=None, shape=None): 

1561 """ 

1562 When using MPI, we use `PFFT` objects generated by mpi4py-fft 

1563 

1564 Args: 

1565 axes (tuple): Axes you want to transform over 

1566 direction (str): use "forward" or "backward" to get functions for performing the transforms or "object" to get the PFFT object 

1567 padding (tuple): Padding for dealiasing 

1568 shape (tuple): Shape of the transform 

1569 

1570 Returns: 

1571 transform 

1572 """ 

1573 axes = tuple(-i - 1 for i in range(self.ndim)) if axes is None else axes 

1574 shape = self.global_shape[1:] if shape is None else shape 

1575 padding = ( 

1576 [ 

1577 1, 

1578 ] 

1579 * self.ndim 

1580 if padding is None 

1581 else padding 

1582 ) 

1583 key = (axes, direction, tuple(padding), tuple(shape)) 

1584 

1585 if key not in self.fft_cache.keys(): 

1586 if self.comm is None: 

1587 assert np.allclose(padding, 1), 'Zero padding is not implemented for non-MPI transforms' 

1588 

1589 if direction == 'forward': 

1590 self.fft_cache[key] = self.xp.fft.fftn 

1591 elif direction == 'backward': 

1592 self.fft_cache[key] = self.xp.fft.ifftn 

1593 elif direction == 'object': 

1594 self.fft_cache[key] = None 

1595 else: 

1596 if direction == 'object': 

1597 from pySDC.helpers.fft_helper import PFFT 

1598 

1599 _fft = PFFT( 

1600 comm=self.comm, 

1601 shape=shape, 

1602 axes=sorted(axes), 

1603 dtype='D', 

1604 collapse=False, 

1605 backend=self.fft_backend, 

1606 comm_backend=self.fft_comm_backend, 

1607 padding=padding, 

1608 ) 

1609 else: 

1610 _fft = self.get_fft(axes=axes, direction='object', padding=padding, shape=shape) 

1611 

1612 if direction == 'forward': 

1613 self.fft_cache[key] = _fft.forward 

1614 elif direction == 'backward': 

1615 self.fft_cache[key] = _fft.backward 

1616 elif direction == 'object': 

1617 self.fft_cache[key] = _fft 

1618 

1619 return self.fft_cache[key] 

1620 

1621 def local_slice(self, forward_output=True): 

1622 if self.fft_obj: 

1623 return self.get_pfft().local_slice(forward_output=forward_output) 

1624 else: 

1625 return [slice(0, me.N) for me in self.axes] 

1626 

1627 def global_slice(self, forward_output=True): 

1628 if self.fft_obj: 

1629 return [slice(0, me) for me in self.fft_obj.global_shape(forward_output=forward_output)] 

1630 else: 

1631 return self.local_slice(forward_output=forward_output) 

1632 

1633 def setup_fft(self, real_spectral_coefficients=False): 

1634 """ 

1635 This function must be called after all axes have been setup in order to prepare the local shapes of the data. 

1636 This must also be called before setting up any BCs. 

1637 

1638 Args: 

1639 real_spectral_coefficients (bool): Allow only real coefficients in spectral space 

1640 """ 

1641 if len(self.components) == 0: 

1642 self.add_component('u') 

1643 

1644 self.global_shape = (len(self.components),) + tuple(me.N for me in self.axes) 

1645 

1646 axes = tuple(i for i in range(len(self.axes))) 

1647 self.fft_obj = self.get_pfft(axes=axes) 

1648 

1649 self.init = ( 

1650 np.empty(shape=self.global_shape)[ 

1651 ( 

1652 ..., 

1653 *self.local_slice(False), 

1654 ) 

1655 ].shape, 

1656 self.comm, 

1657 np.dtype('float'), 

1658 ) 

1659 self.init_physical = ( 

1660 np.empty(shape=self.global_shape)[ 

1661 ( 

1662 ..., 

1663 *self.local_slice(False), 

1664 ) 

1665 ].shape, 

1666 self.comm, 

1667 np.dtype('float'), 

1668 ) 

1669 self.init_forward = ( 

1670 np.empty(shape=self.global_shape)[ 

1671 ( 

1672 ..., 

1673 *self.local_slice(True), 

1674 ) 

1675 ].shape, 

1676 self.comm, 

1677 np.dtype('float') if real_spectral_coefficients else np.dtype('complex128'), 

1678 ) 

1679 

1680 self.BC_mat = self.get_empty_operator_matrix() 

1681 self.BC_rhs_mask = self.newDistArray().astype(bool) 

1682 

1683 def newDistArray(self, pfft=None, forward_output=True, val=0, rank=1, view=False): 

1684 """ 

1685 Get an empty distributed array. This is almost a copy of the function of the same name from mpi4py-fft, but 

1686 takes care of all the solution components in the tensor. 

1687 """ 

1688 if self.comm is None: 

1689 return self.xp.zeros(self.init[0], dtype=self.init[2]) 

1690 from mpi4py_fft.distarray import DistArray 

1691 

1692 pfft = pfft if pfft else self.get_pfft() 

1693 if pfft is None: 

1694 if forward_output: 

1695 return self.u_init_forward 

1696 else: 

1697 return self.u_init 

1698 

1699 global_shape = pfft.global_shape(forward_output) 

1700 p0 = pfft.pencil[forward_output] 

1701 if forward_output is True: 

1702 dtype = pfft.forward.output_array.dtype 

1703 else: 

1704 dtype = pfft.forward.input_array.dtype 

1705 global_shape = (self.ncomponents,) * rank + global_shape 

1706 

1707 if pfft.xfftn[0].backend in ["cupy", "cupyx-scipy"]: 

1708 from mpi4py_fft.distarrayCuPy import DistArrayCuPy as darraycls 

1709 else: 

1710 darraycls = DistArray 

1711 

1712 z = darraycls(global_shape, subcomm=p0.subcomm, val=val, dtype=dtype, alignment=p0.axis, rank=rank) 

1713 return z.v if view else z 

1714 

1715 def infer_alignment(self, u, forward_output, padding=None, **kwargs): 

1716 if self.comm is None: 

1717 return [0] 

1718 

1719 def _alignment(pfft): 

1720 _arr = self.newDistArray(pfft, forward_output=forward_output) 

1721 _aligned_axes = [i for i in range(self.ndim) if _arr.global_shape[i + 1] == u.shape[i + 1]] 

1722 return _aligned_axes 

1723 

1724 if padding is None: 

1725 pfft = self.get_pfft(**kwargs) 

1726 aligned_axes = _alignment(pfft) 

1727 else: 

1728 if self.ndim == 2: 

1729 padding_options = [(1.0, padding[1]), (padding[0], 1.0), padding, (1.0, 1.0)] 

1730 elif self.ndim == 3: 

1731 padding_options = [ 

1732 (1.0, 1.0, padding[2]), 

1733 (1.0, padding[1], 1.0), 

1734 (padding[0], 1.0, 1.0), 

1735 (1.0, padding[1], padding[2]), 

1736 (padding[0], 1.0, padding[2]), 

1737 (padding[0], padding[1], 1.0), 

1738 padding, 

1739 (1.0, 1.0, 1.0), 

1740 ] 

1741 else: 

1742 raise NotImplementedError(f'Don\'t know how to infer alignment in {self.ndim}D!') 

1743 for _padding in padding_options: 

1744 pfft = self.get_pfft(padding=_padding, **kwargs) 

1745 aligned_axes = _alignment(pfft) 

1746 if len(aligned_axes) > 0: 

1747 self.logger.debug( 

1748 f'Found alignment of array with size {u.shape}: {aligned_axes} using padding {_padding}' 

1749 ) 

1750 break 

1751 

1752 assert len(aligned_axes) > 0, f'Found no aligned axes for array of size {u.shape}!' 

1753 return aligned_axes 

1754 

1755 def redistribute(self, u, axis, forward_output, **kwargs): 

1756 if self.comm is None: 

1757 return u 

1758 

1759 pfft = self.get_pfft(**kwargs) 

1760 _arr = self.newDistArray(pfft, forward_output=forward_output) 

1761 

1762 if 'Dist' in type(u).__name__ and False: 

1763 try: 

1764 u.redistribute(out=_arr) 

1765 return _arr 

1766 except AssertionError: 

1767 pass 

1768 

1769 u_alignment = self.infer_alignment(u, forward_output=False, **kwargs) 

1770 for alignment in u_alignment: 

1771 _arr = _arr.redistribute(alignment) 

1772 if _arr.shape == u.shape: 

1773 _arr[...] = u 

1774 return _arr.redistribute(axis) 

1775 

1776 raise Exception( 

1777 f'Don\'t know how to align array of local shape {u.shape} and global shape {self.global_shape}, aligned in axes {u_alignment}, to axis {axis}' 

1778 ) 

1779 

1780 def transform(self, u, *args, axes=None, padding=None, **kwargs): 

1781 pfft = self.get_pfft(*args, axes=axes, padding=padding, **kwargs) 

1782 

1783 if pfft is None: 

1784 axes = axes if axes else tuple(i for i in range(self.ndim)) 

1785 u_hat = u.copy() 

1786 for i in axes: 

1787 _axis = 1 + i if i >= 0 else i 

1788 u_hat = self.axes[i].transform(u_hat, axes=(_axis,)) 

1789 return u_hat 

1790 

1791 _in = self.newDistArray(pfft, forward_output=False, rank=1) 

1792 _out = self.newDistArray(pfft, forward_output=True, rank=1) 

1793 

1794 if _in.shape == u.shape: 

1795 _in[...] = u 

1796 else: 

1797 _in[...] = self.redistribute(u, axis=_in.alignment, forward_output=False, padding=padding, **kwargs) 

1798 

1799 for i in range(self.ncomponents): 

1800 pfft.forward(_in[i], _out[i], normalize=False) 

1801 

1802 if padding is not None: 

1803 _out /= np.prod(padding) 

1804 return _out 

1805 

1806 def itransform(self, u, *args, axes=None, padding=None, **kwargs): 

1807 if padding is not None: 

1808 assert all( 

1809 (self.axes[i].N * padding[i]) % 1 == 0 for i in range(self.ndim) 

1810 ), 'Cannot do this padding with this resolution. Resulting resolution must be integer' 

1811 

1812 pfft = self.get_pfft(*args, axes=axes, padding=padding, **kwargs) 

1813 if pfft is None: 

1814 axes = axes if axes else tuple(i for i in range(self.ndim)) 

1815 u_hat = u.copy() 

1816 for i in axes: 

1817 _axis = 1 + i if i >= 0 else i 

1818 u_hat = self.axes[i].itransform(u_hat, axes=(_axis,)) 

1819 return u_hat 

1820 

1821 _in = self.newDistArray(pfft, forward_output=True, rank=1) 

1822 _out = self.newDistArray(pfft, forward_output=False, rank=1) 

1823 

1824 if _in.shape == u.shape: 

1825 _in[...] = u 

1826 else: 

1827 _in[...] = self.redistribute(u, axis=_in.alignment, forward_output=True, padding=padding, **kwargs) 

1828 

1829 for i in range(self.ncomponents): 

1830 pfft.backward(_in[i], _out[i], normalize=True) 

1831 

1832 if padding is not None: 

1833 _out *= np.prod(padding) 

1834 return _out 

1835 

1836 def get_local_slice_of_1D_matrix(self, M, axis): 

1837 """ 

1838 Get the local version of a 1D matrix. When using distributed FFTs, each rank will carry only a subset of modes, 

1839 which you can sort out via the `SpectralHelper.local_slice()` attribute. When constructing a 1D matrix, you can 

1840 use this method to get the part corresponding to the modes carried by this rank. 

1841 

1842 Args: 

1843 M (sparse matrix): Global 1D matrix you want to get the local version of 

1844 axis (int): Direction in which you want the local version. You will get the global matrix in other directions. 

1845 

1846 Returns: 

1847 sparse local matrix 

1848 """ 

1849 return M.tocsc()[self.local_slice(True)[axis], self.local_slice(True)[axis]] 

1850 

1851 def expand_matrix_ND(self, matrix, aligned): 

1852 sp = self.sparse_lib 

1853 axes = np.delete(np.arange(self.ndim), aligned) 

1854 ndim = len(axes) + 1 

1855 

1856 if ndim == 1: 

1857 mat = matrix 

1858 elif ndim == 2: 

1859 axis = axes[0] 

1860 I1D = sp.eye(self.axes[axis].N) 

1861 

1862 mats = [None] * ndim 

1863 mats[aligned] = self.get_local_slice_of_1D_matrix(matrix, aligned) 

1864 mats[axis] = self.get_local_slice_of_1D_matrix(I1D, axis) 

1865 

1866 mat = sp.kron(*mats) 

1867 elif ndim == 3: 

1868 

1869 mats = [None] * ndim 

1870 mats[aligned] = self.get_local_slice_of_1D_matrix(matrix, aligned) 

1871 for axis in axes: 

1872 I1D = sp.eye(self.axes[axis].N) 

1873 mats[axis] = self.get_local_slice_of_1D_matrix(I1D, axis) 

1874 

1875 mat = sp.kron(mats[0], sp.kron(*mats[1:])) 

1876 

1877 else: 

1878 raise NotImplementedError(f'Matrix expansion not implemented for {ndim} dimensions!') 

1879 

1880 mat = self.eliminate_zeros(mat) 

1881 return mat 

1882 

1883 def get_filter_matrix(self, axis, **kwargs): 

1884 """ 

1885 Get bandpass filter along `axis`. See the documentation `get_filter_matrix` in the 1D bases for what kwargs are 

1886 admissible. 

1887 

1888 Returns: 

1889 sparse bandpass matrix 

1890 """ 

1891 if self.ndim == 1: 

1892 return self.axes[0].get_filter_matrix(**kwargs) 

1893 

1894 mats = [base.get_Id() for base in self.axes] 

1895 mats[axis] = self.axes[axis].get_filter_matrix(**kwargs) 

1896 return self.sparse_lib.kron(*mats) 

1897 

1898 def get_differentiation_matrix(self, axes, **kwargs): 

1899 """ 

1900 Get differentiation matrix along specified axis. `kwargs` are forwarded to the 1D base implementation. 

1901 

1902 Args: 

1903 axes (tuple): Axes along which to differentiate. 

1904 

1905 Returns: 

1906 sparse differentiation matrix 

1907 """ 

1908 D = self.expand_matrix_ND(self.axes[axes[0]].get_differentiation_matrix(**kwargs), axes[0]) 

1909 for axis in axes[1:]: 

1910 _D = self.axes[axis].get_differentiation_matrix(**kwargs) 

1911 D = D @ self.expand_matrix_ND(_D, axis) 

1912 

1913 self.logger.debug(f'Set up differentiation matrix along axes {axes} with kwargs {kwargs}') 

1914 return D 

1915 

1916 def get_integration_matrix(self, axes): 

1917 """ 

1918 Get integration matrix to integrate along specified axis. 

1919 

1920 Args: 

1921 axes (tuple): Axes along which to integrate over. 

1922 

1923 Returns: 

1924 sparse integration matrix 

1925 """ 

1926 S = self.expand_matrix_ND(self.axes[axes[0]].get_integration_matrix(), axes[0]) 

1927 for axis in axes[1:]: 

1928 _S = self.axes[axis].get_integration_matrix() 

1929 S = S @ self.expand_matrix_ND(_S, axis) 

1930 

1931 return S 

1932 

1933 def get_Id(self): 

1934 """ 

1935 Get identity matrix 

1936 

1937 Returns: 

1938 sparse identity matrix 

1939 """ 

1940 I = self.expand_matrix_ND(self.axes[0].get_Id(), 0) 

1941 for axis in range(1, self.ndim): 

1942 _I = self.axes[axis].get_Id() 

1943 I = I @ self.expand_matrix_ND(_I, axis) 

1944 return I 

1945 

1946 def get_Dirichlet_recombination_matrix(self, axis=-1): 

1947 """ 

1948 Get Dirichlet recombination matrix along axis. Not that it only makes sense in directions discretized with variations of Chebychev bases. 

1949 

1950 Args: 

1951 axis (int): Axis you discretized with Chebychev 

1952 

1953 Returns: 

1954 sparse matrix 

1955 """ 

1956 C1D = self.axes[axis].get_Dirichlet_recombination_matrix() 

1957 return self.expand_matrix_ND(C1D, axis) 

1958 

1959 def get_basis_change_matrix(self, axes=None, **kwargs): 

1960 """ 

1961 Some spectral bases do a change between bases while differentiating. This method returns matrices that changes the basis to whatever you want. 

1962 Refer to the methods of the same name of the 1D bases to learn what parameters you need to pass here as `kwargs`. 

1963 

1964 Args: 

1965 axes (tuple): Axes along which to change basis. 

1966 

1967 Returns: 

1968 sparse basis change matrix 

1969 """ 

1970 axes = tuple(-i - 1 for i in range(self.ndim)) if axes is None else axes 

1971 

1972 C = self.expand_matrix_ND(self.axes[axes[0]].get_basis_change_matrix(**kwargs), axes[0]) 

1973 for axis in axes[1:]: 

1974 _C = self.axes[axis].get_basis_change_matrix(**kwargs) 

1975 C = C @ self.expand_matrix_ND(_C, axis) 

1976 

1977 self.logger.debug(f'Set up basis change matrix along axes {axes} with kwargs {kwargs}') 

1978 return C