声明节点
如前所述,节点包括模块和路由,它们的声明、使用方式是类似的,因此放在一起介绍。
声明一个节点,无论是模块还是路由,本质上都是创建一个新的实例。实例的行为和逻辑是由它的类型定义的,如果您更新了类的定义(比如升级模块的扩展包或者修改路由的代码),所有的实例都会发生变化,这保证了代码的协调和一致。
标准语法
不同于 C++ 及其衍生语言中实例化一个类型通常以圆括号结尾包含参数,在 ligral 中,您直接引用类型名字,就创建了它的实例。在下面的例子中,
Step
是一个阶跃信号源模块,
Print
是打印模块,它们所需的参数都是可选的,因此可以直接使用类型名来声明。
Step -> Print;
但是按照这种方法声明实例,从声明所在语句结束起,ligral 就失去了对该实例的引用。也就是说,该实例必须在所在语句中完成所有的端口连接,因为在其他语句您无法调用该实例。如果要实现更复杂的功能,就必须分配一个唯一的 ID。分配 ID 的语法如下所示:
Step[src];
src -> Print;
分配了 ID
src
之后,在第一行仅声明了一个 Step 的实例,直到第二行才调用。如果需要的话,您还可以在其他语句中调用,实现更复杂的逻辑。在 Ligral 中,ID 必须先声明后使用,和信号计算的顺序无关。例如在下面这段代码中,虽然第二行语句会先计算,但是从文法的角度,模块
abs
的声明必须放在第一行。
Abs[abs] -> Print;
Step -> abs;
节点在声明的时候有时需要传入一些参数,传参的格式和 JSON 十分相似,在声明语句之后用花括号包住所有的参数,参数之间用逗号分隔,每个参数都必须显示指定参数名和值(如果是可选参数可以不提供,使用默认值),参数名和值之间使用冒号分隔。比如
Step
模块有两个可选参数,分别是
start
和
level
,制定了阶跃信号的发生时间和水平。如果保持默认水平不变,仅仅改动发生时间为 2 秒,应该写作:
Step{start:2};
如果既要分配 ID,也要传参,分配 ID 应在传参之前。
Step[src]{start:2};
语法糖
Constant 模块
Ligral 的目的之一就是在于减轻建模时的工作量,使仿真模型更加简洁、清晰、直观,因此其支持了许多语法糖,让建模变得更加高效。
首先,模块
Constant
是一个常数信号源,有一个必选参数
value
,其作用是在仿真期间持续发出
value
这一常数。在 v0.2.0以上版本,
value
既可以是一个标量也可以是一个矩阵。例如,如果需要一个值为 1 的常数信号源,可以写作:
Constant{value:1};
但是这么写过于繁琐,ligral 支持之间使用常数本身来答题这一模块,因此,您可以简写成:
1 -> Print;
另外,已经定义好的常量也可以被解析封装成
Constant
模块。
let a = 1;
a -> Print;
Calculator 模块
其次,模块
Calculator
是一个双目运算模块,有一个必选参数
type
,其值可以是
+
、
-
、
*
、
/
、
^
等多种运算符。该模块接收两个输入,计算运算结果然后输出。如果您的仿真模型是
1+1
,那么应该写成(本例连接部分比较复杂,可以参考
节点连接
):
(1, 1) -> Calculator{type:'+'};
为了避免繁琐,ligral 支持直接解析
1+1
这一语句,且还支持括号表达式,运算顺序和
常量声明
章节中提到的一致。
1 + 2 * 3 ^ (4 - 3) -> Print; # 7
从这个例子还能看出,这些双目运算符的运算优先级高于连接符号。此外,ligral 还支持
+
和
-
两个单目运算符,并以补 0 的形式调用
Calculator
模块。
除了常量以外,所有输出端口数目为 1 的节点都可以使用 Calculator 语法糖,如
Step
模块。
Step + 1 -> Print;
Node 模块
尽管“声明 - 引用”体系已经足够强大用以描述复杂的仿真模型,但有些情况下显得违反直觉。例如在一个一阶低通系统中,模型结构为:
┌───┐
┌───────┤ k │◄─────┐
│ └───┘ │
╭───╮ + ▼- ┌─────┐x │
│ r ├───►(x)─────►│ 1/s ├────┘
╰───╯ └─────┘
其中,
r
是输入节点,
k
是常量增益。很显然,积分器的输出需要在积分器的输入之前调用,因此积分器的定义需要出现在前面,代码如下:
r - Integrator[i] * k -> i;
这种书写方式容易让人产生误解,因此 ligral 提供了一个空节点
Node
来代替类似
Integrator
这些具有明显含义的节点实现引用。
Node
节点仅仅原封不动地转发输入到输出,很适合用来做引用。上述代码可以改写为:
r - Node[i] * k -> Integrator -> i;
虽然代码变长了,但是结构却变得更加清晰。在此基础上,ligral 甚至允许在未定义
Node
节点的情况下直接引用,这看似违反了之前所说的“先声明后引用”原则,但是实际上 ligral 只是在第一次识别到未声明的符号时默认解析成
Node
节点。因此,上述代码可以进一步简化为:
r - i * k -> Integrator -> i;
此外,在一些场合可能需要仅声明一个
Node
节点但不引用,可以用
~
符号代替。这意味着,
~
符号可以出现在模块链的任意位置而不影响整个程序,如:
1 -> ~ -> Print; # 1
端口引用
有许多模块有不止一个输入或者输出端口,如果您需要指定调用其中某个端口,您可以用冒号引出端口名。例如,
LogicSwitch
模块具有三个输入端口:
condition
、
first
和
second
,如果需要指定
condition
端口,可以写作:
1 -> LogicSwitch:condition;
在这种场合,通常需要为该模块分配 ID,否则其他端口无法被连接。输入端口的行为如同一个单输入零输出的节点,因此只能有一个信号连接到输入端口,不能有信号从输入端口连接到其他节点。
指定一个输出端口的语法和输入端口完全一致,但是不同的是输出端口的行为却如同一个多输入单输出的节点,其输入端口和原来的节点一致。系统自带的模块没有多输出端口的节点,但单输出节点也可以使用端口引用(虽然看起来有点多余)。以
Gain
节点为例,其有一个输入端口
input
和一个输出端口
output
,在使用时可以这么写:
1 -> Gain{value:10}:output -> Print;
如果某个节点的输出端口需要在多处引用,您也可以为它起个别名,但这个别名也必须是唯一的,因为其相当于分配了一个 ID,或者说事实上,ligral 自动定义了一个
Node
用来被这个输出端口连接,并分配了 ID。输入端口一般不会被多处引用,因为一个输入端口只能连接到一个输出端口,所以端口的别名不支持输入端口。起别名的语法和为节点分配 ID 的语法类似,都是用方括号来起名:
1 -> Gain{value:10}:output[x];
x -> Print;
当然根据上一小节,您也可以写作:
1 -> Gain{value:10}:output -> x;
x -> Print;
但是两者还是有细微的差别,这一点在之后的文档中会详细介绍。