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

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()


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()




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()


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()
