目录

UBC 硅光芯片 CO-OP

UBC Silicon Photonics Biosensor

2022 年 5-8 月,在 UBC 开始了第一份 CO-OP。你说我一个 ME 学生,怎么就拿了 ECE 的 offer 去给 Biosensor 写代码呢?

入职前就给了两大页 Google doc 的参考资料,要么就是论文要么就是公开课。太多了我就看了前两个视频。正式入职以后上来就甩了三篇论文让看,一周后讨论……一篇文献综述过一遍硅光芯片的基本原理背景知识和发展情况,一篇跟我工作有关的曲线拟合和数据处理,一篇完全看不懂的反应速率分析。同时因为要进生物实验室,上了一周实验室安全课。又花了两周过了一遍实验室设备和软件的使用,搭搭环境,演示了一遍完整的实验流程。前后这么一折腾一个多月就过去了。(我还晚入职了半个月……)

第一个任务是给处理实验数据的 Python 脚本搭个 GUI,再做点小优化。正好之前在看 PyQt/PySide,撸个框还不是分分钟的事儿,结果一看代码傻眼了。单文件全局变量一把梭,主要业务逻辑全在一个 600 行大循环里;纯 C 风格的 Python 代码,状态量满天飞;脚本分析双通道数据,方法是复制粘贴……(实际有多通道需求,也想让我做这个“小”优化,之前他们就手动改脚本多跑几遍……)同时业务逻辑上偶尔还有点“小”bug,希望我“顺手”修了。

看了两天试图重构,最终放弃。前后应该有三个人往这屎山上堆过屎,直到现在这任博后才刚刚加上了版本管理……唉,直接单拉个进程重定向输出,强套个 GUI 给领导展示下效果先。

紧接着开始重写。完全理解原来的代码是不可能了,只能照着大致思路重新摸索一遍细节。首先调库曲线拟合找到峰,然后按最近相邻峰值匹配,不出所料,一个噪声就 GG。

看看频谱图,噪声往往在峰值开始大幅移动时发生,有时是噪声,更多是采样频率不够,分不出上下哪个才是了。兵来将挡,水来土掩。缓存下过去数据,给欠采样段加了个预测。博后说,峰值偏移有时候能超出扫描范围,老代码会在靠近扫描边界时跳到另一条线上去,得,咱也照此办理。

大体上效果好了很多,但遇到真“噪声”——微流道里有气泡导致某段时间峰值变得超诡异,还是一样 GG,尤其这噪声发生在跳转阶段时,还容易跳错。博后说老代码也有相同的问题。唉,主逻辑也得重写了。

再看追踪失败的几个功率谱,要么有噪声尖峰,要么是预期出现的峰被分成了对称的两个。我选择拿峰的形状进行匹配。毕竟拟合之后,一个峰不过就是公式里的几个参数,当成高维向量,算个距离再排个序,不就匹配了嘛。至于峰值偏移超出范围,不拿单独一对峰值作为结果,而是拿两个扫描中的所有峰两两匹配成对算平均偏移,再随便挑个初始峰值往上累加,欧了!

现在看来两句话就说清楚的事儿,从刚接手、初步尝试、提出新想法再到初步实现,前后花了三周左右,又额外花了一周套上 GUI,至此算是初步完成,又另花了大半个月分发给组里试用,搭搭 CI,和一些小修小补。不知不觉三个半月就已经过去了一大半。

剩下的时间就要摸鱼得多了,一是前段时间劳累过度,二是工作也比较琐碎提不起太大兴趣:给另一套 Python2 代码升级到了 Python3,重新包了一份仪器驱动。另花了两周准备了两份学生暑研 Presentation,分享了这点儿没啥技术含量的破代码。

从设计模式谈开去

  • 单一职责原则(SRP,Single Responsibility Principle) 一个类应该仅有一个引起它变化的原因。变化的方向隐含着类的责任。
  • 里氏替换原则(LSP,Liskov Substitution Principle) 子类必须能够替换它们的基类 (IS-A)。继承表达类型抽象。
  • 依赖倒置原则(DIP,Dependence Inversion Principle) 高层模块 (稳定) 不应该依赖于低层模块 (变化),二者都应该依赖 于抽象 (稳定) 。抽象 (稳定) 不应该依赖于实现细节 (变化) ,实现细节应该依赖于 抽象 (稳定)。
  • 接口隔离原则(ISP,Interface Segregation Principle) 不应该强迫客户程序依赖它们不用的方法。接口应该小而完备。
  • 迪米特法则(LoD,Law of Demeter) 一个对象应该对其他对象保持最少的了解。
  • 开放封闭原则(OCP,Open Close Principle) 对扩展开放,对更改封闭。类模块应该是可扩展的,但是不可修改。

实习期间重构老代码,看着不忍直视的代码结构,突然理解了所谓“设计模式”云云。

还在更新的陈年老光谱仪

两台同型号的光谱仪,岁数比我还大,有一个激光器插槽和四个传感器插槽。我们的使用场景是用激光器发送波长上的扫描信号,同时用传感器测量芯片的多路输出。因为同时有 C 波段和 O 波段两种待测芯片,买了两台主机和两个激光模块作为两套激光源,切换时只需要调整光纤接法即可。

厂家提供了 DLL 驱动和相关的编程手册,初始化传入仪器地址后,返回文件句柄,作为绝大多数函数的首个参数。2013 年左右,又提供了传感器扩展功能,可以额外添加另一系列的仪器作为传感器,只要把两台仪器的 trigger 相连即可。DLL 升级后,两台仪器可以使用同一套驱动,只需在扫描前向主机注册扩展仪器句柄,随后在主机调用相同的扫描接口函数。但诸如读取传感器实时读数等功能,仍需传入从机句柄。

显然,这种暴露大量底层细节的 C 风格接口完全没有封装,也不面向对象,给程序员带来较大的心智负担。看看还不支持传感器扩展的老代码是怎么做的:

  1. 句柄作为实例属性
  2. 包装了几个会用到的 FFI 接口
  3. 添加了主要接口功能——扫描函数

不错,抽象对于小项目来讲恰到好处。但下一个接手的人是这样支持传感器扩展的:继承原来的仪器类,把所有的属性和方法加个前缀再实现一遍,区别仅仅是传的句柄不一样;对于扫描这种有一定抽象的接口,复制粘贴老实现,然后把所有的仪器调用再追加一份前缀版……

因为多了一套方法和属性,上层调用者想拿到全部数据,也需要改动调用方式。然后这位接手的大神把所有的调用也复制粘贴了一份前缀版,直接导致单主机无法使用,必须外接扩展传感器使用……

更多雷人之处暂且不表。虽然没有版本控制,但看着代码里各种带问号的注释、重复的前缀命名,不难想象出这份代码是怎样从一份小而美的项目,被各路大神堆成强耦合屎山的。想要在界面里加个功能,简直无处落脚。

一切皆黑箱

一直以来喜欢理工科的原因无他:某处原理即使再不明白,也能抽象成黑箱,只要强行记住输入输出(的形式 不是背真值表 ),并不影响整个逻辑链条。

我的初中物理老师说:“学物理要全、要联系”1。我想任意理工科都是如此。然而这种联系并不体现为一张完全图,而是更凝练、条分缕析的一颗树。中学时老师常让画思维导图,往往画成树,从没见画成图的。我想 LRU 之所以难写,正是因为要同时维护哈希表和链表两种联系。

软工更是如此,API caller 从不关心库怎么实现。当设计自己的系统层次时,不仅要把被调模块当作黑箱,更要尽可能地暴露最少的细节。因此我们以稳定的接口作为约定,而不关心具体实现。如同 Python 中的鸭子类型,C++ 中的虚函数,Rust 中的 trait……

与人沟通也有几分黑箱的意思。妈妈让你洗碗,并不是说不用洗筷子,她调用的是名为“洗碗()”的接口,而不是你“洗(碗)”的实现,或者说,她压根不关心你有没有这个实现,而只是在遵循调用约定,反倒是你暴露了太多实现细节,误以为她在事无巨细地干涉细节;“我上级的上级不是我的上级”,则像是私有属性和私有方法,杜绝了微操“机枪阵地向东二百米”这种事。

然而,真实世界要复杂得多。对于复杂系统,与其构造一个多输入多输出的复杂黑箱来描述,不如看作几个小黑箱的继承/多态与组合。继承/多态自不必说,这里聊聊组合。

代数数据类型Algebraic Data Type, ADT,更具体的,积类型与和类型,是 Rust2 里我很喜欢的一对概念,简单讲就是“既有又有”和“某种之一”。想象一台微波炉,既要选一档火力,又要打开开关,可以说,是两个和类型的积。

再举个例子,多项式。初中数学研究一次函数,记住了斜率、截距;研究二次函数,把求根公式背得滚瓜烂熟。却不再研究三次函数了,老师说,到了大学,我们直接研究 N 阶多项式。

第一次见到用线性代数表示多项式的时候,不禁赞叹其精巧。描述任意一个 N 阶多项式,其实只需要 N 个系数而已。

知乎上看到过一点信息论的皮毛后,便喜欢用信息的观点考虑问题,譬如数字电路和状态机。多项式写出来复杂,其实仅包含了 N 个数的信息。很快,我们的计算从加减乘除,变为了指对幂三反,而到了大学,这类函数又有了新的代号——算子。

较理论的东西我不懂,绝大部分工科应用,不过都是算子的组合。听说控制论的前沿研究,都在用群论的语言描述,不知群论是怎样一种算子,抑或是更高于算子的一种抽象?写到这里,又不禁对数学更敬畏了几分。

工程师——黑箱的有机组合

如果上述之黑箱法可以更好地理解世界,那黑箱的组合,或许是一种改造世界的途径。面向对象、设计模式——软件工程几十年来的实践总结是最好的证明。

北京高考题是出了名的简单直接,江苏同学给我看过他们的卷子,简单看下来,也不过是多个知识点的组合,换句话说,一道题考了北京好几道题的内容。而实际的工程问题,往往都是多个单一问题的组合,鲜有能只套一个模型就完美解决的。

虽然还没学过控制论,但据说传递函数、系统框图,也是如此般将复杂系统化作细粒度的模块处理。


  1. 然而他第一节课竟然不自我介绍,因此得外号“全联系” ↩︎

  2. ADT 的概念不仅限于 Rust ↩︎