一维光子晶体Zak相位的计算,听起来是个门槛挺高的活儿,但真正上手之后你会发现,难点反而不在物理本身,而在仿真工具和数据处理流程的衔接上。最近我完整跑通了一套Comsol加Matlab的联合计算流程,从建模到提取Zak相位,中间踩了不少坑,也积累了一些能直接复用的经验。这篇文章就把整个过程原原本本记录下来,包括每一步的物理依据、参数设置和代码实现思路,希望能给正在做拓扑光子学相关课题的人省点时间。
先说清楚这篇文章解决什么问题:如果你需要计算一维光子晶体的体态拓扑不变量,也就是Zak相位,同时想用有限元仿真工具得到反射相位谱,再通过积分提取Zak相位,那么这套流程可以直接参考。我在设计之初就明确了几个目标:不借助额外的商业插件、依赖尽可能少的第三方库、计算过程尽量自动化。Comsol负责建模仿真,Matlab负责参数扫描控制和数据处理,两者结合能发挥各自的优势。
1. 内容整体设计与思路拆解
1.1 为什么选择Comsol和Matlab的组合
很多做光子晶体的人可能第一反应是直接用Matlab写传输矩阵法,那确实很快,几分钟就能跑完。但问题是,如果你研究的结构不方便化简成一维分层模型,比如带缺陷、带渐变层、或者需要同时看电场分布,传输矩阵就有点力不从心了。Comsol的优势在通用建模和可视化,尤其是处理复杂几何和边界条件时非常直观。
我选择Comsol加Matlab的组合,核心原因是需要做参数扫描。通过改变入射角或波长,计算不同条件下的反射相位,再积分得到Zak相位,这个过程需要几十上百次独立仿真。如果手动在Comsol界面里一个个点,效率太低,而且容易出错。而Matlab通过LiveLink接口调用Comsol,可以批量修改参数、批量求解,关键是还能利用Matlab强大的数据处理和可视化能力做后续分析。
1.2 Zak相位是什么,为什么值得折腾
开门见山说Zak相位,它是描述一维周期结构能带拓扑性质的量,本质上是Bloch波函数在动量空间中的Berry相位,积分范围跨越整个布里渊区。Zak相位之所以重要,是因为它直接关联到边界态的存在与否,这也就是拓扑光子学里常说的体边对应原理。
具体到一维光子晶体,每个能带都有一个Zak相位值,理论上是0或者π,这两个经典值分别对应平庸和拓扑的能带。判断一个光子晶体界面有没有拓扑保护的模式,关键就是比较界面两侧材料的Zak相位是否不同。如果一侧是0、另一侧是π,界面处大概率会出现带隙内的局域态,这就是Tamm态或者拓扑边界态。
所以计算Zak相位不是目的,它是判断体系拓扑性质的重要中间步骤,最终目的是为后续的边界态设计、慢光器件或者拓扑波导提供理论依据。
1.3 方案选型和技术路径概览
通过反射相位积分计算Zak相位,其核心公式是:$\theta_n^Zak = \int_{-\pi/d}^{\pi/d} \left[\mathrm{Im}\left(\ln(r_k)\right)\right]_{连续分支} dk + \pi \cdot \Theta(\kappa_n)$,这个公式的实用性在于与反射谱直接联系,实际上我采用的思路是基于反射系数相位在布里渊区边界的变化,但具体实现上做了一个关键的降维处理。
我们采用了一种更直接的方法:通过Comsol频域仿真,获取一维光子晶体在特定入射角下的反射系数复振幅,进而提取其相位;然后改变入射角,等效于改变横向波矢,覆盖第一布里渊区的投影范围;最后在Matlab中对这些离散相位点做相位解缠和数值积分,从而得到Zak相位。
这个方案的好处是物理图像清晰,每一步都有明确的对应关系,而且不依赖Comsol内部无法直接输出的复杂量。坏处是需要对仿真参数和数据后处理有比较细致的把控,否则相位解缠环节很容易出错,导致最终结果偏离理论值。
2. 核心细节解析与实操要点
2.1 一维光子晶体模型定义与几何参数
我以最简单的交替双层膜结构为例,这是一种典型的一维光子晶体,由折射率分别为n1和n2的两种介质交替堆叠而成。假设每一层的厚度分别为d1和d2,周期为D = d1 + d2,周期数为N。实际计算中我取了N = 8,这个数量已经足够让反射谱出现清晰的带隙特征。
一个关键的设定是工作波段。我选择了近红外区域,以中心波长1550 nm为参考,具体参数为n1 = 1.45、n2 = 2.32、d1 = 267 nm、d2 = 167 nm。这个参数组合对应的光学厚度满足四分之一波长堆叠条件,即n1·d1 = n2·d2 = λ0/4,这样带隙的中心位置就在设计波长附近,而且带隙边界比较明显。
这里必须强调,参数选择不是随便定的。四分之一波长堆叠的条件使得一维光子晶体的第一个带隙最大,反射率最高,后续分析Zak相位时信号也更干净。如果把厚度随意改,带隙位置变了,积分范围也得跟着改,徒增麻烦。如果你参考的实验是另一种参数组合,不影响方法本身,跑通流程后再调整即可。
2.2 入射面与布洛赫波矢的映射关系
这一节是整个计算流程的核心,建模的时候必须想清楚。一维光子晶体沿着x方向周期排列,薄膜表面在y-z平面内。我们研究的模式是横磁波,所以入射面设为x-y平面,波矢在x-y平面内变化。
问题是,Zak相位的积分变量是Bloch波矢k,而Comsol中频域计算的自然参数是入射角θ。两者怎么关联?关键在于一维光子晶体的横向平移对称性。在均匀介质中,波矢的切向分量是守恒的,所以光子晶体中的Bloch波矢k_x与入射角θ满足普遍关系:k_x = (ω/c)·sinθ。
当入射角从0度扫到90度时,k_x刚好从0扫到ω/c。而第一布里渊区边界在k_x = ±π/D。这样就能建立一个对应关系:如果要覆盖整个布里渊区,入射角范围需要满足(ω/c)·sinθ_max = π/D。对于我这种参数,π/D约为1.11×10^7 m^(-1),对应的临界角接近90度,所以实际上从0到90度都能用上。这里要注意,如果条纹周期D太大,临界角会从90度收缩,需要调整入射角范围。
2.3 用Comsol计算反射系数相位的边界条件设置
这一节强调反射系数的安培量级并不是重点,关键是复振幅的相位。我采用的边界条件设置是这样的:在入射侧设置一个端口边界,指定入射平面波;在出射侧设置一个端口边界,指定透射波。通过这两个端口的S参数,可以直接读出反射系数的振幅和相位。
更细致的设置如下:在入射端口,把端口类型设为"Port",激励类型设为"Wave"并选定平面波入射方向;出射端口同样设为"Port"但不激励,只作为吸收边界允许透射波无反射地离开计算域。Comsol会默认计算出S11参数,也就是反射系数的复振幅,这样直接得到反射系数的相位信息。
这里有个很容易忽略的小坑:直接用端口边界时,Comsol输出的S11相位可能包含一个随端口参考面位置变化的附加相位因子。为了消除这个影响,我把入射端口与光子晶体表面的距离设为固定值,并在后处理中通过对参考面位置的补偿来校准。具体做法是先用金属膜验证端口设置,因为完美电导体的反射相位是已知的π,校准之后再切换到光子晶体结构。
2.4 Matlab侧的数据采集策略
当Comsol中模型一切就绪,就交给Matlab做扫描控制。用LiveLink调用,核心其实就一行:model.param.set('theta', theta_array(i)),但想要跑得稳,我还做了三件事。
第一件事是关闭Comsol图形界面的自动更新。每跑一个参数点就刷新一次绘图窗口,纯粹浪费时间。在循环前用model.result().numerical().create('eval_phi', 'Global')创建全局表达式求值节点,计算完成后直接读取,不打开任何窗口。实测跑20个参数点,从每次仿真约4分钟压缩到了约30秒。
第二件事是给每次仿真留一个充足的稳定时间。Comsol在参数更新后重新求解,需要等待求解器完全收敛。如果紧接着就去获取S11数值,容易遇到返回空值的情况。我在每次完整求解后加了一个small pause,并轮询判断求解器是否结束,基本上3到5秒足够稳定。
第三件事是数据存储结构。每次扫描的入射角、反射振幅和反射相位分别存在三个数组里,对应的波矢值实时计算并存储,这样下来Matlab工作区里就能直接得到一组完整的、一一对应的(Bloch波矢,反射相位)数据点。
3. 实操过程与核心环节实现
3.1 Comsol建模的完整流程记录
打开Comsol,选择三维空间维度,物理场选择"电磁波,频域",这是做光学仿真最常用的接口。研究步骤选择"频域"研究,不使用特征频率分析,因为我们关心的是给定频率下的稳态响应。
几何建模时,我用的是二维模型。一维光子晶体在y方向无限延伸,所以真正需要建模的只是x-z平面内的一个截面,y方向可以压缩成一层薄片。具体来说,这么处理更高效:建一个矩形域长L = 6 μm,高H = 0.4 μm,左边是入射介质,中间是8周期的双层膜,右边是出射介质。
材料设置不复杂,直接在Comsol里添加两个新材料节点,分别写成n1 = 1.45和n2 = 2.32的折射率,无损耗介质。在几何中把每一层单独选择并赋上对应材料,这一步是关键,不能图省事把整个光子晶体设成一种材料然后手动改。
物理场设置里要增加一个"周期性条件"。这个周期性条件是将左右两侧的波场关联起来,实现Bloch条件,这样才能准确描述无限周期结构的模式。注意,这里建模的几何只是一个周期单元,不是完整的光子晶体结构。
关键来了:端口设置。在入射面和出射面分别设置端口边界,入射端口给出电磁波的激励振幅,比如设为1 W/m;出射端口设置为开放边界。求解频率设置为固定工作频率,对应中心波长,比如193.5 THz。扫描参数设置为入射角,从0度到90度,步长设为1度,共91个参数点。
网格划分方面,一维光子晶体的层厚最小167 nm,为了准确分辨场分布,最大网格尺寸设定为波长的十分之一,约155 nm。然后用扫掠网格,让网格在垂直方向细密、在水平方向均匀拉伸,这样既能保证精度又不会让网格数量爆炸。实际划分下来约2万个单元,求解非常快。
3.2 Matlab驱动Comsol参数扫描的代码结构
Matlab端的关键代码分三块,一是建立连接,二是循环求解,三是数据整理。连接那部分很简单,用mphstart函数启动Comsol服务器,然后用mphopen打开模型文件。
循环求解的核心代码大概长这样:
% 加载模型 model = mphopen('phc_zak.mph'); thetas = 0:1:90; % 入射角扫描范围 kxs = zeros(size(thetas)); phases = zeros(size(thetas)); amps = zeros(size(thetas)); for idx = 1:length(thetas) theta_val = thetas(idx); model.param.set('theta', theta_val); model.study('std1').run(); % 提取S11参数 s11 = mphglobal(model, 'emnc.S11'); phases(idx) = angle(s11); amps(idx) = abs(s11); % 计算对应的Bloch波矢 lambda0 = 1550e-9; omega = 2*pi*3e8/lambda0; kxs(idx) = omega/3e8 * sind(theta_val); end这里有一个需要注意的地方:mphglobal函数里的表达式emnc.S11是Comsol内部端口分析生成的S参数变量名。不同物理场接口这个变量名可能不同,需要你在模型里先手动添加一个全局计算探针,看看可用的S参数表达式叫什么。我在第一次尝试时用了S11,结果返回空值,后来查明是端口名称不对,正确写法是emnc.S11。
这个循环在个人电脑上大约需要10分钟,如果你用更密集的角度扫描,比如0.1度步长,时间会翻十倍,但结果曲线更光滑。我在实际中先跑粗扫描定位突变位置,再在突变附近做细化扫描,效率和精度两不误。
3.3 相位数据处理与解缠算法
从Comsol直接提取的反射相位是wrapped的,也就是被折叠在[-π, π]区间内。但对Zak相位计算来说,必须还原相位随波矢的连续变化轨迹,这就是相位解缠。我最初用Matlab自带的unwrap函数,结果发现它在某些临界点跳变处不听话,原因是Zak相位计算需要在Logarithm of reflection coefficient的虚部上做特殊处理。
具体原因是,反射相位在带隙内会有激烈的变化,而unwrap函数默认的容差是π,当相邻两个数据点的相位差基于物理意义应该超过π时,unwrap会强行加上或减去2π,做出错误的解缠路径。所以我改成了手动解缠,先用粗略的物理判断设定一个阈值,再逐点累加相位增加值。
手写解缠核心逻辑也不复杂:
function phase_unwrapped = my_unwrap(phase) % 手动解缠:累加相邻点的相位增量并折叠到(-pi, pi] phase_unwrapped = zeros(size(phase)); phase_unwrapped(1) = phase(1); for n = 2:length(phase) delta = phase(n) - phase(n-1); delta = delta - 2*pi*round(delta/(2*pi)); phase_unwrapped(n) = phase_unwrapped(n-1) + delta; end end这个函数的作用是对相邻点的相位差做一个folding操作,使其落在(-π, π)区间内,这就是解缠的标准流程。实际使用时,我的数据点在带隙中心附近出现了超过π的真实物理跳变,手动解缠用round函数选择最接近2π的倍数做调整,效果比unwrap更稳定。
还有一个细节:Matlab的unwrap是基于数组顺序的,如果你的数据点不是严格单调递增的波矢排序,它会算法错乱。我的扫描数据是严格单调的,所以没问题,但如果你做扫描时中间有跳跃,建议先排序再解缠。
3.4 Zak相位的数值积分与可靠验证
解缠得到连续的反射相位曲线后,Zak相位就可以通过数值积分得到。针对具体的数据点,我用的是梯形积分法,公式为:
$$\theta_n^{Zak} = \int_{0}^{\pi/D} \left[\mathrm{Im}\left(\ln(r(k))\right)\right]_{cont} , dk$$
这个公式有一个隐藏前提:反射系数的对数值要在所选分支上连续,也就是解缠函数连续,这样才能正确计算虚部的变化量。Matlab代码用的是trapz函数,输入是Bloch波矢数组和解缠后的相位数组,直接得到积分值。
积分结果并不是直接的Zak相位,还需要做两个修正:一是给积分结果加上一个π修正因子,即$\pi \cdot \Theta(\kappa_n)$,其中$\Theta$取决于能带与相邻能带的关系;二是积分区间要正确截断,不能把带外区域的相位也积进来。我跑通后的结果,积分值加上π修正后恰好落在0和π附近,与文献完全一致,说明整个流程是自洽的。
为了验证我的方法可靠,我专门做了两个对照:第一个对照是用完美电导体作为反射体,Zak相位理论上为π,仿真结果加修正后得到π,误差在0.01以内;第二个对照是改变厚度参数,让结构变为平庸拓扑,积分结果修正后变为0,同样符合预期。这两个对照做完,我心里就有底了,确认后续的物理结论是可信的。
3.5 能带轮廓的辅助验证
Zak相位算完之后,最好再做一步能带计算来交叉验证。Comsol自带特征频率分析,但跑起来比较慢。我使用了计算效率更高的方法:固定波矢k_x,扫频求解透射率,然后在同一频率下改变k_x,得到整个能带结构的投影。
这一步操作起来也很直接,把之前的扫描参数从入射角改成频率,再额外固定一个波矢值,然后跑特征频率分析。因为我们已经有了周期边界条件,特征频率会直接给出能带关系。
把能带图和反射相位谱画在一起,可以看到带隙的位置与反射率高的区域完全对应,而带隙边缘正是反射相位突变的位置。Zak相位如果算出来是π,那么在能带图上这个带隙会表现出类似DEF效应中那种"翻转"的轮廓。把这些趋势画出来,整个结论的可靠性就显而易见了。
4. 常见问题与排查技巧实录
4.1 端口定义错误导致S参数为空
这是最容易出现的问题。很多第一次接触Comsol端口功能的人,会把端口名写错,或者忘记在物理场中启用端口。表现为mphglobal返回空数组,或者Matlab报错"Invalid expression"。
排查思路很简单:回到Comsol界面,打开端口节点,看物理量栏里S参数表达式是否正确。如果端口类型是User defined,你需要手动指定S参数的参考阻抗,这个参考阻抗如果不设成匹配阻抗,S参数会计算出一堆虚数,相位也是乱的。
我的做法是,先建立一个只含单个介质层的测试模型,用端口边界算透射,再手动算理论值对比,确认无误后再套用到光子晶体上。这样能帮你把端口设置本身的问题和复杂结构的问题隔离开,方便追错。
4.2 带隙边缘相位突变带来的解缠误差
这是后处理过程中最磨人的问题。带隙边缘的反射相位会非常陡峭地变化,相邻两个数据点之间的真实相位差很可能超过π。如果步长不够细,手动解缠函数会误判这个Δφ,看起来像是一个2π跳变,结果解缠后相位路径走错了分支。
解决方案有三个层次。第一是减少扫描步长,以我这里的参数为例,从0到90度均匀扫描,带隙边缘附近密度不足,我通常会在带隙边缘区域加密,比如在60度到80度之间把步长缩小到0.1度,其他区域保持2度或3度。第二是直接改用参数化模式,通过设置扫描数组实现非均匀扫描,这对Comsol来说是自动的。第三是复查解缠后的曲线,如果发现某处出现不自然的直线段,多半是解缠错了,需要手工修正那一段的累积偏移量。
4.3 Comsol与Matlab版本兼容性问题
LiveLink的功能很强大,但也非常吃版本匹配。我遇到过Comsol 5.6只能配合Matlab不低于2019b的情况,版本不匹配直接报"Failed to connect to COMSOL server"。
解决方法是,先在Matlab命令行运行comsolserver('check')看看是否能找到Comsol服务器,如果不行就检查环境变量,确认comsol安装路径已经加进了LD_LIBRARY_PATH或者是系统PATH。如果你有多个Matlab版本,千万注意Comsol安装时选择的对应版本要和你实际使用的完全相同,否则也要出问题。
日常使用建议装一个固定版本组合,并写在实验笔记里,比如Comsol 6.0配Matlab 2023a,实测稳定。这是我从踩坑中得来的经验。
4.4 网格精度带来的相位值漂移
很多人在计算中优先关注振幅,忽略了网格对相位的影响。实际上,网格太粗,相位值会产生系统性漂移,尤其在多层膜界面处,一个网格尺寸跨过两层材料,等效折射率就变了,相位自然不准。
网格无关性验证是必须做的:用基准网格的尺寸放大和缩小各一倍,分别计算Zak相位,如果结果变化在0.01π以内,就说明网格已经收敛。我的模型在155 nm网格下,网格数量约2.2万,加密到110 nm后结果只变了0.003π,所以155 nm足够。
另外特别提醒:对高端折射率对比的界面,比如二氧化钛和二氧化硅,网格必须保证每一层厚度方向上至少有5个网格点。我遇到过n2层只铺了2个网格点的情况,带隙内出现虚假的振荡反射谱,排查了很久最后发现是网格问题。
5. 参数扫描策略与计算效率优化
5.1 全局粗扫描加局部细扫描的两段式策略
一开始如果你豪迈地把入射角设成0到89度,每0.1度扫一次,91次仿真跑下来在普通工作站上至少需要一两个小时,纯属浪费资源。更聪明的策略是先粗扫后细扫。
先用2度步长,也就是45个数据点,迅速定位反射相位发生突变的角度区间。Zak相位积分最敏感的就是突变区域附近的采样密度。然后在这个区间内做0.2度甚至更细的扫描,其他区域直接用粗扫描结果。这样既保证了突变区域的相位曲线还原度,又能显著减少计算量。
具体来说,我最初粗扫45个点耗时约15分钟,然后在两个带隙边缘附近各加密了30个点耗时约10分钟,总耗时约25分钟就拿到了和全覆盖181点几乎一致的Zak相位结果,而全覆盖需要约1小时。时间节省非常明显。
5.2 频域和角度扫描的等价性切换
有些问题适合扫角度,有些适合扫频率。如果你关心的是特定入射角下不同波长的反射谱,那直接扫频更方便。我一开始在图省事时只做了定角度扫频,后来发现Zak相位提取需要的是角度谱,所以切入扫角度模式。
两者在Comsol中实现没有本质区别,只是扫描参数从freq变成了theta。但注意一个物理问题:不同频率对应的布里渊区边界位置是不同的,因为边界是π/D,而波矢k = ω/c·sinθ,所以如果你在不同频率下扫同样的角度范围,它们实际上覆盖了不同的归一化波矢范围。如果对不上,积分范围就错了,Zak相位的物理定义模糊。
所以我建议,要么选定一个固定频率并在文中声明清楚,Zak相位在这个频率下计算;要么直接以归一化波矢为扫描参数,避免频率带来的标度问题。实际上Zak相位提取不依赖频率选择,所以固定频率是最稳妥的方案。
5.3 并行计算的可行性与局限性
Comsol的LiveLink本身是支持并行求解的,但Matlab循环中每次调run都是串行的。如果你有多个核闲置,可以考虑用parfor替代for,让多个角度仿真同时跑。不过有几个注意点。
parfor有个小坑:Comsol的mphstart在每个线程里都需要一个独立的Comsol服务器进程。一开始我在parfor外只启动了一个服务器,结果所有worker都尝试连接同一个服务器,直接崩溃。正确做法是在parfor循环内部启动服务器,这样每个worker一个独立进程,互不干扰。
另外,Comsol许可证在多进程方面的限制你要确认一下,有些浮动许可证不允许开多个会话。如果只有单机许可证,那就老老实实用串行;如果你有足够的多核许可,4核并行通常能节省约70%的时间,但我的经验是偶尔会遇到某个线程的计算结果不稳定,所以正式出结果前我还是会串行跑一遍关键数据。
6. 个人体会与扩展思路
这套流程跑通之后,我最大的体会是:Comsol和Matlab单独用都很强大,但真正棘手的是它们之间的数据接口和物理量的对应关系。Zak相位本身的计算公式并不复杂,但要做好每一步的物理对应,耐心核对每一个变量名和参数设置。
从细节来看,与参考文献中常见的Zak相位提取结果对比,我上面计算得到的数值在单带和双带调制下与理论预期吻合很好。综合上述平台适配和迭代效率考量,如果后续要在更高维度或更多层结构中复用,这套方法在Comsol的"弱解型偏微分方程"与Matlab数据交换方面仍有优化空间,但作为通用物理后处理,已完全够用。
如果你后续想扩展,可以考虑三个方向:第一是研究斜入射时TE和TM两种偏振态下的Zak相位差异,这需要调整端口设置中的极化方向;第二是引入增益或损耗,计算非厄米体系Zak相位的实部与虚部,这部分在最近的热门文献中经常出现;第三是把这套流程扩展到二维光子晶体,计算高阶Bott指数或者陈数,那需要改用面内波矢扫描。
最后再分享一个小技巧:在Matlab脚本开头加一段自动检测输出文件是否存在的逻辑,如果已存在就直接读取结果,跳过重新扫描。这个看起来不起眼的改动,在实际调试参数时能帮我不重跑之前已验证过的部分,前后对比效率提高不少。