Optuna Optimization of Interdigital Capacitor#
Required extras
This notebook needs the models extra:
uv add "qpdk[models]"
# or, from a checkout of this repository:
uv sync --extra models
# or with pip:
pip install "qpdk[models]"
The Palace FEM solver must also be available on PATH.
See the extras reference for what each extra installs.
This example uses Optuna to optimize an interdigital capacitor for a target capacitance of 40 fF. The optimization is constrained to exactly 5 interdigital fingers.
flowchart LR
A["Optuna suggests<br/>finger geometry"] --> B["Build and mesh<br/>the capacitor"]
B --> C["Palace solves<br/>capacitance"]
C --> D["Score against<br/>40 fF target"]
D -->|next trial| A
Simulation Layout Setup#
Create a simulation layout with an interdigital capacitor, some extended straights and an etch for capacitive simulation.
@gf.cell
def capacitor_simulation(
finger_length: float = 20.0,
finger_gap: float = 2.0,
thickness: float = 5.0,
) -> gf.Component:
"""Create a capacitor simulation layout with launchers and direct connections.
Args:
finger_length: Length of each finger in μm.
finger_gap: Gap between adjacent fingers in μm.
thickness: Thickness of fingers and base section in μm.
Returns:
Component with the simulation layout including ports.
"""
c = gf.Component()
# Create interdigital capacitor with fixed 5 fingers as specified
cap_ref = c << interdigital_capacitor(
fingers=5, # Fixed to 5 fingers as per requirements
finger_length=finger_length,
finger_gap=finger_gap,
thickness=thickness,
etch_layer="M1_ETCH",
etch_bbox_margin=5.0,
cross_section="cpw",
)
# Add straights for larger area
straight_left = c << straight(length=20.0, cross_section="cpw")
straight_right = c << straight(length=20.0, cross_section="cpw")
straight_left.connect("o2", cap_ref.ports["o1"])
straight_right.connect("o1", cap_ref.ports["o2"])
# Add etched end at the straights
etch_left = c << straight(length=6.0, cross_section="etch")
etch_right = c << straight(length=6.0, cross_section="etch")
etch_left.connect("o2", straight_left.ports["o1"], allow_layer_mismatch=True)
etch_right.connect("o1", straight_right.ports["o2"], allow_layer_mismatch=True)
# Add simulation area layer around the layout
c.kdb_cell.shapes(gf.get_layer(LAYER.SIM_AREA)).insert(c.bbox().enlarged(50, 50))
# Add ports for marking capacitor terminals
c.add_port(name="M1_left", port=straight_left.ports["o1"])
c.add_port(name="M1_right", port=straight_right.ports["o2"])
return c
Optuna Objective Function#
Define the objective function that Optuna will optimize. The goal is to minimize the difference between the simulated capacitance and the target of 40 fF.
def _objective_function(trial: optuna.trial.Trial) -> float:
"""Optuna objective function to optimize capacitor for target capacitance.
Args:
trial: Optuna trial object with parameter suggestions.
Returns:
Objective value (difference from target capacitance).
"""
target_capacitance = 40.0 # in fF
# Define parameter search space
finger_length = trial.suggest_float("finger_length", 5.0, 50.0) # μm
finger_gap = trial.suggest_float("finger_gap", 1.0, 10.0) # μm
thickness = trial.suggest_float("thickness", 2.0, 10.0) # μm
try:
c = capacitor_simulation(
finger_length=finger_length,
finger_gap=finger_gap,
thickness=thickness,
)
simulated_capacitance = _run_capacitive_simulation(c)
# Calculate objective: minimize mean squared error from target capacitance
objective_value = (simulated_capacitance - target_capacitance) ** 2
# Store additional info for analysis
trial.set_user_attr("simulated_capacitance", simulated_capacitance)
trial.set_user_attr("target_capacitance", target_capacitance)
except Exception as e:
print(f"Trial failed with error: {e}")
return 1000.0 # Large penalty value
else:
return objective_value
Palace simulation settings#
This section shows how the Palace simulation is set up.
def _setup_palace_simulation(
simulation_folder: Path | None = None,
) -> dict[str, Any]:
"""Setup configuration for Palace capacitive simulation.
The mesh_parameters section is provided as keyword arguments
to :func:`~meshwell.mesh.mesh`.
The mesh parameters here are not optimized but serve as a reasonable
starting point while demonstrating how to set up the mesh in different ways.
Args:
simulation_folder: Folder to use for simulation files
Returns:
Dictionary with simulation configuration.
"""
if simulation_folder is None:
simulation_folder = Path(tempfile.mkdtemp(prefix="palace_"))
simulation_folder.mkdir(exist_ok=True, parents=True)
# Palace simulation configuration
return {
"layer_stack": LAYER_STACK,
"material_spec": material_properties,
"simulation_folder": simulation_folder,
"solver_config": {
"Order": 1, # FEM element order
"Device": "CPU", # or "GPU"
},
"mesh_parameters": {
"default_characteristic_length": 20, # μm
"resolution_specs": {
"M1@M1_left": [
ExponentialField(
sizemin=0.3, lengthscale=2, growth_factor=2.0, apply_to="curves"
),
ConstantInField(resolution=0.3, apply_to="surfaces"),
],
"M1@M1_right": [
ExponentialField(
sizemin=0.3, lengthscale=2, growth_factor=2.0, apply_to="curves"
),
ConstantInField(resolution=0.3, apply_to="surfaces"),
],
"M1": [ConstantInField(resolution=4.0, apply_to="volumes")],
"M1___boundary": [
ExponentialField(
sizemin=0.3,
lengthscale=2,
growth_factor=2.0,
apply_to="surfaces",
),
],
"Substrate": [
ConstantInField(resolution=5.0, apply_to="curves"),
ConstantInField(resolution=8.0, apply_to="surfaces"),
ConstantInField(resolution=15.0, apply_to="volumes"),
],
"Vacuum": [
ConstantInField(resolution=5.0, apply_to="curves"),
ConstantInField(resolution=15.0, apply_to="surfaces"),
ConstantInField(resolution=25.0, apply_to="volumes"),
],
},
"verbosity": 10,
},
}
def _run_capacitive_simulation(
component: gf.Component,
simulation_folder: Path | None = None,
) -> float:
"""Run Palace capacitive simulation (requires system dependencies).
Returns:
The simulated capacitance in femtofarads.
"""
from gplugins.palace import (
run_capacitive_simulation_palace,
)
config = _setup_palace_simulation(simulation_folder=simulation_folder)
results = run_capacitive_simulation_palace(component, n_processes=4, **config)
port_a, port_b = (p.name for p in component.ports)
return results.capacitance_matrix[port_a, port_b] * 1e15
Main Execution and Optuna Study#
Run the optimization study using Optuna to find parameters that achieve the target capacitance of 40 fF.
This section is not run in the documentation because Palace requires a local installation and the optimization may take time.
if __name__ == "__main__":
from qpdk import PDK
PDK.activate()
# First ensure a single simulation runs correctly
c = capacitor_simulation()
c.show()
simulated_capacitance = _run_capacitive_simulation(
c, simulation_folder=PATH.simulation / "capacitor_simulation"
)
print(f"Single simulation capacitance: {simulated_capacitance:.3f} fF")
# Create an Optuna study for minimization
study = optuna.create_study(
direction="minimize",
study_name="interdigital_capacitor_optimization",
)
print("Starting Optuna optimization for 40 fF interdigital capacitor...")
print("Target: 40 fF capacitance with 5 fingers")
print("Optimizing: finger_length, finger_gap, thickness")
print()
study.optimize(_objective_function, n_trials=5, n_jobs=1, show_progress_bar=True)
# Check if we have any successful trials
successful_trials = [
t for t in study.trials if t.value is not None and t.value < 1000.0
]
component_to_show: gf.Component | None = None
if successful_trials:
print(f"Best trial: {study.best_trial.number}")
print(f"Best objective value: {study.best_value:.3f} fF difference from target")
print("\nBest parameters:")
for param, value in study.best_trial.params.items():
print(f" {param}: {value:.3f} μm")
if "simulated_capacitance" in study.best_trial.user_attrs:
print(
f"\nSimulated capacitance: {study.best_trial.user_attrs['simulated_capacitance']:.3f} fF"
)
print(
f"Target capacitance: {study.best_trial.user_attrs['target_capacitance']:.3f} fF"
)
# Create and show the optimized component
best_params = study.best_trial.params
optimized_component = capacitor_simulation(
finger_length=best_params["finger_length"],
finger_gap=best_params["finger_gap"],
thickness=best_params["thickness"],
)
component_to_show = optimized_component
print("\nOptimized component created with:")
print(" - 5 fingers (fixed)")
print(f" - finger_length: {best_params['finger_length']:.3f} μm")
print(f" - finger_gap: {best_params['finger_gap']:.3f} μm")
print(f" - thickness: {best_params['thickness']:.3f} μm")
else:
print(
"No successful trials found. All trials resulted in component generation errors."
)
print(
"This suggests there may be issues with the component geometry or routing."
)
# Still try to create a component with default parameters for debugging
print("\nTrying to create component with default parameters for debugging...")
try:
component_to_show = capacitor_simulation()
print("Default component created successfully")
except Exception as e:
print(f"Error creating default component: {e}")
# Display the component (optional - requires display backend)
try:
if component_to_show is not None:
component_to_show.show()
except Exception:
print("(Component display not available in this environment)")
# Save optimization history for analysis
if not PATH.simulation.exists():
PATH.simulation.mkdir()
results_file = PATH.simulation / "capacitor_optimization_results.csv"
study.trials_dataframe().to_csv(results_file, index=False)
print(f"\nOptimization results saved to: {results_file}")