Coverage for pySDC/implementations/sweeper_classes/delta_form.py: 98%

189 statements  

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

1r""" 

2Deferred-correction ("delta-form") SDC sweepers. 

3 

4A standard SDC sweep 

5 

6.. math:: 

7 u^{k+1}_m = u_0 + \tau_m + \Delta t (Q f^k)_m 

8 + \Delta t \sum_j Q^\Delta_{mj}\,(f^{k+1}_j - f^k_j) 

9 

10is algebraically identical to, with :math:`\delta_m = u^{k+1}_m - u^k_m` and the collocation 

11residual :math:`\varepsilon_m = u_0 + \tau_m + \Delta t (Q f^k)_m - u^k_m`, 

12 

13.. math:: 

14 \delta_m = \varepsilon_m + \Delta t \sum_j Q^\Delta_{mj}\,\Delta f_j, 

15 \qquad \Delta f_j = f(u^k_j + \delta_j) - f(u^k_j). 

16 

17Written this way, every sweep is iterative refinement: a high-precision residual, a correction 

18solve, and a high-precision update ``u <- u + delta``. No Jacobian appears, so an IMEX splitting 

19survives unchanged. 

20 

21The point of the reformulation is that the quantity handed to the node-local solver is a 

22*correction*. Its magnitude tends to zero as the sweeps converge, so a reduced-precision solve 

23introduces an error proportional to :math:`|\delta|` rather than to :math:`|u|` and therefore does 

24not cap the attainable accuracy. 

25 

26Three node-local strategies are supported, selected automatically: 

27 

28``solve_system_delta`` 

29 Used when the problem provides it. Solves 

30 :math:`\delta - \alpha[f(w+\delta) - f(w)] = r` for the correction directly. This is the only 

31 option for a nonlinear implicit operator, and the only one that hands a reduced-precision 

32 solver a small unknown. 

33 

34``linear_implicit=True`` 

35 For a linear or affine implicit operator, :math:`f(w+\delta) - f(w) = A\delta`, so the stock 

36 ``solve_system`` already solves the correction equation once the affine part 

37 :math:`\alpha f(0, t)` is removed from the right-hand side. No problem class needs changing. 

38 

39fallback 

40 Otherwise the substitution :math:`y = u^k_m + \delta_m` reduces the correction equation to the 

41 ordinary implicit solve. Always correct and identical to :class:`generic_implicit`, but the 

42 solver sees an :math:`\mathcal{O}(1)` unknown, so there is no precision benefit. 

43 

44``correction_precision`` additionally stores the small quantities 

45(:math:`\varepsilon`, :math:`\delta`, :math:`\Delta f`) in a reduced-precision datatype built from 

46the problem's own ``init`` tuple, scaled by the residual's magnitude so the format's mantissa is 

47used however small the correction gets. 

48 

49The increment :math:`\Delta f` is formed by subtracting two stored right-hand sides unless the 

50problem provides ``eval_f_increment(base, delta, t)``, which expands it analytically. That 

51subtraction is a cancellation carrying the operator norm, so it binds as soon as a level runs below 

52backend precision. 

53 

54The same sweeper serves any number of levels, and which it is doing is decided by the transfer, not 

55by the choice of sweeper. On the finest level it computes its own residual, which is where the 

56accuracy of the whole iteration is set. Given a transfer that hands one down -- by setting 

57``eps_in`` on the level's sweeper -- it uses that instead and advances it in place, so nothing is 

58ever rebuilt out of :math:`\mathcal{O}(1)` coarse state. :class:`BaseTransfer` hands nothing down, 

59so on a stock hierarchy this sweeper reproduces MLSDC and PFASST exactly. 

60""" 

61 

62import numpy as np 

63 

64from pySDC.implementations.sweeper_classes.generic_implicit import generic_implicit 

65from pySDC.implementations.sweeper_classes.imex_1st_order import imex_1st_order 

66 

67 

68class DeltaFormMixin: 

69 """Shared machinery for the delta-form sweepers.""" 

70 

71 def _delta_setup(self): 

72 """Read the optional sweeper parameters, and arm the per-sweep recorders.""" 

73 token = getattr(self.params, 'correction_precision', None) 

74 self._work_dtype = None if token is None else np.dtype(token) 

75 self._linear_implicit = bool(getattr(self.params, 'linear_implicit', False)) 

76 self._deltas, self._dfs = [], [] 

77 

78 eps_in = None 

79 """Residual handed down by the transfer, or ``None`` on the finest level.""" 

80 

81 delta_acc = None 

82 """Corrections this level has accumulated since the last restriction.""" 

83 

84 def sync_initial_value(self): 

85 r""" 

86 Follow a change of :math:`u_0` made after the residual was handed down. 

87 

88 PFASST receives the initial value from the predecessor *after* the restriction, directly 

89 into ``u[0]``. The residual depends on it additively, so following it is one addition: 

90 :math:`\varepsilon_m \leftarrow \varepsilon_m + (u_0 - u_0^{\mathrm{ref}})`. Exactly zero 

91 when nothing arrived, which is every serial run. 

92 

93 This is the one place the hierarchy still differences two :math:`\mathcal{O}(1)` values, and 

94 it is the reason a reduced-precision coarse level buys less under PFASST than under MLSDC: 

95 the difference is small -- it is the coarse-versus-fine discrepancy at the step interface, 

96 and it converges to zero -- but it is *formed* by cancelling two values of size 

97 :math:`|u|`. Putting the step-to-step exchange itself in delta form is what would remove it. 

98 

99 Returns 

100 ------- 

101 None 

102 """ 

103 lvl = self.level 

104 if self.eps_in is None or lvl.u0_reference is None: 

105 return 

106 shift = lvl.u[0] - lvl.u0_reference 

107 self.eps_in = [eps + shift for eps in self.eps_in] 

108 lvl.u0_reference = lvl.prob.dtype_u(lvl.u[0]) 

109 return None 

110 

111 def compute_residual(self, stage=''): 

112 r""" 

113 Report the residual this level is already tracking, instead of rebuilding one. 

114 

115 A level with an inherited residual carries it forward through every sweep and prolongation, 

116 so recomputing it from :math:`\mathcal{O}(1)` state would be both redundant and less 

117 accurate. It is also what makes the :math:`\tau` term unnecessary: the only other reader is 

118 :meth:`compute_end_point`, and only when the end point comes from the quadrature update. 

119 

120 Parameters 

121 ---------- 

122 stage : str 

123 The stage of the step this level belongs to. 

124 

125 Returns 

126 ------- 

127 None 

128 """ 

129 if self.eps_in is None: 

130 return super().compute_residual(stage=stage) 

131 

132 lvl = self.level 

133 if stage in self.params.skip_residual_computation: 

134 lvl.status.residual = 0.0 if lvl.status.residual is None else lvl.status.residual 

135 return None 

136 

137 lvl.residual = [lvl.prob.dtype_u(eps) for eps in self.eps_in] 

138 norms = [abs(eps) for eps in self.eps_in] 

139 kind = lvl.params.residual_type 

140 if kind not in ('full_abs', 'last_abs', 'full_rel', 'last_rel'): 

141 raise NotImplementedError(f'residual type "{kind}" not implemented!') 

142 value = norms[-1] if kind.startswith('last') else max(norms) 

143 lvl.status.residual = value / abs(lvl.u[0]) if kind.endswith('rel') else value 

144 lvl.status.updated = False 

145 return None 

146 

147 def advance_residual(self, eps, deltas, dfs): 

148 r""" 

149 Advance a residual by an update: :math:`\varepsilon \leftarrow \varepsilon - \delta 

150 + \Delta t (Q \Delta f)`. 

151 

152 Every term is small, so this never cancels. It is exact for any update to the nodal values, 

153 which is why it serves both a sweep and a prolongation. 

154 

155 Parameters 

156 ---------- 

157 eps : list 

158 The residual, one entry per node. 

159 deltas : list 

160 The update applied to the nodal values, one entry per node. 

161 dfs : list 

162 The resulting right-hand side increments, one entry per node. 

163 

164 Returns 

165 ------- 

166 list 

167 The advanced residual. 

168 """ 

169 dt, Q = self.level.dt, self.coll.Qmat 

170 out = [] 

171 for m in range(len(eps)): 

172 acc = eps[m] - deltas[m] 

173 for j in range(len(dfs)): 

174 if Q[m + 1, j + 1] != 0.0: 

175 acc += self._coeff(dt * Q[m + 1, j + 1]) * dfs[j] 

176 out.append(acc) 

177 return out 

178 

179 def accumulate(self, deltas): 

180 """Add one round of corrections to what this level owes upwards.""" 

181 self.delta_acc = ( 

182 deltas if self.delta_acc is None else [a + d for a, d in zip(self.delta_acc, deltas, strict=True)] 

183 ) 

184 

185 def update_nodes(self): 

186 r""" 

187 Sweep, and keep the level's bookkeeping straight if it is part of a hierarchy. 

188 

189 The sweep itself is :meth:`_sweep_nodes`, which each concrete sweeper supplies. Around it: 

190 follow any change of :math:`u_0` that arrived after the residual was handed down, advance 

191 that residual by the update just applied, and bank the corrections for a transfer to prolong. 

192 

193 A level that computes its own residual has no bookkeeping to do, so on a single-level run 

194 every line below the sweep is a no-op. That is why there is one sweeper rather than two. 

195 

196 Returns 

197 ------- 

198 None 

199 """ 

200 self.sync_initial_value() 

201 self._sweep_nodes() 

202 if self.eps_in is None: 

203 return None 

204 

205 deltas = [self._to_work(self.level.prob, d) for d in self._deltas] 

206 self.accumulate(deltas) 

207 self.eps_in = self.advance_residual(self.eps_in, deltas, self._dfs) 

208 return None 

209 

210 _work_scale = 1.0 

211 r"""Shared divisor applied to the correction quantities before they are stored.""" 

212 

213 def _work_init(self, prob): 

214 """Build the problem's ``init`` tuple with the correction dtype substituted.""" 

215 return (prob.init[0], prob.init[1], self._work_dtype) 

216 

217 def _scales_corrections(self): 

218 """ 

219 Whether this sweeper may choose a scale for its correction quantities. 

220 

221 A level that computes its own residual may: everything it stores is derived from that 

222 residual within the same sweep, so one divisor keeps them all commensurate and ordinary 

223 arithmetic on them stays correct. A level that *inherits* a residual may not, because the 

224 inherited value was scaled by whoever produced it and is carried across sweeps. 

225 """ 

226 return self.eps_in is None 

227 

228 def _set_work_scale(self, values): 

229 r""" 

230 Choose the divisor for this sweep, from the residual the corrections will be built out of. 

231 

232 This is what lets a correction be stored below ``float16``'s smallest normal, 6.1e-5. The 

233 delta form drives :math:`\varepsilon` and :math:`\delta` towards zero on purpose, and half 

234 precision has almost no mantissa left down there -- 1.3e-2 relative at 1e-6, 1.9e-1 at 1e-7 -- 

235 so an unscaled correction turns to noise exactly when it starts to matter. Dividing by the 

236 residual's own magnitude keeps the stored values at :math:`\mathcal{O}(1)`, which is block 

237 floating point, and is what half-precision hardware does anyway. 

238 

239 Parameters 

240 ---------- 

241 values : list 

242 The residual at the collocation nodes, in backend units. 

243 """ 

244 if self._work_dtype is None or not self._scales_corrections(): 

245 self._work_scale = 1.0 

246 return 

247 biggest = max((abs(value) for value in values), default=0.0) 

248 self._work_scale = float(biggest) if biggest > 0.0 else 1.0 

249 

250 def _to_work(self, prob, value): 

251 """ 

252 Store a small correction quantity in a reduced-precision datatype. 

253 

254 Returns the value unchanged when no reduced precision was requested, so the default path 

255 makes no assumption about the datatype and works with any pySDC backend. 

256 

257 Raises 

258 ------ 

259 NotImplementedError 

260 If ``correction_precision`` was requested but the datatype cannot be built at another 

261 precision, as is the case for datatypes not backed by a numpy array. 

262 """ 

263 if self._work_dtype is None: 

264 return value 

265 try: 

266 me = prob.dtype_u(self._work_init(prob)) 

267 me[:] = value if self._work_scale == 1.0 else value / self._work_scale 

268 except (TypeError, NotImplementedError) as error: 

269 raise NotImplementedError( 

270 f'correction_precision is not supported for {prob.dtype_u.__name__}: it cannot be ' 

271 f'built at a different precision from the problem init tuple ({error})' 

272 ) from error 

273 return me 

274 

275 def _to_backend(self, prob, value): 

276 """ 

277 Lift a possibly reduced-precision quantity back to backend precision. 

278 

279 A no-op when no reduced precision is in play, which keeps the default path datatype-agnostic. 

280 """ 

281 if self._work_dtype is None: 

282 return value 

283 me = prob.dtype_u(prob.init) 

284 me[:] = value 

285 if self._work_scale != 1.0: 

286 # widen first, scale second. The other order multiplies at the reduced precision, and a 

287 # scale of 1e-8 then lands the result below float16's smallest subnormal on the way out 

288 # -- the value is destroyed before it ever reaches the backend-precision array. 

289 me *= self._work_scale 

290 return me 

291 

292 def _coeff(self, value): 

293 """ 

294 Cast a scalar coefficient so an accumulation stays at the precision it should. 

295 

296 The ``float`` matters. A coefficient taken out of a numpy array is an ``np.float64``, and 

297 multiplying a reduced-precision level quantity by one of those upcasts the result to 

298 ``float64`` under NEP 50 -- NumPy 2's rule -- which would quietly put the whole level back at 

299 backend precision. A plain Python float is weak under both the old and the new rule, so the 

300 array's own dtype wins. 

301 """ 

302 if self._work_dtype is None: 

303 return float(value) 

304 return self._work_dtype.type(value) 

305 

306 def _residual_nodes(self): 

307 r""" 

308 Compute :math:`\varepsilon_m = u_0 + \tau_m + \Delta t (Q f^k)_m - u^k_m`. 

309 

310 This is the high-precision residual of iterative refinement. It is a difference of 

311 :math:`\mathcal{O}(1)` quantities and is therefore always formed in backend precision -- 

312 unless a transfer handed one down, in which case that one is already exact and small, and 

313 rebuilding it here is what the delta-form hierarchy exists to avoid. 

314 

315 Returns 

316 ------- 

317 list 

318 One residual per collocation node. 

319 """ 

320 if self.eps_in is not None: 

321 return self.eps_in 

322 

323 lvl = self.level 

324 eps = self.integrate() 

325 for m in range(self.coll.num_nodes): 

326 eps[m] += lvl.u[0] 

327 eps[m] -= lvl.u[m + 1] 

328 if lvl.tau[m] is not None: 

329 eps[m] += lvl.tau[m] 

330 return eps 

331 

332 def _f_increment(self, prob, f_new, f_old, u_old, delta, t_node): 

333 r""" 

334 Form the right-hand side increment :math:`\Delta f = f(w+\delta) - f(w)`. 

335 

336 Formed by subtraction unless the problem can expand it analytically. The subtraction is a 

337 cancellation of two :math:`\mathcal{O}(|f|)` quantities, so its absolute error is 

338 :math:`\varepsilon |f|` in whatever precision the level stores ``f`` at, and :math:`|f|` 

339 carries the operator norm. That is harmless while the level is at backend precision and 

340 becomes the binding term as soon as it is not, which is why a problem living on a 

341 reduced-precision level should provide ``eval_f_increment``. 

342 

343 Parameters 

344 ---------- 

345 prob : pySDC.core.problem.Problem 

346 The problem on this level. 

347 f_new, f_old : dtype_f 

348 ``f`` at :math:`w+\delta` and at :math:`w`, used by the subtraction fallback. 

349 u_old : dtype_u 

350 The base state :math:`w`. 

351 delta : dtype_u 

352 The correction :math:`\delta`. 

353 t_node : float 

354 Physical time of the collocation node. 

355 

356 Returns 

357 ------- 

358 dtype_f 

359 The increment, with the same splitting as ``eval_f``. 

360 """ 

361 if hasattr(prob, 'eval_f_increment'): 

362 increment = prob.eval_f_increment(u_old, delta, t_node) 

363 else: 

364 increment = prob.dtype_f(f_new) 

365 increment -= f_old 

366 # recorded for the residual recursion, so a transfer never recovers it by subtraction 

367 self._dfs.append(total_increment(prob, increment)) 

368 return increment 

369 

370 def _solve_correction(self, rhs_corr, alpha, u_old, f_old, t_node, implicit_part=None): 

371 r""" 

372 Solve the node-local correction equation. 

373 

374 Parameters 

375 ---------- 

376 rhs_corr : dtype_u 

377 Right-hand side :math:`r` of the correction equation. 

378 alpha : float 

379 Implicit prefactor :math:`\alpha = \Delta t Q^\Delta_{mm}`. 

380 u_old : dtype_u 

381 Current nodal value :math:`u^k_m`, the base state of the correction. 

382 f_old : dtype_f 

383 ``f`` evaluated at ``u_old``; already stored on the level, so it costs nothing. 

384 t_node : float 

385 Physical time of the collocation node. 

386 implicit_part : dtype_u, optional 

387 The implicit component of ``f_old`` for IMEX problems. Defaults to ``f_old``. 

388 

389 Returns 

390 ------- 

391 dtype_u 

392 The correction :math:`\delta_m`. 

393 """ 

394 prob = self.level.prob 

395 f_impl_old = f_old if implicit_part is None else implicit_part 

396 

397 rhs_phys = self._to_backend(prob, rhs_corr) 

398 

399 if alpha == 0: 

400 # explicit node: the correction is the residual itself 

401 delta = rhs_phys 

402 elif hasattr(prob, 'solve_system_delta'): 

403 delta = prob.solve_system_delta(rhs_phys, alpha, u_old, f_old, t_node) 

404 elif self._linear_implicit: 

405 # f(w+d) - f(w) = A d, so solve_system already solves the correction equation once the 

406 # affine part f(0, t) has been removed. f(0, t) vanishes for a homogeneous operator. 

407 zero = prob.dtype_u(prob.init, val=0.0) 

408 affine = prob.eval_f(zero, t_node) 

409 rhs_phys -= alpha * (affine if implicit_part is None else affine.impl) 

410 delta = prob.solve_system(rhs_phys, alpha, zero, t_node) 

411 else: 

412 # Fallback: substitute y = u_old + delta. Always correct, but the solver sees an O(1) 

413 # unknown, so there is no precision benefit. 

414 rhs_phys += u_old 

415 rhs_phys -= alpha * f_impl_old 

416 solution = prob.solve_system(rhs_phys, alpha, u_old, t_node) 

417 delta = prob.dtype_u(solution) 

418 delta -= u_old 

419 # recorded so a transfer never has to recover the correction by subtraction 

420 self._deltas.append(delta) 

421 return delta 

422 

423 

424class delta_implicit(DeltaFormMixin, generic_implicit): 

425 """ 

426 Delta-form counterpart of :class:`generic_implicit`. 

427 

428 Mathematically identical to the standard sweep; see the module docstring for the sweeper 

429 parameters ``correction_precision`` and ``linear_implicit``. 

430 """ 

431 

432 def _sweep_nodes(self): 

433 """ 

434 Perform one delta-form sweep over all collocation nodes. 

435 

436 Returns 

437 ------- 

438 None 

439 """ 

440 lvl = self.level 

441 prob = lvl.prob 

442 assert lvl.status.unlocked 

443 num_nodes = self.coll.num_nodes 

444 self._delta_setup() 

445 

446 residual = self._residual_nodes() 

447 self._set_work_scale(residual) 

448 eps = [self._to_work(prob, value) for value in residual] 

449 df = [None] * (num_nodes + 1) 

450 

451 for m in range(num_nodes): 

452 t_node = lvl.time + lvl.dt * self.coll.nodes[m] 

453 

454 rhs_corr = type(eps[m])(eps[m]) 

455 for j in range(1, m + 1): 

456 if self.QI[m + 1, j] != 0.0: 

457 rhs_corr += self._coeff(lvl.dt * self.QI[m + 1, j]) * df[j] 

458 

459 alpha = lvl.dt * self.QI[m + 1, m + 1] 

460 u_old = prob.dtype_u(lvl.u[m + 1]) 

461 f_old = prob.dtype_f(lvl.f[m + 1]) 

462 

463 delta = self._solve_correction(rhs_corr, alpha, u_old, f_old, t_node) 

464 

465 lvl.u[m + 1] = u_old + self._to_backend(prob, self._to_work(prob, delta)) 

466 lvl.f[m + 1] = prob.eval_f(lvl.u[m + 1], t_node) 

467 

468 increment = self._f_increment(prob, lvl.f[m + 1], f_old, u_old, delta, t_node) 

469 df[m + 1] = self._to_work(prob, increment) 

470 

471 lvl.status.updated = True 

472 return None 

473 

474 

475class delta_imex_1st_order(DeltaFormMixin, imex_1st_order): 

476 """ 

477 Delta-form counterpart of :class:`imex_1st_order`. 

478 

479 The correction equation contains only differences of ``f``, never a Jacobian, so the 

480 explicit/implicit splitting is untouched. 

481 """ 

482 

483 def _sweep_nodes(self): 

484 """ 

485 Perform one delta-form IMEX sweep over all collocation nodes. 

486 

487 ``QE`` is strictly lower triangular, which :class:`imex_1st_order` already enforces, so the 

488 explicit part never contributes to the node-local solve. 

489 

490 Returns 

491 ------- 

492 None 

493 """ 

494 lvl = self.level 

495 prob = lvl.prob 

496 assert lvl.status.unlocked 

497 num_nodes = self.coll.num_nodes 

498 self._delta_setup() 

499 

500 residual = self._residual_nodes() 

501 self._set_work_scale(residual) 

502 eps = [self._to_work(prob, value) for value in residual] 

503 df_impl = [None] * (num_nodes + 1) 

504 df_expl = [None] * (num_nodes + 1) 

505 

506 for m in range(num_nodes): 

507 t_node = lvl.time + lvl.dt * self.coll.nodes[m] 

508 

509 rhs_corr = type(eps[m])(eps[m]) 

510 for j in range(1, m + 1): 

511 if self.QI[m + 1, j] != 0.0: 

512 rhs_corr += self._coeff(lvl.dt * self.QI[m + 1, j]) * df_impl[j] 

513 if self.QE[m + 1, j] != 0.0: 

514 rhs_corr += self._coeff(lvl.dt * self.QE[m + 1, j]) * df_expl[j] 

515 

516 alpha = lvl.dt * self.QI[m + 1, m + 1] 

517 u_old = prob.dtype_u(lvl.u[m + 1]) 

518 f_old = prob.dtype_f(lvl.f[m + 1]) 

519 

520 delta = self._solve_correction(rhs_corr, alpha, u_old, f_old, t_node, implicit_part=f_old.impl) 

521 

522 lvl.u[m + 1] = u_old + self._to_backend(prob, self._to_work(prob, delta)) 

523 lvl.f[m + 1] = prob.eval_f(lvl.u[m + 1], t_node) 

524 

525 increment = self._f_increment(prob, lvl.f[m + 1], f_old, u_old, delta, t_node) 

526 df_impl[m + 1] = self._to_work(prob, prob.dtype_u(increment.impl)) 

527 df_expl[m + 1] = self._to_work(prob, prob.dtype_u(increment.expl)) 

528 

529 lvl.status.updated = True 

530 return None 

531 

532 

533def total_increment(prob, increment): 

534 """ 

535 The full right-hand side increment, recombining an IMEX splitting. 

536 

537 Parameters 

538 ---------- 

539 prob : pySDC.core.problem.Problem 

540 The problem the increment belongs to. 

541 increment : dtype_f 

542 The increment, possibly split into ``impl`` and ``expl``. 

543 

544 Returns 

545 ------- 

546 dtype_u 

547 The sum of the parts. 

548 """ 

549 if not hasattr(increment, 'impl'): 

550 return increment 

551 return prob.dtype_u(increment.impl) + increment.expl