2. 2D Phase Portraits

For two-dimensional linear systems

\[ \dot{\vec x} = A\,\vec x, \qquad A = \begin{pmatrix} a_{11} & a_{12} \\ a_{21} & a_{22} \end{pmatrix} \]

the eigenvalues \(\lambda_{1,2}\) of \(A\) fully classify the equilibrium at the origin. The four canonical cases:

Case Trace\((A)\) Det\((A)\) Eigenvalues Phase portrait
Stable node \(< 0\) \(> 0\), \(\Delta > 0\) \(\lambda_{1,2} \in \mathbb{R}_{<0}\) trajectories converge tangent to slow manifold
Saddle any \(< 0\) \(\lambda_1 \lambda_2 < 0\) crossing stable / unstable manifolds
Stable focus \(< 0\) \(> 0\), \(\Delta < 0\) \(\alpha \pm i\omega,\;\alpha < 0\) inward spiral
Centre \(= 0\) \(> 0\) \(\pm\,i\omega\) nested closed orbits

with \(\Delta = (\mathrm{tr}\,A)^2 - 4\det A\). We sweep all four cases with identical plotting code: only the rhs strings change.

from tvbo import Dynamics
import matplotlib.pyplot as plt

CASES = {
    "Saddle":       ("x1 - x2",  "-x1 - x2"),       # tr=0, det=-2
    "Stable node":  ("-2*x1",    "-x2"),            # tr=-3, det=2
    "Stable focus": ("-0.3*x1 - x2",  "x1 - 0.3*x2"),  # tr=-0.6, det=1.09
    "Centre":       ("-x2",      "x1"),             # tr=0, det=1
}

def make_yaml(rhs1, rhs2, name):
    return f"""
name: {name}
state_variables:
  x1:
    name: x1
    domain: {{ lo: -2.0, hi: 2.0 }}
    equation:
      lhs: Derivative(x1, t)
      rhs: {rhs1}
    initial_value: 1.0
  x2:
    name: x2
    domain: {{ lo: -2.0, hi: 2.0 }}
    equation:
      lhs: Derivative(x2, t)
      rhs: {rhs2}
    initial_value: 1.0
"""

Phase planes for the four canonical cases

Each panel uses the new kind='phaseplane' shortcut, which overlays the vector field with the two nullclines (\(\dot x_1 = 0\) in blue, \(\dot x_2 = 0\) in red) and marks the fixed points detected on the grid (black circles). Axis limits are set declaratively via the YAML domain.lo / hi.

fig, axes = plt.subplots(2, 2, figsize=(9, 8))
for ax, (label, (r1, r2)) in zip(axes.flat, CASES.items()):
    dyn = Dynamics.from_string(make_yaml(r1, r2, label.replace(" ", "")))
    dyn.plot("x1", "x2", kind="phaseplane", ax=ax,
             grid_n=22, n_trajectories=3, duration=15)
    ax.set_title(label)
plt.tight_layout(); plt.show()

Saddle case in isolation (PhasePortraitSaddle.png)

The legacy figure focuses on the saddle with \(A = \begin{pmatrix} 1 & -1 \\ -1 & -1 \end{pmatrix}\), \(\lambda_{\pm} = \pm\sqrt 2\):

dyn = Dynamics.from_string(make_yaml("x1 - x2", "-x1 - x2", "Saddle2D"))
dyn.plot("x1", "x2", kind="phaseplane", n_trajectories=4, duration=10)
plt.gca().set_xlim(-2, 2); plt.gca().set_ylim(-2, 2)
plt.show()

The two orthogonal nullclines intersect only at the origin, where the saddle sits. Trajectories are pulled along the stable manifold and ejected along the unstable one.

Linking to nonlinear bifurcations

A nonlinear vector field \(\dot{\vec x} = f(\vec x)\) can have many equilibria. Linearisation at each one gives a 2D matrix \(A = Df(\vec x^*)\) that classifies its local topology using the table above. Bifurcations occur when the eigenvalues of one of those matrices crosses the imaginary axis as a parameter varies — the topic of the rest of this tutorial.