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

796 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 12:30 +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 def setup_CPU(self, useFFTW=False): 

121 """switch to CPU modules""" 

122 

123 self.xp = np 

124 self.sparse_lib = scipy.sparse 

125 self.linalg = scipy.sparse.linalg 

126 

127 if useFFTW: 

128 from mpi4py_fft import fftw 

129 

130 self.fft_backend = 'fftw' 

131 self.fft_lib = fftw 

132 else: 

133 self.fft_backend = 'scipy' 

134 self.fft_lib = scipy.fft 

135 

136 self.fft_comm_backend = 'MPI' 

137 self.dtype = mesh 

138 

139 def get_Id(self): 

140 """ 

141 Get identity matrix 

142 

143 Returns: 

144 sparse diagonal identity matrix 

145 """ 

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

147 

148 def get_zero(self): 

149 """ 

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

151 

152 Returns: 

153 sparse matrix with zeros everywhere 

154 """ 

155 return 0 * self.get_Id() 

156 

157 def get_differentiation_matrix(self): 

158 """ 

159 Get the differentiation matrix of this basis. Implemented by the derived bases. 

160 

161 Returns: 

162 sparse differentiation matrix 

163 """ 

164 raise NotImplementedError() 

165 

166 def get_integration_matrix(self): 

167 """ 

168 Get the integration matrix of this basis. Implemented by the derived bases. 

169 

170 Returns: 

171 sparse integration matrix 

172 """ 

173 raise NotImplementedError() 

174 

175 def get_integration_weights(self): 

176 """Weights for integration across entire domain""" 

177 raise NotImplementedError() 

178 

179 def get_wavenumbers(self): 

180 """ 

181 Get the grid in spectral space 

182 """ 

183 raise NotImplementedError 

184 

185 def get_empty_operator_matrix(self, S, O): 

186 """ 

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

188 

189 Args: 

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

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

192 

193 Returns: 

194 list of lists containing sparse zeros 

195 """ 

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

197 

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

199 """ 

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

201 between the various bases. 

202 

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

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

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

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

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

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

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

210 

211 Returns: 

212 sparse bases change matrix 

213 """ 

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

215 

216 def get_BC(self, kind): 

217 """ 

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

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

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

221 

222 Args: 

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

224 individual 1D bases for what is implemented 

225 

226 Returns: 

227 self.xp.array: Boundary condition 

228 """ 

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

230 

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

232 """ 

233 Get a bandpass filter. 

234 

235 Args: 

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

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

238 

239 Returns: 

240 sparse matrix 

241 """ 

242 

243 k = abs(self.get_wavenumbers()) 

244 

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

246 

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

248 

249 if self.useGPU: 

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

251 else: 

252 Id = self.get_Id() 

253 F = Id.tolil() 

254 F[:, mask] = 0 

255 return F.tocsc() 

256 

257 def get_1dgrid(self): 

258 """ 

259 Get the grid in physical space 

260 

261 Returns: 

262 self.xp.array: Grid 

263 """ 

264 raise NotImplementedError 

265 

266 

267class ChebychevHelper(SpectralHelper1D): 

268 """ 

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

270 between physical and spectral space by discrete cosine transform. 

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

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

273 be formulated in first order formulation. 

274 

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

276 """ 

277 

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

279 """ 

280 Constructor. 

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

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

283 

284 Args: 

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

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

287 """ 

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

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

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

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

292 

293 self.norm = self.get_norm() 

294 

295 def get_1dgrid(self): 

296 ''' 

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

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

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

300 

301 Returns: 

302 numpy.ndarray: 1D grid 

303 ''' 

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

305 

306 def get_wavenumbers(self): 

307 """Get the domain in spectral space""" 

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

309 

310 @cache 

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

312 ''' 

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

314 - T: Chebychev polynomials of first kind 

315 - U: Chebychev polynomials of second kind 

316 - D: Dirichlet recombination. 

317 

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

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

320 

321 Args: 

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

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

324 

325 Returns: 

326 scipy.sparse: Sparse conversion matrix 

327 ''' 

328 N = N if N else self.N 

329 sp = self.sparse_lib 

330 

331 def get_forward_conv(name): 

332 if name == 'T2U': 

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

334 mat[:, 0] *= 2 

335 elif name == 'D2T': 

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

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

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

339 else: 

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

341 return mat 

342 

343 try: 

344 mat = get_forward_conv(name) 

345 except NotImplementedError as E: 

346 try: 

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

348 import scipy.sparse as sp 

349 

350 if self.sparse_lib == sp: 

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

352 else: 

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

354 except NotImplementedError: 

355 raise NotImplementedError from E 

356 

357 return mat 

358 

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

360 """ 

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

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

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

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

365 

366 Args: 

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

368 

369 Returns: 

370 Sparse conversion matrix 

371 """ 

372 return self.get_conv(conv) 

373 

374 def get_integration_matrix(self, lbnd=0): 

375 """ 

376 Get matrix for integration 

377 

378 Args: 

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

380 

381 Returns: 

382 Sparse integration matrix 

383 """ 

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

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

386 if lbnd == 0: 

387 S = S.tocsc() 

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

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

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

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

392 ) * self.lin_trf_fac 

393 else: 

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

395 return S 

396 

397 def get_integration_weights(self): 

398 """Weights for integration across entire domain""" 

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

400 

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

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

403 

404 weights /= 2 / self.L 

405 return weights 

406 

407 def get_differentiation_matrix(self, p=1): 

408 ''' 

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

410 

411 Args: 

412 p (int): Derivative you want to compute 

413 

414 Returns: 

415 numpy.ndarray: Differentiation matrix 

416 ''' 

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

418 for j in range(self.N): 

419 for k in range(j): 

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

421 

422 D[0, :] /= 2 

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

424 

425 @cache 

426 def get_norm(self, N=None): 

427 ''' 

428 Get normalization for converting Chebychev coefficients and DCT 

429 

430 Args: 

431 N (int, optional): Resolution 

432 

433 Returns: 

434 self.xp.array: Normalization 

435 ''' 

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

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

438 norm[0] /= 2 

439 return norm 

440 

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

442 """ 

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

444 

445 Args: 

446 u: Data you want to transform 

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

448 shape (tuple): Shape of the result along `axes`, passed on as `s` to the FFT library 

449 

450 Returns: 

451 Data in spectral space 

452 """ 

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

454 kwargs['s'] = shape 

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

456 

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

458 for axis in axes: 

459 

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

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

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

463 # in the middle. 

464 _trf = self.xp.zeros_like(trf) 

465 N = self.N 

466 N_pad = _trf.shape[axis] - N 

467 end_first_half = N // 2 + 1 

468 

469 # copy first "half" 

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

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

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

473 

474 # copy second "half" 

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

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

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

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

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

480 

481 # # copy values to be cut 

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

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

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

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

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

487 

488 trf = _trf 

489 

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

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

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

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

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

495 return trf 

496 

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

498 """ 

499 Inverse DCT along axis. 

500 

501 Args: 

502 u: Data you want to transform 

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

504 shape (tuple): Shape of the result along `axes`, passed on as `s` to the FFT library 

505 

506 Returns: 

507 Data in physical space 

508 """ 

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

510 kwargs['s'] = shape 

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

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

513 

514 for axis in axes: 

515 

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

517 _u = u.copy() 

518 else: 

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

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

521 N = self.N 

522 _u = self.xp.zeros_like(u) 

523 

524 # copy first half 

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

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

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

528 

529 # copy second half 

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

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

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

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

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

535 

536 if N % 2 == 0: 

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

538 su[axis] = N // 2 

539 _u[tuple(su)] *= 2 

540 

541 # generate norm 

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

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

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

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

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

547 

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

549 

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

551 

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

553 """ 

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

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

556 set the BC. 

557 

558 Args: 

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

560 """ 

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

562 return self.get_integ_BC_row(**kwargs) 

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

564 return self.get_Dirichlet_BC_row(**kwargs) 

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

566 return self.get_Neumann_BC_row(**kwargs) 

567 else: 

568 return super().get_BC(kind) 

569 

570 def get_integ_BC_row(self): 

571 """ 

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

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

574 

575 Returns: 

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

577 """ 

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

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

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

581 me[0] = 2.0 

582 return me 

583 

584 def get_Dirichlet_BC_row(self, x): 

585 """ 

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

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

588 

589 Args: 

590 x (float): Position of the boundary condition 

591 

592 Returns: 

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

594 """ 

595 if x == -1: 

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

597 elif x == 1: 

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

599 elif x == 0: 

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

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

602 return n 

603 else: 

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

605 

606 def get_Neumann_BC_row(self, x): 

607 """ 

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

609 

610 Args: 

611 x (float): Position of the boundary condition 

612 

613 Returns: 

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

615 """ 

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

617 nn = n**2 

618 if x == -1: 

619 me = nn 

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

621 return me 

622 elif x == 1: 

623 return nn 

624 else: 

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

626 

627 def get_Dirichlet_recombination_matrix(self): 

628 ''' 

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

630 This makes for a good right preconditioner. 

631 

632 Returns: 

633 scipy.sparse: Sparse conversion matrix 

634 ''' 

635 N = self.N 

636 sp = self.sparse_lib 

637 

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

639 

640 

641class UltrasphericalHelper(ChebychevHelper): 

642 """ 

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

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

645 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. 

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

647 

648 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. 

649 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. 

650 """ 

651 

652 def get_differentiation_matrix(self, p=1): 

653 """ 

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

655 

656 Args: 

657 p (int): Order of the derivative 

658 

659 Returns: 

660 sparse differentiation matrix 

661 """ 

662 sp = self.sparse_lib 

663 xp = self.xp 

664 N = self.N 

665 l = p 

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

667 

668 def get_S(self, lmbda): 

669 """ 

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

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

672 

673 Args: 

674 lmbda (int): Ingoing derivative base 

675 

676 Returns: 

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

678 """ 

679 N = self.N 

680 

681 if lmbda == 0: 

682 sp = scipy.sparse 

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

684 mat[:, 0] *= 2 

685 else: 

686 sp = self.sparse_lib 

687 xp = self.xp 

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

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

690 ) 

691 

692 return self.sparse_lib.csc_matrix(mat) 

693 

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

695 """ 

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

697 

698 Args: 

699 p_out (int): Resulting derivative base 

700 p_in (int): Ingoing derivative base 

701 """ 

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

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

704 mat_fwd = self.get_S(i) @ mat_fwd 

705 

706 if p_out > p_in: 

707 return mat_fwd 

708 

709 else: 

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

711 import scipy.sparse as sp 

712 

713 if self.useGPU: 

714 mat_fwd = mat_fwd.get() 

715 

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

717 

718 return self.sparse_lib.csc_matrix(mat_bck) 

719 

720 def get_integration_matrix(self): 

721 """ 

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

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

724 

725 Example: 

726 

727 .. code-block:: python 

728 

729 import numpy as np 

730 from pySDC.helpers.spectral_helper import UltrasphericalHelper 

731 

732 N = 4 

733 helper = UltrasphericalHelper(N) 

734 coeffs = np.random.random(N) 

735 coeffs[-1] = 0 

736 

737 poly = np.polynomial.Chebyshev(coeffs) 

738 

739 S = helper.get_integration_matrix() 

740 U_hat = S @ coeffs 

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

742 

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

744 

745 Returns: 

746 sparse integration matrix 

747 """ 

748 return ( 

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

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

751 * self.lin_trf_fac 

752 ) 

753 

754 def get_integration_constant(self, u_hat, axis): 

755 """ 

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

757 

758 Args: 

759 u_hat: Solution in spectral space 

760 axis: Axis you want to integrate over 

761 

762 Returns: 

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

764 """ 

765 slices = [ 

766 None, 

767 ] * u_hat.ndim 

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

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

770 

771 

772class FFTHelper(SpectralHelper1D): 

773 distributable = True 

774 

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

776 """ 

777 Constructor. 

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

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

780 

781 Args: 

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

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

784 """ 

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

786 

787 def get_1dgrid(self): 

788 """ 

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

790 """ 

791 dx = self.L / self.N 

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

793 

794 def get_wavenumbers(self): 

795 """ 

796 Be careful that this ordering is very unintuitive. 

797 """ 

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

799 

800 def get_differentiation_matrix(self, p=1): 

801 """ 

802 This matrix is diagonal, allowing to invert concurrently. 

803 

804 Args: 

805 p (int): Order of the derivative 

806 

807 Returns: 

808 sparse differentiation matrix 

809 """ 

810 k = self.get_wavenumbers() 

811 

812 if self.useGPU: 

813 if p > 1: 

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

815 from scipy.sparse.linalg import matrix_power 

816 

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

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

819 else: 

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

821 else: 

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

823 

824 def get_integration_matrix(self, p=1): 

825 """ 

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

827 

828 Args: 

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

830 

831 Returns: 

832 sparse integration matrix 

833 """ 

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

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

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

837 

838 def get_integration_weights(self): 

839 """Weights for integration across entire domain""" 

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

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

842 return weights 

843 

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

845 """ 

846 Get a callable that computes the FFT of data like `u`. 

847 

848 With FFTW, the planned transform is cached in `self.plans` by direction, shape of `u` and the other arguments. 

849 Otherwise, this is `fftn` or `ifftn` of the FFT library with the `norm` from `kwargs`, "backward" and "forward" 

850 by default, so that neither direction normalizes. 

851 

852 Args: 

853 u: Data to transform 

854 forward (bool): Forward transform if True, inverse transform otherwise 

855 

856 Returns: 

857 callable transform 

858 """ 

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

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

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

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

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

864 return self.plans[key] 

865 else: 

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

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

868 self.plans[key] = transform 

869 

870 return self.plans[key] 

871 else: 

872 if forward: 

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

874 else: 

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

876 

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

878 """ 

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

880 

881 Args: 

882 u: Data you want to transform 

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

884 shape (tuple): Shape of the result along `axes`, passed on as `s` to the FFT library 

885 

886 Returns: 

887 transformed data 

888 """ 

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

890 kwargs['s'] = shape 

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

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

893 

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

895 """ 

896 Inverse FFT. 

897 

898 Args: 

899 u: Data you want to transform 

900 axes (tuple): Axes over which to transform 

901 shape (tuple): Shape of the result along `axes`, passed on as `s` to the FFT library 

902 

903 Returns: 

904 transformed data 

905 """ 

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

907 kwargs['s'] = shape 

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

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

910 

911 def get_BC(self, kind): 

912 """ 

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

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

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

916 

917 Args: 

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

919 

920 Returns: 

921 self.xp.ndarray: Boundary condition row 

922 """ 

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

924 return self.get_integ_BC_row() 

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

926 assert ( 

927 self.N % 2 == 0 

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

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

930 BC[self.get_Nyquist_mode_index()] = 1 

931 return BC 

932 else: 

933 return super().get_BC(kind) 

934 

935 def get_Nyquist_mode_index(self): 

936 """ 

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

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

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

940 after. 

941 

942 Returns: 

943 int: Index of the Nyquist mode 

944 """ 

945 k = self.get_wavenumbers() 

946 Nyquist_mode = min(k) 

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

948 

949 def get_integ_BC_row(self): 

950 """ 

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

952 """ 

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

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

955 return me 

956 

957 

958class SpectralHelper: 

959 """ 

960 This class has three functions: 

961 - Easily assemble matrices containing multiple equations 

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

963 - Distribute the FFTs to facilitate concurrency. 

964 

965 Attributes: 

966 comm (mpi4py.Intracomm): MPI communicator 

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

968 useGPU (bool): Whether to use GPUs 

969 axes (list): List of 1D bases 

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

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

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

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

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

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

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

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

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

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

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

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

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

983 """ 

984 

985 xp = np 

986 fft_lib = scipy.fft 

987 sparse_lib = scipy.sparse 

988 linalg = scipy.sparse.linalg 

989 dtype = mesh 

990 fft_backend = 'scipy' 

991 fft_comm_backend = 'MPI' 

992 

993 def setup_GPU(self): 

994 """switch to GPU modules""" 

995 import cupy as cp 

996 import cupyx.scipy.sparse as sparse_lib 

997 import cupyx.scipy.sparse.linalg as linalg 

998 import cupyx.scipy.fft as fft_lib 

999 from pySDC.implementations.datatype_classes.cupy_mesh import cupy_mesh 

1000 

1001 self.xp = cp 

1002 self.sparse_lib = sparse_lib 

1003 self.linalg = linalg 

1004 

1005 self.fft_lib = fft_lib 

1006 self.fft_backend = 'cupyx-scipy' 

1007 self.fft_comm_backend = 'NCCL' 

1008 

1009 self.dtype = cupy_mesh 

1010 

1011 def setup_CPU(self, useFFTW=False): 

1012 """switch to CPU modules""" 

1013 

1014 self.xp = np 

1015 self.sparse_lib = scipy.sparse 

1016 self.linalg = scipy.sparse.linalg 

1017 

1018 if useFFTW: 

1019 from mpi4py_fft import fftw 

1020 

1021 self.fft_backend = 'fftw' 

1022 self.fft_lib = fftw 

1023 else: 

1024 self.fft_backend = 'scipy' 

1025 self.fft_lib = scipy.fft 

1026 

1027 self.fft_comm_backend = 'MPI' 

1028 self.dtype = mesh 

1029 

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

1031 """ 

1032 Constructor 

1033 

1034 Args: 

1035 comm (mpi4py.Intracomm): MPI communicator 

1036 useGPU (bool): Whether to use GPUs 

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

1038 """ 

1039 self.comm = comm 

1040 self.debug = debug 

1041 self.useGPU = useGPU 

1042 

1043 if useGPU: 

1044 self.setup_GPU() 

1045 else: 

1046 self.setup_CPU() 

1047 

1048 self.axes = [] 

1049 self.components = [] 

1050 

1051 self.full_BCs = [] 

1052 self.BC_mat = None 

1053 self.BCs = None 

1054 

1055 self.fft_cache = {} 

1056 

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

1058 if debug: 

1059 self.logger.setLevel(logging.DEBUG) 

1060 

1061 @property 

1062 def u_init(self): 

1063 """ 

1064 Get empty data container in physical space 

1065 """ 

1066 return self.dtype(self.init) 

1067 

1068 @property 

1069 def u_init_forward(self): 

1070 """ 

1071 Get empty data container in spectral space 

1072 """ 

1073 return self.dtype(self.init_forward) 

1074 

1075 @property 

1076 def u_init_physical(self): 

1077 """ 

1078 Get empty data container in physical space 

1079 """ 

1080 return self.dtype(self.init_physical) 

1081 

1082 @property 

1083 def shape(self): 

1084 """ 

1085 Get shape of individual solution component 

1086 """ 

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

1088 

1089 @property 

1090 def ndim(self): 

1091 """Number of dimensions, i.e. of 1D bases""" 

1092 return len(self.axes) 

1093 

1094 @property 

1095 def ncomponents(self): 

1096 """Number of solution components""" 

1097 return len(self.components) 

1098 

1099 @property 

1100 def V(self): 

1101 """ 

1102 Get domain volume 

1103 """ 

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

1105 

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

1107 """ 

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

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

1110 bases for possible arguments. 

1111 

1112 Args: 

1113 base (str): 1D spectral method 

1114 """ 

1115 kwargs['useGPU'] = self.useGPU 

1116 

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

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

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

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

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

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

1123 else: 

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

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

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

1127 

1128 def add_component(self, name): 

1129 """ 

1130 Add solution component(s). 

1131 

1132 Args: 

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

1134 """ 

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

1136 for me in name: 

1137 self.add_component(me) 

1138 elif type(name) in [str]: 

1139 if name in self.components: 

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

1141 self.components.append(name) 

1142 else: 

1143 raise NotImplementedError 

1144 

1145 def index(self, name): 

1146 """ 

1147 Get the index of component `name`. 

1148 

1149 Args: 

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

1151 

1152 Returns: 

1153 int: Index of the component 

1154 """ 

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

1156 return self.components.index(name) 

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

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

1159 else: 

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

1161 

1162 def get_empty_operator_matrix(self, diag=False): 

1163 """ 

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

1165 

1166 Args: 

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

1168 

1169 Returns: 

1170 list containing sparse zeros 

1171 """ 

1172 S = len(self.components) 

1173 O = self.get_Id() * 0 

1174 if diag: 

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

1176 else: 

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

1178 

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

1180 """ 

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

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

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

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

1185 

1186 Args: 

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

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

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

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

1191 

1192 Returns: 

1193 sparse matrix containing the BC 

1194 """ 

1195 sp = scipy.sparse 

1196 

1197 base = self.axes[axis] 

1198 

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

1200 if self.useGPU: 

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

1202 else: 

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

1204 

1205 ndim = len(self.axes) 

1206 if ndim == 1: 

1207 mat = self.sparse_lib.csc_matrix(BC) 

1208 elif ndim == 2: 

1209 axis2 = (axis + 1) % ndim 

1210 

1211 if scalar: 

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

1213 else: 

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

1215 

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

1217 

1218 mats = [ 

1219 None, 

1220 ] * ndim 

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

1222 mats[axis2] = Id 

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

1224 elif ndim == 3: 

1225 mats = [ 

1226 None, 

1227 ] * ndim 

1228 

1229 for ax in range(ndim): 

1230 if ax == axis: 

1231 continue 

1232 

1233 if scalar: 

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

1235 else: 

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

1237 

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

1239 

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

1241 

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

1243 else: 

1244 raise NotImplementedError( 

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

1246 ) 

1247 mat = self.eliminate_zeros(mat) 

1248 return mat 

1249 

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

1251 """ 

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

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

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

1255 

1256 Args: 

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

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

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

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

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

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

1263 """ 

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

1265 _BC = self.eliminate_zeros(_BC) 

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

1267 

1268 if scalar: 

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

1270 0, 

1271 ] * self.ndim 

1272 slices[axis + 1] = line 

1273 else: 

1274 slices = ( 

1275 [self.index(equation)] 

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

1277 + [line] 

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

1279 ) 

1280 N = self.axes[axis].N 

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

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

1283 

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

1285 """ 

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

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

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

1289 

1290 Args: 

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

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

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

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

1295 v: Value of the BC 

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

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

1298 """ 

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

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

1301 self.full_BCs += [ 

1302 { 

1303 'component': component, 

1304 'equation': equation, 

1305 'axis': axis, 

1306 'kind': kind, 

1307 'v': v, 

1308 'line': line, 

1309 'scalar': scalar, 

1310 **kwargs, 

1311 } 

1312 ] 

1313 

1314 if scalar: 

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

1316 0, 

1317 ] * self.ndim 

1318 slices[axis + 1] = line 

1319 if self.comm: 

1320 if self.comm.rank == 0: 

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

1322 else: 

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

1324 else: 

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

1326 N = self.axes[axis].N 

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

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

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

1330 

1331 def setup_BCs(self): 

1332 """ 

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

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

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

1336 """ 

1337 sp = self.sparse_lib 

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

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

1340 

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

1342 diags[self.BC_zero_index] = 0 

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

1344 

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

1346 rhs_BCs = self.put_BCs_in_rhs(self.u_init) 

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

1348 del self.BC_rhs_mask 

1349 

1350 def check_BCs(self, u): 

1351 """ 

1352 Check that the solution satisfies the boundary conditions 

1353 

1354 Args: 

1355 u: The solution you want to check 

1356 """ 

1357 assert self.ndim < 3 

1358 for axis in range(self.ndim): 

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

1360 

1361 if len(BCs) > 0: 

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

1363 for BC in BCs: 

1364 kwargs = { 

1365 key: value 

1366 for key, value in BC.items() 

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

1368 } 

1369 

1370 if axis == 0: 

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

1372 elif axis == 1: 

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

1374 want = BC['v'] 

1375 assert self.xp.allclose( 

1376 get, want 

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

1378 

1379 def put_BCs_in_matrix(self, A): 

1380 """ 

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

1382 """ 

1383 return self.BC_line_zero_matrix @ A + self.BCs 

1384 

1385 def put_BCs_in_rhs_hat(self, rhs_hat, BCs_hat=None): 

1386 """ 

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

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

1389 

1390 Args: 

1391 rhs_hat: Right hand side in spectral space 

1392 BCs_hat: Boundary values to use instead of `rhs_BCs_hat`, zero outside the boundary rows 

1393 

1394 Returns: 

1395 rhs in spectral space with BCs 

1396 """ 

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

1398 """ 

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

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

1401 """ 

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

1403 

1404 for axis in range(self.ndim): 

1405 for bc in self.full_BCs: 

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

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

1408 N = self.axes[axis].N 

1409 line = bc['line'] 

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

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

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

1413 

1414 rhs_hat[self._rhs_hat_zero_mask] = 0 

1415 return rhs_hat + (self.rhs_BCs_hat if BCs_hat is None else BCs_hat) 

1416 

1417 def put_BCs_in_rhs(self, rhs): 

1418 """ 

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

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

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

1422 

1423 Args: 

1424 rhs: Right hand side in physical space 

1425 

1426 Returns: 

1427 rhs in physical space with BCs 

1428 """ 

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

1430 

1431 ndim = self.ndim 

1432 

1433 for axis in range(ndim): 

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

1435 

1436 for bc in self.full_BCs: 

1437 

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

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

1440 

1441 N = self.axes[axis].N 

1442 line = bc['line'] 

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

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

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

1446 

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

1448 

1449 return rhs 

1450 

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

1452 """ 

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

1454 that you will convert to an operator later. 

1455 

1456 Example: 

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

1458 preconditioning: 

1459 

1460 .. code-block:: python 

1461 

1462 helper = SpectralHelper() 

1463 

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

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

1466 helper.setup_fft() 

1467 

1468 I = helper.get_Id() 

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

1470 T2U = helper.get_basis_change_matrix('T2U') 

1471 

1472 L_lhs = { 

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

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

1475 } 

1476 

1477 operator = helper.get_empty_operator_matrix() 

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

1479 helper.add_equation_lhs(operator, line, equation) 

1480 

1481 L = helper.convert_operator_matrix_to_operator(operator) 

1482 

1483 Args: 

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

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

1486 relations: (dict): Relations between quantities 

1487 """ 

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

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

1490 

1491 def eliminate_zeros(self, A): 

1492 """ 

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

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

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

1496 

1497 Args: 

1498 A: sparse matrix to be pruned 

1499 

1500 Returns: 

1501 CSC sparse matrix 

1502 """ 

1503 if self.useGPU: 

1504 A = A.get() 

1505 A = A.tocsc() 

1506 A.eliminate_zeros() 

1507 if self.useGPU: 

1508 A = self.sparse_lib.csc_matrix(A) 

1509 return A 

1510 

1511 def convert_operator_matrix_to_operator(self, M): 

1512 """ 

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

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

1515 

1516 Args: 

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

1518 

1519 Returns: 

1520 sparse linear operator 

1521 """ 

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

1523 op = M[0][0] 

1524 else: 

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

1526 

1527 op = self.eliminate_zeros(op) 

1528 return op 

1529 

1530 def get_wavenumbers(self): 

1531 """ 

1532 Get grid in spectral space 

1533 """ 

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

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

1536 

1537 def get_grid(self, forward_output=False): 

1538 """ 

1539 Get grid in physical space 

1540 """ 

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

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

1543 

1544 def get_indices(self, forward_output=True): 

1545 """ 

1546 Get the global indices of the modes or grid points held by this rank in each axis. 

1547 

1548 Args: 

1549 forward_output (bool): Indices in spectral space if True, in physical space otherwise 

1550 

1551 Returns: 

1552 list of self.xp.ndarray: Indices in each axis 

1553 """ 

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

1555 

1556 @cache 

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

1558 """ 

1559 Get a cached distributed transform object from mpi4py-fft, planned by one forward and backward transform. 

1560 Axes that are not in `axes` are not transformed. The arguments must be hashable, e.g. tuples. 

1561 

1562 Args: 

1563 axes (tuple): Axes to transform, all by default 

1564 padding (tuple): Padding factor in each axis for dealiasing, none by default 

1565 grid (tuple): Processor grid, passed on to `PFFT` 

1566 

1567 Returns: 

1568 pySDC.helpers.fft_helper.PFFT: Transform object, or None in 1D or without a communicator 

1569 """ 

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

1571 return None 

1572 from mpi4py_fft import newDistArray 

1573 

1574 from pySDC.helpers.fft_helper import PFFT 

1575 

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

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

1578 

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

1580 return u 

1581 

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

1583 for i in axes: 

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

1585 

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

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

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

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

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

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

1592 ) 

1593 

1594 pfft = PFFT( 

1595 comm=self.comm, 

1596 shape=self.global_shape[1:], 

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

1598 dtype='D', 

1599 collapse=False, 

1600 backend=self.fft_backend, 

1601 comm_backend=self.fft_comm_backend, 

1602 padding=padding, 

1603 transforms=transforms, 

1604 grid=grid, 

1605 ) 

1606 

1607 # do a transform to do the planning 

1608 _u = newDistArray(pfft, forward_output=False) 

1609 pfft.backward(pfft.forward(_u)) 

1610 return pfft 

1611 

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

1613 """ 

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

1615 

1616 Args: 

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

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

1619 padding (tuple): Padding for dealiasing 

1620 shape (tuple): Shape of the transform 

1621 

1622 Returns: 

1623 transform 

1624 """ 

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

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

1627 padding = ( 

1628 [ 

1629 1, 

1630 ] 

1631 * self.ndim 

1632 if padding is None 

1633 else padding 

1634 ) 

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

1636 

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

1638 if self.comm is None: 

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

1640 

1641 if direction == 'forward': 

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

1643 elif direction == 'backward': 

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

1645 elif direction == 'object': 

1646 self.fft_cache[key] = None 

1647 else: 

1648 if direction == 'object': 

1649 from pySDC.helpers.fft_helper import PFFT 

1650 

1651 _fft = PFFT( 

1652 comm=self.comm, 

1653 shape=shape, 

1654 axes=sorted(axes), 

1655 dtype='D', 

1656 collapse=False, 

1657 backend=self.fft_backend, 

1658 comm_backend=self.fft_comm_backend, 

1659 padding=padding, 

1660 ) 

1661 else: 

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

1663 

1664 if direction == 'forward': 

1665 self.fft_cache[key] = _fft.forward 

1666 elif direction == 'backward': 

1667 self.fft_cache[key] = _fft.backward 

1668 elif direction == 'object': 

1669 self.fft_cache[key] = _fft 

1670 

1671 return self.fft_cache[key] 

1672 

1673 def local_slice(self, forward_output=True): 

1674 """ 

1675 Get the slices of the global data held by this rank. 

1676 

1677 Args: 

1678 forward_output (bool): Slices in spectral space if True, in physical space otherwise 

1679 

1680 Returns: 

1681 list of slice: Local slice in each axis 

1682 """ 

1683 if self.fft_obj: 

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

1685 else: 

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

1687 

1688 def global_slice(self, forward_output=True): 

1689 """ 

1690 Get slices spanning the global data. 

1691 

1692 Args: 

1693 forward_output (bool): Slices in spectral space if True, in physical space otherwise 

1694 

1695 Returns: 

1696 list of slice: Global slice in each axis 

1697 """ 

1698 if self.fft_obj: 

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

1700 else: 

1701 return self.local_slice(forward_output=forward_output) 

1702 

1703 def setup_fft(self, real_spectral_coefficients=False): 

1704 """ 

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

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

1707 

1708 Args: 

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

1710 """ 

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

1712 self.add_component('u') 

1713 

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

1715 

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

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

1718 

1719 self.init = ( 

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

1721 ( 

1722 ..., 

1723 *self.local_slice(False), 

1724 ) 

1725 ].shape, 

1726 self.comm, 

1727 np.dtype('float'), 

1728 ) 

1729 self.init_physical = ( 

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

1731 ( 

1732 ..., 

1733 *self.local_slice(False), 

1734 ) 

1735 ].shape, 

1736 self.comm, 

1737 np.dtype('float'), 

1738 ) 

1739 self.init_forward = ( 

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

1741 ( 

1742 ..., 

1743 *self.local_slice(True), 

1744 ) 

1745 ].shape, 

1746 self.comm, 

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

1748 ) 

1749 

1750 self.BC_mat = self.get_empty_operator_matrix() 

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

1752 

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

1754 """ 

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

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

1757 """ 

1758 if self.comm is None: 

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

1760 from mpi4py_fft.distarray import DistArray 

1761 

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

1763 if pfft is None: 

1764 if forward_output: 

1765 return self.u_init_forward 

1766 else: 

1767 return self.u_init 

1768 

1769 global_shape = pfft.global_shape(forward_output) 

1770 p0 = pfft.pencil[forward_output] 

1771 if forward_output is True: 

1772 dtype = pfft.forward.output_array.dtype 

1773 else: 

1774 dtype = pfft.forward.input_array.dtype 

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

1776 

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

1778 from mpi4py_fft.distarrayCuPy import DistArrayCuPy as darraycls 

1779 else: 

1780 darraycls = DistArray 

1781 

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

1783 return z.v if view else z 

1784 

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

1786 """ 

1787 Find the axes in which `u` is not distributed, by comparing its local shape to the global shape of arrays of the 

1788 transform object from `get_pfft`. With `padding`, transforms padded in subsets of the axes are tried until one 

1789 fits. 

1790 

1791 Args: 

1792 u: Data with the solution components in the first axis 

1793 forward_output (bool): Compare to arrays in spectral space if True, in physical space otherwise 

1794 padding (tuple): Padding factor in each axis 

1795 **kwargs: Passed on to `get_pfft` 

1796 

1797 Returns: 

1798 list of int: Aligned axes, not counting the component axis, or [0] without a communicator 

1799 """ 

1800 if self.comm is None: 

1801 return [0] 

1802 

1803 def _alignment(pfft): 

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

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

1806 return _aligned_axes 

1807 

1808 if padding is None: 

1809 pfft = self.get_pfft(**kwargs) 

1810 aligned_axes = _alignment(pfft) 

1811 else: 

1812 if self.ndim == 2: 

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

1814 elif self.ndim == 3: 

1815 padding_options = [ 

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

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

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

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

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

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

1822 padding, 

1823 (1.0, 1.0, 1.0), 

1824 ] 

1825 else: 

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

1827 for _padding in padding_options: 

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

1829 aligned_axes = _alignment(pfft) 

1830 if len(aligned_axes) > 0: 

1831 self.logger.debug( 

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

1833 ) 

1834 break 

1835 

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

1837 return aligned_axes 

1838 

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

1840 """ 

1841 Copy `u` into a new distributed array aligned in `axis`, i.e. not distributed in that axis. 

1842 

1843 Args: 

1844 u: Data with the solution components in the first axis 

1845 axis (int): Axis to align in 

1846 forward_output (bool): Layout of the new array: spectral space if True, physical space otherwise 

1847 **kwargs: Passed on to `get_pfft`, e.g. `padding` 

1848 

1849 Returns: 

1850 mpi4py_fft.DistArray: Redistributed copy of `u`, or `u` itself without a communicator 

1851 """ 

1852 if self.comm is None: 

1853 return u 

1854 

1855 pfft = self.get_pfft(**kwargs) 

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

1857 

1858 # the transforms are complex to complex, so the local shapes in physical and spectral space agree and the 

1859 # alignment can be inferred from the physical shapes either way 

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

1861 for alignment in u_alignment: 

1862 _arr = _arr.redistribute(alignment) 

1863 if _arr.shape == u.shape: 

1864 _arr[...] = u 

1865 return _arr.redistribute(axis) 

1866 

1867 raise Exception( 

1868 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}' 

1869 ) 

1870 

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

1872 """ 

1873 Transform all solution components from physical to spectral space along `axes`. 

1874 With a distributed transform, `u` is first redistributed if its local shape does not fit. 

1875 

1876 Args: 

1877 u: Data with the solution components in the first axis 

1878 axes (tuple): Axes to transform, all by default 

1879 padding (tuple): Padding factor in each axis for dealiasing, only used with a distributed transform 

1880 **kwargs: Passed on to `get_pfft` 

1881 

1882 Returns: 

1883 Transformed data in a new array, which is distributed if the transform is 

1884 """ 

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

1886 

1887 if pfft is None: 

1888 if padding is not None and any(me != 1 for me in padding): 

1889 raise NotImplementedError('Padding is only implemented for distributed FFTs in more than one dimension') 

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

1891 u_hat = u.copy() 

1892 for i in axes: 

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

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

1895 return u_hat 

1896 

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

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

1899 

1900 if _in.shape == u.shape: 

1901 _in[...] = u 

1902 else: 

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

1904 

1905 for i in range(self.ncomponents): 

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

1907 

1908 if padding is not None: 

1909 _out /= np.prod(padding) 

1910 return _out 

1911 

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

1913 """ 

1914 Transform all solution components from spectral to physical space along `axes`, the inverse of `transform`. 

1915 With a distributed transform, `u` is first redistributed if its local shape does not fit. 

1916 

1917 Args: 

1918 u: Data with the solution components in the first axis 

1919 axes (tuple): Axes to transform, all by default 

1920 padding (tuple): Padding factor in each axis for dealiasing, only used with a distributed transform. The 

1921 padded 

1922 resolution must be an integer. 

1923 **kwargs: Passed on to `get_pfft` 

1924 

1925 Returns: 

1926 Transformed data in a new array, which is distributed if the transform is 

1927 """ 

1928 if padding is not None: 

1929 assert all( 

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

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

1932 

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

1934 if pfft is None: 

1935 if padding is not None and any(me != 1 for me in padding): 

1936 raise NotImplementedError('Padding is only implemented for distributed FFTs in more than one dimension') 

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

1938 u_hat = u.copy() 

1939 for i in axes: 

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

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

1942 return u_hat 

1943 

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

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

1946 

1947 if _in.shape == u.shape: 

1948 _in[...] = u 

1949 else: 

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

1951 

1952 for i in range(self.ncomponents): 

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

1954 

1955 if padding is not None: 

1956 _out *= np.prod(padding) 

1957 return _out 

1958 

1959 def get_local_slice_of_1D_matrix(self, M, axis): 

1960 """ 

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

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

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

1964 

1965 Args: 

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

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

1968 

1969 Returns: 

1970 sparse local matrix 

1971 """ 

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

1973 

1974 def expand_matrix_ND(self, matrix, aligned): 

1975 """ 

1976 Expand a global 1D matrix acting along axis `aligned` to the local part of the ND matrix, by Kronecker products 

1977 with identities in the other axes. Uses the local slices in spectral space in every axis. 

1978 

1979 Args: 

1980 matrix (sparse matrix): Global 1D matrix 

1981 aligned (int): Axis the matrix acts along 

1982 

1983 Returns: 

1984 sparse local ND matrix in CSC format 

1985 """ 

1986 sp = self.sparse_lib 

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

1988 ndim = len(axes) + 1 

1989 

1990 if ndim == 1: 

1991 mat = matrix 

1992 elif ndim == 2: 

1993 axis = axes[0] 

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

1995 

1996 mats = [None] * ndim 

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

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

1999 

2000 mat = sp.kron(*mats) 

2001 elif ndim == 3: 

2002 

2003 mats = [None] * ndim 

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

2005 for axis in axes: 

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

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

2008 

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

2010 

2011 else: 

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

2013 

2014 mat = self.eliminate_zeros(mat) 

2015 return mat 

2016 

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

2018 """ 

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

2020 admissible. 

2021 

2022 Returns: 

2023 sparse bandpass matrix 

2024 """ 

2025 if self.ndim == 1: 

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

2027 

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

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

2030 return self.sparse_lib.kron(*mats) 

2031 

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

2033 """ 

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

2035 

2036 Args: 

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

2038 

2039 Returns: 

2040 sparse differentiation matrix 

2041 """ 

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

2043 for axis in axes[1:]: 

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

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

2046 

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

2048 return D 

2049 

2050 def get_integration_matrix(self, axes): 

2051 """ 

2052 Get integration matrix to integrate along specified axis. 

2053 

2054 Args: 

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

2056 

2057 Returns: 

2058 sparse integration matrix 

2059 """ 

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

2061 for axis in axes[1:]: 

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

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

2064 

2065 return S 

2066 

2067 def get_Id(self): 

2068 """ 

2069 Get identity matrix 

2070 

2071 Returns: 

2072 sparse identity matrix 

2073 """ 

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

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

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

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

2078 return I 

2079 

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

2081 """ 

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

2083 

2084 Args: 

2085 axis (int): Axis you discretized with Chebychev 

2086 

2087 Returns: 

2088 sparse matrix 

2089 """ 

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

2091 return self.expand_matrix_ND(C1D, axis) 

2092 

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

2094 """ 

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

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

2097 

2098 Args: 

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

2100 

2101 Returns: 

2102 sparse basis change matrix 

2103 """ 

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

2105 

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

2107 for axis in axes[1:]: 

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

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

2110 

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

2112 return C