"""Utilities for modifying/combining devices into more complex devices or constructing experiments."""
# can be removed in python 3.14, see https://peps.python.org/pep-0749/
from __future__ import annotations
from collections.abc import Sequence
from functools import partial
import numpy as np
from qnngds.typing import LayerSpec, LayerSpecs, DeviceSpec, CrossSectionSpec
from qnngds import Device, LayerSet, Port
import qnngds as qg
import phidl.geometry as pg
[docs]def extend_ports(
device: Device,
port_names: Sequence[int | str],
extension: DeviceSpec,
auto_width: bool = False,
new_ports: bool = True,
ext_swap_ports: bool = False,
ext_mirror: tuple[tuple[float, float], tuple[float, float]] | None = None,
) -> Device:
"""Adds the DeviceSpec extension to the named ports of Device device
Parameters:
device (Device): device to add extensions to
port_names (Sequence[int | str]): names of ports on device which should be extended
extension (DeviceSpec): specification for extension
auto_width (bool): if True, uses the kwarg `start_width` when instantiating the `extension`
`DeviceSpec` to generate the tapers. Determines the `start_width` automatically from
`device`.
new_ports (bool): if True, create new ports, using port `2` from `Device` specified by `extension`.
Also passes any non-extended ports through to the new device that is returned.
ext_swap_ports (bool): if True, connects port `2` of the extension to the device instead of port `1`.
ext_mirror (tuple[tuple[float, float], tuple[float,float]] | None): if not None, mirror the extension
along the vector ext_mirror.
Returns:
(Device): the original device with ports extended
"""
dev_extended = Device()
dev_i = dev_extended << device
ext_ports = [1, 2]
if ext_swap_ports:
ext_ports.reverse()
if not new_ports:
ext_ports.pop()
def check_ext_ports(ext: Device):
"""Check extension ports for keys 1 and optionally 2"""
for p in ext_ports:
if p not in ext.ports:
raise ValueError(
f"port '{p}' not found in extension.ports: {ext.ports.keys()}"
)
if not auto_width:
# all widths are the same
ext = qg.get_device(extension)
check_ext_ports(ext)
for port_name in port_names:
if auto_width:
# determine width from dev
ext = qg.get_device(
partial(extension, start_width=dev_i.ports[port_name].width)
)
check_ext_ports(ext)
ext_i = dev_extended << ext
if ext_mirror is not None:
ext_i.mirror(ext_mirror[0], ext_mirror[1])
ext_i.connect(
port=ext_i.ports[ext_ports[0]], destination=dev_i.ports[port_name]
)
if new_ports and (ext_ports[1] in ext_i.ports):
dev_extended.add_port(
port=ext_i.ports[ext_ports[1]],
name=port_name,
layer=dev_i.ports[port_name].layer,
)
if new_ports:
for _, port in dev_i.ports.items():
if port.name in port_names:
continue
dev_extended.add_port(
port=port,
name=port.name,
layer=port.layer,
)
dev_extended.name = "ext_port_" + device.name
return dev_extended
[docs]def create_layered_ports(device: Device, layer: LayerSpec):
"""Regenerates new ports for device, assigning them all to a layer
Parameters:
device (Device): device to modify
layer (LayerSpec): GDS layer specification
"""
for name, port in device.ports.items():
device.ports[name] = qg.Port(
name=name,
midpoint=port.midpoint,
width=port.width,
orientation=port.orientation,
layer=layer,
parent=port.parent,
)
[docs]def hyper_taper_fn(t: float, start_width: int | float, end_width: int | float) -> float:
"""Used for defining custom cross section widths/offsets
Args:
t (float): value on [0,1] mapping to position along length of taper
start_width (float): starting width (t=0)
end_width (float): ending width (t=1)
Returns:
(float): hyper taper function evaluated at t.
"""
if start_width > end_width:
a = np.arccosh(start_width / end_width)
return np.cosh(a * (1 - t)) * end_width
else:
a = np.arccosh(end_width / start_width)
return np.cosh(a * t) * start_width
[docs]def get_outline_layers(layer_set: LayerSet) -> dict[str, float]:
"""Get dictionary maping each layer in a LayerSet to its desired outline amount
Args:
layer_set (LayerSet): LayerSet
Returns:
(dict[str, float]): mapping of GDS layer name to outline distance. Layers that aren't outlined are omitted.
"""
# outline
outline_layers = {}
for layer in layer_set:
layer = layer_set[layer]
ol = layer.outline
if ol > 0:
outline_layers[layer.name] = ol
return outline_layers
[docs]def get_keepout_layers(layer_set: LayerSet) -> dict[str, str]:
"""Get dictionary maping a layer to a second layer which it should serve as a keepout for
Args:
layer_set (LayerSet): LayerSet
Returns:
(dict[str, str]): mapping of GDS layer name to GDS layer name.
"""
# keepout
keepout_layers = {}
for layer in layer_set:
layer = layer_set[layer]
keepout = layer.keepout
if keepout is not None:
keepout_layers[layer.name] = keepout
return keepout_layers
[docs]def outline(
device: Device,
outline_layers: dict[str, float] | None = None,
kl_tile_size: int | None = None,
kl_precision: float = 1e-4,
) -> Device:
"""Outline polygons within device by layer.
Args:
device (Device): device to outline
outline_layers (dict[str, float]): map of desired outline amount per layer. If a layer is omitted, it will not be outlined
kl_tile_size (int | None): if not None, size of tile to divide geometry into for multithreaded execution
kl_precision (int | None): precision for KLayout operation (equivalently, sets dbu for KLayout)
Returns:
(Device): the outlined device
"""
tile_size = None if kl_tile_size is None else (kl_tile_size, kl_tile_size)
dev_outlined = Device()
# extend ports
dev_extended = Device()
dev = dev_extended.add_ref(device)
if outline_layers is None:
outline_layers = {}
for k, v in outline_layers.items():
if v <= 0:
raise ValueError(f"outline must be greater than zero, got {outline_layers}")
outline_layers = {qg.get_layer(k).tuple: v for k, v in outline_layers.items()}
new_ports = []
processed_ports = []
for layer in outline_layers.keys():
for port in dev.ports:
port = dev.ports[port]
if qg.get_layer(port.layer).tuple != layer:
continue
ext = dev_extended.add_ref(
pg.straight(
size=(port.width, outline_layers[layer] + 2e-4),
layer=qg.get_layer(port.layer).tuple,
)
)
create_layered_ports(ext, layer)
ext.connect(port=ext.ports[1], destination=port)
# translate slightly to make sure there's no gap
ext.move(1e-4 * np.diff(ext.ports[1].normal, axis=0)[0])
p = ext.ports[2]
p.name = port.name
new_ports.append(p)
processed_ports.append(port)
new_ports += [
dev.ports[p] for p in dev.ports if dev.ports[p] not in processed_ports
]
polygons = device.get_polygons(by_spec=True)
extended_polygons = dev_extended.get_polygons(by_spec=True)
for layer, poly in polygons.items():
layer = qg.get_layer(layer).tuple
if layer not in outline_layers:
dev_outlined.add_polygon(poly, layer=layer)
else:
dummy = Device()
dummy.add_polygon(poly, layer=layer)
bloated = pg.kl_offset(
dummy,
distance=outline_layers[layer],
precision=kl_precision,
tile_size=tile_size,
layer=layer,
)
dummy = Device()
dummy.add_polygon(extended_polygons[layer], layer=layer)
outlined = pg.kl_boolean(
A=bloated,
B=dummy,
operation="A-B",
precision=kl_precision,
tile_size=tile_size,
layer=layer,
)
dev_outlined << outlined
# add ports
dev_outlined.flatten()
for port in new_ports:
port.midpoint = np.array(port.midpoint) - 1e-4 * np.diff(port.normal, axis=0)[0]
dev_outlined.add_port(port=port)
dev_outlined.name = "ol_" + device.name
return dev_outlined
[docs]def invert(
device: Device,
ext_bbox_distance: dict[LayerSpec, float] = {},
kl_tile_size: int | None = None,
kl_precision: float = 1e-4,
) -> Device:
"""Outline polygons within device by layer.
Args:
device (Device): device to invert
ext_bbox_distance (dict[LayerSpec, float]): amount to expand bounding box for each layer. If a layer is omitted, it will not be inverted.
kl_tile_size (int | None): if not None, size of tile to divide geometry into for multithreaded execution
kl_precision (int | None): precision for KLayout operation (equivalently, sets dbu for KLayout)
Returns:
(Device): the inverted device
"""
tile_size = None if kl_tile_size is None else (kl_tile_size, kl_tile_size)
dev_inverted = Device()
ext_bbox_distance = {qg.get_layer(k).tuple: v for k, v in ext_bbox_distance.items()}
polygons = device.get_polygons(by_spec=True)
for layer, poly in polygons.items():
layer = qg.get_layer(layer).tuple
if layer not in ext_bbox_distance:
dev_inverted.add_polygon(poly, layer=layer)
else:
dummy = Device()
dummy.add_polygon(poly, layer=layer)
bbox = dummy.bbox
ext = ext_bbox_distance[layer]
bbox[0] -= [ext, ext]
bbox[1] += [ext, ext]
bloated = pg.bbox(bbox, layer=layer)
dummy = Device()
dummy.add_polygon(polygons[layer], layer=layer)
inverted = pg.kl_boolean(
A=bloated,
B=dummy,
operation="A-B",
precision=kl_precision,
tile_size=tile_size,
layer=layer,
)
dev_inverted << inverted
dev_inverted.flatten()
dev_inverted.name = "inv_" + device.name
return dev_inverted
[docs]def keepout(
device: Device,
outline_layers: dict[LayerSpec, float] | None = None,
keepout_layers: dict[LayerSpec, LayerSpecs] | None = None,
kl_tile_size: int | None = None,
kl_precision: float = 1e-4,
) -> Device:
"""Apply keepout layers
Args:
device (Device): device to outline
outline_layers (dict[LayerSpec, float]): map of desired outline amount per layer.
If a layer is omitted, it will not be outlined
keepout_layers (dict[LayerSpec, LayerSpecs]): map of desired layer(s) to keepout. If a keepout
layer applies to a positive-tone layer (i.e. layer in outline_layers with non-zero outline),
then the keepout regions will be unioned. If keepout layer applies to negative-tone
(i.e. layer not in outline_layers), then the keepout region will be subtracted.
kl_tile_size (int | None): if not None, size of tile to divide geometry into for multithreaded execution
kl_precision (int | None): precision for KLayout operation (equivalently, sets dbu for KLayout)
Returns:
(Device): device with keepout applied
"""
tile_size = None if kl_tile_size is None else (kl_tile_size, kl_tile_size)
dev_keepout = Device()
if keepout_layers is None:
return device
if outline_layers is None:
outline_layers = {}
outline_layers = {qg.get_layer(k).tuple: v for k, v in outline_layers.items()}
keepout_layers = {qg.get_layer(k).tuple: v for k, v in keepout_layers.items()}
processed_layers = set([])
polygons = device.get_polygons(by_spec=True)
for keepout_layer, mapped_layers in keepout_layers.items():
if keepout_layer not in polygons:
continue
keepout_poly = polygons[keepout_layer]
for mapped_layer in mapped_layers:
mapped_layer = qg.get_layer(mapped_layer).tuple
neg_tone = mapped_layer not in outline_layers
d_keepout = Device()
d_keepout.add_polygon(keepout_poly, layer=mapped_layer)
if mapped_layer not in polygons:
if neg_tone:
continue
else:
# pos-tone, add/union
# since mapped_layer not in polygons,
# union mapped+keepout is just keepout
dev_keepout << d_keepout
processed_layers.add(mapped_layer)
continue
mapped_poly = polygons[mapped_layer]
d_mapped = Device()
d_mapped.add_polygon(mapped_poly, layer=mapped_layer)
if neg_tone:
# neg-tone, subtract
dev_keepout << pg.kl_boolean(
A=d_mapped,
B=d_keepout,
operation="A-B",
precision=kl_precision,
tile_size=tile_size,
layer=mapped_layer,
)
else:
# pos-tone, add/union
dev_keepout << pg.kl_boolean(
A=d_mapped,
B=d_keepout,
operation="A+B",
precision=kl_precision,
tile_size=tile_size,
layer=mapped_layer,
)
processed_layers.add(mapped_layer)
# add remaining layers
for layer in device.layers:
layer = qg.get_layer(layer).tuple
if layer in processed_layers:
continue
if layer in keepout_layers:
continue
dev_keepout.add_polygon(polygons[layer], layer=layer)
# add ports
dev_keepout.flatten()
dev_keepout.add_ports(device.ports)
return dev_keepout
[docs]def get_cross_section_with_layer(
layer: LayerSpec = "PHOTO1", default: CrossSectionSpec | None = None
) -> CrossSectionSpec | None:
"""Find the cross section associated with the given layer, or default
Args:
layer (LayerSpec): layer specification to find cross section for
default (CrossSectionSpec | None): default return value if cross section
is not found
Returns:
(CrossSectionSpec | None): found cross section or default
"""
for xc in qg.get_active_pdk().cross_sections:
xc = qg.get_cross_section(xc)
if qg.get_layer(xc.sections[0]["layer"]) == qg.get_layer(layer):
return xc
[docs]def get_device_port_direction(component: Device) -> dict[str, Sequence[Port]]:
"""Returns ports of a component organized by direction.
Args:
component (Device): component to get ports from
Returns:
(dict[str, Ports]): list of ports for each direction
"""
ports = {x: [] for x in ["E", "N", "W", "S"]}
# group by direction
for p in component.ports.values():
ports[_get_port_direction(p)].append(p)
return ports
def _get_port_direction(port: Port, warn_not_90: bool = False) -> str:
"""Gets string port direction ("N", "S", "E" or "W") of a port
Args:
port (Port): port
warn_not_90 (bool): warn if orientation is not multiple of 90 deg. default False
Returns:
(str): string of port orientation
"""
angle = port.orientation % 360
if (angle % 90 != 0) and warn_not_90:
raise Warning("non-manhattan port orientation detected")
if angle <= 45 or angle >= 315:
return "E"
elif angle <= 135 and angle >= 45:
return "N"
elif angle <= 225 and angle >= 135:
return "W"
else:
return "S"