Step-by-step tutorial · nanodevice · 批量 RPC

分步教程:画一个神经电极阵列 harness

对应仓库里的 example_template/nanodevice/neural_electrode.py(demo 功能/依赖/实测数字见示例 · Neural electrode)。这是Hall barEBL wraparound两篇教程之后的第三篇:还是同一套 Port/Anchor/geometry-first 流程,但这次的主角是参数化生成 + 批量 RPC——一个 --elec-rows 参数就能改变电极阵列的行数,而全芯片近万微米跨度里,394 个几何对象只用 3 次分阶段的 shape.insert_many 调用写完(生产环境是 1 次)。

前置条件

  • 安装 klink:pip install klayout-klink(一条命令装好 klink 和它的 Rust 内核,无需第三方科学库)。
  • KLayout 正在运行,并已加载 klink 插件(RPC 端口默认 8765)。
  • 建一个项目并进入:klink init nano-demo && cd nano-demo——完整脚本就是 python example_template/nanodevice/neural_electrode.py --port <session-port> --elec-rows 4。仓库 checkout 里等价的是 python -m examples_klink.public.demos.nanodevice.neural_electrode --port <session-port> --elec-rows 4
这篇教程把 generate_harnesspcell() 内部一次性完成的批量调用拆成 7 个可观察的阶段,方便讲清每一步在画什么、标什么、为什么。生产环境里它就是一次 shape.insert_many 批量写入全部 394 个 shape(见 CLAUDE.md 的批量 RPC 铁律)——分阶段只是教学手段,不是推荐用法。
单位说明:取景和截图都直接用微米——view.zoom_box(bbox_um=[x1,y1,x2,y2])view.screenshot(bbox_um=..., width_px=..., height_px=...) 的坐标参数都是 bbox_um,单位微米(klink 的标准用户单位)。view.viewport 同时返回 bbox_um(微米)和 bbox_dbu(数据库单位)两种坐标,按需取用。

1建 cell + 层规划

这个器件的所有层都是 example-owned(example_template/nanodevice/neural_electrode.py 里硬编码的层号),klink 本身不带任何工艺层。先建 cell,把 Port/Anchor 的标记层指定一遍,再把 7 个工艺层加上 klink 保留的 3 个标记层全部 ensure 出来:

用途
1/0M1(近场键合 pad 侧走线,net 1-12)
2/0VIA13(M1↔M3 过孔,仅电极远端一侧用到)
3/0M3(远场走线 net 13-24,以及电极自身接线)
4/0VIA35(pad 过孔 + 电极过孔)
5/0Pads(24 个 net 的键合 pad,72 个方块 + 电极阵列 pad)
6/0Frame(探针外壳/本体轮廓)
7/0Protect(保护层,与 Frame 同形状 + 居中填充)
999/99klink 保留的 Port 标记层(KLINK_PORTS,主 Port,带标签)
999/98klink 保留的辅助 Port 层(KLINK_ROUTE_PORTS,供路由用;键合 pad 侧的 24 个显示标签,电极侧的 24 个隐藏标签)
999/1klink 保留的 Anchor 标记层(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)
空白 cell,已建好全部图层,画布中还没有任何几何形状
Step 1 · 层规划完成后的空 cell,画面框住整个 10600×5400 µm 的探针芯片区域,此时还没有任何形状(view.zoom_box(bbox_um=...) 定位)。

2画探针本体 / 外壳

探针本体是一圈"匚"字形空心外壳:左边是键合 pad housing(三面墙、右侧开口),中间收窄成一段带圆角过渡的颈部(neck),再分叉成上下两条细长的臂(arm),最后在远端收拢成一个尖端三角形(tip)——这正是真实神经探针"宽底座 + 细长探针轴"的形状。_frame_items()6/0(Frame)和 7/0(Protect)两层各画一份(7/0 多两块居中/尖端填充),一共 22 个 shape,一次 shape_insert_many 写完:

frame_items = _frame_items(spec)   # 22 个 box/polygon,覆盖 6/0 + 7/0 两层
c.shape_insert_many(cell_name, frame_items)
左侧是一个三面墙的矩形外壳,右侧伸出一条细长的探针轴,末端收拢成尖点
Step 2 · 探针外壳(6/0/7/0):键合 pad housing + 颈部 + 双臂 + 尖端,22 个 shape 一次写入。全景尺度下双臂之间 130 µm 的间隙已经小到看不出分叉,这也是下面几步要反复面对的"整体 vs. 局部"尺度问题。

3画键合 pad + 中继过孔

housing 内部沿 y 方向排布着 24 个 net;每个 net 在 probe_pad_x_um=(-6650,-5450,-4250) 三个 x 位置各留一个 150×150 µm 键合 pad(探针最常用的探针台/引线键合尺寸),一共 3 列 × 24 行 = 72 个 pad 方块,像接力棒一样从最外侧一路排到 route_via_x_um=-3050;其中一半 net(net_num <= pads_per_half)还在这里多留了一对中继过孔——这是给 Stage 6 路由用的"落脚点"。这一步是全篇批量最大的一次写入:288 个 shape,横跨 1/0/2/0/3/0/4/0/5/0 五个层,一次 RPC

probe_items = items[frame_count:frame_count + probe_count]   # 288 个 shape
c.shape_insert_many(cell_name, probe_items)
housing 内部整齐排列着三列、每列 24 个键合 pad,每个 pad 都带一个小方块过孔标记
Step 3 · 24 个 net 的键合 pad 链路(72 个 pad 方块)+ 中继过孔(1/0/2/0/3/0/4/0/5/0),288 个 shape 一次批量写入。要是逐个 shape.insert_box 单发,这一步就是 288 次独立 RPC——CLAUDE.md 的批量 RPC 铁律在这种规模的版图上不是"最佳实践",是"能不能用"的区别。

4画电极阵列(由 --elec-rows 参数化)

探针轴远端是真正接触组织的电极阵列:elec_rows 行 × 3 列,两组(elec_left_x_um 靠近颈部、elec_right_x_um 靠近尖端),每个电极 pad 只有 20×21 µm——这就是键合 pad(150 µm)和电极 pad(20 µm)之间接近 8 倍的间距落差,和 CLAUDE.md 里"阵列间距 vs 探针间距不同尺度"是同一个问题,只是这次两端都在同一颗芯片上。--elec-rows 只改一个数字,行数、pad 总数、shape 数量全部联动:

--elec-rows每半边键合 pad 数
pads_per_half = 3×rows
net 总数
net_count = 2×pads_per_half
电极 shape 数
rows×21
261242
4(本篇实测)122484
61836126

--elec-rows 2/6 两行是从同一份 HarnessPCellSpec 公式推出来的(pads_per_half/net_count@property,电极 shape 数是 elec_rows × 21),本篇实际跑的是 --elec-rows 4 那一行,其余两行没有单独重跑截图。

electrode_items = items[frame_count + probe_count:]   # elec_rows x 21 = 84 个 shape(本例 rows=4)
c.shape_insert_many(cell_name, electrode_items)
全景图里键合 pad 阵列清晰可见,但电极阵列只在探针轴远端呈现为两个几乎看不清的小点
Step 4a · 全景尺度下的电极阵列:两个浅蓝色小方块(860px1140px 附近,分别是 elec_left/elec_right 两组)就是 84 个电极 shape 的全部视觉痕迹——这就是尺度落差的直观后果。
放大后可见 4 行 3 列共 12 个电极 pad,每个 pad 内部有一个小方块过孔标记
Step 4b · 同一个位置,换成 view.screenshot(bbox_um=[2370,-70,2510,70], width_px=1200, height_px=1200) 的精确裁剪(140×140 µm,无长宽比扩展):elec_rows=4 对应的 4×3=12 个电极 pad 清晰可数,每个 pad 里一个深色小方块是它自己的过孔。

5标记 Port + 走廊 Anchor

几何画完后开始标意图。这里有三类 Port:24 个键合 pad Port(electricalaccess_mode="edge",可沿 pad 边滑动,一个 net 一个)、24 个中继 Port(供路由用,走辅助层 999/98,同样一个 net 一个)、以及电极侧的 12+24 个 Port——每个 (row, col) 电极位置一个主 Port(elec_left 侧,net 字段同时写 bottom_nettop_net 两个 net token)+ 两个辅助 Port(ET{top_net}elec_left 侧、EB{bottom_net}elec_right 侧,各自只认一个 net)——加起来 84 次独立 port.mark 调用。Port 创建走的是 PCell 实例化路径,不在 shape.insert_many 的批量范围内,这 84 次确实是 84 次 RPC,教程如实展示这一点,不掩盖。

Anchor 只有 2 个,但类型和前两篇都不一样:kind="corridor",一条贯穿全芯片 y 方向、宽度由 corridor_width_um()(本例 700 µm)算出的强制走廊——所有键合 pad 侧的走线必须收拢进这条走廊才能穿过颈部,这是"允许穿越窗口"(EBL 教程的 kind="corridor" 窗口)在完全不同场景下的复用:不是避障,而是强制汇聚。

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,
})
键合 pad 一侧出现细小的标签文字,housing 出口处出现一条横贯的深红色走廊标记线,电极一侧因为尺度太小看不出变化
Step 5 · 84 个 Port + 2 个走廊 Anchor 全部标记完成。键合 pad 侧的标签(n1n24)和 housing 出口处的走廊标记线在这个尺度下可见;电极侧的 Port 只有 5 µm 宽,在全景截图里是亚像素级的——像素级对比(step-04 与本图逐像素 diff)证实变化只发生在 x∈[118,719] 键合 pad 区域,电极区域 x∈[800,1200] 零像素差异。要看电极侧的 Port 落位,得等 Step 7 的细节裁剪图。

6走廊约束下的批量路由

标记完成后,一次调用把 24 个 net 全部路由完:先用 port.list/anchor.list 批量取回所有辅助 Port 和走廊 Anchor,按 route_layer1/0 一组、3/0 一组)分组,每组各调一次 route_tapered_hybrid_many(客户端 Python 里做可见性图 + Dijkstra 规划,零栅格化)+ commit_tapered_hybrid_many(一次批量 RPC 写回):

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)   # 按 net token 配对 source/target

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 条走线从每个键合 pad 出发,先各自保持独立通道,再在 housing 出口处收拢进一条窄走廊,穿过颈部伸向探针轴远端
Step 6 · 24 条走线全部路由完成(图下半区粉色 12 条是 net1-12,在 1/0;图上半区紫蓝色 12 条是 net13-24,在 3/0):每条走线在键合 pad 区保持独立通道(CLAUDE.md 的"channel isolation"),到 housing 出口才收拢进 700 µm 宽的走廊 Anchor——多对一收敛,而不是各走各的直线。

7收尾:全景 + 电极端细节

routing 已经是最后一次几何写入,Stage 7 只是看结果:

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)
完整的神经电极探针 harness 全景:键合 pad housing、颈部、双臂、尖端,以及从 pad 收拢到走廊再分散到电极的全部走线
Step 7a · 成品全景(view.zoom_fit):housing + 24 个 net 的键合 pad 链路 + 24 条走线 + 走廊收拢 + 探针轴 + 电极阵列,全部在一个 cell 里。
同一处电极阵列细节裁剪,这次每个电极 pad 上方都多了一条走线,精确落在过孔位置
Step 7b · 与 Step 4b 完全相同的裁剪框,路由完成后再看一次:12 条走线精确终止在各自电极 pad 的过孔上——这才是"两端尺度差 8 倍也能连上"的直接证据。
同一位置,橙色方框和箭头标出右上角一个电极 pad,一条走线正好终止在它的过孔上
Step 7c · 橙色高亮:elec_right_x_um[2]=2480, y=45 处的电极 pad——它的过孔上标着 EB12(net n121/0 层),这条走线从最远的键合 pad 出发、穿过走廊、走完全部近万微米,最终精确落在这个 20×21 µm 的电极上(用 port.list 按坐标核对过,不是目测)。

验证,不是截图

教程首页说的一样,截图只是给人看的,不是完成依据。这次实际运行(--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,两个 route_layer 分组的 sibling_overlap_count/obstacle_hit_count 全部为 0,说明 24 条走线(12 在 1/0,12 在 3/0)互不重叠、没有相互撞线;pair_count=24 等于 net_count,说明每个 net 都恰好配对成功,没有落单的 Port。这三个数字合在一起,才是"这版参数化 harness 布线正确"的依据,而不是"看起来接上了"。

下一步

--elec-rows 改成你自己的行数(比如 2 或 6),对照上面 Stage 4 的公式表核实 shape 数量是否符合预期,再确认 route_result.ok 依旧是 true。如果还没看过前两篇,建议按顺序读:《分步教程:Hall bar 器件》(geometry-first 入门:先标 Port/Anchor 意图再路由)和《分步教程:EBL wraparound 纳米器件》(写场避障:kind="corridor" 窗口第一次出现)——这篇教程的走廊 Anchor 正是从那篇借来的同一个 kind,用在了完全不同的"强制汇聚"场景里。下一篇预告是硅光方向的 gf_mzi_module 教程(python -m examples_klink.public.demos.photonics.gf_mzi_module --port <session-port>),会换到 gdsfactory 桥接和 MZI 器件。Port/Anchor 的完整字段说明见 MCP 参考,批量 RPC 的完整设计边界见仓库 CLAUDE.md 的"Use batch RPCs for generated layouts"一节。