import matplotlib.pyplot as plt
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import catppuccin
mpl.style.use(catppuccin.PALETTE.macchiato.identifier)
# Create a grid of values for x and y
= np.linspace(0, 10, 100)
x = np.linspace(0, 100, 100)
y = np.meshgrid(x, y)
x, y
# Calculate corresponding z
= (2*x)
z
# Create a figure and a 3D axis
= plt.figure()
fig = fig.add_subplot(111, projection='3d')
ax = 0.7)
ax.plot_surface(x, z,y, alpha
# Set labels
'X')
ax.set_xlabel('Y')
ax.set_zlabel("Y'")
ax.set_ylabel(
# Show the plot
plt.show()
Making my ODE solver solve ODEs
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
This may seem like a very restrictive requirement, but there are many famous examples of this kind of equation including:
Simple Harmonic Motion:
Radioactive Decay:
RC Circuit Equation:
Damped Harmonic Oscillator:
Heat Equation (One-Dimensional):
Exponential Growth or Decay:
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:
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
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:
= np.linspace(0, 10, 100)
Lx = np.array([x**2 for x in Lx])
Ly
# Create masks for the conditions
= (Lx <= 10) & (Lx >= 0) & (Ly <= 100) & (Ly >= 0)
mask
# Now apply the mask to both arrays to exclude unwanted values
= Lx[mask]
filtered_Lx = Ly[mask]
filtered_Ly
= (2*filtered_Lx) +1
Lz
# Create a new figure
= plt.figure()
fig = fig.add_subplot(111, projection='3d',computed_zorder=False)
ax
# Plot surface and line
=0,alpha = 0.7)
ax.plot_surface(x, z,y, zorder='r',linestyle='dashed' , zorder=1)
ax.plot(filtered_Lx, Lz, filtered_Ly, color
# Set labels
'X')
ax.set_xlabel('Y')
ax.set_zlabel("Y'")
ax.set_ylabel(
# Show the plot
plt.show()
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 (
Simple Harmonic Motion:
Radioactive Decay:
RC Circuit Equation:
Damped Harmonic Oscillator:
Heat Equation (One-Dimensional):
Exponential Growth or Decay:
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
To make a step in the approximation we use the following equation:
Where
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 (
Implementing this method in our python library:
def expanded_euler(dims, h):
= np.zeros((dims, dims))
step_matrix for i in range(dims):
for j in range(i, dims):
# Is 1, and h at j-i =0, 1 respectively
= h ** (j - i) / math.factorial(j - i)
step_matrix[i, j] = add_x_and_1(step_matrix, h)
expanded_matrix return expanded_matrix
def add_x_and_1(original_matrix, h):
= len(original_matrix) + 2
new_size = np.zeros((new_size, new_size), dtype=original_matrix.dtype)
new_matrix
# Set the 2x2 top left matrix
0:2, 0:2] = [[1, 0], [h, 1]]
new_matrix[
# Copy the original matrix to the bottom right of the new matrix.
2:, 2:] = original_matrix
new_matrix[return new_matrix
def linear(y, step_matrix_generator, transformation_matrix, steps=10, h=0.1):
= len(y) - 2
dims = transformation_matrix @ step_matrix_generator(dims, h)
step_matrix = []
output_list
= y.copy()
y_n = 0
i while i < steps:
= step_matrix @ y_n
y_n
output_list.append(y_n)+= 1 i
Bind this machinery together, and you get a tool capable of tackling the initial example of
import numpy as np
import math
class Solution:
def __init__(self, input_list: list):
= sorted(input_list, key=lambda x: x[1])
solution_list
= len(solution_list[0]) - 2
dims self.x = np.array([x[1] for x in input_list])
= [[] for _ in range(dims)]
value_lists
for v in input_list:
for i in range(dims):
+ 2])
value_lists[i].append(v[i
for i in range(dims):
self.__dict__[f"y_{i}"] = np.array(value_lists[i])
def interpolate(self, x, y_n):
"""
allows you to get any value from the solution by interpolating the points
"""
= self.__dict__[f"y_{y_n}"]
y_values
= np.where(self.x >= x)[0][0]
x_max_index = np.where(self.x <= x)[0][-1]
x_min_index
= self.x[x_max_index]
x_at_x_max = self.x[x_min_index]
x_at_x_min
= y_values[x_max_index]
y_at_x_max = y_values[x_min_index]
y_at_x_min
= (y_at_x_max - y_at_x_min) / (x_at_x_max - x_at_x_min)
slope
= y_at_x_min + slope * (x - x_at_x_min)
value return value
def linear(y, step_matrix_generator, transformation_matrix, steps=10, h=0.1):
= len(y) - 2
dims = transformation_matrix @ step_matrix_generator(dims, h)
step_matrix = []
output_list
= y.copy()
y_n = 0
i while i < steps:
= step_matrix @ y_n
y_n
output_list.append(y_n)+= 1
i
return Solution(output_list)
= [1,0,0,0] #[1,x,y,y']
init_y = np.array([
transformation_matrix 1,0,0,0 ],
[ 0,1,0,0 ],
[ 0,0,1,0 ],
[ 0,2,0,0 ]
[
])= linear(
solution
init_y,
expanded_euler,
transformation_matrix,=100, h=0.01) steps
='Approximated Solution')
plt.plot(solution.x, solution.y_0, label**2, label='True Solution', linestyle='--')
plt.plot(solution.x, solution.x'x') # Label for the x-axis
plt.xlabel('y') # Label for the y-axis
plt.ylabel(True) # Show a grid for better readability
plt.grid(
plt.legend() plt.show()
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!