Skip to content

Palace Field Visualization

Top-view and cross-section visualization of electromagnetic fields from a Palace driven simulation on a CPW (coplanar waveguide) structure at 50 GHz.

Requirements:

  • IHP PDK: uv pip install ihp-gdsfactory
  • GDSFactory+ account for cloud simulation

Note: This notebook now uses :func:gsim.palace.fields.plot_boundary_field for NaN-free direct-mesh rendering (replaces the old probe-grid resampling approach that produced NaN values and poor-quality plots).

Simulation setup

import gdsfactory as gf
from ihp import LAYER, PDK

from gsim.common.stack import get_stack
from gsim.palace import DrivenSim

PDK.activate()


@gf.cell
def gsg_electrode(
    length=300, s_width=20, g_width=40, gap_width=15, layer=LAYER.TopMetal2drawing
):
    c = gf.Component()
    r1 = c << gf.c.rectangle((length, g_width), centered=True, layer=layer)
    r1.move((0, (g_width + s_width) / 2 + gap_width))
    _r2 = c << gf.c.rectangle((length, s_width), centered=True, layer=layer)
    r3 = c << gf.c.rectangle((length, g_width), centered=True, layer=layer)
    r3.move((0, -(g_width + s_width) / 2 - gap_width))
    c.add_port(
        name="o1",
        center=(-length / 2, 0),
        width=s_width,
        orientation=180,
        port_type="electrical",
        layer=layer,
    )
    c.add_port(
        name="o2",
        center=(length / 2, 0),
        width=s_width,
        orientation=0,
        port_type="electrical",
        layer=layer,
    )
    return c


sim = DrivenSim()
sim.set_output_dir("./palace-sim-cpw-fields")
sim.set_geometry(gsg_electrode())

stack = get_stack(
    include_substrate=True, substrate_thickness=2.0
)  # auto-detects active PDK
sim.set_stack(stack)

sim.add_cpw_port("o1", layer="topmetal2", s_width=20, gap_width=15)
sim.add_cpw_port("o2", layer="topmetal2", s_width=20, gap_width=15)

# Single frequency point at 50 GHz, adaptive off so Palace does a full solve
sim.set_driven(
    fmin=50e9,
    fmax=50e9 + 1e6,  # tiny range = effectively one point
    num_points=1,
    adaptive_tol=0,
    save_step=1,
)
sim.set_airbox(margin_x=50, margin_y=0, z_above=100, z_below=100)
sim.mesh(
    preset="default",
    refined_mesh_size=2.0,
    max_mesh_size=25.0,
)
Mesh Summary
========================================
Dimensions: 500.0 x 130.0 x 217.9 µm
Nodes:      16,745
Elements:   124,068
Tetrahedra: 90,126
Edge length: 1.12 - 51.35 µm
Quality:    0.645 (min: 0.012)
SICN:       0.692 (all valid)
----------------------------------------
Volumes (4):
  - silicon [1]
  - si [2]
  - sin [3]
  - air [4]
Surfaces (15):
  - topmetal2_xy [5]
  - topmetal2_z [6]
  - P1_E0 [7]
  - P1_E1 [8]
  - P2_E0 [9]
  - P2_E1 [10]
  - air__silicon [11]
  - silicon__None [12]
  - si__silicon [13]
  - air__si [14]
  - si__None [15]
  - si__sin [16]
  - air__sin [17]
  - sin__None [18]
  - air__None [19]
----------------------------------------
Mesh:   palace-sim-cpw-fields/palace.msh
sim.write_config()
results = sim.run_local()
Info    : Reading 'palace-sim-cpw-fields/palace.msh'...
Info    : 16745 nodes
Info    : 120892 elements
Info    : Done reading 'palace-sim-cpw-fields/palace.msh'                                                        
Info    : Reading 'palace-sim-cpw-fields/palace.msh'...
Info    : 16745 nodes
Info    : 120892 elements
Info    : Done reading 'palace-sim-cpw-fields/palace.msh'                                                        
Info    : Reading 'palace-sim-cpw-fields/palace.msh'...
Info    : 16745 nodes
Info    : 120892 elements
Info    : Done reading 'palace-sim-cpw-fields/palace.msh'


Running Palace simulation in palace-sim-cpw-fields via Apptainer
Command: apptainer run /opt/palace/Palace.sif -np 16 config.json
Processes: 16


Info    : Reading 'palace-sim-cpw-fields/palace.msh'...
Info    : 16745 nodes
Info    : 120892 elements
Info    : Done reading 'palace-sim-cpw-fields/palace.msh'


>> /usr/lib64/mpich/bin/mpirun -n 16 /opt/palace/bin/palace-x86_64.bin config.json
_____________     _______
_____   __   \____ __   /____ ____________
____   /_/  /  __ ` /  /  __ ` /  ___/  _ \
___   _____/  /_/  /  /  /_/  /  /__/  ___/
  /__/     \___,__/__/\___,__/\_____\_____/
--> Warning!
Output folder is not empty; program will overwrite content! (output/palace)
Git changeset ID: v0.14.0-270-g9d6ea72f
Running with 16 MPI processes, 1 OpenMP thread
Device configuration: omp,cpu
Memory configuration: host-std
libCEED backend: /cpu/self/xsmm/blocked
Added 5864 duplicate vertices for interior boundaries in the mesh
Added 11144 duplicate boundary elements for interior boundaries in the mesh
Finished partitioning mesh into 16 subdomains
Characteristic length and time scales:
 L₀ = 5.000e-04 m, t₀ = 1.668e-03 ns
Mesh curvature order: 1
Mesh bounding box:
 (Xmin, Ymin, Zmin) = (-2.500e-04, -6.500e-05, -1.020e-04) m
 (Xmax, Ymax, Zmax) = (+2.500e-04, +6.500e-05, +1.159e-04) m
Parallel Mesh Stats:
                minimum     average     maximum       total
 vertices          1004        1413        2183       22609
 edges             6602        7930       10096      126886
 faces            11069       12150       13647      194408
 elements          5471        5632        5802       90126
 neighbors            0           5          10
            minimum     maximum
 h       0.00225773   0.0821941
 kappa      1.09182     218.824
Configuring Robin absorbing BC (order 2) at attributes:
 12, 15, 18-19
Configuring Robin finite conductivity BC at attributes:
 5: σ = 3.770e+07 S/m, h = 3.000e-06 m, n = (-0.0,+0.0,+1.0)
 6: σ = 3.770e+07 S/m, h = 3.000e-06 m, n = (-0.0,+1.0,+0.0)
Configuring Robin impedance BC for lumped ports at attributes:
 7: Rs = 1.333e+01 Ω/sq, n = (+0.0,-0.0,+1.0)
 8: Rs = 1.333e+01 Ω/sq, n = (+0.0,+0.0,+1.0)
 9: Rs = 1.333e+01 Ω/sq, n = (-0.0,+0.0,+1.0)
 10: Rs = 1.333e+01 Ω/sq, n = (-0.0,-0.0,+1.0)
Configuring lumped port circuit properties:
 Index = 1: R = 5.000e+01 Ω
 Index = 2: R = 5.000e+01 Ω
Configuring lumped port excitation source term at attributes:
 7: Index = 1
 8: Index = 1
 9: Index = 2
 10: Index = 2
Computing frequency response for:
Excitation 1/2 with index 1 has contributions from:
 Lumped port  1
Excitation 2/2 with index 2 has contributions from:
 Lumped port  2
Assembling system matrices, number of global unknowns:
 H1 (p = 2): 149495, ND (p = 2): 642588, RT (p = 2): 853602
 Operator assembly level: Partial
 Mesh geometries:
  Tetrahedron: P = 20, Q = 11 (quadrature order = 4)
 SuperLUSolver: Using 2D processor grid 4 x 4
Sweeping excitation index 1 (1/2):
Assembling multigrid hierarchy:
 Level 0 (p = 1): 126886 unknowns
 Level 1 (p = 2): 642588 unknowns
 Level 0 (auxiliary) (p = 1): 22609 unknowns
 Level 1 (auxiliary) (p = 2): 149495 unknowns
It 1/2: ω/2π = 5.000e+01 GHz (total elapsed time = 6.28e+00 s, solve 1/4)
  Residual norms for GMRES solve
    matrix dimension    126886
    nonzeros in A      1834090
    nonzeros in L     17281828
    nonzeros in U     17281828
    nonzeros in L+U   34436770
    fill ratio            18.8
    nonzeros in LSUB   5718579
** Memory Usage **********************************
** Total highmark (MB):
    Sum-of-all :  2034.29 | Avg :   127.14  | Max :   136.67
    Max at rank 5, different stages (MB):
    . symbfact           55.13
    . distribution      125.16
    . numfact            58.26
** NUMfact space (MB): (sum-of-all-processes)
    L\U :          295.81 |  Total :   941.94
    . max at rank 5, max L+U memory (MB):    25.08
    . max at rank 15, peak buffer (MB):       41.27
**************************************************
** number of Tiny Pivots:        0
  0 (restart 0) KSP residual norm 6.076592e+01
  1 (restart 0) KSP residual norm 5.109583e+00
  2 (restart 0) KSP residual norm 1.359014e+00
  3 (restart 0) KSP residual norm 9.562134e-01
  4 (restart 0) KSP residual norm 1.455992e-01
  5 (restart 0) KSP residual norm 7.300275e-02
  6 (restart 0) KSP residual norm 2.922808e-02
  7 (restart 0) KSP residual norm 1.859018e-02
  8 (restart 0) KSP residual norm 5.416040e-03
  9 (restart 0) KSP residual norm 3.159745e-03
 10 (restart 0) KSP residual norm 1.124984e-03
 11 (restart 0) KSP residual norm 6.398113e-04
 12 (restart 0) KSP residual norm 2.986644e-04
 13 (restart 0) KSP residual norm 1.773515e-04
 14 (restart 0) KSP residual norm 8.209257e-05
 15 (restart 0) KSP residual norm 5.345515e-05
GMRES solver converged in 15 iterations (avg. reduction factor: 3.947e-01)
 Sol. ||E|| = 2.682384e+01 (||RHS|| = 2.609552e+00)
 Field energy E (1.365e-03 J) + H (1.232e-03 J) = 2.597e-03 J
 S[1][1] = -4.823e-02-3.059e-02i, |S[1][1]| = -2.487e+01, arg(S[1][1]) = -1.476e+02
 S[2][1] = +6.290e-01-7.288e-01i, |S[2][1]| = -3.297e-01, arg(S[2][1]) = -4.920e+01
 Wrote fields to disk (Paraview) at step 1
 Updating solution error estimates
It 2/2: ω/2π = 5.000e+01 GHz (total elapsed time = 5.54e+01 s, solve 2/4)
  Residual norms for GMRES solve
** Memory Usage **********************************
** Total highmark (MB):
    Sum-of-all :  1335.57 | Avg :    83.47  | Max :    90.65
    Max at rank 5, different stages (MB):
    . symbfact            0.00
    . distribution       81.89
    . numfact            58.26
** NUMfact space (MB): (sum-of-all-processes)
    L\U :          295.81 |  Total :   941.94
    . max at rank 5, max L+U memory (MB):    25.08
    . max at rank 15, peak buffer (MB):       41.27
**************************************************
** number of Tiny Pivots:        0
  0 (restart 0) KSP residual norm 6.673979e-04
  1 (restart 0) KSP residual norm 1.239932e-04
  2 (restart 0) KSP residual norm 3.474756e-05
GMRES solver converged in 2 iterations (avg. reduction factor: 7.562e-04)
 Sol. ||E|| = 2.682381e+01 (||RHS|| = 2.609605e+00)
 Field energy E (1.365e-03 J) + H (1.232e-03 J) = 2.597e-03 J
 S[1][1] = -4.823e-02-3.059e-02i, |S[1][1]| = -2.487e+01, arg(S[1][1]) = -1.476e+02
 S[2][1] = +6.290e-01-7.288e-01i, |S[2][1]| = -3.297e-01, arg(S[2][1]) = -4.920e+01
 Wrote fields to disk (Paraview) at step 2
 Updating solution error estimates
Sweeping excitation index 2 (2/2):
It 1/2: ω/2π = 5.000e+01 GHz (total elapsed time = 8.45e+01 s, solve 3/4)
  Residual norms for GMRES solve
** Memory Usage **********************************
** Total highmark (MB):
    Sum-of-all :  1335.57 | Avg :    83.47  | Max :    90.65
    Max at rank 5, different stages (MB):
    . symbfact            0.00
    . distribution       81.89
    . numfact            58.26
** NUMfact space (MB): (sum-of-all-processes)
    L\U :          295.81 |  Total :   941.94
    . max at rank 5, max L+U memory (MB):    25.08
    . max at rank 15, peak buffer (MB):       41.27
**************************************************
** number of Tiny Pivots:        0
  0 (restart 0) KSP residual norm 9.044130e+00
  1 (restart 0) KSP residual norm 9.015920e-01
  2 (restart 0) KSP residual norm 3.971593e-01
  3 (restart 0) KSP residual norm 1.941001e-01
  4 (restart 0) KSP residual norm 1.341383e-01
  5 (restart 0) KSP residual norm 3.645206e-02
  6 (restart 0) KSP residual norm 1.713165e-02
  7 (restart 0) KSP residual norm 1.207056e-02
  8 (restart 0) KSP residual norm 3.717866e-03
  9 (restart 0) KSP residual norm 1.928589e-03
 10 (restart 0) KSP residual norm 9.327876e-04
 11 (restart 0) KSP residual norm 4.515979e-04
 12 (restart 0) KSP residual norm 2.215293e-04
 13 (restart 0) KSP residual norm 1.249671e-04
 14 (restart 0) KSP residual norm 6.457379e-05
 15 (restart 0) KSP residual norm 3.772007e-05
GMRES solver converged in 15 iterations (avg. reduction factor: 3.857e-01)
 Sol. ||E|| = 2.682447e+01 (||RHS|| = 2.623449e+00)
 Field energy E (1.365e-03 J) + H (1.232e-03 J) = 2.597e-03 J
 S[1][2] = +6.290e-01-7.288e-01i, |S[1][2]| = -3.297e-01, arg(S[1][2]) = -4.920e+01
 S[2][2] = -4.824e-02-3.057e-02i, |S[2][2]| = -2.487e+01, arg(S[2][2]) = -1.476e+02
 Wrote fields to disk (Paraview) at step 1
 Updating solution error estimates
It 2/2: ω/2π = 5.000e+01 GHz (total elapsed time = 1.32e+02 s, solve 4/4)
  Residual norms for GMRES solve
** Memory Usage **********************************
** Total highmark (MB):
    Sum-of-all :  1335.57 | Avg :    83.47  | Max :    90.65
    Max at rank 5, different stages (MB):
    . symbfact            0.00
    . distribution       81.89
    . numfact            58.26
** NUMfact space (MB): (sum-of-all-processes)
    L\U :          295.81 |  Total :   941.94
    . max at rank 5, max L+U memory (MB):    25.08
    . max at rank 15, peak buffer (MB):       41.27
**************************************************
** number of Tiny Pivots:        0
  0 (restart 0) KSP residual norm 6.668819e-04
  1 (restart 0) KSP residual norm 1.258537e-04
  2 (restart 0) KSP residual norm 2.884878e-05
GMRES solver converged in 2 iterations (avg. reduction factor: 6.893e-04)
 Sol. ||E|| = 2.682445e+01 (||RHS|| = 2.623501e+00)
 Field energy E (1.365e-03 J) + H (1.232e-03 J) = 2.597e-03 J
 S[1][2] = +6.290e-01-7.288e-01i, |S[1][2]| = -3.297e-01, arg(S[1][2]) = -4.920e+01
 S[2][2] = -4.825e-02-3.057e-02i, |S[2][2]| = -2.487e+01, arg(S[2][2]) = -1.476e+02
 Wrote fields to disk (Paraview) at step 2
 Updating solution error estimates
Completed 0 iterations of adaptive mesh refinement (AMR):
 Indicator norm = 1.118e-01, global unknowns = 642588
 Max. iterations = 0, tol. = 1.000e-02
Elapsed Time Report (s)           Min.        Max.        Avg.
==============================================================
Initialization                   0.116       0.125       0.123
  Mesh Preprocessing             1.160       1.161       1.161
Operator Construction            0.476       0.512       0.497
Linear Solve                     5.973       9.132       6.580
  Setup                         24.713      24.727      24.721
  Preconditioner                39.280      45.907      44.924
  Coarse Solve                  12.300      15.969      12.873
Estimation                       0.368       0.395       0.381
  Construction                   8.610       8.643       8.630
  Solve                         50.622      50.657      50.637
Postprocessing                   0.604       0.642       0.613
  Paraview                      12.883      12.915      12.906
Disk IO                          0.604       0.607       0.606
--------------------------------------------------------------
Total                          164.704     164.706     164.704
Simulation completed successfully
Results saved to palace-sim-cpw-fields/output/palace

Load results and setup

from pathlib import Path

import numpy as np
import pyvista as pv

# New NaN-free field-visualization module (replaces old gsim.viz.plot_topview/plot_cross_section)
from gsim.palace.fields import (
    load_boundary_field_data,
    load_field_context,
    load_volume_field_data,
    plot_boundary_field,
    plot_volume_slice,
)

pv.OFF_SCREEN = True

# Get results dir from sim output (or hardcode for re-runs)
results_dir = Path(results.files["port-S.csv"]).parent
print(f"Results dir: {results_dir}")

# Read frequency from S-parameter CSV
s_csv = np.loadtxt(results_dir / "port-S.csv", delimiter=",", skiprows=1)
freq_ghz = s_csv[0, 0]

# Load volume + boundary meshes and build the SelectorContext in one call
vol, bnd, ctx, pg_map = load_field_context(results_dir, excitation=1)

print(f"Frequency: {freq_ghz:.1f} GHz")
print(f"Volume: {vol.n_points:,} points, {vol.n_cells:,} cells")
print(f"Boundary: {bnd.n_points:,} points, {bnd.n_cells:,} cells")

# Resolve topmetal2_xy attributes for surface-current plot
topmetal2_attrs = [
    int(tag) for name, tag in pg_map.items() if "topmetal2_xy" in name.lower()
]
print(f"topmetal2_xy attributes: {topmetal2_attrs}")
print(f"Known entity names: {sorted(pg_map.keys())}")

# Top-view conductor z-plane
z_conductor = 16.0
Results dir: palace-sim-cpw-fields/output/palace
Frequency: 50.0 GHz
Volume: 901,260 points, 90,126 cells
Boundary: 251,460 points, 41,910 cells

topmetal2_xy attributes: [5]
Known entity names: ['P1_E0', 'P1_E1', 'P2_E0', 'P2_E1', 'air', 'air__None', 'air__si', 'air__silicon', 'air__sin', 'si', 'si__None', 'si__silicon', 'si__sin', 'silicon', 'silicon__None', 'sin', 'sin__None', 'topmetal2_xy', 'topmetal2_z']

Top-view volume slices at conductor layer (NaN-free direct mesh rendering)

# |E| volume slice at z_conductor — uses extract_axis_slice + direct mesh rendering (no NaN)
vol_data = load_volume_field_data(results_dir, excitation=1)

pl_e = plot_volume_slice(
    vol_data,
    vector_field="E_real",
    component="mag",
    axis="z",
    value=z_conductor,
    cmap="turbo",
    scalar_bar_title=f"|E| @ {freq_ghz:.1f} GHz — volume slice (V/m)",
    off_screen=True,
)
pl_e.show(jupyter_backend="static")

png

# |S| Poynting vector — power flow along the waveguide
pl_s_vol = plot_volume_slice(
    vol_data,
    vector_field="S",
    component="mag",
    axis="z",
    value=z_conductor,
    cmap="turbo",
    scalar_bar_title=f"|S| @ {freq_ghz:.1f} GHz — power flow (W/m²)",
    off_screen=True,
)
pl_s_vol.show(jupyter_backend="static")

png

Surface-current boundary plot (NaN-free direct mesh rendering)

This uses :func:gsim.palace.fields.plot_boundary_field which renders the actual boundary mesh cells directly — no resampling to a regular grid — so there are zero NaN values.

# Load boundary field data — only topmetal2_xy conductor faces
bnd_data = load_boundary_field_data(
    results_dir,
    ctx,
    attributes=topmetal2_attrs,
    excitation=1,
)

print(
    f"Boundary mesh: {bnd_data.mesh.n_points:,} points, {bnd_data.mesh.n_cells:,} cells"
)
print(f"Available fields: {sorted(bnd_data.point_arrays)}")

# Explicit NaN check — the whole point
if "J_s_real" in bnd_data.point_arrays:
    from gsim.palace.fields import activate_vector_component

    scalar_name = activate_vector_component(bnd_data.mesh, "J_s_real", component="mag")
    n_nan = int(np.isnan(bnd_data.mesh.point_data[scalar_name]).sum())
    print(f"  NaN count in |J_s_real|: {n_nan} / {bnd_data.mesh.n_points:,} points")
    assert n_nan == 0, f"Expected 0 NaN values, got {n_nan}"
Boundary mesh: 100,944 points, 16,824 cells
Available fields: ['B_imag', 'B_real', 'E_imag', 'E_real', 'J_s_imag', 'J_s_real', 'Q_s_imag', 'Q_s_real', 'S', 'U_e', 'U_m', 'vtkOriginalPointIds']
  NaN count in |J_s_real|: 0 / 100,944 points
# Plot surface current — NO NaN, direct mesh rendering
pl = plot_boundary_field(
    bnd_data,
    vector_field="J_s_real",
    component="mag",
    cmap="rainbow",
    log_scale=False,
    opacity=0.95,
    scalar_bar_title=f"|J_s| @ {freq_ghz:.1f} GHz — NaN-free direct mesh (A/m)",
    show_edges=False,
    off_screen=True,
)

# Orthographic top view
bounds = bnd_data.mesh.bounds
cx = 0.5 * (bounds[0] + bounds[1])
cy = 0.5 * (bounds[2] + bounds[3])
cz = 0.5 * (bounds[4] + bounds[5])
span_xy = max(bounds[1] - bounds[0], bounds[3] - bounds[2])
pl.camera_position = (cx, cy, cz + span_xy * 3.0)
pl.camera.focal_point = (cx, cy, cz)
pl.camera.up = (0.0, 1.0, 0.0)
pl.enable_parallel_projection()

pl.show(jupyter_backend="static")
2026-07-08 17:05:54.258 ( 176.538s) [    760869D87080]        vtkRenderer.cxx:1144  WARN| vtkOpenGLRenderer (0x439bc070): Resetting view-up since view plane normal is parallel

png

Cross-sections — YZ plane at x=0

# |E| cross-section — uses 3D volume slice + direct mesh rendering
pl_cs_e = plot_volume_slice(
    vol_data,
    vector_field="E_real",
    component="mag",
    axis="x",
    value=0.0,
    cmap="turbo",
    scalar_bar_title=f"|E| cross-section @ {freq_ghz:.1f} GHz (V/m)",
    off_screen=True,
)

# Camera looks straight into the YZ plane (down the +X axis toward x=0)
_b = pl_cs_e.mesh.bounds
_cx, _cy, _cz = 0.0, 0.5 * (_b[2] + _b[3]), 0.5 * (_b[4] + _b[5])
_span_yz = max(_b[3] - _b[2], _b[5] - _b[4])
pl_cs_e.camera.position = (_cx + _span_yz * 3.0, _cy, _cz)
pl_cs_e.camera.focal_point = (_cx, _cy, _cz)
pl_cs_e.camera.up = (0.0, 0.0, 1.0)
pl_cs_e.enable_parallel_projection()

pl_cs_e.show(jupyter_backend="static")

png

# |B| cross-section — magnetic field circulating around conductor
pl_cs_b = plot_volume_slice(
    vol_data,
    vector_field="B_real",
    component="mag",
    axis="x",
    value=0.0,
    cmap="turbo",
    scalar_bar_title=f"|B| cross-section @ {freq_ghz:.1f} GHz (T)",
    off_screen=True,
)

# Camera looks straight into the YZ plane (down the +X axis toward x=0)
_b = pl_cs_b.mesh.bounds
_cx, _cy, _cz = 0.0, 0.5 * (_b[2] + _b[3]), 0.5 * (_b[4] + _b[5])
_span_yz = max(_b[3] - _b[2], _b[5] - _b[4])
pl_cs_b.camera.position = (_cx + _span_yz * 3.0, _cy, _cz)
pl_cs_b.camera.focal_point = (_cx, _cy, _cz)
pl_cs_b.camera.up = (0.0, 0.0, 1.0)
pl_cs_b.enable_parallel_projection()

pl_cs_b.show(jupyter_backend="static")

png

Field components — E_y at conductor layer

E_y is the dominant E-field component in a CPW — the transverse field across the gaps between signal and ground. A diverging colormap shows the polarity flipping between the two gaps.

# E_y component via plot_volume_slice with vector component 'y'
pl_ey = plot_volume_slice(
    vol_data,
    vector_field="E_real",
    component="y",
    axis="z",
    value=z_conductor,
    cmap="RdBu_r",
    scalar_bar_title=f"E_y @ {freq_ghz:.1f} GHz — transverse field (V/m)",
    off_screen=True,
)
pl_ey.show(jupyter_backend="static")

png