User Guide
Systems of equations
A system is passed to solve() as a list of equations. The
variables need not have the same order; odecast determines the highest
derivative of each variable separately and builds the first-order system
accordingly.
from odecast import var, Eq, solve
x, z = var("x"), var("z")
eqs = [
Eq(x.d(2) + z, 0), # second order in x
Eq(z.d() - x, 0), # first order in z
]
sol = solve(eqs, ivp={x: 1.0, x.d(): 0.0, z: 0.0}, tspan=(0, 5))
Each equation must be solvable for the highest derivative of one variable.
Declaring the order
The order of a variable may be declared explicitly. This is optional and
serves as a consistency check: if an equation uses a higher derivative, an
OrderMismatchError is raised.
w = var("w", order=1)
Vector and matrix variables
A variable with shape represents a vector- or matrix-valued function.
Its components are scalar variables and can be used anywhere a scalar
variable can:
u = var("u", shape=2)
u[0], u[1] # components
u.d(), u.d(2) # vector derivatives
M = var("M", shape=(2, 3))
M[1, 2] # component in row 1, column 2
An equation involving a vector variable as a whole is expanded into one equation per component. The following are equivalent:
Eq(u.d(2) + u, 0)
[Eq(u[0].d(2) + u[0], 0),
Eq(u[1].d(2) + u[1], 0)]
Arithmetic operations are element-wise, and all array-valued terms in one
equation must have the same shape. Scalars and scalar variables are
broadcast. Functions such as sin must be applied to components,
sp.sin(u[0]), not to the variable as a whole.
Matrix products are written with @. The matrix may be a NumPy array, a
nested list or a SymPy matrix, and may contain parameters (see
Parameters):
A = np.array([[0.0, 1.0], [-2.0, -0.3]])
eq = Eq(u.d(), A @ u) # u' = A u
K = np.array([[2, -1], [-1, 2]])
eq = Eq(u.d(2) + K @ u, 0) # coupled oscillators
M = var("M", shape=(2, 2))
eq = Eq(M.d(), A @ M) # matrix equation M' = A M
Initial conditions for array-valued variables are given as (nested) sequences or NumPy arrays of matching shape:
sol = solve(
Eq(u.d(2) + u, 0),
ivp={u: [1.0, 0.5], u.d(): [0.0, -0.2]},
tspan=(0, 10),
)
sol[u] # shape (2, n)
sol[u[0]] # shape (n,)
For a matrix variable, sol[M] has shape (2, 3, n).
Scalar and vector variables can be mixed in one system:
x = var("x")
u = var("u", shape=2)
eqs = [
Eq(x.d(2) + x - u[0], 0),
Eq(u[0].d() + u[1], x),
Eq(u[1].d() + u[0], 0),
]
sol = solve(eqs, ivp={x: 1.0, x.d(): 0.0, u: [0.5, 0.0]}, tspan=(0, 5))
Nonlinear and time-dependent terms
Equations may contain powers and functions of the unknowns. Both SymPy and NumPy functions can be used:
import numpy as np
import sympy as sp
# Van der Pol oscillator: y'' - mu (1 - y^2) y' + y = 0
y = var("y")
mu = 1.0
eq = Eq(y.d(2) - mu*(1 - y**2)*y.d() + y, 0)
# Pendulum
theta = var("theta")
eq = Eq(theta.d(2) + sp.sin(theta), 0) # or np.sin(theta)
The independent variable is exported as odecast.t and can be used in
the same way:
from odecast import t
eq = Eq(y.d(2) + y, sp.cos(2*t)) # driven oscillator
sol = solve(eq, ivp={y: 0.0, y.d(): 1.0}, tspan=(0, 10))
The SymPy symbol for \(t\) is t.symbol, which is useful when working
with the expressions returned by the SymPy backend.
Each equation must be solvable for the highest derivative of one of the
variables; implicit equations such as sp.sin(y.d()) = y are solved with
sympy.solve(), and the first solution is used.
Parameters
SymPy symbols in the equations are parameters. Their values are passed to
solve() with params, keyed by symbol or by name:
k, c = sp.symbols("k c")
eq = Eq(y.d(2) + c*y.d() + k*y, 0)
sol = solve(eq, ivp={y: 1, y.d(): 0}, tspan=(0, 10), params={k: 4.0, c: 0.1})
Initial and boundary values may contain parameters as well, e.g.
ivp={y: y0, y.d(): 0} with params={y0: 2.0, ...}.
The equations are reduced and compiled once; solving them again with other parameter values reuses the compiled system. This makes parameter studies cheap:
for kv in np.linspace(1, 4, 100):
sol = solve(eq, ivp={y: 1, y.d(): 0}, tspan=(0, 10), params={k: kv, c: 0.1})
The SymPy backend keeps parameters without a value symbolic. Declaring assumptions helps SymPy simplify the result:
k = sp.Symbol("k", positive=True)
sol = solve(Eq(y.d(2) + k*y, 0), backend="sympy", ivp={y: 1, y.d(): 0})
sol.as_expr(y) # cos(sqrt(k)*t)
Sensitivities
The derivatives of the solution with respect to parameters show how sensitive it is to them. They are computed together with the solution, from the exact Jacobians:
k = sp.Symbol("k")
sol = solve(Eq(y.d(2) + k*y, 0), ivp={y: 1, y.d(): 0}, tspan=(0, 10),
params={k: 4.0}, sensitivities=[k])
sol.sensitivity(y, k) # dy/dk at sol.t
sol.sensitivity(y, k, t=5.0) # dy/dk at t = 5
Parameters that appear in the initial values are taken into account, e.g.
ivp={y: y0, ...} with sensitivities=[y0]. Sensitivities are
available for initial value problems without event resets, and not for
algebraic variables. fit() uses them for exact derivatives of
the residuals.
Boundary value problems
A two-point boundary value problem specifies conditions at both ends of the
interval tspan. Conditions are given as a list of BC
objects:
from odecast import BC
# y'' + y = 0, y(0) = 0, y(1) = 1
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)
Conditions may involve derivatives below the highest order, e.g.
BC(y.d(), t=0, value=0), and array-valued variables, e.g.
BC(u, t=0, value=[0, 1]). The total number of conditions must equal the
total order of the system.
The problem is solved with scipy.integrate.solve_bvp(). Further
keyword arguments are passed on (tol, max_nodes, bc_tol), and two
more are available:
meshNumber of nodes of the initial mesh (default 50), or the mesh itself.
guessInitial guess, keyed like an
ivpdictionary. Values may be numbers, arrays over the mesh, or functions of \(t\). States without a guess start at zero.
Nonlinear boundary value problems may have several solutions, and the guess determines which one is found. The Bratu problem \(y'' + e^y = 0\), \(y(0) = y(1) = 0\) has two:
eq = Eq(y.d(2) + sp.exp(y), 0)
bcs = [BC(y, t=0, value=0), BC(y, t=1, value=0)]
lower = solve(eq, bvp=bcs, tspan=(0, 1)) # y(0.5) = 0.14
upper = solve(eq, bvp=bcs, tspan=(0, 1),
guess={y: lambda t: 12*t*(1 - t)}) # y(0.5) = 4.09
Unknown parameters
Parameters can be determined together with the solution by passing them as
unknowns with a starting value. Each unknown requires one boundary
condition in addition to one per first-order state. This covers eigenvalue
problems:
# y'' + lam y = 0, y(0) = y(pi) = 0; y'(0) = 1 fixes the amplitude
lam = sp.Symbol("lam")
sol = solve(
Eq(y.d(2) + lam*y, 0),
bvp=[BC(y, t=0, value=0), BC(y, t=np.pi, value=0), BC(y.d(), t=0, value=1)],
tspan=(0, np.pi),
unknowns={lam: 3.8},
guess={y: lambda t: np.sin(2*t) / 2},
)
sol.unknowns # {lam: 4.000...}
The starting values of the unknowns and the guess for the solution select which eigenvalue is found.
Differential-algebraic equations
A variable that occurs without derivatives is algebraic. It is determined by the equations that contain no highest derivative, the algebraic equations. No special syntax is needed:
# Fast equilibrium A <=> B (B = K A) followed by a slow reaction B -> C.
# T = A + B is the total amount of A and B.
T, C, A, B = var("T"), var("C"), var("A"), var("B")
k, K = 0.5, 3.0
eqs = [
Eq(T.d(), -k*B),
Eq(C.d(), k*B),
Eq(B, K*A), # algebraic
Eq(A + B, T), # algebraic
]
sol = solve(eqs, ivp={T: 1.0, C: 0.0}, tspan=(0, 10))
sol[B] # algebraic variables are part of the solution
Initial conditions are only needed for the differential variables. The number of algebraic equations must equal the number of algebraic variables.
odecast reduces the system to ordinary differential equations in three ways, depending on its structure.
- Algebraic variables determined directly (index 1)
If the algebraic equations can be solved for the algebraic variables and are linear in them, as above, the algebraic variables are eliminated symbolically. The result is an ordinary system; everything else (events, parameters, the SymPy backend, equilibria, fitting) works as usual.
- Constraints on the states (higher index)
An algebraic equation may not contain any algebraic variable at all. The classic example is a pendulum of length \(L\) in Cartesian coordinates, where the string tension \(\lambda\) (per unit mass and length) keeps the bob on the circle:
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), # constraint without lam ] sol = solve(eqs, ivp={x: 0.6, x.d(): 0, y: -0.8, y.d(): 0}, tspan=(0, 10))
Such a constraint is differentiated with respect to \(t\) until an algebraic variable appears: here twice, after which \(\lambda\) follows from the equations of motion. The index of the system is the number of differentiations plus one; the pendulum has index 3.
The initial values must satisfy the constraint and the derivatives that were taken, i.e. the bob starts on the circle with a tangential velocity. odecast checks this and reports inconsistent initial values.
Numerically, only the differentiated constraint is enforced, so the solution would slowly drift away from the original one. odecast damps this drift with Baumgarte stabilization: the \(k\)-th derivative \(c^{(k)} = 0\) of a constraint \(c\) is replaced by \((d/dt + \alpha)^k c = 0\). The rate \(\alpha\) is chosen as five times the largest absolute eigenvalue of the Jacobian at the initial point, so that violations decay faster than the dynamics evolve.
solve(..., baumgarte=alpha)sets it explicitly;baumgarte=0disables the stabilization. The remaining violation is available assol.constraint_residuals(); it is of the order of the solver tolerance.- Algebraic equations without closed-form solution
If the (possibly differentiated) algebraic equations are not linear in the algebraic variables, they are solved numerically with Newton’s method each time the right-hand side is evaluated, starting from the previous solution. Values given for algebraic variables in
ivpare used as the starting guess at the initial time. Where an equation has several solutions, the guess selects one:y, z = var("y"), var("z") eqs = [Eq(y.d(), -z), Eq(z**2, y)] solve(eqs, ivp={y: 1.0, z: 0.5}, tspan=(0, 1)) # follows z = +sqrt(y) solve(eqs, ivp={y: 1.0, z: -0.5}, tspan=(0, 1)) # follows z = -sqrt(y)
For implicit solvers, the Jacobian is computed from the implicit function theorem. If the algebraic equations have no solution at some point, the integration stops with an error that names the time.
Limitations:
Only constraints that can be differentiated one at a time are reduced. Systems whose algebraic equations remain singular after this (which would require the Pantelides algorithm) are rejected with an error.
Boundary value problems, the SymPy backend and
equilibria()support only the first case (index 1, linear).Events may involve algebraic variables only if they are eliminated symbolically.
Events
An event is a condition whose occurrences are located during integration,
for example the time at which a falling body reaches the ground. Events are
passed to solve() as a list of Event
objects, or simply as equations:
from odecast import Event
y = var("y")
hits_ground = Event(Eq(y, 0), terminal=True, direction=-1)
sol = solve(Eq(y.d(2), -9.81), ivp={y: 10, y.d(): 0}, tspan=(0, 10),
events=[hits_ground])
sol.t_events[0] # [1.428]
sol.event_values(0, y.d()) # velocity at impact: [-14.01]
An event occurs where the difference of the two sides of its equation
changes sign. terminal=True stops the integration at the first
occurrence; direction restricts events to crossings from negative to
positive (1) or from positive to negative (-1). Conditions may involve
variables, their derivatives below the highest order, \(t\) and
parameters. Plain functions g(t, x) of the state vector, as accepted by
scipy.integrate.solve_ivp(), are passed through unchanged. An event is
not triggered at the very start of the integration.
Resets
An event with reset changes the state when it occurs and then continues
the integration; this describes impacts, switching and dosing. The new
values are expressions in the values just before the event:
# A ball that loses 20% of its speed at every bounce
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, y.d(): 0}, tspan=(0, 3),
events=bounce)
sol.t_events[0] # times of the bounces
# A dose at t = 1, 2, 3, ... (the zeros of sin(pi t))
c = var("c")
dose = Event(Eq(sp.sin(sp.pi*t), 0), reset={c: c + 1})
sol = solve(Eq(c.d(), -0.5*c), ivp={c: 1}, tspan=(0, 10), events=dose)
The solution, including sol.eval, is discontinuous at the events. Only
states (variables and derivatives below the highest order) can be reset.
If events accumulate, as for a bouncing ball coming to rest, the integration
stops with an error after many resets; shorten tspan or add a terminal
event.
Backends
The backend argument of solve() selects the solver.
"scipy"(default)Numerical solution with
scipy.integrate.solve_ivp()for initial value problems andscipy.integrate.solve_bvp()for boundary value problems. Requiresivporbvp, andtspan. Further keyword arguments tosolveare passed on to the SciPy solver, for examplemethod,rtol,atol,max_step,first_steport_eval:solve(eq, ivp=ic, tspan=(0, 10), method="Radau", rtol=1e-8)
For the implicit methods
"Radau","BDF"and"LSODA", which are suited for stiff problems, odecast supplies the exact Jacobian, computed symbolically."sympy"Symbolic solution with
sympy.dsolve(). Initial or boundary conditions, if given, determine the integration constants; initial conditions apply attspan[0]iftspanis given, otherwise at \(t = 0\). Conditions may also be incomplete, in which case the remaining constants stay in the result.sol = solve(Eq(y.d(2) + y, 0), backend="sympy") sol.as_expr(y) # C1*sin(t) + C2*cos(t) sol = solve(Eq(y.d(2) + y, 0), backend="sympy", ivp={y: 1, y.d(): 0}) sol.as_expr(y) # cos(t)
Independent equations are solved one at a time. Coupled systems are passed to
dsolveas a whole, which handles linear systems but generally not nonlinear ones."auto"Solves numerically if
ivporbvpandtspanare given, and falls back to"sympy"if they are not or if the numerical solution fails. Invalid conditions are reported as errors rather than triggering the fallback.
Working with solutions
A numerical solution provides:
sol.tThe time points returned by the solver; set them with
t_eval.sol[y],sol[y.d()]Values of a variable or derivative at
sol.t.sol.eval(y, times)Values at arbitrary times within
tspan.timesmay be a scalar or an array. The values come from the interpolant of the solver (dense_outputofsolve_ivp, or the cubic interpolant ofsolve_bvp) and are as accurate as the solution itself.sol.plot(y, y.d())Plots variables or derivatives against \(t\) with Matplotlib; all variables if none are given. Returns the axes.
sol.to_pandas()A
pandas.DataFramewith one column per variable, indexed by \(t\). Pass targets andt=...to choose columns and rows.sol.t_events,sol.event_values(i, y)Event times and solution values there (see Events).
sol.as_first_order()The first-order system constructed by odecast: right-hand side function, Jacobian (or
None), initial state vector, initial time, and a mapping from(variable, derivative order)to state vector index. Useful for debugging or for passing the system to a different integrator.
Matplotlib and pandas are optional; install them with
pip install "odecast[examples]".
A symbolic solution provides sol.as_expr(target), which returns a SymPy
expression for a variable or derivative (a SymPy array for vector
variables), and sol.eval(target, times) once all integration constants
are determined.
In Jupyter, equations and symbolic solutions are displayed as formulas.
odecast.latex() returns the LaTeX code:
from odecast import latex
latex(Eq(y.d(2) + 0.3*y.d() + y, sp.sin(t)))
# y + 0.3 y' + y'' = \sin{\left(t \right)}
Errors
Problems with the equations or conditions raise exceptions derived from
OdecastError. Conditions are checked before
solving:
from odecast.errors import MissingInitialConditionError
y = var("y")
try:
solve(Eq(y.d(2) + y, 0), ivp={y: 1.0}, tspan=(0, 10))
except MissingInitialConditionError as e:
print(e)
# Missing initial condition for y^(1). Variable has order 2 and
# requires conditions for levels 0 to 1.
Supplying a condition for the highest derivative (e.g. y.d(2) above)
raises OverdeterminedConditionsError. Invalid
arguments raise ValueError or TypeError, for example an
initial condition whose shape does not match the variable, or a boundary
condition that is not at an end point of tspan. If the numerical solver
fails, RuntimeError is raised.