From fd2698c5035a04cbe2bbe170d6c2d19bb96d4bcc Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Thu, 2 Apr 2026 12:19:51 -0700 Subject: [PATCH] [docs / examples] Update docs and examples --- examples/tutorial/README.md | 16 +++++- examples/tutorial/devices.py | 96 +++++++++++++++++--------------- examples/tutorial/library.py | 49 +++++++++------- examples/tutorial/pather.py | 2 +- examples/tutorial/port_pather.py | 68 +++++++++++----------- masque/builder/tools.py | 4 +- masque/builder/utils.py | 4 +- 7 files changed, 132 insertions(+), 107 deletions(-) diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index 6e5730b..ea4471f 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -1,6 +1,12 @@ masque Tutorial =============== +These examples are meant to be read roughly in order. + +- Start with `basic_shapes.py` for the core `Pattern` / GDS concepts. +- Then read `devices.py` and `library.py` for hierarchical composition and libraries. +- Read the `pather*` tutorials separately when you want routing helpers. + Contents -------- @@ -8,11 +14,13 @@ Contents * Draw basic geometry * Export to GDS - [devices](devices.py) + * Build hierarchical photonic-crystal example devices * Reference other patterns * Add ports to a pattern - * Snap ports together to build a circuit + * Use `Builder` to snap ports together into a circuit * Check for dangling references - [library](library.py) + * Continue from `devices.py` using a lazy library * Create a `LazyLibrary`, which loads / generates patterns only when they are first used * Explore alternate ways of specifying a pattern for `.plug()` and `.place()` * Design a pattern which is meant to plug into an existing pattern (via `.interface()`) @@ -28,7 +36,8 @@ Contents * Advanced port manipulation and connections -Additionaly, [pcgen](pcgen.py) is a utility module for generating photonic crystal lattices. +Additionally, [pcgen](pcgen.py) is a utility module used by `devices.py` for generating +photonic-crystal lattices; it is support code rather than a step-by-step tutorial. Running @@ -40,3 +49,6 @@ cd examples/tutorial python3 basic_shapes.py klayout -e basic_shapes.gds ``` + +Some tutorials depend on outputs from earlier ones. In particular, `library.py` +expects `circuit.gds`, which is generated by `devices.py`. diff --git a/examples/tutorial/devices.py b/examples/tutorial/devices.py index 79d318a..d6beb2a 100644 --- a/examples/tutorial/devices.py +++ b/examples/tutorial/devices.py @@ -1,3 +1,11 @@ +""" +Tutorial: building hierarchical devices with `Pattern`, `Port`, and `Builder`. + +This file uses photonic-crystal components as the concrete example, so some of +the geometry-generation code is domain-specific. The tutorial value is in the +Masque patterns around it: creating reusable cells, annotating ports, composing +hierarchy with references, and snapping ports together to build a larger circuit. +""" from collections.abc import Sequence, Mapping import numpy @@ -64,9 +72,9 @@ def perturbed_l3( Provided sequence should have same length as `shifts_a`. xy_size: `(x, y)` number of mirror periods in each direction; total size is `2 * n + 1` holes in each direction. Default (10, 10). - perturbed_radius: radius of holes perturbed to form an upwards-driected beam + perturbed_radius: radius of holes perturbed to form an upwards-directed beam (multiplicative factor). Default 1.1. - trench width: Width of the undercut trenches. Default 1200. + trench_width: Width of the undercut trenches. Default 1200. Returns: `Pattern` object representing the L3 design. @@ -79,14 +87,15 @@ def perturbed_l3( shifts_a=shifts_a, shifts_r=shifts_r) - # Build L3 cavity, using references to the provided hole pattern + # Build the cavity by instancing the supplied `hole` pattern many times. + # Using references keeps the pattern compact even though it contains many holes. pat = Pattern() pat.refs[hole] += [ Ref(scale=r, offset=(lattice_constant * x, lattice_constant * y)) for x, y, r in xyr] - # Add rectangular undercut aids + # Add rectangular undercut aids based on the referenced hole extents. min_xy, max_xy = pat.get_bounds_nonempty(hole_lib) trench_dx = max_xy[0] - min_xy[0] @@ -95,7 +104,7 @@ def perturbed_l3( Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width), ] - # Ports are at outer extents of the device (with y=0) + # Define the interface in Masque terms: two ports at the left/right extents. extent = lattice_constant * xy_size[0] pat.ports = dict( input=Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -125,17 +134,17 @@ def waveguide( Returns: `Pattern` object representing the waveguide. """ - # Generate hole locations + # Generate the normalized lattice locations for the line defect. xy = pcgen.waveguide(length=length, num_mirror=mirror_periods) - # Build the pattern + # Build the pattern by placing repeated references to the same hole cell. pat = Pattern() pat.refs[hole] += [ Ref(offset=(lattice_constant * x, lattice_constant * y)) for x, y in xy] - # Ports are at outer edges, with y=0 + # Publish the device interface as two ports at the outer edges. extent = lattice_constant * length / 2 pat.ports = dict( left=Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -164,17 +173,17 @@ def bend( `Pattern` object representing the waveguide bend. Ports are named 'left' (input) and 'right' (output). """ - # Generate hole locations + # Generate the normalized lattice locations for the bend. xy = pcgen.wgbend(num_mirror=mirror_periods) - # Build the pattern - pat= Pattern() + # Build the pattern by instancing the shared hole cell. + pat = Pattern() pat.refs[hole] += [ Ref(offset=(lattice_constant * x, lattice_constant * y)) for x, y in xy] - # Figure out port locations. + # Publish the bend interface as two ports. extent = lattice_constant * mirror_periods pat.ports = dict( left=Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -203,17 +212,17 @@ def y_splitter( `Pattern` object representing the y-splitter. Ports are named 'in', 'top', and 'bottom'. """ - # Generate hole locations + # Generate the normalized lattice locations for the splitter. xy = pcgen.y_splitter(num_mirror=mirror_periods) - # Build pattern + # Build the pattern by instancing the shared hole cell. pat = Pattern() pat.refs[hole] += [ Ref(offset=(lattice_constant * x, lattice_constant * y)) for x, y in xy] - # Determine port locations + # Publish the splitter interface as one input and two outputs. extent = lattice_constant * mirror_periods pat.ports = { 'in': Port((-extent, 0), rotation=0, ptype='pcwg'), @@ -227,13 +236,13 @@ def y_splitter( def main(interactive: bool = True) -> None: - # Generate some basic hole patterns + # First make a couple of reusable primitive cells. shape_lib = { 'smile': basic_shapes.smile(RADIUS), 'hole': basic_shapes.hole(RADIUS), } - # Build some devices + # Then build a small library of higher-level devices from those primitives. a = LATTICE_CONSTANT devices = {} @@ -245,22 +254,23 @@ def main(interactive: bool = True) -> None: devices['ysplit'] = y_splitter(lattice_constant=a, hole='hole', mirror_periods=5) devices['l3cav'] = perturbed_l3(lattice_constant=a, hole='smile', hole_lib=shape_lib, xy_size=(4, 10)) # uses smile :) - # Turn our dict of devices into a Library. - # This provides some convenience functions in the future! + # Turn the device mapping into a `Library`. + # That gives us convenience helpers for hierarchy inspection and abstract views. lib = Library(devices) # # Build a circuit # - # Create a `Builder`, and add the circuit to our library as "my_circuit". + # Create a `Builder`, and register the resulting top cell as "my_circuit". circ = Builder(library=lib, name='my_circuit') - # Start by placing a waveguide. Call its ports "in" and "signal". + # Start by placing a waveguide and renaming its ports to match the circuit-level + # names we want to use while assembling the design. circ.place('wg10', offset=(0, 0), port_map={'left': 'in', 'right': 'signal'}) - # Extend the signal path by attaching the "left" port of a waveguide. - # Since there is only one other port ("right") on the waveguide we - # are attaching (wg10), it automatically inherits the name "signal". + # Extend the signal path by attaching another waveguide. + # Because `wg10` only has one unattached port left after the plug, Masque can + # infer that it should keep the name `signal`. circ.plug('wg10', {'signal': 'left'}) # We could have done the following instead: @@ -268,8 +278,8 @@ def main(interactive: bool = True) -> None: # lib['my_circuit'] = circ_pat # circ_pat.place(lib.abstract('wg10'), ...) # circ_pat.plug(lib.abstract('wg10'), ...) - # but `Builder` lets us omit some of the repetition of `lib.abstract(...)`, and uses similar - # syntax to `Pather` and `RenderPather`, which add wire/waveguide routing functionality. + # but `Builder` removes some repeated `lib.abstract(...)` boilerplate and keeps + # the assembly code focused on port-level intent. # Attach a y-splitter to the signal path. # Since the y-splitter has 3 ports total, we can't auto-inherit the @@ -281,13 +291,10 @@ def main(interactive: bool = True) -> None: circ.plug('wg05', {'signal1': 'left'}) circ.plug('wg05', {'signal2': 'left'}) - # Add a bend to both ports. - # Our bend's ports "left" and "right" refer to the original counterclockwise - # orientation. We want the bends to turn in opposite directions, so we attach - # the "right" port to "signal1" to bend clockwise, and the "left" port - # to "signal2" to bend counterclockwise. - # We could also use `mirrored=(True, False)` to mirror one of the devices - # and then use same device port on both paths. + # Add a bend to both branches. + # Our bend primitive is defined with a specific orientation, so choosing which + # port to plug determines whether the path turns clockwise or counterclockwise. + # We could also mirror one instance instead of using opposite ports. circ.plug('bend0', {'signal1': 'right'}) circ.plug('bend0', {'signal2': 'left'}) @@ -296,29 +303,26 @@ def main(interactive: bool = True) -> None: circ.plug('l3cav', {'signal1': 'input'}) circ.plug('wg10', {'signal1': 'left'}) - # "signal2" just gets a single of equivalent length + # `signal2` gets a single waveguide of equivalent overall length. circ.plug('wg28', {'signal2': 'left'}) - # Now we bend both waveguides back towards each other + # Now bend both branches back towards each other. circ.plug('bend0', {'signal1': 'right'}) circ.plug('bend0', {'signal2': 'left'}) circ.plug('wg05', {'signal1': 'left'}) circ.plug('wg05', {'signal2': 'left'}) - # To join the waveguides, we attach a second y-junction. - # We plug "signal1" into the "bot" port, and "signal2" into the "top" port. - # The remaining port gets named "signal_out". - # This operation would raise an exception if the ports did not line up - # correctly (i.e. they required different rotations or translations of the - # y-junction device). + # To join the branches, attach a second y-junction. + # This succeeds only if both chosen ports agree on the same translation and + # rotation for the inserted device; otherwise Masque raises an exception. circ.plug('ysplit', {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'}) # Finally, add some more waveguide to "signal_out". circ.plug('wg10', {'signal_out': 'left'}) - # We can also add text labels for our circuit's ports. - # They will appear at the uppermost hierarchy level, while the individual - # device ports will appear further down, in their respective cells. + # Bake the top-level port metadata into labels so it survives GDS export. + # These labels appear on the circuit cell; individual child devices keep their + # own port labels in their own cells. ports_to_data(circ.pattern) # Check if we forgot to include any patterns... ooops! @@ -330,12 +334,12 @@ def main(interactive: bool = True) -> None: lib.add(shape_lib) assert not lib.dangling_refs() - # We can visualize the design. Usually it's easier to just view the GDS. + # We can visualize the design directly, though opening the written GDS is often easier. if interactive: print('Visualizing... this step may be slow') circ.pattern.visualize(lib) - #Write out to GDS, only keeping patterns referenced by our circuit (including itself) + # Write out only the subtree reachable from our top cell. subtree = lib.subtree('my_circuit') # don't include wg90, which we don't use check_valid_names(subtree.keys()) writefile(subtree, 'circuit.gds', **GDS_OPTS) diff --git a/examples/tutorial/library.py b/examples/tutorial/library.py index abfbbf1..faaa5a1 100644 --- a/examples/tutorial/library.py +++ b/examples/tutorial/library.py @@ -1,3 +1,11 @@ +""" +Tutorial: using `LazyLibrary` and `Builder.interface()`. + +This example assumes you have already read `devices.py` and generated the +`circuit.gds` file it writes. The goal here is not the photonic-crystal geometry +itself, but rather how Masque lets you mix lazily loaded GDS content with +python-generated devices inside one library. +""" from typing import Any from pprint import pformat @@ -12,8 +20,9 @@ from basic_shapes import GDS_OPTS def main() -> None: - # Define a `LazyLibrary`, which provides lazy evaluation for generating - # patterns and lazy-loading of GDS contents. + # A `LazyLibrary` delays work until a pattern is actually needed. + # That applies both to GDS cells we load from disk and to python callables + # that generate patterns on demand. lib = LazyLibrary() # @@ -23,9 +32,9 @@ def main() -> None: # Scan circuit.gds and prepare to lazy-load its contents gds_lib, _properties = load_libraryfile('circuit.gds', postprocess=data_to_ports) - # Add it into the device library by providing a way to read port info - # This maintains the lazy evaluation from above, so no patterns - # are actually read yet. + # Add those cells into our lazy library. + # Nothing is read yet; we are only registering how to fetch and postprocess + # each pattern when it is first requested. lib.add(gds_lib) print('Patterns loaded from GDS into library:\n' + pformat(list(lib.keys()))) @@ -40,8 +49,8 @@ def main() -> None: hole = 'triangle', ) - # Triangle-based variants. These are defined here, but they won't run until they're - # retrieved from the library. + # Triangle-based variants. These lambdas are only recipes for building the + # patterns; they do not execute until someone asks for the cell. lib['tri_wg10'] = lambda: devices.waveguide(length=10, mirror_periods=5, **opts) lib['tri_wg05'] = lambda: devices.waveguide(length=5, mirror_periods=5, **opts) lib['tri_wg28'] = lambda: devices.waveguide(length=28, mirror_periods=5, **opts) @@ -53,22 +62,22 @@ def main() -> None: # Build a mixed waveguide with an L3 cavity in the middle # - # Immediately start building from an instance of the L3 cavity + # Start a new design by copying the ports from an existing library cell. + # This gives `circ2` the same external interface as `tri_l3cav`. circ2 = Builder(library=lib, ports='tri_l3cav') - # First way to get abstracts is `lib.abstract(name)` - # We can use this syntax directly with `Pattern.plug()` and `Pattern.place()` as well as through `Builder`. + # First way to specify what we are plugging in: request an explicit abstract. + # This works with `Pattern` methods directly as well as with `Builder`. circ2.plug(lib.abstract('wg10'), {'input': 'right'}) - # Second way to get abstracts is to use an AbstractView - # This also works directly with `Pattern.plug()` / `Pattern.place()`. + # Second way: use an `AbstractView`, which behaves like a mapping of names + # to abstracts. abstracts = lib.abstract_view() circ2.plug(abstracts['wg10'], {'output': 'left'}) - # Third way to specify an abstract works by automatically getting - # it from the library already within the Builder object. - # This wouldn't work if we only had a `Pattern` (not a `Builder`). - # Just pass the pattern name! + # Third way: let `Builder` resolve a pattern name through its own library. + # This shorthand is convenient, but it is specific to helpers that already + # carry a library reference. circ2.plug('tri_wg10', {'input': 'right'}) circ2.plug('tri_wg10', {'output': 'left'}) @@ -77,13 +86,15 @@ def main() -> None: # - # Build a device that could plug into our mixed_wg_cav and joins the two ports + # Build a second device that is explicitly designed to mate with `circ2`. # - # We'll be designing against an existing device's interface... + # `Builder.interface()` makes a new pattern whose ports mirror an existing + # design's external interface. That is useful when you want to design an + # adapter, continuation, or mating structure. circ3 = Builder.interface(source=circ2) - # ... that lets us continue from where we left off. + # Continue routing outward from those inherited ports. circ3.plug('tri_bend0', {'input': 'right'}) circ3.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry circ3.plug('tri_bend0', {'input': 'right'}) diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py index f7bbdb2..386384a 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -243,7 +243,7 @@ def main() -> None: # If we wanted to place our via manually, we could add `pather.plug('m1_via', {'GND': 'top'})` here # and achieve the same result without having to define any transitions in M1_tool. # Note that even though we have changed the tool used for GND, the via doesn't get placed until - # the next time we draw a path on GND (the pather.mpath() statement below). + # the next time we route GND (the `pather.ccw()` call below). pather.retool(M1_tool, keys='GND') # Bundle together GND and VCC, and path the bundle forward and counterclockwise. diff --git a/examples/tutorial/port_pather.py b/examples/tutorial/port_pather.py index 3fad6e7..6d41a39 100644 --- a/examples/tutorial/port_pather.py +++ b/examples/tutorial/port_pather.py @@ -27,14 +27,14 @@ def main() -> None: # and remembers the selected port(s). This allows method chaining. # Route VCC: 6um South, then West to x=0. - # (Note: since the port points North into the pad, path() moves South by default) + # (Note: since the port points North into the pad, trace() moves South by default) (rpather.at('VCC') - .path(ccw=False, length=6_000) # Move South, turn West (Clockwise) - .path_to(ccw=None, x=0) # Continue West to x=0 + .trace(False, length=6_000) # Move South, turn West (Clockwise) + .trace_to(None, x=0) # Continue West to x=0 ) # Route GND: 5um South, then West to match VCC's x-coordinate. - rpather.at('GND').path(ccw=False, length=5_000).path_to(ccw=None, x=rpather['VCC'].x) + rpather.at('GND').trace(False, length=5_000).trace_to(None, x=rpather['VCC'].x) # @@ -49,45 +49,45 @@ def main() -> None: .retool(M1_tool) # this only retools the 'GND' port ) - # We can also pass multiple ports to .at(), and then use .mpath() on them. + # We can also pass multiple ports to .at(), and then route them together. # Here we bundle them, turn South, and retool both to M1 (VCC gets an auto-via). (rpather.at(['GND', 'VCC']) - .mpath(ccw=True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South - .retool(M1_tool) # Retools both GND and VCC - .mpath(ccw=True, emax=50_000, spacing=1_200) # Turn East, moves 50um extension - .mpath(ccw=False, emin=1_000, spacing=1_200) # U-turn back South - .mpath(ccw=False, emin=2_000, spacing=4_500) # U-turn back West + .trace(True, xmax=-10_000, spacing=5_000) # Move West to -10k, turn South + .retool(M1_tool) # Retools both GND and VCC + .trace(True, emax=50_000, spacing=1_200) # Turn East, moves 50um extension + .trace(False, emin=1_000, spacing=1_200) # U-turn back South + .trace(False, emin=2_000, spacing=4_500) # U-turn back West ) # Retool VCC back to M2 and move both to x=-28k rpather.at('VCC').retool(M2_tool) - rpather.at(['GND', 'VCC']).mpath(ccw=None, xmin=-28_000) + rpather.at(['GND', 'VCC']).trace(None, xmin=-28_000) # Final segments to -50k - rpather.at('VCC').path_to(ccw=None, x=-50_000, out_ptype='m1wire') + rpather.at('VCC').trace_to(None, x=-50_000, out_ptype='m1wire') with rpather.at('GND').toolctx(M2_tool): - rpather.at('GND').path_to(ccw=None, x=-40_000) - rpather.at('GND').path_to(ccw=None, x=-50_000) + rpather.at('GND').trace_to(None, x=-40_000) + rpather.at('GND').trace_to(None, x=-50_000) # - # Branching with save_copy and into_copy + # Branching with mark and fork # - # .save_copy(new_name) creates a port copy and keeps the original selected. - # .into_copy(new_name) creates a port copy and selects the new one. + # .mark(new_name) creates a port copy and keeps the original selected. + # .fork(new_name) creates a port copy and selects the new one. # Create a tap on GND (rpather.at('GND') - .path(ccw=None, length=5_000) # Move GND further West - .save_copy('GND_TAP') # Mark this location for a later branch - .pathS(length=10_000, jog=-10_000) # Continue GND with an S-bend + .trace(None, length=5_000) # Move GND further West + .mark('GND_TAP') # Mark this location for a later branch + .jog(offset=-10_000, length=10_000) # Continue GND with an S-bend ) # Branch VCC and follow the new branch (rpather.at('VCC') - .path(ccw=None, length=5_000) - .into_copy('VCC_BRANCH') # We are now manipulating 'VCC_BRANCH' - .path(ccw=True, length=5_000) # VCC_BRANCH turns South + .trace(None, length=5_000) + .fork('VCC_BRANCH') # We are now manipulating 'VCC_BRANCH' + .trace(True, length=5_000) # VCC_BRANCH turns South ) # The original 'VCC' port remains at x=-55k, y=VCC.y @@ -99,27 +99,25 @@ def main() -> None: # Route the GND_TAP we saved earlier. (rpather.at('GND_TAP') .retool(M1_tool) - .path(ccw=True, length=10_000) # Turn South - .rename_to('GND_FEED') # Give it a more descriptive name + .trace(True, length=10_000) # Turn South + .rename('GND_FEED') # Give it a more descriptive name .retool(M1_tool) # Re-apply tool to the new name ) # We can manage the active set of ports in a PortPather pp = rpather.at(['VCC_BRANCH', 'GND_FEED']) - pp.add_port('GND') # Now tracking 3 ports - pp.drop_port('VCC_BRANCH') # Now tracking 2 ports: GND_FEED, GND - pp.path_each(ccw=None, length=5_000) # Move both 5um forward (length > transition size) + pp.select('GND') # Now tracking 3 ports + pp.deselect('VCC_BRANCH') # Now tracking 2 ports: GND_FEED, GND + pp.trace(None, each=5_000) # Move both 5um forward (length > transition size) # We can also delete ports from the pather entirely rpather.at('VCC').delete() # VCC is gone (we have VCC_BRANCH instead) # - # Advanced Connections: path_into and path_from + # Advanced Connections: trace_into # - - # path_into routes FROM the selected port TO a target port. - # path_from routes TO the selected port FROM a source port. + # trace_into routes FROM the selected port TO a target port. # Create a destination component dest_ports = { @@ -133,10 +131,10 @@ def main() -> None: # Connect GND_FEED to DEST_A # Since GND_FEED is moving South and DEST_A faces West, a single bend will suffice. - rpather.at('GND_FEED').path_into('DEST_A') + rpather.at('GND_FEED').trace_into('DEST_A') - # Connect VCC_BRANCH to DEST_B using path_from - rpather.at('DEST_B').path_from('VCC_BRANCH') + # Connect VCC_BRANCH to DEST_B + rpather.at('VCC_BRANCH').trace_into('DEST_B') # diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 48f48ed..f0772a1 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -276,8 +276,8 @@ class Tool: Args: length: The total distance from input to output, along the input's axis only. jog: The total offset from the input to output, along the perpendicular axis. - A positive number implies a rightwards shift (i.e. clockwise bend followed - by a counterclockwise bend) + A positive number implies a leftward shift (i.e. counterclockwise bend followed + by a clockwise bend) in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. kwargs: Custom tool-specific parameters. diff --git a/masque/builder/utils.py b/masque/builder/utils.py index 4de6dbb..ca36fff 100644 --- a/masque/builder/utils.py +++ b/masque/builder/utils.py @@ -46,7 +46,7 @@ def ell( ccw: Turn direction. `True` means counterclockwise, `False` means clockwise, and `None` means no bend. If `None`, spacing must remain `None` or `0` (default), Otherwise, spacing must be set to a non-`None` value. - bound_method: Method used for determining the travel distance; see diagram above. + bound_type: Method used for determining the travel distance; see diagram above. Valid values are: - 'min_extension' or 'emin': The total extension value for the furthest-out port (B in the diagram). @@ -64,7 +64,7 @@ def ell( the x- and y- axes. If specifying a position, it is projected onto the extension direction. - bound_value: Value associated with `bound_type`, see above. + bound: Value associated with `bound_type`, see above. spacing: Distance between adjacent channels. Can be scalar, resulting in evenly spaced channels, or a vector with length one less than `ports`, allowing non-uniform spacing.