ToyDrift · Electrostatic drift

Vortex merger

Two blobs of charge of the same sign circle each other under E×B drift and merge into one, the guiding-centre analogue of the merger of two like-signed vortices. The initial density is n(x,y,0)=3∑s=±1exp⁡ ⁣[−(x−xs)2+(y−ys)22(0.55)2],n(x,y,0)=3\sum_{s=\pm1}\exp\!\left[-\frac{(x-x_s)^2+(y-y_s)^2}{2(0.55)^2}\right], with blob centres (xs,ys)=(3.52−1,s)(x_s,y_s)=(\sqrt{3.5^2-1},s), separated by two length units.

Animated charge density showing two blobs winding into one core
Actual output from the script below. Interactive controls are available in the plot above.Open graph in a new tab ↗
Electrostatic energy change after the first Poisson solve
Electrostatic energy relative to the first solved field, at t = 0.02. The t = 0 scalar is an uninitialized zero and is omitted. The drift measures the error of this finite-resolution particle and field calculation; it is not a convergence study.Open graph in a new tab ↗

Physical problem

Two charge blobs in an annulus

PDEs solved by model:

Drift equation:

Poisson equation:

where is the guiding center distribution and

The control variate method can be activated in the Poisson equation; if enabled, the following Poisson equation is solved:

Simulation geometry

Domain used in this run

The actual HollowCylinder geometry reconstructed from this run's metadata. Switch between the 3D view and physical-coordinate slices.

Domain

HollowCylinder

Generated and exported by Struphy.Drag to rotate · Scroll to zoom · Shift-drag to pan

Parameters

ParameterValueDescription
Loading parameters…

Performance

Profiling

Struphy's built-in scope-profiler instruments every propagator, pusher and solver call — these are the real per-call timings from the run above.

Durations

Gantt (rank 0)

Region statistics

RegionCountAvg (s)Min (s)Max (s)Total (s)

Complete source

Simulation script

Download .py

Run it locally

1. Install and compile

Use Python 3.10 or newer with Struphy's system prerequisites installed. Run these commands in your Python environment:

pip install "struphy[pproc]>=3.4.0"
pip install "plasma-plots[plotly]>=0.1.1"
struphy compile

PNG exports need Chrome or Chromium. If Chrome is not installed, run kaleido_get_chrome.

2. Run the script

Download the .py file above, open a terminal in its directory, then run:

python vortex-merger.py

Figures are saved in the current directory. Add --show to display them before saving.

"""Vortex merger: two like-signed blobs of charge co-rotate and merge.

In a strong magnetic field, a cloud of charge drifts with the E×B velocity of its own electric
field. Two blobs of the same sign therefore circle around each other, and when they start close
enough they merge into one: the guiding-centre analogue of the merger of two like-signed vortices
in a two-dimensional incompressible fluid.

Follows the setup of Struphy's diocotron example (examples/ToyGyrokinetic/diocotron_instability):
an annulus with grounded walls, a uniform background field, and the ToyDrift model.

Install dependencies and compile the kernels (Python 3.10 or newer):

    pip install "struphy[pproc]>=3.4.0"
    pip install "plasma-plots[plotly]>=0.1.1"
    struphy compile

PNG exports require Chrome or Chromium. If Chrome is not installed, run:

    kaleido_get_chrome

Save this file and run it from the directory where you want the output:

    python vortex-merger.py

Figures are saved in the current directory; add --show to display them before saving.
"""

import argparse

import numpy as np

from struphy import (
    BaseUnits,
    BinningPlot,
    BoundaryParameters,
    DerhamOptions,
    EnvironmentOptions,
    LoadingParameters,
    SavingParameters,
    Simulation,
    SortingParameters,
    Time,
    WeightsParameters,
    domains,
    equils,
    grids,
    maxwellians,
)
from struphy.models import ToyDrift
from plasma_plots import save_figure

# Two Gaussian blobs of peak density `peak` and width `width`, on the circle of radius `ring_radius`,
# with their centres `separation` apart.
peak, width, ring_radius, separation = 3.0, 0.55, 3.5, 2.0
half_angle = np.arcsin(separation / (2.0 * ring_radius))
centres = [(ring_radius * np.cos(sign * half_angle), ring_radius * np.sin(sign * half_angle)) for sign in (1, -1)]


def create_simulation() -> Simulation:
    model = ToyDrift(epsilon=1.0, alpha=1.0, base_units=BaseUnits(kBT=1.0))

    # An annulus between r = 1 and r = 6 with grounded walls and a uniform background field.
    domain = domains.HollowCylinder(a1=1.0, a2=6.0, Lz=10.0)
    equil = equils.HomogenSlab()
    grid = grids.TensorProductGrid(num_elements=(32, 64, 1), mpi_dims_mask=(False, True, False))
    derham_opts = DerhamOptions(degree=(3, 3, 1), bcs=(("dirichlet", "dirichlet"), None, None))
    time_opts = Time(dt=0.02, Tend=20.0, split_algo="LieTrotter")

    # A binned radial-angular density snapshot at every step.
    density_bins = BinningPlot(slice="e1_e2", n_bins=(48, 96), ranges=((0.0, 1.0), (0.0, 1.0)))
    model.kinetic_ions.set_markers(
        loading_params=LoadingParameters(ppc=30, loading="sobol_standard", spatial="disc"),
        # The markers are loaded over the whole annulus; those outside the blobs carry no weight and are removed.
        weights_params=WeightsParameters(control_variate=True, reject_weights=True, threshold=0.0001),
        boundary_params=BoundaryParameters(),
        sorting_params=SortingParameters(boxes_per_dim=(8, 8, 1), do_sort=True, sorting_frequency=5),
        saving_params=SavingParameters(binning_plots=(density_bins,)),
        bufsize=2.0,
    )

    model.propagators.gc_poisson.options = model.propagators.gc_poisson.Options()
    model.propagators.push_gc_bxe.options = model.propagators.push_gc_bxe.Options(
        algo="discrete_gradient_1st_order_newton",
        evaluate_e_field=True,
        # The Newton iteration of the discrete-gradient push, which needs about ten iterations per step at the
        # default 1e-7, is the main cost of the run. 1e-5 is far below the error of the time step.
        tol=1e-5,
    )
    a1, a2 = domain.params["a1"], domain.params["a2"]

    def two_blobs(etas):
        radius = a1 + (a2 - a1) * etas[:, 0]
        angle = 2.0 * np.pi * etas[:, 1]
        x, y = radius * np.cos(angle), radius * np.sin(angle)
        return sum(peak * np.exp(-((x - cx) ** 2 + (y - cy) ** 2) / (2.0 * width**2)) for cx, cy in centres)

    model.kinetic_ions.var.add_background(maxwellians.GyroMaxwellian2D(n=(0.0, None)))
    model.kinetic_ions.var.add_initial_condition(maxwellians.GyroMaxwellian2D(n=(two_blobs, None)))

    env = EnvironmentOptions(
        out_folders="struphy_gallery_runs",
        sim_folder="vortex_merger",
    )
    sim = Simulation(
        model=model,
        name="Vortex merger",
        description=(
            "Two blobs of charge of the same sign circle each other under E×B drift "
            "and merge into one, the guiding-centre analogue of the merger of two "
            "like-signed vortices."
            r" The initial density is $$n(x,y,0)=3\sum_{s=\pm1}\exp\!\left[-\frac{(x-x_s)^2+(y-y_s)^2}{2(0.55)^2}\right],$$"
            r" with blob centres :math:`(x_s,y_s)=(\sqrt{3.5^2-1},s)`, separated by two length units."
        ),
        env=env,
        time_opts=time_opts,
        domain=domain,
        equil=equil,
        grid=grid,
        derham_opts=derham_opts,
    )
    return sim


def pproc(sim: Simulation, show: bool = False):
    a1, a2 = sim.domain.params["a1"], sim.domain.params["a2"]
    time_opts = sim.time_opts
    import xarray as xr
    from scipy.ndimage import map_coordinates

    output = sim.output
    output.pproc()
    density = output.evaluate("kinetic_ions/e1_e2_density/f")
    times = density.t.values
    if times[-1] < time_opts.Tend - 0.5 * time_opts.dt:
        raise RuntimeError("Vortex-merger simulation ended before the requested final time")

    # Display the radial-angular bins in the physical plane. Pad the angular
    # coordinate periodically so that interpolation is continuous at theta = 0.
    axis = np.linspace(-a2, a2, 128)
    xx, yy = np.meshgrid(axis, axis)
    rr = np.hypot(xx, yy)
    eta_r = (rr - a1) / (a2 - a1)
    eta_theta = np.mod(np.arctan2(yy, xx) / (2 * np.pi), 1.0)
    ir = (eta_r - float(density.eta1[0])) / float(density.eta1[1] - density.eta1[0])
    itheta = (eta_theta - float(density.eta2[0])) / float(density.eta2[1] - density.eta2[0]) + 1
    picks = np.unique(np.linspace(0, len(times) - 1, min(100, len(times)), dtype=int))
    images = []
    for index in picks:
        bins = density.isel(t=index).transpose("eta1", "eta2").values
        padded = np.pad(bins, ((0, 0), (1, 1)), mode="wrap")
        image = map_coordinates(padded, [ir, itheta], order=1, mode="nearest").astype(np.float32)
        image[(rr < a1) | (rr > a2)] = np.nan
        images.append(image)
    mapped = xr.DataArray(np.array(images), dims=("t", "y", "x"), coords={"t": times[picks], "x": axis, "y": axis})
    movie = mapped.plasma.plot.animation(
        x="x", y="y", max_frames=150, vmin=0.0, vmax=float(density.max()), cmap="viridis",
        title="Vortex merger: binned charge density", xlabel="x", ylabel="y", colorbar_label="density",
        equal_aspect=True, backend="plotly",
    )
    save_figure(movie, "vortex-merger", height=750, frame=len(movie.fig.frames) // 2, show=show)

    # The first Poisson solve initializes the field energy after t=0. Compare
    # subsequent field energies to that first solved state, not to the zero placeholder.
    energy = output.scalars["en_phi"].isel(t=slice(1, None))
    change = energy.plasma.analysis.drift() / energy.isel(t=0)
    change.attrs.update(label="(W − W₁) / W₁", units="")
    energy_figure = change.plasma.plot.timeseries(
        logy=False, title="Vortex merger: electrostatic-energy change", backend="plotly"
    )
    drift = float(energy.plasma.analysis.relative_error().max())
    print(f"Maximum relative drift of the electrostatic energy: {drift:.2e}")
    save_figure(energy_figure, "vortex-merger-energy", show=show)


if __name__ == "__main__":
    argparser = argparse.ArgumentParser(description="Run the vortex merger example.")
    argparser.add_argument(
        "--pproc-only",
        action="store_true",
        help="Run post-processing on an existing simulation instead of running a new one.",
    )
    argparser.add_argument("--show", action="store_true", help="Show the figures before saving them.")
    args = argparser.parse_args()

    simulation = create_simulation()
    if not args.pproc_only:
        simulation.run()
    pproc(simulation, show=args.show)

See the post-processing guide for more ways to inspect the result.