1Time-Stepping Simulation Fundamentals
▶
Almost every dynamic system in engineering — a vibrating bridge, a charging capacitor, a rocket ascending through the atmosphere, a chemical reactor approaching equilibrium — evolves continuously through time according to rules that relate how quickly something changes to what it currently is. These rules are ordinary differential equations (ODEs), and they sit at the heart of engineering simulation. Because most real-world systems cannot be solved by pen and paper, engineers rely on time-stepping simulation: a numerical strategy that advances a system's state forward in small, discrete increments of time, approximating the true continuous solution at each step. Understanding the principles behind this strategy — what can go wrong, how to choose an appropriate method, and how to implement it efficiently — is essential for any practicing engineer who works with dynamic models.
Ordinary Differential Equations in Engineering
An ordinary differential equation describes how a quantity changes with respect to a single independent variable, almost always time in engineering simulations. The simplest and most important form is the first-order ODE, which relates the rate of change of a state variable directly to the current value of that state variable and, optionally, time itself:
dx/dt = f(x, t)
Here, x is the state variable (for example, temperature, charge, population, velocity), t is time, and f(x, t) is a function — possibly nonlinear — that computes the instantaneous rate of change. As a concrete example, Newton's law of cooling states that the rate at which an object loses heat is proportional to the difference between the object's temperature and the surrounding air temperature:
dT/dt = -k * (T - T_ambient)
This is a first-order ODE where the state variable is temperature T, the parameter k is a positive cooling constant, and T_ambient is a fixed environmental value. The equation tells us everything about how the temperature evolves — but only once we know where it starts.
Second-order ODEs introduce the second derivative of the state variable and appear naturally wherever inertia or inductance matters. Newton's second law for a mass-spring-damper system is the canonical example:
m * d²x/dt² + c * dx/dt + k * x = F(t)
where m is mass, c is the damping coefficient, k is the spring constant, x is displacement, and F(t) is an external force. An RLC circuit obeys an equation of exactly the same mathematical form, with inductance, resistance, and capacitance playing the roles of mass, damping, and stiffness, respectively. Second-order ODEs are handled numerically by a standard trick: they are converted into a system of two first-order ODEs by introducing an auxiliary state variable for the first derivative. Defining v = dx/dt (velocity), the above equation becomes:
dx/dt = v
dv/dt = (F(t) - c*v - k*x) / m
Now the system has a two-element state vector [x, v], and any first-order ODE solver can handle it directly. This trick generalises: an nth-order ODE always reduces to a system of n coupled first-order ODEs.
Initial conditions are not optional details — they are what make a specific physical scenario emerge from the abstract equation. The ODE describes a whole family of possible trajectories; the initial conditions pick out the one that matches reality. For the mass-spring system, the initial displacement x(0) and initial velocity v(0) = dx/dt(0) together pin down the entire future trajectory. In general, a system of n first-order ODEs requires exactly n initial conditions.
Analytical solutions — closed-form expressions like x(t) = A*exp(-k*t) — exist only for a restricted class of problems: linear ODEs with constant coefficients, driven by simple forcing functions like sinusoids or step inputs. As soon as coefficients become time-varying, or the equation becomes nonlinear (think of a pendulum with large angles, where sin(θ) cannot be approximated by θ), analytical solutions generally disappear. Numerical time-stepping methods fill this gap for the vast majority of engineering ODEs encountered in practice.
Euler's Method
Euler's method is the most direct translation of the ODE definition into a computational algorithm. It reasons as follows: if we know the state x at time t, and we know the rate of change f(x, t) at that moment, then over a small time step h we can approximate the new state by assuming the rate stays constant across the entire step:
x(t + h) = x(t) + h * f(x(t), t)
This is the Euler update rule. It is essentially a linear extrapolation: start at the current point, travel in the direction of the current slope for a horizontal distance of h, and land at the next approximate state. For the cooling example above, with k = 0.1, T_ambient = 20, and initial temperature T(0) = 100, a single step of size h = 1 second gives:
f(T, t) = -0.1 * (T - 20)
f(100, 0) = -0.1 * (100 - 20) = -8
T(1) ≈ 100 + 1 * (-8) = 92
The true answer (from the analytical solution T(t) = T_ambient + (T0 - T_ambient)*exp(-k*t)) is approximately 92.31, so the error here is small. However, if we had used a much larger step — say h = 10 — the accumulated error over the simulation would become significant.
Step size and accuracy are directly linked. Euler's method is said to be first-order accurate, meaning the local error at each step is proportional to h², and the global (accumulated) error over a fixed time interval is proportional to h. Halving the step size roughly halves the global error. This sounds acceptable, but in practice, achieving engineering accuracy with Euler's method can require step sizes so small that the computation becomes prohibitively slow, or so large that the method becomes unstable.
Instability is a critical concern. For many ODEs, Euler's method works only if h stays below a certain system-dependent threshold. For the linear decay ODE dx/dt = -k*x, the stability condition is h < 2/k. If k is large (a fast-decaying, or "stiff" system), this forces an extremely small step size. Exceed the limit even slightly, and the numerical solution oscillates with growing amplitude, diverging wildly from the true solution that quietly decays to zero. This sensitivity to step size is the central weakness of so-called explicit methods like Euler.
A simple Python implementation of Euler's method illustrates the loop structure clearly:
import numpy as np
# System parameters
k = 0.1
T_ambient = 20.0
T0 = 100.0
# Time settings
h = 0.5 # step size in seconds
t_end = 50.0
t = np.arange(0, t_end + h, h)
# Pre-allocate result array
T = np.zeros(len(t))
T[0] = T0 # initial condition
# Derivative function
def dTdt(T, t):
return -k * (T - T_ambient)
# Euler time-stepping loop
for i in range(len(t) - 1):
T[i+1] = T[i] + h * dTdt(T[i], t[i])
Notice that the result array T is pre-allocated with np.zeros before the loop begins. This is a crucial performance habit: repeatedly appending to a Python list inside a loop causes repeated memory reallocations, slowing large simulations significantly. Allocating the full array upfront and filling it by index is far more efficient.
Runge-Kutta Methods
The shortcomings of Euler's method motivate a family of more sophisticated algorithms collectively called Runge-Kutta (RK) methods. The central idea is to estimate the derivative not just at the beginning of the time step, but at several carefully chosen points within the step, then combine those estimates in a weighted average that cancels out leading error terms. The result is a much more accurate approximation without requiring a dramatically smaller step size.
The workhorse of this family is the classical fourth-order Runge-Kutta method (RK4). For a step from t to t + h with state x(t), RK4 computes four slope estimates:
k1 = f(x, t)
k2 = f(x + h*k1/2, t + h/2)
k3 = f(x + h*k2/2, t + h/2)
k4 = f(x + h*k3, t + h)
x(t + h) = x(t) + (h/6) * (k1 + 2*k2 + 2*k3 + k4)
k1 is the slope at the start of the interval — identical to what Euler uses. k2 uses k1 to make a half-step and estimates the slope at the interval's midpoint. k3 refines the midpoint estimate using k2. k4 estimates the slope at the end of the interval using the full step. The final update weights the two midpoint estimates twice as heavily as the endpoint estimates, in analogy with Simpson's rule for numerical integration. This specific weighting is what produces fourth-order accuracy: the local error at each step scales as h⁵, and the global error over a fixed interval scales as h⁴. To achieve the same accuracy as Euler, RK4 needs far fewer — and therefore larger — steps, making it dramatically more efficient in most situations.
In concrete terms: if Euler with step size h produces a global error of 0.01, then to cut that error to 0.0001 (100× improvement) you must reduce h by a factor of 100, increasing computation by 100×. With RK4, you only need to reduce h by a factor of about 3.16 (since 3.16⁴ ≈ 100), increasing computation by just 3.16× — and each RK4 step costs only 4 function evaluations versus Euler's 1, so the real savings are roughly a factor of 25. For engineering problems where f(x, t) is expensive to evaluate, this difference is enormous.
Here is a Python implementation of RK4 for the same cooling problem:
import numpy as np
k = 0.1
T_ambient = 20.0
T0 = 100.0
h = 2.0
t_end = 50.0
t = np.arange(0, t_end + h, h)
T = np.zeros(len(t))
T[0] = T0
def dTdt(T, t):
return -k * (T - T_ambient)
for i in range(len(t) - 1):
ti = t[i]
Ti = T[i]
k1 = dTdt(Ti, ti)
k2 = dTdt(Ti + h*k1/2, ti + h/2)
k3 = dTdt(Ti + h*k2/2, ti + h/2)
k4 = dTdt(Ti + h*k3, ti + h)
T[i+1] = Ti + (h/6) * (k1 + 2*k2 + 2*k3 + k4)
Even with a step size of 2 seconds — four times larger than the Euler example — RK4 produces results accurate to many more decimal places. RK4 is the standard first choice for simulating non-stiff engineering ODEs and is used throughout scientific computing as a reliable, general-purpose integrator.
For problems where you cannot easily predict the right step size, or where the system's behaviour changes dramatically over time (fast transients followed by slow drift), adaptive Runge-Kutta solvers are the professional solution. These methods automatically estimate the local error at each step — by comparing, for instance, a 4th-order and a 5th-order solution — and adjust h up or down to keep the error within a specified tolerance. The user sets a desired accuracy, and the solver figures out how large a step is safe. SciPy's solve_ivp function implements several such adaptive methods.
Implementing a Time-Stepping Loop
Whether using Euler or RK4, the structure of a time-stepping simulation in Python follows a consistent pattern. Getting this structure right — efficiently, clearly, and correctly — is a foundational skill.
The first task is defining the time array. Two NumPy functions are commonly used. np.linspace(t_start, t_end, n_points) generates exactly n_points values evenly spaced from start to end, guaranteeing that the endpoint is included. np.arange(t_start, t_end, h) generates values spaced by h, but due to floating-point rounding it may or may not include the exact endpoint. For most simulations, np.linspace is preferable when you want a specific number of output points, while np.arange is natural when the step size is the primary concern. Note that the number of time steps is one less than the number of time points: with n points you perform n-1 update steps.
The second task is initialising the state vector. For a scalar problem, this is just x[0] = x0. For a multi-state system (like the mass-spring-damper with states [x, v]), the state at each time step is a 1D array, and the full storage is a 2D array with shape (n_points, n_states):
import numpy as np
# Mass-spring-damper parameters
m = 1.0 # kg
c = 0.5 # Ns/m
k = 4.0 # N/m
# Time setup
h = 0.01
t = np.arange(0, 10 + h, h)
n = len(t)
# State storage: columns are [x, v]
state = np.zeros((n, 2))
state[0] = [0.5, 0.0] # initial displacement 0.5m, initial velocity 0m/s
# Derivative function for the system
def derivatives(s, t):
x, v = s
dxdt = v
dvdt = (-c*v - k*x) / m
return np.array([dxdt, dvdt])
# RK4 loop
for i in range(n - 1):
s = state[i]
ti = t[i]
k1 = derivatives(s, ti)
k2 = derivatives(s + h*k1/2, ti + h/2)
k3 = derivatives(s + h*k2/2, ti + h/2)
k4 = derivatives(s + h*k3, ti + h)
state[i+1] = s + (h/6)*(k1 + 2*k2 + 2*k3 + k4)
x = state[:, 0] # displacement over time
v = state[:, 1] # velocity over time
This pattern — pre-allocate, initialise, loop, extract — should become second nature. Pre-allocating the full state array before the loop is critical. If you instead build the solution by appending to a list at each step, Python must occasionally copy the entire list to a new memory location, introducing unpredictable slowdowns in long simulations. The pre-allocated array approach is both faster and cleaner for downstream analysis and plotting.
At each step, the loop extracts the current state, evaluates the derivative(s), applies the update rule, and writes the new state into the next row of the array. After the loop, individual state variables are extracted by slicing columns from the 2D array.
Step Size Selection and Accuracy
Choosing an appropriate time step is one of the most consequential decisions in time-stepping simulation. Choose too large a step and you sacrifice accuracy, or worse, trigger instability. Choose too small a step and the computation takes unnecessarily long, potentially accumulating significant floating-point rounding errors from millions of tiny arithmetic operations.
The fundamental rule is that the time step must be small relative to the fastest characteristic time scale of the system. Every dynamic system has one or more time constants that describe how quickly it responds. For the exponential decay dx/dt = -k*x, the time constant is τ = 1/k: the state falls to about 37% of its initial value in one time constant. A safe rule of thumb for explicit methods is to use h ≤ τ/10, though the exact stability limit depends on the method. For Euler's method applied to this system, the hard stability limit is h < 2τ, but accuracy usually requires much smaller steps well before instability is reached.
Stiffness is the term for a particularly challenging situation where a system contains multiple time scales that differ by orders of magnitude. Imagine a system with two components: one with time constant τ₁ = 0.001 seconds and another with τ₂ = 1000 seconds. The stability limit forces h to be on the order of 0.001 seconds, but the simulation must run for thousands of seconds to capture the slow dynamics — requiring millions of steps. Explicit methods like Euler and RK4 handle stiff problems very poorly. Specialised implicit methods (such as backward differentiation formulas, implemented in SciPy's 'BDF' and 'Radau' solvers) are designed for stiff systems and can take much larger steps without instability.
Convergence testing is the practical way to verify that your chosen step size is producing trustworthy results. The procedure is straightforward: run the simulation with step size h, then repeat with h/2, and compare the results at corresponding time points. If the maximum difference is smaller than your acceptable tolerance, the solution has "converged" and the original step size is adequate. If the difference is still significant, halve again. For a first-order method like Euler, halving h should roughly halve the error; for RK4, it should reduce the error by a factor of about 16. Observing this expected reduction is reassuring evidence that the simulation is behaving as intended.
# Convergence test example: run with h and h/2, compare final values
import numpy as np
def run_euler(k, T0, T_amb, h, t_end):
t = np.arange(0, t_end + h, h)
T = np.zeros(len(t))
T[0] = T0
for i in range(len(t)-1):
T[i+1] = T[i] + h * (-k * (T[i] - T_amb))
return T[-1] # return final temperature
k, T0, T_amb, t_end = 0.1, 100.0, 20.0, 30.0
T_h = run_euler(k, T0, T_amb, h=1.0, t_end=t_end)
T_h2 = run_euler(k, T0, T_amb, h=0.5, t_end=t_end)
T_h4 = run_euler(k, T0, T_amb, h=0.25, t_end=t_end)
print(f"h=1.0: T={T_h:.6f}")
print(f"h=0.5: T={T_h2:.6f}")
print(f"h=0.25: T={T_h4:.6f}")
print(f"Diff h->h/2: {abs(T_h - T_h2):.6f}")
print(f"Diff h/2->h/4: {abs(T_h2 - T_h4):.6f}")
If the difference between successive runs is consistently halving (for Euler), convergence is confirmed and the solution is reliable at the chosen tolerance.
Using SciPy for ODE Integration
Writing your own Euler or RK4 loop is invaluable for understanding what is happening under the hood. But for production simulations, Python's SciPy library provides scipy.integrate.solve_ivp — a professional-grade ODE solver with adaptive step sizing, multiple algorithm options, event detection, and dense output. It is the right tool once the fundamentals are understood.
The function signature is:
from scipy.integrate import solve_ivp
sol = solve_ivp(fun, t_span, y0, method='RK45', t_eval=None, dense_output=False, ...)
The arguments are:
- fun: the derivative function, which must accept arguments
(t, y)— note the order is t first, then y, which is the reverse of many hand-written conventions. It must return an array of derivatives with the same shape asy. - t_span: a two-element tuple
(t_start, t_end)specifying the integration interval. - y0: a 1D array of initial conditions, one element per state variable.
- method: the integration algorithm.
'RK45'(the default) is an adaptive Runge-Kutta method of 4th/5th order, suitable for non-stiff problems.'RK23'is a lower-order adaptive method.'Radau'and'BDF'are implicit methods for stiff problems.'DOP853'is an 8th-order explicit method for high-accuracy requirements. - t_eval: an optional array of times at which to store the solution. Without this,
solve_ivpchooses its own internal steps and returns results only at those times, which may not align with a uniform grid. - dense_output: if
True, the solver builds a continuous interpolating function across the entire time span, accessible assol.sol(t)for anytwithin the span.
Here is the mass-spring-damper problem solved with solve_ivp:
from scipy.integrate import solve_ivp
import numpy as np
m, c, k = 1.0, 0.5, 4.0
def derivatives(t, y):
x, v = y
dxdt = v
dvdt = (-c*v - k*x) / m
return [dxdt, dvdt]
y0 = [0.5, 0.0] # initial displacement and velocity
t_span = (0, 10)
t_eval = np.linspace(0, 10, 1000)
sol = solve_ivp(derivatives, t_span, y0, method='RK45',
t_eval=t_eval, rtol=1e-8, atol=1e-10)
x = sol.y[0] # displacement
v = sol.y[1] # velocity
t = sol.t # time points
print(f"Solver succeeded: {sol.success}")
print(f"Number of function evaluations: {sol.nfev}")
The rtol and atol parameters control the relative and absolute error tolerances, respectively. The solver internally adjusts its step size at every step to keep the estimated local error below atol + rtol * |y|. This adaptive strategy is far more robust than any fixed step size: in regions of rapid change, the solver automatically takes smaller steps; in calm regions, it stretches the step as large as accuracy allows, minimising the total number of function evaluations.
The result object sol contains not just the solution arrays but also diagnostic information: sol.nfev counts how many times the derivative function was called, sol.message describes the termination condition, and sol.success is True if the solver completed without error.
Comparing custom implementations against solve_ivp is an excellent validation strategy. If you have written a hand-coded RK4 loop and obtained results, running the same problem through solve_ivp with tight tolerances gives you a high-accuracy reference solution. Plotting the difference between the two over time reveals how your fixed-step implementation's error grows. This kind of cross-validation builds confidence that both implementations are correct and helps calibrate the step size needed for your custom solver to achieve adequate accuracy.
Taken together, these concepts — the structure of ODEs and their initial conditions, the Euler update rule and its limitations, the superior accuracy of RK4, the discipline of pre-allocated loops, the importance of step-size selection and convergence testing, and the professional capabilities of scipy.integrate.solve_ivp — form the complete foundation for simulating dynamic engineering systems in Python. Mastery of these tools opens the door to modelling phenomena across mechanical, electrical, thermal, chemical, and aerospace engineering with confidence and computational efficiency.