Step-by-step tutorial · nanodevice · batch RPC

Step-by-step: drawing a neural electrode harness

This walks through example_template/nanodevice/neural_electrode.py from the public gallery (for the demo's function, requirements, and measured numbers, see Examples · Neural electrode). It's the third in the series, after Hall bar and EBL wraparound: same Port/Anchor/geometry-first loop, but this time the headline feature is parametric generation + batch RPCs -- one --elec-rows argument changes the electrode array's row count, and across a chip that spans nearly ten thousand microns, 394 geometry objects get written with just 3 staged shape.insert_many calls (1 call in production).

Prerequisites

  • Install klink: pip install klayout-klink (one command installs klink plus its Rust kernels -- no third-party scientific libraries needed).
  • KLayout running with the klink plugin loaded (RPC port defaults to 8765).
  • Scaffold a project and enter it: klink init nano-demo && cd nano-demo -- the full script is python example_template/nanodevice/neural_electrode.py --port <session-port> --elec-rows 4. In a repo checkout the equivalent is python -m examples_klink.public.demos.nanodevice.neural_electrode --port <session-port> --elec-rows 4.
This tutorial breaks the single batched call inside generate_harnesspcell() into 7 observable stages so each step of what's drawn, marked, and why is visible on its own. In production it's one shape.insert_many call writing all 394 shapes (see the batch-RPC rule in this repo's CLAUDE.md) -- the staged version here is purely for teaching, not a recommended pattern.
Units: framing and screenshots both take microns directly -- view.zoom_box(bbox_um=[x1,y1,x2,y2]) and view.screenshot(bbox_um=..., width_px=..., height_px=...) both take a bbox_um coordinate argument, in microns (klink's standard user-facing unit). view.viewport reports both bbox_um (microns) and bbox_dbu (database units) so you can use whichever you need.

1Create the cell + plan the layers

Every layer this device uses is example-owned (hardcoded layer numbers in example_template/nanodevice/neural_electrode.py) -- klink itself ships no process layers. Create the cell, set the Port/Anchor marker layers once, then ensure the 7 process layers plus klink's 3 reserved marker layers:

LayerPurpose
1/0M1 (near-field bond-pad-side routing, nets 1-12)
2/0VIA13 (M1↔M3 via, used only on the far electrode group)
3/0M3 (far-field routing, nets 13-24, plus the electrodes' own wiring)
4/0VIA35 (pad vias + electrode vias)
5/0Pads (24 nets' worth of bond pads, 72 squares, plus the electrode array pads)
6/0Frame (probe shell / body outline)
7/0Protect (protective layer, same shape as Frame plus a center fill)
999/99klink's reserved Port marker layer (KLINK_PORTS, primary Ports, labeled)
999/98klink's reserved auxiliary Port layer (KLINK_ROUTE_PORTS, for routing; the 24 bond-pad-side ones show labels, the 24 electrode-side ones hide them)
999/1klink's reserved Anchor marker layer (KLINK_ANCHORS)
from klink import KLinkClient

with KLinkClient(port=PORT).connect() as c:
    c.cell_create(cell_name)
    c.call("port.set_layer", {"layer": "999/99"})
    c.call("anchor.set_layer", {"layer": "999/1"})
    for layer, name in ((1,"M1"),(2,"VIA13"),(3,"M3"),(4,"VIA35"),(5,"Pads"),(6,"Frame"),(7,"Protect")):
        c.layer_ensure(layer, 0, name=name)
    c.layer_ensure(999, 99, name="KLINK_PORTS")
    c.layer_ensure(999, 98, name="KLINK_ROUTE_PORTS")
    c.layer_ensure(999, 1, name="KLINK_ANCHORS")
    c.show_cell(cell_name, zoom_fit=False)
Empty cell with every layer already created, no geometry on the canvas yet
Step 1 · Empty cell right after the layer plan, framed on the full 10600x5400 um probe-chip area (view.zoom_box(bbox_um=...)); nothing is drawn yet.

2Draw the probe body / shell

The probe body is a hollow "C"-shaped shell: on the left, a bond-pad housing (three walls, open on the right), narrowing into a rounded-transition neck, then forking into two thin upper/lower arms, and finally converging into a triangular tip far to the right -- exactly the "wide base + thin shank" shape of a real neural probe. _frame_items() draws one copy on layer 6/0 (Frame) and one on 7/0 (Protect) -- 7/0 gets two extra center/tip fill pieces -- 22 shapes total, written with one shape_insert_many call:

frame_items = _frame_items(spec)   # 22 box/polygon items, covering layers 6/0 + 7/0
c.shape_insert_many(cell_name, frame_items)
A three-walled rectangular shell on the left, with a thin shank extending to the right and converging to a point
Step 2 · Probe shell (6/0/7/0): bond-pad housing + neck + twin arms + tip, 22 shapes written in one call. At full-chip zoom the 130 um gap between the two arms is already too small to see as a fork -- the first taste of the "whole chip vs. local detail" scale problem the rest of this tutorial keeps coming back to.

3Draw the bond pads + relay vias

Inside the housing, 24 nets are laid out along y; each net gets a 150x150 um bond pad (a typical probe-station/wire-bond pad size) at each of three x positions -- probe_pad_x_um=(-6650,-5450,-4250) -- for 3 columns x 24 rows = 72 pad squares total, relayed like a baton from the outermost position all the way to route_via_x_um=-3050. Half the nets (net_num <= pads_per_half) also get an extra pair of relay vias there -- the landing spot Stage 6's routing will use. This is the biggest single write in the whole tutorial: 288 shapes across 5 layers (1/0/2/0/3/0/4/0/5/0) in one RPC:

probe_items = items[frame_count:frame_count + probe_count]   # 288 shapes
c.shape_insert_many(cell_name, probe_items)
Three neat columns of 24 bond pads each fill the housing, each pad carrying a small via marker
Step 3 · The bond-pad chain for 24 nets (72 pad squares) + relay vias (1/0/2/0/3/0/4/0/5/0), 288 shapes in one batched write. Fired one shape.insert_box at a time, this step alone would be 288 separate RPCs -- at this scale, CLAUDE.md's batch-RPC rule isn't "best practice," it's the difference between usable and not.

4Draw the electrode array (parametrized by --elec-rows)

At the far end of the shank sits the actual tissue-contacting electrode array: elec_rows rows x 3 columns, in two groups (elec_left_x_um near the neck, elec_right_x_um near the tip), each electrode pad only 20x21 um -- roughly an 8x size gap from the 150 um bond pads, the exact same "array pitch vs. probe-pad pitch, different scales" issue from this repo's CLAUDE.md, except both ends now live on one chip. Changing --elec-rows touches one number, and the row count, pad total, and shape count all move together:

--elec-rowsBond pads per half
pads_per_half = 3×rows
Total nets
net_count = 2×pads_per_half
Electrode shape count
rows×21
261242
4 (measured in this run)122484
61836126

The --elec-rows 2/6 rows are derived from the same HarnessPCellSpec formulas (pads_per_half/net_count are @propertys; the electrode shape count is elec_rows x 21) -- this tutorial actually ran --elec-rows 4, the other two rows were not independently re-run or screenshotted.

electrode_items = items[frame_count + probe_count:]   # elec_rows x 21 = 84 shapes (rows=4 here)
c.shape_insert_many(cell_name, electrode_items)
In the full-chip overview the bond-pad array is clearly visible, but the electrode array only appears as two barely-visible dots at the far end of the shank
Step 4a · The electrode array at full-chip scale: two tiny light-blue squares (around 860px and 1140px, the elec_left and elec_right groups) are the entire visible trace of 84 electrode shapes -- the direct consequence of the scale gap.
Zoomed in, a clear 4-row by 3-column grid of 12 electrode pads appears, each with a small via marker inside
Step 4b · Same location, this time with view.screenshot(bbox_um=[2370,-70,2510,70], width_px=1200, height_px=1200)'s exact clip (140x140 um, no aspect-ratio expansion): the 4x3=12 electrode pads for elec_rows=4 are now clearly countable, each pad's darker inner square its own via.

5Mark Port + corridor Anchor

With geometry drawn, intent-marking begins. There are three kinds of Port here: 24 bond-pad Ports (electrical, access_mode="edge", free to slide along the pad edge, one per net), 24 relay Ports (for routing, on auxiliary layer 999/98, also one per net), and 12+24 Ports on the electrode side -- one primary Port per (row, col) electrode site (on the elec_left side, its net field carries both the bottom_net and top_net tokens) plus two auxiliary Ports (ET{top_net} on the elec_left side, EB{bottom_net} on the elec_right side, each carrying just one net) -- 84 separate port.mark calls in total. Port creation goes through PCell instantiation, which is outside shape.insert_many's batch surface -- these really are 84 individual RPCs, and this tutorial shows that plainly instead of glossing over it.

There are only 2 Anchors, but a different kind from either earlier tutorial: kind="corridor", a mandatory lane running the full chip height, its width computed by corridor_width_um() (700 um here) -- every bond-pad-side trace must converge into this corridor before it can cross the neck. This reuses the same kind="corridor" concept from the EBL tutorial's "allowed crossing window," but for a completely different purpose: not obstacle avoidance, but forced convergence.

c.call("port.mark", {
    "cell": cell_name, "layer": "999/99", "name": "P36", "label": "n1",
    "center_um": [x, y], "orientation": 0, "width_um": 30.0,
    "port_type": "electrical", "net": "n1", "target_layer": "1/0",
    "access_mode": "edge", "slide_allowed": True, "slide_edge": "x0,y0,x1,y1",
})
c.call("anchor.mark", {
    "cell": cell_name, "layer": "999/1", "id": "A1_BOTTOM_M1_CORRIDOR",
    "kind": "corridor", "center_um": [-1650.0, 0.0], "width_um": 700.0,
    "path_points": "0,-1;0,1", "net": "n1,n2,...,n12", "priority": 1, "required": True,
})
Small labels appear on the bond-pad side, a dark red corridor marker line crosses the housing exit, and the electrode side shows no visible change at this scale
Step 5 · All 84 Ports + 2 corridor Anchors are marked. The bond-pad-side labels (n1...n24) and the corridor marker line at the housing exit are visible at this scale; the electrode-side Ports are only 5 um wide -- sub-pixel in a full-chip screenshot. A pixel-level diff (step-04 vs. this image) confirms every changed pixel is inside x∈[118,719], the bond-pad region -- the electrode region x∈[800,1200] shows zero pixel difference. Seeing where the electrode-side Ports actually landed has to wait for Step 7's detail crop.

6Corridor-constrained batch routing

Once everything is marked, one pass routes all 24 nets: pull every auxiliary Port and the corridor Anchors back in bulk with port.list/anchor.list, group by route_layer (1/0 in one group, 3/0 in the other), and call route_tapered_hybrid_many once per group (a visibility graph + Dijkstra plan, computed client-side in Python, no rasterization) followed by commit_tapered_hybrid_many (one batched RPC write-back):

from klink.routing.backends.geometric.tapered_segments import (
    pair_ports_by_net_tokens, route_tapered_hybrid_many, commit_tapered_hybrid_many,
)

ports = c.call("port.list", {"cell": cell_name, "layer": "999/98"})["ports"]
ports = [p for p in ports if str(p.get("name") or "").startswith(("R", "EB", "ET"))]
anchors = c.call("anchor.list", {"cell": cell_name, "layer": "999/1"})["anchors"]
pairs = pair_ports_by_net_tokens(ports)   # pair source/target by shared net token

by_layer = {}
for pair in pairs:
    by_layer.setdefault(pair.get("route_layer") or "10/0", []).append(pair)

for route_layer, layer_pairs in sorted(by_layer.items()):
    planned = route_tapered_hybrid_many(
        layer_pairs, anchors=anchors, spacing_um=8.0,
        strategy="uniform", angle_mode="manhattan", validate_sibling_overlap=True,
    )
    commit_tapered_hybrid_many(c, cell_name, planned, route_layer=route_layer, clear=True)
24 traces leave the bond pads, each keeping its own channel at first, then converging into a single narrow corridor at the housing exit before crossing the neck toward the far shank
Step 6 · All 24 traces routed (the 12 pink ones in the bottom half of the image are nets 1-12, on 1/0; the 12 blue-violet ones in the top half are nets 13-24, on 3/0): each trace keeps its own channel in the bond-pad region (CLAUDE.md's "channel isolation"), only converging into the 700 um corridor Anchor at the housing exit -- a many-to-one convergence, not each tracing its own straight line.

7Finish: overview + electrode-end detail

Routing was the last geometry write; Stage 7 just looks at the result:

c.zoom_fit()
overview = c.screenshot(mode="base64", width_px=1200)
detail = c.screenshot(mode="base64", bbox_um=[2370.0, -70.0, 2510.0, 70.0],
                       width_px=1200, height_px=1200)
The finished neural electrode probe harness in full: bond-pad housing, neck, twin arms, tip, and every trace from the converged corridor out to the electrode array
Step 7a · Finished overview (view.zoom_fit): housing + 24 nets' worth of bond-pad chains + 24 traces + corridor convergence + shank + electrode array, all in one cell.
The same electrode-array detail crop, now with a trace landing on top of each electrode pad's via
Step 7b · The exact same crop as Step 4b, revisited after routing: 12 traces terminate precisely on their respective electrode pads' vias -- the direct evidence that an 8x scale gap between the two ends is not a problem.
Same location, an orange box and arrow point at one electrode pad in the top-right, with a trace terminating exactly on its via
Step 7c · Orange highlight: the electrode pad at elec_right_x_um[2]=2480, y=45 -- its via is labeled EB12 (net n12, layer 1/0). This trace started at the farthest bond pad, crossed the corridor, ran nearly ten thousand microns, and landed exactly on this 20x21 um electrode (checked by coordinates via port.list, not by eye).

Verify, don't just look

As the tutorials home page says: screenshots are for people, not proof of done. Here's the actual structured result from this run (--elec-rows 4):

{
  "shape_item_counts": {"frame": 22, "bondpads_and_vias": 288, "electrodes": 84, "total": 394},
  "port_count_total": 84,
  "anchor_count_total": 2,
  "route_result": {
    "ok": true,
    "port_count": 48,
    "anchor_count": 2,
    "pair_count": 24,
    "groups": [
      {"route_layer": "1/0", "route_count": 12, "sibling_overlap_count": 0, "obstacle_hit_count": 0, "inserted": 84},
      {"route_layer": "3/0", "route_count": 12, "sibling_overlap_count": 0, "obstacle_hit_count": 0, "inserted": 84}
    ]
  }
}

ok=true, and both route-layer groups report zero sibling_overlap_count/obstacle_hit_count -- meaning all 24 traces (12 on 1/0, 12 on 3/0) avoid each other with no crossed wiring; pair_count=24 equals net_count, meaning every net paired successfully with no orphaned Port. Together these three numbers -- not "it looks connected" -- are what "this parametric harness is correctly routed" actually rests on.

Next steps

Change --elec-rows to your own row count (say 2 or 6), check the shape counts against the formula table in Stage 4, and confirm route_result.ok is still true. If you haven't read the first two yet, read them in order: "Step-by-step: drawing a Hall bar device" (the geometry-first basics: mark Port/Anchor intent, then route) and "Step-by-step: drawing an EBL wraparound nanodevice" (writefield obstacle avoidance, where kind="corridor" windows first appear) -- this tutorial's corridor Anchor borrows that exact same kind for a completely different "forced convergence" purpose. Next up is a photonics-flavored gf_mzi_module tutorial (python -m examples_klink.public.demos.photonics.gf_mzi_module --port <session-port>), which switches to the gdsfactory bridge and an MZI device. The full Port/Anchor field reference is in the MCP reference; the full design boundary for batch RPCs is in this repo's CLAUDE.md, under "Use batch RPCs for generated layouts."