Running Palace Simulations¶
Palace is an open-source 3D electromagnetic simulator supporting eigenmode, driven (S-parameter), and electrostatic simulations. This notebook demonstrates using the gsim.palace API to run a driven simulation on a spiral inductor with Metal1 guard ring, and fitting the resulting S-parameters to an RLC equivalent circuit model for use in circulax.
Requirements:
- Notebook dependencies: uv sync --group palace-inductor
- gsim with Palace backend
Build inductor + guard ring¶
Known PDK limitation: gf.components.inductor accepts a turns parameter but does not use it in geometry construction. The spiral is always single-turn regardless of the value passed.
import gdsfactory as gf
from ihp import PDK
PDK.activate()
c = gf.components.inductor(
width=2,
space=2.1,
diameter=50,
turns=1,
layer_metal="TopMetal2drawing",
layer_inductor="INDdrawing",
layer_metal_pin="TopMetal2drawing",
layers_no_fill=("NoMetFillerdrawing",),
).copy()
# Define guard ring dimensions based on the inductor's bounding box
bbox = c.bbox()
xmin, ymin = bbox.left, bbox.bottom
xmax, ymax = bbox.right, bbox.top
margin_outer = 0.0
margin_inner = -15.0
xlo, xro = xmin - margin_outer, xmax + margin_outer
ybo, yto = ymin - margin_outer, ymax + margin_outer
xli, xri = xmin - margin_inner, xmax + margin_inner
ybi, yti = ymin - margin_inner, ymax + margin_inner
w_v = xli - xlo # Width vertical walls
h_h = yto - yti # Height horizontal walls
over = 0.5 # Overlap for Gmsh to fuse the pieces
# Left wall
c.add_ref(
gf.components.rectangle(
size=(w_v + over, yto - ybo), layer="Metal1drawing", centered=True
)
).move((xlo + w_v / 2 + over / 2, (yto + ybo) / 2))
# Right wall
c.add_ref(
gf.components.rectangle(
size=(w_v + over, yto - ybo), layer="Metal1drawing", centered=True
)
).move((xro - w_v / 2 - over / 2, (yto + ybo) / 2))
# Top wall
c.add_ref(
gf.components.rectangle(
size=(xro - xlo, h_h + over), layer="Metal1drawing", centered=True
)
).move(((xro + xlo) / 2, yto - h_h / 2 - over / 2))
# Bottom wall
c.add_ref(
gf.components.rectangle(
size=(xro - xlo, h_h + over), layer="Metal1drawing", centered=True
)
).move(((xro + xlo) / 2, ybo + h_h / 2 + over / 2))
cc = c.copy()
c.draw_ports()
c.plot()

Configure and run simulation with DrivenSim¶
from gsim.palace import DrivenSim
# Create simulation object
sim = DrivenSim()
# Set output directory
sim.set_output_dir("./palace-sim-inductor")
# Set the component geometry
sim.set_geometry(cc)
# Configure layer stack from active PDK
sim.set_stack(substrate_thickness=180.0, include_substrate=True)
# Configure ports
sim.add_port(
"P1", from_layer="metal1", to_layer="topmetal2", geometry="interlayer", excited=True
)
sim.add_port(
"P2", from_layer="metal1", to_layer="topmetal2", geometry="interlayer", excited=True
)
# Configure driven simulation (frequency sweep for S-parameters)
sim.set_driven(fmin=10e9, fmax=200e9, num_points=501)
# Validate configuration
print(sim.validate_config())
pyvirtualdisplay not available; continuing without Xvfb
Validation: PASSED
# Generate mesh (presets: "coarse", "default", "fine")
sim.set_airbox(margin_x=50, margin_y=50, z_above=50, z_below=5)
sim.mesh(preset="default", refined_mesh_size=1.5)
Small conductor feature detected (2.100 um) may be under-resolved by refined_mesh_size=5.000 um. Pass auto_size=True to scale the mesh down.
Mesh Summary
========================================
Dimensions: 262.6 x 262.6 x 250.9 µm
Nodes: 22,436
Elements: 167,920
Tetrahedra: 126,417
Edge length: 0.42 - 86.86 µm
Quality: 0.584 (min: 0.009)
SICN: 0.633 (all valid)
Worst element distortion, κ: 105.862 (tet centers; 1 is ideal)
Estimated Field DOFs: 805,372 (order 2; before Palace preprocessing)
Mesh hash: sha256:1bcee4d18ee87a16 (gmsh 4.15.2, gsim 0.6.0)
Mesher: 2D algorithm 5, 3D algorithm 1, threads 1 (1D/2D/3D limits 1/1/1)
----------------------------------------
Volumes (4):
- silicon [1]
- sio2 [2]
- sin [3]
- air [4]
Surfaces (12):
- metal1_xy [5]
- metal1_z [6]
- topmetal2_xy [7]
- topmetal2_z [8]
- P1 [9]
- P2 [10]
- air__silicon [11]
- silicon__sio2 [12]
- air__sio2 [13]
- sin__sio2 [14]
- air__sin [15]
- air__None [16]
----------------------------------------
Mesh: palace-sim-inductor/palace.msh
pyvirtualdisplay not available; continuing without Xvfb
[0m[33m2026-10-02 04:24:45.616 ( 12.664s) [ 7F3C5836F080]vtkXOpenGLRenderWindow.:1452 WARN| bad X server connection. DISPLAY=[0m

Run simulation on cloud¶
palace-e91ed6b3 [####################] 100% complete elapsed 0m 00s
Extracting results.tar.gz...
Downloaded 13 files to sim-data-palace-e91ed6b3
Port mapping: Port 1: P1, Port 2: P2
Port mapping: Port 1: P1, Port 2: P2
Analytical RLC Model Fit¶
Extract Differential Impedance¶
We assemble the S-parameter matrix from the simulation results into a scikit-rf Network object, which handles the conversion to Z-parameters. From the 2×2 Z-matrix we compute the differential impedance \(Z_\text{diff} = Z_{11} - Z_{12} - Z_{21} + Z_{22}\), which is the impedance seen between the two ports of the inductor under differential excitation.
import numpy as np
import skrf as rf
f = results.freq * 1e9 # GHz -> Hz
w = 2 * np.pi * f
ports = results.port_names
n = len(ports)
S = np.zeros((len(f), n, n), dtype=complex)
for i, pi in enumerate(ports):
for j, pj in enumerate(ports):
S[:, i, j] = results[(pi, pj)].complex
ntwk = rf.Network(f=f, s=S, f_unit="hz")
Z = ntwk.z
Z_sim = Z[:, 0, 0] - Z[:, 0, 1] - Z[:, 1, 0] + Z[:, 1, 1]
f_sim = f
RLC Model Definition¶
We fit an RLC equivalent circuit model to the simulated impedance data. The inductor is modeled as a series RL branch in parallel with a parasitic capacitance C:
The total impedance is:
We define the RLC impedance as a function of frequency. The total admittance (inverse of impedance) is the sum of the admittance of the series RL branch and the parasitic capacitance:
We rewrite the RLC impedance in normalized form. Defining \(\tilde\omega = \omega/\omega_0\), the dimensionless impedance is:
so that \(Z(f) = R \cdot z(f/f_0, Q)\).
The loss function measures the total squared error between the model and the simulated data across all frequencies:
This is a real-valued scalar that JAX will differentiate with respect to \(f_0\), \(Q\), and \(R\) to drive the optimization.
import jax
import jax.numpy as jnp
jax.config.update("jax_enable_x64", True)
# The RLC impedance function
def z_rlc(w, Q):
return (1 + 1j * w * Q) / (1 - w**2 + 1j * w / Q)
f_jnp = jnp.array(f_sim, dtype=jnp.float64)
Z_target = jnp.array(Z_sim, dtype=jnp.complex128)
@jax.jit
def loss_fn(param):
z_fit = param[2] * z_rlc(f_jnp / param[0], param[1])
z_err = Z_target - z_fit
return jnp.real(jnp.sum(z_err * jnp.conj(z_err)))
Initial Parameter Estimation¶
We estimate the initial values of \(R\), \(L\), and \(C\) directly from the data before running the optimization. Good initial values help the optimizer converge faster and avoid local minima.
- \(f_0\) is read from the frequency at which \(|Z|\) is maximum
- \(R \approx \text{Re}(Z)|_{f \to 0}\), the low-frequency resistance
- \(Q\) is estimated from the -3 dB bandwidth: \(Q = f_0 / \Delta f\), where \(\Delta f\) is the width of the peak above \(|Z|_\text{max}/\sqrt{2}\)
absZ = np.abs(Z_sim)
f0_ini = float(f_sim[np.argmax(absZ)])
R_ini = float(Z_sim.real[0])
mask = absZ > np.max(absZ) / np.sqrt(2)
Q_ini = f0_ini / np.ptp(f_sim[mask]) if mask.sum() > 1 else 5.0
par_ini = jnp.array([f0_ini, Q_ini, R_ini])
print(f"f0 = {f0_ini / 1e9:.3f} GHz | Q = {Q_ini:.3f} | R = {R_ini:.4f} Ohm")
f0 = 169.980 GHz | Q = 34.409 | R = 0.9722 Ohm
Optimization¶
We minimize the squared error between the model and the data over all frequencies using the Adam optimizer.
At each step, Adam computes the gradient \(\nabla_\theta \mathcal{L}\) automatically via JAX autodiff and updates the parameters:
import optax
optimizer = optax.adam(learning_rate=0.05)
opt_state = optimizer.init(par_ini)
vg_fn = jax.jit(jax.value_and_grad(loss_fn))
vg_fn(par_ini)
par = par_ini
for step in range(1000):
loss, grads = vg_fn(par)
if step % 200 == 0:
print(
f"step {step:4d}: f0={float(par[0]) / 1e9:.4f} GHz Q={float(par[1]):.3f} R={float(par[2]):.4f} loss={float(loss):.3e}"
)
updates, opt_state = optimizer.update(grads, opt_state)
par = optax.apply_updates(par, updates)
f0_fit, Q_fit, R_fit = float(par[0]), float(par[1]), float(par[2])
step 0: f0=169.9800 GHz Q=34.409 R=0.9722 loss=1.438e+08
step 200: f0=169.9800 GHz Q=35.780 R=3.0559 loss=7.837e+05
step 400: f0=169.9800 GHz Q=34.457 R=3.2415 loss=3.748e+05
step 600: f0=169.9800 GHz Q=33.480 R=3.3908 loss=2.021e+05
step 800: f0=169.9800 GHz Q=32.944 R=3.4777 loss=1.584e+05
Recover L and C¶
Once converged, \(L\) and \(C\) are recovered analytically from the fitted \((f_0, Q, R)\) using the RLC resonance relations:
where \(\omega_0 = 2\pi f_0\).
w0 = 2 * np.pi * f0_fit
tau = Q_fit / w0
L_fit = tau * R_fit
C_fit = 1 / (L_fit * w0**2)
print(f"R = {R_fit:.6f} Ohm")
print(f"L = {L_fit * 1e12:.4f} pH")
print(f"C = {C_fit * 1e15:.4f} fF")
print(f"f0 = {f0_fit / 1e9:.4f} GHz Q = {Q_fit:.3f}")
R = 3.516311 Ohm
L = 107.7021 pH
C = 8.1399 fF
f0 = 169.9800 GHz Q = 32.713
Results¶
We evaluate the fitted model across the full frequency range and compare it against the simulation data. The two plots show the magnitude \(|Z(f)|\) on a log scale and the phase \(\arg(Z(f))\) in degrees — a good fit should reproduce both the inductive rise, the resonance peak, and the phase transition.
import matplotlib.pyplot as plt
print(f"\nR = {R_fit:.6f} Ohm")
print(f"L = {L_fit * 1e12:.4f} pH")
print(f"C = {C_fit * 1e15:.4f} fF")
print(f"f0 = {f0_fit / 1e9:.4f} GHz Q = {Q_fit:.3f}")
Z_fit = np.array([R_fit * z_rlc(f / f0_fit, Q_fit) for f in f_sim])
fig, axes = plt.subplots(2, 1, figsize=(10, 8))
axes[0].plot(f_sim / 1e9, np.abs(Z_sim), ".", ms=3, label="Sim")
axes[0].plot(
f_sim / 1e9,
np.abs(Z_fit),
label=f"RLC fit L={L_fit * 1e12:.1f}pH C={C_fit * 1e15:.1f}fF R={R_fit:.3f}Ohm",
)
axes[0].set_yscale("log")
axes[0].set_xlabel("f [GHz]")
axes[0].set_ylabel("|Z| [Ohm]")
axes[0].legend()
axes[1].plot(f_sim / 1e9, np.angle(Z_sim, deg=True), ".", ms=3, label="Sim")
axes[1].plot(f_sim / 1e9, np.angle(Z_fit, deg=True), label="RLC fit")
axes[1].set_xlabel("f [GHz]")
axes[1].set_ylabel("arg(Z) [°]")
axes[1].legend()
plt.tight_layout()
plt.show()
R = 3.516311 Ohm
L = 107.7021 pH
C = 8.1399 fF
f0 = 169.9800 GHz Q = 32.713

Circulax-Based Inverse Design¶
Define Circulax Component¶
With the fitted values from the analytical fit, we define my_inductor as a frequency-domain circulax component using @fdomain_component. The decorator converts the RLC admittance matrix into a two-port component compatible with any circulax netlist.
The admittance matrix for a symmetric two-port is:
The netlist connects the inductor directly between IN and GND — a single-port measurement configuration, consistent with how \(Z_\text{diff}\) was extracted from the simulation.
from circulax import compile_circuit
from circulax.s_transforms import fdomain_component
# Equivalent circuit:
#
# --- C ---
# | |
# p1 ----+---R--L--+---- p2
@fdomain_component(ports=("p1", "p2"))
def my_inductor(f, R=1.0, L=100e-12, C=10e-15):
w = 2.0 * jnp.pi * f
Y_RL = 1.0 / (R + 1j * w * L) # series RL branch
Y_C = 1j * w * C # parallel capacitance
Y = Y_RL + Y_C
return jnp.array([[Y, -Y], [-Y, Y]], dtype=jnp.complex128)
net_dict = {
"instances": {
"GND": {"component": "ground"},
"L1": {
"component": "my_inductor",
"settings": {"R": R_fit, "L": L_fit, "C": C_fit},
},
},
"ports": {"IN": "L1,p1"},
"connections": {
"L1,p2": "GND,p1",
},
}
models = {"my_inductor": my_inductor, "ground": lambda: 0}
circuit = compile_circuit(net_dict, models)
groups = circuit.groups
freqs = jnp.asarray(f_sim)
Z_target = jnp.asarray(Z_sim)
print("Circuit compiled. System size:", circuit.sys_size)
print("Port map:", circuit.port_map)
Circuit compiled. System size: 2
Port map: {'IN': 1, 'L1,p1': 1, 'GND,p1': 0, 'L1,p2': 0}
Inverse Design with Circulax¶
We use circulax inside the optimization loop as part of a differentiable inverse design workflow. At each step, we perform a full AC sweep and minimize the discrepancy between the compact-model impedance and the Palace simulation data:
where the impedance is recovered from the simulated reflection coefficient through the standard one-port relation
The optimization is initialized using the analytical RLC fit parameters. To ensure physically meaningful values throughout the optimization, we optimize unconstrained variables and map them to positive parameters using a softplus parameterization:
This enables stable gradient-based optimization using JAX automatic differentiation and Optax optimizers.
from circulax.solvers import setup_ac_sweep
from circulax.utils import update_params_dict
# Port node for IN — check port_map output above
port_node = next(v for k, v in circuit.port_map.items() if k == "IN")
# Positive parametrization
# raw_params -> softplus -> positive physical parameters
def positive(x):
return jax.nn.softplus(x)
# inverse-softplus
def inv_softplus(y):
return jnp.log(jnp.exp(y) - 1.0)
def loss_circulax(raw_params):
params = positive(raw_params)
R, L, C = params
g = update_params_dict(groups, "my_inductor", "L1", "R", R)
g = update_params_dict(g, "my_inductor", "L1", "L", L)
g = update_params_dict(g, "my_inductor", "L1", "C", C)
y_op = circuit.with_groups(g)()
ac = setup_ac_sweep(groups=g, num_vars=circuit.sys_size, port_nodes=[port_node])
sol = ac(freqs=freqs, y_dc=y_op)
S11 = sol[:, 0, 0]
Z_cx = 50.0 * (1 + S11) / (1 - S11)
err_re = jnp.real(Z_cx) - jnp.real(Z_target)
err_im = jnp.imag(Z_cx) - jnp.imag(Z_target)
loss = jnp.mean(err_re**2 + err_im**2)
return loss
raw_params_ini = inv_softplus(
jnp.array(
[
R_fit,
L_fit,
C_fit,
]
)
)
optimizer = optax.adam(1e-2)
opt_state = optimizer.init(raw_params_ini)
vg_fn = jax.jit(jax.value_and_grad(loss_circulax))
vg_fn(raw_params_ini) # warm-up
raw_params = raw_params_ini
for step in range(500):
loss, grads = vg_fn(raw_params)
if step % 20 == 0:
params = positive(raw_params)
R_, L_, C_ = params
print(
f"step {step:3d}: R={float(R_):.5f} Ohm L={float(L_) * 1e12:.3f} pH C={float(C_) * 1e15:.3f} fF loss={float(loss):.3e}"
)
updates, opt_state = optimizer.update(grads, opt_state)
raw_params = optax.apply_updates(raw_params, updates)
R_fit_cx, L_fit_cx, C_fit_cx = [float(x) for x in positive(raw_params)]
f0_cx = 1.0 / (2 * np.pi * np.sqrt(L_fit_cx * C_fit_cx))
Q_cx = 2 * np.pi * f0_cx * L_fit_cx / R_fit_cx
print(f"\nR = {R_fit_cx:.6f} Ohm")
print(f"L = {L_fit_cx * 1e12:.4f} pH")
print(f"C = {C_fit_cx * 1e15:.4f} fF")
print(f"f0 = {f0_cx / 1e9:.4f} GHz Q = {Q_cx:.3f}")
step 0: R=3.51631 Ohm L=107.702 pH C=8.216 fF loss=2.399e+04
step 20: R=3.56581 Ohm L=107.197 pH C=8.178 fF loss=4.764e+02
step 40: R=3.48641 Ohm L=107.361 pH C=8.180 fF loss=6.571e+02
step 60: R=3.48603 Ohm L=107.305 pH C=8.173 fF loss=2.169e+02
step 80: R=3.49437 Ohm L=107.318 pH C=8.172 fF loss=2.142e+02
step 100: R=3.49169 Ohm L=107.346 pH C=8.171 fF loss=2.056e+02
step 120: R=3.49440 Ohm L=107.368 pH C=8.170 fF loss=2.045e+02
step 140: R=3.49527 Ohm L=107.388 pH C=8.168 fF loss=2.039e+02
step 160: R=3.49684 Ohm L=107.409 pH C=8.167 fF loss=2.034e+02
step 180: R=3.49822 Ohm L=107.431 pH C=8.165 fF loss=2.029e+02
step 200: R=3.49969 Ohm L=107.453 pH C=8.163 fF loss=2.025e+02
step 220: R=3.50115 Ohm L=107.475 pH C=8.162 fF loss=2.020e+02
step 240: R=3.50261 Ohm L=107.497 pH C=8.160 fF loss=2.016e+02
step 260: R=3.50405 Ohm L=107.519 pH C=8.158 fF loss=2.012e+02
step 280: R=3.50546 Ohm L=107.541 pH C=8.157 fF loss=2.009e+02
step 300: R=3.50685 Ohm L=107.562 pH C=8.155 fF loss=2.006e+02
step 320: R=3.50820 Ohm L=107.583 pH C=8.154 fF loss=2.003e+02
step 340: R=3.50951 Ohm L=107.603 pH C=8.152 fF loss=2.000e+02
step 360: R=3.51078 Ohm L=107.622 pH C=8.151 fF loss=1.998e+02
step 380: R=3.51199 Ohm L=107.640 pH C=8.149 fF loss=1.995e+02
step 400: R=3.51315 Ohm L=107.658 pH C=8.148 fF loss=1.994e+02
step 420: R=3.51426 Ohm L=107.675 pH C=8.147 fF loss=1.992e+02
step 440: R=3.51531 Ohm L=107.691 pH C=8.145 fF loss=1.990e+02
step 460: R=3.51630 Ohm L=107.706 pH C=8.144 fF loss=1.989e+02
step 480: R=3.51724 Ohm L=107.720 pH C=8.143 fF loss=1.988e+02
R = 3.518117 Ohm
L = 107.7334 pH
C = 8.1422 fF
f0 = 169.9321 GHz Q = 32.696
Results¶
We compare the analytical fit and the circulax inverse design against the original simulation data.
g_final = update_params_dict(groups, "my_inductor", "L1", "R", R_fit_cx)
g_final = update_params_dict(g_final, "my_inductor", "L1", "L", L_fit_cx)
g_final = update_params_dict(g_final, "my_inductor", "L1", "C", C_fit_cx)
y_op_final = circuit.with_groups(g_final)()
ac_final = setup_ac_sweep(
groups=g_final, num_vars=circuit.sys_size, port_nodes=[port_node]
)
sol_final = ac_final(freqs=freqs, y_dc=y_op_final)
S11_final = sol_final[:, 0, 0]
Z_cx_final = 50.0 * (1 + S11_final) / (1 - S11_final)
fig, axes = plt.subplots(2, 1, figsize=(10, 8))
axes[0].plot(f_sim / 1e9, np.abs(Z_sim), ".", ms=3, label="Sim original")
axes[0].plot(
f_sim / 1e9, np.abs(Z_fit), label=f"Analytical fit L={L_fit * 1e12:.1f}pH"
)
axes[0].plot(
f_sim / 1e9, np.abs(Z_cx_final), label=f"Circulax fit L={L_fit_cx * 1e12:.1f}pH"
)
axes[0].set_yscale("log")
axes[0].set_xlabel("f [GHz]")
axes[0].set_ylabel("|Z| [Ohm]")
axes[0].legend()
axes[1].plot(
f_sim / 1e9, np.angle(np.array(Z_sim), deg=True), ".", ms=3, label="Sim original"
)
axes[1].plot(f_sim / 1e9, np.angle(np.array(Z_fit), deg=True), label="Analytical fit")
axes[1].plot(
f_sim / 1e9, np.angle(np.array(Z_cx_final), deg=True), label="Circulax fit"
)
axes[1].set_xlabel("f [GHz]")
axes[1].set_ylabel("arg(Z) [°]")
axes[1].legend()
plt.tight_layout()
plt.show()
