↓ Skip to main content

Differential Equations with SciPy

·7270 words·35 mins·
 Author
Author
Francisco Bustamante
Chemist, data scientist and Python programmer.
Table of Contents
From Symbol to Number - This article is part of a series.
Part 9: This Article

The formula for the period of a simple pendulum, \(T = 2\pi\sqrt{L/g}\), is one of the first physics equations every student memorizes, and almost no one notices that it only holds for small oscillations. A real pendulum, released from a large angle, has a longer period than this formula predicts. And the exact differential equation of this pendulum, without the small-angle simplification, has no closed-form solution at all, not even SymPy can solve it.

This is not a limitation of SymPy specifically. A good share of the ordinary differential equations (ODEs) that describe real systems (nonlinear, with more than one variable, with resistance or reaction terms) simply have no analytic solution. When that happens, one way out remains: solve numerically.

This article closes the numerical calculus block of the series. The numerical derivatives article showed how to differentiate data with no formula behind it; the previous one integrated functions and tables, but always with the integrand known beforehand. Here that assumption falls: when the rate of change depends on the current value of the quantity itself, there is no integrand to tabulate before starting, because each step depends on the previous one. That is the difference between computing an area and solving a differential equation.

Why Does Solving Differential Equations Numerically Matter?
#

  • Physics: large-angle pendulums, perturbed orbits, projectiles with air resistance. Most real mechanical systems only have a closed-form solution under simplifications that do not always hold.
  • Chemistry and reaction kinetics: mechanisms with more than one step (consecutive, reversible, catalyzed reactions) give rise to systems of coupled differential equations that rarely close analytically.
  • Electrical and control engineering: circuits with multiple components, feedback control systems. Time-response analysis depends on solving ODEs, often numerically.
  • Biology and epidemiology: models of population growth and disease spread (predator-prey, SIR) are nonlinear by nature and almost always require a numerical solution.

From dsolve to solve_ivp
#

The SymPy derivatives article solved a single differential equation: \(f(x) - f^{\prime}(x) = 0\), whose symbolic solution is the family \(f(x) = C_1 e^x\), with the constant \(C_1\) left open. That is how dsolve always answers: the general solution, valid for any initial condition.

import sympy as sp

t = sp.symbols("t")
f = sp.Function("f")
diff_eq = sp.Eq(f(t) - sp.diff(f(t), t), 0)
print(sp.dsolve(diff_eq, f(t)))
Eq(f(t), C1*exp(t))

scipy.integrate.solve_ivp solves the same kind of problem in a different way: instead of a family of solutions, it returns a numerical solution, specific to one concrete initial condition, hence the name, initial value problem (IVP). The signature convention is always the same: a function field(t, y) that returns the derivative of each component of y, a time interval, and the initial condition:

import numpy as np
from scipy.integrate import solve_ivp


def exponential_field(t, y):
    return y  # f'(t) = f(t)


sol = solve_ivp(exponential_field, [0, 3], [1.0], dense_output=True, rtol=1e-10, atol=1e-12)

t_plot = np.linspace(0, 3, 200)
y_numeric = sol.sol(t_plot)[0]
y_analytic = np.exp(t_plot)
print("maximum error numeric × analytic:", np.max(np.abs(y_numeric - y_analytic)))
maximum error numeric × analytic: 1.581241804160527e-09

With \(f(0) = 1\) fixing the constant (\(C_1 = 1\)), the numerical curve falls right on top of the analytic curve \(e^t\), with an error on the order of \(10^{-9}\), a pure artifact of numerical tolerance, not of the technique. dense_output=True makes it possible to evaluate the solution at any point of the interval after solving, which is what sol.sol(t_plot) does here.

But two parameters showed up without explanation, rtol and atol, and they will reappear in almost every call in this article. It is worth understanding what they mean before moving on.

rtol and atol: how solve_ivp decides whether a step is good

Every adaptive step-size method needs a criterion to accept or reject a step. solve_ivp estimates the error made in each step and compares it with a limit built from the two parameters:

$$\text{estimated error} \le \texttt{atol} + \texttt{rtol} \times |y|$$

If the error exceeds this limit, the step is rejected and redone with a smaller \(h\); if it stays well below, the integrator allows itself to increase \(h\) on the next step. That is how it chooses the step size on its own.

The two play different roles, which is why both exist:

  • rtol (relative tolerance) controls the significant digits. An rtol=1e-6 asks, roughly, for 6 correct digits, regardless of whether \(y\) is \(10^{-3}\) or \(10^{8}\).
  • atol (absolute tolerance) is a floor, and it exists for the case where \(y\) passes near zero. There the term \(\texttt{rtol} \times |y|\) also tends to zero, and demanding relative precision on a tiny number would be impossible: the integrator would keep shrinking the step forever. atol says “below this absolute value, stop worrying”.

The default values are rtol=1e-3 and atol=1e-6, the same for all methods, and quite loose: they were chosen to be fast, not precise. The difference is easy to measure, solving the same exponential both ways:

import numpy as np
from scipy.integrate import solve_ivp


def exponential_field(t, y):
    return y  # f'(t) = f(t)


t_plot = np.linspace(0, 3, 200)
y_analytic = np.exp(t_plot)

sol_default = solve_ivp(exponential_field, [0, 3], [1.0], dense_output=True)
print("error with the defaults:", np.max(np.abs(sol_default.sol(t_plot)[0] - y_analytic)))
error with the defaults: 0.0020342038563727627

An error of \(2 \times 10^{-3}\) against the \(1.6 \times 10^{-9}\) from before: a million times larger, for the same ODE and the same interval. Three correct digits are enough to draw a plot, but not to compare against an analytic solution, which is what we will be doing here all the time. That is why the calls in this article almost always tighten both.

“Almost always”: the stiffness comparison, later on, deliberately uses the defaults, because the step counts of each method only make sense if all of them run under the same criterion, and the default is the criterion most people will use in practice.

import matplotlib.pyplot as plt
import numpy as np
from scipy.integrate import solve_ivp


def exponential_field(t, y):
    return y  # f'(t) = f(t)


sol = solve_ivp(exponential_field, [0, 3], [1.0], dense_output=True, rtol=1e-10, atol=1e-12)

t_plot = np.linspace(0, 3, 200)
y_numeric = sol.sol(t_plot)[0]
y_analytic = np.exp(t_plot)

fig, ax = plt.subplots()
ax.plot(t_plot, y_analytic, label=r"analytic $e^t$")
ax.plot(t_plot, y_numeric, "--", label="solve_ivp")
ax.set_xlabel("t")
ax.set_ylabel("f(t)")
ax.legend()

The following plot shows the two curves overlaid:

Plot of f(t) growing exponentially with time: the analytic curve and the numerical curve overlap completely
f’(t) = f(t), f(0) = 1: the numerical solution falls exactly on the analytic one.

The Pendulum That the High School Formula Does Not Describe
#

Back to the pendulum from the opening: the exact equation of motion, with no approximation at all, is

$$ \theta''(t) + \frac{g}{L}\sin\theta(t) = 0 $$

The \(\sin\theta\) term is the problem: it is nonlinear, and it is exactly what prevents a closed-form solution in terms of elementary functions. The exact solution exists, but it is written with elliptic integrals, which are not elementary functions; we will come back to them shortly, to check the period we will measure numerically. The high school simplification assumes a small angle, \(\sin\theta \approx \theta\), which turns the equation into a linear ODE with a closed-form solution in cosine: \(\theta(t) = \theta_0 \cos(\omega t)\), with \(\omega = \sqrt{g/L}\), hence the period formula, \(T = 2\pi/\omega = 2\pi\sqrt{L/g}\).

solve_ivp needs none of these simplifications, but it only solves first-order systems, and the pendulum equation is second order. The conversion is mechanical, and it is worth doing explicitly, because it is the maneuver that repeats for every high-order ODE:

Reducing the order: from one 2nd-order equation to two 1st-order ones

Name each derivative up to one order below the maximum as a new variable:

$$u_1 = \theta, \qquad u_2 = \dot\theta$$

Now write the derivative of each one. The first is immediate, by definition; the second comes from the original equation, isolating \(\ddot\theta\):

$$\dot u_1 = u_2, \qquad \dot u_2 = -\frac{g}{L}\sin u_1$$

Done: two first-order equations, each giving the derivative of one component as a function of both. That is exactly what the function field(t, y) needs to return, in the order [u1_dot, u2_dot].

The recipe holds in general: an equation of order \(n\) becomes a system of \(n\) first-order equations, with \(n\) initial conditions. A damped oscillator, an RLC circuit, a mass-spring system: all go through here.

Translating to code, \(\theta\) and \(\dot\theta\) become the two components of y:

import numpy as np
from scipy.integrate import solve_ivp

g = 9.81  # m/s^2
L = 1.0  # m
omega = np.sqrt(g / L)


def nonlinear_pendulum(t, y):
    theta, theta_dot = y
    return [theta_dot, -(g / L) * np.sin(theta)]


def linear_pendulum(t, theta0):
    return theta0 * np.cos(omega * t)


t_span = [0, 6]
t_eval = np.linspace(*t_span, 400)

for theta0_deg in [10, 60]:
    theta0 = np.radians(theta0_deg)
    sol = solve_ivp(nonlinear_pendulum, t_span, [theta0, 0.0], t_eval=t_eval, rtol=1e-8, atol=1e-10)
    diff_max = np.max(np.abs(np.degrees(sol.y[0] - linear_pendulum(t_eval, theta0))))
    print(f"theta0={theta0_deg} degrees: maximum divergence from the linear approximation = {diff_max:.3f} degrees")
theta0=10 degrees: maximum divergence from the linear approximation = 0.330 degrees
theta0=60 degrees: maximum divergence from the linear approximation = 69.384 degrees

For \(10°\), the small-angle approximation is off by less than half a degree, which is why it works so well in textbook problems, which almost always use small angles. For \(60°\), the divergence exceeds \(69°\).

But be careful about what this number means. It is tempting to read “\(69°\) of divergence” as “the approximation gets the shape of the motion wrong”, but that is not it. At \(60°\), the nonlinear solution still looks a lot like a sinusoid; what changes is the period. And since the comparison above runs for \(6\) s, that is, almost three oscillations, a small difference in period accumulates until the two curves are out of phase. The \(69°\) measure accumulated phase drift, not shape error.

Measuring the Real Period
#

The hook that opened this article was exactly about this: a pendulum released from a large angle has a longer period than \(2\pi\sqrt{L/g}\). Time to put a number on it, and the right tool is the events parameter of solve_ivp, which stops (or just records) the integration when a function of the state crosses zero.

Here, the pendulum starts from rest, so the angular velocity \(\dot\theta\) returns to zero exactly at each extreme of the oscillation. Detecting this crossing gives half a period:

import numpy as np
from scipy.integrate import solve_ivp

g = 9.81  # m/s^2
L = 1.0  # m


def nonlinear_pendulum(t, y):
    theta, theta_dot = y
    return [theta_dot, -(g / L) * np.sin(theta)]


def zero_velocity(t, y):
    return y[1]  # theta dot

zero_velocity.direction = 1  # only negative-to-positive crossings

T0 = 2 * np.pi * np.sqrt(L / g)

for theta0_deg in [10, 60, 90, 170]:
    theta0 = np.radians(theta0_deg)
    sol = solve_ivp(nonlinear_pendulum, [1e-9, 20], [theta0, 0.0],
                    events=zero_velocity, rtol=1e-11, atol=1e-13)
    T = 2 * sol.t_events[0][0]
    print(f"theta0={theta0_deg:4d}°  T={T:.4f} s   T/T0={T/T0:.4f}")
theta0=  10°  T=2.0099 s   T/T0=1.0019
theta0=  60°  T=2.1529 s   T/T0=1.0732
theta0=  90°  T=2.3678 s   T/T0=1.1803
theta0= 170°  T=4.8935 s   T/T0=2.4394

Now the hook is paid off, with interpretable numbers. At \(10°\), the real period exceeds the high school formula by \(0.2\%\), invisible in any laboratory experiment. At \(60°\), it is already \(7.3\%\). At \(170°\), almost hanging upward, the pendulum takes more than twice the predicted time.

Checking against the elliptic integral

The exact solution of the pendulum exists, but in terms of a complete elliptic integral of the first kind, \(K(m)\), which is not an elementary function, and that is why dsolve does not close it:

$$\frac{T}{T_0} = \frac{2}{\pi} K\left(\sin^2\frac{\theta_0}{2}\right)$$

scipy.special.ellipk computes \(K\), so we can check the numerical measurement against the exact value:

import numpy as np
from scipy.integrate import solve_ivp
from scipy.special import ellipk

g = 9.81  # m/s^2
L = 1.0  # m


def nonlinear_pendulum(t, y):
    theta, theta_dot = y
    return [theta_dot, -(g / L) * np.sin(theta)]


def zero_velocity(t, y):
    return y[1]  # theta dot

zero_velocity.direction = 1  # only negative-to-positive crossings

T0 = 2 * np.pi * np.sqrt(L / g)

print(f"{'theta0':>7}  {'numeric T':>11}  {'T/T0':>7}  {'(2/pi)K':>8}  {'difference':>10}")
for theta0_deg in [10, 60, 90, 170]:
    theta0 = np.radians(theta0_deg)
    sol = solve_ivp(nonlinear_pendulum, [1e-9, 20], [theta0, 0.0],
                    events=zero_velocity, rtol=1e-11, atol=1e-13)
    T = 2 * sol.t_events[0][0]
    exact = (2 / np.pi) * ellipk(np.sin(theta0 / 2) ** 2)
    print(f"{theta0_deg:>6}°  {T:>11.5f}  {T/T0:>7.5f}  {exact:>8.5f}  {abs(T/T0 - exact):>10.2e}")
 theta0   numeric T     T/T0   (2/pi)K  difference
    10°      2.00989  1.00191   1.00191    9.97e-10
    60°      2.15287  1.07318   1.07318    9.96e-10
    90°      2.36784  1.18034   1.18034    9.95e-10
   170°      4.89352  2.43936   2.43936    9.22e-10

Agreement to the ninth decimal place in all four rows, and the residual is the event’s own stopping criterion, not an error of the method.

A convention trap: there are two notations for the elliptic integral in the literature. Some write \(K(k)\), with the modulus \(k\); others, \(K(m)\), with the parameter \(m = k^2\). SciPy uses the second, hence the np.sin(theta0/2) ** 2, with the square. Passing \(k\) instead of \(m\) raises no error at all, only a silently wrong answer:

import numpy as np
from scipy.special import ellipk

theta0 = np.radians(60)
print("with m = sin²(θ₀/2), correct:", (2 / np.pi) * ellipk(np.sin(theta0 / 2) ** 2))
print("with k = sin(θ₀/2),  wrong  :", (2 / np.pi) * ellipk(np.sin(theta0 / 2)))
with m = sin²(θ₀/2), correct: 1.0731820071493645
with k = sin(θ₀/2),  wrong  : 1.1803405990160962

Notice the wrong value: \(1.1803\) is exactly the right answer for \(\theta_0 = 90°\). In other words, the mistake returns a perfectly plausible number, the kind that would go unnoticed in a review; that is why the check against the numerical solution, done above, is worth it even when an exact formula exists.

There is also the series approximation that many books give, \(T/T_0 \approx 1 + \theta_0^2/16\). It gives \(1.0019\) at \(10°\) and \(1.0685\) at \(60°\), good in the first two cases. But it predicts \(1.55\) at \(170°\), against the real \(2.44\): an error of \(36\%\). The progressive degradation of this series is, in itself, a good lesson on how far an approximation can be stretched.

Sweeping the amplitude from \(1°\) to \(179°\), the three descriptions line up side by side:

Plot of the ratio between the real period and the high school formula, as a function of the initial amplitude from 0 to 180 degrees. A blue curve rises smoothly and then shoots up vertically near 180 degrees; a dashed red curve, from the series approximation, follows it up to about 60 degrees and then falls far below; a horizontal dotted line marks the value 1, which is what the high school formula predicts
The real period diverges from the formula T = 2π√(L/g) as the amplitude grows, and shoots up near 180°. The series approximation follows well up to about 60° and then gets lost.

The dotted line at \(1.0\) is what the high school formula predicts: that the period does not depend on the amplitude. It is a good description up to about \(20°\), and that is what makes a pendulum clock possible, as long as it swings little.

And the divergence near \(180°\) has a direct physical meaning: at that amplitude the pendulum reaches the top with zero velocity, in (unstable) equilibrium. It would take an infinite time to complete the oscillation.

Phase Portrait: All the Solutions at Once
#

Each call to solve_ivp returns one curve, for one initial condition. The phase portrait shows the whole set: instead of \(\theta\) against time, one plots \(\dot\theta\) against \(\theta\), and each initial condition becomes a closed or open trajectory in this plane.

Plot of angular velocity against angle, from minus 180 to 180 degrees. At the center there is an equilibrium point surrounded by several concentric closed eye-shaped curves, which represent oscillations. A thicker red curve, the separatrix, encloses all of them and touches the unstable equilibrium points at minus 180 and plus 180 degrees. Above it, open curves cross the plot from left to right, representing the pendulum making full turns
Phase portrait of the pendulum. Inside the separatrix (red), the trajectories are closed: the pendulum oscillates. Outside it, they are open: it has enough energy to make full turns.

The figure summarizes the entire dynamics in one image:

  • The point at the center is the stable equilibrium (pendulum at rest at the bottom). Near it, the curves are almost ellipses: this is the linear regime, the high school one.
  • The closed curves are oscillations. The larger the orbit, the larger the amplitude, and, as we just saw, the longer the period.
  • The two “X” marks at \(\pm180°\) are the unstable equilibrium: the upright pendulum. They are the same physical point, since \(-180°\) and \(+180°\) coincide.
  • The red curve is the separatrix: the trajectory with exactly the energy needed to reach the top, and none to spare. It is the boundary between the two behaviors, and it is what the divergence of the period at \(180°\) corresponds to.
  • The open curves, above, are rotations: enough energy to pass the top and keep spinning, always in the same direction.

Notice that none of this is visible in a single solution in time. That is why the phase portrait is the standard tool for understanding nonlinear systems: it shows what can happen, not just what happened with one specific initial condition.

import matplotlib.pyplot as plt
import numpy as np
from scipy.integrate import solve_ivp

g = 9.81  # m/s^2
L = 1.0  # m


def nonlinear_pendulum(t, y):
    theta, theta_dot = y
    return [theta_dot, -(g / L) * np.sin(theta)]


omega = np.sqrt(g / L)


def linear_pendulum(t, theta0):
    return theta0 * np.cos(omega * t)


theta0 = np.radians(60)

# 2 periods of the NONLINEAR pendulum (T = 2.153 s), instead of a fixed 6 s: this way
# the phase lag grows visibly without turning into a tangle of curves
t_span_fig = [0, 2 * 2.1529]
t_eval_fig = np.linspace(*t_span_fig, 400)
sol_pendulum = solve_ivp(nonlinear_pendulum, t_span_fig, [theta0, 0.0], t_eval=t_eval_fig, rtol=1e-8, atol=1e-10)

fig, ax = plt.subplots()
ax.plot(t_eval_fig, np.degrees(sol_pendulum.y[0]), label="nonlinear (solve_ivp)")
ax.plot(t_eval_fig, np.degrees(linear_pendulum(t_eval_fig, theta0)), "--", label=r"linear ($\sin\theta\approx\theta$)")
ax.set_xlabel("t (s)")
ax.set_ylabel(r"$\theta$ (degrees)")
ax.legend()

The following plot makes this visible: the two curves start together, but quickly come apart, because the nonlinear one has a longer period (it oscillates more slowly) than the linear approximation:

Plot of angle as a function of time over two periods, with two oscillating curves of the same amplitude, between plus and minus 60 degrees: the nonlinear one is visibly slower than the dashed one from the linear approximation, and the phase lag between them grows with each oscillation
Two periods of the pendulum at 60°. Notice that the amplitudes are identical and both curves still look like sinusoids: what changes is the period, and the phase lag accumulates oscillation after oscillation.

Choosing a Method: When the Problem Is Stiff
#

solve_ivp accepts several methods via method=, and the right choice can be the difference between solving in milliseconds or taking much longer to solve the same equation.

What “Stiffness” Means
#

A classic example exposes the problem: \(y^{\prime} = -1000(y - \cos t)\). This equation embeds two very different time scales. The factor \(-1000\) makes any deviation between \(y\) and \(\cos t\) vanish almost instantly: the time scale of this relaxation is \(1/1000 = 0.001\) s. The \(\cos t\) itself, which \(y\) starts to follow closely after this initial transient, varies on a much slower scale (period of \(2\pi \approx 6.3\) s). A system like this, with one component that reacts extremely fast and another that evolves slowly, is called stiff.

The problem is not lack of precision, it is stability: a method like RK45 (seen next) chooses the step size based on the fastest dynamics of the system, and keeps needing tiny steps even after that fast dynamics has already “died” and \(y\) is already evolving smoothly along with \(\cos t\). If the step exceeds the limit imposed by the fast scale, the method does not just become imprecise: it diverges numerically (the solution oscillates and blows up). That is why the integrator is forced to keep small steps the whole time, even in the “easy” part of the solution, hence the cost.

Explicit and Implicit Methods, Step by Step
#

Every numerical method for ODEs works in the same general way: given the current value, \(y_n\), compute the next one, \(y_{n+1}\), after a step of size \(h\). The difference between an explicit and an implicit method is in how that computation is done.

Explicit method × implicit method

A method is explicit when it computes \(y_{n+1}\) directly, using only what is already known: the current value and the derivative at it (or at intermediate points already computed from it). The simplest case, the explicit Euler method:

$$y_{n+1} = y_n + h \cdot f(t_n, y_n)$$

Everything on the right-hand side is already a known number before the step: just substitute and compute.

A method is implicit when \(y_{n+1}\) appears on both sides of the equation, including inside the very function being integrated. The implicit equivalent of the Euler method above:

$$y_{n+1} = y_n + h \cdot f(t_{n+1}, y_{n+1})$$

Since \(y_{n+1}\) is at the same time what we want to find and one of the arguments of \(f\) on the right-hand side, it cannot be isolated in a direct computation: solving this equation normally requires an iterative method (such as Newton’s) at each step.

The Runge-Kutta methods (RK45, RK23, DOP853, seen in the table below) follow the explicit logic, only combining several intermediate evaluations of \(f\) in a more precise formula, but always a direct computation, without having to solve any equation. The extra cost per step of an implicit method buys stability: its stability region is much larger, and does not depend on the fast scale of the system the way an explicit method’s does; that is why it tolerates much larger steps on stiff problems, like the one in the example above.

import numpy as np
from scipy.integrate import solve_ivp


def stiff(t, y):
    return [-1000 * (y[0] - np.cos(t))]


for method_name in ["RK45", "Radau", "LSODA"]:
    sol = solve_ivp(stiff, [0, 2], [0.0], method=method_name)
    print(f"{method_name:>6}: success={sol.success}  steps={len(sol.t)}  nfev={sol.nfev}")
  RK45: success=True  steps=611  nfev=4238
 Radau: success=True  steps=15  nfev=100
 LSODA: success=True  steps=58  nfev=106

RK45 needed \(611\) steps and \(4238\) function evaluations to solve the same problem that Radau solves in \(15\) steps and \(100\) evaluations, more than 40 times less work. That is the practical symptom of stiffness: a number of steps or evaluations much larger than expected for the default explicit method, on a problem that “does not look” that complicated.

4,238

RK45

function evaluations, in 611 steps

100

Radau

evaluations for the same problem, in 15 steps

40×

less work

the practical symptom of a stiff problem

But these counts say how much, not where. Plotting the size of each step along the integration, the mechanism shows up:

import matplotlib.pyplot as plt
import numpy as np
from scipy.integrate import solve_ivp


def stiff(t, y):
    return [-1000 * (y[0] - np.cos(t))]


fig, ax = plt.subplots(figsize=(9, 5.5))
for method_name, color in (("RK45", "C1"), ("Radau", "C0"), ("LSODA", "C3")):
    sol_p = solve_ivp(stiff, [0, 2], [0.0], method=method_name)
    steps = np.diff(sol_p.t)          # the interval between consecutive instants
    ax.semilogy(sol_p.t[1:], steps, "o-", color=color, markersize=3, linewidth=1,
                label=f"{method_name} ({len(sol_p.t)} steps)")
    print(f"{method_name:>6}: median step={np.median(steps):.2e}"
          f"  maximum={steps.max():.2e}")

ax.axhline(1 / 1000, color="C4", linestyle="--", linewidth=1.5,
           label="fast time scale (1/1000 s)")
ax.set_xlabel("t (s)")
ax.set_ylabel("step size h")
ax.legend(fontsize=9)

Logarithmic-scale plot of the step size over time for three methods. The RK45 curve stays stuck in a narrow band near 0.003 from start to finish; the Radau and LSODA ones start small and rise by several orders of magnitude after the initial transient. A dashed line marks the fast time scale of the problem
RK45 never manages to increase the step: it stays stuck near the fast time scale of the problem throughout the integration. The implicit methods escape it as soon as the initial transient passes.

  RK45: median step=3.25e-03  maximum=4.18e-03
 Radau: median step=1.43e-03  maximum=1.67e+00
 LSODA: median step=5.75e-04  maximum=1.75e-01

The maximum step tells the whole story. RK45 never goes past \(4.2\times10^{-3}\), always in the same order of magnitude as the fast time scale of the problem (\(1/1000\) s), from start to finish. Radau reaches \(1.67\), four hundred times larger.

And that is what stiffness means in practice. After the initial transient, the solution is smooth: \(y\) simply follows \(\cos t\), and a large step would deliver the precision without any problem. But the explicit method cannot increase the step: not for lack of precision, but because above a certain \(h\) it becomes unstable and the solution blows up. It remains hostage to a fast dynamics that no longer exists.

What “Order p(q)” Means
#

Before the table, it is worth understanding the “order \(p(q)\)” notation that appears in almost every method. It describes an embedded pair of formulas, which run together reusing the same intermediate evaluations of \(f\):

  • The solution that actually advances, \(y_{n+1}\), comes from the formula of order \(p\) (the higher one). “Order \(p\)” means that the error accumulated over the integration falls like \(O(h^p)\), the same concept of order already seen in the numerical derivatives article with finite differences (the central one was \(O(h^2)\), the forward/backward, \(O(h)\)): the higher the order, the faster the error falls as \(h\) decreases.
  • The formula of order \(q\) (the lower one, in parentheses) does not advance the solution: it serves only to estimate the step error, comparing its result with that of the formula of order \(p\). Since the two formulas share the evaluations of \(f\), this estimate comes at no extra cost, and it is what allows solve_ivp to grow or shrink \(h\) on its own along the integration (adaptive step size), without the user having to fix a value.

So “RK45, order 5(4)” reads: it advances with the 5th-order formula and measures the step error with a 4th-order formula embedded in it.

Quick Guide to Methods
#

The following table summarizes the options of solve_ivp, with the full name behind each acronym and when each one is justified:

Method Name behind the acronym Type When to use
RK45 (default) Runge-Kutta, Dormand-Prince pair of formulas, order 5(4) Explicit First choice for most non-stiff problems, a good balance between cost and precision
RK23 Runge-Kutta, Bogacki-Shampine pair of formulas, order 3(2) Explicit Low precision is acceptable, or the field(t, y) is expensive to evaluate and fewer evaluations per step matter more than the order
DOP853 Dormand-Prince Runge-Kutta, order 8 Explicit Smooth, non-stiff problem where high precision is needed, more accurate than RK45, at the cost of more evaluations per step
Radau Implicit Runge-Kutta of the Radau IIA family, order 5(3) Implicit Stiff problems that also need high precision
BDF Backward Differentiation Formulas, variable order from 1 to 5 Implicit Stiff problems with many variables, robust and efficient on large systems
LSODA Solver from the ODEPACK package (Lawrence Livermore National Laboratory), automatically switching between an Adams-type method (non-stiff) and BDF Automatic When it is not known beforehand whether the problem is stiff, it detects the transition and switches method on its own

(The full names and the origin of each method come from the SciPy documentation itself, which cites the original papers for each one.)

Without this table as a reference, the reasonable default choice is always RK45; it is only worth switching when the symptom of stiffness appears (steps or nfev well above what is expected) or when the precision of RK45 is not enough for a problem known to be smooth.

Real Applications
#

With the basic mechanics and the choice of method established, five applications follow, from the simplest (with a closed-form solution, for validation) to the ones that do not close at all.

Example 1: Logistic population growth

Statement: A population grows according to the logistic model, \(\frac{dP}{dt} = rP\left(1 - \frac{P}{K}\right)\), with growth rate \(r = 0.5\) year\(^{-1}\), carrying capacity \(K=100\), and initial population \(P_0=5\). Solve numerically and compare with the known analytic solution.

Solution: The closed-form solution of this model is \(P(t) = \dfrac{K}{1 + (K/P_0 - 1)e^{-rt}}\):

import numpy as np
from scipy.integrate import solve_ivp

r, K, P0 = 0.5, 100.0, 5.0


def logistic(t, P):
    return [r * P[0] * (1 - P[0] / K)]


def logistic_analytic(t):
    return K / (1 + (K / P0 - 1) * np.exp(-r * t))


t_eval_log = np.linspace(0, 20, 200)
sol_log = solve_ivp(logistic, [0, 20], [P0], t_eval=t_eval_log, rtol=1e-8, atol=1e-10)
error = np.max(np.abs(sol_log.y[0] - logistic_analytic(t_eval_log)))
print("maximum error:", error)
maximum error: 5.216148338149651e-06

An error on the order of \(10^{-6}\): the nonlinear ODE is solved numerically without the model having to be linearized or simplified in any way, and the result matches the closed-form formula within the requested tolerance.

Example 2: Lotka-Volterra: predator and prey

Statement: The Lotka-Volterra predator-prey model describes two coupled populations:

$$\frac{dP}{dt} = \alpha P - \beta P Q, \qquad \frac{dQ}{dt} = \delta P Q - \gamma Q$$

The prey \(P\) grows on its own (\(\alpha\)) and is eaten (\(\beta PQ\)); the predator \(Q\) dies on its own (\(\gamma\)) and reproduces by eating (\(\delta PQ\)). Solve with \(\alpha=1\), \(\beta=0.02\), \(\delta=0.01\), \(\gamma=0.5\), starting from 40 prey and 9 predators.

Solution: This is the first example in the article with parameters passed via args, instead of captured from the global scope, which is the recommended way, and avoids the class of bug in which changing a global variable silently changes the result of another cell:

import numpy as np
from scipy.integrate import solve_ivp

def lotka_volterra(t, y, alpha, beta, delta, gamma):
    prey, predator = y
    return [alpha * prey - beta * prey * predator,
            delta * prey * predator - gamma * predator]

params = (1.0, 0.02, 0.01, 0.5)   # alpha, beta, delta, gamma
sol_lv = solve_ivp(lotka_volterra, [0, 40], [40.0, 9.0],
                   t_eval=np.linspace(0, 40, 2000),
                   args=params, rtol=1e-10, atol=1e-12)

The system is genuinely nonlinear (the \(PQ\) term couples the two) and has no closed-form solution. But it has a conserved quantity:

$$V = \delta P - \gamma\ln P + \beta Q - \alpha\ln Q$$

which serves as a quality test for the integrator, just as mass conservation will in the kinetics example, further on:

import numpy as np
from scipy.integrate import solve_ivp

def lotka_volterra(t, y, alpha, beta, delta, gamma):
    prey, predator = y
    return [alpha * prey - beta * prey * predator,
            delta * prey * predator - gamma * predator]

params = (1.0, 0.02, 0.01, 0.5)   # alpha, beta, delta, gamma
sol_lv = solve_ivp(lotka_volterra, [0, 40], [40.0, 9.0],
                   t_eval=np.linspace(0, 40, 2000),
                   args=params, rtol=1e-10, atol=1e-12)

prey, predator = sol_lv.y
V = 0.01 * prey - 0.5 * np.log(prey) + 0.02 * predator - 1.0 * np.log(predator)
print(f"relative variation of the invariant: {(V.max() - V.min()) / abs(V.mean()):.2e}")
relative variation of the invariant: 7.30e-10

The two panels below show the result in time and in the phase plane:

Two plots side by side. On the left, prey and predator populations oscillating over time, with the predator peak always lagging behind the prey peak. On the right, a phase portrait with four concentric closed orbits around an equilibrium point marked in red
Lotka-Volterra: the populations oscillate with a lag: the predator only grows after the prey has grown. In the phase portrait, each initial condition generates a closed orbit around the equilibrium at (50, 50).

The left panel shows the behavior that gives the model its name. Both populations oscillate, but out of step: the predator only thrives after the prey has thrived, and the predator’s collapse comes after the prey’s collapse.

The right one is the phase portrait, the same tool used on the pendulum: each initial condition generates a closed orbit around the equilibrium at \((50, 50)\), which is where both derivatives vanish (\(P^\ast = \gamma/\delta\), \(Q^\ast = \alpha/\beta\)). A closed orbit means the system returns exactly to the starting point: the oscillations neither damp nor grow, which is a peculiarity of this model, and one of the reasons it is more a caricature than a realistic description of ecosystems.

Example 3: Charging RC circuit

Statement: An uncharged capacitor (\(V(0)=0\)) is connected to a \(5\) V source through a \(1\) k\(\Omega\) resistor and a \(1\) mF capacitor. Find the voltage across the capacitor over time.

Solution: The 1st-order linear ODE \(RC\dfrac{dV}{dt} + V = V_0\) has a known closed-form solution, \(V(t) = V_0(1 - e^{-t/RC})\), one more validation with a closed-form solution before moving on to more complex examples:

import numpy as np
from scipy.integrate import solve_ivp

R, C, V0 = 1e3, 1e-3, 5.0  # ohm, F, V
tau = R * C


def rc_charging(t, V):
    return [(V0 - V[0]) / tau]


t_eval_rc = np.linspace(0, 5 * tau, 200)
sol_rc = solve_ivp(rc_charging, [0, 5 * tau], [0.0], t_eval=t_eval_rc, rtol=1e-8, atol=1e-10)
V_analytic = V0 * (1 - np.exp(-t_eval_rc / tau))
print("maximum error:", np.max(np.abs(sol_rc.y[0] - V_analytic)))
maximum error: 9.250205046384963e-09
Example 4: Falling with quadratic air resistance

Statement: An object of mass \(80\) kg falls from rest, subject to gravity and to an air resistance proportional to the square of the velocity (\(F_{\text{drag}} = -kv^2\), with \(k=0.25\) kg/m, a more realistic model than linear drag at higher velocities). Find the velocity as a function of time.

Solution: The equation of motion is \(m\dfrac{dv}{dt} = mg - kv^2\). The terminal velocity, where \(dv/dt=0\), comes out immediately: \(v_t = \sqrt{mg/k}\).

import numpy as np
from scipy.integrate import solve_ivp

m, g_local, k = 80.0, 9.81, 0.25  # kg, m/s^2, kg/m
v_terminal = np.sqrt(m * g_local / k)


def fall_with_drag(t, v):
    return [g_local - (k / m) * v[0] ** 2]


t_eval_fall = np.linspace(0, 15, 200)
sol_fall = solve_ivp(fall_with_drag, [0, 15], [0.0], t_eval=t_eval_fall,
                     rtol=1e-10, atol=1e-12)
print("terminal velocity:", round(v_terminal, 2), "m/s")
print("velocity at t=15s:", round(sol_fall.y[0][-1], 2), "m/s")
terminal velocity: 56.03 m/s
velocity at t=15s: 55.45 m/s

At 15 s, the object is already at \(98.9\%\) of the terminal velocity, but formally never reaches it, since \(v_t\) is an asymptotic limit, not a value reached in finite time.

And this case still has a closed form, which makes it the third validation among these examples. Separating the variables into \(dv/(1 - v^2/v_t^2) = g\ dt\) and integrating, one arrives at a hyperbolic tangent:

$$v(t) = v_t \tanh\left(\frac{g t}{v_t}\right)$$
import numpy as np
from scipy.integrate import solve_ivp

m, g_local, k = 80.0, 9.81, 0.25  # kg, m/s^2, kg/m
v_terminal = np.sqrt(m * g_local / k)


def fall_with_drag(t, v):
    return [g_local - (k / m) * v[0] ** 2]


t_eval_fall = np.linspace(0, 15, 200)
sol_fall = solve_ivp(fall_with_drag, [0, 15], [0.0], t_eval=t_eval_fall,
                     rtol=1e-10, atol=1e-12)

v_analytic = v_terminal * np.tanh(g_local * t_eval_fall / v_terminal)
print("maximum error:", np.max(np.abs(sol_fall.y[0] - v_analytic)))
maximum error: 1.00780468414996e-08

It matches to the eighth decimal place, pure tolerance residue. It is a classic result of mechanics, and it is good that it appears here: “has quadratic drag” is not a synonym for “does not close”.

The article on orbits and trajectories of this series had already solved a problem of the same family (a projectile under air resistance), but as a transcendental equation for the flight time, with linear drag (\(F_{\text{drag}} = -kmv\), 2D, with a launch angle). Here the physics is different (quadratic drag, vertical component only), but the change of approach is the same lesson: without drag simplified enough to isolate a variable in a closed-form equation, solving the ODE directly in time is the way to go.

Example 5: Chemical kinetics: consecutive reactions

Statement: In a reaction \(A \to B \to C\), with rate constants \(k_1=0.3\) s\(^{-1}\) and \(k_2=0.1\) s\(^{-1}\), starting from \(A_0=1\) mol/L and no \(B\) or \(C\), find the concentrations of the three species over time.

Solution: The system of coupled ODEs is \(\frac{dA}{dt}=-k_1A\), \(\frac{dB}{dt}=k_1A-k_2B\), \(\frac{dC}{dt}=k_2B\), three chained equations, each feeding the next:

import numpy as np
from scipy.integrate import solve_ivp

k1, k2 = 0.3, 0.1  # 1/s
A0 = 1.0


def consecutive_reactions(t, y):
    A, B, C = y
    return [-k1 * A, k1 * A - k2 * B, k2 * B]


t_eval_kin = np.linspace(0, 30, 300)
sol_kin = solve_ivp(
    consecutive_reactions, [0, 30], [A0, 0.0, 0.0], t_eval=t_eval_kin, rtol=1e-8, atol=1e-10
)

A_analytic = A0 * np.exp(-k1 * t_eval_kin)
print("maximum error in A(t):", np.max(np.abs(sol_kin.y[0] - A_analytic)))
print("mass conservation deviation:", np.max(np.abs(sol_kin.y.sum(axis=0) - A0)))
maximum error in A(t): 4.733793357303284e-10
mass conservation deviation: 4.440892098500626e-16

Two checks: \(A(t)\) matches the pure exponential (since \(A\) does not depend on \(B\) or \(C\)), and the sum \(A+B+C\) remains equal to \(A_0\) the whole time. That is mass conservation, which no line of code imposed explicitly and which only follows from how the three equations were written.

And we can check more. This system, although coupled, has a closed-form solution, a classic result of chemical kinetics:

$$B(t) = \frac{k_1 A_0}{k_2-k_1}\left(e^{-k_1t}-e^{-k_2t}\right)$$
import numpy as np
from scipy.integrate import solve_ivp

k1, k2 = 0.3, 0.1  # 1/s
A0 = 1.0


def consecutive_reactions(t, y):
    A, B, C = y
    return [-k1 * A, k1 * A - k2 * B, k2 * B]


t_eval_kin = np.linspace(0, 30, 300)
sol_kin = solve_ivp(
    consecutive_reactions, [0, 30], [A0, 0.0, 0.0], t_eval=t_eval_kin, rtol=1e-8, atol=1e-10
)

B_analytic = A0 * k1 / (k2 - k1) * (np.exp(-k1 * t_eval_kin) - np.exp(-k2 * t_eval_kin))
print("maximum error in B(t):", np.max(np.abs(sol_kin.y[1] - B_analytic)))
maximum error in B(t): 7.066555118839801e-10

A third check, and the most demanding of the three: the entire curve of the intermediate, with its characteristic peak, reproduced to the ninth decimal place.

import matplotlib.pyplot as plt
import numpy as np
from scipy.integrate import solve_ivp

k1, k2 = 0.3, 0.1  # 1/s
A0 = 1.0


def consecutive_reactions(t, y):
    A, B, C = y
    return [-k1 * A, k1 * A - k2 * B, k2 * B]


t_eval_kin = np.linspace(0, 30, 300)
sol_kin = solve_ivp(
    consecutive_reactions, [0, 30], [A0, 0.0, 0.0], t_eval=t_eval_kin, rtol=1e-8, atol=1e-10
)

fig, ax = plt.subplots()
for curve, name in zip(sol_kin.y, ["A", "B", "C"]):
    ax.plot(t_eval_kin, curve, label=name)
ax.set_xlabel("t (s)")
ax.set_ylabel("concentration (mol/L)")
ax.legend()

The following plot shows the three concentrations over time:

Plot of concentration as a function of time with three curves: A decaying smoothly to zero, B rising, reaching a peak around t=5s and then falling, and C rising continuously until approaching the initial concentration of A
Consecutive reactions A → B → C: the intermediate B has a characteristic peak before being consumed.

Where the Closed-Form Formula Really Breaks
#

If the system closes, why solve it numerically? Because the formula is fragile in two ways, and both show up early.

First: it blows up when \(k_1 = k_2\). Notice the \(k_2 - k_1\) in the denominator. It is not a physical singularity (two steps with the same rate constant are perfectly reasonable): it is a singularity of the formula. In Python, the result is literally an exception:

import numpy as np

A0 = 1.0
t = np.linspace(0, 30, 300)

k1 = k2 = 0.2
B_analytic = A0 * k1 / (k2 - k1) * (np.exp(-k1 * t) - np.exp(-k2 * t))
ZeroDivisionError: float division by zero

The correct solution in this case exists, but it has a different functional form, in which the difference of exponentials disappears and a factor \(t\) enters:

$$ B(t) = A_0 k t e^{-kt} $$

To get there one has to redo the integration from scratch, or take the limit \(k_2 \to k_1\) with L’Hôpital. solve_ivp, on the other hand, notices no difference at all. It is the same field, the same code, and the answer comes out right:

import numpy as np
from scipy.integrate import solve_ivp

k1 = k2 = 0.2  # the degenerate case
A0 = 1.0


def consecutive_reactions(t, y):
    A, B, C = y
    return [-k1 * A, k1 * A - k2 * B, k2 * B]


t_eval_kin = np.linspace(0, 30, 300)
sol_deg = solve_ivp(consecutive_reactions, [0, 30], [A0, 0.0, 0.0],
                    t_eval=t_eval_kin, rtol=1e-8, atol=1e-10)
print("error against A0*k*t*exp(-k*t):",
      np.max(np.abs(sol_deg.y[1] - A0 * 0.2 * t_eval_kin * np.exp(-0.2 * t_eval_kin))))
error against A0*k*t*exp(-k*t): 8.172407195417009e-10

Second: it does not survive a slightly more realistic mechanism. It is enough to make the first step reversible (\(A \rightleftharpoons B \to C\), with a constant \(k_{-1}\) going back) for the system to stop being a cascade and become genuinely coupled:

k1, k_minus1, k2 = 0.3, 0.15, 0.1

def reversible_reactions(t, y):
    A, B, C = y
    return [-k1 * A + k_minus1 * B,
            k1 * A - k_minus1 * B - k2 * B,
            k2 * B]

Now \(A\) depends on \(B\), which depends on \(A\). Solving this by hand means finding the eigenvalues of a \(3\times3\) matrix (the roots of a cubic characteristic polynomial) and assembling the solution as a combination of three exponentials with those exponents. It is possible, it is ugly, and it vanishes completely as soon as a fourth species or a second-order step is added (where a closed-form solution does not even exist). For solve_ivp, the change is three lines.

This is the pattern that repeats throughout this series: the closed form does not disappear all at once, it degrades. First it becomes a special case that needs separate handling, then an expression nobody wants to write, then it no longer exists. The numerical method goes through all three stages without changing code, and that is why it is worth having the analytic validation while it exists: it is what gives confidence to use the method when it runs out.

The numerical derivatives article estimated the rate of a 1st-order kinetics from measured concentration data, with no model at all. Here the logic is inverted: starting from the model (the constants \(k_1\), \(k_2\), and the two-step mechanism), the ODE generates the concentration over time, including the characteristic peak of the intermediate \(B\), at \(t = \ln(k_1/k_2)/(k_1-k_2) \approx 5.49\) s, which a pure 1st-order kinetics would never produce.

Points of Attention
#

The initial condition always matters

The same pendulum ODE, with different initial conditions (\(10°\), \(60°\), or \(170°\), all starting from rest), gives curves with different periods: 2.01 s, 2.15 s, and 4.89 s. This is a peculiarity of nonlinear equations worth highlighting: in a linear oscillator, the period does not depend on the amplitude (which is what makes a pendulum clock possible, as long as it swings little). As soon as \(\sin\theta\) enters without approximation, this independence ends.

solve_ivp always solves one specific IVP: change the initial condition, and the whole curve changes. This is quite different from the general family that symbolic dsolve returns, with the constant \(C_1\) left open: there, “the solution” is a family; here, it is always one curve.

Tolerance (atol, rtol) silently affects the result

Solving the \(60°\) pendulum twice, with different tolerances:

import numpy as np
from scipy.integrate import solve_ivp

g = 9.81  # m/s^2
L = 1.0  # m


def nonlinear_pendulum(t, y):
    theta, theta_dot = y
    return [theta_dot, -(g / L) * np.sin(theta)]


t_span = [0, 6]
t_eval = np.linspace(*t_span, 400)
theta0 = np.radians(60)

sol_tight = solve_ivp(nonlinear_pendulum, t_span, [theta0, 0.0], t_eval=t_eval, rtol=1e-10, atol=1e-12)
sol_loose = solve_ivp(nonlinear_pendulum, t_span, [theta0, 0.0], t_eval=t_eval, rtol=1e-2, atol=1e-2)
diff = np.max(np.abs(np.degrees(sol_tight.y[0] - sol_loose.y[0])))
print("maximum divergence:", round(diff, 3), "degrees")
maximum divergence: 12.628 degrees

More than \(12°\) of difference between two “apparently identical” solve_ivp calls: same ODE, same initial condition, only the tolerance changed.

Notice where the default sits in this comparison: rtol=1e-3 is between the two, ten times tighter than the loose version and ten million times looser than the tight one. In other words, accepting the default without thinking is not a neutral choice: it is a choice, and on this problem it would still leave error visible to the naked eye after a few oscillations.

The practical recipe is simple: solve twice, with different tolerances, and compare. If the two curves coincide, the looser tolerance was already enough. If they do not, tighten until they do. It is the equivalent, for ODEs, of the “check the residual” that the article on roots, at the start of this series, recommended.

import matplotlib.pyplot as plt
import numpy as np
from scipy.integrate import solve_ivp

g = 9.81  # m/s^2
L = 1.0  # m


def nonlinear_pendulum(t, y):
    theta, theta_dot = y
    return [theta_dot, -(g / L) * np.sin(theta)]


t_span = [0, 6]
t_eval = np.linspace(*t_span, 400)
theta0 = np.radians(60)

sol_tight = solve_ivp(nonlinear_pendulum, t_span, [theta0, 0.0], t_eval=t_eval, rtol=1e-10, atol=1e-12)
sol_loose = solve_ivp(nonlinear_pendulum, t_span, [theta0, 0.0], t_eval=t_eval, rtol=1e-2, atol=1e-2)

fig, ax = plt.subplots()
ax.plot(t_eval, np.degrees(sol_tight.y[0]), label="tight tolerance")
ax.plot(t_eval, np.degrees(sol_loose.y[0]), "--", label="loose tolerance (rtol=atol=1e-2)")
ax.set_xlabel("t (s)")
ax.set_ylabel(r"$\theta$ (degrees)")
ax.legend()

The two curves start together and come apart over time, the same kind of progressive divergence already seen between the nonlinear pendulum and its linear approximation, now caused only by numerical tolerance:

Plot of angle as a function of time with two curves that start overlapping and progressively come apart, one with tight tolerance and the other with loose tolerance
Same ODE, same initial condition: numerical tolerance alone is enough for visibly different curves.

Summary Table
#

Tool / concept Use Note
dsolve (SymPy) General symbolic solution, with open constants Only works when a closed form exists
solve_ivp(field, t_span, y0) Numerical solution of a specific IVP field(t, y) returns the derivative of each component of y
dense_output=True Allows evaluating the solution at any point after solving Uses sol.sol(t)
Explicit method Computes \(y_{n+1}\) directly, using only known values Simple computation, no equation to solve, but unstable on stiff problems with a large step
Implicit method \(y_{n+1}\) appears on both sides of the equation More expensive per step (requires solving the equation), but much more stable
Order \(p(q)\) Embedded pair: advances with the formula of order \(p\), estimates the error with the one of order \(q\) Basis of the adaptive step size, error estimated without extra evaluations
method="RK45" (default) Explicit Runge-Kutta Fast in most cases; inefficient on stiff problems
method="Radau" / "BDF" Implicit methods Choice for stiff systems
method="LSODA" Detects stiffness automatically Switches method on its own
Symptom of stiffness Number of steps/evaluations far above what is expected Compare len(sol.t) or sol.nfev across methods
Initial condition Determines which specific curve is obtained Different from the general family of dsolve
Tolerance (rtol, atol) Controls precision vs. computational cost Two solutions with different tolerances can diverge visibly

Next Steps
#

This article covered how to solve a differential equation when there is no closed-form solution, but always starting from an already written model, with the equations and parameters known beforehand. A sibling problem remains: what if what we want is not to solve the ODE, but to find the best parameters for a model, or the minimum (or maximum) point of a function with no closed-form formula for its derivative? In the next article, scipy.optimize tackles exactly this problem, and, later in the series, in an article on optimization applications, the very differential equation solved here comes back as part of the model to be optimized.

From Symbol to Number - This article is part of a series.
Part 9: This Article

Related

Numerical Integration with SciPy

The Gaussian function has no elementary antiderivative, and that is a theorem of Liouville. When the antiderivative does not exist, or all you have is a table of measurements, the way forward is numerical: quad, trapezoid and Simpson, from scipy.integrate, with the care each one demands.

Integrals - Calculus with Python and SymPy

We have already written here on the site about the concepts of limit and derivative and how we can do the related computations with Python. Continuing our journey through calculus, in this article we will see how to work with integrals using SymPy and SciPy, Python packages. Along the way, we will pick up some notions of numerical methods.