Source code for qnngds.geometries

"""Geometries contains useful shapes/tools that are not available in phidl's
geometry library."""

# can be removed in python 3.14, see https://peps.python.org/pep-0749/
from __future__ import annotations


import numpy as np
import qnngds as qg
from qnngds import Device

from qnngds.typing import LayerSpec

from qnngds import CrossSection
import phidl.path as pp
import phidl.geometry as pg


[docs]@qg.device def taper( length: int | float = 10, start_width: int | float = 5, end_width: int | float = 2, layer: LayerSpec = (1, 0), ) -> Device: """Linear taper (solid). Args: length (int or float): Length of taper start_width (int or float): Width of first end of taper end_width (int or float): Width of second end of taper layer (LayerSpec): GDS layer specification Returns: (Device): a single taper """ T = Device("taper") pts = [ (0, -start_width / 2), (length, -end_width / 2), (length, end_width / 2), (0, start_width / 2), ] T.add_polygon(pts, layer=qg.get_layer(layer)) T.add_port( name=1, midpoint=[0, 0], width=start_width, orientation=180, layer=layer, ) T.add_port( name=2, midpoint=[length, 0], width=end_width, orientation=0, layer=layer, ) return T
[docs]@qg.device def ramp( length: int | float = 10, start_width: int | float = 5, end_width: int | float = 2, layer: LayerSpec = (1, 0), ) -> Device: """Linear ramp (solid). Asymmetric version of taper. Args: length (int or float): Length of ramp start_width (int or float): Width of first end of ramp end_width (int or float): Width of second end of ramp layer (LayerSpec): GDS layer specification Returns: (Device): a single taper """ T = Device("taper") pts = [ (0, 0), (length, 0), (length, end_width), (0, start_width), ] T.add_polygon(pts, layer=qg.get_layer(layer)) T.add_port( name=1, midpoint=[0, start_width / 2], width=start_width, orientation=180, layer=layer, ) T.add_port( name=2, midpoint=[length, end_width / 2], width=end_width, orientation=0, layer=layer, ) return T
[docs]@qg.device def hyper_taper( length: int | float = 10, start_width: int | float = 5, end_width: int | float = 50, layer: LayerSpec = (1, 0), num_points: int = 50, ) -> Device: """Hyperbolic taper (solid). Designed by colang. Args: length (int or float): Length of taper start_width (int or float): Width of start of taper end_width (int or float): Width of end of taper layer (LayerSpec): GDS layer specification num_points (int): number of points to use Returns: (Device): a single taper """ path = pp.straight(length=length, num_pts=num_points) xc = CrossSection() xc.add( width=lambda t: qg.utilities.hyper_taper_fn(t, start_width, end_width), offset=0, layer=qg.get_layer(layer), ports=(1, 2), ) taper = path.extrude(xc) qg.utilities.create_layered_ports(taper, layer) return taper
[docs]@qg.device def euler_taper( start_width: int | float = 5, end_width: int | float = 50, layer: LayerSpec = (1, 0), num_points: int = 200, ) -> Device: """Euler taper (solid). Designed by reedf. Args: length (int | float): Length of taper start_width (int | float): Width of start of taper end_width (int | float): Width of end of taper layer (LayerSpec): GDS layer specification num_points (int): number of points to use Returns: (Device): a single taper """ swapped = False if start_width > end_width: start_width, end_width = end_width, start_width swapped = True P_euler = pp.euler( radius=end_width / 2 - start_width / 2, angle=90, use_eff=True, p=1, num_pts=num_points, ) D = Device("euler_taper") upper = np.array([(x, y + start_width / 2) for x, y in P_euler.points]) lower = np.array([(x, -y - start_width / 2) for x, y in P_euler.points[::-1]]) length = np.max(P_euler.points[:, 1]) # create a polygon points = np.concatenate((upper, lower)) points = np.array([(length - x if swapped else x, y) for x, y in points]) D.add_polygon(points, layer=qg.get_layer(layer)) if swapped: start_width, end_width = end_width, start_width # port 1: narrow/start_width end, port 2: wide/end_width end D.add_port( name=1, midpoint=(0, 0), width=start_width, orientation=180, layer=layer, ) D.add_port( name=2, midpoint=(length, 0), width=end_width, orientation=0, layer=layer, ) return D
[docs]@qg.device def angled_taper( end_width: int | float = 0.2, start_width: int | float = 0.1, angle: int | float = 60, layer: LayerSpec = (1, 0), ) -> Device: """Create an angled taper with euler curves. Args: end_width (int or float): Width of wide end of taper start_width (int or float): Width of narrow end of taper angle (int or float): Angle between taper ends in degrees layer (LayerSpec): GDS layer specification Returns: (Device): a single taper """ if start_width > end_width: raise ValueError("{start_width=} > {end_width=} is not yet implemented") D = Device("angled_taper") # heuristic for length between narrow end and bend l_constr = start_width * 2 + end_width * 2 # heuristic for length between wide end and bend l_wire = start_width * 2 + end_width * 2 sin = np.sin(angle * np.pi / 180) cos = np.cos(angle * np.pi / 180) # path along the midpoint of the taper p_midpoint = np.array( [[0, 0], [l_constr, 0], [l_constr + l_wire * cos, l_wire * sin]] ) # upper (shorter) path along the inside edge of the taper p_upper = np.array( [ [0, start_width / 2], [0, start_width / 2], p_midpoint[2] + [-end_width / 2 * sin, end_width / 2 * cos], ] ) p_upper[1, 0] = (start_width / 2 - p_upper[2, 1]) * cos / sin + p_upper[2, 0] # lower (longer) path along the outside edge of the taper p_lower = np.array( [ [0, -start_width / 2], [0, -start_width / 2], p_midpoint[2] + [end_width / 2 * sin, -end_width / 2 * cos], ] ) p_lower[1, 0] = (-start_width / 2 - p_lower[2, 1]) * cos / sin + p_lower[2, 0] # interpolate euler curve between points P_upper = pp.smooth( points=p_upper, radius=end_width, corner_fun=pp.euler, use_eff=False ) P_lower = pp.smooth( points=p_lower, radius=end_width, corner_fun=pp.euler, use_eff=False ) # create a polygon points = np.concatenate((P_upper.points, P_lower.points[::-1])) D.add_polygon(points, layer=qg.get_layer(layer)) # port 1: narrow/start_width end, port 2: wide/end_width end D.add_port( name=1, midpoint=(P_upper.points[0] + P_lower.points[0]) / 2, width=start_width, orientation=180, layer=layer, ) D.add_port( name=2, midpoint=(P_upper.points[-1] + P_lower.points[-1]) / 2, width=end_width, orientation=angle, layer=layer, ) return D
[docs]@qg.device def tee( size: tuple[float, float] = (4, 2), stub_size: tuple[float, float] = (2, 1), taper_type: str | None = "fillet", taper_radius: float | None = None, layer: LayerSpec = (1, 0), ) -> Device: """Creates a T-shaped geometry. Adapted from phidl and adds additional argument to specify the radius of fillet tapers. Args: size (array-like): (width, height) of the tee. stub_size : (array-like): (width, height) of the stub. taper_type (str | None) : {'straight', 'fillet', None} Type of taper between the bottom corner of the stub on the side of the flag and the corner of the flag closest to the stub. taper_radius (float | None) : radius of taper. If None, uses stub_size layer (LayerSpec): Specification of layer(s) to put polygon geometry on. Returns: (Device): tee """ f = np.array(size).astype(np.float64) p = np.array(stub_size).astype(np.float64) assert taper_type in [ "straight", "fillet", None, ], 'tee() taper_type must "straight" or "fillet" or None' xpts = np.array([f[0], f[0], p[0], p[0], -p[0], -p[0], -f[0], -f[0]]) / 2 ypts = [f[1], 0, 0, -p[1], -p[1], 0, 0, f[1]] D = Device("tee") tee = D.add_polygon([xpts, ypts], layer=qg.get_layer(layer)) if taper_type == "fillet": if taper_radius is None: taper_radius = min([abs(f[0] - p[0]), abs(p[1])]) tee.fillet([0, 0, taper_radius, 0, 0, taper_radius, 0, 0]) elif taper_type == "straight": D.add_polygon([xpts[1:4], ypts[1:4]], layer=qg.get_layer(layer)) D.add_polygon([xpts[4:7], ypts[4:7]], layer=qg.get_layer(layer)) D.add_port( name=1, midpoint=(f[0] / 2, f[1] / 2), width=abs(f[1]), orientation=0, layer=layer, ) D.add_port( name=2, midpoint=(-f[0] / 2, f[1] / 2), width=abs(f[1]), orientation=180, layer=layer, ) D.add_port( name=3, midpoint=(0, -p[1]), width=abs(p[0]), orientation=270, layer=layer, ) return D
[docs]@qg.device def fillet_90deg( size: tuple[float, float] = (4, 2), stub_size: tuple[float, float] = (2, 1), taper_type: str | None = "fillet", taper_radius: float | None = None, layer: LayerSpec = (1, 0), ) -> Device: """Creates a 90 degree with fillet bend. Adapted from phidl.flagpole Args: size (array-like): (width, height) of the flag. stub_size : (array-like): (width, height) of the pole stub. taper_type (str | None) : {'straight', 'fillet', None} Type of taper between the bottom corner of the stub on the side of the flag and the corner of the flag closest to the stub. taper_radius (float | None) : radius of taper. If None, uses stub_size layer (LayerSpec): Specification of layer(s) to put polygon geometry on. Returns: (Device): fillet_90deg """ f = np.array(size).astype(np.float64) p = np.array(stub_size).astype(np.float64) assert taper_type in [ "straight", "fillet", None, ], 'fillet_90deg() taper_type must "straight" or "fillet" or None' xpts = [0, 0, f[0], f[0], p[0], p[0]] ypts = [-p[1], f[1], f[1], 0, 0, -p[1]] D = Device("tee") tee = D.add_polygon([xpts, ypts], layer=qg.get_layer(layer)) if taper_type == "fillet": if taper_radius is None: taper_radius = min([abs(f[0] - p[0]), abs(p[1])]) tee.fillet([0, 0, 0, 0, taper_radius, 0]) elif taper_type == "straight": D.add_polygon([xpts[3:6], ypts[3:6]], layer=qg.get_layer(layer)) D.add_port( name=1, midpoint=(f[0], f[1] / 2), width=abs(f[1]), orientation=0, layer=layer, ) D.add_port( name=2, midpoint=(p[0] / 2, -p[1]), width=abs(p[0]), orientation=270, layer=layer, ) return D
[docs]@qg.device def via( size: tuple[float, float] = (5, 5), via_undersize: float = 0.5, layer_bottom: LayerSpec = (1, 0), layer_via: LayerSpec = (10, 0), layer_top: LayerSpec = (20, 0), ) -> Device: """Creates a via between two layers Args: size (tuple[float, float]): width, height of top/bottom pads via_undersize (float): amount on each side to compensate overetch of via layer_bottom (LayerSpec): bottom layer specification layer_via (LayerSpec): via layer specification layer_top (LayerSpec): top layer specification Returns: (Device): via """ VIA = Device("via") if 2 * via_undersize > min(size[0], size[1]): raise ValueError(f"{via_undersize=} is too small for a pad with {size=}.") bot_pad = VIA << pg.compass(size=size, layer=qg.get_layer(layer_bottom)) qg.utilities.create_layered_ports(bot_pad, layer_bottom) via = VIA << pg.compass( size=(size[0] - 2 * via_undersize, size[1] - 2 * via_undersize), layer=qg.get_layer(layer_via), ) top_pad = VIA << pg.compass(size=size, layer=qg.get_layer(layer_top)) qg.utilities.create_layered_ports(top_pad, layer_top) bot_pad.move(bot_pad.center, (0, 0)) via.move(via.center, (0, 0)) top_pad.move(top_pad.center, (0, 0)) for n, comp in enumerate([top_pad, bot_pad]): for k, port in comp.ports.items(): VIA.add_port(name=f"{n + 1}{k}", port=port) return VIA
[docs]@qg.device def optimal_hairpin( width: float | int = 0.2, pitch: float | int = 0.6, length: float | int = 10, turn_ratio: float | int = 4, num_pts: float | int = 50, layer: LayerSpec = (1, 0), ) -> qg.Device: """Returns an optimally-rounded hairpin geometry, with a 180 degree turn. based on phidl.geometry. Used instead of phidl implementation to center the apex of the hairpin at (0, 0). Args: width: Width of the hairpin leads. pitch: Distance between the two hairpin leads. Must be greater than width. length: Length of the hairpin from the connectors to the opposite end of the curve. turn_ratio: int or float Specifies how much of the hairpin is dedicated to the 180 degree turn. A turn_ratio of 10 will result in 20% of the hairpin being comprised of the turn. num_pts: Number of points constituting the 180 degree turn. layer: Specific layer(s) to put polygon geometry on. Notes: Hairpin pitch must be greater than width. Optimal structure from https://doi.org/10.1103/PhysRevB.84.174510 Clem, J., & Berggren, K. (2011). Geometry-dependent critical currents in superconducting nanocircuits. Physical Review B, 84(17), 1-27. """ # ========================================================================== # Create the basic geometry # ========================================================================== if pitch < width: raise Warning("Hairpin pitch must be greater than width") a = (pitch + width) / 2 y = -(pitch - width) / 2 x = -pitch dl = width / (num_pts * 2) n = 0 # Get points of ideal curve from conformal mapping # TODO This is an inefficient way of finding points that you need xpts = [x] ypts = [y] while (y < 0) & (n < 1e6): s = x + 1j * y w = np.sqrt(1 - np.exp(np.pi * s / a)) wx = np.real(w) wy = np.imag(w) wx = wx / np.sqrt(wx**2 + wy**2) wy = wy / np.sqrt(wx**2 + wy**2) x = x + wx * dl y = y + wy * dl xpts.append(x) ypts.append(y) n += 1 ypts[-1] = 0 # Set last point be on the x=0 axis for sake of cleanliness ds_factor = len(xpts) // num_pts xpts = xpts[::-ds_factor] xpts = xpts[::-1] # This looks confusing, but it's just flipping the arrays around ypts = ypts[::-ds_factor] ypts = ypts[::-1] # so the last point is guaranteed to be included when downsampled apex = (xpts[-1], 0) # Add points for the rest of meander xpts.append(xpts[-1] + turn_ratio * width) ypts.append(0) xpts.append(xpts[-1]) ypts.append(-a) xpts.append(xpts[0]) ypts.append(-a) xpts.append(max(xpts) - length) ypts.append(-a) xpts.append(xpts[-1]) ypts.append(-a + width) xpts.append(xpts[0]) ypts.append(ypts[0]) xpts = np.array(xpts) ypts = np.array(ypts) # ========================================================================== # Create a blank device, add the geometry, and define the ports # ========================================================================== HP = qg.Device("optimal_hairpin") HP.add_polygon([xpts, +ypts], layer=qg.get_layer(layer)) HP.add_polygon([xpts, -ypts], layer=qg.get_layer(layer)) xports = float(np.min(xpts)) yports = -a + width / 2 HP.add_port( name=1, midpoint=(xports, -yports), width=width, orientation=180, layer=layer, ) HP.add_port( name=2, midpoint=(xports, yports), width=width, orientation=180, layer=layer, ) HP.move(apex, (0, 0)) return HP
[docs]@qg.device def default_cross_section( width: float = 25, layer: LayerSpec = (1, 0), radius: float = 30.0, force_no_outline: bool = False, ) -> CrossSection: """Return a default cross_section. Args: width (float): width of cross section layer (LayerSpec): layer specification for cross section radius (float): bend radius force_no_outline (bool): if True, ignores if layer is positive tone. Returns: (CrossSection) """ outline = qg.get_layer(layer).outline XC = CrossSection(radius=radius) if (outline > 0) and not (force_no_outline): # if outline is greater than zero, then do a positive tone cross section # with the center of the cross section missing (hidden=True) XC.add( width=width, offset=0, layer=qg.get_layer(layer), hidden=True, ports=(1, 2), ) for i in range(2): XC.add( width=outline, layer=qg.get_layer(layer), offset=(-1) ** i * (width + outline) / 2, ) else: # just do a normal cross section XC.add( width=width, offset=0, layer=qg.get_layer(layer), ports=(1, 2), ) return XC
[docs]@qg.device def fine_to_coarse( width1: float = 2.0, width2: float = 20.0, layer1: LayerSpec = "EBEAM_FINE", layer2: LayerSpec = "EBEAM_COARSE", ) -> Device: """Create transition between fine and coarse layers. Automatically performs outlining for positive-tone resist. Args: width1 (float): starting width on first layer width2 (float): ending width on second layer layer1 (LayerSpec): layer specification for first layer layer2 (LayerSpec): layer specification for second layer Returns: (Device): transition between fine and coarse layers """ taper = Device() outline_layers = qg.utilities.get_outline_layers(qg.get_active_pdk().layers) pos_tone = False for layer in outline_layers.keys(): if (qg.get_layer(layer) == qg.get_layer(layer1)) or ( qg.get_layer(layer) == qg.get_layer(layer2) ): pos_tone = True break if pos_tone: outline = outline_layers[qg.get_layer(layer2).name] # positive tone t2 = qg.utilities.get_cross_section_with_layer(layer2).extrude( pp.straight(length=2 * outline) ) wide = 2 * outline + width2 if wide < width1: wide = width2 wide += 2 * outline t1 = qg.geometries.hyper_taper( length=wide * 0.8, start_width=wide, end_width=width1, layer=layer1, ) t1 = qg.utilities.outline(t1, outline_layers) t2_i = taper.add_ref(t2) t1_i = taper.add_ref(t1) t1_i.connect( port=t1_i.ports[1], destination=t2_i.ports[2], ) t1_i.movex(-outline_layers[qg.get_layer(layer2).name] * 2) ports = [t1_i.ports[2], t2_i.ports[1]] else: t2 = pg.optimal_step( start_width=0.7 * width1, end_width=width2, num_pts=500, anticrowding_factor=0.6, symmetric=True, layer=qg.get_layer(layer2), ) t1 = qg.geometries.hyper_taper( length=t2.xsize * 0.7, start_width=(width2 + width1) / 2, end_width=width1, layer=layer1, ) t2_i = taper.add_ref(t2) t1_i = taper.add_ref(t1) t1_i.connect( port=t1_i.ports[1], destination=t2_i.ports[1], ) t1_i.movex(0.8 * t1_i.xsize) ports = [t1_i.ports[2], t2_i.ports[2]] for n, port in enumerate(ports): taper.add_port( name=n + 1, midpoint=port.midpoint, orientation=port.orientation, width=port.width, layer=layer1 if n == 0 else layer2, ) return taper