前置条件
- KLayout 正在运行,并已加载 klink 插件(RPC 端口默认
8765)。 - 这篇教程需要同一个解释器里既有 klink 又有 gdsfactory——klink 是 RPC client,几何在这个 Python 进程里用 gdsfactory 构建,再推给 KLayout。两条路都行:
- 一个 venv 两个库都装(最简单):
pip install "klayout-klink[photonics]"(= klink + 一个测试过的 gdsfactory 版本线,>=9.0,<10); - gdsfactory 已经在另一个 venv 里:
<that-venv>/python -m pip install klayout-klink(klink 是纯 Python),再从那个 venv 跑这篇教程的脚本。
- 一个 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.Port 的 center 单位契约在不同 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 默认行为)会在同一次调用里接着布线,这里特意分两步是为了在教程里把状态摊开看。
import_gf_component(route=False) 之后:13 个器件实例全部就位,端口全部标记,但一条布线都还没画——脚本自己的 6 条 Manhattan 路由已经被丢弃(视图里那两条像"线"的痕迹其实是 MMI 器件自身的简化几何,不是路由)。取景 view.zoom_box(bbox_um=[-150,-290,420,230])。max_hier_levels=1——凡是落在这个"层级边界"上的实例,KLayout 会在真实几何之上叠一层"box + cell 名字"的提示标注;器件挤得近时,这些标注文字互相重叠,糊成一片读不出的字。没有对应的 typed RPC,用 exec.python 把 view.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 坐标,而不是任何写死的角度表。这一步只是查询,没有写入任何几何。
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=true、crossings=0、device_hits=0——这组数字和仓库 示例页 gf_mzi_module 卡片公开记录的实测完全一致。
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 走线仍然钉在旧坐标,端口标记也还留在旧坐标——这正是"刚拖完、还没重布线"那一刻的真实状态。
gc_up 出现在右上角新位置,孤零零地立在那里;原来那条 sbend 走线还停在旧坐标,明显"断"在半空——这就是"拖完还没重布线"的样子。
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 才是"保留编辑、只重新接线"的那条路。
gc_up 已经挪走,走线还停在旧坐标。
gc_up 的新位置——组件挪了,线跟上了,其余 11 条 net 一个像素都没变。
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, ...} -- 精确等于拖动后的新坐标
view.zoom_fit):13 个器件、12 条 net、拖动之后的 gc_up 全部在同一个 cell 里,接线完整。
grating_coupler_elliptical4_o1(gc_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=0、device_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.py 与 MCP 参考 · 硅光。