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')
Beispiel #7
0
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}')
Beispiel #8
0
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}')
Beispiel #9
0
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}')