Examples

The examples below are rendered with Matplotlib when the documentation is built. Runnable scripts are in the examples/ directory of the repository.

Mechanics

Damped harmonic oscillator

A mass \(m\) on a spring with stiffness \(k\) and damping coefficient \(c\) obeys

\[m\ddot x + c\dot x + kx = 0,\]

or, in terms of the natural frequency \(\omega_n = \sqrt{k/m}\) and the damping ratio \(\zeta = c/(2\sqrt{km})\),

\[\ddot x + 2\zeta\omega_n\dot x + \omega_n^2 x = 0.\]
import numpy as np
import matplotlib.pyplot as plt
from odecast import var, Eq, solve

omega_n = 2.0
zeta = 0.3

x = var("x")
eq = Eq(x.d(2) + 2*zeta*omega_n*x.d() + omega_n**2*x, 0)
sol = solve(eq, ivp={x: 1.0, x.d(): 0.0}, tspan=(0, 5), max_step=0.01)

t = np.linspace(0, 5, 1000)
pos = sol.eval(x, t)
vel = sol.eval(x.d(), t)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))

ax1.plot(t, pos, label="$x$")
ax1.plot(t, vel, "--", label=r"$\dot x$")
ax1.set_xlabel("$t$")
ax1.legend()
ax1.set_title("Time series")

ax2.plot(pos, vel)
ax2.set_xlabel("$x$")
ax2.set_ylabel(r"$\dot x$")
ax2.set_title("Phase portrait")

plt.tight_layout()

(png, svg)

_images/examples-1.png

With \(\zeta < 1\) the system is underdamped: the amplitude decays exponentially while the trajectory spirals into the origin.

Pendulum at large amplitude

The undamped pendulum satisfies

\[\ddot\theta + \frac{g}{L}\sin\theta = 0.\]

For small angles \(\sin\theta \approx \theta\), which gives the harmonic oscillator with period \(2\pi\sqrt{L/g}\). At larger angles the restoring force is weaker than in the linear approximation and the period grows. The comparison below starts both at \(\theta_0 = 60^\circ\).

import numpy as np
import matplotlib.pyplot as plt
import sympy as sp
from odecast import var, Eq, solve

g, L = 9.81, 1.0
theta0 = np.pi / 3

theta = var("theta")
eq_lin = Eq(theta.d(2) + (g/L)*theta, 0)
eq_full = Eq(theta.d(2) + (g/L)*sp.sin(theta), 0)

ic = {theta: theta0, theta.d(): 0.0}
sol_lin = solve(eq_lin, ivp=ic, tspan=(0, 10), max_step=0.02)
sol_full = solve(eq_full, ivp=ic, tspan=(0, 10), max_step=0.02)

t = np.linspace(0, 10, 1000)
plt.figure(figsize=(8, 4))
plt.plot(t, np.degrees(sol_lin.eval(theta, t)), "--", label=r"$\sin\theta \approx \theta$")
plt.plot(t, np.degrees(sol_full.eval(theta, t)), label=r"$\sin\theta$")
plt.xlabel("$t$ [s]")
plt.ylabel(r"$\theta$ [deg]")
plt.ylim(-75, 90)
plt.legend(loc="upper center", ncol=2)
plt.tight_layout()

(png, svg)

_images/examples-2.png

Bouncing ball

A ball dropped from a height of 1 m loses 20% of its speed at every bounce. The bounce is an event that resets the velocity, \(\dot y \to -0.8\,\dot y\). The bounces follow at intervals that shrink by the factor 0.8.

import numpy as np
import matplotlib.pyplot as plt
from odecast import var, Eq, Event, solve

y = var("y")
bounce = Event(Eq(y, 0), direction=-1, reset={y.d(): -0.8*y.d()})
sol = solve(Eq(y.d(2), -9.81), ivp={y: 1.0, y.d(): 0.0}, tspan=(0, 4),
            events=bounce, rtol=1e-8, atol=1e-10)

t = np.linspace(0, 4, 2000)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 3.5))
ax1.plot(t, sol.eval(y, t))
ax1.plot(sol.t_events[0], 0*sol.t_events[0], "k.", label="bounces")
ax1.set_xlabel("$t$ [s]")
ax1.set_ylabel("$y$ [m]")
ax1.legend()
ax2.plot(t, sol.eval(y.d(), t))
ax2.set_xlabel("$t$ [s]")
ax2.set_ylabel(r"$\dot y$ [m/s]")
plt.tight_layout()

(png, svg)

_images/examples-3.png

Isotropic oscillator in two dimensions

A particle in the potential \(V = \tfrac12\omega^2|\mathbf r|^2\) obeys

\[\ddot{\mathbf r} + \omega^2\mathbf r = 0, \qquad \mathbf r = (x, y).\]

This is a natural use case for a vector variable. Generic initial conditions lead to an elliptical orbit, and the total energy is conserved.

import numpy as np
import matplotlib.pyplot as plt
from odecast import var, Eq, solve

omega = 1.0

r = var("r", shape=2)
eq = Eq(r.d(2) + omega**2 * r, 0)
sol = solve(eq, ivp={r: [1.0, 0.0], r.d(): [0.0, 1.5]},
            tspan=(0, 4*np.pi), max_step=0.05)

t = np.linspace(0, 4*np.pi, 1000)
x, y = sol.eval(r, t)
vx, vy = sol.eval(r.d(), t)

kinetic = 0.5 * (vx**2 + vy**2)
potential = 0.5 * omega**2 * (x**2 + y**2)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))

ax1.plot(x, y)
ax1.set_aspect("equal")
ax1.set_xlabel("$x$")
ax1.set_ylabel("$y$")
ax1.set_title("Orbit")

ax2.plot(t, kinetic, label="kinetic")
ax2.plot(t, potential, label="potential")
ax2.plot(t, kinetic + potential, "k", label="total")
ax2.set_xlabel("$t$")
ax2.set_title("Energy")
ax2.legend(loc="center right")

plt.tight_layout()

(png, svg)

_images/examples-4.png

Population dynamics

Lotka–Volterra model

The predator–prey model

\[\begin{split}\dot x &= ax - bxy, \\ \dot y &= -cy + dxy,\end{split}\]

with prey \(x\), predators \(y\) and positive parameters \(a, b, c, d\), has the conserved quantity

\[V(x, y) = dx - c\ln x + by - a\ln y.\]

Solutions are therefore closed orbits around the coexistence equilibrium \((c/d,\ a/b)\). Plotting \(V\) along the numerical solution is a convenient check of the integration accuracy.

import numpy as np
import matplotlib.pyplot as plt
from odecast import var, Eq, solve

a, b, c, d = 1.0, 0.1, 1.5, 0.075

x, y = var("x"), var("y")
eqs = [
    Eq(x.d() - a*x + b*x*y, 0),
    Eq(y.d() + c*y - d*x*y, 0),
]
sol = solve(eqs, ivp={x: 10.0, y: 5.0}, tspan=(0, 15), max_step=0.05)

t = np.linspace(0, 15, 1000)
xs = sol.eval(x, t)
ys = sol.eval(y, t)
V = d*xs - c*np.log(xs) + b*ys - a*np.log(ys)

fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(13, 4))

ax1.plot(t, xs, label="prey")
ax1.plot(t, ys, label="predators")
ax1.set_xlabel("$t$")
ax1.legend()
ax1.set_title("Populations")

ax2.plot(xs, ys)
ax2.plot(c/d, a/b, "k+", markersize=10)
ax2.set_xlabel("prey")
ax2.set_ylabel("predators")
ax2.set_title("Phase portrait")

ax3.plot(t, V - V[0])
ax3.set_xlabel("$t$")
ax3.set_title("$V(t) - V(0)$")

plt.tight_layout()

(png, svg)

_images/examples-5.png

Electrical circuits

Series RLC circuit

Kirchhoff’s voltage law for a series RLC circuit driven by a voltage \(V(t)\) gives, for the capacitor charge \(q\),

\[L\ddot q + R\dot q + \frac{q}{C} = V(t).\]

The current is \(i = \dot q\). The natural frequency and damping ratio are \(\omega_n = 1/\sqrt{LC}\) and \(\zeta = \tfrac{R}{2}\sqrt{C/L}\). We switch on a constant voltage \(V_0\) at \(t = 0\) with the capacitor initially discharged.

import numpy as np
import matplotlib.pyplot as plt
from odecast import var, Eq, solve

L = 1e-3    # H
R = 10.0    # Ohm
C = 1e-6    # F
V0 = 12.0   # V

q = var("q")
eq = Eq(L*q.d(2) + R*q.d() + q/C, V0)
sol = solve(eq, ivp={q: 0.0, q.d(): 0.0}, tspan=(0, 5e-3), max_step=5e-6)

t = np.linspace(0, 5e-3, 1000)
v_C = sol.eval(q, t) / C
i = sol.eval(q.d(), t)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))

ax1.plot(t*1e3, v_C)
ax1.axhline(V0, color="k", linestyle=":")
ax1.set_xlabel("$t$ [ms]")
ax1.set_ylabel("$v_C$ [V]")
ax1.set_title("Capacitor voltage")

ax2.plot(t*1e3, i*1e3)
ax2.set_xlabel("$t$ [ms]")
ax2.set_ylabel("$i$ [mA]")
ax2.set_title("Current")

plt.tight_layout()

(png, svg)

_images/examples-6.png

With these values \(\zeta \approx 0.16\); the capacitor voltage overshoots and rings before settling at \(V_0\).

Step response of a second-order system

The standard second-order plant with unit DC gain,

\[\ddot y + 2\zeta\omega_n\dot y + \omega_n^2 y = \omega_n^2 r,\]

responds to a unit step \(r = 1\) as follows.

import numpy as np
import matplotlib.pyplot as plt
from odecast import var, Eq, solve

omega_n, zeta, r = 2.0, 0.1, 1.0

y = var("y")
eq = Eq(y.d(2) + 2*zeta*omega_n*y.d() + omega_n**2*y, omega_n**2*r)
sol = solve(eq, ivp={y: 0.0, y.d(): 0.0}, tspan=(0, 20), max_step=0.01)

t = np.linspace(0, 20, 2000)
plt.plot(t, sol.eval(y, t))
plt.axhline(r, color="k", linestyle=":")
plt.xlabel("$t$")
plt.ylabel("$y$")
plt.show()

overshoot = sol.eval(y, t).max() - r
print(f"overshoot: {overshoot:.2f}")   # theory: exp(-pi*zeta/sqrt(1-zeta^2)) = 0.73

Boundary value problems

Deflection of a beam

The deflection \(w(x)\) of a beam with bending stiffness \(EI\) under a uniform load \(q\) satisfies the fourth-order equation

\[EI\,w^{(4)} = q.\]

For a beam of length \(\ell\) that is simply supported at both ends, the deflection and the bending moment \(EI\,w''\) vanish at \(x = 0\) and \(x = \ell\). The exact solution is

\[w(x) = \frac{q\,x}{24\,EI}\left(\ell^3 - 2\ell x^2 + x^3\right),\]

with maximum \(5q\ell^4/(384\,EI)\) at the centre. Here the independent variable is a position rather than a time; odecast still calls it \(t\).

import numpy as np
import matplotlib.pyplot as plt
from odecast import var, Eq, solve, BC

EI, q, ell = 1.0, 1.0, 1.0

w = var("w")
eq = Eq(EI*w.d(4), q)
bcs = [
    BC(w, t=0, value=0), BC(w.d(2), t=0, value=0),
    BC(w, t=ell, value=0), BC(w.d(2), t=ell, value=0),
]
sol = solve(eq, bvp=bcs, tspan=(0, ell))

x = np.linspace(0, ell, 200)
exact = q*x*(ell**3 - 2*ell*x**2 + x**3) / (24*EI)

plt.figure(figsize=(8, 3.5))
plt.plot(x, -sol.eval(w, x), label="odecast")
plt.plot(x, -exact, "k:", label="exact")
plt.xlabel("$x$")
plt.ylabel("$-w(x)$")
plt.legend()
plt.tight_layout()

print(f"max deflection: {sol.eval(w, ell/2):.6f}")   # 5/384 = 0.013021

(png, svg)

_images/examples-7.png

Quantum harmonic oscillator

The stationary Schrödinger equation of the harmonic oscillator, in units where \(\hbar = m = \omega = 1\),

\[\psi'' = (x^2 - 2E)\,\psi,\]

has normalizable solutions only for the energies \(E_n = n + \tfrac12\). The energy is an unknown parameter of the boundary value problem: besides \(\psi(\pm L) = 0\), the condition \(\psi'(-L) = 10^{-6}\) fixes the otherwise arbitrary amplitude. The starting values select the eigenvalue. Here the independent variable is the position; odecast still calls it \(t\).

import numpy as np
import matplotlib.pyplot as plt
import sympy as sp
from odecast import var, Eq, solve, BC, t

psi = var("psi")
E = sp.Symbol("E")
L = 6
eq = Eq(psi.d(2), (t**2 - 2*E)*psi)
bcs = [BC(psi, t=-L, value=0), BC(psi, t=L, value=0),
       BC(psi.d(), t=-L, value=1e-6)]

x = np.linspace(-L, L, 400)
plt.figure(figsize=(8, 4))
for n in range(4):
    hermite = np.polynomial.hermite.hermval(x, [0]*n + [1]) * np.exp(-x**2/2)
    sol = solve(eq, bvp=bcs, tspan=(-L, L), unknowns={E: n + 0.4}, mesh=200,
                tol=1e-6, guess={psi: lambda s: np.interp(s, x, hermite)})
    energy = sol.unknowns[E]
    values = sol.eval(psi, x)
    values /= np.sqrt(np.sum(values**2) * (x[1] - x[0]))
    plt.plot(x, energy + 0.5*values, label=f"$E_{n} = {energy:.4f}$")
    plt.axhline(energy, color="0.8", linewidth=0.8)
plt.plot(x, x**2/2, "k:", label="$V = x^2/2$")
plt.ylim(0, 4.5)
plt.xlim(-4, 4)
plt.xlabel("$x$")
plt.legend(loc="upper center", ncol=3, fontsize="small")
plt.tight_layout()

(png, svg)

_images/examples-8.png

Differential-algebraic equations

Pendulum in Cartesian coordinates

A pendulum of length \(L = 1\) described by the position \((x, y)\) of the bob and the string tension \(\lambda\) (per unit mass and length):

\[\ddot x = -\lambda x, \qquad \ddot y = -\lambda y - g, \qquad x^2 + y^2 = L^2.\]

The constraint does not contain \(\lambda\); odecast differentiates it twice (index 3). The result is compared with the pendulum in polar form, \(\ddot\theta + g\sin\theta = 0\), for which the tension is \(\lambda = g\cos\theta + \dot\theta^2\).

import numpy as np
import matplotlib.pyplot as plt
import sympy as sp
from odecast import var, Eq, solve

g, theta0 = 9.81, np.pi / 3

x, y, lam = var("x"), var("y"), var("lam")
eqs = [Eq(x.d(2), -lam*x), Eq(y.d(2), -lam*y - g), Eq(x**2 + y**2, 1)]
ic = {x: np.sin(theta0), x.d(): 0.0, y: -np.cos(theta0), y.d(): 0.0}
sol = solve(eqs, ivp=ic, tspan=(0, 10), rtol=1e-8, atol=1e-10)

theta = var("theta")
polar = solve(Eq(theta.d(2) + g*sp.sin(theta), 0),
              ivp={theta: theta0, theta.d(): 0.0}, tspan=(0, 10),
              rtol=1e-8, atol=1e-10)

t = np.linspace(0, 10, 1000)
tension = g*np.cos(polar.eval(theta, t)) + polar.eval(theta.d(), t)**2

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))
ax1.plot(t, sol.eval(lam, t), label="Cartesian (DAE)")
ax1.plot(t, tension, "k:", label="polar")
ax1.set_xlabel("$t$")
ax1.set_title(r"Tension $\lambda$")
ax1.set_ylim(3, 24)
ax1.legend(loc="upper center", ncol=2)

ax2.semilogy(t, np.abs(sol.constraint_residuals(t)[0]) + 1e-17)
ax2.set_xlabel("$t$")
ax2.set_title(r"Constraint violation $|x^2 + y^2 - 1|$")
plt.tight_layout()

(png, svg)

_images/examples-9.png

The constraint violation stays at the level of the solver tolerance.

Tank draining through a pipe

A tank with cross-section \(A_t\) drains through a pipe of length \(\ell\) and diameter \(D\). The water level \(h\) falls as

\[A_t \dot h = -A_p v,\]

where the outflow velocity \(v\) follows from the energy balance with pipe friction,

\[g h = \left(1 + K + f\frac{\ell}{D}\right)\frac{v^2}{2},\]

and the friction factor \(f\) from the Colebrook equation for turbulent flow,

\[\frac{1}{\sqrt f} = -2\log_{10}\left(\frac{\varepsilon}{3.7 D} + \frac{2.51}{\mathrm{Re}\sqrt f}\right), \qquad \mathrm{Re} = \frac{vD}{\nu}.\]

The algebraic equations for \(v\) and \(f\) have no closed-form solution, so odecast solves them numerically at every step. The starting guess for the first step is taken from ivp. The integration stops when the level reaches 0.5 m.

import numpy as np
import matplotlib.pyplot as plt
import sympy as sp
from odecast import var, Eq, Event, solve

g = 9.81
A_t, D, ell = 1.0, 0.05, 20.0      # m^2, m, m
eps, nu, K = 1e-4, 1e-6, 0.5       # roughness (m), viscosity (m^2/s), inlet loss
A_p = np.pi * D**2 / 4

h, v, f = var("h"), var("v"), var("f")
Re = v * D / nu
eqs = [
    Eq(A_t * h.d(), -A_p * v),
    Eq(g * h, (1 + K + f * ell / D) * v**2 / 2),
    Eq(1 / sp.sqrt(f), -2 * sp.log(eps / (3.7 * D) + 2.51 / (Re * sp.sqrt(f)), 10)),
]
sol = solve(eqs, ivp={h: 2.0, v: 1.0, f: 0.02}, tspan=(0, 2000),
            events=Event(Eq(h, 0.5), terminal=True))

t = np.linspace(0, sol.t[-1], 300)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))
ax1.plot(t, sol.eval(h, t))
ax1.set_xlabel("$t$ [s]")
ax1.set_title("Water level $h$ [m]")
ax2.plot(t, sol.eval(f, t))
ax2.set_xlabel("$t$ [s]")
ax2.set_title("Friction factor $f$")
plt.tight_layout()

print(f"level 0.5 m reached after {sol.t_events[0][0]:.0f} s")

(png, svg)

_images/examples-10.png

As the flow slows down, the Reynolds number falls and the friction factor rises.

Analysis

Phase portrait of the damped pendulum

The damped pendulum \(\ddot\theta + 0.3\,\dot\theta + \sin\theta = 0\) has equilibria at \(\theta = k\pi\). The downward position is a stable focus, the upward one a saddle. The trajectory starting just beside the upright position \(\theta = -\pi\) swings through, settles at \(0\), and shows how the saddles separate the basins of attraction.

import numpy as np
import matplotlib.pyplot as plt
import sympy as sp
from odecast import var, Eq, phase_portrait

theta = var("theta")
plt.figure(figsize=(8, 4.5))
phase_portrait(
    Eq(theta.d(2) + 0.3*theta.d() + sp.sin(theta), 0),
    xlim=(-4, 7), ylim=(-3, 3),
    trajectories=[{theta: -np.pi + 0.01, theta.d(): 0},
                  {theta: -np.pi, theta.d(): 3.2}],
)
plt.tight_layout()

(png, svg)

_images/examples-11.png

The second trajectory starts with enough energy to pass over the top at \(\pi\) once and settles at \(2\pi\). Only the equilibria \(0\) and \(\pi\) are marked: sympy.solve() returns representatives of periodic solutions.

Hopf bifurcation in the Brusselator

The Brusselator is a model of an oscillating chemical reaction,

\[\dot x = a - (b + 1)\,x + x^2 y, \qquad \dot y = b\,x - x^2 y.\]

Its equilibrium \((a, b/a)\) loses stability in a Hopf bifurcation at \(b = 1 + a^2\), where a stable limit cycle appears. The continuation finds the bifurcation; the limit cycles beyond it are computed with periodic_orbit(), and their range of \(x\) is drawn into the bifurcation diagram.

import numpy as np
import matplotlib.pyplot as plt
import sympy as sp
from odecast import var, Eq, continuation, periodic_orbit

a = 1.0
b = sp.Symbol("b")
x, y = var("x"), var("y")
eqs = [Eq(x.d(), a - (b + 1)*x + x**2*y), Eq(y.d(), b*x - x**2*y)]

branch = continuation(eqs, b, start={x: a, y: 1/a, b: 1.0}, p_range=(1, 3.5))
(hopf,) = branch.bifurcations

bs, lows, highs, periods = [], [], [], []
for bv in np.linspace(hopf.param + 0.02, 3.5, 12):
    orbit = periodic_orbit(eqs, ivp={x: 1.2, y: 2.0}, period=6.5,
                           params={b: bv}, transient=60)
    xs = orbit.solution.eval(x, np.linspace(0, orbit.period, 400))
    bs.append(bv)
    lows.append(xs.min())
    highs.append(xs.max())
    periods.append(orbit.period)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))
branch.plot(x, ax=ax1)
ax1.plot(bs, highs, "o-", color="tab:blue", markersize=3, label="limit cycle")
ax1.plot(bs, lows, "o-", color="tab:blue", markersize=3)
ax1.legend(fontsize="small")
ax2.plot(bs, periods, "o-", markersize=3)
ax2.axhline(2*np.pi/a, color="0.6", linestyle=":", label=r"$2\pi/a$")
ax2.set_xlabel("b")
ax2.set_ylabel("period")
ax2.legend(fontsize="small")
plt.tight_layout()

(png, svg)

_images/examples-12.png

Near the bifurcation the period approaches \(2\pi/a\), given by the imaginary part of the critical eigenvalues.

Estimating parameters from measurements

Noisy measurements of a damped oscillation are used to estimate the damping \(c\), the stiffness \(k\) and the initial displacement \(y_0\) of \(y'' + c\,y' + k\,y = 0\). The data are generated here from known values, so the result can be checked.

import numpy as np
import matplotlib.pyplot as plt
import sympy as sp
from odecast import var, Eq, solve, fit

y = var("y")
c, k, y0 = sp.symbols("c k y0")
eq = Eq(y.d(2) + c*y.d() + k*y, 0)

# Synthetic measurements
true = {c: 0.4, k: 3.0, y0: 1.5}
t_data = np.linspace(0, 10, 60)
exact = solve(eq, ivp={y: y0, y.d(): 0}, tspan=(0, 10), params=true)
y_data = exact.eval(y, t_data) + np.random.default_rng(0).normal(0, 0.05, t_data.size)

result = fit(eq, {y: (t_data, y_data)}, guess={c: 1.0, k: 2.0, y0: 1.0},
             ivp={y: y0, y.d(): 0})
print(result)

t = np.linspace(0, 10, 500)
plt.figure(figsize=(8, 3.5))
plt.plot(t_data, y_data, "k.", label="measurements")
plt.plot(t, result.solution.eval(y, t), label="fit")
plt.xlabel("$t$")
plt.legend()
plt.tight_layout()

(png, svg)

_images/examples-13.png

The fitted values deviate from the true ones by less than two standard errors.

Delay equations

Mackey-Glass equation

The Mackey-Glass equation, a model of blood cell production,

\[\dot y = \frac{\beta\, y(t - \tau)}{1 + y(t - \tau)^{10}} - \gamma y,\]

becomes chaotic for long delays. With \(\tau = 17\) the solution is irregular, and the delay embedding \((y(t), y(t - \tau))\) shows a strange attractor.

import numpy as np
import matplotlib.pyplot as plt
from odecast import var, Eq, solve

y = var("y")
tau = 17
eq = Eq(y.d(), 0.2*y.delay(tau)/(1 + y.delay(tau)**10) - 0.1*y)
sol = solve(eq, ivp={y: 1.2}, tspan=(0, 1000), rtol=1e-8, atol=1e-10)

t = np.linspace(300, 1000, 7000)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 3.8),
                               gridspec_kw={"width_ratios": [2, 1]})
ax1.plot(t, sol.eval(y, t), linewidth=0.8)
ax1.set_xlabel("$t$")
ax1.set_ylabel("$y$")
ax2.plot(sol.eval(y, t), sol.eval(y, t - tau), linewidth=0.4)
ax2.set_xlabel("$y(t)$")
ax2.set_ylabel(r"$y(t - \tau)$")
plt.tight_layout()

(png, svg)

_images/examples-14.png

Stochastic equations

Langevin equation

A damped oscillator driven by white noise,

\[\ddot x + \gamma \dot x + \omega^2 x = \sigma \xi(t),\]

reaches a stationary state in which \(x\) has the variance \(\sigma^2 / (2\gamma\omega^2)\). The plot shows sample paths, the ensemble mean and standard deviation, and the variance over time.

import numpy as np
import matplotlib.pyplot as plt
from odecast import var, Eq, solve_sde

gamma, omega, sigma = 0.5, 2.0, 0.4
x = var("x")
sol = solve_sde(Eq(x.d(2) + gamma*x.d() + omega**2*x, 0), noise={x.d(): sigma},
                ivp={x: 0.3, x.d(): 0}, tspan=(0, 20), dt=5e-3, paths=2000,
                seed=1)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 3.8))
sol.plot(x, paths=5, ax=ax1)
ax2.plot(sol.t, sol.std(x)**2, label="ensemble")
ax2.axhline(sigma**2/(2*gamma*omega**2), color="k", linestyle=":",
            label="stationary")
ax2.set_xlabel("$t$")
ax2.set_ylabel("Var $x$")
ax2.legend()
plt.tight_layout()

(png, svg)

_images/examples-15.png

Partial differential equations

Travelling front of the Fisher-KPP equation

The Fisher-KPP equation \(u_t = D u_{xx} + r u (1 - u)\) describes a population that spreads by diffusion and grows logistically. From a localized initial population a front develops that travels at the speed \(2\sqrt{rD}\).

import numpy as np
import matplotlib.pyplot as plt
from odecast import Eq
from odecast.pde import Grid, field, solve_pde, Neumann

x = np.linspace(0, 100, 501)
u = field("u", Grid(x=x))
sol = solve_pde(Eq(u.d(), u.dx(2) + u*(1 - u)),
                ic={u: lambda x: np.where(x < 5, 1.0, 0.0)},
                bc={u: {"x": (Neumann(0), Neumann(0))}},
                tspan=(0, 40))

plt.figure(figsize=(8, 3.5))
sol.plot(u, times=[0, 10, 20, 30, 40])
plt.tight_layout()

(png, svg)

_images/examples-16.png

Plucked string

The wave equation \(u_{tt} = c^2 u_{xx}\) for a string fixed at both ends, plucked in the middle. The initial triangle splits into two waves that travel to the ends and are reflected with opposite sign. The small ripples behind the corners are numerical dispersion of the finite differences; they shrink on a finer grid.

import numpy as np
import matplotlib.pyplot as plt
from odecast import Eq
from odecast.pde import Grid, field, solve_pde, Dirichlet

x = np.linspace(0, 1, 201)
u = field("u", Grid(x=x))
sol = solve_pde(Eq(u.d(2), u.dx(2)),
                ic={u: lambda x: 0.1*(1 - np.abs(2*x - 1)), u.d(): 0},
                bc={u: {"x": (Dirichlet(0), Dirichlet(0))}},
                tspan=(0, 1), rtol=1e-6, atol=1e-9)

plt.figure(figsize=(8, 3.5))
sol.plot(u, times=[0, 0.125, 0.25, 0.5, 0.75])
plt.tight_layout()

(png, svg)

_images/examples-17.png

Symbolic solutions

Initial conditions

With conditions, the SymPy backend determines the integration constants:

import sympy as sp
from odecast import var, Eq, solve, t

# Driven oscillator at resonance: y'' + y = cos(t), y(0) = y'(0) = 0
y = var("y")
sol = solve(Eq(y.d(2) + y, sp.cos(t)), backend="sympy", ivp={y: 0, y.d(): 0})
sol.as_expr(y)   # t*sin(t)/2

The amplitude grows linearly in time.

General solution of a linear equation

  1#!/usr/bin/env python3
  2"""
  3Example 2: Symbolic Solution (SymPy Backend)
  4
  5This example demonstrates solving a simple ODE symbolically using the SymPy backend
  6to obtain a closed-form analytical solution.
  7
  8The equation: y'' + y = 0 (simple harmonic oscillator)
  9
 10This is a classic example where symbolic solution provides exact expressions
 11rather than numerical approximations.
 12"""
 13
 14import sympy as sp
 15
 16from odecast import Eq, solve, t, var
 17
 18
 19def main():
 20    print("Symbolic Solution Example")
 21    print("=" * 40)
 22
 23    # Define the dependent variable
 24    y = var("y")
 25
 26    # Create the differential equation: y'' + y = 0
 27    eq = Eq(y.d(2) + y, 0)
 28    print(f"Equation: {eq}")
 29    print("This is the simple harmonic oscillator equation.")
 30
 31    # Solve symbolically using SymPy backend
 32    print("\nSolving symbolically...")
 33    sol = solve(eq, backend="sympy")
 34
 35    # Get the symbolic expression
 36    expr = sol.as_expr(y)
 37    print(f"Symbolic solution: y(t) = {expr}")
 38
 39    # Verify the solution by substituting back into the ODE
 40    print("\nVerification:")
 41    t_sym = t.symbol  # Get the SymPy symbol for t
 42
 43    # Compute derivatives
 44    y_expr = expr
 45    yprime_expr = sp.diff(y_expr, t_sym)
 46    ydoubleprime_expr = sp.diff(y_expr, t_sym, 2)
 47
 48    print(f"y(t)    = {y_expr}")
 49    print(f"y'(t)   = {yprime_expr}")
 50    print(f"y''(t)  = {ydoubleprime_expr}")
 51
 52    # Check if y'' + y = 0
 53    lhs = ydoubleprime_expr + y_expr
 54    simplified = sp.simplify(lhs)
 55    print(f"y''(t) + y(t) = {lhs} = {simplified}")
 56
 57    if simplified == 0:
 58        print("The solution satisfies the ODE.")
 59    else:
 60        print("The solution does NOT satisfy the ODE.")
 61
 62    # Show general form with constants
 63    print("\nGeneral solution form:")
 64    print("The solution contains arbitrary constants C1 and C2,")
 65    print("which would be determined by initial or boundary conditions.")
 66    print("General form: y(t) = C1*cos(t) + C2*sin(t)")
 67
 68    # Demonstrate symbolic manipulation
 69    print("\nSymbolic manipulation examples:")
 70
 71    # Extract coefficients if possible
 72    try:
 73        # Try to collect terms (this depends on SymPy's output format)
 74        cos_coeff = expr.coeff(sp.cos(t_sym), 1)
 75        sin_coeff = expr.coeff(sp.sin(t_sym), 1)
 76
 77        if cos_coeff is not None:
 78            print(f"Coefficient of cos(t): {cos_coeff}")
 79        if sin_coeff is not None:
 80            print(f"Coefficient of sin(t): {sin_coeff}")
 81    except Exception:
 82        print("Coefficients depend on the specific form SymPy returns")
 83
 84    # Show derivative relationships
 85    print("\nDerivative relationship:")
 86    print("If y(t) = A*cos(t) + B*sin(t), then:")
 87    print("y'(t) = -A*sin(t) + B*cos(t)")
 88    print("y''(t) = -A*cos(t) - B*sin(t) = -y(t)")
 89    print("Therefore: y''(t) + y(t) = 0 ")
 90
 91    # Demonstrate evaluation at specific points
 92    print("\nSymbolic evaluation:")
 93    eval_points = [0, sp.pi / 4, sp.pi / 2, sp.pi]
 94    for t_val in eval_points:
 95        y_val = expr.subs(t_sym, t_val)
 96        y_val_simplified = sp.simplify(y_val)
 97        print(f"y({t_val}) = {y_val_simplified}")
 98
 99
100if __name__ == "__main__":
101    main()

Mixed-order systems

  1#!/usr/bin/env python3
  2"""
  3Example 3: Coupled Mixed-Order System
  4
  5This example demonstrates solving a system of coupled differential equations
  6with different orders using the numeric backend.
  7
  8System:
  9- x'' + 0.1*x' + x - 0.5*y = 0  (second-order in x)
 10- y' + 2*y + 0.3*x = 0          (first-order in y)
 11
 12This represents a coupled oscillator system where one variable affects the other.
 13"""
 14
 15import matplotlib.pyplot as plt
 16
 17from odecast import Eq, solve, var
 18
 19
 20def main():
 21    print("Coupled Mixed-Order System Example")
 22    print("=" * 45)
 23
 24    # Define the dependent variables
 25    x = var("x")
 26    y = var("y")
 27
 28    # Create the system of coupled equations
 29    eq1 = Eq(x.d(2) + 0.1 * x.d() + x - 0.5 * y, 0)  # Second-order in x
 30    eq2 = Eq(y.d() + 2 * y + 0.3 * x, 0)  # First-order in y
 31
 32    equations = [eq1, eq2]
 33
 34    print("System of equations:")
 35    for i, eq in enumerate(equations, 1):
 36        print(f"  Eq {i}: {eq}")
 37
 38    print("\nVariable orders:")
 39    print("  x: 2nd order (requires x(0) and x'(0))")
 40    print("  y: 1st order (requires y(0))")
 41
 42    # Set initial conditions
 43    initial_conditions = {
 44        x: 1.0,  # x(0) = 1
 45        x.d(): 0.0,  # x'(0) = 0
 46        y: 0.5,  # y(0) = 0.5
 47    }
 48
 49    print("\nInitial conditions:")
 50    print(f"  x(0) = {initial_conditions[x]}")
 51    print(f"  x'(0) = {initial_conditions[x.d()]}")
 52    print(f"  y(0) = {initial_conditions[y]}")
 53
 54    # Solve over time span [0, 15] seconds
 55    time_span = (0.0, 15.0)
 56    print(f"\nTime span: {time_span}")
 57
 58    # Solve using the SciPy numeric backend
 59    print("\nSolving coupled system...")
 60    sol = solve(equations, ivp=initial_conditions, tspan=time_span, backend="scipy")
 61
 62    # Extract solution data
 63    t_vals = sol.t
 64    x_vals = sol[x]  # Position x
 65    xprime_vals = sol[x.d()]  # Velocity x'
 66    y_vals = sol[y]  # Variable y
 67
 68    print(f"Solution computed with {len(t_vals)} time points")
 69    print(f"   Final values at t = {t_vals[-1]:.1f}:")
 70    print(f"     x({t_vals[-1]:.1f}) = {x_vals[-1]:.6f}")
 71    print(f"     x'({t_vals[-1]:.1f}) = {xprime_vals[-1]:.6f}")
 72    print(f"     y({t_vals[-1]:.1f}) = {y_vals[-1]:.6f}")
 73
 74    # Create comprehensive plots
 75    _fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(12, 10))
 76
 77    # Plot x position vs time
 78    ax1.plot(t_vals, x_vals, "b-", linewidth=2, label="x(t)")
 79    ax1.set_xlabel("Time (s)")
 80    ax1.set_ylabel("x Position")
 81    ax1.set_title("Variable x vs Time")
 82    ax1.grid(True, alpha=0.3)
 83    ax1.legend()
 84
 85    # Plot y vs time
 86    ax2.plot(t_vals, y_vals, "r-", linewidth=2, label="y(t)")
 87    ax2.set_xlabel("Time (s)")
 88    ax2.set_ylabel("y Value")
 89    ax2.set_title("Variable y vs Time")
 90    ax2.grid(True, alpha=0.3)
 91    ax2.legend()
 92
 93    # Plot x velocity vs time
 94    ax3.plot(t_vals, xprime_vals, "g-", linewidth=2, label="x'(t)")
 95    ax3.set_xlabel("Time (s)")
 96    ax3.set_ylabel("x Velocity")
 97    ax3.set_title("Velocity of x vs Time")
 98    ax3.grid(True, alpha=0.3)
 99    ax3.legend()
100
101    # Plot phase space for x (position vs velocity)
102    ax4.plot(x_vals, xprime_vals, "m-", linewidth=2, alpha=0.8)
103    ax4.plot(x_vals[0], xprime_vals[0], "go", markersize=8, label="Start")
104    ax4.plot(x_vals[-1], xprime_vals[-1], "rs", markersize=8, label="End")
105    ax4.set_xlabel("x Position")
106    ax4.set_ylabel("x Velocity (x')")
107    ax4.set_title("Phase Space of x")
108    ax4.grid(True, alpha=0.3)
109    ax4.legend()
110
111    plt.tight_layout()
112    plt.show()
113
114    # Show coupling effects
115    plt.figure(figsize=(10, 6))
116    plt.plot(t_vals, x_vals, "b-", linewidth=2, label="x(t)", alpha=0.8)
117    plt.plot(t_vals, y_vals, "r-", linewidth=2, label="y(t)", alpha=0.8)
118    plt.xlabel("Time (s)")
119    plt.ylabel("Value")
120    plt.title("Coupled Variables x and y")
121    plt.legend()
122    plt.grid(True, alpha=0.3)
123    plt.show()
124
125    # Demonstrate solution evaluation
126    print("\nSolution evaluation at specific times:")
127    eval_times = [0, 3, 6, 9, 12, 15]
128    print("   Time   x(t)      x'(t)     y(t)")
129    print("   " + "-" * 35)
130    for t_eval in eval_times:
131        x_eval = sol.eval(x, t_eval)
132        xprime_eval = sol.eval(x.d(), t_eval)
133        y_eval = sol.eval(y, t_eval)
134        print(f"   {t_eval:4.0f}   {x_eval:8.4f}  {xprime_eval:8.4f}  {y_eval:8.4f}")
135
136    # Show system properties
137    print("\nSystem Analysis:")
138    print("This coupled system demonstrates:")
139    print("  - Mixed orders: x is 2nd order, y is 1st order")
140    print("  - Coupling: x affects y through the +0.3*x term")
141    print("  - Coupling: y affects x through the -0.5*y term")
142    print("  - Damping: x has damping (0.1*x' term), y has decay (2*y term)")
143
144    # Inspect the first-order system
145    print("\nFirst-order system inspection:")
146    _f, _jac, x0, _t0, mapping = sol.as_first_order()
147    print(f"  State vector dimension: {len(x0)}")
148    print("  State mapping:")
149    print(f"    x    -> index {mapping[(x, 0)]}")
150    print(f"    x'   -> index {mapping[(x, 1)]}")
151    print(f"    y    -> index {mapping[(y, 0)]}")
152    print(f"  Initial state vector: {x0}")
153
154
155if __name__ == "__main__":
156    main()

Example scripts

The examples/ directory contains:

01_ivp_damped_oscillator.py

Numerical solution of the damped oscillator, with plots.

02_symbolic_simple.py

Symbolic solution with the SymPy backend.

03_mixed_orders.py

Coupled system with variables of different order.

04_vector_harmonic_oscillator.py

Two-dimensional oscillator using a vector variable.

05_vector_mixed_system.py

System combining scalar and vector variables.

06_vector_simple.py

Introduction to vector variables.

Run a script with, e.g.,

python examples/01_ivp_damped_oscillator.py