def unifying() -> None: r""" The Forward Euler, Backward Euler, and Crank-Nicolson schemes. Notes ---------- The Forward Euler, Backward Euler, and Crank-Nicolson schemes can be formulated as one scheme with varying parameter θ: (uⁿ⁺¹ - uⁿ) / (t{n+1} - tn) = -a (θ uⁿ⁺¹ + (1 - θ) uⁿ) Observe that - θ = 0 gives the Forward Euler scheme. - θ = 1 gives the Backward Euler scheme. - θ = ½ gives the Crank-Nicolson scheme. One may alternatively choose any other value of θ in [0, 1], but this is not so common since the accuracy and stability of the scheme do not improve compared to the values θ = 0, 1, ½. As before, uⁿ is considered known and uⁿ⁺¹ unknown, so we solve for the latter: uⁿ⁺¹ = (1 - (1 - θ) a (t{n+1} - tn)) / (1 + θ a (t{n+1} - tn)) This is known as the θ-rule, or alternatively written as the "theta-rule". Compact notation: The θ-rule can be specified in operator notation by [D̄ₜ u = -a ūᵗʼᶿ]ⁿ⁺ᶿ We define a new time difference [D̄ₜ u]ⁿ⁺ᶿ = (uⁿ⁺¹ - uⁿ) / (t{n+1} - tn) To be applied at the time point t{n+θ} ≈ θ tn + (1 - θ) t{n+1}. This weighted average gives rise to the weighted average operator [ūᵗʼᶿ]ⁿ⁺ᶿ = (1 - θ) uⁿ + θ uⁿ⁺¹ ≈ u(t{n+θ}) where θ ∈ [0, 1] as usual. Note that for θ = ½, we recover the standard centered difference and standard arithmetic mean. An alternative and perhaps clearer notation is [Dₜ u]ⁿ⁺¹⸍² = θ [-a u]ⁿ⁺¹ + (1 - θ) [-a u]ⁿ """ from utils.solver import solver_chap2 as solver I = 1 a = 2 T = 8 dt = 0.8 # Write out a table of t and u values for each theta for th in (0, 0.5, 1): u, t = solver(I=I, a=a, T=T, dt=dt, theta=th) print('theta = {:g}'.format(th)) for idx, t_i in enumerate(t): print('t={0:6.3f} u={1:g}'.format(t_i, u[idx])) print('-----')
def numerical_error() -> None: r""" Computing the numerical error as a mesh function. Notes ---------- A natural way to compare the exact and discrete solutions is to calculate their difference as a mesh function for the error: eⁿ = uₑ(tn) - uⁿ, n = 0,1,...Nₜ We may also compute the norm of the error mesh function, so that we can get a single number expressing the size of the error. This is obtained by taking the norm of the error function. Three common norms are ‖f‖_{L²} = √(∫₀ᵀ f(t)² dt) ‖f‖_{L¹} = ∫₀ᵀ |f(t)| dt ‖f‖_{L∞} = max_{t ∈ [0, T]} |f(t)| The L² norm ("L-two norm") has nice mathematical properties and is the most popular norm. Numerical computations involving mesh functions need corresponding norms. Imagining that the mesh function is extended to vary linearly between the mesh points, the Trapezoidal rule is in fact an exact integration rule. A possible possible modification of the L² norm for a mesh function fⁿ on a uniform mesh with spacing Δt is therefore the well-known Trapezoidal formula. A common approximation of this, motivated by convenience is ‖fⁿ‖_{ℓ²} = √(Δt ∑₀ᴺᵗ (fⁿ)²) This is called the discrete L² norm and denoted by ℓ². If the square of this norm is used instead of the Trapezoidal integration formula, the error is Δt ((f⁰)² + (fᴺᵗ)²) / 2. This means that the weights at the end points of the mesh function are perturbed, but as Δt -> 0, the error from this perturbation goes to zero. The three discrete norms are then define by ‖fⁿ‖_{ℓ²} = √(Δt ∑₀ᴺᵗ (fⁿ)²) ‖fⁿ‖_{ℓ¹} = Δt ∑₀ᴺᵗ |fⁿ| ‖fⁿ‖_{ℓ∞} = max_{0 ≤ n ≤ Nₜ} |fⁿ| Note that L², L¹, ℓ², and ℓ¹ norms depend on the length of the interval of interest. In some applications it is convenient to think of the mesh function as just a vector of function values without any relation to the interval [0, T]. Then one can replace Δt by T / Nₜ and simply drop the T (which is just a common scaling factor). Moreover, people prefer to divide by the total length of the vector, Nₜ+1 instead of just Nₜ. This reasoning gives rise to the vector norms for a vector f = (f₀,...,f_N): ‖f‖₂ = √((1 / (N + 1)) ∑₀ᴺᵗ (fₙ)²) ‖f‖₁ = (1 / (N + 1)) ∑₀ᴺ |fₙ| ‖f‖_{ℓ∞} = max_{0 ≤ n ≤ N} |fₙ| We will mostly work with the mesh functions and use discrete ℓ² norm or the max norm ℓ∞, but the vector norms are also much used in numerical computations, so it is important to know the different norms and the relations between them. A single number that expresses the size of the numerical error will be taken as ‖eⁿ‖_{ℓ²} and called E: E = √(Δt ∑₀ᴺᵗ (eⁿ)²) """ from utils.solver import solver_chap2 as solver I = 1 a = 2 T = 8 th_dict = {0: ('Forward Euler', 'fe'), 1: ('Backward Euler', 'be'), 0.5: ('Crank-Nicolson', 'cn')} for th in th_dict: print('theta = {:g}'.format(th)) print('dt error') for dt in (0.4, 0.04): # Solve u, t = solver(I=I, a=a, T=T, dt=dt, theta=th) # Calculate exact solution u_exact = lambda t, I, a: I * np.exp(-a * t) u_e = u_exact(t, I, a) # Calculate the error and its discrete norm e = u_e - u E = np.sqrt(dt * np.sum(e**2)) print('{0:6.2f} {1:12.3E}'.format(dt, E)) # Plot with red dashes w/ circles plt.figure() plt.errorbar(t, u, e, 0, 'r--o', label='numerical') # Plot with blue line plt.plot(t, u_e, 'b-', label='exact') # Save figure plt.xlabel('t') plt.ylabel('u') plt.title('{}, dt={:g}'.format(th_dict[th][0], dt)) plt.legend() plt.savefig(IMGDIR + '{0}_{1:.2f}_err.png'.format( th_dict[th][1], dt), bbox_inches='tight') print('-----')
def forward_euler() -> None: r""" Solve ODE (1) by the Forward Euler (FE) finite difference scheme. Notes ---------- Solving an ODE like (1) by a finite difference method consists of the following four steps: 1) discretising the domain, 2) requiring fulfillment of the equation at discrete time points, 3) replacing derivatives by finite differences, 4) formulating a recursive algorithm. Step 1: Discretising the domain. We represent the time domain [0, T] by a finite number of points Nₜ+1, called a "mesh" or "grid". 0 = t0 < t1 < t2 < ... < t{Nₜ-1} < t{Nₜ} = T The goal is to find the solution u at the mesh points: u(tn) := uⁿ, n=0,1,...,Nₜ. More precisely, we let uⁿ be the numerical approximation to the exact solution u(tn) at t=tn. We say that the numerical approximation constitutes a mesh function, which is defined at discrete points in time. To compute the solution for some solution t ∈ [tn, tn+1], we can use an interpolation method, e.g. the linear interpolation formula u(t) ≈ uⁿ + (uⁿ⁺¹ - uⁿ) / (t{n+1} - tn) * (t - tn) Step 2: Fulfilling the equation at discrete time points. We relax the requirement that the ODE hold for all t ∈ (0, T] and require only that the ODE be fulfilled at discrete points in time. The mesh points are a natural (but not the only) choice of points. The original ODE is reduced to the following u'(tn) = -a u(tn) a > 0 n = 0,...,Nₜ u(0) = I Step 3: Replace derivative with finite differences. The next most essential step is to replace the derivative u' by the finite difference approximation. Here the forward difference approximation is u'(tn) ≈ (uⁿ⁺¹ - uⁿ) / (t{n+1} - tn) The name forward relates to the face that we use a value forward in time, uⁿ⁺¹, together with the value uⁿ at the point tn. Therefore, (uⁿ⁺¹ - uⁿ) / (t{n+1} - tn) = -a uⁿ, n=0,1,...,Nₜ-1 This is often referred to as a finite difference scheme or more generally as the discrete equations of the problem. The fundamental feature of these equations is that they are algebraic and can hence be straightforwardly solved to produce the mesh function uⁿ. Step 4: Formulating a recursive algorithm The final step is to identify the computational algorithm to be implemented in a program. In general, starting from u⁰ = u(0) = I, uⁿ can be assumed known, and then we can easily solve for the unknown uⁿ⁺¹ uⁿ⁺¹ = uⁿ - a (t{n+1} - tn) uⁿ From a mathematical point of view, these type of equations are known as difference equations since they express how differences in the dependent variable, here u, evolve with n. Interpretation: We have computed some point values on the solution curve, and the question is how we reason about the next point. Since we know u and t at the most recently computed point, the differential equation gives us the slope of the solution u' = -a u. We can continue the solution curve along that slop. As soon as we have chosen the next point on this line, we have a new t and u value and can compute a new slope and continue this process. Derivation: The key tool for this is the Taylor series. f(x) ≈ f(a) + f'(a)(x - a) + ½f''(a)(x - a)² + ⅙f'''(a)(x - a)³ + ... + (1 / m!) dᵐf/dxᵐ(a)(x - a)ᵐ For a function of time, f(t), related to mesh spacing Δt, f(tn + Δt) ≈ f(tn) + f'(tn)Δt + ½f''(tn)Δt² + ⅙f'''(tn)Δt³ + ... + (1 / m!) dᵐf/dtᵐ(tn)Δtᵐ Now, by rearranging for f'(tn) f'(tn) ≈ (f(tn + Δt) - f(tn)) / Δt - ½f''(tn)Δt - ⅙f'''(tn)Δt² - ... - (1 / m!) dᵐf/dtᵐ(tn)Δtᵐ⁻¹ Now, in the limit Δt -> 0, f'(tn) ≈ (f(tn + Δt) - f(tn) / Δt An interesting point is that we have a measure of the error as seen by the O(Δtᵐ) terms. A leading order, the error can be given as ½f''(tn)Δt. Compact notation: For a function u(t), a forward difference approximation is denoted by the Dₜ⁺ operator and written as [Dₜ⁺ u]ⁿ = (uⁿ⁺¹ - uⁿ) / Δt (≈ du(tn)/dt) This notation consists of an operator that approximates differentiation wrt an independent variable, here t. The operator is built on the symbol D, with the independent variable as subscript and a superscript denoting the type of difference. The superscript ⁺ indicates a forward difference. We place square brackets around the operator and the function it operates on and specify the mesh point, where the operator is acting, by a superscript after the closing bracket. In our compact notation, the Forward Euler scheme can be written as [Dₜ⁺ u]ⁿ = -a uⁿ In difference equations, we often place the square brackets around the whole equation, to indicate which mesh point the equation applies, since each term by be approximated at the same point: [Dₜ⁺ u = -a u]ⁿ """ # Define solver function def solver(I: float, a: float, T: float, dt: float): """Solve u'=-a*u, u(0)=I, for t in (0,T] with steps of dt using FE. Parameters ---------- I : Initial condition. a : Constant coefficient. T : Maximum time to compute to. dt : Step size. Returns ---------- u : Mesh function. t : Mesh points. """ # Initialise data structures Nt = int(T / dt) # Number of time intervals u = np.zeros(Nt + 1) # Mesh function t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points # Calculate mesh function using difference equation # uⁿ⁺¹ = uⁿ - a (t{n+1} - tn) uⁿ u[0] = I for n_idx in range(Nt): u[n_idx + 1] = (1 - a * dt) * u[n_idx] return u, t I = 1 a = 2 T = 8 dt = 0.8 u, t = solver(I=I, a=a, T=T, dt=dt) # Write out a table of t and u values for idx, t_i in enumerate(t): print('t={0:6.3f} u={1:g}'.format(t_i, u[idx])) # Plot with red dashes w/ circles plt.figure() plt.plot(t, u, 'r--o', label='numerical') # Calculate exact solution u_exact = lambda t, I, a: I * np.exp(-a * t) t_e = np.linspace(0, T, 1001) u_e = u_exact(t_e, I, a) # Plot with blue line plt.plot(t_e, u_e, 'b-', label='exact') # Save figure plt.xlabel('t') plt.ylabel('u') plt.title('Forward Euler, dt={:g}'.format(dt)) plt.legend() plt.savefig(IMGDIR + 'fe.png', bbox_inches='tight')
def crank_nicolson() -> None: r""" Solve ODE (1) by the Crank-Nicolson (CN) finite difference scheme. Notes ---------- The finite difference schemes derived in `forward_euler` and `backward_euler` are both one-sided differences. Such one-sided differences are known to be less accurate than central (or midpoint) differences, where we use information both forward and backward in time. A natural next step is therefore to construct a central difference approximation. The central difference approximation to the derivative is sought at the point t{n+½} = ½(tn + t{n+1}). The approximation reads u'(t{n + ½}) ≈ (uⁿ⁺¹ - uⁿ) / (t{n+1} - tn) With this formula, it is natural to demand the ODE be fulfilled at the time points between the mesh points: u'(t{n + ½}) = -a u(t{n + ½}), n=0,...,Nₜ-1 Combining these results results in the approximate discrete equation (uⁿ⁺¹ - uⁿ) / (t{n+1} - tn) = -a uⁿ⁺¹⸍², n=0,1,...,Nₜ-1 However, there is a fundamental problem with the right-hand side of the equation. We aim to compute uⁿ for integer n, which means that uⁿ⁺¹⸍² is not a quantity computed by our method. One possibility is to approximate uⁿ⁺¹⸍² as an arithmetic mean of the u values at the neighbouring mesh points uⁿ⁺¹⸍² ≈ ½(uⁿ + uⁿ⁺¹) We then obtain the approximate discrete equation (uⁿ⁺¹ - uⁿ) / (t{n+1} - tn) = -a ½(uⁿ + uⁿ⁺¹), n=0,1,...,Nₜ-1 There are three approximation steps leading to this formula. First, the ODE is only valid at discrete points. Second, the derivative is approximated by finite differences, and third, the value of u between mesh points is approximated by an arithmetic mean value. Despite one more approximation than for the Backward and Forward Euler schemes, the use of a centered difference leads to a more accurate method. To formulate a recursive method, we assume that uⁿ is already computed so that uⁿ⁺¹ is the unknown, which we can solve for: uⁿ⁺¹ = (1 - ½ a (t{n+1} - tn) / (1 + ½ a (t{n+1} - tn) * uⁿ Derivation: The centered difference approximates the derivative at tn + ½Δt. The Taylor expansions of f(tn) and f(t{n+1}) around tn + ½Δt are f(tn) ≈ f(tn + ½Δt) - f'(tn + ½Δt)½Δt + ½f''(tn + ½Δt)(½Δt)² - ⅙f'''(tn + ½Δt)(½Δt)³ + ... + (1 / m!) dᵐf/dtᵐ(tn + ½Δt)(½Δt)ᵐ f(t{n+1}) ≈ f(tn + ½Δt) + f'(tn + ½Δt)½Δt + ½f''(tn + ½Δt)(½Δt)² + ⅙f'''(tn + ½Δt)(½Δt)³ + ... + (1 / m!) dᵐf/dtᵐ(tn + ½Δt)(½Δt)ᵐ Subtracting the first from the second gives f(t{n+1}) - f(tn) ≈ f'(tn + ½Δt)Δt + ⅓f'''(tn + ½Δt)(½Δt)³ + ... Solving with respect to f'(tn + ½Δt) results in f'(tn + ½Δt) ≈ (f(t{n+1}) - f(tn)) / Δt - (1/24)f'''(tn + ½Δt)Δt² + ... The error measure goes like O(Δt²), which means the error here goes faster to zero compared to the forward and backward differences for small Δt. Compact notation: The centered difference operator notation reads [Dₜ u]ⁿ = (uⁿ⁺¹⸍² - uⁿ⁻¹⸍²) / Δt (≈ du(tn)/dt) Note here that no superscript implies a central differences. An averaging operator is also convenient to have: [ūᵗ]ⁿ = ½(uⁿ⁻¹⸍² + uⁿ⁺¹⸍²) ≈ u(tn) The superscript ᵗ indicates that the average is taken along the time coordinate. The common average (uⁿ + uⁿ⁺¹⸍²) / 2 can now be expressed as [ūᵗ]ⁿ⁺¹⸍². Now the Crank-Nicolson scheme to our ODE can be written as [Dₜ u = -a ūᵗ]ⁿ⁺¹⸍² """ # Define solver function def solver(I: float, a: float, T: float, dt: float): """Solve u'=-a*u, u(0)=I, for t in (0,T] with steps of dt using BE. Parameters ---------- I : Initial condition. a : Constant coefficient. T : Maximum time to compute to. dt : Step size. Returns ---------- u : Mesh function. t : Mesh points. """ # Initialise data structures Nt = int(T / dt) # Number of time intervals u = np.zeros(Nt + 1) # Mesh function t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points # Calculate mesh function using difference equation # uⁿ⁺¹ = 1 / (1 + a (t{n+1} - tn)) * uⁿ u[0] = I for n_idx in range(Nt): u[n_idx + 1] = (1 - 0.5 * a * dt) / (1 + 0.5 * a * dt) * u[n_idx] return u, t I = 1 a = 2 T = 8 dt = 0.8 u, t = solver(I=I, a=a, T=T, dt=dt) # Write out a table of t and u values for idx, t_i in enumerate(t): print('t={0:6.3f} u={1:g}'.format(t_i, u[idx])) # Plot with red dashes w/ circles plt.figure() plt.plot(t, u, 'r--o', label='numerical') # Calculate exact solution u_exact = lambda t, I, a: I * np.exp(-a * t) t_e = np.linspace(0, T, 1001) u_e = u_exact(t_e, I, a) # Plot with blue line plt.plot(t_e, u_e, 'b-', label='exact') # Save figure plt.xlabel('t') plt.ylabel('u') plt.title('Crank-Nicolson, dt={:g}'.format(dt)) plt.legend() plt.savefig(IMGDIR + 'cn.png', bbox_inches='tight')
def backward_euler() -> None: r""" Solve ODE (1) by the Backward Euler (BE) finite difference scheme. Notes ---------- There are several choices of difference approximations in step 3 of the finite difference scheme presenting in `forward_euler`. Another alternative is u'(tn) ≈ (uⁿ - uⁿ⁻¹) / (tn - t{n-1}) Since this difference is going backward in time (t{n-1}) for information, it is known as a backward difference, also called Backward Euler difference. Inserting our equation yields (uⁿ - uⁿ⁻¹) / (tn - t{n-1}) = -a uⁿ, n=1,...,Nₜ For direct similarity to the Forward Euler scheme, we replace n by n+1 and solve for the unknown value uⁿ⁺¹ uⁿ⁺¹ = 1 / (1 + a (t{n+1} - tn)) * uⁿ, n=0,...,Nₜ-1 Derivation: Here we use the Taylor series around f(fn - Δt) f(tn - Δt) ≈ f(tn) - f'(tn)Δt + ½f''(tn)Δt² - ⅙f'''(tn)Δt³ + ... + (1 / m!) dᵐf/dtᵐ(tn)Δtᵐ Solving with respect to f'(tn) gives f'(tn) ≈ (f(tn) - f(tn - Δt)) / Δt + ½f''(tn)Δt - ⅙f'''(tn)Δt² + ... - (1 / m!) dᵐf/dtᵐ(tn)Δtᵐ⁻¹ Then term ½f''(tn)Δt can be taken as a simple measure of the approximation error. Compact notation: The backward difference reads [Dₜ⁻ u]ⁿ = (uⁿ - uⁿ⁻¹) / Δt (≈ du(tn)/dt) Note the subscript ⁻ denotes the backward difference. The Backward Euler scheme to our ODE can be written as [Dₜ⁻ u = -a u]ⁿ """ # Define solver function def solver(I: float, a: float, T: float, dt: float): """Solve u'=-a*u, u(0)=I, for t in (0,T] with steps of dt using BE. Parameters ---------- I : Initial condition. a : Constant coefficient. T : Maximum time to compute to. dt : Step size. Returns ---------- u : Mesh function. t : Mesh points. """ # Initialise data structures Nt = int(T / dt) # Number of time intervals u = np.zeros(Nt + 1) # Mesh function t = np.linspace(0, Nt * dt, Nt + 1) # Mesh points # Calculate mesh function using difference equation # uⁿ⁺¹ = 1 / (1 + a (t{n+1} - tn)) * uⁿ u[0] = I for n_idx in range(Nt): u[n_idx + 1] = 1 / (1 + a * dt) * u[n_idx] return u, t I = 1 a = 2 T = 8 dt = 0.8 u, t = solver(I=I, a=a, T=T, dt=dt) # Write out a table of t and u values for idx, t_i in enumerate(t): print('t={0:6.3f} u={1:g}'.format(t_i, u[idx])) # Testing Backward difference for idx in reversed(range(len(u) - 1)): print('calc u(n-1) = {:.6f}'.format(u[idx + 1] + a * dt * u[idx + 1])) print('actu u(n-1) = {:.6f}'.format(u[idx])) # Plot with red dashes w/ circles plt.figure() plt.plot(t, u, 'r--o', label='numerical') # Calculate exact solution u_exact = lambda t, I, a: I * np.exp(-a * t) t_e = np.linspace(0, T, 1001) u_e = u_exact(t_e, I, a) # Plot with blue line plt.plot(t_e, u_e, 'b-', label='exact') # Save figure plt.xlabel('t') plt.ylabel('u') plt.title('Backward Euler, dt={:g}'.format(dt)) plt.legend() plt.savefig(IMGDIR + 'be.png', bbox_inches='tight')
def scaling() -> None: """ Scaling and dimensionless variables. Notes ---------- Real applications of a model u' = -a u + b will often involve a lot of parameters in the expressions for a and b. It can be quite challenging to find relevant values for all parameters. In simple problems, however, it turns out that it is not always necessary to estimate all parameters because we can lump them into one or a few dimensionless numbers by using a very attractive technique called scaling. It simply means to stretch the u and t axis in the present problem - and suddenly all parameters in the problem are lumped into one parameter if b ≠ 0 and no parameter when b = 0! Scaling means we introduce a new function ū(ť), with ū = (u - uᵐ) / uᶜ ť = t / tᶜ where uᵐ is a characteristic value of u, uᶜ is a characteristic size of the range of u values, and tᶜ is a characteristic size of the range of t where u shows significant variation. Choosing uᵐ, uᶜ, and tᶜ is not always easy and is often an art in complicated problems. We just state one choice first: uᶜ = I, uᵐ = b / a, tᶜ = 1 / a Inserting u = uᵐ + uᶜ ū and t = tᶜ ť in the problem u' = a u + b, assuming a and b are constants, results (after some algebra) in the scaled problem dū/dť = -ū, ū(0) = 1 - β where β = b / (I a) The parameter β is a dimensionless number. An important observation is that ū depends on ť and β. That is, only the special combination of b / (I a) matters, not what the individual values of b, a, and I are. The original unscaled function depends on t, b, a, and I. A second observation is striking: if b = 0, the scaled problem is independent of a and I! In practice, this means that we can perform a single numerical simulation of the scaled problem and recover the solution of any problems for any given a and I by stretching the axis in the plot: u = I ū and t = ť / a. For any b ≠ 0, we simulate the scaled problem for a few β values and recover the physical solution u by translating and stretching the t axis. In general, scaling combines the parameters in a problem to a set of dimensionless parameters. The number of dimensionless parameters is usually much smaller than the number of original parameters. This scaling breaks down if I = 0. In that case, we may choose uᵐ = 0, uᶜ = b / a, and tᶜ = 1 / b, resulting in the slightly different scaled problem dū/dť = 1 - ū, ū(0) = 0 As with b = 0, the case I = 0 has a scaled problem with no physical parameters! It is common to drop the bars after scaling and write the scaled problem as u' = u, u(0) = 1 - β, or u' = 1 - u, u(0) = 0. Any implementation of the problem u' = -a u + b, u(0) = I can then be reused for the scaled problem by setting a = 1, b = 0, and I = 1 - β if I ≠ 0, or one sets a = 1, b = 1, and I = 0 when the physical I is zero. """ from utils.solver import solver_chap4 as solver # Definitions # Solving y'(x) = λ y + α x ∈ (0, X] y(0) = I ≠ 0 I = 1 X = 4 # lamda = -0.8 # alpha = 0.5 # ylims = (0.5, 1) lamda = 0.8 alpha = 0.5 ylims = (0, 40) y_exact = lambda x: np.exp(x * lamda) * I + \ ((-1 + np.exp(x * lamda)) * alpha) / lamda u_exact = lambda I, t: I * np.exp(t) # Scaling # ỹ = (y + yᵐ) / yᶜ, 𝐱 = x / xᶜ # yᶜ = I, yᵐ = α / λ, xᶜ = 1 / λ # therefore, # dỹ/d𝐱 = ỹ, 𝐱 ∈ (0, λ T], ỹ(0) = 1 + β = 1 + α / (I λ) # # y(x) = (ỹ(λ x) - β) * I beta = alpha / (I * lamda) ulims = list(map(lambda x: (x / I) + beta, ylims)) th_dict = { 0: ('Forward Euler', 'fe', 'r-s'), 1: ('Backward Euler', 'be', 'g-v'), 0.5: ('Crank-Nicolson', 'cn', 'b-^') } fig1 = plt.figure(figsize=(14, 10)) fig3 = plt.figure(figsize=(14, 10)) fig1.suptitle( r'$\frac{dy(x)}{dx}=\lambda y(x)+\alpha\:{\rm where}\:\lambda=' + f'{lamda:g}' + r',\alpha=' + f'{alpha:g}' + r'$', y=0.95) fig3.suptitle(r'$\frac{du(t)}{dt}=u(t)\:{\rm where}\:\beta=' + f'{beta:g}' + r'$', y=0.95) axs1 = [] axs3 = [] gs1 = gridspec.GridSpec(2, 2) gs3 = gridspec.GridSpec(2, 2) gs1.update(hspace=0.2, wspace=0.2) gs3.update(hspace=0.2, wspace=0.2) for th_idx, th in enumerate(th_dict): fig2, axs2 = plt.subplots(2, 2, figsize=(10, 8), gridspec_kw={'hspace': 0.3}) for dx_idx, dx in enumerate((1.5, 1.25, 0.75, 0.1)): # Calculate mesh points Nx = int(X / dx) x = np.linspace(0, Nx * dx, Nx + 1) # Calculate scaled mesh points T = lamda * X t = lamda * x dt = lamda * dx if th_idx == 0: gssub1 = gs1[dx_idx].subgridspec(2, 1, height_ratios=(2, 1), hspace=0) ax1 = fig1.add_subplot(gssub1[0]) ax2 = fig1.add_subplot(gssub1[1]) gssub2 = gs3[dx_idx].subgridspec(2, 1, height_ratios=(2, 1), hspace=0) axs1.append([ax1, ax2]) ax3 = fig3.add_subplot(gssub2[0]) ax4 = fig3.add_subplot(gssub2[1]) axs3.append([ax3, ax4]) ax1.set_title('$\Delta x={:g}$'.format(dx)) ax1.set_ylim(*ylims) ax1.set_xlim(0, X) ax1.set_ylabel('y(x)') ax1.set_xticks([]) ax1.set_yticks(ax1.get_yticks()[1:]) ax2.grid(c='k', ls='--', alpha=0.3) ax2.set_yscale('log') ax2.set_xlim(0, X) ax2.set_xlabel('x') ax2.set_ylabel('log err') ax3.set_title('$\Delta t={:g}$'.format(dt)) ax3.set_ylim(*ulims) ax3.set_xlim(0, T) ax3.set_ylabel('u(t)') ax3.set_xticks([]) ax3.set_yticks(ax3.get_yticks()[1:]) ax4.grid(c='k', ls='--', alpha=0.3) ax4.set_yscale('log') ax4.set_xlim(0, T) ax4.set_xlabel('t') ax4.set_ylabel('log err') # Calculate exact solution x_e = np.linspace(0, X, 1001) y_e = y_exact(x_e) t_e = np.linspace(0, T, 1001) u_e = u_exact(1 + beta, t_e) # Plot with black line ax1.plot(x_e, y_e, 'k-', label='exact') ax3.plot(t_e, u_e, 'k-', label='exact') ax1, ax2 = axs1[dx_idx] ax3, ax4 = axs3[dx_idx] ax = axs2.flat[dx_idx] ax.set_title('{}, dx={:g}'.format(th_dict[th][0], dx)) # ax.set_ylim(0, 15) ax.set_xlim(0, X) ax.set_xlabel(r'x') ax.set_ylabel(r'$y(x)$') # Solve dỹ/d𝐱 = ỹ using Crank-Nicolson scheme u = solver(I=1 + beta, a=-1, t=t, theta=th) # Convert back to y y = (u - beta) * I # Calculate exact solution y_e = y_exact(x) u_e = u_exact(1 + beta, t) # Plot ax.plot(x, y, 'r--o', label='numerical') ax1.plot(x, y, th_dict[th][2], label=th_dict[th][0]) ax1.legend() ax3.plot(t, u, th_dict[th][2], label=th_dict[th][0]) ax3.legend() # Plot exact solution ax.plot(x, y_e, 'b-', label='exact') ax.legend() err = np.abs(y_e - y) ax2.plot(x, err, th_dict[th][2]) err = np.abs(u_e - u) ax4.plot(t, err, th_dict[th][2]) # Save figure fig2.savefig(IMGDIR + f'{th_dict[th][1]}_growth.png', bbox_inches='tight') # Save figure fig1.savefig(IMGDIR + f'comparison.png', bbox_inches='tight') fig3.savefig(IMGDIR + f'comparison_scaled.png', bbox_inches='tight')
def generalisation() -> None: """ Generalisation: including a variable coefficient and a source term. Notes ---------- We now start to look at the generalisations u' = -a(t) u and u' = -a(t) u + b(t). Verification can no longer make use of an exact solution of the numerical problem, so we make use of manufactured solutions, for deriving an exact solution of the ODE problem, and then we can compute empirical convergence rates for the method and see if these coincide with the expected rates from theory. We start by considered the case where a depends on time u'(t) = -a(t) u(t) t ∈ (0, T] u(0) = I (2) A Forward Euler scheme consists of evaluating (2) at t = tn, and approximating with a forward difference [Dₜ⁺ u]ⁿ. (uⁿ⁺¹ - uⁿ) / Δt = -a(tn) uⁿ The Backward scheme becomes (uⁿ - uⁿ⁻¹) / Δt = -a(tn) uⁿ The Crank-Nicolson scheme builds on sampling the ODE at t{n+½}. We can evaluate at t{n+½} and use an average u at times tn and t{n+1} (uⁿ⁺¹ - uⁿ) / Δt = -a(t{n+½}) ½(uⁿ + uⁿ⁺¹) Alternatively, we can use an average for the product a u (uⁿ⁺¹ - uⁿ) / Δt = -½(a(tn) uⁿ + a(t{n+1}) uⁿ⁺¹) The θ-tule unifies the three mentioned schemes. One version is to have a evaluated at the weighted time point (1 - θ) tn + θ t{n+1} (uⁿ⁺¹ - uⁿ) / Δt = -a((1 - θ) tn + θ t{n+1})((1 - θ) uⁿ + θ uⁿ⁺¹) Another possibility is to apply a weighted average for the product a u (uⁿ⁺¹ - uⁿ) / Δt = -(1 - θ) a(tn) uⁿ + θ a(t{n+1})uⁿ⁺¹ With the finite difference operator notation, the Forward Euler and Backward Euler can be summarised as [Dₜ⁺ u = -a u]ⁿ [Dₜ⁻ u = -a u]ⁿ The Crank-Nicolson and θ schemes depend on whether we evaluate a at the sample point for the ODE or if we use an average. [Dₜ u = -a ūᵗ]ⁿ⁺¹⸍² [Dₜ u = -ā ūᵗ]ⁿ⁺¹⸍² [D̄ₜ u = -a ūᵗʼᶿ]ⁿ⁺ᶿ [D̄ₜ u = -ā ūᵗʼᶿ]ⁿ⁺ᶿ A further extension of the model ODE is to include a source term b(t): u'(t) = a(t) u(t) + b(t) t ∈ (0, T] u(0) = I The time point where we sample the ODE determines where b(t) is evaluated. For the Crank-Nicolson scheme and the θ-rule, we have a choice of whether to evaluate a(t) and b(t) at the correct point, or use an average. The chosen strategy becomes particularly clear if we write up the schemes in operation notation: [Dₜ⁺ u = -a u + b]ⁿ [Dₜ⁻ u = -a u + b]ⁿ [Dₜ u = -a ūᵗ + b]ⁿ⁺¹⸍² [Dₜ u = -ā ū + b̄ᵗ]ⁿ⁺¹⸍² [D̄ₜ u = -a ūᵗʼᶿ + b]ⁿ⁺ᶿ [D̄ₜ u = -ā ū + b̄ᵗʼᶿ]ⁿ⁺ᶿ Deriving the θ-rule formula when averaging over a and b, we get (uⁿ⁺¹ - uⁿ) / Δt = θ(-aⁿ⁺¹ uⁿ⁺¹ + bⁿ⁺¹) + (1 - θ)(-aⁿ uⁿ + bⁿ) Solving for uⁿ⁺¹, uⁿ⁺¹ = ((1 - Δt(1 - θ)aⁿ) uⁿ + Δt(θ bⁿ⁺¹ + (1 - θ)bⁿ)) / (1 + Δt θ aⁿ⁺¹) Here, we start by verifying a constant solution, where u = C. We choose any a(t) and set b(t) = a(t) C and I = C. """ # Definitions u_const = 2.15 I = u_const Nt = 4 dt = 4 theta = 0.4 a = lambda t: 2.5 * (1 + t**3) b = lambda t: a(t) * u_const # Solve u, t = solver(I=I, a=a, b=b, T=Nt * dt, dt=dt, theta=theta) # Calculate exact solution u_exact = lambda t: u_const u_e = u_exact(t) # Assert that max deviation is below tolerance tol = 1E-14 diff = np.max(np.abs(u_e - u)) if diff > tol: raise AssertionError(f'Tolerance not reached, diff = {diff}')
def convergence() -> None: """ Computing convergence rates. Notes ---------- We expect that the error E in the numerical solution is reduced if the mesh size Δt is decreased. More specifically, many numerical methods obey a power-law relation between E and Δt E = C Δtʳ where C and r are (usually unknown) constant independent of Δt. The parameter r is known as the convergence rate. For example, if the convergence rate is 2, halving Δt reduces the error by a factor of 4. Diminishing Δt then has a greater impact on the error compared with methods that have r = 1. For a given value of r, we refer to the method as of r-th order. There are two alternative ways of estimating C and r based on a set of m simulations with corresponding pairs (Δtᵢ, Eᵢ), i = 0,...,m-1, and Δtᵢ < Δtᵢ₋₁. 1) Take the logarithm of E = C Δtʳ and fit a straight line to the data points (Δtᵢ, Eᵢ). 2) Consider two consecutive experiments (Δtᵢ, Eᵢ) and (Δtᵢ₋₁, Eᵢ₋₁). Dividing the equation Eᵢ₋₁ = C Δtʳᵢ₋₁ by Eᵢ = C Δtʳᵢ and solving for r rᵢ₋₁ = ln(Eᵢ₋₁ / Eᵢ) / ln(Δtᵢ₋₁ / Δtᵢ) for i = 1,...,m-1. Note that we have introduced a subindex i - 1 on r because r estimated from a pair of experiments must be expected to change with i. The disadvantage of method 1 is that it may not be valid for the coarsest meshes (largest Δt values). Fitting a line to all the data points is then misleading. Method 2 computes the convergence rates for pairs of experiments and allows us to see if the sequence rᵢ converges to some values as i → m - 2. The final rₘ₋₂ can then be taken as the convergence rate. The strong practical application of computing convergence rates is for verification: wrong convergence rates point to errors in the code, and correct convergence rates provide strong support for a correct implementation. Bugs in the code can easily destroy the expected convergence rate. The example here used the manufactured solution uₑ(t) = sin(t) exp{-2t} and a(t) = t². This implies we must fit b as b(t) = u'(t) + a(t) u(t). We first compute with SymPy expressions and then convert the exact solution, a, and b to Python functions that we can use in subsequent numerical computing. """ # Created manufactured solution with SymPy t = sym.Symbol('t') u_e_sym = sym.sin(t) * sym.exp(-2 * t) a_sym = t**2 b_sym = sym.diff(u_e_sym, t) + a_sym * u_e_sym # Turn SymPy expressions into Python functions u_exact = sym.lambdify([t], u_e_sym, modules='numpy') a = sym.lambdify([t], a_sym, modules='numpy') b = sym.lambdify([t], b_sym, modules='numpy') T = 6 I = u_exact(0) dt_values = [1 * 2**(-i) for i in range(6)] print(STR_FMT.format('dt_values', f'{dt_values}')) th_dict = { 0: ('Forward Euler', 'fe', 'r-s'), 1: ('Backward Euler', 'be', 'g-v'), 0.5: ('Crank-Nicolson', 'cn', 'b-^') } for theta in th_dict: print(f'{th_dict[theta][0]}') E_values = [] for dt in dt_values: # Solve for mesh function u, t = solver(I=I, a=a, b=b, T=T, dt=dt, theta=theta) # Compute error u_e = u_exact(t) e = u_e - u E = np.sqrt(dt * np.sum(e**2)) E_values.append(E) # Compute convergence rates r = compute_rates(dt_values, E_values) print(STR_FMT.format('r', f'{r}')) # Test final entry with expected convergence rate expected_rate = 2 if theta == 0.5 else 1 tol = 0.1 diff = np.abs(expected_rate - r[-1]) if diff > tol: raise AssertionError(f'Tolerance not reached, diff = {diff}')
def verification() -> None: """ Verification via manufactured solutions. Notes ---------- Following the idea above, we choose any formula as the exact solution, insert the formula in the ODE problem and fit the data a(t), b(t), and I to make the chosen formula fulfill the equation. This powerful technique for generating exact solutions is very useful for verification purposes and known as the method of manufactured solutions, often abbreviated MMS. One common choice of solution is a linear function in the independent variable(s). The rationale behind such a simple variation is that almost any relevant numerical solution method for differential equation problems is able to reproduce a linear function exactly to machine precision. The linear solution also makes some stronger demands to the numerical solution and the implementation than the constant solution one. We choose a linear solution u(t) = c t + d. From the initial condition, it follows that d = I. Inserting this u in the left-hand side of (1), we get c = -a(t) u + b(t) Any function u = c t + I is then a correct solution if we choose b(t) = c + a(t)(c t + I) Therefore, we must check that uⁿ = c a(tn)(c tn + I) fulfills the discrete equations. For these equations, it is convenient to compute the action of the difference operator on a linear function t: [Dₜ⁺ t]ⁿ = (tⁿ⁺¹ - tⁿ) / Δt = 1 [Dₜ⁻ t]ⁿ = (tⁿ - tⁿ⁻¹) / Δt = 1 [Dₜ t]ⁿ = (tⁿ⁺¹⸍² - tⁿ⁻¹⸍²) / Δt = ((n + ½)Δt - (n - ½)Δt) / Δt = 1 Clearly, all three difference approximations to the derivative are exact for u(t) = t or its mesh function counterpart uⁿ = tn. The difference equation in the Forward Euler scheme [Dₜ⁺ u = -a u + b]ⁿ with aⁿ = a(tn), bⁿ = c + a(tn)(c tn + I), and uⁿ = ctn + I then results in c = -a(tn)(c tn + I) + c + a(tn)(c tn + I) = c which is always fulfilled. Similar calculations can be done for the Forward Euler and Crank-Nicolson schemes. Therefore, we expect that uⁿ - uₑ(tn) = 0 mathematically and |uⁿ - uₑ(tn)| less than a small number about the machine precision. """ # Definitions I = 0.1 T = 4 dt = 0.1 Nt = int(T / dt) theta = 0.4 c = -0.5 u_exact = lambda t: c * t + I a = lambda t: t**0.5 b = lambda t: c + a(t) * u_exact(t) # Solve u, t = solver(I=I, a=a, b=b, T=Nt * dt, dt=dt, theta=theta) # Calculate exact solution u_e = u_exact(t) # Assert that max deviation is below tolerance tol = 1E-14 diff = np.max(np.abs(u_e - u)) if diff > tol: raise AssertionError(f'Tolerance not reached, diff = {diff}')