Skip to content

Paper 27 — Knm Phase Dynamics in scpn-control

Demonstrates the integration of SCPN Paper 27 (The Knm Matrix: A Simulation Framework for Modelling Multi-Scale Bidirectional Causality) into the scpn-control tokamak control repo.

What this notebook covers:

  1. Kuramoto–Sakaguchi + ζ sin(Ψ−θ) global driver — single-layer mean-field
  2. Paper 27 Knm matrix — 16×16 coupling with calibration anchors
  3. Multi-layer UPDE — 16 SCPN layers coupled through Knm
  4. FusionKernel.phase_sync_step() — reviewer's ζ injection into fusion path
  5. Convergence analysis — R(t) trajectories and per-layer coherence

References: - Paper 27 (Academia) - arXiv:2004.06344 — Generalized Kuramoto–Sakaguchi


Copyright 1996–2026 Miroslav Šotek · MIT OR Apache-2.0

import numpy as np
import matplotlib.pyplot as plt

from scpn_control.phase.kuramoto import (
    kuramoto_sakaguchi_step, order_parameter, wrap_phase,
)
from scpn_control.phase.knm import KnmSpec, build_knm_paper27, OMEGA_N_16
from scpn_control.phase.upde import UPDESystem

SEED = 42
rng = np.random.default_rng(SEED)
plt.rcParams.update({"figure.dpi": 120, "figure.facecolor": "white"})
print(f"OMEGA_N_16 shape: {OMEGA_N_16.shape}")
print(f"Frequencies (rad/s): {OMEGA_N_16}")

1. Knm Coupling Matrix (Paper 27)

The 16×16 Knm matrix encodes inter-layer coupling with exponential distance decay, calibration anchors from Paper 27 Table 2, and cross-hierarchy boosts (L1↔L16, L5↔L7).

spec = build_knm_paper27(L=16)

fig, ax = plt.subplots(figsize=(7, 6))
im = ax.imshow(spec.K, cmap="YlOrRd", origin="upper")
ax.set_xlabel("Target layer m")
ax.set_ylabel("Source layer n")
ax.set_title("Paper 27 — Knm Coupling Matrix (16×16)")
ax.set_xticks(range(16))
ax.set_yticks(range(16))
ax.set_xticklabels([f"L{i+1}" for i in range(16)], fontsize=7, rotation=45)
ax.set_yticklabels([f"L{i+1}" for i in range(16)], fontsize=7)
plt.colorbar(im, ax=ax, label="Coupling strength")
plt.tight_layout()
plt.show()

# Verify calibration anchors
print(f"K[L1,L2] = {spec.K[0,1]:.3f}  (expected 0.302)")
print(f"K[L2,L3] = {spec.K[1,2]:.3f}  (expected 0.201)")
print(f"K[L3,L4] = {spec.K[2,3]:.3f}  (expected 0.252)")
print(f"K[L4,L5] = {spec.K[3,4]:.3f}  (expected 0.154)")
print(f"K[L1,L16] = {spec.K[0,15]:.3f} (cross-hierarchy ≥ 0.05)")
print(f"K[L5,L7] = {spec.K[4,6]:.3f}  (cross-hierarchy ≥ 0.15)")

2. Single-Layer Kuramoto–Sakaguchi: Effect of ζ sin(Ψ−θ)

Compare three regimes on a population of 200 oscillators: - K only — standard Kuramoto mean-field coupling - K + ζ — coupling plus exogenous global driver at Ψ = 0 - ζ only — pure intention/carrier pull (no inter-oscillator coupling)

The ζ sin(Ψ−θ) term acts as a Lagrangian pull toward the carrier phase Ψ without its own dynamics (no ˙Ψ equation).

N = 200
n_steps = 800
dt = 0.01
omega = rng.normal(0, 0.8, N)
theta0 = rng.uniform(-np.pi, np.pi, N)

configs = {
    "K=3, ζ=0": dict(K=3.0, zeta=0.0, psi_mode="mean_field"),
    "K=3, ζ=2": dict(K=3.0, zeta=2.0, psi_driver=0.0, psi_mode="external"),
    "K=0, ζ=3": dict(K=0.0, zeta=3.0, psi_driver=0.0, psi_mode="external"),
}

fig, axes = plt.subplots(1, 3, figsize=(14, 4), sharey=True)
R_traces = {}

for ax, (label, kw) in zip(axes, configs.items()):
    theta = theta0.copy()
    R_hist = np.empty(n_steps)
    for t in range(n_steps):
        out = kuramoto_sakaguchi_step(theta, omega, dt=dt, **kw)
        theta = out["theta1"]
        R_hist[t] = out["R"]
    R_traces[label] = R_hist
    ax.plot(np.arange(n_steps) * dt, R_hist, lw=1.5)
    ax.set_title(label, fontsize=11)
    ax.set_xlabel("Time (s)")
    ax.set_ylim(0, 1.05)
    ax.axhline(0.95, color="red", ls="--", lw=0.8, label="Theurgic threshold")
    ax.legend(fontsize=8)

axes[0].set_ylabel("Order parameter R")
fig.suptitle("Kuramoto–Sakaguchi: ζ sin(Ψ−θ) Global Driver Effect", fontsize=13)
plt.tight_layout()
plt.show()

for label, R in R_traces.items():
    print(f"  {label}: R_final = {R[-1]:.3f}")

3. Sakaguchi Phase-Lag α: Frustration Effect

With α > 0, the coupling term becomes sin(ψ_r − θ_i − α), introducing a phase-lag frustration that prevents full synchronisation (R < 1 at equilibrium). This models finite-size effects per arXiv:2004.06344.

alphas = [0.0, 0.3, 0.6, 0.9, 1.2]
theta0_alpha = rng.uniform(-np.pi, np.pi, N)

fig, ax = plt.subplots(figsize=(8, 4))
for alpha in alphas:
    theta = theta0_alpha.copy()
    R_hist = np.empty(n_steps)
    for t in range(n_steps):
        out = kuramoto_sakaguchi_step(
            theta, omega, dt=dt, K=4.0, alpha=alpha, psi_mode="mean_field",
        )
        theta = out["theta1"]
        R_hist[t] = out["R"]
    ax.plot(np.arange(n_steps) * dt, R_hist, lw=1.5, label=f"α={alpha:.1f}")

ax.set_xlabel("Time (s)")
ax.set_ylabel("Order parameter R")
ax.set_title("Sakaguchi Phase-Lag Frustration (K=4)")
ax.legend(fontsize=9)
ax.set_ylim(0, 1.05)
plt.tight_layout()
plt.show()

4. Multi-Layer UPDE — 16 SCPN Layers

Full Paper 27 UPDE: each of 16 layers has its own oscillator population, coupled through Knm (intra-layer diagonal + inter-layer off-diagonal).

We add a uniform ζ = 1.0 global driver at Ψ = 0 and track per-layer coherence R_m(t) and global R(t) over 2000 steps.

L = 16
N_per = 50
n_upde_steps = 2000
dt_upde = 0.005

spec16 = build_knm_paper27(L=16, zeta_uniform=1.0)
upde = UPDESystem(spec=spec16, dt=dt_upde, psi_mode="external")

theta_layers = [rng.uniform(-np.pi, np.pi, N_per) for _ in range(L)]
omega_layers = [OMEGA_N_16[m] + rng.normal(0, 0.2, N_per) for m in range(L)]

result = upde.run(
    n_upde_steps, theta_layers, omega_layers,
    psi_driver=0.0,
)

R_layer = result["R_layer_hist"]   # (n_steps, 16)
R_global = result["R_global_hist"] # (n_steps,)
t_axis = np.arange(n_upde_steps) * dt_upde

print(f"R_global: {R_global[0]:.3f}{R_global[-1]:.3f}")
for m in range(L):
    print(f"  L{m+1:2d}: R = {R_layer[-1, m]:.3f}")
fig, axes = plt.subplots(2, 1, figsize=(12, 7), gridspec_kw={"height_ratios": [2, 3]})

# Top: global R
axes[0].plot(t_axis, R_global, color="black", lw=2)
axes[0].axhline(0.95, color="red", ls="--", lw=1, label="Theurgic threshold (R=0.95)")
axes[0].set_ylabel("R_global")
axes[0].set_title("16-Layer UPDE — Global Order Parameter")
axes[0].legend(fontsize=9)
axes[0].set_ylim(0, 1.05)

# Bottom: per-layer R heatmap
im = axes[1].imshow(
    R_layer.T, aspect="auto", cmap="inferno",
    extent=[0, t_axis[-1], 16.5, 0.5], vmin=0, vmax=1,
)
axes[1].set_xlabel("Time (s)")
axes[1].set_ylabel("SCPN Layer")
axes[1].set_title("Per-Layer Coherence R_m(t)")
axes[1].set_yticks(range(1, 17))
axes[1].set_yticklabels([f"L{i}" for i in range(1, 17)], fontsize=7)
plt.colorbar(im, ax=axes[1], label="R_m")

plt.tight_layout()
plt.show()

5. PAC Gating — Cross-Frequency Coupling

Paper 27 §4.3 describes phase-amplitude coupling (PAC) between layers. The pac_gamma parameter modulates inter-layer coupling by (1 + γ·(1 − R_source)), boosting coupling when the source layer is less coherent (low R → stronger drive).

gammas = [0.0, 0.5, 1.0, 2.0]
spec_pac = build_knm_paper27(L=8, zeta_uniform=0.5)
n_pac = 1500

fig, ax = plt.subplots(figsize=(8, 4))
for gamma in gammas:
    upde_pac = UPDESystem(spec=spec_pac, dt=0.005, psi_mode="external")
    th = [rng.uniform(-np.pi, np.pi, 40) for _ in range(8)]
    om = [OMEGA_N_16[m] + rng.normal(0, 0.15, 40) for m in range(8)]
    res = upde_pac.run(n_pac, th, om, psi_driver=0.0, pac_gamma=gamma)
    ax.plot(
        np.arange(n_pac) * 0.005, res["R_global_hist"],
        lw=1.5, label=f"γ={gamma:.1f}",
    )

ax.set_xlabel("Time (s)")
ax.set_ylabel("R_global")
ax.set_title("PAC Gating Effect on Global Synchronisation")
ax.legend(fontsize=9)
ax.set_ylim(0, 1.05)
plt.tight_layout()
plt.show()

6. Plasma Sync via FusionKernel.phase_sync_step()

The reviewer requested ζ sin(Ψ−θ) inside the fusion kernel path. Here we simulate a population of 128 plasma mode oscillators (representing ELM pacing, tearing modes, edge oscillations) and show the phase-reduction kernel converging under the global driver.

This uses FusionKernel.phase_sync_step() directly — the same entry point a real control loop would call.

import json, tempfile
from scpn_control.core.fusion_kernel import FusionKernel

# Minimal config just to instantiate the kernel
cfg = {
    "reactor_name": "phase_sync_demo",
    "dimensions": {"R_min": 0.5, "R_max": 2.5, "Z_min": -1.5, "Z_max": 1.5},
    "grid_resolution": [9, 9],
    "coils": {"positions": [], "currents": [], "turns": []},
    "physics": {},
    "solver": {"method": "sor", "max_iterations": 5, "tol": 1e-4},
    "phase_sync": {
        "K": 3.0,
        "alpha": 0.0,
        "zeta": 1.5,
        "psi_mode": "external",
        "actuation_gain": 1.0,
    },
}

with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
    json.dump(cfg, f)
    cfg_path = f.name

fk = FusionKernel(cfg_path)

# Simulate plasma mode ensemble
N_modes = 128
theta_plasma = rng.uniform(-np.pi, np.pi, N_modes)
omega_plasma = rng.normal(0, 0.6, N_modes)
Psi_intention = 0.0  # carrier phase

n_fk_steps = 600
dt_fk = 0.01
R_fk = np.empty(n_fk_steps)
theta_hist = np.empty((n_fk_steps, N_modes))

for t in range(n_fk_steps):
    out = fk.phase_sync_step(
        theta_plasma, omega_plasma, dt=dt_fk, psi_driver=Psi_intention,
    )
    theta_plasma = out["theta1"]
    R_fk[t] = out["R"]
    theta_hist[t] = theta_plasma

print(f"FusionKernel phase_sync: R = {R_fk[0]:.3f}{R_fk[-1]:.3f}")
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# Left: R(t) convergence
axes[0].plot(np.arange(n_fk_steps) * dt_fk, R_fk, color="navy", lw=2)
axes[0].axhline(0.95, color="red", ls="--", lw=1, label="Theurgic threshold")
axes[0].set_xlabel("Time (s)")
axes[0].set_ylabel("R (plasma mode coherence)")
axes[0].set_title("FusionKernel.phase_sync_step() — Plasma Sync")
axes[0].legend(fontsize=9)
axes[0].set_ylim(0, 1.05)

# Right: phase portrait (unit circle snapshots)
for snap_idx, color, label in [
    (0, "gray", "t=0"),
    (n_fk_steps // 4, "orange", f"t={n_fk_steps//4 * dt_fk:.1f}s"),
    (-1, "green", f"t={n_fk_steps * dt_fk:.1f}s"),
]:
    th = theta_hist[snap_idx]
    axes[1].scatter(
        np.cos(th), np.sin(th), s=8, alpha=0.6, color=color, label=label,
    )

circle = np.linspace(0, 2 * np.pi, 200)
axes[1].plot(np.cos(circle), np.sin(circle), "k-", lw=0.5, alpha=0.3)
axes[1].arrow(0, 0, np.cos(Psi_intention) * 0.85, np.sin(Psi_intention) * 0.85,
              head_width=0.06, color="red", lw=2, label="Ψ carrier")
axes[1].set_xlim(-1.3, 1.3)
axes[1].set_ylim(-1.3, 1.3)
axes[1].set_aspect("equal")
axes[1].set_title("Phase Portrait — Mode Oscillators on Unit Circle")
axes[1].legend(fontsize=8, loc="upper left")

plt.tight_layout()
plt.show()

7. Actuation Gain Sweep — Control Authority

The actuation_gain parameter scales both K and ζ, modelling how the controller's authority over the plasma mode ensemble increases. This maps directly to SNN or PID output amplitude in a closed-loop control scenario.

gains = [0.2, 0.5, 1.0, 2.0, 5.0]

fig, ax = plt.subplots(figsize=(8, 4))
for gain in gains:
    theta_g = rng.uniform(-np.pi, np.pi, 128)
    omega_g = rng.normal(0, 0.6, 128)
    R_g = np.empty(400)
    for t in range(400):
        out = fk.phase_sync_step(
            theta_g, omega_g, dt=0.01, psi_driver=0.0, actuation_gain=gain,
        )
        theta_g = out["theta1"]
        R_g[t] = out["R"]
    ax.plot(np.arange(400) * 0.01, R_g, lw=1.5, label=f"gain={gain:.1f}")

ax.set_xlabel("Time (s)")
ax.set_ylabel("R")
ax.set_title("Actuation Gain Sweep — FusionKernel Phase Sync")
ax.legend(fontsize=9)
ax.set_ylim(0, 1.05)
plt.tight_layout()
plt.show()

8. Lyapunov Stability — ½Σ(1 − cos(θ_i − Ψ))

A natural Lyapunov candidate for the ζ-driven system is:

\[V(t) = \frac{1}{N}\sum_{i=1}^{N} \bigl(1 - \cos(\theta_i - \Psi)\bigr)\]

V → 0 as all oscillators lock to the carrier Ψ. Monotonic decrease of V confirms stability of the phase-locked state.

def lyapunov_V(theta, psi):
    """V = (1/N) Σ (1 - cos(θ_i - Ψ)).  V=0 at full lock."""
    return float(np.mean(1.0 - np.cos(theta - psi)))

N_ly = 200
theta_ly = rng.uniform(-np.pi, np.pi, N_ly)
omega_ly = rng.normal(0, 0.5, N_ly)
Psi_ly = 0.0
n_ly = 1000

V_hist = np.empty(n_ly)
R_ly_hist = np.empty(n_ly)

for t in range(n_ly):
    V_hist[t] = lyapunov_V(theta_ly, Psi_ly)
    out = kuramoto_sakaguchi_step(
        theta_ly, omega_ly, dt=0.01, K=3.0, zeta=2.0,
        psi_driver=Psi_ly, psi_mode="external",
    )
    theta_ly = out["theta1"]
    R_ly_hist[t] = out["R"]

fig, ax1 = plt.subplots(figsize=(8, 4))
t_ly = np.arange(n_ly) * 0.01

color_v = "tab:blue"
ax1.plot(t_ly, V_hist, color=color_v, lw=2, label="V(t) Lyapunov")
ax1.set_xlabel("Time (s)")
ax1.set_ylabel("V(t)", color=color_v)
ax1.tick_params(axis="y", labelcolor=color_v)

ax2 = ax1.twinx()
color_r = "tab:red"
ax2.plot(t_ly, R_ly_hist, color=color_r, lw=2, ls="--", label="R(t)")
ax2.set_ylabel("R(t)", color=color_r)
ax2.tick_params(axis="y", labelcolor=color_r)

fig.suptitle("Lyapunov V(t) Decay ↔ Order Parameter R(t) Rise", fontsize=12)
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, fontsize=9, loc="center right")
plt.tight_layout()
plt.show()

# Monotonicity check (allow small numerical noise)
dV = np.diff(V_hist)
violations = int(np.sum(dV > 1e-6))
print(f"Lyapunov monotonicity violations (dV > 1e-6): {violations}/{n_ly-1}")
print(f"V: {V_hist[0]:.4f}{V_hist[-1]:.6f}")

9. SNN Controller Output → Phase Sync (Closed-Loop Sketch)

In a real control loop, the SNN controller output maps to actuation_gain and optionally to psi_driver (carrier phase tracking a reference). Below is a toy sketch showing how a sinusoidal SNN output modulates the phase sync kernel's authority over plasma modes.

n_cl = 1000
dt_cl = 0.01
theta_cl = rng.uniform(-np.pi, np.pi, 128)
omega_cl = rng.normal(0, 0.6, 128)

R_cl = np.empty(n_cl)
gain_cl = np.empty(n_cl)

for t in range(n_cl):
    # Simulated SNN controller output: ramps up, then oscillates
    snn_out = 0.5 + 0.5 * np.tanh((t - 200) / 50) + 0.15 * np.sin(0.05 * t)
    gain_cl[t] = max(snn_out, 0.1)

    out = fk.phase_sync_step(
        theta_cl, omega_cl, dt=dt_cl,
        psi_driver=0.0, actuation_gain=gain_cl[t],
    )
    theta_cl = out["theta1"]
    R_cl[t] = out["R"]

fig, ax1 = plt.subplots(figsize=(10, 4))
t_cl = np.arange(n_cl) * dt_cl

ax1.plot(t_cl, R_cl, color="navy", lw=2, label="R(t) plasma coherence")
ax1.set_xlabel("Time (s)")
ax1.set_ylabel("R(t)", color="navy")
ax1.set_ylim(0, 1.05)

ax2 = ax1.twinx()
ax2.plot(t_cl, gain_cl, color="darkorange", lw=1.5, ls="--", label="SNN gain(t)")
ax2.set_ylabel("actuation_gain", color="darkorange")

lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, fontsize=9, loc="center right")
ax1.set_title("Closed-Loop Sketch: SNN Output → Phase Sync Gain → Plasma R(t)")
plt.tight_layout()
plt.show()

10. Full PAC Cross-Layer SNN Closed-Loop

A dual-pool SNN controls two plasma mode ensembles (low-frequency MHD modes on L2, high-frequency edge oscillations on L7). The SNN error signal comes from the per-layer R deficit. PAC gating cross-couples the layers: when L2 decoherence rises, L7 coupling strengthens and vice versa — modelling the cross-frequency coupling in Paper 27 §4.3.

# Two-layer UPDE with PAC cross-coupling + dual SNN gains
L_pac = 4  # L1 (slow MHD), L2, L5 (edge), L7 (symbolic)
N_osc = 60
spec_snn = build_knm_paper27(L=L_pac, zeta_uniform=1.0)
upde_snn = UPDESystem(spec=spec_snn, dt=0.005, psi_mode="external")

th_snn = [rng.uniform(-np.pi, np.pi, N_osc) for _ in range(L_pac)]
om_snn = [OMEGA_N_16[m] + rng.normal(0, 0.2, N_osc) for m in range(L_pac)]

n_snn = 1500
R_target = 0.85
Kp = 3.0  # SNN proportional gain

R_per_layer = np.empty((n_snn, L_pac))
gains_snn = np.empty((n_snn, L_pac))

for t in range(n_snn):
    out = upde_snn.step(th_snn, om_snn, psi_driver=0.0, pac_gamma=1.5)
    R_m = out["R_layer"]
    th_snn = out["theta1"]

    # SNN error → gain per layer (proportional controller)
    for m in range(L_pac):
        error = R_target - R_m[m]
        gains_snn[t, m] = max(0.2, 1.0 + Kp * error)
        R_per_layer[t, m] = R_m[m]

    # Apply gains as actuation for next step (scale omega)
    om_snn = [
        (OMEGA_N_16[m] + rng.normal(0, 0.2, N_osc)) * gains_snn[t, m]
        for m in range(L_pac)
    ]

t_snn = np.arange(n_snn) * 0.005
fig, axes = plt.subplots(2, 1, figsize=(11, 6), sharex=True)

layer_labels = [f"L{i+1}" for i in range(L_pac)]
for m in range(L_pac):
    axes[0].plot(t_snn, R_per_layer[:, m], lw=1.5, label=layer_labels[m])
axes[0].axhline(R_target, color="red", ls="--", lw=1, label=f"R_target={R_target}")
axes[0].set_ylabel("R_m(t)")
axes[0].set_title("PAC Cross-Layer SNN — Per-Layer Coherence")
axes[0].legend(fontsize=8, ncol=5)
axes[0].set_ylim(0, 1.05)

for m in range(L_pac):
    axes[1].plot(t_snn, gains_snn[:, m], lw=1.2, label=layer_labels[m])
axes[1].set_xlabel("Time (s)")
axes[1].set_ylabel("SNN gain")
axes[1].set_title("SNN Proportional Controller — Actuation Gains")
axes[1].legend(fontsize=8, ncol=5)

plt.tight_layout()
plt.show()

Summary

Component Location Status
Kuramoto–Sakaguchi + ζ sin(Ψ−θ) scpn_control.phase.kuramoto Verified
Paper 27 Knm (16×16) scpn_control.phase.knm Calibration anchors match
Multi-layer UPDE scpn_control.phase.upde 16-layer convergence shown
FusionKernel hook FusionKernel.phase_sync_step() Config-driven, non-invasive
Lyapunov stability V(t) = (1/N)Σ(1−cos(θ−Ψ)) Monotonic decay confirmed
Rust Kuramoto kernel control-math::kuramoto Rayon-parallelised, 7/7 tests
PAC cross-layer SNN Notebook §10 Dual-pool, per-layer gain control
Markdown export docs/paper27_phase_dynamics.md Auto-generated from notebook