做FDTD仿真的人迟早会遇到同一个问题:结构稍微复杂一点,手动画图就变成纯粹的体力活。我第一次在Lumerical FDTD Solutions里搭建带光栅的波导结构时,还老老实实在GUI里一个个拖矩形、改尺寸、设材料,等把8个齿摆完,半小时已经没了。更崩溃的是,后面要改成16个齿,又得从头再来一遍。从那天起,我几乎所有结构创建都改成了脚本方式。这篇教程就从“用FDTD脚本创建结构”这个主题讲起,内容包括最基础的对象创建API、属性设置方法、参数化与扫描思路,最后用一个光栅耦合器作为完整案例跑一遍。想学脚本建结构的新手可以按顺序看,已经会基础操作的可以直接跳到第三节和第四节看参数化方法。
1. 为什么要用脚本创建结构:手工建模的几个真实痛点
1.1 重复操作与批量生成才是脚本的主场
先说最直接的场景。你在设计亚波长光栅、超表面或者光子晶体时,结构动不动就是几十上百个单元。手动画的话,每一个单元都要重复一遍“添加矩形-设置尺寸-设置位置-设置材料”这个流程,改一个参数全部重来。脚本的核心优势在于循环:同样的代码加一个for,8个齿、64个单元、几百个圆孔都是一次性生成,而且不会出现手动点歪、漏点的问题。我第一次用循环生成一排圆孔阵列时,脚本运行完模型树里齐刷刷地出现了64个圆孔,那个感觉真的是“这才对”。
1.2 参数扫描时的“改一个值重画一遍”其实没必要
做FDTD仿真的人绕不开参数扫描。最痛苦的做法是:手动改结构的宽度、跑一次仿真、记录结果、再改、再跑。如果直接在GUI里改对象属性,还可能漏改或改错。用脚本之后,扫描参数可以直接挂在对象属性或者结构组变量上,一次把起始值、终止值、步长设好,仿真自动跑完,结果自动汇总。参数扫描是脚本化结构创建最刚需的理由,没有之一。我见过不少同事用GUI手动扫描,一个三参数扫描能折腾一整天,换成脚本之后,设置完下班走人,第二天早上结果就齐了。
1.3 可复现性与版本管理
还有一个容易被低估的好处:脚本让仿真模型具备可复现性。同一个结构,三个月后回头再看,鼠标点过的东西你可能忘了一大半,而脚本还清清楚楚地写在那里,每个尺寸、每层材料都有据可查。配合脚本文件版本管理,组里任何人拿到脚本都能重建出完全一致的模型。这一点在写论文和团队协作时特别重要,审稿人要求提供仿真文件时,脚本就是最好的“实验记录”。我个人的习惯是,每个仿真项目建一个scripts文件夹,里面按日期保存脚本版本,模型文件里的对象命名也和脚本一一对应,后期回溯非常快。
1.4 脚本化不等于放弃GUI
要注意的是,“用脚本创建结构”不等于所有步骤都写代码。我的习惯是:初期调试和观察单个结构的几何形状时用GUI,确认没问题后再把整个过程固化成脚本。Lumerical的脚本编辑器和布局窗口是实时联动的,脚本运行完可以立刻在GUI里旋转视角、检查截面、核对尺寸。脚本负责效率和可复现,GUI负责检查和直观理解,两者是互补关系。有些新手容易走极端,要么全程手点,要么强行纯脚本不用界面,其实都不太健康。
2. 结构创建脚本的核心API:从addrect到set属性
2.1 基础结构原语:矩形、圆柱、多边形、球体
Lumerical脚本创建结构的API非常有规律,一个结构对应一个add函数,创建之后当前对象会自动处于“选中”状态,紧接着用set函数设置属性即可。最常用的几个原语我列在下面:
| 脚本函数 | 结构类型 | 常见用途 |
|---|---|---|
| addrect | 矩形块 | 波导、衬底、光栅齿、金属电极 |
| addcircle | 圆柱体 | 圆孔、纳米柱、光纤端面 |
| addsphere | 球体 | 纳米球、散射粒子 |
| addpoly | 多边形柱体 | 锥形波导、任意截面结构 |
| addstructuregroup | 结构组 | 封装多个结构,配合扫描与参数化 |
每个函数调用之后,新建对象就处于选中状态,直接在后面跟set就能修改属性。比如最简单的创建:
addrect; set("name", "box");这行代码会在布局里创建一个默认尺寸的矩形块,同时命名为box。如果你在脚本编辑器里按F5运行完,切到Layout窗口就能看到模型树里多了一个叫box的对象。
2.2 set方法:定位、尺寸、旋转与材料
在Lumerical里,set是通用属性修改函数,格式是set("属性名", 值)。结构创建最常用到的属性有这么几类:位置属性x、y、z,以及x span、y span、z span。其中x、y、z是结构中心的坐标,span是沿轴的总长度。注意,脚本里面所有长度单位都是米,这是新手最容易踩的坑,后面单开一节详细说。
设置材料的属性名是material,值是材料库里的字符串名称,例如:
set("material", "Si (Silicon) - Palik");材料名必须和材料库里的名称完全一致,包括括号和空格。你可以在GUI的材料下拉框里直接复制,避免手打出错。也可以用listmaterial命令把当前可用的材料全部打印到脚本提示区。
旋转相关的属性是rotation x、rotation y、rotation z,单位是度:
set("rotation z", 45);这个在设置倾斜结构时会用到,比如MEMS反射镜阵列或者斜入射光栅。旋转轴默认经过结构中心,如果需要绕其他轴旋转,还可以配rotation x axis、rotation y axis、rotation z axis这几个属性指定旋转轴位置。
2.3 对象命名与层级关系
每个结构都应该有个有意义的名字,方便后续select、sweep和排查。命名用set("name", "..."),命名之后,可以用select("name")重新选中它,再get或set属性。比如:
select("waveguide"); set("x span", 0.6e-6);就能把名为waveguide的对象宽度改成0.6微米。这里有个容易忽略的点:select的字符串要和set("name", ...)时完全一致,大小写、空格、下划线都不能错。Lumerical的模型树是按名称索引的,名称冲突或拼写错误都会导致操作失败。
层级管理主要靠结构组。addstructuregroup创建组,之后用addtogroup把当前选中的结构加进去。批量生成的同类结构建议放进一个组,这样在层叠关系、材料覆盖和扫描设置时会省很多事。分组还有一个好处是整体移动、整体隐藏,几何检查起来清爽得多。
2.4 用一条最小脚本跑通创建-设置-查看流程
为了让新手对整个过程有体感,这里给一个最小可运行示例:
switchtolayout; addrect; set("name", "si_waveguide"); set("x", 0); set("y", 0); set("z", 0); set("x span", 4e-6); set("y span", 0.5e-6); set("z span", 0.22e-6); set("material", "Si (Silicon) - Palik");这段代码在脚本编辑器里按F5运行后,切到布局窗口就能看到模型树里的矩形块和3D视图里的实体结构。你可以在脚本编辑器里把z span改成0.3e-6再运行,结构会立即更新,这就是脚本创建结构最直观的反馈。这一小段代码虽然简单,但它包含了脚本建结构的所有关键要素:切换到正确模式、创建原语、设置命名、设置位置、设置尺寸、设置材料。
3. 让结构参数化:变量、表达式与sweep扫描
3.1 脚本变量与GUI的联动方式
Lumerical脚本不是简单的录制回放,它本质上是一种类似MATLAB的脚本语言,支持变量、表达式、循环和函数。这让结构创建从“一次画一个”变成“定义规则”。比如:
period = 0.7e-6; width = 0.35e-6; addrect; set("name", "bar"); set("x span", width);这里period和width是脚本里的变量,它们不会自动出现在GUI的某个输入框里,但脚本运行时决定了对象属性。如果需要让变量在GUI中可调、可扫描,就要结合结构组的本地变量(local variables)。这一步是很多初学脚本的人卡住的地方,我单独在3.4节展开。
3.2 表达式驱动的尺寸联动
结构设计里经常需要“一个参数变了,其他尺寸跟着变”。比如占空比duty等于线宽除以周期,如果你想扫描周期同时保持占空比不变,就得让线宽由period和duty共同决定:
duty = 0.5; period = 0.7e-6; line_width = period * duty; addrect; set("x span", line_width);脚本在运行时会先计算line_width再创建结构,所以改duty、改period,最终都能自动得到对应的几何。这就是参数驱动建模的核心逻辑。用表达式链路把几何参数串起来,之后做优化才不会被单一变量卡死。实际工程中,衬底厚度、刻蚀深度、周期之间往往有耦合关系,比如固定深宽比时,凹槽宽度变了,深度也得跟着变,这种联动用表达式处理最方便。
还有一个很有用的内置函数是num2str,它能把数字转成字符串,配合循环给对象命名。比如:
for(i = 1:8) { addrect; set("name", "tooth_" + num2str(i)); }这样生成的8个对象名称分别是tooth_1、tooth_2到tooth_8。如果你不需要对象名带序号,也可以统一命名成同一个名字,但那样后面select的时候会把所有同名对象一起选中,容易导致set操作作用范围超出预期,所以还是建议用序号区分。
3.3 参数扫描的挂载方式
Lumerical提供sweep功能来做参数扫描,脚本里对应的API是addsweep、setsweep和addsweepparameter。最简单的一种用法是选中一个对象,把它的某个属性设为扫描参数:
select("si_waveguide"); addsweep; setsweep("name", "width_sweep"); setsweep("sweep_type", "parameter"); addsweepparameter("x span", 0.3e-6, 0.7e-6);addsweepparameter的格式是“参数名或对象属性路径,起始值,终止值”。扫描任务会在仿真中自动生成多个case并依次运行。这里要注意的是,addsweepparameter支持的是对象属性路径,比如"waveguide::x span",或者是结构组里的参数名,不要凭感觉乱写。
如果你希望一次性扫多个参数,可以连续多次调用addsweepparameter,但要注意参数组合会成倍增加,计算资源和时间消耗要提前评估。扫描完的结果会在sweep的result选项卡里,可以用脚本提取,也可以直接在GUI里画图。
3.4 结构组在参数扫描中的关键作用
假如你有8个光栅齿,宽度都等于line_width,能否通过sweep让它们一起变?答案是可以,但要注意方法。直接对每个齿单独添加sweep参数不仅繁琐,而且扫描维度会爆炸。正确做法是:把整个光栅封装在结构组里,在结构组的本地变量中定义period、duty、thickness等参数,结构组内部的几何创建脚本统一引用这些变量。之后sweep时只需要把period、duty作为扫描参数添加,一组几何就会自动按变量变化。
这也是Lumerical结构组在参数化建模里最重要的价值:它不只是视觉上的分组,更是一个参数封装和复用单元。你可以在结构组的脚本选项卡里编写几何生成逻辑,把局部变量暴露给sweep;也可以先用普通脚本建好结构,再通过addtogroup把对象收进组里,然后对组内属性做操作。两种方式都能用,但如果你要做“多参数联动扫描”,结构组脚本变量方案才是干净的路径。
4. 实战:用脚本构建一个光栅耦合器结构
4.1 设计目标与几何参数
这个案例的目标是创建一个典型的波导光栅耦合器结构:底部是玻璃衬底,上面一层硅波导,波导表面刻蚀出一排空气凹槽形成光栅。所有几何通过脚本控制,关键参数定义为变量,方便之后做扫描。结构的基本几何参数如下:
| 参数 | 含义 | 初值 |
|---|---|---|
| sub_thickness | 衬底厚度 | 0.5e-6米 |
| si_thickness | 波导层厚度 | 0.22e-6米 |
| period | 光栅周期 | 0.7e-6米 |
| duty | 光栅占空比 | 0.5 |
| grating_depth | 刻蚀深度 | 0.1e-6米 |
| width | 波导宽度 | 0.5e-6米 |
| num_teeth | 光栅齿数 | 8 |
实际设计时,光栅周期通常需要结合目标波长和有效折射率来估算,这里为了演示脚本,参数本身并不代表某个经过优化的最优值。脚本的价值在于:给定一套参数,几十行代码就能把结构完整重建出来,后续调参只是改最上面几行赋值。
4.2 创建衬底与波导层
先在脚本里把衬底矩形和波导矩形建出来:
switchtolayout; # 衬底 addrect; set("name", "substrate"); set("x", 0); set("y", 0); set("z", 0); set("x span", 8e-6); set("y span", 8e-6); set("z span", 0.5e-6); set("material", "SiO2 (Glass) - Palik"); # 波导层 addrect; set("name", "si_layer"); set("x", 0); set("y", 0); set("z", 0.36e-6); set("x span", 8e-6); set("y span", 0.5e-6); set("z span", 0.22e-6); set("material", "Si (Silicon) - Palik");这里z坐标的选择很关键。我习惯把衬底几何中心放在z=0,那么衬底顶面就在z=0.25e-6的位置,也就是衬底z span的一半。硅波导层的中心放在z=0.36e-6,z span是0.22e-6,那么它的底面就是0.36e-6减去0.11e-6,正好等于0.25e-6,刚好和衬底顶面贴平。这个坐标推算的思路比死记数字重要,因为当你把衬底厚度改成0.8微米时,波导层的z中心也得跟着动,否则就会悬空或者嵌入。
4.3 用循环生成光栅齿
光栅齿本质上是波导表面的一组空气凹槽。我的做法是在硅层上方加一组空气矩形块,凹槽中心设在波导顶面以下grating_depth/2的位置:
period = 0.7e-6; duty = 0.5; grating_depth = 0.1e-6; num_teeth = 8; for(i = 1:num_teeth) { addrect; set("name", "tooth_" + num2str(i)); set("x", -4e-6 + (i - 0.5) * period); set("y", 0); set("z", 0.42e-6); set("x span", period * duty); set("y span", 0.6e-6); set("z span", grating_depth); set("material", "air"); }解释一下z坐标:0.36e-6是硅层中心,0.11e-6是硅层半厚度,所以波导顶面在0.47e-6。空气槽的中心设在顶面往下grating_depth/2,也就是0.47e-6减去0.05e-6,得到0.42e-6。这里我已经把它写成了固定数值,但更好的写法是写成一个表达式,比如:
si_center = 0.36e-6; si_half_thickness = 0.11e-6; slot_center = si_center + si_half_thickness - grating_depth / 2;这样grating_depth变化时,凹槽仍然会从波导顶面向下长,不会飘走。这正是脚本建结构最舒服的地方:坐标不靠手算,靠表达式。如果用手工建模,你每次改刻蚀深度都得手动重算一大堆坐标。
x的取值为-4e-6加(i-0.5)乘period,是为了让8个齿在-4微米到0微米之间均匀分布。如果你想从0开始往正方向排,把-4e-6改成0就行。循环变量i的类型是整数,但和浮点数运算后结果是浮点数,这一点Lumerical会自动处理。
4.4 用结构组封装并配置扫描
为了让齿统一管理,可以先创建结构组,再把每一个齿加进去:
addstructuregroup; set("name", "grating_group"); for(i = 1:num_teeth) { addrect; set("name", "tooth_" + num2str(i)); # ... 省略尺寸和材料设置 addtogroup("grating_group"); }这里有个顺序问题我特意说一下:必须先addstructuregroup,然后再循环里建齿、addtogroup,顺序反了会报“组不存在”的错。addtogroup执行时,组必须已经存在且未被选中,否则分组逻辑会乱。
如果你要做完整的参数扫描,尤其是让所有齿同步变化,我更推荐在结构组脚本变量方案里做:在结构组的属性窗口中找到本地变量选项卡,定义period、duty、grating_depth,然后在结构组自带的脚本编辑器中写循环建齿的代码。这样sweep参数直接绑定变量名,运行时一组几何就会按变量自动变化,效果远比给8个齿分别加sweep参数干净。
4.5 完整脚本注释版
把上面的内容整合一下,给一个可直接运行的完整版本:
switchtolayout; # 参数定义 period = 0.7e-6; duty = 0.5; grating_depth = 0.1e-6; num_teeth = 8; width = 0.5e-6; si_center = 0.36e-6; si_half_thickness = 0.11e-6; # 衬底 addrect; set("name", "substrate"); set("x", 0); set("y", 0); set("z", 0); set("x span", 8e-6); set("y span", 8e-6); set("z span", 0.5e-6); set("material", "SiO2 (Glass) - Palik"); # 波导层 addrect; set("name", "si_layer"); set("x", 0); set("y", 0); set("z", si_center); set("x span", 8e-6); set("y span", width); set("z span", 0.22e-6); set("material", "Si (Silicon) - Palik"); # 光栅结构组 addstructuregroup; set("name", "grating_group"); for(i = 1:num_teeth) { addrect; set("name", "tooth_" + num2str(i)); set("x", -4e-6 + (i - 0.5) * period); set("y", 0); set("z", si_center + si_half_thickness - grating_depth / 2); set("x span", period * duty); set("y span", 0.6e-6); set("z span", grating_depth); set("material", "air"); addtogroup("grating_group"); }这个脚本运行后,布局窗口的模型树应该出现substrate、si_layer和grating_group三个顶层对象,grating_group下面挂着8个tooth。建议运行完先在GUI里转一下视角,检查凹槽是否从波导顶面向下挖,以及有没有嵌入到衬底里。几何不对,后面仿真精度无从谈起。
4.6 扫描运行和结果查看
如果要做周期扫描,选中grating_group,通过结构组sweep添加period变量扫描。如果只是单独测试某个齿的宽度变化,选中该齿后:
select("grating_group::tooth_1"); addsweep; setsweep("name", "sweep_tooth_width"); setsweep("sweep_type", "parameter"); addsweepparameter("x span", 0.2e-6, 0.5e-6);这里要特别提醒一个坑:用这种属性扫描方式,每次只有一个齿会变,其他齿保持不变,这在多数光栅设计中并不是你想要的效果。如果希望所有齿同步变化,还是那句老话,用结构组变量方案。这个细节我见过不少人在论坛里问“为什么我只扫一个参数,别的结构也变了”或者“为什么扫了以后只有其中一个齿变了”,根因基本都是没搞清楚对象属性和组变量的区别。
5. 脚本调试与常见报错排查
5.1 单位混乱:最常见的“形体消失”原因
我见过的脚本建结构问题里,至少有一半是单位问题。Lumerical脚本的默认长度单位是米,但GUI界面里的长度信息通常显示成微米。于是很多新手在脚本里写set("x span", 0.5),本意是0.5微米,实际创建了一个0.5米宽的巨型结构。小尺寸在有限视野里根本看不到,大尺寸可能会把仿真区域撑爆,或者导致网格数爆炸。
正确的写法是:
set("x span", 0.5e-6);我建议所有脚本里的长度量一律用科学计数法,不要贪图省事写0.5或者500e-9。越是复杂的结构,单位混乱越难排查。还有一个习惯是:在脚本开头写一个注释块,标明所有基本单位,比如“所有长度单位:米”,提醒自己下次打开脚本的人。
5.2 set属性失败与属性名拼写
set函数报错的常见原因有两个:属性名拼写错误,或者当前选中的对象不是你想操作的对象。Lumerical的属性名是区分大小写的,比如"X span"会报错,正确的是"x span"。如果你在脚本里写错了属性名,脚本提示区会直接报出找不到该属性的错误,这时候先检查拼写,不要急着怀疑软件。
另一个问题是select的对象不对。比如模型里有两个名字相近的对象,select("tooth_1")和select("tooth_01")是不同对象。建议在set操作之前先用get确认:
select("si_layer"); ? get("x span");?是Lumerical脚本里打印变量的快捷方式,get("x span")返回当前选中对象的x span值。如果打印出来的值和你预期不一致,说明select到的不是你以为的对象。
5.3 多边形顶点矩阵的格式坑
addpoly是创建任意形状结构最常用的原语,但它的顶点格式确实坑过不少人。polygon vertices属性要求是一个二维矩阵,每一行代表一个顶点的x、y坐标,行与行之间用分号分隔:
addpoly; set("name", "tri_prism"); set("polygon vertices", [0, 0; 1e-6, 0.5e-6; -1e-6, 0.5e-6]);如果你把顶点写成一维数组,比如[0, 0, 1e-6, 0.5e-6, -1e-6, 0.5e-6],脚本很可能不会直接报错,但画出来的多边形形状完全不对。顶点矩阵里的坐标是相对于结构中心的偏移坐标,不是绝对坐标,这一点也和addrect的坐标系一样。我在创建锥形波导时经常用addpoly配合表达式,把多个顶点坐标用变量算好再填进去,这样改长度、改锥角都只需要改几个变量。
5.4 用print和get做断点式排查
Lumerical脚本本身没有图形化断点调试功能,但可以靠print语句做分段排查。在怀疑的位置插入print:
print(si_center); print(get("x span"));运行后脚本提示区会输出当前变量值和对象属性值。如果你发现某个变量输出的是0或者空,说明赋值可能有误;如果某个get返回值和预期不符,说明select对象有问题。
还有一个技巧是把大脚本拆成几个小节,分多次运行。比如先运行参数定义和衬底创建,检查衬底位置对不对;再运行波导层创建,检查贴合关系;最后运行光栅循环。每次都确认无误后再继续往下写。这样做的好处是问题容易定位,不会出现一个几百行脚本运行完,报错信息指向最后一行,实际错误却发生在第50行的情况。
5.5 运行环境与保存习惯
创建结构之前先执行switchtolayout,确保当前处于布局模式。如果当前停在FDTD求解器分析模式或者网格模式,addrect等结构创建指令可能会报错或行为不正常。这个命令本身很轻量,我习惯在每个结构脚本的第一行都写上。
再一个就是保存习惯。运行脚本之前,先保存当前的模型文件。因为有些脚本错误会导致几何丢失、对象名冲突、甚至模型树混乱。我吃过大亏:一个跑得正好的脚本,改了一个参数后运行直接报错,结果模型文件里所有结构全消失了,Ctrl+Z都救不回来。从那以后我养成两个习惯:一是运行脚本前手动保存模型,二是脚本文件本身也复制一份备份。每次改完一轮参数,模型另存为一个新文件名,脚本同样按日期命名。虽然麻烦一点,但相比重写脚本几十行代码的成本,这点麻烦完全值得。
最后一个个人体会是:脚本建结构这件事,入门门槛其实不高,真正难的是建立“参数驱动”的思维。刚开始你可能只是为了省点鼠标操作,写着写着就会发现,把结构拆成参数、把位置关系写成表达式,能让你对设计的理解上升一个层次。而且一旦你手里的模型全是脚本可生成的,后续做优化、做统计分析、做批量仿真都会顺很多。这篇文章里的代码和思路都是我之前项目里实践过的,照着敲一遍,再套到你自己的结构上,很快就能上手。