odecast is a Python library for solving ordinary differential equations of
arbitrary order. Equations are written in their natural form, e.g.
y'' + 0.3 y' + y = 0, and odecast performs the reduction to a first-order
system that numerical integrators require. Numerical solutions are computed
with SciPy, closed-form solutions with SymPy.
Full documentation: https://maroba.github.io/odecast
pip install odecastThis installs NumPy, SciPy and SymPy. Python 3.10 or later is required.
Plotting and to_pandas() additionally need Matplotlib and pandas
(pip install "odecast[examples]"), partial differential equations need
findiff (pip install "odecast[pde]").
The damped harmonic oscillator y'' + 0.3 y' + y = 0 with y(0) = 1,
y'(0) = 0:
from odecast import var, Eq, solve
y = var("y")
eq = Eq(y.d(2) + 0.3 * y.d() + y, 0)
sol = solve(eq, ivp={y: 1.0, y.d(): 0.0}, tspan=(0, 10))
sol.t # time points
sol[y] # y(t)
sol[y.d()] # y'(t)
sol.eval(y, 5.0)For comparison, the same problem written directly against
scipy.integrate.solve_ivp requires the state vector to be set up by hand:
def rhs(t, z):
y, dy = z
return [dy, -0.3 * dy - y]
solve_ivp(rhs, (0, 10), [1.0, 0.0])This is manageable for a single equation but becomes tedious and error-prone for coupled systems of mixed order, which is the case odecast is designed for.
- Arbitrary order. The order of each variable is inferred from the equations; no manual reduction is needed.
- Coupled systems of mixed order. Pass a list of equations; each variable may appear with a different highest derivative.
- Nonlinear equations. Powers and functions of the unknowns, such as
y**2orsp.sin(y)(ornp.sin(y)), can be used directly. - Vector and matrix variables.
var("u", shape=2)orvar("M", shape=(2, 2))declares an array-valued unknown. Equations such asEq(u.d(2) + u, 0)are expanded component-wise; matrix products are writtenA @ u. - Parameters. SymPy symbols in the equations take values from
params=. The system is compiled once and reused for further values. - Initial and boundary value problems. Events are located during integration and can reset the state (impacts, switching, dosing). Two-point boundary value problems may have unknown parameters, e.g. eigenvalues.
- Differential-algebraic equations. Variables without derivatives are
algebraic. Constraints such as
Eq(x**2 + y**2, L**2)are handled by index reduction with stabilization; algebraic equations without closed-form solution are solved numerically. - Stiff problems. Implicit solvers receive the exact Jacobian.
- Symbolic solutions. The SymPy backend returns closed-form solutions, with initial or boundary conditions applied.
- Sensitivities. Derivatives of the solution with respect to parameters, computed exactly alongside the solution.
- Analysis. Equilibria and their stability, linearization and transfer functions, phase portraits, continuation of equilibria with detection of bifurcations, periodic orbits with Floquet multipliers, and fitting parameters to measured data.
- Delay and stochastic equations.
y.delay(tau)for delayed values;solve_sde()for equations with noise (Euler-Maruyama, Milstein). - Partial differential equations. Time-dependent PDEs on grids in one to three dimensions, with the method of lines and finite differences from findiff.
- Validation. Missing, superfluous or inconsistent conditions are reported before solving, with a message naming the offending condition.
x, y = var("x"), var("y")
eqs = [
Eq(x.d() - x * (1 - y), 0),
Eq(y.d() - y * (x - 1), 0),
]
sol = solve(eqs, ivp={x: 1.0, y: 0.5}, tspan=(0, 15))The independent variable is available as odecast.t:
import sympy as sp
from odecast import t
theta = var("theta")
eq = Eq(theta.d(2) + 0.1 * theta.d() + sp.sin(theta), 0.5 * sp.cos(2 * t))u = var("u", shape=2)
eq = Eq(u.d(2) + 0.1 * u.d() + u, 0)
sol = solve(eq, ivp={u: [1.0, 0.0], u.d(): [0.0, 1.0]}, tspan=(0, 10))
sol[u] # array of shape (2, n)
sol[u[0]] # array of shape (n,)from odecast import BC
y = var("y")
sol = solve(
Eq(y.d(2) + y, 0),
bvp=[BC(y, t=0, value=0), BC(y, t=1, value=1)],
tspan=(0, 1),
)
sol.eval(y, 0.5)For nonlinear problems an initial guess can be passed, e.g.
guess={y: lambda t: t * (1 - t)}.
sol = solve(Eq(y.d(2) + y, 0), backend="sympy", ivp={y: 1, y.d(): 0})
sol.as_expr(y) # cos(t)
sol.eval(y, 1.0) # 0.5403...Without conditions the general solution is returned, e.g.
C1*sin(t) + C2*cos(t).
SymPy symbols in the equations are parameters. Solving again with other values reuses the compiled system, so parameter studies are fast:
import numpy as np
u = var("u", shape=2)
k = sp.Symbol("k")
A = np.array([[0, 1], [-k, -0.3]], dtype=object)
for kv in [1.0, 2.0, 4.0]:
sol = solve(Eq(u.d(), A @ u), ivp={u: [1, 0]}, tspan=(0, 10), params={k: kv})A pendulum in Cartesian coordinates, with the string tension lam as an
algebraic variable:
x, y, lam = var("x"), var("y"), var("lam")
eqs = [
Eq(x.d(2), -lam * x),
Eq(y.d(2), -lam * y - 9.81),
Eq(x**2 + y**2, 1),
]
sol = solve(eqs, ivp={x: 0.6, x.d(): 0, y: -0.8, y.d(): 0}, tspan=(0, 10))
sol[lam] # tension
sol.constraint_residuals() # remaining violation of x**2 + y**2 = 1from odecast import Event
y = var("y")
sol = solve(
Eq(y.d(2), -9.81),
ivp={y: 10, y.d(): 0},
tspan=(0, 10),
events=[Event(Eq(y, 0), terminal=True)], # stop when y reaches 0
)
sol.t_events[0] # [1.428]from odecast import equilibria, fit
x, y = var("x"), var("y")
equilibria([Eq(x.d(), x - x * y), Eq(y.d(), x * y - y)])
# [Equilibrium(x=0, y=0, saddle), Equilibrium(x=1, y=1, center)]
c, k = sp.symbols("c k")
result = fit(Eq(y.d(2) + c * y.d() + k * y, 0), {y: (t_data, y_data)},
guess={c: 1.0, k: 1.0}, ivp={y: 1, y.d(): 0})
result.params, result.stderrfrom odecast import equilibria, linearize, continuation, periodic_orbit, phase_portrait
x, y = var("x"), var("y")
equilibria([Eq(x.d(), x - x * y), Eq(y.d(), x * y - y)])
# [Equilibrium(x=0, y=0, saddle), Equilibrium(x=1, y=1, center)]
F = sp.Symbol("F")
linearize(Eq(2 * x.d(2) + 0.5 * x.d() + 8 * x, F), around={x: 0},
inputs=[F], outputs=[x]).transfer_function()
# Matrix([[0.5/(s**2 + 0.25*s + 4.0)]])
r = sp.Symbol("r")
continuation(Eq(x.d(), r + x - x**3 / 3), r, start={x: -2.5, r: -3},
p_range=(-3, 3)).bifurcations
# [Bifurcation(fold at param=0.666667, x=-1), Bifurcation(fold at param=-0.666667, x=1)]
v = var("v")
periodic_orbit(Eq(v.d(2) - (1 - v**2) * v.d() + v, 0), ivp={v: 2, v.d(): 0},
period=6.5, transient=20)
# PeriodicOrbit(period=6.66329, stable)phase_portrait() draws direction field, nullclines, equilibria and
trajectories of planar systems; fit() estimates parameters from data,
with exact derivatives from solve(..., sensitivities=[...]).
from odecast import solve_sde
y = var("y")
solve(Eq(y.d(), 1.8 * y * (1 - y.delay(1))), ivp={y: 0.5}, tspan=(0, 50))
sol = solve_sde(Eq(y.d(), 0.1 * y), noise={y: 0.3 * y}, ivp={y: 1.0},
tspan=(0, 1), dt=1e-3, paths=1000, method="milstein")
sol.mean(y), sol.std(y)With pip install "odecast[pde]", time-dependent PDEs are solved with the
method of lines, using finite differences from
findiff:
from odecast.pde import Grid, field, solve_pde, Neumann
u = field("u", Grid(x=np.linspace(0, 100, 501)))
sol = solve_pde(
Eq(u.d(), u.dx(2) + u * (1 - u)), # Fisher-KPP
ic={u: lambda x: np.where(x < 5, 1.0, 0.0)},
bc={u: {"x": (Neumann(0), Neumann(0))}},
tspan=(0, 40),
)sol.plot() plots the solution with Matplotlib, sol.to_pandas() returns
a DataFrame, and in Jupyter equations and symbolic solutions are displayed
as formulas.
Additional keyword arguments to solve are passed to
scipy.integrate.solve_ivp (or solve_bvp for boundary value problems):
solve(eq, ivp=ic, tspan=(0, 10), method="LSODA", rtol=1e-9, t_eval=t_points)odecast is at an early stage (alpha). In particular:
- Boundary value problems must be two-point problems: all conditions lie at
the end points of
tspan. - Functions must be applied to components of vector variables
(
sp.sin(u[0])), not to the vector as a whole. - Matrix products require one constant operand:
A @ uworks, a product of two variables such asM @ udoes not. - The SymPy backend can solve what
sympy.dsolvecan solve: single equations and linear systems, but generally not nonlinear coupled systems. - Differential-algebraic systems are reduced by differentiating constraints
one at a time; structures that need the Pantelides algorithm are not
supported. Boundary value problems, the SymPy backend and
equilibria()support only algebraic equations that are linear in the algebraic variables and determine them directly (index 1). - Delay equations must have constant delays and may not delay the highest derivative (neutral equations).
- The analysis functions find equilibria with
sympy.solve; for periodic nonlinearities such assinonly representatives are returned.
The examples/ directory contains runnable scripts, e.g.
python examples/01_ivp_damped_oscillator.pyMore examples with plots are in the documentation.
git clone https://github.com/maroba/odecast.git
cd odecast
pip install -e ".[dev]"
pytest
ruff check .
black --check .See the contributing guide for details. Bug reports and pull requests are welcome.
MIT. See LICENSE.
