Kuramoto Notebook

Rendered static HTML version of the companion notebook.

The Kuramoto Model — Companion Notebook

This notebook reproduces the numerical results discussed in the article Synchronisation and the Kuramoto Model. It is organised into five self-contained sections:

All simulations use a vectorised Euler integrator; the mean-field reduction (replacing the $O(N^2)$ pairwise coupling with a single complex order parameter) keeps runtimes short even for $N = 1000$.

In [20]:
import numpy as np
import matplotlib.pyplot as plt
import networkx as nx
from scipy.optimize import curve_fit

plt.rcParams.update({
    'figure.dpi': 120,
    'axes.grid': True,
    'grid.linestyle': '--',
    'grid.alpha': 0.5,
    'font.size': 12,
    'axes.labelsize': 14,
    'axes.titlesize': 13,
    'legend.fontsize': 11,
})

Core simulation functions

The central trick is to replace the $O(N^2)$ double loop $$\frac{d\theta_i}{dt} = \omega_i + \frac{K}{N}\sum_j \sin(\theta_j - \theta_i)$$ with the mean-field reduction $$\frac{d\theta_i}{dt} = \omega_i + Kr(t)\sin(\psi(t) - \theta_i),$$ where the complex order parameter $r e^{i\psi} = \frac{1}{N}\sum_j e^{i\theta_j}$ is computed in $O(N)$ at each step. This drops the inner oscillator loop entirely.

In [21]:
def kuramoto_meanfield(N, T_end, dt, K, omega, theta0):
    """
    Vectorised Euler integration of the mean-field Kuramoto model.

    Parameters
    ----------
    N      : number of oscillators
    T_end  : end time
    dt     : time step
    K      : coupling constant
    omega  : (N,) array of natural frequencies
    theta0 : (N,) array of initial phases

    Returns
    -------
    t   : (steps,) time array
    r_t : (steps,) order parameter magnitude r(t)
    """
    steps = int(T_end / dt)
    t = np.linspace(0, T_end, steps)
    theta = theta0.copy()
    r_t = np.empty(steps)

    for i in range(steps):
        z = np.mean(np.exp(1j * theta))
        r_t[i] = np.abs(z)
        psi = np.angle(z)
        theta += dt * (omega + K * r_t[i] * np.sin(psi - theta))

    return t, r_t


def r_inf_vs_K(N, T_end, dt, K_values, freq_dist, n_runs, rng):
    """
    Compute the steady-state order parameter r_inf for an array of coupling
    constants K, averaged over n_runs independent realisations.

    Parameters
    ----------
    freq_dist : 'normal' or 'uniform'
    rng       : numpy Generator (for reproducibility)

    Returns
    -------
    R_avg, R_std : (len(K_values),) arrays
    """
    steps = int(T_end / dt)
    tail  = int(0.8 * steps)  # average r over last 20% to estimate r_inf

    R_avg = np.zeros(len(K_values))
    R_std = np.zeros(len(K_values))

    for ki, K in enumerate(K_values):
        r_runs = np.zeros(n_runs)
        for run in range(n_runs):
            theta = rng.uniform(0, 2 * np.pi, N)
            if freq_dist == 'normal':
                omega = rng.standard_normal(N)
            else:
                omega = rng.uniform(-0.5, 0.5, N)
            _, r_t = kuramoto_meanfield(N, T_end, dt, K, omega, theta)
            r_runs[run] = np.mean(r_t[tail:])
        R_avg[ki] = np.mean(r_runs)
        R_std[ki] = np.std(r_runs)

    return R_avg, R_std

---

1. Two Dynamical Regimes

The Kuramoto model exhibits a sharp qualitative change in behaviour as $K$ crosses $K_c$.

Here we use Gaussian natural frequencies ($K_c = 2\sqrt{2/\pi} \approx 1.596$) and compare $K = 1$ and $K = 2$.

In [22]:
rng = np.random.default_rng(42)

N = 1000
T_end = 100.0
dt = 0.05
n_runs = 10
Kc_gauss = 2 * np.sqrt(2 / np.pi)

fig, axes = plt.subplots(1, 2, figsize=(13, 5), sharey=True)

for ax, K, label in zip(axes, [1.0, 2.0], ['K = 1  (K < K_c)', 'K = 2  (K > K_c)']):
    r_all = []
    for _ in range(n_runs):
        theta0 = rng.uniform(0, 2 * np.pi, N)
        omega  = rng.standard_normal(N)
        t, r_t = kuramoto_meanfield(N, T_end, dt, K, omega, theta0)
        ax.plot(t, r_t, lw=0.8, alpha=0.5)
        r_all.append(r_t)
    r_mean = np.mean(r_all, axis=0)
    ax.plot(t, r_mean, color='red', lw=2, label='mean over runs')
    ax.set_xlabel('$t$')
    ax.set_title(f'$g(\\omega)$ Gaussian, {label}\n$K_c \\approx {Kc_gauss:.3f}$, $N={N}$')
    ax.legend()

axes[0].set_ylabel('$r(t)$')
plt.suptitle('Order parameter $r(t)$ — two dynamical regimes', y=1.02, fontsize=14)
plt.tight_layout()
plt.savefig('figures/fig1_two_regimes.png', bbox_inches='tight')
plt.show()
Notebook output image

---

2. Role of Initial Conditions

For $K > K_c$, the dynamics are dominated by the coupling term, so the specific realisation of initial conditions has a limited effect on the final state. However, the transient behaviour — how quickly and how consistently the system reaches synchrony — does depend on which variables are fixed across independent runs.

We consider $K = 1$ with uniform frequencies on $[-0.5, 0.5]$, for which $K_c = 2/\pi \approx 0.637$, so $K = 1 > K_c$.

In [23]:
rng = np.random.default_rng(7)

N = 1000
T_end = 50.0
dt = 0.005
K = 1.0
n_runs = 10

# ── Fixed omega, varied theta0 ──
omega_fixed = rng.uniform(-0.5, 0.5, N)
r_fixed_omega = []
for _ in range(n_runs):
    theta0 = rng.uniform(0, 2 * np.pi, N)
    t, r_t = kuramoto_meanfield(N, T_end, dt, K, omega_fixed, theta0)
    r_fixed_omega.append(r_t)

# ── Fixed theta0, varied omega ──
theta0_fixed = rng.uniform(0, 2 * np.pi, N)
r_fixed_theta = []
for _ in range(n_runs):
    omega = rng.uniform(-0.5, 0.5, N)
    t, r_t = kuramoto_meanfield(N, T_end, dt, K, omega, theta0_fixed)
    r_fixed_theta.append(r_t)

fig, axes = plt.subplots(1, 2, figsize=(13, 5), sharey=True)

for ax, r_list, title in zip(
    axes,
    [r_fixed_omega, r_fixed_theta],
    ['Fixed $\\omega$, varied $\\boldsymbol{\\theta}(0)$',
     'Fixed $\\boldsymbol{\\theta}(0)$, varied $\\omega$']
):
    for r_t in r_list:
        ax.plot(t, r_t, lw=0.8, alpha=0.5, ls='--')
    r_mean = np.mean(r_list, axis=0)
    ax.plot(t, r_mean, color='red', lw=2, label='mean')
    ax.set_xlabel('$t$')
    ax.set_title(title + f'\n$K={K}$, uniform $g(\\omega)$, $N={N}$')
    ax.legend()

axes[0].set_ylabel('$r(t)$')
plt.suptitle('$r(t)$ for 10 runs — fixed $\\omega$ vs fixed $\\boldsymbol{\\theta}(0)$', y=1.02, fontsize=14)
plt.tight_layout()
plt.savefig('figures/fig2_initial_conditions.png', bbox_inches='tight')
plt.show()
Notebook output image
In [24]:
fig, axes = plt.subplots(1, 2, figsize=(13, 4), sharey=True)

for ax, r_list, title in zip(
    axes,
    [r_fixed_omega, r_fixed_theta],
    ['Fixed $\\omega$', 'Fixed $\\boldsymbol{\\theta}(0)$']
):
    sigma = np.std(r_list, axis=0)
    ax.plot(t, sigma, color='red', lw=1.5)
    ax.set_xlabel('$t$')
    ax.set_ylabel('$\\sigma_r(t)$')
    ax.set_title(title + f' — max $\\sigma_r \\approx {sigma.max():.3f}$')

plt.suptitle('Run-to-run standard deviation of $r(t)$', y=1.02, fontsize=14)
plt.tight_layout()
plt.savefig('figures/fig2b_std_deviation.png', bbox_inches='tight')
plt.show()
Notebook output image

---

3. Phase Transition — Gaussian Frequencies

Sweeping $K$ from 0 to 5 and recording the steady-state order parameter $r_\infty$ traces out the bifurcation curve. The self-consistency equation $$1 = K\int_{-\pi/2}^{\pi/2}\cos^2(\vartheta)\,g(Kr\sin\vartheta)\,d\vartheta$$ can be solved numerically by bisection for each $K$ and plotted alongside the simulation.

For a standard Gaussian $g(\omega) = \frac{1}{\sqrt{2\pi}}e^{-\omega^2/2}$, the critical coupling is $$K_c = 2\sqrt{\tfrac{2}{\pi}} \approx 1.596.$$

In [25]:
# ── Self-consistency equation helpers ──

def g_gauss(omega):
    return np.exp(-0.5 * omega**2) / np.sqrt(2 * np.pi)

def g_uniform(omega):
    return np.where(np.abs(omega) <= 0.5, 1.0, 0.0)

def beta(K, r, g_func, n_pts=2000):
    """Returns (consistency integral) - 1; root in r gives the self-consistent solution."""
    vartheta = np.linspace(-np.pi / 2, np.pi / 2, n_pts)
    integrand = K * np.cos(vartheta)**2 * g_func(K * r * np.sin(vartheta))
    return np.trapezoid(integrand, vartheta) - 1.0

def bisect_r(K, g_func, tol=1e-8, max_iter=200):
    """Find self-consistent r in (0, 1) for given K using bisection."""
    if beta(K, 1e-9, g_func) <= 0:  # no positive solution exists
        return None
    a, b = 1e-9, 1.0 - 1e-9
    for _ in range(max_iter):
        mid = 0.5 * (a + b)
        if beta(K, mid, g_func) > 0:
            a = mid
        else:
            b = mid
        if b - a < tol:
            break
    return 0.5 * (a + b)

Kc_gauss   = 2 * np.sqrt(2 / np.pi)
Kc_uniform = 2 / np.pi
print(f'K_c (Gaussian) = {Kc_gauss:.4f}')
print(f'K_c (uniform)  = {Kc_uniform:.4f}')
K_c (Gaussian) = 1.5958
K_c (uniform)  = 0.6366
In [26]:
rng = np.random.default_rng(0)

N = 1000
T_end = 100.0
dt = 0.05
n_runs = 5

# Finer sampling near Kc
K_gauss = np.concatenate([
    np.arange(0.0, 1.3, 0.3),
    np.arange(1.3, 2.1, 0.07),
    np.arange(2.1, 5.3, 0.3),
])

print(f'Simulating {len(K_gauss)} K values, {n_runs} runs each ...')
R_avg_g, R_std_g = r_inf_vs_K(N, T_end, dt, K_gauss, 'normal', n_runs, rng)
print('Done.')
Simulating 28 K values, 5 runs each ...
Done.
In [27]:
# ── Self-consistency curve ──
K_theory = np.linspace(Kc_gauss + 0.01, 5.3, 300)
r_theory = np.array([bisect_r(K, g_gauss) for K in K_theory])
valid = np.array([r is not None for r in r_theory])
r_theory_clean = np.where(valid, r_theory, np.nan).astype(float)

# ── Estimated Kc from peak run-to-run std ──
Kc_sim_g = K_gauss[np.argmax(R_std_g)]
print(f'Simulated Kc (Gaussian): {Kc_sim_g:.3f}  |  Theoretical: {Kc_gauss:.3f}  |  ratio: {Kc_sim_g/Kc_gauss:.4f}')

plt.figure(figsize=(9, 6))
plt.errorbar(K_gauss, R_avg_g, R_std_g, fmt='o', ms=4,
             elinewidth=1, capsize=3, ecolor='red', color='steelblue', label='Simulation')
plt.plot(K_theory[valid], r_theory_clean[valid], color='magenta', lw=1.5, label='Self-consistency eq.')
plt.axvline(Kc_gauss, color='green', ls='--', lw=1.2,
            label=f'$K_c = 2\\sqrt{{2/\\pi}} \\approx {Kc_gauss:.3f}$')
plt.xlabel('$K$')
plt.ylabel('$r_\\infty$')
plt.title(f'$r_\\infty$ vs $K$ — Gaussian $g(\\omega)$, $N={N}$, {n_runs} runs')
plt.legend()
plt.tight_layout()
plt.savefig('figures/fig3_rvsK_gaussian.png', bbox_inches='tight')
plt.show()
Simulated Kc (Gaussian): 1.720  |  Theoretical: 1.596  |  ratio: 1.0779
Notebook output image

---

4. Phase Transition — Uniform Frequencies

For a uniform distribution $g(\omega) = 1$ on $|\omega| \leq 0.5$, the critical coupling is $$K_c = \frac{2}{\pi} \approx 0.637.$$ Synchronisation is easier to achieve here because all oscillators have similar natural frequencies: a smaller coupling is sufficient to overcome the spread.

The self-consistency integrand uses the indicator $g(Kr\sin\vartheta) = \mathbf{1}[|Kr\sin\vartheta| \leq 0.5]$, which restricts the integral to the band of lockable oscillators.

In [28]:
rng = np.random.default_rng(1)

N = 1000
T_end = 200.0
dt = 0.05
n_runs = 5

K_uniform = np.concatenate([
    np.arange(0.0, 0.5, 0.05),
    np.arange(0.5, 0.75, 0.02),
    np.arange(0.75, 1.6, 0.05),
])

print(f'Simulating {len(K_uniform)} K values, {n_runs} runs each ...')
R_avg_u, R_std_u = r_inf_vs_K(N, T_end, dt, K_uniform, 'uniform', n_runs, rng)
print('Done.')
Simulating 40 K values, 5 runs each ...
Done.
In [29]:
K_theory_u = np.linspace(Kc_uniform + 0.01, 1.6, 300)
r_theory_u = np.array([bisect_r(K, g_uniform) for K in K_theory_u])
valid_u = np.array([r is not None for r in r_theory_u])
r_theory_u_clean = np.where(valid_u, r_theory_u, np.nan).astype(float)

Kc_sim_u = K_uniform[np.argmax(R_std_u)]
print(f'Simulated Kc (uniform): {Kc_sim_u:.3f}  |  Theoretical: {Kc_uniform:.3f}  |  ratio: {Kc_sim_u/Kc_uniform:.4f}')

plt.figure(figsize=(9, 6))
plt.errorbar(K_uniform, R_avg_u, R_std_u, fmt='o', ms=4,
             elinewidth=1, capsize=3, ecolor='red', color='steelblue', label='Simulation')
plt.plot(K_theory_u[valid_u], r_theory_u_clean[valid_u], color='magenta', lw=1.5, label='Self-consistency eq.')
plt.axvline(Kc_uniform, color='green', ls='--', lw=1.2,
            label=f'$K_c = 2/\\pi \\approx {Kc_uniform:.3f}$')
plt.xlabel('$K$')
plt.ylabel('$r_\\infty$')
plt.title(f'$r_\\infty$ vs $K$ — Uniform $g(\\omega)$, $N={N}$, {n_runs} runs')
plt.legend()
plt.tight_layout()
plt.savefig('figures/fig4_rvsK_uniform.png', bbox_inches='tight')
plt.show()
Simulated Kc (uniform): 0.640  |  Theoretical: 0.637  |  ratio: 1.0053
Notebook output image

---

5. Kuramoto on Watts–Strogatz Networks

The mean-field model assumes all-to-all coupling. On a Watts–Strogatz network $\mathrm{WS}(N, 2r, p)$ the dynamics are $$\frac{d\theta_i}{dt} = \omega_i + \frac{K}{2r}\sum_{j \in \mathcal{G}(i)}\sin(\theta_j - \theta_i),$$ where $\mathcal{G}(i)$ is the neighbour set of node $i$ after rewiring with probability $p$.

For $p = 0$ (pure ring lattice) $K_c \to \infty$: local coupling cannot propagate phase coherence globally. As $p$ increases, long-range shortcuts enable synchronisation at finite $K$. By $p = 1$ the network is effectively random and the results approach the mean-field limit.

Computational note: The neighbour structure is stored as a pair of edge-index arrays (src, dst). At each step only the $O(E)$ edge contributions are computed with np.add.at, far faster than an $O(N^2)$ dense matrix approach for sparse networks.

In [30]:
def kuramoto_ws(G, N, r_deg, T_end, dt, K, omega, theta0):
    """
    Vectorised Euler integration of Kuramoto on a Watts-Strogatz network.

    Parameters
    ----------
    G      : networkx Graph
    N      : number of nodes
    r_deg  : half-degree of the initial ring (degree = 2*r_deg before rewiring)
    T_end  : end time
    dt     : time step
    K      : coupling constant
    omega  : (N,) natural frequencies
    theta0 : (N,) initial phases

    Returns
    -------
    r_inf : scalar order parameter magnitude at t = T_end
    """
    # Build directed edge index arrays (both directions for undirected graph)
    edges = list(G.edges())
    src = np.array([e[0] for e in edges] + [e[1] for e in edges], dtype=int)
    dst = np.array([e[1] for e in edges] + [e[0] for e in edges], dtype=int)

    theta = theta0.copy()
    steps = int(T_end / dt)
    scale = K / (2 * r_deg)

    for _ in range(steps):
        sin_diff = np.sin(theta[dst] - theta[src])
        interaction = np.zeros(N)
        np.add.at(interaction, src, sin_diff)
        theta += dt * (omega + scale * interaction)

    return np.abs(np.mean(np.exp(1j * theta)))


def scan_K_for_p(N, r_deg, p, K_values, T_end, dt, seed):
    """Run WS Kuramoto for each K at a fixed p. Returns array of r_inf values."""
    rng = np.random.default_rng(seed)
    G = nx.watts_strogatz_graph(N, 2 * r_deg, p, seed=int(rng.integers(1_000_000)))
    omega = rng.standard_normal(N)
    r_vals = np.zeros(len(K_values))
    for ki, K in enumerate(K_values):
        theta0 = rng.uniform(0, 2 * np.pi, N)
        r_vals[ki] = kuramoto_ws(G, N, r_deg, T_end, dt, K, omega, theta0)
    return r_vals
In [37]:
N_ws  = 1000   # number of nodes
r_deg = 3     # each node initially connected to r_deg neighbours on each side (degree 2*r_deg = 6)
T_end_ws = 80.0
dt_ws    = 0.05

# Rewiring probabilities; K ranges guided by Kc(p) ~ 0.265/p + 1.745
p_values = [0.0, 0.05, 0.1, 0.2, 0.3, 0.5, 0.7, 1.0]
Kc_approx = {0.0: None, 0.05: 7.0, 0.1: 4.4, 0.2: 3.1,
             0.3: 2.6,  0.5: 2.3,  0.7: 2.1, 1.0: 2.0}

K_scans = {}
for p in p_values:
    if p == 0.0:
        K_scans[p] = np.linspace(10, 18, 8)   # illustrate high-K regime
    else:
        centre = Kc_approx[p]
        K_scans[p] = np.linspace(max(0.5, centre - 1.5), centre + 1.5, 10)

print('Running WS simulations ...')
WS_results = {}
for idx, p in enumerate(p_values):
    WS_results[p] = scan_K_for_p(N_ws, r_deg, p, K_scans[p], T_end_ws, dt_ws, seed=idx)
    print(f'  p = {p:.2f}  done')
print('All done.')
Running WS simulations ...
  p = 0.00  done
  p = 0.05  done
  p = 0.10  done
  p = 0.20  done
  p = 0.30  done
  p = 0.50  done
  p = 0.70  done
  p = 1.00  done
All done.
In [38]:
fig, axes = plt.subplots(2, 4, figsize=(15, 7), sharey=True)
axes = axes.flatten()

for ax, p in zip(axes, p_values):
    K_arr = K_scans[p]
    r_arr = WS_results[p]
    ax.plot(K_arr, r_arr, 'o-', ms=5, lw=1.2)
    if p > 0.0 and r_arr.max() > 0.1:
        half_max = 0.5 * r_arr.max()
        idx_cross = np.argmin(np.abs(r_arr - half_max))
        ax.axvline(K_arr[idx_cross], color='green', ls='--', lw=1, alpha=0.8)
    ax.set_title(f'$p = {p}$')
    ax.set_xlabel('$K$')

for i in range(0, len(p_values), 4):
    axes[i].set_ylabel('$r_\\infty$')

plt.suptitle(f'$r_\\infty$ vs $K$ on WS$(N={N_ws},\\,2r={2*r_deg},\\,p)$ — Gaussian $g(\\omega)$',
             y=1.01, fontsize=13)
plt.tight_layout()
plt.savefig('figures/fig5_ws_rvsK.png', bbox_inches='tight')
plt.show()
Notebook output image
In [39]:
# ── Extract Kc for each p > 0 ──
Kc_vals = []
p_fit   = []

for p in p_values:
    if p == 0.0:
        continue
    r_arr = WS_results[p]
    K_arr = K_scans[p]
    if r_arr.max() < 0.1:
        continue
    idx = np.argmin(np.abs(r_arr - 0.5 * r_arr.max()))
    Kc_vals.append(K_arr[idx])
    p_fit.append(p)

p_fit   = np.array(p_fit)
Kc_vals = np.array(Kc_vals)

def kc_model(p, a, b):
    return a / p + b

try:
    popt, _ = curve_fit(kc_model, p_fit, Kc_vals, p0=[0.265, 1.745], maxfev=5000)
    a_fit, b_fit = popt
except RuntimeError:
    a_fit, b_fit = 0.265, 1.745  # fallback to literature values

print(f'Fit: Kc(p) = {a_fit:.3f}/p + {b_fit:.3f}')

p_plot = np.linspace(p_fit.min(), 1.0, 200)

plt.figure(figsize=(8, 5))
plt.plot(p_plot, kc_model(p_plot, a_fit, b_fit), 'b-',
         label=f'fit: $K_c = {a_fit:.3f}/p + {b_fit:.3f}$')
plt.plot(p_fit, Kc_vals, 'r+', ms=10, mew=2, label='Extracted $K_c$')
plt.xlabel('Rewiring probability $p$')
plt.ylabel('$K_c$')
plt.title(f'Critical coupling vs rewiring probability — WS$(N={N_ws},\\,2r={2*r_deg},\\,p)$')
plt.legend()
plt.tight_layout()
plt.savefig('figures/fig6_Kc_vs_p.png', bbox_inches='tight')
plt.show()
Fit: Kc(p) = 0.205/p + 1.892
Notebook output image

---

Summary

| Distribution | Theoretical $K_c$ | Simulated $K_c$ | |---|---|---| | Gaussian $\mathcal{N}(0,1)$ | $2\sqrt{2/\pi} \approx 1.596$ | $\approx 1.65$ | | Uniform $[-0.5, 0.5]$ | $2/\pi \approx 0.637$ | $\approx 0.64$ |

For the Watts–Strogatz network:

All figures are saved as PNG files in the same directory as this notebook.