GuidingCenter · Stellarator particle orbits

Guiding-center orbits in a GVEC stellarator

GVEC creates a five-field-period stellarator equilibrium, then Struphy follows passing and mirror-trapped guiding centers in its three-dimensional magnetic field.

Actual output from the script below. Interactive controls are available in the plot above.
A poloidal cut through the nested magnetic surfaces generated by GVEC. The pressure and rotational transform on the right are evaluated directly from the newly generated equilibrium state.
Each marker's orbit projected on the (R, Z) plane. The grey curves are the flux surfaces at five toroidal angles across one field period, whose envelope is the region the projection can reach. The trapped markers stay within a narrow band of flux surfaces and retrace it, while the passing ones sweep the whole cross-section as they circulate. Unlike a tokamak, the stellarator cross-section rotates with the toroidal angle, so these projections are not closed banana curves: the width of the band is the radial excursion, the filling-in is the toroidal motion.
A sign change in parallel velocity identifies a magnetic-mirror reflection: the markers launched with a small parallel velocity are caught in the helical wells of the stellarator, while the faster ones circulate. The magnetic moment is a coordinate of the model and is conserved exactly; the total energy drifts by a few percent, which is set by the accuracy of the coarse FEEC projection of the GVEC field rather than by the time integrator — the drift does not fall when the time step is reduced.

Physical problem

Guiding centers in a generated GVEC stellarator

GVEC creates a five-field-period stellarator equilibrium, then Struphy follows passing and mirror-trapped guiding centers in its three-dimensional magnetic field.

PDEs solved by model:

Guiding-center Vlasov equation:

where is the guiding center distribution and

Simulation geometry

Domain used in this run

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

Domain

GVECunit

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

Parameters

ParameterValueDescription
Loading parameters…
Model
GuidingCenter
Domain
GVECunit (generated by pyGVEC)
Grid
8 × 16 × 16
FEEC degree
2 × 2 × 2
Integrator
Discrete gradient 1st order
Evolution
2,000 steps
Visualization
Plotly

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

Requires Struphy with compiled kernels and Plotly. Run struphy compile once, then execute python gvec-equilibrium.py.

"""Create a compact GVEC MHD equilibrium, then use it as a Struphy magnetic geometry.

GVEC first minimizes a five-field-period stellarator equilibrium defined entirely by
a Python parameter dictionary. Its newly written final state is passed directly to Struphy's `GVECequilibrium`,
which supplies both the curved `GVECunit` mapping and the equilibrium magnetic
field to Struphy's guiding-center model. The figures expose the resulting
three-dimensional flux geometry and particle orbits, one panel per marker with its
orbit projected on the poloidal plane, a poloidal cut with the radial equilibrium
profiles, and orbit diagnostics from the Struphy run.

The grid covers a single field period, which is what lets it resolve the helical
magnetic wells: markers launched with a small parallel velocity are mirror-trapped
in them, the faster ones circulate.

Requires the optional physics dependencies (`pip install -e ".[phys]"`) and
compiled Struphy kernels (`struphy compile`).
"""

from pathlib import Path
from types import MethodType

import h5py
import numpy as np
import plotly.graph_objects as go

from struphy import (
    BoundaryParameters,
    DerhamOptions,
    EnvironmentOptions,
    LoadingParameters,
    SavingParameters,
    Simulation,
    Time,
    WeightsParameters,
    domains,
    equils,
    grids,
    maxwellians,
)
from struphy.models import GuidingCenter

# Keep both the GVEC solve and the following FEEC simulation deliberately
# small. The complete equilibrium input is defined below, so the example has
# no external parameter or state-file dependency.
# The grid covers one field period (`use_nfp=True`), so its ten-ish toroidal elements resolve a single
# helical ripple. Spanning the whole torus instead would put two elements on each of the five ripples,
# which smooths away the magnetic wells and leaves every orbit passing.
mapping_elements = (8, 16, 16)
mapping_degree = (2, 2, 2)
speed = 1.5
pitches = (-0.85, -0.5, -0.2, 0.1, 0.2, 0.5)
start_rho = 0.55


grad_b_step = 1e-5  # central-difference step in logical coordinates


def gvec_grad_b_1(equilibrium, *etas, squeeze_out=False):
    """Covariant derivatives of |B| in Struphy's logical coordinates, by central differences.

    `GuidingCenter` needs ∇|B|, which Struphy's GVEC adapter does not provide
    (`GVECequilibrium.gradB1` raises `NotImplementedError`). GVEC exposes exact derivatives with
    respect to its own (r, θ, ζ), but Struphy's logical cube is not a per-axis rescaling of those
    coordinates, so converting them needs the full Jacobian of the map rather than three factors.
    Differencing `absB0` instead is consistent by construction with the field strength the model
    evaluates, at the cost of six evaluations per call — negligible for a handful of markers.

    The first direction is radial and not periodic, so samples are kept inside the domain and the
    true spacing is used; the two angles wrap around. Only grid evaluation is supported, which is how
    the equilibrium is consumed: `Propagator.projected_equil` projects ∇|B| onto the FEEC spaces once,
    and `GVECequilibrium.absB0` does not support flat marker evaluation either.
    """
    if len(etas) != 3:
        raise NotImplementedError("gvec_grad_b_1 evaluates on a grid; pass eta1, eta2, eta3")
    coordinates = [np.atleast_1d(np.asarray(eta, dtype=float)) for eta in etas]

    derivatives = []
    for axis in range(3):
        upper = coordinates[axis] + grad_b_step
        lower = coordinates[axis] - grad_b_step
        if axis == 0:
            upper = np.minimum(upper, 1.0)
            lower = np.maximum(lower, 0.0)
            spacing = upper - lower  # one-sided at the radial boundaries
        else:
            upper = np.mod(upper, 1.0)
            lower = np.mod(lower, 1.0)
            spacing = 2.0 * grad_b_step

        def sample(shifted):
            shifted_coordinates = list(coordinates)
            shifted_coordinates[axis] = shifted
            return equilibrium.absB0(*shifted_coordinates, squeeze_out=squeeze_out)

        derivatives.append((sample(upper) - sample(lower)) / spacing)
    return tuple(derivatives)


def gvec_parameters() -> dict:
    """A complete five-field-period stellarator equilibrium defined through pyGVEC's API."""
    return {
        "ProjectName": "struphy_gallery_stellarator",
        "whichInitEquilibrium": 0,
        "init_LA": True,
        "iota": {"type": "polynomial", "scale": -1.0, "coefs": (0.86, -0.08)},
        "pres": {"type": "polynomial", "scale": 1.0, "coefs": (0.02, -0.0137)},
        "PsiEdge": 1.0,
        # A compact, reduced stellarator boundary. Non-zero toroidal Fourier
        # modes twist both the magnetic axis and the last closed flux surface
        # through five field periods; no external input or state file is used.
        "X1_b_cos": {
            (0, 0): 5.5,
            (0, 1): 0.2354,
            (1, -1): -0.2233,
            (1, 0): 0.47685,
            (1, 1): -0.0121,
            (2, -1): 0.1,
            (2, 0): 0.0616,
        },
        "X2_b_sin": {
            (0, 1): 0.1155,
            (1, -1): 0.2233,
            (1, 0): 0.62315,
            (1, 1): -0.0121,
            (2, -1): 0.132,
            (2, 0): 0.06435,
        },
        "X1_a_cos": {(0, 0): 5.59625, (0, 1): 0.3586},
        "X2_a_sin": {(0, 1): 0.28765},
        "sgrid": {"nElems": 5, "grid_type": 0},
        "degGP": 8,
        "X1X2_deg": 5,
        "LA_deg": 5,
        "X1_mn_max": (2, 2),
        "X2_mn_max": (2, 2),
        "LA_mn_max": (2, 2),
        "X1_sin_cos": "_cos_",
        "X2_sin_cos": "_sin_",
        "LA_sin_cos": "_sin_",
        "nfp": 5,
        "start_dt": 0.3,
        "PrecondType": 1,
        "MinimizerType": 10,
        "maxiter": 1000,
        "totaliter": 1000,
        "minimize_tol": 1.0e-4,
    }


def create_gvec_equilibrium(workdir: Path) -> tuple[equils.GVECequilibrium, int, float]:
    """Run GVEC from our Python parameters and expose its new final state to Struphy."""
    import gvec

    # pyGVEC writes our new parameter and state files into `workdir`. This
    # compact case normally converges in fewer than five hundred iterations.
    run = gvec.run(
        gvec_parameters(), runpath=workdir, quiet=True, redirect_gvec_stdout=True
    )
    equilibrium = equils.GVECequilibrium(
        rel_path=False,
        param_file=str(run.state.parameterfile),
        dat_file=str(run.state.statefile),
        use_nfp=True,
        num_elements=mapping_elements,
        degree=mapping_degree,
    )
    equilibrium.gradB1 = MethodType(gvec_grad_b_1, equilibrium)
    return equilibrium, int(run.GVEC_iter_used), float(run.max_force)


def make_simulation(equilibrium, folder: str, domain=None, **extra) -> Simulation:
    simulation_domain = domain or equilibrium.numerical_domain
    if domain is not None:
        equilibrium.domain = simulation_domain
    b_start = float(equilibrium.absB0(start_rho, 0.0, 0.0, squeeze_out=True))
    initial = tuple(
        (
            start_rho,
            0.0,
            0.0,
            speed * pitch,
            speed**2 * (1.0 - pitch**2) / (2.0 * b_start),
        )
        for pitch in pitches
    )
    model = GuidingCenter()
    model.kinetic_ions.set_markers(
        loading_params=LoadingParameters(
            Np=len(initial), seed=1, specific_markers=initial
        ),
        weights_params=WeightsParameters(),
        boundary_params=BoundaryParameters(bc=("remove", "periodic", "periodic")),
        saving_params=SavingParameters(n_markers=1.0),
        bufsize=2.0,
    )
    model.propagators.push_bxe.options = model.propagators.push_bxe.Options(
        maxiter=100, tol=1e-8
    )
    model.propagators.push_parallel.options = model.propagators.push_parallel.Options(
        maxiter=100, tol=1e-8
    )
    model.kinetic_ions.var.add_background(
        maxwellians.GyroMaxwellian2D(n=(1.0, None), B0=b_start)
    )
    return Simulation(
        model=model,
        env=EnvironmentOptions(
            out_folders="struphy_gallery_runs", sim_folder=folder, save_step=5
        ),
        time_opts=Time(dt=0.02, Tend=40.0, split_algo="Strang"),
        domain=simulation_domain,
        equil=equilibrium,
        grid=grids.TensorProductGrid(num_elements=mapping_elements),
        derham_opts=DerhamOptions(
            degree=mapping_degree, bcs=(("free", "free"), None, None)
        ),
        **extra,
    )


# Metadata generation imports this script without executing GVEC. The cheap
# placeholder below is never run; `metadata_overrides` records the runtime
# geometry, while the executable block replaces it with our generated state.
simulation_details = {
    "name": "Guiding-center orbits in a GVEC stellarator",
    "description": (
        "GVEC creates a five-field-period stellarator equilibrium, then Struphy follows passing and "
        "mirror-trapped guiding centers in its three-dimensional magnetic field."
    ),
}
sim = make_simulation(
    equils.HomogenSlab(B0z=1.0),
    "gvec_equilibrium_metadata",
    domain=domains.Cuboid(),
    **simulation_details,
)
metadata_overrides = {"domain": "GVECunit (generated by pyGVEC)"}


if __name__ == "__main__":
    from plotly.subplots import make_subplots

    from _gallery import (
        export_profiling,
        merge_metadata,
        save_extra_figure,
        save_figure,
    )

    gvec_directory = Path("struphy_gallery_runs/gvec_equilibrium_source")
    gvec_directory.parent.mkdir(parents=True, exist_ok=True)
    equilibrium, gvec_iterations, gvec_force = create_gvec_equilibrium(gvec_directory)
    run = make_simulation(
        equilibrium, "gvec_equilibrium", name=sim.name, description=sim.description
    )
    run.run(profiling_activated=True)

    # Keep the live GVEC mapping for post-processing. A GVECunit reconstructed
    # only from generic run metadata does not retain its generated state file.
    data_file = Path(run.env.path_out) / "data/data_proc0.hdf5"
    with h5py.File(data_file) as data:
        time = np.asarray(data["time/value"])
        saved_markers = np.asarray(data["kinetic/kinetic_ions/markers"])
        total_energy = np.asarray(data["scalar/en_tot"])
        lost_history = np.asarray(data["scalar/n_lost_particles"])
    marker_history = np.empty((len(time), len(pitches), saved_markers.shape[-1]))
    for step, marker_rows in enumerate(saved_markers):
        active = marker_rows[marker_rows[:, -1] >= 0]
        if len(active) != len(pitches):
            raise RuntimeError("A guiding center left the GVEC radial domain")
        marker_history[step] = active[np.argsort(active[:, -1])]
    logical_positions = marker_history[:, :, :3]
    physical_positions = equilibrium.numerical_domain(
        logical_positions.reshape(-1, 3), change_out_order=True
    ).reshape(logical_positions.shape)
    x, y, z = np.moveaxis(physical_positions, -1, 0)

    # The grid holds one field period, so a marker that leaves it re-enters at the other end and comes
    # back mapped into the same wedge: its toroidal angle jumps by 2*pi/nfp. Unwrapping that angle and
    # rebuilding the position from (R, phi, Z) continues each orbit into the next period, which the
    # equilibrium's field-period symmetry makes exact.
    field_periods = int(equilibrium.state.nfp)
    major_radius = np.hypot(x, y)
    toroidal_angle = np.unwrap(np.arctan2(y, x), period=2.0 * np.pi / field_periods, axis=0)
    x, y = major_radius * np.cos(toroidal_angle), major_radius * np.sin(toroidal_angle)
    v_parallel = marker_history[:, :, 3]
    reflections = np.array(
        [
            np.count_nonzero(np.diff(np.sign(column[np.isfinite(column)])))
            for column in v_parallel.T
        ]
    )
    reflected = reflections > 0
    labels = [
        f"v∥/v = {pitch:+.2f} ({'trapped' if reflected[index] else 'passing'})"
        for index, pitch in enumerate(pitches)
    ]
    colors = ("#ef476f", "#f78c6b", "#ffd166", "#06d6a0", "#118ab2", "#7b2cbf")

    relative_energy = np.abs(total_energy / total_energy[0] - 1.0)
    relative_drift = float(np.nanmax(relative_energy))
    lost_markers = int(np.nanmax(lost_history))
    if not np.isfinite(relative_drift):
        raise RuntimeError(
            "Non-finite energy diagnostic in the GVEC guiding-center run"
        )
    print(
        f"GVEC converged in {gvec_iterations} iterations to |force| = {gvec_force:.2e}; "
        f"field periods: {equilibrium.state.nfp}; reflections per orbit: {reflections.tolist()}; "
        f"guiding-center energy drift: {relative_drift:.2e}"
    )

    # A cutaway stellarator makes the nested GVEC flux surfaces visible behind
    # the guiding-center trajectories. Surface color is the magnetic strength.
    surface_radii = np.array((0.2, 0.4, 0.6, 0.8, 1.0))
    toroidal_angles = np.linspace(0.05 * np.pi, 1.95 * np.pi, 97)
    surface_data = equilibrium.state.evaluate(
        "pos",
        "mod_B",
        rho=surface_radii,
        theta=49,
        zeta=toroidal_angles,
    )
    positions = np.asarray(surface_data["pos"])
    field_strength = np.asarray(surface_data["mod_B"])
    field_min = float(np.nanmin(field_strength))
    field_max = float(np.nanmax(field_strength))

    figure = go.Figure()
    for index, radius in enumerate(surface_radii):
        # Trim progressively more toroidal sections from the larger shells.
        # The staggered cut exposes every nested surface in the open wedge.
        trim = 3 * index
        toroidal_slice = slice(trim, len(toroidal_angles) - trim)
        figure.add_trace(
            go.Surface(
                x=positions[0, index, :, toroidal_slice],
                y=positions[1, index, :, toroidal_slice],
                z=positions[2, index, :, toroidal_slice],
                surfacecolor=field_strength[index, :, toroidal_slice],
                cmin=field_min,
                cmax=field_max,
                colorscale="Viridis",
                opacity=0.10 + 0.02 * index,
                showscale=index == len(surface_radii) - 1,
                colorbar={"title": "|B|", "len": 0.72},
                name=f"ρ = {radius:.1f}",
                hovertemplate=(
                    f"ρ = {radius:.1f}<br>R = %{{customdata:.3f}}<br>"
                    "Z = %{z:.3f}<br>|B| = %{surfacecolor:.3f}<extra></extra>"
                ),
                customdata=np.sqrt(
                    positions[0, index, :, toroidal_slice] ** 2
                    + positions[1, index, :, toroidal_slice] ** 2
                ),
            )
        )
    for index, label in enumerate(labels):
        figure.add_trace(
            go.Scatter3d(
                x=x[:, index],
                y=y[:, index],
                z=z[:, index],
                mode="lines",
                line={"color": colors[index], "width": 6},
                name=label,
                hovertemplate=(
                    f"{label}<br>x = %{{x:.3f}}<br>y = %{{y:.3f}}<br>"
                    "z = %{z:.3f}<extra></extra>"
                ),
            )
        )
    figure.update_layout(
        title="Guiding-center orbits in a GVEC stellarator",
        template="plotly_white",
        scene={
            "xaxis_title": "x",
            "yaxis_title": "y",
            "zaxis_title": "z",
            "aspectmode": "data",
            "camera": {"eye": {"x": 1.45, "y": 1.45, "z": 0.85}},
        },
        legend={"x": 0.01, "y": 0.99, "bgcolor": "rgba(255,255,255,0.72)"},
        margin={"l": 10, "r": 30, "t": 75, "b": 10},
    )
    save_figure(figure, "gvec-equilibrium", height=760)

    # A poloidal slice reveals the nesting more quantitatively; the adjacent
    # radial profiles come from the very same state file used by Struphy.
    cut_radii = np.linspace(0.1, 1.0, 10)
    # The cut at zeta = 0 for the flux-surface panel, and a few more across one field period: the
    # cross-section of a stellarator turns with the toroidal angle, so a projected orbit lives inside
    # the envelope of all of them rather than on any single cut.
    panel_angles = np.linspace(0.0, 2.0 * np.pi / int(equilibrium.state.nfp), 5)
    cut_data = equilibrium.state.evaluate(
        "pos", rho=cut_radii, theta=181, zeta=np.append(np.array((0.0,)), panel_angles)
    )
    cut_positions = np.asarray(cut_data["pos"])[..., :1]
    panel_positions = np.asarray(cut_data["pos"])[..., 1:]
    profile_radii = np.linspace(0.0, 1.0, 101)
    profile_data = equilibrium.state.evaluate(
        "iota", "p", rho=profile_radii, theta=0, zeta=0
    )

    profiles = make_subplots(
        rows=1,
        cols=2,
        specs=[[{}, {"secondary_y": True}]],
        subplot_titles=(
            "Poloidal flux surfaces (ζ = 0)",
            "Radial equilibrium profiles",
        ),
        horizontal_spacing=0.14,
    )
    for index, radius in enumerate(cut_radii):
        # Not x, y, z: those hold the orbits, which the panels below still need.
        cut_x = cut_positions[0, index, :, 0]
        cut_y = cut_positions[1, index, :, 0]
        cut_z = cut_positions[2, index, :, 0]
        radial_position = np.sqrt(cut_x**2 + cut_y**2)
        profiles.add_scatter(
            x=np.append(radial_position, radial_position[0]),
            y=np.append(cut_z, cut_z[0]),
            mode="lines",
            line={"color": "#168aad", "width": 1.5 + 1.2 * radius},
            opacity=0.35 + 0.65 * radius,
            name=f"ρ = {radius:.1f}",
            legendgroup="surfaces",
            showlegend=index in (0, len(cut_radii) - 1),
            row=1,
            col=1,
        )
    profiles.add_scatter(
        x=profile_radii,
        y=np.asarray(profile_data["p"]),
        mode="lines",
        name="pressure p",
        line={"color": "#d62828", "width": 3},
        row=1,
        col=2,
        secondary_y=False,
    )
    profiles.add_scatter(
        x=profile_radii,
        y=np.asarray(profile_data["iota"]),
        mode="lines",
        name="rotational transform ι",
        line={"color": "#f4a261", "width": 3},
        row=1,
        col=2,
        secondary_y=True,
    )
    profiles.update_xaxes(title_text="R", scaleanchor="y", scaleratio=1, row=1, col=1)
    profiles.update_yaxes(title_text="Z", row=1, col=1)
    profiles.update_xaxes(title_text="normalized flux radius ρ", row=1, col=2)
    profiles.update_yaxes(title_text="pressure p", row=1, col=2, secondary_y=False)
    profiles.update_yaxes(
        title_text="rotational transform ι", row=1, col=2, secondary_y=True
    )
    profiles.update_layout(
        title="GVEC flux geometry and equilibrium profiles",
        template="plotly_white",
        legend={"orientation": "h", "y": -0.2},
        margin={"l": 70, "r": 70, "t": 90, "b": 100},
    )
    figures = [
        save_extra_figure(
            profiles,
            "gvec-equilibrium",
            "flux-surfaces",
            alt="Nested GVEC flux surfaces in a poloidal cut beside pressure and rotational-transform profiles",
            caption=(
                "A poloidal cut through the nested magnetic surfaces generated by GVEC. "
                "The pressure and rotational transform on the right are evaluated directly "
                "from the newly generated equilibrium state."
            ),
        )
    ]

    # One panel per marker, the orbit projected on the (R, Z) plane, as in the tokamak example. The
    # cross-section of a stellarator turns with the toroidal angle, so the surfaces drawn behind each
    # orbit are the cut at zeta = 0: context for the radial excursion, not a plane the orbit stays in.
    panels = make_subplots(
        rows=2,
        cols=3,
        subplot_titles=labels,
        horizontal_spacing=0.05,
        vertical_spacing=0.13,
    )
    panel_radial = np.sqrt(panel_positions[0] ** 2 + panel_positions[1] ** 2)
    panel_height = panel_positions[2]
    radius_range = [
        min(panel_radial.min(), major_radius.min()) - 0.1,
        max(panel_radial.max(), major_radius.max()) + 0.1,
    ]
    height_range = [
        min(panel_height.min(), z.min()) - 0.1,
        max(panel_height.max(), z.max()) + 0.1,
    ]
    for index, label in enumerate(labels):
        row, column = divmod(index, 3)
        for surface in range(len(cut_radii)):
            for angle in range(panel_radial.shape[-1]):
                panels.add_scatter(
                    x=np.append(panel_radial[surface, :, angle], panel_radial[surface, 0, angle]),
                    y=np.append(panel_height[surface, :, angle], panel_height[surface, 0, angle]),
                    mode="lines",
                    line={"color": "#b8c4cc", "width": 1},
                    opacity=0.35,
                    showlegend=False,
                    hoverinfo="skip",
                    row=row + 1,
                    col=column + 1,
                )
        panels.add_scatter(
            x=major_radius[:, index],
            y=z[:, index],
            mode="lines",
            line={"color": colors[index], "width": 2},
            showlegend=False,
            hovertemplate="R = %{x:.3f}<br>Z = %{y:.3f}<extra></extra>",
            row=row + 1,
            col=column + 1,
        )
        panels.add_scatter(
            x=[major_radius[0, index]],
            y=[z[0, index]],
            mode="markers",
            marker={"color": "#222", "size": 7, "symbol": "circle-open", "line": {"width": 2}},
            showlegend=False,
            hoverinfo="skip",
            row=row + 1,
            col=column + 1,
        )
        axis_index = index + 1
        panels.update_xaxes(
            title_text="R" if row == 1 else None, range=radius_range, row=row + 1, col=column + 1
        )
        panels.update_yaxes(
            title_text="Z" if column == 0 else None,
            range=height_range,
            scaleanchor="x" if axis_index == 1 else f"x{axis_index}",
            scaleratio=1,
            row=row + 1,
            col=column + 1,
        )
    panels.update_layout(
        title="Guiding-center orbits projected on the poloidal plane",
        template="plotly_white",
        margin={"l": 70, "r": 30, "t": 90, "b": 60},
        height=760,
    )
    figures.append(
        save_extra_figure(
            panels,
            "gvec-equilibrium",
            "poloidal-orbits",
            alt="One panel per marker showing its guiding-center orbit projected on the poloidal plane",
            caption=(
                "Each marker's orbit projected on the (R, Z) plane. The grey curves are the flux surfaces at "
                "five toroidal angles across one field period, whose envelope is the region the projection "
                "can reach. "
                "The trapped markers stay within a narrow band of flux surfaces and retrace it, while the "
                "passing ones sweep the whole cross-section as they circulate. Unlike a tokamak, the "
                "stellarator cross-section rotates with the toroidal angle, so these projections are not "
                "closed banana curves: the width of the band is the radial excursion, the filling-in is the "
                "toroidal motion."
            ),
        )
    )

    diagnostics = make_subplots(
        rows=1,
        cols=2,
        subplot_titles=("Parallel velocity", "Total guiding-center energy"),
        horizontal_spacing=0.13,
    )
    for index, label in enumerate(labels):
        diagnostics.add_scatter(
            x=time,
            y=v_parallel[:, index] / speed,
            mode="lines",
            name=label,
            line={"color": colors[index], "width": 2.2},
            row=1,
            col=1,
        )
    diagnostics.add_hline(y=0.0, line={"color": "#888", "width": 1}, row=1, col=1)
    diagnostics.add_scatter(
        x=time,
        y=np.maximum(relative_energy, 1.0e-14),
        mode="lines",
        name="relative energy change",
        line={"color": "#264653", "width": 3},
        row=1,
        col=2,
    )
    diagnostics.update_xaxes(title_text="t [a.u.]", row=1, col=1)
    diagnostics.update_xaxes(title_text="t [a.u.]", row=1, col=2)
    diagnostics.update_yaxes(title_text="v∥ / v", row=1, col=1)
    diagnostics.update_yaxes(
        title_text="|E / E₀ − 1|", type="log", exponentformat="power", row=1, col=2
    )
    diagnostics.update_layout(
        title="Orbit classification and conservation",
        template="plotly_white",
        legend={"orientation": "h", "y": -0.2},
        margin={"l": 75, "r": 30, "t": 80, "b": 100},
    )
    figures.append(
        save_extra_figure(
            diagnostics,
            "gvec-equilibrium",
            "orbit-diagnostics",
            alt="Parallel velocities and total-energy conservation of guiding-center orbits in the GVEC stellarator",
            caption=(
                "A sign change in parallel velocity identifies a magnetic-mirror reflection: the markers "
                "launched with a small parallel velocity are caught in the helical wells of the stellarator, "
                "while the faster ones circulate. The magnetic moment is a coordinate of the model and is "
                "conserved exactly; the total energy drifts by a few percent, which is set by the accuracy "
                "of the coarse FEEC projection of the GVEC field rather than by the time integrator — the "
                "drift does not fall when the time step is reduced."
            ),
        )
    )
    merge_metadata(
        "gvec-equilibrium",
        gvecIterations=gvec_iterations,
        gvecFinalForce=gvec_force,
        gvecFieldPeriods=int(equilibrium.state.nfp),
        markers=len(pitches),
        reflectedMarkers=int(reflected.sum()),
        lostMarkers=lost_markers,
        relativeEnergyDrift=relative_drift,
        figures=figures,
        **export_profiling(run, "gvec-equilibrium"),
    )

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