After writing out the last post where I wrote out a python library for using an improved version of Euler’s method to solve ODEs. But so far, we haven’t been solving ODES, instead we have just been taking an initial value and iterating it over the length of a domain. To To make the ODE estimator work, we need to ensure that the conditions of the ODE are met at each step.

Simplifying ODEs: Constant-Linear ODEs

ODEs are often categorized as linear or non-linear. Linear ODEs take the form \(a_0(x)y + a_1(x)y' + ... + a_n(x)y^{n} = b(x)\), with both \(a\) and \(b\) representing functions of \(x\), while Non-linear equations are all the others. In our solver’s context, we’ll concentrate on a subset I’ve termed “constant-linear” ODEs, characterized by constant coefficients for \(y\) terms and a linear function of \(x\) for \(b\). Specifically, a constant-linear ODE looks like \(a_0y + a_1y' + ... + a_ny^{n} = bx + c\).
This may seem like a very restrictive requirement, but there are many famous examples of this kind of equation including:

  1. Simple Harmonic Motion: \[ y'' + \omega^2 y = 0 \]

  2. Radioactive Decay: \[ \frac{dy}{dt} = -\lambda y \]

  3. RC Circuit Equation: \[ y' + \frac{1}{RC} y = 0 \]

  4. Damped Harmonic Oscillator: \[ y'' + 2\gamma y' + \omega_0^2 y = 0 \]

  5. Heat Equation (One-Dimensional): \[ u'' - \frac{1}{\alpha} u'= 0 \]

  6. Exponential Growth or Decay: \[ y' = ky \]

A Quick Diversion: ODEs in Vector Space

Pivoting for a moment, I want to take a quick moment to reframe how we are imagining ODEs. Most of the time, we see ODEs as curves in space and/or time, but I want to reframe them as planes in a vector space.

Each point in this vector space describes the state of a point along a curve, such that a values of the vector give:

\[\begin{bmatrix} 1\\ x\\ y(x)\\ y'(x)\\ y''(x)\\ ...\\ y^{n}(x)\\ \end{bmatrix} \]

This means that an ODE can be defined by a plane that contains all the points which meet the requirements of the ODE.

For example, for the equation \(y' = 2x\) this plane looks like:

Then a specific solution to the ODE exists as a curve that sits on this plane. For example, for the IVP that starts at (0,0), the solution follows this curve:

But Why Does This Matter

The reason that we want to reframe ODEs in this way is because of the following fact:

For all constant-linear ODEs, we can express the ODE as a matrix such that applying it to any point in the vector space would map any point to a valid point on the curve defined by the ODE

Looking at the equations above, these matrices (\(T\)) are:

  1. Simple Harmonic Motion: \[T = \begin{bmatrix} 1 & 0 & 0 & 0 & 0\\ 0 & 1 & 0 & 0 & 0\\ 0 & 0 & 1 & 0 & 0\\ 0 & 0 & 0 & 1 & 0\\ 0 & 0 & -\omega^2 & 0 & 0\\ \end{bmatrix} \]

  2. Radioactive Decay: \[T = \begin{bmatrix} 1 & 0 & 0 & 0\\ 0 & 1 & 0 & 0\\ 0 & 0 & 1 & 0\\ 0 & 0 & -\lambda & 0\\ \end{bmatrix}\]

  3. RC Circuit Equation: \[T = \begin{bmatrix} 1 & 0 & 0 & 0\\ 0 & 1 & 0 & 0\\ 0 & 0 & 1 & 0\\ 0 & 0 & \frac{-1}{RC} & 0\\ \end{bmatrix}\]

  4. Damped Harmonic Oscillator: \[T = \begin{bmatrix} 1 & 0 & 0 & 0 & 0\\ 0 & 1 & 0 & 0 & 0\\ 0 & 0 & 1 & 0 & 0\\ 0 & 0 & 0 & 1 & 0\\ 0 & 0 & -\omega^2 & -2\gamma & 0\\ \end{bmatrix}\]

  5. Heat Equation (One-Dimensional): \[T = \begin{bmatrix} 1 & 0 & 0 & 0 & 0\\ 0 & 1 & 0 & 0 & 0\\ 0 & 0 & 1 & 0 & 0\\ 0 & 0 & 0 & 1 & 0\\ 0 & 0 & 0 & \frac{1}{\alpha} & 0\\ \end{bmatrix}\]

  6. Exponential Growth or Decay: \[T = \begin{bmatrix} 1 & 0 & 0 & 0\\ 0 & 1 & 0 & 0\\ 0 & 0 & 1 & 0\\ 0 & 0 & k & 0\\ \end{bmatrix}\]

Using these to fit ODEs

Now that we can express the ODEs in the form of a matrix, we can implement these matriexies in the ODE solver package to make the solution fit the ode. It’s important here to note that I’ve diverted from my old definitions of \(Y\) here, where the first element of the vector is \(y(x)\).

To make a step in the approximation we use the following equation:

\[ \begin{bmatrix} 1 \\ x+h \\ y(x+h)\\ y'(x+h)\\ y''(x+h)\\ ...\\ y^{n}(x+h)\\ \end{bmatrix} = S \cdot \begin{bmatrix} 1 \\ x\\ y(x)\\ y'(x)\\ y''(x)\\ ...\\ y^{n}(x)\\ \end{bmatrix} \epsilon \]

Where \(S\) is: \[ \begin{bmatrix} 1 & 0 & 0 & 0 & 0 & ... & 0 \\ h & 1 & 0 & 0 & 0 & ... & 0 \\ 0 & 0 & 1 & \frac{h}{1!} & \frac{h^2}{2!} & ... & \frac{h^n}{n!}\\ 0 & 0 & 0 & 1 & \frac{h}{1!} & ... & \frac{h^{n-1}}{(n-1)!}\\ 0 & 0 & 0 & 0 & 1 & ... & \frac{h^{n-2}}{(n-2)!}\\ ... & ... & ... & ... & ... & ...\\ 0 & 0 & 0 & 0 & 0 & ... & 1\\ \end{bmatrix}\]

When making this step, the error in the approximation will move the point away from the plane that contains all valid solutions to the ODE, and therefore we will have to snap it back using one of the transformation matrices (\(T\)).

Implementing this method in our python library:

def expanded_euler(dims, h):
    step_matrix = np.zeros((dims, dims))
    for i in range(dims):
        for j in range(i, dims):
            # Is 1, and h at j-i =0, 1 respectively
            step_matrix[i, j] = h ** (j - i) / math.factorial(j - i)
    expanded_matrix = add_x_and_1(step_matrix, h)
    return expanded_matrix


def add_x_and_1(original_matrix, h):
    new_size = len(original_matrix) + 2
    new_matrix = np.zeros((new_size, new_size), dtype=original_matrix.dtype)

    # Set the 2x2 top left matrix
    new_matrix[0:2, 0:2] = [[1, 0], [h, 1]]

    # Copy the original matrix to the bottom right of the new matrix.
    new_matrix[2:, 2:] = original_matrix
    return new_matrix


def linear(y, step_matrix_generator, transformation_matrix, steps=10, h=0.1):
    dims = len(y) - 2
    step_matrix = transformation_matrix @ step_matrix_generator(dims, h)
    output_list = []

    y_n = y.copy()
    i = 0
    while i < steps:
        y_n = step_matrix @ y_n
        output_list.append(y_n)
        i += 1

Bind this machinery together, and you get a tool capable of tackling the initial example of \(y' = 2x\) passing through the point (0,0):

init_y = [1,0,0,0] #[1,x,y,y']
transformation_matrix = np.array([
   [ 1,0,0,0 ],
   [ 0,1,0,0 ],
   [ 0,0,1,0 ],
   [ 0,2,0,0 ]
])
solution = linear(
    init_y,
    expanded_euler,
    transformation_matrix,
    steps=100, h=0.01)

What’s Next?

This method seems to work pretty well and follows the true solution pretty closely. I’m going to stop here for now but there are many things on my wishlist that I want to build in later posts. This includes:

  • Solving IVPs which aren’t constant-linear
  • Solving BVPs
  • Applying this method to PDEs

Stay tuned for more posts in this series where I try to implement these features into my solver!