Best practices for layouts

In this set of tutorials, we will cover several techniques that improve code readability and reusability, as well as highlight several functions provided by the qnngds.utilities module

Use of DeviceSpec and functools.partial

In this tutorial, we will cover an example that illustrates why using the DeviceSpec pattern along with functools.partial is essential for keeping code readable, reusable, and maintainable by comparing two ways to implement the code used in _custom_circuit.

First will be the example circuit without using DeviceSpec or functools.partial. This particular example is a canonical example of the type of problem one runs into when trying to compose many simple devices into a circuit. Now imagine the complexity if you are integrating this subcircuit into another, larger circuit and want to change the arguments between different instances of this subcircuit.

 1import qnngds as qg
 2from qnngds import Device
 3from qnngds.typing import LayerSpec
 4import phidl.geometry as pg
 5from phidl import quickplot as qp
 6
 7
 8def ntron_meander_complicated(
 9    ntron_choke_w: float = 0.03,
10    ntron_gate_w: float = 0.2,
11    ntron_channel_w: float = 0.2,
12    ntron_source_w: float = 0.3,
13    ntron_drain_w: float = 0.3,
14    ntron_choke_shift: float = -0.3,
15    meander_width: float = 0.2,
16    meander_pitch: float = 0.6,
17    meander_size: tuple[int | float | None, int | float | None] = (5, 5),
18    meander_num_squares: int | None = None,
19    meander_turn_ratio: int | float = 4,
20    meander_extend_terminals: bool = True,
21    meander_terminals_same_side: bool = False,
22    tee_size: tuple[float, float] = (4, 2),
23    tee_stub_size: tuple[float, float] = (2, 1),
24    tee_taper_type: str | None = "fillet",
25    tee_taper_radius: float | None = None,
26    num_pts: int = 50,
27    layer: LayerSpec = (1, 0),
28) -> Device:
29    """nTron with meander on drain
30
31    Returns: nTron with connected meander and tee
32    """
33    D = Device("ntron_meander")
34
35    ntron = D << qg.devices.ntron.smooth(
36        choke_w=ntron_choke_w,
37        gate_w=ntron_gate_w,
38        channel_w=ntron_channel_w,
39        source_w=ntron_source_w,
40        drain_w=ntron_drain_w,
41        choke_shift=ntron_choke_shift,
42        num_pts=num_pts,
43        layer=layer,
44    )
45    meander = D << qg.devices.snspd.basic(
46        wire_width=meander_width,
47        wire_pitch=meander_pitch,
48        size=meander_size,
49        num_squares=meander_num_squares,
50        turn_ratio=meander_turn_ratio,
51        num_pts=num_pts,
52        extend_terminals=meander_extend_terminals,
53        terminals_same_side=meander_terminals_same_side,
54        layer=layer,
55    )
56    tee = D << qg.geometries.tee(
57        size=tee_size,
58        stub_size=tee_stub_size,
59        taper_type=tee_taper_type,
60        taper_radius=tee_taper_radius,
61        layer=layer,
62    )
63
64    tee.connect(port=tee.ports[2], destination=ntron.ports["d"])
65    meander.connect(port=meander.ports[1], destination=tee.ports[1])
66
67    D.add_port(name="g", port=ntron.ports["g"])
68    D.add_port(name="s", port=ntron.ports["s"])
69    D.add_port(name="d", port=meander.ports[2])
70    D.add_port(name="o", port=tee.ports[3])
71
72    return D
73
74
75D = ntron_meander_complicated(
76    meander_width=0.3,
77    tee_size=(2, 0.3),
78    tee_stub_size=(0.3, 5),
79    tee_taper_type="fillet",
80    layer=(1, 0),
81)
82qp(D)
../../../_images/best_practicesverbose.png

Now we’ll do the same using DeviceSpec and partial

 1from qnngds.typing import DeviceSpec
 2from functools import partial
 3
 4
 5def ntron_meander(
 6    ntron_spec: DeviceSpec,
 7    meander_spec: DeviceSpec,
 8    output_tee_spec: DeviceSpec,
 9    layer_spec: LayerSpec,
10) -> Device:
11    """nTron with meander on drain
12
13    Args:
14        ntron_spec (DeviceSpec): device or callable function that returns a device for the nTron
15        meander_spec (DeviceSpec): specification for drain meander/inductor
16        output_tee_spec (DeviceSpec): specification for tee that connects output, nTron drain, and inductor
17        layer_spec (LayerSpec): layer to put circuit on
18
19    Returns:
20        (Device): nTron with connected meander and tee
21    """
22    D = Device("ntron_meander")
23
24    ntron = D << qg.get_device(ntron_spec, layer=qg.get_layer(layer_spec))
25    meander = D << qg.get_device(meander_spec, layer=qg.get_layer(layer_spec))
26    tee = D << qg.get_device(tee_spec, layer=qg.get_layer(layer_spec))
27
28    tee.connect(port=tee.ports[2], destination=ntron.ports["d"])
29    meander.connect(port=meander.ports[1], destination=tee.ports[1])
30
31    D.add_port(name="g", port=ntron.ports["g"])
32    D.add_port(name="s", port=ntron.ports["s"])
33    D.add_port(name="d", port=meander.ports[2])
34    D.add_port(name="o", port=tee.ports[3])
35
36    return D
37
38
39ntron_spec = qg.devices.ntron.smooth
40meander_spec = partial(qg.devices.snspd.basic, wire_width=0.3)
41tee_spec = partial(pg.tee, size=(2, 0.3), stub_size=(0.3, 5), taper_type="fillet")
42
43D = ntron_meander(ntron_spec, meander_spec, tee_spec, layer_spec=(1, 0))
44qp(D)
../../../_images/best_practicesconcise.png

This code is a bit more concise, and nicely separates the arguments for the different sub-devices of the circuit: parameters for configuring the meander are passed in to qg.devices.snspd.basic when generating the meander_spec. Not only is the code easier to read, but it’s also much more maintainable and composable. Imagine we would like to tweak the design a bit to use the ntron.sharp geometry. With the ntron_meander_complicated implementation, we would need to write a whole new function or increase the number of arguments even more, or resort to using **kwargs, which makes code difficult to understand and document. Fundamentally the **kwargs pattern has problems when composing many functions due to the possibility of naming conflicts, and in general should be avoided except in situations where the input arguments to a function are unknown (e.g. in a decorator). By using the DeviceSpec pattern, to change the ntron type, we just pass a different DeviceSpec for the ntron_spec argument. For example:

1ntron_spec = qg.devices.ntron.sharp
2D = ntron_meander(ntron_spec, meander_spec, tee_spec, layer_spec=(1, 0))
3qp(D)
../../../_images/best_practicessharp.png

Use of extend_ports

In order to avoid current crowding, optimal tapers should be used when transitioning from a narrow device to a wide routing trace. See Clem, J. & Berggren, K. “Geometry-dependent critical currents in superconducting nanocircuits.” Phys. Rev. B 84, 1-27 (2011). This can be achieved with phidl.geometry.optimal_step. Instead of manually instantiating and connecting these tapers, we can use functools.partial along with the extend_ports utility provided by qnngds to do this in a more concise way:

 1import qnngds as qg
 2import phidl.geometry as pg
 3from phidl import quickplot as qp
 4from functools import partial
 5
 6taper = partial(pg.optimal_step, end_width=1, symmetric=True, layer=(1, 0))
 7snspd = qg.utilities.extend_ports(
 8    device=qg.devices.snspd.vertical(extend=None),
 9    port_names=(1, 2),
10    extension=taper,
11    auto_width=True,
12)
13qp(snspd)
../../../_images/best_practicessnspd.png

the auto_width argument to qg.utilities.extend_ports will ensure that the tapers automatically match the starting width of the SNSPD. This can be useful if the device whose ports you wish to extend has several ports with different widths; you can just specify the desired final width for routing and extend_ports will automatically create the necessary taper geometries.

Lithography test structures

Alignment markers

The alignment marks provided by qnngds.test_structures.alignment_mark and qnngds.test_structures.multilayer_alignment create vernier caliper comb(s) between two layers. This allows one to measure with an optical microscope sub-micron alignment errors. The design is written with photolithography in mind, given the large area. However, they can be adapted to be used to measure alignment between high and low-current e-beam exposure if the structures are appropriately outlined to limit the writing time.

../../../_images/best_practicescalipers.png

Usage:

  1. Start by locating the calipers on the first layer (the central caliper).

  2. Find the position where the calipers on each layer are aligned.

  3. Count the number of positions between the center of the comb and the position at which the calipers are best aligned between the two layers.

  4. Multiply the number from step 3 by the vernier offset (labeled next to the caliper).

  5. For best accuracy, pick a caliper with a vernier offset such that best alignment occurs at > 5 positions away from the center.

  6. Repeat for both vertical and horizontal calipers to determine the misalignment in each direction.

Resolution structures for dose-defocus tests

In order to calibrate the correct dose (and defocus for optical lithography), it’s useful to have a variety of lithographic test structures for analyzing failure modes for smaller features. The qnngds.test_structures.dose_defocus test structure provides a fairly comprehensive set of structures. Depending on the particular structure being fabricated, some structures may be more relevant than others. For example, for long wires, resolution_L and resolution_checkerboard may be most useful, whereas for dense features, resolution_waffle and resolution_checkerboard may be more useful. litho_stars can be helpful as well for non-manhattan oriented features, as well as for wires joining at a shallow angle.

../../../_images/best_practicesdosedefoc.png

Device arrays

phidl provides several methods for arraying devices. However, in some cases, one wishes to create a grid of identical devices. phidl.geometry.grid can achieve this, but it does not store/represent the array in the most efficient way. Furthermore, you lose access to the ports on the device after performing this operation.

qnngds provides a special method for qnngds.Device instances, qnngds.layout.Device.add_array(). Lets say we want to make a linear array of identical SNSPDs and route them:

1import qnngds as qg
2from phidl import quickplot as qp
3from functools import partial
4
5snspd = qg.devices.snspd.vertical()
6qp(snspd)
../../../_images/best_practicessnspdcell.png
1from qnngds import Device
2
3D = Device("SNSPD array")
4arr = D.add_array(
5    device=snspd,
6    columns=5,
7    rows=1,
8    spacing=(10, 0),
9)

This leverages the gdspy primitve CellArray, which is an efficient way to store the layout Furthermore, we can access the ports like so:

1for n, port_dict in enumerate(arr.ports[0, :]):
2    for port_name, port in port_dict.items():
3        D.add_port(name=f"{n}{port_name}", port=port)

Now, if we plot D, we can see the new ports we’ve added to it from the CellArray.

1qp(D)
../../../_images/best_practicessnspdarray.png
1arr.ports[0, 0]