Maxwell · Electromagnetism

Electromagnetic pulse on a distorted mesh

A Gaussian pulse of the electric field spreads as a ring across a periodic box, on a mesh that the Colella mapping has bent. The structure-preserving Maxwell solver keeps the energy constant and follows the exact solution, which is a sum of cosine modes. The initial pulse is a truncated, zero-mean Gaussian Fourier expansion: Ez(x,y,0)=∑0≤l,m≤6(l,m)≠(0,0)almcos⁡[kl(x−1)]cos⁡[qm(y−1.5)],E_z(x,y,0)=\sum_{\substack{0\le l,m\le6\\(l,m)\ne(0,0)}}a_{lm}\cos[k_l(x-1)]\cos[q_m(y-1.5)], where kl=πlk_l=\pi l, qm=2πm/3q_m=2\pi m/3 and alm=(2−δl0)(2−δm0)(2πσ2/6)e−σ2(kl2+qm2)/2a_{lm}=(2-\delta_{l0})(2-\delta_{m0})(2\pi\sigma^2/6)e^{-\sigma^2(k_l^2+q_m^2)/2}. The width is σ=0.25\sigma=0.25, δij\delta_{ij} is the Kronecker delta, and the magnetic field starts at zero.

A pulse of the out-of-plane electric field spreading across a box on a distorted mesh
Actual output from the script below. Interactive controls are available in the plot above.Open graph in a new tab ↗
Error of the electric field against the exact solution and change of the total energy over time
Top: the root-mean-square difference between the computed E_z and the exact solution at the points of the distorted mesh, relative to the rms of the initial pulse. Bottom: the relative change of the total energy, which stays small: the scheme conserves it whatever the shape of the mesh.Open graph in a new tab ↗

Physical problem

A pulse on a bent mesh

PDEs solved by model:

Ampère's law (no current):

Faraday's law:

Simulation geometry

Domain used in this run

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

Domain

Colella

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 maxwell-curved-mesh.py

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

"""An electromagnetic pulse on a distorted mesh, with Struphy's Maxwell model.

The Colella mapping x = Lx (eta1 + alpha sin(2 pi eta1) sin(2 pi eta2)), y = Ly (eta2 + alpha sin(2 pi eta2) sin(2 pi eta1)) leaves the
boundary of the box [0, Lx] x [0, Ly] where it is but bends the mesh lines inside it. Struphy's finite element spaces are built on the
logical cube and pushed forward with this map, so Maxwell's equations are solved on a curved mesh. Because the physical domain is
still the periodic rectangle, the exact solution is known: a Gaussian pulse of E_z, with B = 0 at first, is a sum of cosine modes,
each oscillating at omega = c |k|, and it can be compared with the numerical field at every point of the distorted mesh. The
energy, which the structure-preserving scheme conserves for any mesh, is followed as well.

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 maxwell-curved-mesh.py

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

import argparse

import numpy as np
import plotly.graph_objects as go

from struphy import DerhamOptions, EnvironmentOptions, Simulation, Time, domains, grids
from struphy.initial.base import GenericPerturbation
from struphy.models import Maxwell
from plasma_plots import save_figure

lx, ly = 2.0, 3.0
distortion = 0.1
width = 0.25  # of the Gaussian pulse
max_mode = 6  # cosine modes per direction in the Fourier series of the Gaussian
x0, y0 = 0.5 * lx, 0.5 * ly

# The Gaussian pulse as a cosine series about its centre, E_z = sum c_lm cos(kx (x - x0)) cos(ky (y - y0)) without the mean (l, m) = (0, 0).
modes = [(ell, m) for ell in range(max_mode + 1) for m in range(max_mode + 1) if (ell, m) != (0, 0)]
kx = np.array([2 * np.pi * ell / lx for ell, _ in modes])
ky = np.array([2 * np.pi * m / ly for _, m in modes])
weights = np.array([(1 if ell == 0 else 2) * (1 if m == 0 else 2) for ell, m in modes])
coefficients = weights * 2 * np.pi * width**2 / (lx * ly) * np.exp(-0.5 * width**2 * (kx**2 + ky**2))
frequencies = np.hypot(kx, ky)


def exact_field(x, y, t):
    """E_z(x, y, t): every cosine mode oscillates at c |k|, since B is zero and dE/dt is zero at the start."""
    x, y = np.asarray(x)[..., None], np.asarray(y)[..., None]
    return np.sum(coefficients * np.cos(kx * (x - x0)) * np.cos(ky * (y - y0)) * np.cos(frequencies * t), axis=-1)


def create_simulation() -> Simulation:
    time_opts = Time(dt=0.01, Tend=3.0)

    model = Maxwell()
    model.propagators.maxwell.options = model.propagators.maxwell.Options(algo="implicit")
    model.em_fields.e_field.add_perturbation(
        GenericPerturbation(lambda x, y, z: exact_field(x, y, 0.0), given_in_basis="physical", comp=2)
    )

    domain = domains.Colella(Lx=lx, Ly=ly, alpha=distortion, Lz=1.0)
    grid = grids.TensorProductGrid(num_elements=(24, 36, 1))
    derham_opts = DerhamOptions(degree=(3, 3, 1))

    sim = Simulation(
        model=model,
        name="Electromagnetic pulse on a distorted mesh",
        description=(
            "A Gaussian pulse of the electric field spreads as a ring across a periodic box, on a mesh that the Colella "
            "mapping has bent. The structure-preserving Maxwell solver keeps the energy constant and follows the exact "
            "solution, which is a sum of cosine modes."
            r" The initial pulse is a truncated, zero-mean Gaussian Fourier expansion: $$E_z(x,y,0)=\sum_{\substack{0\le l,m\le6\\(l,m)\ne(0,0)}}a_{lm}\cos[k_l(x-1)]\cos[q_m(y-1.5)],$$"
            r" where :math:`k_l=\pi l`, :math:`q_m=2\pi m/3` and :math:`a_{lm}=(2-\delta_{l0})(2-\delta_{m0})(2\pi\sigma^2/6)e^{-\sigma^2(k_l^2+q_m^2)/2}`."
            r" The width is :math:`\sigma=0.25`, :math:`\delta_{ij}` is the Kronecker delta, and the magnetic field starts at zero."
        ),
        env=EnvironmentOptions(out_folders="struphy_gallery_runs", sim_folder="maxwell_curved_mesh"),
        time_opts=time_opts,
        domain=domain,
        grid=grid,
        derham_opts=derham_opts,
    )
    return sim


def pproc(sim: Simulation, show: bool = False):
    from plotly.subplots import make_subplots
    from scipy.interpolate import griddata

    output = sim.output
    output.pproc(physical=True)

    # (t, e1, e2), on the mesh points
    e_z = output.evaluate("em_fields/e_field_xyz").isel(component=2, eta3=0).transpose("t", "eta1", "eta2")
    times = e_z.t.values
    mesh_x, mesh_y = e_z.X.values, e_z.Y.values
    numeric = e_z.values
    exact = np.array([exact_field(mesh_x, mesh_y, t) for t in times])
    scale = float(np.abs(exact[0]).max())
    # The rms error over the mesh points at each time, relative to the rms of the initial pulse.
    error = (e_z.plasma.analysis.error(exact) / np.sqrt(np.mean(exact[0] ** 2))).values
    if not np.isfinite(error).all():
        raise RuntimeError("Non-finite field")
    total = output.scalars["total_energy"].values
    energy_drift = float(output.scalars["total_energy"].plasma.analysis.relative_error().max())
    print(f"Largest rms error of E_z, relative to the initial rms: {error.max():.3e}")
    print(f"Maximum relative drift of the total energy: {energy_drift:.2e}")

    # Resample the values on the distorted mesh onto a regular grid, for the heatmap.
    x_plot, y_plot = np.linspace(0, lx, 64), np.linspace(0, ly, 96)
    xx, yy = np.meshgrid(x_plot, y_plot)
    points = np.column_stack([mesh_x.ravel(), mesh_y.ravel()])

    def resample(values):
        # Rounded, to keep the frames of the page small.
        return np.round(griddata(points, values.ravel(), (xx, yy), method="linear") / scale, 3)

    picks = np.unique(np.linspace(0, len(times) - 1, 45, dtype=int))
    mesh_lines = [
        go.Scatter(x=mesh_x[i, :], y=mesh_y[i, :], mode="lines", line={"color": "rgba(0,0,0,0.35)", "width": 0.7},
                   showlegend=False, hoverinfo="skip")
        for i in range(0, mesh_x.shape[0], 3)
    ] + [
        go.Scatter(x=mesh_x[:, j], y=mesh_y[:, j], mode="lines", line={"color": "rgba(0,0,0,0.35)", "width": 0.7},
                   showlegend=False, hoverinfo="skip")
        for j in range(0, mesh_x.shape[1], 3)
    ]
    frames_numeric = {i: resample(numeric[i]) for i in picks}
    figure = go.Figure(
        data=[go.Heatmap(z=frames_numeric[picks[0]], x=x_plot, y=y_plot, colorscale="RdBu", zmid=0.0, zmin=-0.5, zmax=0.5,
                         colorbar={"title": "E_z / initial peak"})] + mesh_lines,
        frames=[go.Frame(name=f"{times[i]:.2f}", data=[go.Heatmap(z=frames_numeric[i], x=x_plot, y=y_plot, colorscale="RdBu",
                                                                 zmid=0.0, zmin=-0.5, zmax=0.5)], traces=[0])
                for i in picks],
    )
    figure.update_layout(
        title="An electromagnetic pulse on a distorted mesh: E_z", template="plotly_white", autosize=True,
        xaxis_title="x", yaxis_title="y", margin={"l": 70, "r": 30, "t": 80, "b": 130},
        updatemenus=[{"type": "buttons", "showactive": False, "x": 0, "y": -0.1,
                      "buttons": [{"label": "Play", "method": "animate", "args": [None, {"frame": {"duration": 60, "redraw": True}, "transition": {"duration": 0}, "fromcurrent": True}]}]}],
        sliders=[{"active": 0, "x": 0.12, "len": 0.88, "y": -0.02, "currentvalue": {"prefix": "t = "},
                  "steps": [{"args": [[frame.name], {"frame": {"duration": 0, "redraw": True}, "transition": {"duration": 0}, "mode": "immediate"}], "label": frame.name, "method": "animate"} for frame in figure.frames]}],
    )
    figure.update_xaxes(range=[0, lx], constrain="domain")
    figure.update_yaxes(range=[0, ly], scaleanchor="x")
    # The image shows the pulse at a fifth of the run, when the ring has spread; the page keeps the animation.
    still = go.Figure(data=figure.data, layout=figure.layout)
    still.data[0].z = resample(numeric[len(times) // 5])
    save_figure(figure, "maxwell-curved-mesh", width=750, height=1000, still=still, show=show)

    diagnostics = make_subplots(rows=2, cols=1, vertical_spacing=0.18,
                                subplot_titles=("Error of E_z against the exact solution", "Total energy"))
    diagnostics.add_scatter(x=times, y=np.maximum(error, 1e-12), mode="lines", showlegend=False,
                            line={"color": "#d62828", "width": 2}, row=1, col=1)
    diagnostics.add_scatter(x=times, y=total / total[0] - 1.0, mode="lines", showlegend=False,
                            line={"color": "#168aad", "width": 2}, row=2, col=1)
    diagnostics.update_layout(template="plotly_white", autosize=True, margin={"l": 80, "r": 30, "t": 80, "b": 60})
    diagnostics.update_yaxes(title_text="rms error / initial rms", type="log", row=1, col=1)
    diagnostics.update_yaxes(title_text="relative change", exponentformat="e", row=2, col=1)
    diagnostics.update_xaxes(title_text="t", row=2, col=1)
    save_figure(diagnostics, "maxwell-curved-mesh-diagnostics", show=show)


if __name__ == "__main__":
    argparser = argparse.ArgumentParser(description="Run the maxwell curved mesh 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.