1D Mass Spring System
Generalized coordinates
Given a particle of mass \(m\)
so that the generalized velocity is
Kinetic Energy
In 1-D the kinetic energy is \(\frac 12 mv^2 = \frac 12 m\dot q\)
Potential Energy
By Hooke's Law, force is linearly proportional to stretch in spring, i.e.
for some stiffness coefficient \(k\), then the total mechanical work is
and the potential energy is the negative of work
Equation of Motion
Therefore, we have
By Euler Lagrange Equation
Time Integration
Note that \(q:\mathbb R\rightarrow \mathbb R\) is the mapping from time to the position of the particle at that time. Time integration
- input: A ODE \(\ddot q = f(q, \dot q)\) and the initial conditions \(q_0 = q(t_0), \dot q_0 = \dot q(t_0)\)
- output: the discrete update equation \(q^{t+1} = f(q^t, q^{t+1},...,\dot q^t, \dot q^{t+1}, ...)\)
The Coupled First Order Systems
Note that we have a second-order ODE, \(m\ddot q = -kq\), replaces $\dot q $ with velocity \(v\), we can transform the system into a coupled first order ODE
rewrite into matrix form
Denote \(\mathbf y = [v, q]^T\), the equation above is further written as
Phase Space
We define the phase space as the x-axis being the value of \(v\) and y-axis being \(q\), so that we can plot the trajectory of the position and velocity through time.
Numerical Integration
Note that the integration above is simple, while more complex equations may not be suitable for analytical solution, so we need integrate it numerically
Explicit and Implicit Integration
- Explicit: Next time step can be computed entirely using the previous and current time step
- Implicit: Next time step is computed using future values
Concerns
- Performance: runtime / efficiency
- Stability: We don't want the spring to "fly" out, which means we need the solution to stay "within" the bounded system of the analytical solution
- Accuracy: the "visual" accuracy, we mostly want it looks real, even if the solution may not be mathematically correct
Forward Euler Time Integration
Replace time derivative with finite difference
so that
Problem
Because we replace the derivative with the current "slope", the trajectory is going outwards, which is unstable.
class ForwardEuler(TimeIntegration):
def one_step(self):
q_t = self.trajectory_q[-1]
v_t = self.trajectory_v[-1]
self.trajectory_v.append(v_t - self.dt * self.k / self.m * q_t)
self.trajectory_q.append(q_t + self.dt * v_t)
Runge-Kutta Time Integration
To fix the issue with Forward Euler, we can average several "slope" to pull the trajectory back. The general idea is
where \(\tilde {\mathbf y}^{t+a} = y^t + \alpha \Delta tA^{-1}\mathbf f(\mathbf y^t)\) is the Forward Euler estimate.
Following this template, we can have Heun's Method by taking \(a=0, b= 1, \alpha=\beta=\frac12\)
The most general used method is RK4, The Fourth-order Runge Kutta Method.
class RK4(TimeIntegration):
def one_step(self):
q_t = self.trajectory_q[-1]
v_t = self.trajectory_v[-1]
weights = np.array((1, 2, 2, 1))
kappa = np.empty((4, 2))
kappa[0, 0] = v_t
kappa[0, 1] = - self.k / self.m * q_t
kappa[1, 0] = v_t + self.dt * 0.5 * kappa[0, 1]
kappa[1, 1] = - self.k / self.m * (q_t + self.dt * 0.5 * kappa[0, 0])
kappa[2, 0] = v_t + self.dt * 0.5 * kappa[1, 1]
kappa[2, 1] = - self.k / self.m * (q_t + self.dt * 0.5 * kappa[1, 0])
kappa[3, 0] = v_t + self.dt * kappa[2, 1]
kappa[3, 1] = - self.k / self.m * (q_t + self.dt * kappa[2, 0])
self.trajectory_q.append(q_t + self.dt / 6 * np.dot(kappa[:, 0], weights))
self.trajectory_v.append(v_t + self.dt / 6 * np.dot(kappa[:, 1], weights))
Backward Euler Time Integration
This is the implicit time integration. Compare to Forward Euler, instead of evaluating at the current step, we evaluate at the next time step, i.e.
Note that the unknown \(\mathbf y^{t+1}\) appears on both sides, which causes problem. However, if we look back at \(\mathbf f (\mathbf y)\), note that
Since \(\mathbf f\) is a linear function, we have
Note that this is stable since the vector difference is the slope at \(t+1\), which means it "pulls" back the trajectory to the origin.
class BackwardEuler(TimeIntegration):
def one_step(self):
q_t = self.trajectory_q[-1]
v_t = self.trajectory_v[-1]
v = v_t - self.dt * self.k / self.m * q_t
v = v / (1. + self.dt * self.dt * self.k / self.m)
self.trajectory_v.append(v)
self.trajectory_q.append(q_t + self.dt * v)
Symplectic Euler Time Integration
Note that Forward Euler causes the exploding trajectory and the Backward Euler causes damping, we can do the two integrations alternately to "cancel out" long term effect, i.e.
First take an explicit velocity step
Then take an implicit position step
class Symplectic(TimeIntegration):
def one_step(self):
q_t = self.trajectory_q[-1]
v_t = self.trajectory_v[-1]
v = v_t - self.dt * self.k / self.m * q_t
self.trajectory_v.append(v)
self.trajectory_q.append(q_t + self.dt * v)
Source code
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
class TimeIntegration:
def __init__(self, mass, stiffness, dt, q0, v0):
"""
mass: the mass of the object
stiffness: the stiffness coefficient of the spring
dt: Delta t for each step taken
q0: the initial position at t0
v0: the initial velocity at t0
"""
self.m = mass
self.k = stiffness
self.dt = dt
self.q0 = q0
self.v0 = v0
self.trajectory_q = [q0]
self.trajectory_v = [v0]
def one_step(self):
raise NotImplementedError
def run(self, step):
for _ in range(step):
self.one_step()
def plot(self, step, output_file):
self.run(step)
fig = make_subplots(rows=1, cols=2,
subplot_titles=('Object', 'Phase Space'),
column_widths=[0.7, 0.3])
fig.add_trace(go.Scatter(x=[0, self.q0], y=[
0, 0], marker_size=10), 1, 1)
fig.add_trace(go.Scatter(x=[self.q0], y=[
self.v0], marker_size=3), 1, 2)
fig.frames = [go.Frame(data=[go.Scatter(x=[0, self.trajectory_q[i]]),
go.Scatter(x=self.trajectory_q[:i], y=self.trajectory_v[:i])],
traces=[0, 1]) for i in range(len(self.trajectory_q))]
button = dict(
label='Play',
method='animate',
args=[None, dict(
frame=dict(duration=50, redraw=False),
transition=dict(duration=0),
fromcurrent=True,
mode='immediate')])
fig.update_layout(
updatemenus=[
dict(
type='buttons',
showactive=False,
y=0,
x=1.05,
xanchor='left',
yanchor='bottom',
buttons=[button])
],
showlegend=False
)
fig.update_xaxes(range=[-3, 3])
fig.update_yaxes(range=[-20, 20])
fig.write_html(output_file, full_html=False, auto_open=False,
include_plotlyjs="cdn", auto_play=False)
mass = 1
stiffness = 100
dt = 0.01
q0 = 1
v0 = 1
STEPS=75
# --8<-- [start:fe]
class ForwardEuler(TimeIntegration):
def one_step(self):
q_t = self.trajectory_q[-1]
v_t = self.trajectory_v[-1]
self.trajectory_v.append(v_t - self.dt * self.k / self.m * q_t)
self.trajectory_q.append(q_t + self.dt * v_t)
# --8<-- [end:fe]
fe = ForwardEuler(mass, stiffness, dt, q0, v0)
fe.plot(STEPS, "../assets/1d_mass_spring_fe.html")
# --8<-- [start:rk4]
class RK4(TimeIntegration):
def one_step(self):
q_t = self.trajectory_q[-1]
v_t = self.trajectory_v[-1]
weights = np.array((1, 2, 2, 1))
kappa = np.empty((4, 2))
kappa[0, 0] = v_t
kappa[0, 1] = - self.k / self.m * q_t
kappa[1, 0] = v_t + self.dt * 0.5 * kappa[0, 1]
kappa[1, 1] = - self.k / self.m * (q_t + self.dt * 0.5 * kappa[0, 0])
kappa[2, 0] = v_t + self.dt * 0.5 * kappa[1, 1]
kappa[2, 1] = - self.k / self.m * (q_t + self.dt * 0.5 * kappa[1, 0])
kappa[3, 0] = v_t + self.dt * kappa[2, 1]
kappa[3, 1] = - self.k / self.m * (q_t + self.dt * kappa[2, 0])
self.trajectory_q.append(q_t + self.dt / 6 * np.dot(kappa[:, 0], weights))
self.trajectory_v.append(v_t + self.dt / 6 * np.dot(kappa[:, 1], weights))
# --8<-- [end:rk4]
rk4 = RK4(mass, stiffness, dt, q0, v0)
rk4.plot(STEPS, "../assets/1d_mass_spring_rk4.html")
# --8<-- [start:be]
class BackwardEuler(TimeIntegration):
def one_step(self):
q_t = self.trajectory_q[-1]
v_t = self.trajectory_v[-1]
v = v_t - self.dt * self.k / self.m * q_t
v = v / (1. + self.dt * self.dt * self.k / self.m)
self.trajectory_v.append(v)
self.trajectory_q.append(q_t + self.dt * v)
# --8<-- [end:be]
be = BackwardEuler(mass, stiffness, dt, q0, v0)
be.plot(STEPS, "../assets/1d_mass_spring_be.html")
# --8<-- [start:sc]
class Symplectic(TimeIntegration):
def one_step(self):
q_t = self.trajectory_q[-1]
v_t = self.trajectory_v[-1]
v = v_t - self.dt * self.k / self.m * q_t
self.trajectory_v.append(v)
self.trajectory_q.append(q_t + self.dt * v)
# --8<-- [end:sc]
sc = Symplectic(mass, stiffness, dt, q0, v0)
sc.plot(STEPS, "../assets/1d_mass_spring_sc.html")