"""Capacitive coupler components."""
from __future__ import annotations
from itertools import chain
from math import ceil, floor
import gdsfactory as gf
from gdsfactory.component import Component
from gdsfactory.typings import CrossSectionSpec, LayerSpec
from qpdk.cells._coupling import mark_coupling_regions
from qpdk.cells._schematic import (
half_circle_coupler_schematic,
interdigital_capacitor_schematic,
plate_capacitor_schematic,
plate_capacitor_single_schematic,
)
from qpdk.cells.waveguides import add_etch_gap, bend_circular, straight
from qpdk.tech import LAYER, get_etch_section, get_etch_sections
from qpdk.utils import merge_layers_with_etch as _merge_layers_with_etch
[docs]
@gf.cell(
tags=["capacitors", "couplers"],
schematic_function=half_circle_coupler_schematic,
)
def half_circle_coupler(
radius: float = 50.0,
angle: float = 180.0,
extension_length: float = 10.0,
cross_section: CrossSectionSpec = "cpw",
extra_straight_length: float = 20.0,
open_end: bool = True,
) -> Component:
"""Creates a half-circle coupler for readout.
This coupler consists of a circular bend (typically 180 degrees) that wraps
around a resonator arm for capacitive coupling.
Args:
radius: Inner radius of the half-circle in μm.
angle: Angle of the circular arc in degrees.
extension_length: Length of the straight sections extending from the
ends of the half-circle in μm.
cross_section: Cross-section specification for the coupler.
extra_straight_length: Length of the straight section extending from the
bottom of the half-circle in μm.
open_end: If True, adds an etched gap at the ends of the extensions.
Returns:
Component: A gdsfactory component with the half-circle coupler geometry.
"""
c = Component()
bend = c.add_ref(
bend_circular(
radius=radius,
angle=angle,
cross_section=cross_section,
)
)
# Position bend such that it's centered and opening upwards
bend.rotate(-angle / 2)
bend.move((-bend.dcenter[0], -bend.dcenter[1]))
# Add extensions to the ends of the bend
if extension_length > 0:
ext1 = c.add_ref(straight(length=extension_length, cross_section=cross_section))
ext1.connect("o1", bend.ports["o1"])
ext2 = c.add_ref(straight(length=extension_length, cross_section=cross_section))
ext2.connect("o1", bend.ports["o2"])
where_to_add_gaps = [ext1.ports["o2"], ext2.ports["o2"]]
else:
where_to_add_gaps = [bend.ports["o1"], bend.ports["o2"]]
if open_end:
for port in where_to_add_gaps:
add_etch_gap(c, port, cross_section=cross_section)
# Get cross section details to calculate overlap
xs = gf.get_cross_section(cross_section)
cross_section_etch_section = get_etch_section(xs)
# Ensure significant overlap by moving stem into the bend metal
# and considering the bend radius
overlap = xs.width / 2 + cross_section_etch_section.width
# Add a stem/lead straight from the center of the arc.
# The stem uses the CPW cross-section but does NOT extend into the bend,
# to avoid M1_ETCH overlapping with the bend's M1_DRAW.
stem = c.add_ref(
straight(
length=extra_straight_length,
cross_section=cross_section,
)
)
stem.rotate(-90)
stem.movey(bend.dbbox().bottom)
stem.movex(bend.dcenter[0]) # Center it
# Bridge the stem into the bend with M1_DRAW center conductor and
# M1_ETCH gap sections, matching the CPW cross-section pattern.
# Unlike using a straight ref, these polygons avoid hierarchical
# M1_ETCH-over-M1_DRAW conflicts with the bend.
bridge_x = bend.dcenter[0]
bridge_y_bottom = bend.dbbox().bottom
bridge_y_top = bridge_y_bottom + overlap
c.add_polygon(
[
(bridge_x - xs.width / 2, bridge_y_bottom),
(bridge_x + xs.width / 2, bridge_y_bottom),
(bridge_x + xs.width / 2, bridge_y_top),
(bridge_x - xs.width / 2, bridge_y_top),
],
layer=LAYER.M1_DRAW,
)
etch_height = overlap / 3
for etch_s in get_etch_sections(xs):
etch_x_center = bridge_x + etch_s.offset
c.add_polygon(
[
(etch_x_center - etch_s.width / 2, bridge_y_bottom),
(etch_x_center + etch_s.width / 2, bridge_y_bottom),
(etch_x_center + etch_s.width / 2, bridge_y_bottom + etch_height),
(etch_x_center - etch_s.width / 2, bridge_y_bottom + etch_height),
],
layer=LAYER.M1_ETCH,
)
c.add_port("o3", port=stem.ports["o2"])
# Place anchor at the arc center, computed as the midpoint of the
# bend ports for a circular arc, for concentric alignment with an
# inner resonator bend.
arc_center_x = (bend.ports["o1"].dx + bend.ports["o2"].dx) / 2
arc_center_y = (bend.ports["o1"].dy + bend.ports["o2"].dy) / 2
c.add_port(
name="anchor",
center=(arc_center_x, arc_center_y),
width=xs.width,
orientation=90,
layer=LAYER.M1_DRAW,
port_type="placement",
)
return c
def _interdigital_capacitor(
fingers: int,
finger_length: float,
finger_gap: float,
thickness: float,
layer_metal: LayerSpec,
etch_layer: LayerSpec | None,
etch_bbox_margin: float,
cross_section: CrossSectionSpec,
half: bool,
) -> Component:
"""Shared geometry of the interdigital capacitor variants.
Args:
fingers: Total number of fingers of the capacitor (must be >= 1).
finger_length: Length of each finger in μm.
finger_gap: Gap between adjacent fingers in μm.
thickness: Thickness of fingers and the base section in μm.
layer_metal: Layer for the metal fingers.
etch_layer: Optional layer for etching around the capacitor.
etch_bbox_margin: Margin around the capacitor for the etch layer in μm.
cross_section: Cross-section for the short straight from the etch box capacitor.
half: If True, creates a single-sided capacitor (half of the interdigital capacitor).
Returns:
Component: A gdsfactory component with the interdigital capacitor
geometry and one port ('o1'), plus a second one ('o2') on the
opposing side unless *half* is True.
Raises:
ValueError: If fingers is less than 1.
"""
c = Component()
if fingers < 1:
raise ValueError("Must have at least 1 finger")
width = (
2 * thickness + finger_length + finger_gap
if not half
else thickness + finger_length
) # total length
height = fingers * thickness + (fingers - 1) * finger_gap # total height
points_1 = [
(0.0, 0.0),
(0, height),
(thickness + finger_length, height),
(thickness + finger_length, height - thickness),
(thickness, height - thickness),
# TODO(Python 3.15): `*(*pts for i in ...)` (PEP 798) replaces chain.from_iterable here
# and below; drop the itertools import.
*chain.from_iterable(
(
(thickness, height - (2 * i) * (thickness + finger_gap)),
(
thickness + finger_length,
height - (2 * i) * (thickness + finger_gap),
),
(
thickness + finger_length,
height - (2 * i) * (thickness + finger_gap) - thickness,
),
(thickness, height - (2 * i) * (thickness + finger_gap) - thickness),
)
for i in range(ceil(fingers / 2))
),
(thickness, 0),
(0.0, 0.0),
]
c.add_polygon(points_1, layer=layer_metal)
if not half:
points_2 = [
(width, 0),
(width, height),
(width - thickness, height),
*chain.from_iterable(
(
(
width - thickness,
height - (1 + 2 * i) * thickness - (1 + 2 * i) * finger_gap,
),
(
width - (thickness + finger_length),
height - (1 + 2 * i) * thickness - (1 + 2 * i) * finger_gap,
),
(
width - (thickness + finger_length),
height - (2 + 2 * i) * thickness - (1 + 2 * i) * finger_gap,
),
(
width - thickness,
height - (2 + 2 * i) * thickness - (1 + 2 * i) * finger_gap,
),
)
for i in range(floor(fingers / 2))
),
(width - thickness, 0),
(width, 0),
]
c.add_polygon(points_2, layer=layer_metal)
# Add etch layer bbox if specified
if etch_layer is not None:
etch_bbox = [
(-etch_bbox_margin, -etch_bbox_margin),
(width + etch_bbox_margin, -etch_bbox_margin),
(width + etch_bbox_margin, height + etch_bbox_margin),
(-etch_bbox_margin, height + etch_bbox_margin),
]
c.add_polygon(etch_bbox, layer=etch_layer)
# Add small straights on the left and right sides of the capacitor
straight_cross_section = gf.get_cross_section(cross_section)
straight_out_of_etch = straight(
length=etch_bbox_margin, cross_section=straight_cross_section
)
straight_left = c.add_ref(straight_out_of_etch).move((
-etch_bbox_margin,
height / 2,
))
straight_right = None
if not half:
straight_right = c.add_ref(straight_out_of_etch).move((width, height / 2))
# Merge WG marker layer with draw metal and create etch negative
c = _merge_layers_with_etch(
component=c,
draw_layer=layer_metal,
wg_layer=straight_cross_section.layer,
etch_layer=etch_layer,
)
ports_config: list[tuple[str, gf.Port] | None] = [
("o1", straight_left["o1"]),
]
if not half and straight_right is not None:
ports_config.append(("o2", straight_right["o2"]))
for port_name, port_ref in filter(None, ports_config):
c.add_port(
name=port_name,
width=port_ref.width,
center=port_ref.center,
orientation=port_ref.orientation,
layer=LAYER.M1_DRAW,
)
# Center at (0,0)
c.move((-width / 2, -height / 2))
return c
[docs]
@gf.cell(tags=["capacitors"], schematic_function=interdigital_capacitor_schematic)
def interdigital_capacitor(
fingers: int = 4,
finger_length: float = 20.0,
finger_gap: float = 2.0,
thickness: float = 5.0,
layer_metal: LayerSpec = LAYER.M1_DRAW,
etch_layer: LayerSpec | None = "M1_ETCH",
etch_bbox_margin: float = 2.0,
cross_section: CrossSectionSpec = "cpw",
) -> Component:
"""Generate an interdigital capacitor component with ports on both ends.
An interdigital capacitor consists of interleaved metal fingers that create
a distributed capacitance. This component creates a planar capacitor with
two sets of interleaved fingers extending from opposite ends.
.. svgbob::
┌─┐───────┐┌─┐
│ │───────┘│ │
│ │ ┌──────│ │
┌│ │ └──────│ │┐
o1└│ │──────┐ │ │┘o2
│ │──────┘ │ │
│ │ ┌──────│ │
└─┘ └──────└─┘
See for example :cite:`leizhuAccurateCircuitModel2000`.
Note:
``finger_length=0`` effectively provides a parallel plate capacitor.
The capacitance scales approximately linearly with the number of fingers
and finger length.
The matching SAX model is
:func:`~qpdk.models.capacitor.interdigital_capacitor`. The single-sided
variant is its own factory, :func:`interdigital_capacitor_half`, with a
different port contract.
Args:
fingers: Total number of fingers of the capacitor (must be >= 1).
finger_length: Length of each finger in μm.
finger_gap: Gap between adjacent fingers in μm.
thickness: Thickness of fingers and the base section in μm.
layer_metal: Layer for the metal fingers.
etch_layer: Optional layer for etching around the capacitor.
etch_bbox_margin: Margin around the capacitor for the etch layer in μm.
cross_section: Cross-section for the short straight from the etch box capacitor.
Returns:
Component: A gdsfactory component with the interdigital capacitor geometry
and two ports ('o1' and 'o2') on opposing sides.
"""
return _interdigital_capacitor(
fingers=fingers,
finger_length=finger_length,
finger_gap=finger_gap,
thickness=thickness,
layer_metal=layer_metal,
etch_layer=etch_layer,
etch_bbox_margin=etch_bbox_margin,
cross_section=cross_section,
half=False,
)
[docs]
@gf.cell(
tags=[
"capacitors",
]
)
def interdigital_capacitor_half(
fingers: int = 4,
finger_length: float = 20.0,
finger_gap: float = 2.0,
thickness: float = 5.0,
layer_metal: LayerSpec = LAYER.M1_DRAW,
etch_layer: LayerSpec | None = "M1_ETCH",
etch_bbox_margin: float = 2.0,
cross_section: CrossSectionSpec = "cpw",
) -> Component:
"""Generate a single-sided interdigital capacitor with a single port.
This is one comb of the interdigital capacitor, for layouts whose second
plate is a separate object. It exposes only port 'o1', so the two-port
:func:`~qpdk.models.capacitor.interdigital_capacitor` model does not
describe it and no SAX model is registered for this factory.
Args:
fingers: Total number of fingers of the capacitor (must be >= 1).
finger_length: Length of each finger in μm.
finger_gap: Gap between adjacent fingers in μm.
thickness: Thickness of fingers and the base section in μm.
layer_metal: Layer for the metal fingers.
etch_layer: Optional layer for etching around the capacitor.
etch_bbox_margin: Margin around the capacitor for the etch layer in μm.
cross_section: Cross-section for the short straight from the etch box capacitor.
Returns:
Component: A gdsfactory component with the single-sided interdigital
capacitor geometry and a single port ('o1').
"""
return _interdigital_capacitor(
fingers=fingers,
finger_length=finger_length,
finger_gap=finger_gap,
thickness=thickness,
layer_metal=layer_metal,
etch_layer=etch_layer,
etch_bbox_margin=etch_bbox_margin,
cross_section=cross_section,
half=True,
)
[docs]
@gf.cell(tags=["capacitors", "couplers"], schematic_function=plate_capacitor_schematic)
def plate_capacitor(
length: float = 80.0,
width: float = 10.0,
gap: float = 7.0,
offset: float = 0.0,
) -> Component:
"""Two facing plate capacitor pads with the dataset's coplanar ground frame.
Reuses two :func:`plate_capacitor_single` cells. The first pad is centered
at the origin and the second is shifted vertically by ``offset``. Ports
``o1`` and ``o2`` lie at the outer ends of the pads' short CPW necks.
A grounded frame, 45 um wide and 10 um from the electrode bounding box,
matches :func:`qpdk.models.plate_capacitor` on silicon. The frame is
part of the modeled environment and surrounds both port reference planes.
The model includes the coupling and both capacitances to ground. Its
validated dimensions are length 40 to 120 um, width 5 to 20 um, gap 4 to
10 um and offset -12 to 12 um. The lookup returns NaN outside that range.
Args:
length: Vertical pad length in um.
width: Horizontal pad width in um.
gap: Horizontal separation between facing pad edges in um.
offset: Vertical displacement of the second pad in um.
Returns:
Component with two signal ports and an implicit common ground.
Raises:
ValueError: If length, width or gap is not positive.
"""
if width <= 0:
raise ValueError(f"width must be positive, got {width}")
if length <= 0:
raise ValueError(f"length must be positive, got {length}")
if gap <= 0:
raise ValueError(f"gap must be positive, got {gap}")
component = Component()
pad = plate_capacitor_single(length=length, width=width)
first = component.add_ref(pad)
second = component.add_ref(pad)
second.drotate(180)
second.dmove((width + gap, offset))
component.add_port("o1", port=first.ports["o1"])
component.add_port("o2", port=second.ports["o1"])
dbu = component.kcl.dbu
electrodes = component.get_region(LAYER.M1_DRAW).merged()
inner = gf.kdb.Region(electrodes.bbox().enlarged(round(10.0 / dbu)))
outer = gf.kdb.Region(inner.bbox().enlarged(round(45.0 / dbu)))
for polygon in (outer - inner).each():
component.add_polygon(polygon.to_dtype(dbu), layer=LAYER.M1_DRAW)
for polygon in (inner - electrodes).each():
component.add_polygon(polygon.to_dtype(dbu), layer=LAYER.M1_ETCH)
mark_coupling_regions(component, first, second, gap=gap)
return component
[docs]
@gf.cell(
tags=["capacitors", "couplers"],
schematic_function=plate_capacitor_single_schematic,
)
def plate_capacitor_single(
length: float = 26.0,
width: float = 5.0,
layer_metal: LayerSpec = LAYER.M1_DRAW,
etch_layer: LayerSpec | None = "M1_ETCH",
etch_bbox_margin: float = 2.0,
cross_section: CrossSectionSpec = "cpw",
) -> Component:
"""Creates a single plate capacitor for coupling.
This is essentially half of a :func:`~plate_capacitor`.
.. svgbob::
______
_________| |
| |
| o1 pad1 |
| |
|_________ |
|______|
Args:
length: Length (vertical extent) of the capacitor pad in μm.
width: Width (horizontal extent) of the capacitor pad in μm.
layer_metal: Layer for the metal pad.
etch_layer: Optional layer for etching around the capacitor.
etch_bbox_margin: Margin around the capacitor for the etch layer in μm.
cross_section: Cross-section for the short straight from the etch box capacitor.
Returns:
A gdsfactory component with the plate capacitor geometry.
Raises:
ValueError: If width or length is not positive.
"""
if width <= 0:
raise ValueError(f"width must be positive, got {width}")
if length <= 0:
raise ValueError(f"length must be positive, got {length}")
c = Component()
points = [
(0.0, 0.0),
(0, length),
(width, length),
(width, 0),
]
c.add_polygon(points, layer=layer_metal)
# Add etch layer bbox if specified
if etch_layer is not None:
etch_bbox = [
(-etch_bbox_margin, -etch_bbox_margin),
(width + etch_bbox_margin, -etch_bbox_margin),
(width + etch_bbox_margin, length + etch_bbox_margin),
(-etch_bbox_margin, length + etch_bbox_margin),
]
c.add_polygon(etch_bbox, layer=etch_layer)
# Add small straight on the left side of the capacitor
straight_cross_section = gf.get_cross_section(cross_section)
straight_out_of_etch = straight(
length=etch_bbox_margin, cross_section=straight_cross_section
)
straight_left = c.add_ref(straight_out_of_etch).move((
-etch_bbox_margin,
length / 2,
))
# Merge WG marker layer with draw metal and create etch negative
c = _merge_layers_with_etch(
component=c,
draw_layer=layer_metal,
wg_layer=straight_cross_section.layer,
etch_layer=etch_layer,
)
c.add_port(
name="o1",
width=straight_left["o1"].width,
center=straight_left["o1"].center,
orientation=straight_left["o1"].orientation,
layer=LAYER.M1_DRAW,
)
# Center at (0,0)
c.move((-width / 2, -length / 2))
return c