Note
Go to the end to download the full example code.
Jones-Roberts solitons¶
The soliton family of a 2D superfluid, and the trip along it, after Baker-Rasooli et al., Phys. Rev. Lett. 134, 233401 (2025).
A one-dimensional fluid has dark solitons. A two-dimensional one has a whole family of solitary waves, found by Jones and Roberts in 1982, and the family has two halves: a vortex pair at low speed, a rarefaction pulse – a density dip carrying no circulation at all – at high speed, and a critical speed where one becomes the other. What makes it a family rather than two unrelated objects is that a single soliton can travel along it.
That is what this page shows, in a single propagation. Two counter-rotating circulations are imprinted on the beam, as the experiment imprints them with a spatial light modulator, and left alone. They drift together; the depletion between them fills in; and the two phase singularities meet and annihilate, leaving behind a rarefaction pulse – a solitary wave that still travels, still holds together, and has no circulation anywhere in it. Nothing is done to it in between. The soliton walks from one half of the family into the other on its own.
The parameters are the experiment’s, from the paper’s supplementary material: 5 W in a 2.23 mm waist through 20 cm of rubidium at 150 °C, 780 nm and about -10 GHz detuned, \(n_2 = -1.04\times10^{-10}\,\mathrm{m^2/W}\), \(I_{sat} = 60\,\mathrm{W/cm^2}\), and the ~30% losses the paper quotes. Two checks that they are the right ones: they put the beam at 71 healing lengths across, against the fluid radius of \(63\,\xi\) the paper states, and they put the 20 cm cell at \(\tau = 52\), against the \(\tau > 52\) the paper gives for the onset of the transition – the cell ends just about where the circulation dies.
The experiment reaches other \(\tau\) by scanning the fluid beam power, since its cell has one length. A simulation has the whole propagation in hand, so this one simply runs on past the cell to \(\tau = 100\) and watches.
Note
Two conventions travel with this paper and neither is the Bogoliubov one. Its nonlinear length is \(z_{NL} = 1/k_0\Delta n\) and its healing length \(\xi = \sqrt{z_{NL}/k_0}\), both built from \(\Delta n\) rather than from \(c_s^2 = I\,\partial\Delta n/\partial I\). In a saturable medium those differ by \(\sqrt{1+I/I_{sat}}\), which is 1.44 here – 21.9 µm against 31.4 µm. The paper’s are used throughout, so that \(\tau\) and \(\Delta r/\xi\) mean what they mean in the paper.
Warning
The step size for a run like this is a stability condition, not an accuracy one. Split-step applies the linear part exactly in Fourier space, so dispersion cannot limit accuracy on its own – but on a finite-amplitude background the nonlinear step couples modes, and past about a radian of linear phase per step at the grid’s highest wavenumber the resonant modes grow out of round-off. It does not look numerical when it happens: the background fills with density fluctuations of order the density itself and thousands of spurious vortices, which reads as the fluid going turbulent. The solver enforces the bound itself and warns when it reduces the step.
The experiment’s parameters, from the supplementary material.
N2 = -1.04e-10 # nonlinear index in m^2/W, defocusing
I_SAT = 60e4 # saturation intensity, 60 W/cm^2
POWER = 5.0 # fluid beam power in W
WAIST = 2.23e-3 # fluid beam waist in m
L_CELL = 20e-2 # cell length in m
TRANSMISSION = 0.7 # the paper's ~30% losses
WVL = 780e-9 # 87Rb D2, about -10 GHz detuned, cell at 150 C
N_PTS = 1024
K0 = 2 * np.pi / WVL
I0 = 2 * POWER / (np.pi * WAIST**2) # peak intensity of the Gaussian
DN = abs(N2) * I0 / (1 + I0 / I_SAT) # saturated index depression
Z_NL = 1 / (K0 * DN) # the paper's nonlinear length
XI = np.sqrt(Z_NL / K0) # the paper's healing length
XI_BOGOLIUBOV = (1 + I0 / I_SAT) / (K0 * np.sqrt(abs(N2) * I0))
# The window holds the beam and the dark surround it decays into. That the
# beam is Gaussian rather than uniform matters for more than realism: the
# intensity falls to nothing at the edges, so the periodic boundary has no
# fluid to join up. A dipole's phase is only periodic to order dr/W, and on a
# uniform background that mismatch radiates from the seam and fringes the
# whole picture.
WINDOW = 3.5 * WAIST
DX = WINDOW / N_PTS
# Past the cell, which is tau = 52 -- where the paper puts the onset of the
# transition -- so the rarefaction pulse can be watched for a while after it
# forms rather than only at the moment it does.
TAU_END = 100
LENGTH = TAU_END * Z_NL
print(
f"medium: Dn = {DN:.2e}, z_NL = {Z_NL * 1e3:.2f} mm, xi = {XI * 1e6:.1f} um "
f"(Bogoliubov xi would be {XI_BOGOLIUBOV * 1e6:.1f} um)"
)
print(
f"beam: {POWER} W, waist {WAIST * 1e3:.2f} mm = {WAIST / XI:.0f} xi, "
f"I0 = {I0 / I_SAT:.2f} Isat"
)
print(
f"cell: {L_CELL * 1e2:.0f} cm = tau {L_CELL / Z_NL:.0f}; this run goes to "
f"tau {TAU_END}"
)
print(f"grid: {N_PTS}^2, dx = xi/{XI / DX:.1f}, window = {WINDOW / XI:.0f} xi")
def small_signal_alpha():
"""Return the alpha whose saturable absorption gives TRANSMISSION.
The medium bleaches where the beam is bright, so the loss is not
exponential and ``-log(T)/L`` is not the coefficient to hand the solver.
Solved on the radial profile instead.
Returns
-------
float
Small-signal absorption coefficient in 1/m.
"""
r = np.linspace(0, 3 * WAIST, 800)
profile = I0 * np.exp(-2 * r**2 / WAIST**2)
def transmitted(alpha):
"""Power fraction left after the cell at this alpha."""
solution = solve_ivp(
lambda z, intensity: -alpha * intensity / (1 + intensity / I_SAT),
(0, L_CELL),
profile,
rtol=1e-9,
atol=1e-12,
)
return np.trapezoid(solution.y[:, -1] * 2 * np.pi * r, r) / np.trapezoid(
profile * 2 * np.pi * r, r
)
return brentq(lambda a: transmitted(a) - TRANSMISSION, 1e-4, 500, xtol=1e-9)
ALPHA = small_signal_alpha()
print(
f"loss: alpha = {ALPHA:.2f}/m for {TRANSMISSION:.0%} over the cell "
f"(a plain exponential would say {-np.log(TRANSMISSION) / L_CELL:.2f})"
)
def vortex(simu, x0, y0, charge):
"""Return one vortex: a phase winding with a healed core.
Parameters
----------
simu : NLSE
Solver, for its coordinate grids.
x0, y0 : float
Core position in m.
charge : int
Sign of the circulation.
Returns
-------
ndarray
Complex field of unit modulus far from the core.
Notes
-----
The core profile only needs the right asymptotics; whatever is imprinted
radiates its excess within a few nonlinear lengths and relaxes onto the
true one. A Pade profile was tried and left no less sound behind, which
says the relaxation is set by the dipole and not by the core.
"""
x, y = simu.XX - x0, simu.YY - y0
r = np.hypot(x, y)
return r / np.sqrt(r**2 + XI**2) * np.exp(1j * charge * np.arctan2(y, x))
def singularities(field):
"""Return the pixel coordinates of the phase singularities.
How many there are is what says which half of the family the soliton is
in: two for a vortex pair, none for a rarefaction pulse.
Parameters
----------
field : ndarray
Complex field.
Returns
-------
tuple of ndarray
Column indices, row indices, and the sign of each circulation.
"""
phase = np.angle(field)
dx = np.angle(np.exp(1j * (np.roll(phase, -1, axis=1) - phase)))
dy = np.angle(np.exp(1j * (np.roll(phase, -1, axis=0) - phase)))
curl = dx + np.roll(dy, -1, axis=1) - np.roll(dx, -1, axis=0) - dy
rows, cols = np.where(np.abs(curl) > np.pi)
return cols, rows, np.sign(curl[rows, cols])
medium: Dn = 3.22e-05, z_NL = 3.85 mm, xi = 21.9 um (Bogoliubov xi would be 31.4 um)
beam: 5.0 W, waist 2.23 mm = 102 xi, I0 = 1.07 Isat
cell: 20 cm = tau 52; this run goes to tau 100
grid: 1024^2, dx = xi/2.9, window = 357 xi
loss: alpha = 2.52/m for 70% over the cell (a plain exponential would say 1.78)
The imprint, and the propagation. The pair goes in off-centre, as the paper puts it: the Gaussian carries a density gradient, so the healing length the pair sits in changes as it travels, and it is that changing \(\Delta r/\xi\) which walks the soliton along the family.
# Just above the critical separation. Below about 2.5 xi the pair annihilates
# within a couple of nonlinear lengths, which is over before the animation
# starts; above 3 xi it survives the whole run. This sits in between, so the
# transition happens on screen.
SEPARATION = 3.0 * XI
simu = NLSE(
ALPHA, POWER, WINDOW, N2, None, LENGTH, NX=N_PTS, NY=N_PTS, Isat=I_SAT, wvl=WVL
)
envelope = np.exp(-(simu.XX**2 + simu.YY**2) / WAIST**2)
E_in = (
envelope
* vortex(simu, -0.25 * WAIST, +SEPARATION / 2, +1)
* vortex(simu, -0.25 * WAIST, -SEPARATION / 2, -1)
).astype(np.complex64)
frames, taus, gaps, counts, centres = [], [], [], [], []
def follow(sim, A, z, i):
"""Sample the field, and how far apart the two circulations are."""
if i % 120:
return
field = np.asarray(sim._backend.to_numpy(A))
cols, rows, _ = singularities(field)
frames.append(field.copy())
taus.append(z / Z_NL)
counts.append(len(cols))
gap = 0.0
if len(cols) == 2:
gap = float(np.hypot(np.diff(cols)[0], np.diff(rows)[0])) * sim.delta_X / XI
gaps.append(gap)
# Where the soliton is, recorded here rather than hunted for later. With
# two cores it is between them; with none it is the deepest point, but
# searched only near where the soliton was last seen -- looking over the
# beam instead finds whatever else happens to be dark and puts the inset
# on the wrong object.
if len(cols) == 2:
centres.append((float(np.mean(rows)), float(np.mean(cols))))
else:
density = np.abs(field) ** 2
near = np.full(density.shape, np.inf)
if centres:
row0, col0 = (round(v) for v in centres[-1])
span = round(12 * XI / sim.delta_X)
rows_ = slice(max(row0 - span, 0), row0 + span)
cols_ = slice(max(col0 - span, 0), col0 + span)
near[rows_, cols_] = density[rows_, cols_]
else:
near = density
row, col = np.unravel_index(np.argmin(near), near.shape)
centres.append((float(row), float(col)))
simu.out_field(
E_in, LENGTH, delta_z=Z_NL / 20, verbose=False, plot=False, callback=follow
)
taus, gaps, counts = np.array(taus), np.array(gaps), np.array(counts)
paired = counts == 2
print(f"\nsampled {len(frames)} planes out to tau = {taus[-1]:.0f}")
if (~paired).any():
print(
f"circulation absent between tau = {taus[~paired][0]:.0f} and "
f"{taus[~paired][-1]:.0f}"
)
else:
print("the circulation never annihilated")
/home/runner/work/NLSE/NLSE/NLSE/solvers/nlse.py:380: UserWarning: delta_z=1.93e-04 exceeds split-step accuracy limit (4.74e-05). Reducing to 4.27e-05.
delta_z = self._capped_delta_z(delta_z, A, method)
sampled 76 planes out to tau = 100
circulation absent between tau = 15 and 39
The separation, and where the circulation goes. The two singularities close in, meet and vanish – the soliton crossing from the vortex-pair half of the family into the rarefaction-pulse half – and then reappear. The shaded stretch is where the field carries no circulation at all.
fig, ax = plt.subplots(figsize=(7.2, 4.2), layout="constrained")
ax.plot(taus[paired], gaps[paired], "o", ms=4, label=r"vortex pair, $\Delta r$")
if (~paired).any():
ax.axvspan(
taus[~paired][0],
taus[~paired][-1],
color="tab:red",
alpha=0.15,
label="no circulation (rarefaction pulse)",
)
ax.axvline(L_CELL / Z_NL, color="gray", ls="--", lw=1.1)
ax.annotate(
"end of the\n20 cm cell",
xy=(L_CELL / Z_NL, 0.82),
xycoords=("data", "axes fraction"),
xytext=(6, 0),
textcoords="offset points",
fontsize=9,
color="gray",
)
ax.set_xlabel(r"$\tau = z / z_{NL}$")
ax.set_ylabel(r"separation $\Delta r / \xi$")
ax.set_ylim(0, 5.4) # headroom for the insets
ax.set_title("The circulation dies, and comes back", pad=26)
ax.legend(fontsize=9, loc="lower right")
def crop_phase(frame, half=7):
"""Return the phase around the soliton, ``half`` healing lengths either side.
Centred on the position recorded during the propagation, where the
circulations were still in hand, rather than on anything re-derived from
the saved plane.
Parameters
----------
frame : int
Index into the sampled planes.
half : float
Half-width of the crop in healing lengths.
Returns
-------
ndarray
The cropped phase, measured from the surrounding fluid.
"""
field = frames[frame]
row, col = (round(v) for v in centres[frame])
span = round(half * XI / simu.delta_X)
rows = slice(max(row - span, 0), row + span)
cols = slice(max(col - span, 0), col + span)
crop = field[rows, cols]
# Referenced to the fluid around the soliton rather than to zero. Each
# plane carries its own accumulated nonlinear phase, tens of radians by
# the end, so without this the three insets sit on unrelated parts of a
# cyclic colormap and look like three different physical situations.
border = np.concatenate([crop[0, :], crop[-1, :], crop[:, 0], crop[:, -1]])
reference = np.angle(np.mean(border / np.abs(border)))
return np.angle(crop * np.exp(-1j * reference))
# One inset per regime, so the plot says what each stretch of it looks like
# without having to be read against the animation. The phase is the telling
# one: two singularities, none, then two again.
before = int(np.argmax(~paired)) // 2
during = int(np.argmax(~paired) + np.argmin(~paired[np.argmax(~paired) :]) // 2)
after = len(frames) - 4
for position, frame, label in (
(0.02, before, "pair"),
(0.36, during, "rarefaction pulse"),
(0.70, after, "pair again"),
):
inset = ax.inset_axes([position, 0.60, 0.17, 0.28])
inset.imshow(crop_phase(frame), cmap="twilight_shifted", vmin=-np.pi, vmax=np.pi)
inset.set_xticks([]), inset.set_yticks([])
inset.set_title(rf"$\tau={taus[frame]:.0f}$, {label}", fontsize=7.5)
plt.show()

And the thing itself. Density on the left, phase on the right, with any circulations circled: watch the two dark cores approach, merge into a single dip whose phase is smooth all the way round, and then separate again with the circulation restored.
extent = [simu.X[0] / XI, simu.X[-1] / XI, simu.Y[0] / XI, simu.Y[-1] / XI]
def normalised(field):
"""Return the density scaled to its own peak.
Each plane is scaled to itself rather than to the launch, so the 30% the
vapour absorbs over the cell does not slowly dim the picture. It costs the
absolute scale, which is not what this figure is about -- the soliton is a
hole in the fluid, and what matters is how deep it is relative to the
fluid around it.
Parameters
----------
field : ndarray
Complex field.
Returns
-------
ndarray
Density in units of its own maximum.
"""
density = np.abs(field) ** 2
return density / density.max()
fig, axs = plt.subplots(1, 2, figsize=(9.6, 4.8), layout="constrained")
density = axs[0].imshow(
normalised(frames[0]),
cmap="viridis",
origin="lower",
extent=extent,
vmin=0,
vmax=1.05,
)
phase = axs[1].imshow(
np.angle(frames[0]),
cmap="twilight_shifted",
origin="lower",
extent=extent,
vmin=-np.pi,
vmax=np.pi,
)
# One colour per sign of the circulation, so that the pair reads as two
# opposite objects rather than two dots -- which is the whole reason they
# advect each other, and the whole reason they can annihilate.
marks = {
sign: [
ax.plot([], [], "o", mfc="none", mec=colour, ms=11, mew=1.8)[0] for ax in axs
]
for sign, colour in ((+1, "tab:red"), (-1, "tab:blue"))
}
for ax, label in zip(axs, ("density", "phase")):
ax.set_xlim(-45, 45), ax.set_ylim(-30, 30)
ax.set_xlabel(r"$x/\xi$"), ax.set_ylabel(r"$y/\xi$")
ax.set_title(label)
def show(frame):
"""Draw one sampled plane, circling the circulations if there are any."""
field = frames[frame]
density.set_data(normalised(field))
phase.set_data(np.angle(field))
cols, rows, charge = singularities(field)
for sign, artists in marks.items():
here = charge == sign
for artist in artists:
artist.set_data(simu.X[cols[here]] / XI, simu.Y[rows[here]] / XI)
state = "vortex pair" if len(cols) == 2 else "rarefaction pulse"
fig.suptitle(rf"$\tau = {taus[frame]:.0f}$ -- {state}")
return (density, phase, *(a for pair in marks.values() for a in pair))
# Bound at module level rather than dropped: sphinx-gallery looks through the
# example's namespace for an Animation and embeds what it finds there, so one
# that only exists inside a function is a still picture on the page.
anim = FuncAnimation(fig, show, frames=len(frames), interval=100, blit=False)
plt.show()
Total running time of the script: (4 minutes 30.048 seconds)