4. Pitchfork

The supercritical pitchfork bifurcation:

\[ \dot x = a\,x - x^3. \]

Equilibria: \(x = 0\) for all \(a\), plus \(x_{\pm} = \pm\sqrt{a}\) for \(a > 0\). The Jacobian at the trivial branch is \(f'(0) = a\), so \(x = 0\) is stable for \(a < 0\) and unstable for \(a > 0\). The two non-trivial branches inherit stability from the loss-of-stability of the trivial one.

This is the canonical symmetry-breaking bifurcation: the system has the symmetry \(x \mapsto -x\), and at the bifurcation point a pair of asymmetric solutions emerges from the symmetric one.

from tvbo import Dynamics, Continuation, SimulationExperiment
import matplotlib.pyplot as plt
import numpy as np

PF_YAML = """
name: Pitchfork
description: Supercritical pitchfork $\\dot x = a x - x^3$.
parameters:
  a:
    name: a
    value: 1.0
state_variables:
  x:
    name: x
    domain: { lo: -2.0, hi: 2.0 }
    equation:
      lhs: Derivative(x, t)
      rhs: a*x - x**3
    initial_value: 0.01
"""

CONT_YAML = """
name: pf_in_a
dynamics: Pitchfork
free_parameters:
  - name: a
    domain: { lo: -1.0, hi: 2.0 }
max_steps: 400
ds: 0.005
bothside: true
"""

dyn = Dynamics.from_string(PF_YAML)
cont = Continuation.from_string(CONT_YAML)
exp = SimulationExperiment(dynamics=dyn, continuations=[cont])

Phase line \(f(x)\) across the bifurcation

x = np.linspace(-2, 2, 400)
fig, axes = plt.subplots(1, 3, figsize=(12, 3.6), sharey=True)
for ax, a in zip(axes, [-0.5, 0.0, 1.0]):
    f = a*x - x**3
    ax.axhline(0, color="0.7", lw=0.8)
    ax.plot(x, f, lw=2, color="C0")
    eqs = [0.0]
    if a > 0:
        eqs += [+np.sqrt(a), -np.sqrt(a)]
    for r in eqs:
        slope = a - 3*r**2
        ax.plot(r, 0, "o", color="C2" if slope < 0 else "C3", ms=8, mec="k")
    ax.set_xlim(-2, 2); ax.set_ylim(-2.0, 2.0)
    ax.set_xlabel("$x$"); ax.set_title(f"$a = {a}$")
axes[0].set_ylabel(r"$\dot x = a x - x^3$")
plt.tight_layout(); plt.show()

For \(a > 0\) the trivial equilibrium (red) is unstable and two new stable branches (green) flank it.

AUTO-07p (NumCont)

Mirrors the standalone PitchFork.py example: AUTO-07p time-integrates the YAML-defined system to a steady state, then continues in both directions of \(a\) via bothside: true.

result_auto = exp.run("auto-07p")
result_auto.continuations["pf_in_a"].plot(VOI="x")
plt.gca().set_xlim(-1, 2); plt.gca().set_ylim(-1.5, 1.5); plt.show()
/opt/homebrew/bin/gfortran -arch arm64 -O -fopenmp -O -c /var/folders/ym/9kw1g21j1nd7kwfn8c0z3st40000gn/T/tvbo_numcont_Pitchfork__vq26j6p/model.f90 -o /var/folders/ym/9kw1g21j1nd7kwfn8c0z3st40000gn/T/tvbo_numcont_Pitchfork__vq26j6p/model.o
/opt/homebrew/bin/gfortran -arch arm64 -O -fopenmp -O /var/folders/ym/9kw1g21j1nd7kwfn8c0z3st40000gn/T/tvbo_numcont_Pitchfork__vq26j6p/model.o -o /var/folders/ym/9kw1g21j1nd7kwfn8c0z3st40000gn/T/tvbo_numcont_Pitchfork__vq26j6p/model.exe /Applications/auto-07p/lib/*.o
Starting /var/folders/ym/9kw1g21j1nd7kwfn8c0z3st40000gn/T/tvbo_numcont_Pitchfork__vq26j6p/model ...
ld: warning: duplicate -rpath '/Library/Developer/CommandLineTools/SDKs/MacOSX26.4.sdk/usr/lib' ignored

  BR    PT  TY  LAB       a          L2-NORM          x       
   1     1  EP    1   1.00000E+00   1.00000E+00   1.00000E+00
   1    10        2   1.20109E+00   1.09594E+00   1.09594E+00
   1    20        3   1.55022E+00   1.24508E+00   1.24508E+00
   1    30  EP    4   2.04811E+00   1.43112E+00   1.43112E+00

 Total Time    0.991E-03
/var/folders/ym/9kw1g21j1nd7kwfn8c0z3st40000gn/T/tvbo_numcont_Pitchfork__vq26j6p/model ... done
Starting /var/folders/ym/9kw1g21j1nd7kwfn8c0z3st40000gn/T/tvbo_numcont_Pitchfork__vq26j6p/model ...

  BR    PT  TY  LAB       a          L2-NORM          x       
   1     1  EP    1   1.00000E+00   1.00000E+00   1.00000E+00
   1    10        2   8.02934E-01   8.96066E-01   8.96066E-01
   1    20        3   4.81697E-01   6.94044E-01   6.94044E-01
   1    30        4   1.97049E-01   4.43902E-01   4.43902E-01
   1    40        5   1.34522E-02   1.15984E-01   1.15984E-01
   1    44  BP    6   5.51118E-09   1.11387E-09   1.11387E-09
   1    50        7   4.87844E-02   2.20872E-01  -2.20872E-01
   1    60        8   2.74087E-01   5.23533E-01  -5.23533E-01
   1    70        9   5.72943E-01   7.56930E-01  -7.56930E-01
   1    80       10   9.00364E-01   9.48875E-01  -9.48875E-01
   1    90       11   1.24198E+00   1.11444E+00  -1.11444E+00
   1   100       12   1.59191E+00   1.26171E+00  -1.26171E+00
   1   108  EP   13   2.00091E+00   1.41453E+00  -1.41453E+00

  BR    PT  TY  LAB       a          L2-NORM          x       
   2    10       14  -4.60000E-01   5.96950E-68   5.96950E-68
   2    16  EP   15  -1.06000E+00   1.23082-109  -1.23082-109

  BR    PT  TY  LAB       a          L2-NORM          x       
   2    10       16   4.60000E-01   5.73768E-68   5.73768E-68
   2    20       17   1.46000E+00   7.05377-138   7.05377-138
   2    26  EP   18   2.06000E+00   0.00000E+00   1.23508-180

 Total Time    0.175E-02
/var/folders/ym/9kw1g21j1nd7kwfn8c0z3st40000gn/T/tvbo_numcont_Pitchfork__vq26j6p/model ... done
Merge done
Saving to pf_in_a, pf_in_a, and pf_in_a ... done

3D bifurcation surface \((a, x, t)\)

A pedagogical view: integrate the system from a small positive perturbation across a finite time horizon for many values of \(a\), and stack the trajectories along the \(a\)-axis. The convergence to \(x \to +\sqrt a\) for \(a > 0\) and to \(x \to 0\) for \(a < 0\) is visible as a 3-D surface.

from mpl_toolkits.mplot3d import Axes3D  # noqa: F401
a_grid = np.linspace(-1, 2, 60)
t = np.linspace(0, 8, 200)
X = np.empty((len(a_grid), len(t)))
for i, a in enumerate(a_grid):
    # closed-form for $\dot x = a x - x^3$ via separation
    x0 = 0.05
    if abs(a) < 1e-6:
        X[i] = x0 / np.sqrt(1 + 2 * x0**2 * t)
    else:
        c = (1.0 / x0**2 - a) if x0 != 0 else 1.0
        X[i] = a / np.sqrt(a + c * np.exp(-2*a*t)) * np.sign(x0)

fig = plt.figure(figsize=(7, 5))
ax = fig.add_subplot(111, projection="3d")
A_mesh, T_mesh = np.meshgrid(a_grid, t, indexing="ij")
ax.plot_surface(A_mesh, T_mesh, X, cmap="viridis", alpha=0.85,
                rcount=40, ccount=40)
ax.set_xlabel("$a$"); ax.set_ylabel("$t$"); ax.set_zlabel("$x(t)$")
ax.set_xlim(-1, 2); ax.set_zlim(-1.5, 1.5)
plt.tight_layout(); plt.show()

Note that detecting all three branches with continuation typically requires starting from a non-zero initial guess so that branch-switching at the pitchfork point is triggered. All backends label the bifurcation point at \(a = 0\).