Skip to content

Phase portraits — streamlines of any ODE system, in 2D or 3D

PhasePortrait draws the streamlines of an autonomous system $\dot{\mathbf x} = \mathbf f(\mathbf x)$. It knows nothing about your system: pass any callable, a vector field sampled on a grid, or curves you have already integrated. The integration is independent of the backend, so the same portrait renders with matplotlib or plotly.

Integration needs SciPy for adaptive and stiff solvers (pip install cplots[dynamics]); without it a built-in fixed-step RK4 is used.

A callable

Streamlines are integrated both forward and backward from each seed, clipped to bounds, and coloured by speed. Arrowheads show the direction of the flow. arrow_size= scales the arrowheads (0.7 for subtler heads, 1.5 for bolder ones) in both backends and in 3D, and arrows=False removes them.

Same portrait, interactive

Seeds, direction and colouring

Seed strategies are callables bounds -> (N, d) array; seeds.grid, seeds.random and seeds.line are provided, and an explicit (N, 2) array works too.

A vector field on a grid

Evenly spaced streamlines

Grid seeds pile curves up near attractors and leave gaps where the flow spreads out. spacing= switches to a Jobard–Lefer layout: curves are grown one at a time, cut where they come closer than half the spacing to an existing curve, and new seeds are placed beside each accepted curve. The value is the target distance between neighbours as a fraction of the box diagonal (2D only; it replaces seed strategies, and an explicit seeds= array becomes the starting point).

The default integrator for this mode is the built-in fixed-step RK4, which is fast and accurate enough for display; SciPy's adaptive methods work too (method="RK45") but are much slower because each curve is integrated past the point where it is cut. The layout is also available as cplots.dynamics.even_streamlines(f, bounds, separation=0.04).

Fixed points

fixed_points="auto" finds the equilibria of $\mathbf f$ inside the box (damped Newton from a grid of starting points, no SciPy needed) and classifies them from the eigenvalues of the Jacobian: filled circles are attracting, hollow circles repelling, diamonds saddles, squares centres and crosses non-hyperbolic points. Hovering in plotly shows the eigenvalues.

The search is also available on its own, cplots.dynamics.find_fixed_points(f, bounds), which returns FixedPoint objects with .point, .eigenvalues, .kind and .stable. It only finds roots that some starting point converges to, so pass starts=seeds.grid(...) with more points for systems with many equilibria.

Nullclines

A nullcline is where one component of the vector field vanishes: trajectories cross the $\dot x = 0$ curve vertically and the $\dot y = 0$ curve horizontally, and the equilibria sit where the two meet. nullclines=True draws both as dashed curves (2D only), and cplots.dynamics.nullclines(f, bounds) returns the polylines.

Curves only appear where the component changes sign, so a tangential zero such as $f = x^2$ is missed. The nullclines are found on a grid (nullcline_resolution, default 200) by marching squares; where two of them cross inside a grid cell the contour cuts the corner by up to one cell.

Three dimensions

Give three bounds and the same call draws a 3D scene you can rotate and zoom (in matplotlib's interactive window, or natively in plotly). seeds.sphere and seeds.box_shell are handy 3D seed layouts, and camera sets the starting view.

With latex_labels=True and $...$ axis labels, plotly draws the 3D labels as scene annotations (its 3D axis titles cannot typeset MathJax) and places them from the camera, beside the edges nearest the viewer.

Everything else carries over: color_by, arrows, fixed_points (now (M, 3)), from_grid((X, Y, Z, U, V, W)) and from_trajectories. 3D portraits are supported by the matplotlib and plotly backends, not inside multi-panel Figures.

Parameter exploration

A portrait is a pure function of its arguments, so parameter exploration is just rebuilding it. With marimo, bind a slider and re-render in the next cell:

gamma = mo.ui.slider(0.0, 1.0, 0.05, value=0.3)
cplots.PhasePortrait(lambda x: damped_oscillator(x, gamma.value), [(-3, 3)] * 2).render("plotly")