Coverage for pySDC/tutorial/step_8/A_adaptive_time_stepping.py: 100%

70 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 12:30 +0000

1# --- 

2# jupyter: 

3# jupytext: 

4# formats: py:percent 

5# kernelspec: 

6# display_name: Python 3 

7# name: python3 

8# --- 

9 

10# %% [markdown] 

11# # Part A: Adaptive time-stepping 

12# 

13# So far, all our steps had the same size. For many problems this is wasteful: the solution changes quickly at some 

14# times and slowly at others, and a fixed step size has to be small enough for the fastest phase. pySDC can choose 

15# the step size on the fly with the `Adaptivity` convergence controller. We try it on the van der Pol oscillator 

16# 

17# $$ 

18# u'' - \mu (1 - u^2) u' + u = 0 

19# $$ 

20# 

21# with $\mu = 5$, written as a system for $u$ and $u'$ in `vanderpol`. In $[0, 10]$, its solution creeps along, jumps 

22# once, and creeps along again. We solve it with implicit SDC and exactly 4 iterations per step. The function below 

23# does one run, with the fixed step size `dt` or, if a tolerance `e_tol` is given, adaptively, starting with `dt`. 

24 

25# %% 

26import matplotlib.pyplot as plt 

27import numpy as np 

28 

29from pySDC.helpers.stats_helper import get_sorted 

30from pySDC.implementations.controller_classes.controller_nonMPI import controller_nonMPI 

31from pySDC.implementations.convergence_controller_classes.adaptivity import Adaptivity 

32from pySDC.implementations.hooks.log_solution import LogSolution 

33from pySDC.implementations.hooks.log_work import LogWork 

34from pySDC.implementations.problem_classes.Van_der_Pol_implicit import vanderpol 

35from pySDC.implementations.sweeper_classes.generic_implicit import generic_implicit 

36 

37mu = 5.0 

38Tend = 10.0 

39 

40 

41def run_vdp(dt, e_tol=None): 

42 description = { 

43 'problem_class': vanderpol, 

44 'problem_params': {'mu': mu, 'newton_tol': 1e-9, 'relative_tolerance': True}, 

45 'sweeper_class': generic_implicit, 

46 'sweeper_params': {'quad_type': 'RADAU-RIGHT', 'num_nodes': 3, 'QI': 'LU'}, 

47 'level_params': {'dt': dt}, # no restol: every step does maxiter iterations 

48 'step_params': {'maxiter': 4}, 

49 } 

50 if e_tol is not None: 

51 description['convergence_controllers'] = {Adaptivity: {'e_tol': e_tol}} 

52 

53 controller_params = { 

54 'logger_level': 30, 

55 'mssdc_jac': False, # Adaptivity refuses the (default) Jacobi mode of multi-step SDC 

56 'hook_class': [LogSolution, LogWork], 

57 } 

58 controller = controller_nonMPI(num_procs=1, controller_params=controller_params, description=description) 

59 P = controller.MS[0].levels[0].prob 

60 return controller.run(u0=P.u_exact(0.0), t0=0.0, Tend=Tend) 

61 

62 

63# reference solution at Tend, computed once with scipy's solve_ivp at a tight tolerance 

64u_ref = vanderpol(mu=mu).u_exact(Tend) 

65 

66 

67def summary(uend, stats): 

68 """Accepted steps, restarts, the work of all steps, restarted ones included, and the error at the end.""" 

69 return { 

70 't_end': get_sorted(stats, type='u', recomputed=False)[-1][0], 

71 'steps': len(get_sorted(stats, type='niter', recomputed=False)), 

72 'restarts': sum(me[1] for me in get_sorted(stats, type='restart')), 

73 'iterations': sum(me[1] for me in get_sorted(stats, type='niter')), 

74 'rhs': sum(me[1] for me in get_sorted(stats, type='work_rhs')), 

75 'newton': sum(me[1] for me in get_sorted(stats, type='work_newton')), 

76 'error': abs(uend - u_ref), 

77 } 

78 

79 

80def show(name, me): 

81 print( 

82 f"{name:>12}: {me['steps']:4d} steps, {me['restarts']:3d} restarts, {me['iterations']:5d} iterations, " 

83 f"{me['rhs']:6d} right-hand sides, {me['newton']:5d} Newton iterations, error {me['error']:.2e}" 

84 ) 

85 

86 

87# %% [markdown] 

88# `LogSolution` records the solution after each step and `LogWork` the increments of the problem's work counters, 

89# here of right-hand side evaluations and Newton iterations. The error is measured against a reference solution 

90# from scipy. 

91# 

92# ## An adaptive run 

93# 

94# All it takes is the `Adaptivity` convergence controller with a tolerance `e_tol` for the local error. We start with 

95# a small step and let it grow: 

96 

97# %% 

98e_tol = 2e-5 

99uend, stats = run_vdp(dt=1e-2, e_tol=e_tol) 

100adaptive = summary(uend, stats) 

101show('adaptive', adaptive) 

102 

103# %% [markdown] 

104# `Adaptivity` adds the hooks it needs itself: `LogStepSize` records the step size as `dt`, and the error estimate 

105# it relies on comes with `LogEmbeddedErrorEstimate`. The restarts are recorded by `LogRestarts`, which comes with 

106# the restarting that `controller_nonMPI` always has. When a step is restarted, its values stay in the statistics; 

107# `recomputed=False` in `get_sorted` leaves them out and keeps only the accepted steps. 

108 

109# %% 

110u = get_sorted(stats, type='u', recomputed=False) 

111dt = get_sorted(stats, type='dt', recomputed=False) 

112e_em = get_sorted(stats, type='error_embedded_estimate', recomputed=False) 

113restart_times = [t for t, restarted in get_sorted(stats, type='restart') if restarted] 

114 

115ratio = np.array([e for _, e in e_em]) / e_tol 

116print(f'estimate / e_tol of the accepted steps: median {np.median(ratio):.2f}, max {ratio.max():.2f}') 

117 

118# %% tags=["hide-input"] 

119fig, axs = plt.subplots(3, 1, figsize=(8, 6), sharex=True, constrained_layout=True) 

120axs[0].plot([t for t, _ in u], [v[0] for _, v in u], label="$u$") 

121axs[0].plot([t for t, _ in u], [v[1] for _, v in u], label="$u'$") 

122axs[0].legend(frameon=False) 

123axs[0].set_ylabel('solution') 

124axs[1].semilogy([t for t, _ in dt], [v for _, v in dt], '.-', color='black') 

125for t in restart_times: 

126 axs[1].axvline(t, color='grey', ls=':', lw=0.8) 

127axs[1].set_ylabel(r'$\Delta t$ (dotted: restarts)') 

128axs[2].semilogy([t for t, _ in e_em], [v for _, v in e_em], '.', color='magenta', label='embedded estimate') 

129axs[2].axhline(e_tol, color='black', ls='--', label='e_tol') 

130axs[2].legend(frameon=False) 

131axs[2].set_ylabel('local error') 

132axs[2].set_xlabel('time') 

133for ax in axs: 

134 ax.grid(alpha=0.3) 

135 

136# %% [markdown] 

137# The step size follows the solution: it grows from the initial $0.01$ to about $1$ within the first time unit, 

138# shrinks as the jump approaches, is smallest ($0.015$) at $t \approx 5.3$, where $u$ changes fastest ($u'$ reaches 

139# $-7.6$), and grows again to its largest value ($1.28$) after the jump. The estimates of the accepted steps stay below 

140# `e_tol`, and all but 5 of the 77 above a tenth of it: their median is $0.48$ `e_tol`. 

141# 

142# ## How it works 

143# 

144# - **Error estimate.** Each iteration raises the order of SDC by one, so the solution of the next-to-last iteration 

145# is a lower order approximation than that of the last one. `EstimateEmbeddedError` takes the difference of the two 

146# at the end of the step as an estimate of the local error of the lower order one: the embedded error estimate. 

147# - **New step size.** After the last iteration, the next step size is 

148# $\Delta t_\mathrm{new} = \beta \, \Delta t \, (\epsilon_\mathrm{tol} / \epsilon)^{1/k}$, with the estimate 

149# $\epsilon$, the tolerance $\epsilon_\mathrm{tol}$ (`e_tol`), the safety factor $\beta$ (`beta`, 0.9 by default), 

150# and $k$ the number of iterations, which the code uses as the order of the estimate. Hence, all steps must do the 

151# same number of iterations: `Adaptivity` refuses a residual tolerance `restol` of zero or more. 

152# - **Restarts.** If $\epsilon$ is not below $\epsilon_\mathrm{tol}$, the step is not accepted but restarted with 

153# $\Delta t_\mathrm{new}$, which is then smaller than $\Delta t$. A step that still fails after 10 restarts in a 

154# row stops the run with a `ConvergenceError` (parameters `max_restarts` and `crash_after_max_restarts` of 

155# `BasicRestarting`). 

156# 

157# The rule proposes the next step as if the error behaves there as in the current one. Where the error grows from step 

158# to step, as on the way to the jump, the proposal is too large and the step is restarted. That is where most of the 

159# restarts are: each of them redid one step, and none had to redo a step twice. 

160# 

161# ## A comparison with fixed steps 

162# 

163# The same SDC with fixed step sizes, halving them until the error matches that of the adaptive run: 

164 

165# %% 

166dts_fixed = [1 / 8, 1 / 16, 1 / 32] 

167fixed = [summary(*run_vdp(dt=me)) for me in dts_fixed] 

168for dt_fixed, me in zip(dts_fixed, fixed, strict=True): 

169 show(f'dt = 1/{round(1 / dt_fixed)}', me) 

170show('adaptive', adaptive) 

171 

172# %% tags=["hide-input"] 

173fig, ax = plt.subplots(figsize=(6, 3.5), constrained_layout=True) 

174ax.loglog([me['newton'] for me in fixed], [me['error'] for me in fixed], 'o-', label='fixed step size') 

175for dt_fixed, me in zip(dts_fixed, fixed, strict=True): 

176 ax.annotate(f' 1/{round(1 / dt_fixed)}', (me['newton'], me['error'])) 

177ax.loglog(adaptive['newton'], adaptive['error'], '*', ms=12, label='adaptive') 

178ax.set_xlabel('Newton iterations') 

179ax.set_ylabel('error at the end') 

180ax.legend(frameon=False) 

181ax.grid(alpha=0.3) 

182 

183# %% [markdown] 

184# With $\Delta t = 1/32$, the error is the same as with adaptivity, $7.3 \cdot 10^{-6}$ against 

185# $7.2 \cdot 10^{-6}$. For that, the fixed steps need 320 steps, 1280 iterations, 5120 right-hand side evaluations 

186# and 4768 Newton iterations. The adaptive run gets there with 77 steps plus 27 restarts, 416 iterations, 1664 

187# right-hand side evaluations and 2072 Newton iterations: about a third of the iterations, although a quarter of them 

188# went into restarted steps, and less than half the Newton iterations. 

189# 

190# :::{admonition} Important things to note 

191# - `e_tol` bounds the estimated local error of each step, not the error at the end. Here, a local tolerance of 

192# $2 \cdot 10^{-5}$ gave an error of $7.2 \cdot 10^{-6}$ at the end, but the relation depends on the problem. 

193# - The step size can be limited with the parameters `dt_min` and `dt_max` of `Adaptivity`, which pass them on to 

194# the `StepSizeLimiter` convergence controller. 

195# - With `avoid_restarts`, `Adaptivity` estimates from the contraction of the iteration how many more iterations 

196# would reach the tolerance, and continues iterating instead of restarting if they are few enough. 

197# ::: 

198# 

199# The checks the tests run: 

200 

201# %% 

202assert all(me['t_end'] == Tend for me in [adaptive] + fixed), 'ERROR: not all runs reached Tend' 

203assert ratio.max() < 1, f'ERROR: an accepted step has an estimate above the tolerance: {ratio.max()}' 

204assert adaptive['restarts'] > 0, 'ERROR: expected restarts in the adaptive run' 

205assert ( 

206 adaptive['error'] < 1.1 * fixed[-1]['error'] 

207), f"ERROR: adaptive run is less accurate than dt=1/32: {adaptive['error']:.2e} vs. {fixed[-1]['error']:.2e}" 

208assert ( 

209 adaptive['iterations'] < fixed[-1]['iterations'] / 2 

210), f"ERROR: adaptive run needs too many iterations: {adaptive['iterations']} vs. {fixed[-1]['iterations']}" 

211assert ( 

212 adaptive['newton'] < fixed[-1]['newton'] / 2 

213), f"ERROR: adaptive run needs too many Newton iterations: {adaptive['newton']} vs. {fixed[-1]['newton']}"