Skip to content

CPW length sweep and de-embedding

Simulate 100, 400, and 800 µm CPW lines with both lumped ports and wave ports over 300 frequencies from 1 to 100 GHz. De-embed the 100 µm reference from each longer line and compare propagation, impedance, and distributed RLGC parameters. Then check the cross-section modes and voltage–power impedance with a 2D eigensolver at 50 GHz.

Start with the basic lumped-port or wave-port example for a single simulation. This notebook is self-contained and runs both sweeps when executed from top to bottom.

Requirements:

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

Define the CPW

import gdsfactory as gf
from ihp import LAYER, PDK

PDK.activate()


@gf.cell
def gsg_electrode(
    length: float = 800,
    s_width: float = 20,
    g_width: float = 40,
    gap_width: float = 15,
    layer=LAYER.TopMetal2drawing,
) -> gf.Component:
    """
    Create a GSG (Ground-Signal-Ground) electrode.

    Args:
        length: horizontal length of the electrodes
        s_width: width of the signal (center) electrode
        g_width: width of the ground electrodes
        gap_width: gap between signal and ground electrodes
        layer: layer for the metal
    """
    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


c = gsg_electrode()
cc = c.copy()
cc.draw_ports()
cc

png

Configure both port types

Use the geometry, materials, airbox, and solver settings from the basic examples. Both ports are excited in separate solves to obtain all four S-parameters required for de-embedding.

The examples use different airbox margins for the two port types. The study checks length consistency within each setup; a physical comparison between port types also requires airbox and mesh convergence.

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


def setup_sim(cell, port_type, *, solver_type="Default"):
    sim = DrivenSim()
    if port_type == "lumped":
        output_name = "lumped"
    elif port_type == "wave":
        output_name = f"waveport-{solver_type.lower()}"
    else:
        raise ValueError(f"Unknown port type: {port_type}")
    sim.set_output_dir(f"./palace-sim-cpw-{output_name}-{cell.name}")
    sim.set_geometry(cell)
    sim.set_stack(get_stack())

    if port_type == "lumped":
        sim.set_airbox(margin_x=50, margin_y=0, z_above=100, z_below=100)
        for port_name in ["o1", "o2"]:
            sim.add_cpw_port(
                port_name, layer="topmetal2", s_width=20, gap_width=15, excited=True
            )
    else:
        sim.set_airbox(margin_x=0.0, margin_y=50, z_above=100.0, z_below=100.0)
        for port_name in ["o1", "o2"]:
            sim.add_wave_port(
                port_name,
                layer="topmetal2",
                max_size=True,
                mode=1,
                excited=True,
                eigensolver_type=solver_type,
                eigensolver_tol=1e-6,
                eigensolver_ksp_tol=1e-8,
                eigensolver_max_size=40,
                eigensolver_verbose=0,
            )

    sim.set_driven(fmin=1e9, fmax=100e9, num_points=300)
    print(sim.validate_config())
    return sim
pyvirtualdisplay not available; continuing without Xvfb

Run the length sweep

Run all six cases, reusing matching cached results, including the 800 µm simulations from the basic notebooks. Submit the jobs without waiting, then collect their results in the same order.

Lumped-port validation reads the mesh once per gap surface: two ports × two gaps gives four reads before the cache lookup. Extracting results.tar.gz... means downloaded results are being unpacked, including on cache hits. Use verbose="full" instead of "status" if you need solver logs.

lengths_um = (100, 400, 800)
port_types = ("lumped", "wave")
port_labels = {"lumped": "Lumped ports", "wave": "Wave ports"}
job_ids = {}

for port_type in port_types:
    for length_um in lengths_um:
        component = gsg_electrode(length=length_um)
        simulation = setup_sim(component, port_type)
        simulation.mesh(
            preset="default",
            refined_mesh_size=2.0,
            max_mesh_size=40.0,
            fmax=150e9,
            verbose=False,
        )
        job_ids[port_type, length_um] = simulation.run(
            wait=False, check_cache=True, verbose="status"
        )
Validation: PASSED


Info    : Reading 'palace-sim-cpw-lumped-gsg_electrode_L100_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'...
Info    : 5800 nodes
Info    : 41496 elements
Info    : Done reading 'palace-sim-cpw-lumped-gsg_electrode_L100_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'
Info    : Reading 'palace-sim-cpw-lumped-gsg_electrode_L100_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'...
Info    : 5800 nodes
Info    : 41496 elements
Info    : Done reading 'palace-sim-cpw-lumped-gsg_electrode_L100_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'
Info    : Reading 'palace-sim-cpw-lumped-gsg_electrode_L100_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'...
Info    : 5800 nodes
Info    : 41496 elements
Info    : Done reading 'palace-sim-cpw-lumped-gsg_electrode_L100_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'
Info    : Reading 'palace-sim-cpw-lumped-gsg_electrode_L100_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'...
Info    : 5800 nodes
Info    : 41496 elements
Info    : Done reading 'palace-sim-cpw-lumped-gsg_electrode_L100_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'


Cache hit: reusing job 01a0f81b-9e76-7e43-a286-085c71dadc1a
Validation: PASSED


Info    : Reading 'palace-sim-cpw-lumped-gsg_electrode_L400_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'...
Info    : 14840 nodes
Info    : 106937 elements
Info    : [  0%] Reading elements

Info : [ 10%] Reading elements
Info : [ 20%] Reading elements
Info : [ 30%] Reading elements
Info : [ 40%] Reading elements
Info : [ 50%] Reading elements
Info : [ 60%] Reading elements
Info : [ 70%] Reading elements
Info : [ 80%] Reading elements
Info : [ 90%] Reading elements

Info : Done reading 'palace-sim-cpw-lumped-gsg_electrode_L400_SW20_GW40_GW15_LTopMetal2drawing/palace.msh' Info : Reading 'palace-sim-cpw-lumped-gsg_electrode_L400_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'... Info : 14840 nodes Info : 106937 elements Info : [ 0%] Reading elements
Info : [ 10%] Reading elements
Info : [ 20%] Reading elements
Info : [ 30%] Reading elements
Info : [ 40%] Reading elements
Info : [ 50%] Reading elements
Info : [ 60%] Reading elements
Info : [ 70%] Reading elements
Info : [ 80%] Reading elements
Info : [ 90%] Reading elements

Info : Done reading 'palace-sim-cpw-lumped-gsg_electrode_L400_SW20_GW40_GW15_LTopMetal2drawing/palace.msh' Info : Reading 'palace-sim-cpw-lumped-gsg_electrode_L400_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'... Info : 14840 nodes Info : 106937 elements Info : [ 0%] Reading elements
Info : [ 10%] Reading elements

Info    : [ 20%] Reading elements

Info : [ 30%] Reading elements
Info : [ 40%] Reading elements
Info : [ 50%] Reading elements
Info : [ 60%] Reading elements
Info : [ 70%] Reading elements
Info : [ 80%] Reading elements
Info : [ 90%] Reading elements

Info : Done reading 'palace-sim-cpw-lumped-gsg_electrode_L400_SW20_GW40_GW15_LTopMetal2drawing/palace.msh' Info : Reading 'palace-sim-cpw-lumped-gsg_electrode_L400_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'... Info : 14840 nodes Info : 106937 elements Info : [ 0%] Reading elements
Info : [ 10%] Reading elements
Info : [ 20%] Reading elements
Info : [ 30%] Reading elements
Info : [ 40%] Reading elements
Info : [ 50%] Reading elements
Info : [ 60%] Reading elements
Info : [ 70%] Reading elements
Info : [ 80%] Reading elements
Info : [ 90%] Reading elements

Info : Done reading 'palace-sim-cpw-lumped-gsg_electrode_L400_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'

Cache hit: reusing job 01a0f81b-b6e3-7801-932e-8cc602b773a4
Validation: PASSED


Info    : Reading 'palace-sim-cpw-lumped-gsg_electrode_L800_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'...
Info    : 25428 nodes
Info    : 183685 elements
Info    : [  0%] Reading elements

Info : [ 10%] Reading elements
Info : [ 20%] Reading elements
Info : [ 30%] Reading elements
Info : [ 40%] Reading elements
Info : [ 50%] Reading elements
Info : [ 60%] Reading elements
Info : [ 70%] Reading elements
Info : [ 80%] Reading elements
Info : [ 90%] Reading elements

Info : Done reading 'palace-sim-cpw-lumped-gsg_electrode_L800_SW20_GW40_GW15_LTopMetal2drawing/palace.msh' Info : Reading 'palace-sim-cpw-lumped-gsg_electrode_L800_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'... Info : 25428 nodes Info : 183685 elements Info : [ 0%] Reading elements

Info    : [ 10%] Reading elements

Info : [ 20%] Reading elements
Info : [ 30%] Reading elements
Info : [ 40%] Reading elements
Info : [ 50%] Reading elements
Info : [ 60%] Reading elements
Info : [ 70%] Reading elements
Info : [ 80%] Reading elements
Info : [ 90%] Reading elements

Info : Done reading 'palace-sim-cpw-lumped-gsg_electrode_L800_SW20_GW40_GW15_LTopMetal2drawing/palace.msh' Info : Reading 'palace-sim-cpw-lumped-gsg_electrode_L800_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'... Info : 25428 nodes Info : 183685 elements Info : [ 0%] Reading elements
Info : [ 10%] Reading elements
Info : [ 20%] Reading elements

Info    : [ 30%] Reading elements

Info : [ 40%] Reading elements
Info : [ 50%] Reading elements
Info : [ 60%] Reading elements
Info : [ 70%] Reading elements
Info : [ 80%] Reading elements
Info : [ 90%] Reading elements

Info : Done reading 'palace-sim-cpw-lumped-gsg_electrode_L800_SW20_GW40_GW15_LTopMetal2drawing/palace.msh' Info : Reading 'palace-sim-cpw-lumped-gsg_electrode_L800_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'... Info : 25428 nodes Info : 183685 elements Info : [ 0%] Reading elements
Info : [ 10%] Reading elements
Info : [ 20%] Reading elements
Info : [ 30%] Reading elements
Info : [ 40%] Reading elements

Info    : [ 50%] Reading elements

Info : [ 60%] Reading elements
Info : [ 70%] Reading elements
Info : [ 80%] Reading elements
Info : [ 90%] Reading elements

Info : Done reading 'palace-sim-cpw-lumped-gsg_electrode_L800_SW20_GW40_GW15_LTopMetal2drawing/palace.msh'

Cache hit: reusing job 01a0f81b-dd0e-7dd0-9a70-01705d7c407f
Validation: PASSED


Cache hit: reusing job 01a0f81b-eded-7e10-a6a2-655a6ad377f5
Validation: PASSED


Cache hit: reusing job 01a0f81c-0b5f-7fb1-ad41-67087290705d
Validation: PASSED


Cache hit: reusing job 01a0f81c-9945-72c1-b404-fe1f27fb3121
import gsim

sweep_results = gsim.wait_for_results(list(job_ids.values()), verbose="status")
results_by_case = dict(zip(job_ids, sweep_results, strict=True))
Waiting for 6 jobs...
  palace-37c7405e                [####################] 100% complete     elapsed 0m 00s
  palace-4da933a7                [####################] 100% complete     elapsed 0m 00s
  palace-22ac198a                [####################] 100% complete     elapsed 0m 00s
  palace-b3002e4b                [####################] 100% complete     elapsed 0m 00s
  palace-83b512c0                [####################] 100% complete     elapsed 0m 00s
  palace-1afcf1d9                [####################] 100% complete     elapsed 0m 00s


Extracting results.tar.gz...
Downloaded 12 files to sim-data-palace-37c7405e


Extracting results.tar.gz...
Downloaded 12 files to sim-data-palace-4da933a7


Extracting results.tar.gz...
Downloaded 12 files to sim-data-palace-22ac198a


Extracting results.tar.gz...
Downloaded 10 files to sim-data-palace-b3002e4b


Extracting results.tar.gz...
Downloaded 10 files to sim-data-palace-83b512c0


Extracting results.tar.gz...
Downloaded 10 files to sim-data-palace-1afcf1d9

Prepare the two-port networks

Check that every result contains the full S-matrix before converting it to scikit-rf.

Impedance reference: 50 Ω matches the configured lumped-port resistance. For wave ports, to_skrf(z0=50.0) only assigns 50 Ω to the power-normalized S-matrix; this notebook does not measure the mode's circuit impedance or renormalize from it. The wave-port impedance and RLGC are therefore equivalent-circuit values in that assigned reference. Assigning twice the reference impedance to the same S-matrix doubles \(Z_c\) without changing the extracted index. A physical impedance comparison needs a consistent voltage/power definition; see Palace's port normalization conventions.

# De-embedding requires all four S-parameters, including the output reflection.
networks = {}
for case, result in results_by_case.items():
    port_names = result.port_names
    assert len(port_names) == 2, "Expected a two-port CPW result."
    expected_pairs = {
        (to_port, from_port) for to_port in port_names for from_port in port_names
    }
    assert set(result.keys()) == expected_pairs, (
        "Run both port excitations before de-embedding."
    )
    networks[case] = result.to_skrf(z0=50.0)
    networks[case].frequency.unit = "GHz"

De-embed the 100 µm reference

Split the 100 µm line into mirrored fixtures using IEEE P370 NZC 2x-thru. Assuming the same end fixtures at every length, removing them from the longer simulations leaves 300 µm (400−100) and 700 µm (800−100) sections. These are inferred networks, not separately simulated lines.

For a uniform, symmetric line, use \(Z_c=\sqrt{B/C}\) with nonnegative real part and unwrap the phase of \(e^{\gamma\ell}=(A+D)/2+B/Z_c\). This keeps the propagation constant continuous when a longer line passes through 180° of electrical length. Reconstructing the original networks checks the fixture orientation.

import matplotlib.pyplot as plt
import numpy as np
from scipy.constants import speed_of_light
from skrf.calibration.deembedding import IEEEP370_SE_NZC_2xThru


def _positive_real_branch(z):
    return np.where(np.real(z) < 0, -z, z)


def extract_modal_parameters(net, length_m):
    """Return Zc, gamma and neff of a uniform symmetric 2-port from its ABCD.

    Branch-free: Zc = sqrt(B/C) on the passive branch determines
    sinh(gamma*l) = B/Zc, hence exp(gamma*l) = cosh + sinh, leaving only the
    2*pi of the complex log to unwrap. No eigenvalue selection is involved, so
    the beta*l = pi eigenvalue collision of a low-loss line never arises.
    """
    a = net.a
    A, B, C, D = a[:, 0, 0], a[:, 0, 1], a[:, 1, 0], a[:, 1, 1]

    zc = _positive_real_branch(np.sqrt(B / C))
    cosh_gl = (A + D) / 2
    sinh_gl = B / zc
    exp_gl = cosh_gl + sinh_gl

    gamma_l = np.log(np.abs(exp_gl)) + 1j * np.unwrap(np.angle(exp_gl))
    gamma = gamma_l / length_m

    neff = np.full(len(net), np.nan)
    nonzero = net.f != 0
    neff[nonzero] = (
        np.imag(gamma[nonzero]) * speed_of_light / (2 * np.pi * net.f[nonzero])
    )

    # Symmetry residual: a uniform symmetric line has A == D.
    symmetry_error = np.max(np.abs(A - D) / np.maximum(np.abs(A), 1e-30))

    return {
        "zc": zc,
        "gamma": gamma,
        "neff": neff,
        "symmetry_error": symmetry_error,
    }


def p370_extract(short_2xthru, long_fix_dut_fix, delta_length_m):
    """Extract the additional line length using a mirrored IEEE P370 split."""
    reference_z0 = float(np.real(np.median(short_2xthru.z0[:, 0])))
    deembedding = IEEEP370_SE_NZC_2xThru(
        dummy_2xthru=short_2xthru,
        z0=reference_z0,
        use_z_instead_ifft=True,
        name="100 um non-zero-length 2xThru",
    )
    dut = deembedding.deembed(long_fix_dut_fix)

    # Explicitly reconstruct both topologies to check fixture orientation.
    reconstructed_short = deembedding.s_side1 ** deembedding.s_side2.flipped()
    reconstructed_long = deembedding.s_side1**dut ** deembedding.s_side2.flipped()
    result = extract_modal_parameters(dut, delta_length_m)
    result.update(
        {
            "dut": dut,
            "deembedding": deembedding,
            "split_error": np.max(np.abs(reconstructed_short.s - short_2xthru.s)),
            "reembed_error": np.max(np.abs(reconstructed_long.s - long_fix_dut_fix.s)),
        }
    )
    return result
raw_parameters = {}
deembedded = {}

for port_type in port_types:
    reference = networks[port_type, 100]
    for length_um in lengths_um:
        raw_parameters[port_type, length_um] = extract_modal_parameters(
            networks[port_type, length_um], length_m=length_um * 1e-6
        )
    for total_length_um in lengths_um[1:]:
        remaining_um = total_length_um - 100
        deembedded[port_type, remaining_um] = p370_extract(
            reference,
            networks[port_type, total_length_um],
            delta_length_m=remaining_um * 1e-6,
        )

Compare de-embedded transmission

The remaining length still changes the S21 attenuation and phase, even when the sections have the same propagation constant and impedance.

for port_type in port_types:
    plt.figure()
    for remaining_um in (300, 700):
        network = deembedded[port_type, remaining_um]["dut"]
        network.plot_s_db(1, 0, label=f"{remaining_um} µm ({remaining_um + 100}−100)")
    plt.title(port_labels[port_type])
    plt.xlabel("Frequency [GHz]")
    plt.ylabel("De-embedded S21 [dB]")
    plt.grid(True)
    plt.legend()
    plt.show()

png

png

Compare index and impedance across lengths

At each frequency, the same uniform line should have the same \(n_{\mathrm{eff}}\) and \(Z_c\) regardless of length. Raw curves include port and end effects; the two de-embedded curves should agree more closely. Both extractions share the same 100 µm reference, so agreement checks consistency rather than independent accuracy or mesh convergence.

Compare the lengths within each port type. The wave-port impedance uses the assigned 50 Ω reference described above and cannot yet be interpreted as a physical impedance comparison with the lumped-port result.

for port_type in port_types:
    for parameter in ("neff", "zc"):
        plt.figure()
        for length_um in lengths_um:
            values = raw_parameters[port_type, length_um][parameter].real
            plt.plot(
                networks[port_type, length_um].f / 1e9,
                values,
                label=f"Raw {length_um} µm",
            )
        for remaining_um in (300, 700):
            parameters = deembedded[port_type, remaining_um]
            plt.plot(
                parameters["dut"].f / 1e9,
                parameters[parameter].real,
                "--",
                label=f"De-embedded {remaining_um} µm ({remaining_um + 100}−100)",
            )
        title = port_labels[port_type]
        if parameter == "neff":
            plt.ylabel(r"$n_{\mathrm{eff}}$")
        elif port_type == "wave":
            plt.ylabel(r"Apparent Re($Z_c$) [Ω]")
            title += " — assigned 50 Ω reference"
        else:
            plt.ylabel(r"Re($Z_c$) [Ω]")
        plt.title(title)
        plt.xlabel("Frequency [GHz]")
        plt.grid(True)
        plt.legend()
        plt.show()

png

png

png

png

Distributed line parameters

Compare resistance, inductance, conductance, and capacitance per unit length from the two remaining sections: \(R+j\omega L=\gamma Z_c\) and \(G+j\omega C=\gamma/Z_c\).

Negative resistance or conductance would prevent interpreting the extraction as a passive uniform RLGC line, even if the S-parameters remain passive. Check port normalization, fixture assumptions, mesh and airbox convergence, and sweep accuracy before interpreting small losses. Wave-port RLGC uses the assigned 50 Ω reference.

units = {"R": "Ω/m", "L": "µH/m", "G": "S/m", "C": "pF/m"}
for port_type in port_types:
    fig, axes = plt.subplots(2, 2, sharex=True)
    for remaining_um in (300, 700):
        parameters = deembedded[port_type, remaining_um]
        network = parameters["dut"]
        # Wave-port zc uses an assigned 50 Ω reference, not measured modal impedance.
        # For fixed S, scaling that reference by a positive real k scales Zc, R,
        # and L by k, and G and C by 1/k; gamma and effective index stay unchanged.
        # This can explain much of the L/C offset relative to lumped ports, but
        # does not establish agreement in R/G or fix negative G. A physical
        # comparison needs a consistent voltage/power impedance convention.
        series_per_m = parameters["gamma"] * parameters["zc"]
        shunt_per_m = parameters["gamma"] / parameters["zc"]
        values = {
            "R": series_per_m.real,
            "L": series_per_m.imag / network.frequency.w * 1e6,
            "G": shunt_per_m.real,
            "C": shunt_per_m.imag / network.frequency.w * 1e12,
        }
        for axis, (parameter, unit) in zip(axes.flat, units.items(), strict=True):
            axis.plot(network.f / 1e9, values[parameter], label=f"{remaining_um} µm")
            axis.set_ylabel(f"{parameter} [{unit}]")
    for axis in axes.flat:
        axis.grid(True)
        axis.legend()
    for axis in axes[1]:
        axis.set_xlabel("Frequency [GHz]")
    title = port_labels[port_type]
    if port_type == "wave":
        title += " — assigned 50 Ω reference"
    fig.suptitle(title)
    fig.tight_layout()
    plt.show()

png

png

Check the cross-section with a 2D eigensolver

BoundaryModeSim solves directly on the CPW cross-section. At 50 GHz, compute two guided modes using the wave-port airbox from above. The solver provides field profiles and complex effective indices. Voltage paths across both gaps also give voltage–power impedance, using Palace's power convention. See Palace's 2D CPW example.

For this simulation type, add_cpw_port defines postprocessing paths only: it does not add a 50 Ω termination or excite the structure. The paths run from the signal to each ground at the conductor's mid-height. The CPW mode should have similar, in-phase voltages across the two gaps; inspect its fields as well as its index. Mode ordering alone does not identify the CPW mode.

This gives an independent check at one frequency. The lumped driven model uses a different airbox, so its comparison also includes that difference. Mesh, airbox, and voltage-path convergence still matter. The wave-port curves above retain their assigned 50 Ω reference; the 2D result does not automatically renormalize them.

from gsim.palace import BoundaryModeSim

mode_frequency = 50e9
mode_sim = BoundaryModeSim()
mode_sim.set_output_dir("./palace-sim-cpw-2d-wave-50ghz")
mode_sim.set_geometry(gsg_electrode())
mode_sim.set_stack(get_stack())
mode_sim.set_airbox(margin_x=0, margin_y=50, z_above=100, z_below=100)
mode_sim.set_cross_section("x=0")
mode_sim.set_boundary_mode(
    freq=mode_frequency, num_modes=2, save=2, target=1.7, tolerance=1e-8
)
# In BoundaryMode, this adds signal-to-ground voltage paths for both gaps.
mode_sim.add_cpw_port(
    "o2",
    layer="topmetal2",
    s_width=20,
    gap_width=15,
    excited=False,
    nsamples=200,
)
mode_sim.mesh(
    preset="default",
    refined_mesh_size=2,
    max_mesh_size=40,
    fmax=150e9,
    verbose=False,
)
mode_job_id = mode_sim.run(wait=False, check_cache=True, verbose="status")
Cache hit: reusing job 01a0f820-6610-7160-b842-40fe9e9e8ca3
import pandas as pd
from gsim.palace.results import load_text_results

mode_files = gsim.wait_for_results(mode_job_id, verbose="status")
mode_results = load_text_results(mode_files)
mode_rows = []
for mode_number, metrics in mode_results.modes.items():
    voltage_1 = mode_results.mode_voltage(index=1, mode=mode_number)
    voltage_2 = mode_results.mode_voltage(index=2, mode=mode_number)
    impedance_1 = mode_results.characteristic_impedance(index=1, mode=mode_number)
    impedance_2 = mode_results.characteristic_impedance(index=2, mode=mode_number)
    if any(value is None for value in (voltage_1, voltage_2, impedance_1, impedance_2)):
        raise ValueError("Missing mode voltage/impedance postprocessing results.")
    voltage_scale = abs(voltage_1) + abs(voltage_2)
    mismatch = abs(voltage_1 - voltage_2) / voltage_scale if voltage_scale else np.inf
    mode_rows.append(
        {
            "Mode": mode_number,
            "Re(n_eff)": metrics["n_eff"].real,
            "Im(n_eff)": metrics["n_eff"].imag,
            "Z_PV gap 1 [Ω]": impedance_1,
            "Z_PV gap 2 [Ω]": impedance_2,
            "Gap-voltage mismatch": mismatch,
        }
    )
mode_table = pd.DataFrame(mode_rows).set_index("Mode")
mode_table
  palace-94e5c9b7  [####################] 100%  complete  elapsed 0m 00s


Extracting results.tar.gz...
Downloaded 31 files to sim-data-palace-94e5c9b7
Re(n_eff) Im(n_eff) Z_PV gap 1 [Ω] Z_PV gap 2 [Ω] Gap-voltage mismatch
Mode
1 1.739413 -0.021797 69.743083 69.957534 0.000771
2 1.457172 -0.010083 43.958351 43.713514 1.000000

Compare the CPW mode with the driven extraction

Select the mode whose two signal-to-ground voltages agree most closely, and require their relative mismatch to be below 10%. This rejects the opposite gap-voltage signs of a slotline-like mode. Check the field plot below to confirm the mode identity.

Compare at 50 GHz by interpolating the driven sweep. The 2D row uses voltage–power impedance from a specified path. The wave-port extraction still uses its assigned 50 Ω reference. This comparison does not apply an impedance correction or validate the extracted R and G.

The heatmap shows transverse electric-field magnitude on a logarithmic color scale. Arrows show direction with phase referenced to the signal-to-ground voltage; their lengths are normalized. Conductor interiors are excluded from the simulation mesh.

cpw_mode = int(mode_table["Gap-voltage mismatch"].idxmin())
if mode_table.loc[cpw_mode, "Gap-voltage mismatch"] > 0.1:
    raise ValueError("No symmetric CPW mode found; inspect the modes.")
comparison_rows = [
    {
        "Model": f"2D CPW mode {cpw_mode}",
        "Airbox": "Wave",
        "Effective index": mode_results.modes[cpw_mode]["n_eff"].real,
        "Impedance [Ω]": mode_results.characteristic_impedance(mode=cpw_mode),
        "Impedance convention": "Voltage–power, gap 1",
    }
]
for port_type in port_types:
    for remaining_um in (300, 700):
        parameters = deembedded[port_type, remaining_um]
        frequency = parameters["dut"].f
        comparison_rows.append(
            {
                "Model": f"{port_labels[port_type]}, de-embedded {remaining_um} µm",
                "Airbox": port_type.capitalize(),
                "Effective index": np.interp(
                    mode_frequency, frequency, parameters["neff"]
                ),
                "Impedance [Ω]": np.interp(
                    mode_frequency, frequency, parameters["zc"].real
                ),
                "Impedance convention": (
                    "Assigned 50 Ω reference"
                    if port_type == "wave"
                    else "Lumped circuit"
                ),
            }
        )
pd.DataFrame(comparison_rows).set_index("Model")
Airbox Effective index Impedance [Ω] Impedance convention
Model
2D CPW mode 1 Wave 1.739413 69.743083 Voltage–power, gap 1
Lumped ports, de-embedded 300 µm Lumped 1.733796 68.179652 Lumped circuit
Lumped ports, de-embedded 700 µm Lumped 1.733581 68.101141 Lumped circuit
Wave ports, de-embedded 300 µm Wave 1.743988 50.823756 Assigned 50 Ω reference
Wave ports, de-embedded 700 µm Wave 1.744395 50.798011 Assigned 50 Ω reference
import pyvista as pv
from matplotlib.colors import LogNorm
from matplotlib.tri import Triangulation

from gsim.palace.results import load_fields

mode_directory = mode_results.files["mode-kn.csv"].parent
field_mesh = (
    load_fields(mode_directory, cycle=cpw_mode)
    .extract_surface(algorithm="dataset_surface")
    .triangulate()
    .clean()
)
# Plot on the native triangles so conductor interiors remain empty.
triangles = field_mesh.faces.reshape(-1, 4)[:, 1:]
triangulation = Triangulation(
    field_mesh.points[:, 0], field_mesh.points[:, 1], triangles
)
electric_field = field_mesh["E_real"] + 1j * field_mesh["E_imag"]
field_magnitude = np.linalg.norm(electric_field, axis=1)

plt.figure()
heatmap = plt.tripcolor(
    triangulation, field_magnitude, shading="gouraud", norm=LogNorm()
)
plt.colorbar(heatmap, label=r"$|E_t|$ [V/m]")
# Sample arrows on a regular grid; exclude points outside the dielectric mesh.
grid_y, grid_z = np.meshgrid(
    np.linspace(field_mesh.bounds[0], field_mesh.bounds[1], 25),
    np.linspace(field_mesh.bounds[2], field_mesh.bounds[3], 20),
)
arrow_points = pv.PolyData(
    np.column_stack((grid_y.ravel(), grid_z.ravel(), np.zeros(grid_y.size)))
).sample(field_mesh)
arrow_field = arrow_points["E_real"] + 1j * arrow_points["E_imag"]
voltage_phase = np.angle(mode_results.mode_voltage(index=1, mode=cpw_mode))
arrow_vectors = np.real(arrow_field * np.exp(-1j * voltage_phase))
arrow_magnitude = np.linalg.norm(arrow_vectors, axis=1)
valid = arrow_points["vtkValidPointMask"].astype(bool) & (arrow_magnitude > 0)
# Arrows show direction only; the heatmap shows magnitude.
arrow_vectors = arrow_vectors[valid] / arrow_magnitude[valid, None]
plt.quiver(
    arrow_points.points[valid, 0],
    arrow_points.points[valid, 1],
    arrow_vectors[:, 0],
    arrow_vectors[:, 1],
    angles="xy",
)
plt.xlabel("y [µm]")
plt.ylabel("z [µm]")
plt.title("CPW electric-field magnitude at 50 GHz")
plt.gca().set_aspect("equal")
plt.grid(False)
plt.show()

png