Ligral

声明节点

如前所述,节点包括模块和路由,它们的声明、使用方式是类似的,因此放在一起介绍。

声明一个节点,无论是模块还是路由,本质上都是创建一个新的实例。实例的行为和逻辑是由它的类型定义的,如果您更新了类的定义(比如升级模块的扩展包或者修改路由的代码),所有的实例都会发生变化,这保证了代码的协调和一致。

标准语法

不同于 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;

但是两者还是有细微的差别,这一点在之后的文档中会详细介绍。