Step-by-step tutorial · silicon photonics · gdsfactory 桥接

分步教程:gdsfactory MZI 接管与"拖动 → 重布线"

对应仓库里的 example_template/photonics/gf_mzi_module.py(demo 功能/依赖/实测数字见示例 · gdsfactory MZI 接管)。这是Hall barEBL wraparound神经电极阵列三篇教程之后的第四篇,也是第一批收官篇:前三篇都是"在 KLayout 里画",这一篇换成"在 gdsfactory 里画,让 klink 接管"——一个普通的 gdsfactory 热光 MZI 脚本,被一次 import_gf_component 调用接管进 klink 的交互循环,之后你在 KLayout GUI 里拖动任意组件,不重建、只重布线,一次 photonics.reroute(或脚本的 --reroute flag)就把光学和电学一起重新接上。这一套"拖动 → 重布线"闭环,是硅光用户日常迭代版图的核心工作流。

前置条件

  • KLayout 正在运行,并已加载 klink 插件(RPC 端口默认 8765)。
  • 这篇教程需要同一个解释器里既有 klink 又有 gdsfactory——klink 是 RPC client,几何在这个 Python 进程里用 gdsfactory 构建,再推给 KLayout。两条路都行:
    1. 一个 venv 两个库都装(最简单):pip install "klayout-klink[photonics]"(= klink + 一个测试过的 gdsfactory 版本线,>=9.0,<10);
    2. gdsfactory 已经在另一个 venv 里:<that-venv>/python -m pip install klayout-klink(klink 是纯 Python),再从那个 venv 跑这篇教程的脚本。
  • 建一个项目并进入:klink init my-chip && cd my-chip——完整脚本就是 python example_template/photonics/gf_mzi_module.py --port <session-port>。仓库 checkout 里等价的是 python -m examples_klink.public.demos.photonics.gf_mzi_module --port <session-port>
不要把仓库 sys.path 硬塞进一个随便的解释器再去 monkey-patch klink 内部来绕过 gdsfactory 版本差异——gf.Portcenter 单位契约在不同 gdsfactory 版本间变过,这条捷径的真实后果是 1000× 走样的几何。[photonics] 装的是一个经过 CI 测试的版本,如果你必须用别的 gdsfactory 版本,预期要跟着调整脚本,而不是改 klink。

1一次调用接管整个模块

脚本的"用户部分"(build_user_module())是纯 gdsfactory 代码,这一步还没有任何 KLayout 参与:一个 1×2 MMI 分束器 → 两条热相移臂(下臂 mirror_y() 镜像,故意摆得别扭)→ 一个 2×2 MMI 合束器 → 两个横向偏移的输出光栅耦合器(GC),外加一对倾斜 15° 的输入 GC(Manhattan 路由器够不到)、一对任意朝向的 loopback 对准 GC,以及加热器用的四个 pad。脚本自己用 gf.routing.route_bundle 画了 6 条 Manhattan 光学路由——这些路由待会会被 klink 接管时整体丢弃、按 net 重画,脚本自己布线只是为了让这份 gdsfactory 代码本身站得住脚。

真正的接管只有一次调用

from klink import KLinkClient
from klink.domains.photonics.gf_import import import_gf_component
from examples_klink.public.demos.photonics.gf_mzi_module import build_user_module, CELL, OPTICAL_LAYER

component = build_user_module()   # 纯 gdsfactory,还没有 KLayout 参与
with KLinkClient(port=PORT).connect() as client:
    result = import_gf_component(
        client, component, cell=CELL, route_layer=OPTICAL_LAYER, route=False)
    # result: {"ok": True, "nets": [...6 个...], "instances": 13, "device_cells": [...5 个...], ...}

这一次调用同时做了三件事——按 docs/AGENT_TOOL_DESIGN.md 的"一个用户意图 = 一次调用"设计:① 把 13 个器件实例(splitter、combiner、两条热相移臂、5 个 GC、4 个 pad——共 5 种唯一器件 cell)批量插入 KLayout;② 把脚本自己画的光学路由折叠成设备级 net(6 条),持久化进 .klink/specs/GF_MZI_MODULE.nets.json;③ 因为传了 route=False,这一步只标记全部已收割端口(57 个)方便检查接线,还不画任何布线。传给 route=True(demo 默认行为)会在同一次调用里接着布线,这里特意分两步是为了在教程里把状态摊开看。

散落摆放的多个器件:两个热相移臂、若干光栅耦合器、若干 pad,器件之间没有任何走线,只有细小的端口标记
Stage 1 · import_gf_component(route=False) 之后:13 个器件实例全部就位,端口全部标记,但一条布线都还没画——脚本自己的 6 条 Manhattan 路由已经被丢弃(视图里那两条像"线"的痕迹其实是 MMI 器件自身的简化几何,不是路由)。取景 view.zoom_box(bbox_um=[-150,-290,420,230])
这篇教程遇到的唯一一个纯显示层面的坑:klink 批量插入的器件全部叠在一层子 cell 层级里,KLayout 默认 max_hier_levels=1——凡是落在这个"层级边界"上的实例,KLayout 会在真实几何之上叠一层"box + cell 名字"的提示标注;器件挤得近时,这些标注文字互相重叠,糊成一片读不出的字。没有对应的 typed RPC,用 exec.pythonview.max_hier_levels 调大到 4(超过实际层级深度)+ view.update_content() 强制重绘,标注就消失了——这是纯显示修正,不改一个字节的几何:改前改后 port.list/reroute() 报告数字逐一核对完全一致。下文所有截图都已经应用这个修正。

2收割端口:从活的实例位置派生

硅光教程与 docs/GDSFACTORY_PDK_LOOP.md 反复强调的一句话在这里看得最清楚:端口是派生数据,永远从活的实例位置重新计算,不是画一次就定死的静态标注。全部 57 个端口里最能说明这件事的是倾斜输入光栅耦合器 gc_in——它在 gdsfactory 脚本里被 rotate(195) 转了个刁钻角度("195° 朝向"和"面向外 15°"是同一件事的两种说法),Manhattan 路由器完全够不到它,这正是脚本自己留它不布线的原因:

ports = client.call(
    "port.list", {"cell": "GF_MZI_MODULE", "layer": "999/99", "sort": "name"}
).get("ports", [])
by_name = {p["name"]: p for p in ports}
gc_in_port = by_name["grating_coupler_elliptical1_o1"]
# {"center_um": [-80.0, -25.0], "orientation": 15.0, "net": ""}

端口中心 [-80.0, -25.0] 精确等于 gdsfactory 脚本里 gc_in.move((-80, -25)) 的落点,朝向 15.0° 是从器件自身 rotate(195) 正确变换出来的出光方向——全靠klink.domains.photonics.gf_import.harvest_gf_template_ports 把 gf 端口模板(child-local 坐标)按每个实例的活 transform 变换到 parent 坐标,而不是任何写死的角度表。这一步只是查询,没有写入任何几何。

一个明显倾斜的紫色器件轮廓,右侧伸出一个粉色小三角端口标记,朝向也是倾斜的
Stage 2 · 倾斜输入 GC(gc_in)的细节裁剪:紫色是器件自身按 195° 旋转后的轮廓,粉色小三角是从活实例位置重新收割出来的端口标记,朝向精确跟随器件旋转——这正是后面 Stage 3 里 router="all_angle" 能接上它的前提。取景 view.screenshot(bbox_um=[-145,-55,-65,25], width_px=900, height_px=900)(80×80 µm 正方形,无长宽比扩展)。

3扩展 net 表 + 一次 reroute() 布满全部连接

接管时折叠出的 6 条光学 net 只覆盖了脚本自己能布的 Manhattan 部分。demo 在同一张持久化 net 表上继续加三类东西,全部加完才调一次 reroute()——这也是这份 net 表设计的关键:net 的风格(router 选哪个、layer 是哪层)和 net 的存在是分开的两件事,可以先加完所有意图、最后统一路由:

from klink.domains.photonics.net_intent import NetTable, RouteStyle, reroute

table = NetTable.load("GF_MZI_MODULE")

# 1) 偏移输出光栅耦合器那两条 net 重排成 sbend(平滑 S 弯,不用两次直角弯)
for entry in table.entries:
    members = {entry["a"], entry["b"]}
    if any(m.startswith("mmi2x2") and m.endswith(("_o3", "_o4")) for m in members):
        entry["style"]["router"] = "sbend"

# 2) 加上 Manhattan 路由器够不到的:倾斜 GC 用 all_angle,loopback 对用 dubins 圆弧
table.add_pair("grating_coupler_elliptical1_o1", "mmi1x20_o1",
               RouteStyle(router="all_angle", route_layer="1/0"))
table.add_pair("grating_coupler_elliptical2_o1", "grating_coupler_elliptical3_o1",
               RouteStyle(router="dubins", radius_um=40.0, route_layer="1/0"))

# 3) 加热器 -> pad 的电学 net,走金属层 49/0
metal = RouteStyle(router="electrical", route_layer="49/0", separation_um=12.0)
table.add_pair("straight_heater_metal_undercut0_l_e2", "pad0_e2", metal)
# ...其余 3 对同理(上臂×2 + 下臂×2,与 pad 按 x 坐标顺序配对)
table.save()

report = reroute(client, cell="GF_MZI_MODULE")
# {"ok": True, "routes": 12, "abutted": 0, "crossings": 0, "device_hits": 0}

12 条 net 一次路满:6 条导入的光学 net(Manhattan,重排后 2 条是 sbend)+ 1 条 all_angle(倾斜 GC)+ 1 条 dubins(loopback 对)+ 4 条 electrical(两条热臂各 2 个电极 → 4 个 pad)。ok=truecrossings=0device_hits=0——这组数字和仓库 示例页 gf_mzi_module 卡片公开记录的实测完全一致。

完整接线的 MZI:分束器到两条臂、两条臂到合束器、合束器到两个偏移输出 GC 的平滑弯线、左侧倾斜 GC 的全角度连线、下方两个 loopback GC 之间的圆弧、以及加热器到 pad 的直角走线
Stage 3 · 一次 reroute() 之后:12 条 net 全部画完——光学在 1/0(粉色),电学在 49/0(同色系,走 pad 与加热器电极之间)。这是接下来"拖动 → 重连"的基线状态。

4模拟拖动:让一条布线过期

真实工作流里这一步是人在 KLayout GUI 里用鼠标拖。这篇教程要在无人操作的环境下如实复现同一个动作,于是要回答一个问题:有没有 typed RPC 能挪动一个已经放置好的实例?查过 klink_plugin/python/klink_server/methods/instance_m.py 之后,答案是没有——这个模块只有 instance.insert / insert_many / insert_pcell / insert_pcell_many / query / delete,没有一个能修改已存在实例的 transform。人在 GUI 里拖动,底层调用的正是 pya.Instance.dcplx_trans 的 setter——没有更高层的 klink RPC 可以替代,所以这里是一次有理由、有记录的 exec.python 逃生舱,而不是绕过 typed RPC 的偷懒:

# 5 个光栅耦合器实例共享同一个子 cell,靠活 transform 的位置精确定位到 gc_up
top_cell = layout.cell("GF_MZI_MODULE")
child_idx = layout.cell("GFDEV_grating_coupler_elliptical_2f9b04be").cell_index()
for inst in top_cell.each_inst():
    if inst.cell_index != child_idx:
        continue
    t = inst.dcplx_trans
    if abs(t.disp.x - 360.0) > 0.01 or abs(t.disp.y - 30.0) > 0.01:
        continue  # 不是 gc_up,跳过其余 4 个光栅耦合器
    inst.dcplx_trans = pya.DCplxTrans(t.mag, t.angle, t.mirror,
                                       t.disp + pya.DVector(0.0, 70.0))

挪的是 gc_up(偏移输出光栅耦合器之一,只挂一条 net),从 (360, 30) 沿 +y 挪了 70 µm(360, 100)——上方是空地,不会撞到 pad 或其它器件的避障框。只挪了实例的位置,没有动任何布线几何、没有动 net 表:旧的 sbend 走线仍然钉在旧坐标,端口标记也还留在旧坐标——这正是"刚拖完、还没重布线"那一刻的真实状态。

整体版图和上一张几乎一样,但右上角多出一个孤立的光栅耦合器方块,原来连向那个位置的弯线现在悬在半空中没有接到任何东西
Stage 4a · 拖动后的全景(与 Stage 3 完全相同的取景框,方便逐像素对比):gc_up 出现在右上角新位置,孤零零地立在那里;原来那条 sbend 走线还停在旧坐标,明显"断"在半空——这就是"拖完还没重布线"的样子。
紧凑裁剪:一条粉色弯线从左侧爬升,末端悬空停在一个小黄点上,右上角远处孤立着一个紫色器件方块,两者之间没有任何连接
Stage 4b · 同一处的紧凑裁剪:走线末端(黄点,旧端口位置)和挪到新位置的 gc_up 之间空出一大截——布线已经"过期",肉眼就能确认,不用等验证工具。

5reroute():同一个调用,从新位置重新接上

这是全篇的落点:修复过期布线用的和 Stage 3 建立基线时同一个函数——photonics.reroute(脚本里对应 --reroute flag)。它只需要 cell 名字,其余全部状态从磁盘上的持久化 net 表读:

from klink.domains.photonics.net_intent import reroute

report = reroute(client, cell="GF_MZI_MODULE")
# {"ok": True, "routes": 12, "abutted": 0, "crossings": 0, "device_hits": 0}

reroute() 内部先重新收割端口(就是 Stage 2 那个从活实例位置派生的过程——gc_up 的端口这时候会被发现已经挪到了新坐标),再按 net 表里存的每一路 RouteStyle 重新布线。不重建任何器件,你的拖动被保留;这正是脚本文档里反复强调的那句话——不带 flag 重跑整个脚本会从 gdsfactory 源重建、把每个器件吸回原位,撤销你的编辑,--reroute 才是"保留编辑、只重新接线"的那条路。

拖动后的全景:右上角孤立的光栅耦合器,旧走线悬空
Stage 5a · reroute 之前(与 Stage 4a 相同):gc_up 已经挪走,走线还停在旧坐标。
与前一张几乎相同的全景,但右上角那条走线现在向上弯折,精确接到了挪动后的光栅耦合器上,版图重新变得完整
Stage 5b · reroute 之后(同一取景框):走线向上弯折,精确接到 gc_up 的新位置——组件挪了,线跟上了,其余 11 条 net 一个像素都没变。
紧凑裁剪:走线末端悬空,光栅耦合器孤立在远处
Stage 5c · 紧凑裁剪,reroute 之前:与 Stage 4b 相同——悬空的走线末端和孤立的器件。
同一处紧凑裁剪,走线现在弯折向上,末端正好落在光栅耦合器的端口位置上
Stage 5d · 紧凑裁剪,reroute 之后:走线弯折向上,末端精确落在 gc_up 端口新坐标上——同一个裁剪框,Stage 5c → 5d 就是这篇教程最想讲清楚的那件事。

6收尾:全景 + 精确核对落点

reroute() 已经是最后一次几何写入,这一步只是确认结果、并用坐标核对而不是用肉眹:

client.zoom_fit()
overview = client.screenshot(mode="base64", width_px=1200)

ports = client.call("port.list", {"cell": "GF_MZI_MODULE", "layer": "999/99", "sort": "name"})["ports"]
gc_up_port = next(p for p in ports if p["name"] == "grating_coupler_elliptical4_o1")
# {"center_um": [360.0, 100.0], "orientation": 180.0, ...} -- 精确等于拖动后的新坐标
完整的 MZI 版图全景:分束器、两条热相移臂、合束器、两个偏移输出光栅耦合器(其中一个已经挪到新位置)、倾斜输入光栅耦合器、loopback 光栅耦合器对、四个加热器 pad,全部由走线连接完整
Stage 6a · 成品全景(view.zoom_fit):13 个器件、12 条 net、拖动之后的 gc_up 全部在同一个 cell 里,接线完整。
紧凑裁剪,橙色方框和箭头标出走线末端与光栅耦合器之间的连接点
Stage 6b · 橙色高亮:grating_coupler_elliptical4_o1gc_up 的光纤端口)——port.list 查到它现在的坐标是 [360.0, 100.0],和拖动脚本里 (360, 30) + (0, 70) = (360, 100) 精确一致,不是目测;这也证明 reroute() 是从活的实例位置重新收割端口,不是照抄脚本原始布局。

验证,不是截图

教程首页说的一样,截图只是给人看的,不是完成依据。这次实际运行的结构化结果:

{
  "import": {"ok": true, "nets": 6, "instances": 13, "device_cells": 5, "ports_marked": 57},
  "baseline_reroute":  {"ok": true, "routes": 12, "abutted": 0, "crossings": 0, "device_hits": 0},
  "drag": {"cell": "GFDEV_grating_coupler_elliptical_2f9b04be", "target": "gc_up",
           "from_to": [360.0, 30.0, 360.0, 100.0]},
  "post_drag_reroute": {"ok": true, "routes": 12, "abutted": 0, "crossings": 0, "device_hits": 0}
}

拖动前后两次 reroute() 报告逐字段相同routes=12(等于 net 表里的全部 net 数)、crossings=0device_hits=0——说明 12 条 net 拖动前后都各自成功配对、互不穿越、不撞任何器件避障框。这组数字和示例页公开记录6 光学 net · 13/5 instances/device cells · 12 reroute routes · 0/0 crossings/device-hits 完全对得上——这份教程用的正是同一条公开路径,没有另开小灶。

下一步

拿你自己的 gdsfactory 脚本试一遍这条路径:把脚本里任何一次 gf.routing.route_bundle 调用留着或删掉都不影响接管——import_gf_component 只认设备实例和它们之间折叠出的连接,不认脚本自己是怎么布线的。换成你自己的 PDK 时,层号、stub 规则、cross-section 全部来自你的脚本或 pdk.py,klink 本身不带任何工艺层。如果还没看过前三篇,建议按顺序读:《分步教程:Hall bar 器件》(geometry-first 入门)、《分步教程:EBL wraparound 纳米器件》(写场避障)、《分步教程:神经电极阵列 harness》(参数化生成 + 批量 RPC)——这篇教程的 reroute() 闭环和它们共享同一套"标记意图 → 路由 → 验证"骨架,只是这次意图来自 gdsfactory 而不是手写 port.mark。下一批预告是数字方向:自定义器件从网表到 place&route 再到 live LVS 的完整闭环。完整命令、依赖、公开实测数字见示例页 · gdsfactory MZI 接管卡片;NetTable/RouteStyle/reroute 的完整字段说明见仓库 klink/domains/photonics/net_intent.pyMCP 参考 · 硅光