2024年4月

转载请注明出处:

在Go语言中,结构体(struct)是一种复合数据类型,它允许你将多个不同类型的字段组合成一个单一的类型。结构体为数据的封装和抽象提供了便利,使得数据组织更加清晰和易于管理。

结构体的定义

结构体的定义使用
type
关键字和
struct
类型,然后列出结构体的字段名和类型。

type Person struct{  
Name
stringAgeintEmailstring}

在这个例子中,定义了一个名为
Person
的结构体,它有三个字段:
Name
(字符串类型),
Age
(整型),和
Email
(字符串类型)。

结构体的实例化

使用结构体类型来创建结构体变量,这通常被称为结构体的实例化。

//使用字面量创建结构体实例
p1 :=Person{
Name:
"Alice",
Age:
30,
Email:
"alice@example.com",
}
//使用字段名来设置值
p2 :=Person{
Name:
"Bob",
Age:
25,
}
p2.Email
= "bob@example.com"

//使用结构体类型创建变量,然后分别设置字段
varp3 Person
p3.Name
= "Charlie"p3.Age= 35p3.Email= "charlie@example.com"

结构体的集合

结构体集合通常指的是一个包含多个结构体实例的切片(slice)。

//定义一个Person类型的切片
varpeople []Person//向切片中添加结构体实例
people = append(people, p1)
people
= append(people, p2)
people
= append(people, p3)//或者直接初始化一个包含多个实例的切片
people =[]Person{
{Name:
"Alice", Age: 30, Email: "alice@example.com"},
{Name:
"Bob", Age: 25, Email: "bob@example.com"},
{Name:
"Charlie", Age: 35, Email: "charlie@example.com"},
}

结构体的遍历

使用
for
循环来遍历结构体切片。

//遍历结构体切片
for _, person := rangepeople {
fmt.Printf(
"Name: %s, Age: %d, Email: %s\n", person.Name, person.Age, person.Email)
}

在这个循环中,
range
关键字用于迭代
people
切片中的每一个元素。
_
是一个空白标识符,用于忽略索引值,只获取切片中的元素。
person
是每次迭代时当前元素的副本,可以访问它的字段。

结构体方法和接收器

在Go中,还可以为结构体定义方法,方法是一种特殊的函数,它关联到一个特定的类型上。方法的第一个参数是接收器(receiver),它指定了方法关联的类型。

func(p Person) SayHello() {  
fmt.Printf(
"Hello, my name is %s and I'm %d years old.\n", p.Name, p.Age)
}
//使用结构体方法
p1.SayHello() //输出: Hello, my name is Alice and I'm 30 years old.

在上面的代码中,定义了一个名为
SayHello
的方法,它接收一个
Person
类型的接收器
p
。然后可以像调用普通函数一样调用这个方法,但是需要使用结构体实例来调用它。

Kalman滤波器的原理与实现

卡尔曼滤波器(Kalman Filter)是一个十分强大滤波器,虽然叫做滤波器,卡尔曼滤波器其实可以起到到两个作用,即预测与更新,这与我们在其运行时所关注的环节有关。当我们关注预测状态量这一步时,我们可以通过卡尔曼滤波器获取状态量的超前预测值,预测的值取决于滤波对象的运动建模。而当我们关注更新来获取最优估计状态时,我们关心的是如何通过
卡尔曼增益
以及
测量量

估计量
来获取当前的最优估计状态,这时卡尔曼滤波就像一个正常的滤波器。本篇是看完Dr_Can博士对于卡尔曼滤波的讲解后的小笔记。
链接在这
Dr_Can的卡尔曼滤波器讲解

一些参数明确

在解释卡尔曼滤波之前,需要先明确一些参数,如下

\(x_k\)

\(k\)
时刻的状态量的真实值
\(\\\)
\(\hat{x}_k^-\)

\(k\)
时刻的状态量的先验预测值
\(\\\)
\(\hat{x}_{k-1}\)

\(k-1\)
时刻的状态量的最优后验估计值
\(\\\)
\(z_k\)

\(k\)
时刻的测量量
\(\\\)
\(u\)
为外部输入的控制量
\(\\\)
\(B\)
为控制量矩阵
\(\\\)
\(A\)
为状态转移矩阵
\(\\\)
\(P^-\)
为误差协方差矩阵的先验预测值
\(\\\)
\(P\)
为误差协方差矩阵
\(\\\)
\(H\)
为观测转移矩阵
\(\\\)
\(Q\)
为状态噪声协方差矩阵
\(\\\)
\(R\)
为测量噪声协方差矩阵
\(\\\)
\(K_k\)
为卡尔曼增益
\(\\\)

例子引出

给出一个匀速运动模型的例子来引出卡尔曼滤波。这也是一个经典的例子。
image

给出一个做匀速直线运动的小球,其速度为
\(V\)
,其相对于某坐标系的位置为
\(x\)
,根据运动学相关的知识,可以写出小球的运动方程
\(x_{t} = x_{t-1} + V_x * t\)
。假设小球不仅在
\(x\)
方向上有运动,在
\(y\)
方向上也有同样的匀速直线运动,其
\(y\)
方向的运动方程为
\(y_{t} = y_{t-1} + V_y * t\)
。即我们现在有了描述小球运动的两个方程,再加上速度的两个方程
\(V_{xt} = V_{xt-1}\)
以及
\(V_{yt} = V_{yt - 1}\)
。我们称物体的以上的变量为物体的状态量,也就是我们想要获得一些量。

\[x_{t} = x_{t-1} + V_{xt-1} t \\
y_{t} = y_{t-1} + V_{yt-1} t \\
V_{xt} = V_{xt-1} \\
V_{yt} = V_{yt - 1}
\]

不妨写为矩阵相乘的形式

\[\begin{bmatrix}
x_t \\
y_t \\
V_{xt} \\
V_{yt}
\end{bmatrix} =
\begin{bmatrix}
1&0&t&0 \\
0&1&0&t \\
0&0&1&0 \\
0&0&0&1
\end{bmatrix}
\begin{bmatrix}
x_{t-1} \\
y_{t-1} \\
V_{xt-1} \\
V_{yt-1}
\end{bmatrix}
\]

倘若假设采样时间为1即
\(t = 1\)
,可以写成

\[\begin{bmatrix}
x_t \\
y_t \\
V_{xt} \\
V_{yt}
\end{bmatrix} =
\begin{bmatrix}
1&0&1&0 \\
0&1&0&1 \\
0&0&1&0 \\
0&0&0&1
\end{bmatrix}
\begin{bmatrix}
x_{t-1} \\
y_{t-1} \\
V_{xt-1} \\
V_{yt-1}
\end{bmatrix}
\]

可以看出,
\(t\)
时刻物体的状态量可以由
\(t-1\)
时刻物体的状态量来推算出来。此时我们将上式记为

\[x_{t} = Ax_{t-1}
\]

显然有时物体并不会一直线性运动,假设此时物体被外部因素赋予(输入)一个加速度
\(a\)
,则物体此时的运动方程变为

\[x_{t} = x_{t-1} + V_{xt-1} t + \frac{1}{2}at^2\\
V_{xt} = V_{xt-1} + at
\]

则矩阵形式的方程变为

\[\begin{bmatrix}
x_t \\
y_t \\
V_{xt} \\
V_{yt}
\end{bmatrix} =
\begin{bmatrix}
1&0&t&0 \\
0&1&0&t \\
0&0&1&0 \\
0&0&0&1
\end{bmatrix}
\begin{bmatrix}
x_{t-1} \\
y_{t-1} \\
V_{xt-1} \\
V_{yt-1}
\end{bmatrix} +
\begin{bmatrix}
0&\frac{1}{2}t^2 \\
0&\frac{1}{2}t^2 \\
0&t\\
0&t
\end{bmatrix}
\begin{bmatrix}
a_x \\
a_y
\end{bmatrix}
\]

不妨记为

\[x_{t} = Ax_{t-1} + Bu
\]

这样我们就得出了如何使用前一时刻或者说前一次的状态量来获得预测当前的状态量。不难看出,预测的准不准,取决于对观测物体运动建模的准确性。

倘若模型不准确,根据该模型推理出的数据是有噪声干扰和误差的,我们将这个噪声干扰叫做
\(w_i\)
。并假设噪声的概率分布
符合期望为0的正态分布
(这是卡尔曼滤波的重要假设之一),即
\(P(w_i)\sim N(0,Q)\)
。加上这个误差后的公式为

\[x_{t} = Ax_{t-1} + Bu + w_i
\]

需要注意的是
\(w_i\)
也为一个矩阵,同理
\(Q\)
也为一个协方差矩阵,这是一个多元正态分布。

此时我们再来考虑更多一种情况。倘若我们不仅可以通过模型对状态量进行预测得出当前时刻的状态量,同时还可以通过一些测量仪器来获得物体的当前状态的测量值。

在这个例子中可以认为有一个雷达来检测物体每一时刻的位置信息以及速度信息。这些测量值统称为
\(z_k\)
,为一个矩阵。注意测量量的维度并不要求与状态量相同。考虑到有时测量量与状态量可能需要些许转换,如测量电阻时我们总是使用电压处以电流(
\(R = \frac{U}{I}\)
)的转换,我们将这种转换关系使用一个矩阵
\(H\)
表示,
\(H\)
就表示状态量可以通过某个公式或者关系转换到测量量。在上面的例子中,
\(H\)
为单位矩阵。综上,可以得出以下式子。

\[z_k = H x_k
\]

当然,考虑到测量仪器测量时的各种噪声以及误差,我们将其记为
\(v_i\)
,并假设其概率分布也满足正态分布,即
\(P(v_i)\sim N(0,R)\)

最终得出

\[z_k = Hx_k + v_i
\]

最后,我们好像得出了观测物体的两个值,一个是通过不太精确的模型得出的预测值,另一个是通过不太准确的仪器测出的当前的测量值。我们所有的信息只有这两个值。那么如何在一些合理的假设下,通过这两个不太准确的值,获得最接近物体真实值的最优解呢?这便是
这便是卡尔曼滤波所要做的事情
,通过两个不准确值来估计出最接近真实值的最优解。

写在题外:这两个公式实质上的严格数学推导是由现代控制理论里的状态空间表达式的离散形式得出的,想要了解更多的推导请看
Dr_Can的卡尔曼滤波器讲解
,讲的非常详细易懂!

卡尔曼滤波简单推导

下面对卡尔曼滤波的一些思想和公式进行简单的说明

数据融合

考虑最简单的均值滤波,对于物体的某一属性(如上述的位置
\(x\)
)我们已经有
\(k-1\)
个已经测量到的值,以及当前第
\(k\)
次的测量值。为了利用当前这些数据获取接近真实值的值,我们最容易想到的就是均值滤波,也就是从1到
\(k\)
的测量取均值。

\[\hat{x_k} = \frac{1}{k}\sum_{i = 1}^kx_i
\]

不妨将其做以下变换

\[\hat{x_k} = \frac{1}{k}(x_1 + ... + x_{k-1} + x_k) \\
\hat{x_k} = \frac{1}{k}\sum_{i=1}^{k-1}x_i + \frac{1}{k}x_{k}\\
\hat{x_k} = \frac{k-1}{k}\frac{1}{k-1}\sum_{i=1}^{k-1}x_i + \frac{1}{k}x_{k} \\
记\frac{1}{k-1}\sum_{i=1}^{k-1}x_i = \hat{x_{k-1}} \\
\hat{x_k} = \frac{k-1}{k} \hat{x_{k-1}} + \frac{1}{k}x_k = \hat{x_{k-1}} + \frac{1}{k}(\hat{x_{k-1}} - x_k)
\]

最终我们得出了这个式子

\[\hat{x_k} = \hat{x_{k-1}} + \frac{1}{k}(x_k - \hat{x_{k-1}})
\]

不难看出,当前
\(k\)
时刻的估计值由
\(x_{k-1}\)
时刻的估计值
\(\hat{x_{k-1}}\)
以及
\(k\)
时刻的测量值
\(x_k\)
来共同决定,而
\(\frac{1}{k}\)
这个系数决定了这两者的占比。我们不妨令
\(\frac{1}{k} = G\)
,这里姑且称其为卡尔曼增益。且
\(\hat{x_{k-1}}\)
可以视为基于前
\(k-1\)
次的数据对第
\(k\)
次的数据的一个预测或者说估计。不妨即为
\(\hat{x_k}^-\)

利用这个思想,对于小球的例子中,
\(x_{k}\)
时刻的预测值可以由运动方程给出

\[\hat{x_k}^- = A\hat{x_{k-1}} + Bu_{k-1}
\]


\(k\)
时刻的测量量满足以下

\[z_k = Hx_k \\
x_k = H^-z_k
\]

利用上述思想,得出
\(k\)
时刻的估计值为

\[\hat{x_k} = \hat{x_k}^- + G(x_k -\hat{x_k}^- ) = \hat{x_k}^- + G( H^-z_k - \hat{x_k}^-)
\]

不妨假设
\(G = K_kH\)
,带入得出

\[\hat{x_k} = \hat{x_k}^- + K_k(z_k - H\hat{x_k}^-)
\]

这便是卡尔曼滤波的核心公式之一,我们使用这个公式来估计当前状态量的最优值。至于为何为最优,后续会给出简单说明。

协方差矩阵

这部分是概率统计的知识。对于随机变量
\(X\)
,我们可以用期望
\(E(x)\)
来描述它的平均水平或者一个趋势,同时可以使用方差
\(D(x)\)
或者
\(Var(x)\)
来描述其波动情况。同时我们定义这样一个式子

\[Cov(X,Y) = E((X - E(X))(Y - E(Y)))
\]


\(Cov(X,Y)\)
为两个随机变量的协方差。不难看出,协方差描述了两个变量之间的相关程度,具体可以看这个
通俗理解协方差
。当两个变量相互独立时,其协方差为0,说明彼此毫无关系,互不影响。

对于两个随机变量
\(X\)

\(Y\)
,可以给出一个矩阵来描述其各个变量之间的关系

\[P = \begin{bmatrix}
\sigma_{xx}&\sigma_{xy} \\
\sigma_{yx}&\sigma_{yy}
\end{bmatrix}
\]

这样使用协方差矩阵就可以记录出2个以及多个随机变量之间的关系。

对于
\(e = [e_1\quad e_2]\)
假设随机变量
\(e_1,e_2\)
符合正态分布
\((0,\sigma)\)
(基本上卡尔曼滤波就是建立在所有误差都是正态分布这个假设上的),其各个元素协方差矩阵可以这样求

\[E[e *e^T]
\]

展开即为

\[\begin{bmatrix}
E(e_1e_1)&E(e_1e_2) \\
E(e_2e_1)&E(e_2e_2)
\end{bmatrix}
\]

已知方差的计算式有
\(D(x) = E(x^2) - E^2(x)\)
,且我们已知符合期望为0的正态分布,则
\(E(x) = 0\)
,故有
\(D(x) = E(x^2)\)
,同理对于协方差的计算式
\(Cov(X,Y) = E(XY) - E(X)E(Y)\)
,应用如上条件变为
\(Cov(X,Y) = E(XY)\)
,这两个公式应用于上式有

\[\begin{bmatrix}
E(e_1^2)&E(e_1e_2) \\
E(e_2e_1)&E(e_2^2)
\end{bmatrix}
=
\begin{bmatrix}
\sigma_{e_1e_1}&\sigma_{e_1e_2} \\
\sigma_{e_2e_1}&\sigma_{e_2e_2}
\end{bmatrix}
\]

这个公式在推导卡尔曼增益的计算式时会用到。

卡尔曼公式简单推导

先给出卡尔曼滤波的五个核心公式,然后对其做一些说明

\[\hat{x_k}^- = A\hat{x_{k-1}} + Bu_{k-1}\qquad [1] \\
P_k^- = AP_{k-1}A^T + Q \qquad[2]\\
K_k = P_k^-H^T/(HP^-_kH^T + R) \qquad [3]\\
\hat{x_k} = \hat{x_k}^- + K_k(z_k - H\hat{x_k}^-) \qquad [4] \\
P_k = P_k^- - K_k HP_k^-或者(I - K_kH)P_k^- \qquad [5]
\]

[1]式

\[\hat{x_k}^- = A\hat{x_{k-1}} + Bu_{k-1}
\]

对于
1
式,我们将上一部分推断的公式写到这里

\[x_{k} = Ax_{k-1} + Bu_{k-1} + w_i
\]

估计时,误差已经融入估计的计算,也就是误差与噪声是包含在模型本身中的,无法像上面一样直接提出来一个
\(w_i\)
,因此我们将这个带有误差与噪声的预测值记为
\(\hat{x_k}^-\)
,代表着状态量的先验预测值。这样我们就得出来第一个公式

\[\hat{x_k} = A\hat{x_{k-1}} + Bu_{k-1}
\]

[4]式

\[\hat{x_k} = \hat{x_k}^- + K_k(z_k - H\hat{x_k}^-)
\]

对于
4
式,我们可以看到出现了当前的最优估计值
\(\hat{x_{k}}\)
以及先验预测值
\(\hat{x_{k}}^-\)
和测量量
\(z_k\)
。还有一个新的量
\(K_k\)
,我们称之为
卡尔曼增益
。卡尔曼增益是卡尔曼滤波中最为灵魂最为重要的一个量。

不难看出,当
\(K_k = 0\)
时(实际上是一个全0矩阵),
\(x\)

\(k\)
时刻的最优估计值
\(\hat{x_k}\)
就为
\(k\)
时刻的先验预测值
\(\hat{x_{k}}^-\)
,当
\(K_k = H\)
时,
\(\hat{x_k}\)
就为测量值
\(H^-z_k\)
(这是由测量量
\(z_k = Hx_k\)
两边同乘
\(H^-\)

\(x_k = H^-z
_k\)

)。因此
\(K_k\)
这个参数决定了测量量与预测量对于最优估计值的贡献程度。

利用上面中数据融合的思想可以知道,为了求出当前状态下最优的估计值,我们唯一不知道的量就是
\(K_k\)
。而衡量
最优
的条件是什么呢?显然在概率统计中,方差越小,数据围绕真实值越集中。因此我们此时的目标就是寻找一个
\(K_k\)
的计算式,这样的
\(K_k\)
使得
\(
x_k -\hat{x_k}\)

的方差(多元数据使用协方差矩阵的迹)最小。

[3]式

\[K_k = P_k^-H^T/(HP^-_kH^T + R)
\]

再讲这个式子之前,我们不妨给出一个真实状态量与后验最优估计量之间的误差的量化。

\[e_k = x_k - \hat{x_k}
\]

同理,假设
\(e_k\)
的误差也符合正态分布
\(P(e_k)\sim{N(0,P)}\)
。与噪声的方差一样,
\(P\)
也是一个协方差矩阵,反映了
\(e_k\)
的各个状态之间的关系以及误差。以匀速运动的物体为例,我们仅关注其位置为状态量。

\[P =
\begin{bmatrix}
\sigma_{xx} & \sigma_{xy} \\
\sigma_{xy} & \sigma_{yy}
\end{bmatrix}
\]

因此当前的目的就是寻求一个
\(K_k\)
,使得
\(e_k\)
的误差最小,也就是协方差矩阵的迹最小(迹上的元素描述的是各个变量相对于其真实值的误差,而其他元素描述的是各个变量之间的相关性)。我们依旧认为
\(e_k\)
符合正态分布
\((0,P)\)
,由上述协方差矩阵的计算式可知

\[P = E(e_ke_k^T)\\
\]

带入
\(e_k\)

\[P = E((x_k - \hat{x_k})(x_k - \hat{x_k})^T)\\
\]

又已知
\(\hat{x_k}\)
的表达式为

\[\hat{x_k} = \hat{x_k}^- + K_k(z_k - H\hat{x_k}^-)
\]

将其带入计算P的式子,这样
\(K_k\)
就引入进来了。接着只需要应用一些线性代数的技巧化简即可。最后对得到的式子求导取极值可以得到
\(K_k\)
的计算式

\[K_k = P_k^-H^T/(HP^-_kH^T + R)
\]

其中

\[P_k^- = E(e_k^-{e_k^-}^T)
\]

[2]式

对于[2]式,是由
\(k-1\)
次的误差协方差矩阵推算出
\(k\)
次的先验误差协方差矩阵的过程。使用了计算公式
\(D(AX) = AD(X)A^T\)
,外加上噪声矩阵Q的测量噪声,可以得出[2]式

\[P_k^- = AP_{k-1}A^T + Q
\]

这是很不严谨的推理,算不上推导,推导请看
Dr_Can的卡尔曼滤波讲解

[5]式

最后,我们要更新误差协方差矩阵,得出根据
\(\hat{x_k}\)
计算出的最新的误差协方差矩阵。直接使用公式

\[P = E((x_k - \hat{x_k})(x_k - \hat{x_k})^T)
\]

带入求出表达式即可。最终得出

\[P_k = P_k^- - K_k HP_k^-或者(I - K_kH)P_k^-
\]

综上,[1][2]式用来根据模型进行当前状态的预测,[3][4][5]式用来对当前状态进行最优估计,这两部分不断循环执行,一个卡尔曼滤波器就开始工作了。

相对于简单的均值滤波,我们可以看到卡尔曼滤波并不太依赖于之前的一些数据与状态,本次的更新仅取决于上一次的数据,而且可以取得不错的效果。而均值的话非常依赖之前的样本数量,样本数量少的话会导致本次的估计不太准确

至此,卡尔曼滤波的黄金五式就解释完啦,知识水平有限,讲的很粗糙,有错误的话记得提醒我。

简单扩展Kalman滤波器

显然,kalman滤波器的预测的第一个式子

\[\hat{x_k}^- = A\hat{x_{k-1}} + Bu_{k-1}
\]

注定了卡尔曼滤波只能使用与线性系统,且系统的各种误差与噪声均符合期望为0的正态分布。那么,对于非线性系统,怎么才可以使用卡尔曼滤波呢?答案是线性化。

对于一个使用非线性模型描述的系统(即包含
\(sinx,cosx,e^x\)
),我们可以通过泰勒展开的方式将其线性化。通过非线性函数在上一次最优估计
\(\hat{x_{k-1}}\)
处的一阶泰勒展开,将其化为多项式的形式。对于一些矩阵如A,B,H等,可以利用矩阵求导的知识将其化为对应的雅可比矩阵来求解。

也就是说扩展卡尔曼滤波与卡尔曼滤波的不同之处仅在于预测这一步中的模型是非线性系统线性化后的模型,其他的步骤与普通而卡尔曼滤波一模一样。

c++实现

下面给出一个用Eigen库以及模板写的卡尔曼滤波,其中模板参数1为状态量的维度,2为测量量的维度

kalman.h

#ifndef _KALMAN_H_
#define _KALMAN_H_
#include <Eigen/Core>
#include <Eigen/Dense>
/*
 * Kalman滤波模板类,不考虑控制量B以及u
 * 模板dimx = 状态量维度,dimz = 测量量维度
 */
template <int dimx, int dimz>
class Kalman
{
    /*
     * 常用矩阵定义
     */
public:
    using Vec_x = Eigen::Matrix<double, dimx, 1>;
    using Vec_z = Eigen::Matrix<double, dimz, 1>;
    using Mat_xx = Eigen::Matrix<double, dimx, dimx>;
    using Mat_zx = Eigen::Matrix<double, dimz, dimx>;
    using Mat_xz = Eigen::Matrix<double, dimx, dimz>;
    using Mat_zz = Eigen::Matrix<double, dimz, dimz>;

private:
    Vec_x x;  // 状态量,dimx * 1 维度列向量
    Vec_z z;  // 测量量,dimz * 1 维度列向量
    Mat_xx A; // 状态转移矩阵, dimx * dimx 维度矩阵
    Mat_zx H; // 观测转移矩阵, dimz * dimx 维度矩阵
    Mat_xx P; // 状态量的误差协方差矩阵, dimx * dimx 维度矩阵
    Mat_xx Q; // 状态噪声协方差矩阵, dimx * dimx 维度矩阵
    Mat_zz R; // 测量噪声协方差矩阵, dimz * dimz 维度矩阵
    Mat_xz K; // 卡尔曼增益, dimx * dimz 维度矩阵
    Mat_xx I; // 单位矩阵, dimx * dimx 维度矩阵

public:
    Kalman();
    ~Kalman();
    void Init(Vec_x _x, Mat_xx _P, Mat_xx A_, Mat_zx H_, Mat_xx Q_, Mat_zz R_);
    Vec_x Predict();
    Vec_x Predict(Mat_xx B, Vec_x u);
    void Update(Vec_z _z);
    Vec_x Get_Best_x();
};

template class Kalman<2, 2>;
template class Kalman<4, 2>;
template class Kalman<4, 4>;
#endif

kalman.cpp

#include "Kalman.h"
#include <unistd.h>
#include <iostream>
using namespace std;
template <int dimx, int dimz>
Kalman<dimx, dimz>::Kalman()
{
}

template <int dimx, int dimz>
Kalman<dimx, dimz>::~Kalman()
{
}

template <int dimx, int dimz>
void Kalman<dimx, dimz>::Init(Vec_x _x, Mat_xx _P, Mat_xx _A, Mat_zx _H, Mat_xx _Q, Mat_zz _R)
{
    this->x = _x;
    this->P = _P;
    this->A = _A;
    this->H = _H;
    this->Q = _Q;
    this->R = _R;
    this->I.setIdentity();
}

/*
 * 这里的Kalman<dimx, dimz>::Vec_x是整一个返回类型,因为Vec_x是在类内定义的类型
 */
template <int dimx, int dimz>
typename Kalman<dimx, dimz>::Vec_x Kalman<dimx, dimz>::Predict()
{
    x = A * x;                     // 状态预测估计,不带输入
    P = A * P * A.transpose() + Q; // 预测估计误差协方差矩阵
    return x;
}

template <int dimx, int dimz>
typename Kalman<dimx, dimz>::Vec_x Kalman<dimx, dimz>::Predict(Mat_xx B, Vec_x u)
{
    x = A * x + B * u; // 状态预测估计,带输入
    P = A * P * A.transpose() + Q; // 预测估计误差协方差矩阵
    return x;
}

template <int dimx, int dimz>
void Kalman<dimx, dimz>::Update(Vec_z _z)
{
    K = P * H.transpose() * (H * P * H.transpose() + R).inverse(); // 计算卡尔曼增益
    x = x + K * (_z - H * x);                                      // 更新最优状态值
    P = (I - K * H) * P;
}

template <int dimx, int dimz>
typename Kalman<dimx, dimz>::Vec_x Kalman<dimx, dimz>::Get_Best_x()
{
    return x;
}

在Keycloak中实现授权,首先需要了解与授权相关的一些概念。授权,简单地说就是某个(些)
用户
或者某个(些)
用户组

Policy
),是否具有对某个
资源

Resource
)具有某种
操作

Scope
)的
权限

Permission
)。所以,授权是一种权限管理,它建立在认证的基础上:用户首先要完成认证(Authentication),才能谈授权(Authorization)。在讨论认证与授权的文章或论坛里,往往用Authn代表认证(Authentication),而用Authz代表授权(Authorization)。

Keycloak中的授权模型

在上面这段描述中,我已经将几个重要的概念用黑体字标注了。或许会有这样的疑问:用户/用户组不应该是User/Group吗?在谈授权的时候,至少也应该是角色(Role)吧,比如我们熟悉的基于角色的访问控制(RBAC),里面就是角色,怎么会是策略(Policy)呢?在回答这个问题前,还是先看一下Keycloak中的授权模型:

这个模型中,包含了几个重要的概念:

  1. 资源(
    Resource
    ):资源是应用程序中能够被访问的对象。假设有个有关天气预报的API,它的URL是http://localhost:5678/WeatherForecast,那么,在这台资源服务器上,URI /WeatherForecast就是一个资源的地址,它表示一个跟天气预报相关的API端点资源
  2. 操作(
    Scope
    ):其实Scope并不翻译为“操作”,这里我使用“操作”来表示Scope,是因为在授权的场景中,Scope就是定义针对资源的一些“操作”。比如:对于上面的天气预报API资源,我们可以有获取资源的操作,也可以有更新资源的操作(比如,让气象员根据其它科学数据来调整某地的天气预报)。于是,在定义Scope的时候,可以用“weatherforecast.read”、“weatherforecast.update”这样的名字来命名
  3. 对于某个资源,它可以声明自己所需要的操作,例如,在RESTful API中,/WeatherForecast这个API可以有读取(read/HTTP GET)的操作,也可以有更新(update/HTTP PATCH)的操作,那么,就可以在这个API资源上声明weatherforecast.read和weatherforecast.update这两个Scope
  4. 一个用户组(
    Group
    )可以包含多个子组,一个组下可以有多个用户(
    User
    ),一个用户又可以属于多个用户组。对于一个用户或者一个组而言,它可以扮演多种角色(
    Role
    ),而一个角色又可以被赋予多个用户或者多个用户组,这些都是耳熟能详的RBAC授权的基本概念,就不多说明了
  5. 策略(
    Policy
    )可以理解为满足某种条件的资源访问者(可以是用户或用户组),所以,Policy定义的是
    条件:
    角色就是一种条件,表示“被赋予某种角色”的条件。基于角色的策略实现的访问控制,就是RBAC。当然条件不仅仅只有角色,用户满足某个条件也可以成为一种策略,比如要求某个用户年龄大于18岁。除此之外,策略是可以被聚合的,聚合策略的投票结果(允许还是拒绝),取决于被聚合的策略以及投票的方式(是所有被聚合策略都允许,结果才被允许,还是只要有一个投票为“允许”,整个聚合策略的结果就是“允许”),比如要求用户是年龄大于18岁(User Policy)的系统管理员(Role Policy)。上图中只简单列了几个继承于Policy的子类用以示意,Keycloak所支持的策略类型不止这些
  6. 权限(
    Permission
    )是资源(Resource)或者操作(Scope)与策略(Policy)之间的关联关系。在Keycloak中,权限分为两种:基于资源的权限和基于操作的权限。表达的语义是:符合某些策略的访问者对指定的资源或者操作可以访问

理解了这些概念后,在Keycloak中实现授权并不困难。

演练:在Keycloak中实现授权

还是以Weather API为例,设置这样的业务场景:

  1. 服务供应商(Service Provider)发布/WeatherForecast API供外部访问
  2. 在企业应用(Client)里有三个用户:super,daxnet,nobody
  3. 在企业应用里有两个用户组:administrators,users
  4. 在企业应用里定义了两个用户角色:administrator,regular user
  5. super用户同时属于users和administrators组,daxnet属于users组,nobody部署于任何组
  6. administrators组被赋予了administrator角色,users组被赋予了regular user角色
  7. 对于/WeatherForecast API,它支持两种操作:GET /WeatherForecast,用以返回天气预报数据;PATCH /WeatherForecast,用以调整天气预报数据
  8. 拥有administrator角色的用户/组,具有PATCH操作的权限;拥有regular user角色但没有administrator角色的用户/组,具有GET操作的权限;没有任何角色的用户,就没有访问/WeatherForecast API的权限

这个业务场景也可以用下面的图来表述:

首先,在Keycloak中新建一个名为aspnetcoreauthz的Realm,在这个Realm下,新建三个User,分别是super,daxnet和nobody;然后新建两个Group:administrators和users,将super用户放到administrators组和users组里,并将daxnet用户放入users组里。

然后,新建一个名为weatherapiclient的Client,在weatherapiclient的页面里,点击Roles选项卡,创建两个名为administrator和regular user的角色,然后回到Groups里,选中administrators组,在Role mapping中,将administrator角色赋予该组:

用同样的方法,将regular user角色赋予users组。

现在进入Authorization选项卡,点击Scopes选项卡,然后点击Create authorization scope按钮:

在Create authorization scope页面中,Name字段输入weather.read,用同样的方法,新建另一个Scope,名称为weather.update。然后点击Resources选项卡,并点击Create resource按钮,创建API resource:

在Create resource页面,新建名为weather-api的资源,填入如下字段,然后点击Save按钮保存:

回到Authorization标签页,点击Policies标签页,点击Create client policy按钮,在弹出的对话框中,选择Role,表示需要创建一个基于角色的策略。在Create role policy页面,新建一个名为require-admin-policy的策略,在Roles部分,点击Add roles按钮,选择weatherapiclient下的administrator角色,然后点击Save按钮保存:

用同样的方法创建require-registered-user策略,并将regular user作为角色加入。接下来开始创建权限实体(Permission)。在Authorization选项卡里,点击Permission选项卡,然后点击Create permission,然后选择Create scope-based permission。在Create scope-based permission页面,创建一个名为weather-view-permission的Permission,Authorization scopes选择weather.read,Policies选择require-registered-user,这里的语义已经很明白了:
执行weather.read操作,需要require-registered-user策略
,也就是
要读取天气预报信息,就需要已注册用户
。点击Save按钮保存即可。

用同样的方法创建另一个名为weather-modify-permission的Permission,Authorization scopes为weather.update,Policies为require-admin-policy。

接下来,就可以测试权限的设置是否正确了。仍然在Authorization选项卡下,点击Evaluate选项卡,在Identity Information部分,Users里选择super:

然后点击Evaluate按钮,之后就可以看到,weather-modify-permission和weeather-view-permission均投票为Permit,表示该用户具有两者权限:

如果点击Show authorization data,则在弹出的Authorization data对话框中,可以看到token里已经包含了授权信息(authorization Claim):

{
  "exp": 1712996185,
  "iat": 1712995885,
  "jti": "4f1178f2-5e8b-41e4-b726-da9120d77baa",
  "aud": "weatherapiclient",
  "sub": "44bbfc3a-16a0-499a-aae9-a2aa36219d33",
  "typ": "Bearer",
  "azp": "weatherapiclient",
  "session_state": "2b228dd4-38c8-4002-bc11-b35ecd109a63",
  "acr": "1",
  "allowed-origins": [
    "/*"
  ],
  "realm_access": {
    "roles": [
      "default-roles-aspnetcoreauthz",
      "offline_access",
      "uma_authorization"
    ]
  },
  "resource_access": {
    "weatherapiclient": {
      "roles": [
        "administrator",
        "regular user"
      ]
    },
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },
  "authorization": {
    "permissions": [
      {
        "scopes": [
          "weather.update",
          "weather.read"
        ],
        "rsid": "f6fd1d6f-3bfd-44a1-a6fb-a1fb49769ac9",
        "rsname": "weather-api"
      }
    ]
  },
  "scope": "email profile",
  "sid": "2b228dd4-38c8-4002-bc11-b35ecd109a63",
  "email_verified": false,
  "name": "Admin User",
  "groups": [
    "/administrators",
    "/users"
  ],
  "preferred_username": "super",
  "given_name": "Admin",
  "family_name": "User",
  "email": "super@abc.com"
}

换一个用户,如果选择daxnet,可以看到,weather-view-permission为Permit,而weather-modify-permission为Deny:

再将用户换为nobody测试一下,发现两个Permission的结果都为Deny:

通过token API端点请求授权信息

要使用OpenID Connect的token API端点获得某个用户的授权信息,需要首先得到Bearer token:

然后,使用这个Bearer token,再次调用token API,注意此时的grant_type为
urn:ietf:params:oauth:grant-type:uma-ticket
,audience为Client ID,即weatherapiclient:

在jwt.io中解码第二步生成的这个access_token,就可以拿到授权信息了:

3.3、客户端编译生成GRPC类

1. 在“解决方案资源管理器”中,使用鼠标左键选中项目名称“Demo.Grpc.Cmd”,然后单击鼠标右键,在弹出的快捷菜单中选择“重新生成”菜单项。

2. 在“解决方案资源管理器”中,使用鼠标左键选中项目名称“Demo.Grpc.Cmd,在弹出的快捷菜单中选择“在文件资源管理器中打开文件夹”菜单项。如下图。

3.我们打开“文件资源管理器”,进入到Demo.Grpc.Cmd
\obj\Debug\
net7.0
目录,发现此时目录下也有与服务端一样的4个.cs文件,就是GRPC协议文件对应的类文件,如下图所示:

3.4、gRPC服务的https调用

1.在服务端项目(Demo.GrpcService)中,由Visual Studio 2022在创建项目时默认配置了两个地址,让我们来调用。2个地址分别为:
http://localhost:5209

https://localhost:7149
, gRPC客户端会使用到这2个地址,目的是给客户端请求请求地址,服务端将监听这两个端口。

2. 在Visual Studio 2022的“解决方案资源管理器”中,使用鼠标右键单击“Demo.Grpc.Cmd”项目名称,在弹出菜单中选择“添加--> 类”。 在“添加新项”对话框中将类命名为 User,然后选择“添加”。

3. 在Visual Studio 2022的“解决方案资源管理器”中,使用鼠标双击打开刚才创建的User.cs文件,添加如下代码:

usingSystem;usingSystem.Collections.Generic;usingSystem.Linq;usingSystem.Text;usingSystem.Text.Json;usingSystem.Threading.Tasks;usingGrpc.Net.Client;usingDemo.GrpcService.Protos;namespaceDemo.Grpc.Cmd
{
public classUser

{
public voidGetUserInfo()
{
//使用https
const string urlHttps = "https://localhost:7149";using (var channel =GrpcChannel.ForAddress(urlHttps))
{
var client = newUserInfo.UserInfoClient(channel);

UserInfoResult userInfo
= client.GetUserInfo(newUserInfoRequest()
{
UserName
= "Admin",

Password
= "12345"});//打印服务方法返回的结果
Console.WriteLine($"{userInfo.UserName},{userInfo.Age},{userInfo.Name}");
Console.WriteLine( JsonSerializer.Serialize(userInfo));
}
//return string.Empty;
Console.ReadKey();

}
}
}

4. 在Visual Studio 2022的“解决方案资源管理器”中,使用鼠标双击打开program.cs文件,添加如下代码:

/ 、See https://aka.ms/new-console-template for more information

usingDemo.Grpc.Cmd;


Console.WriteLine(
"Hello, World!");new User().GetUserInfo();

5.我们在开启一个Visual Studio 2022,打开“Demo.GrpcService”解决方案,将“Demo.GrpcService”设置为启动项目,并使用https协议启动运行。

6.启动运行之后的结果如图。

7.我们切换到“Demo.Grpc.Cmd”为启动项目Visual Studio 2022,按F5,启动。

8.启动之后的运行结果,如图。

到此,调用gRPC服务端提供的https地址就成功了。

3.5、gRPC服务的http调用

相比https的调用,我们只需要在调用前加上如下代码即可:

AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);

1. 在Visual Studio 2022的“解决方案资源管理器”中,使用鼠标双击打开User.cs文件,添加如下代码:

usingSystem;usingSystem.Collections.Generic;usingSystem.Linq;usingSystem.Text;usingSystem.Text.Json;usingSystem.Threading.Tasks;usingGrpc.Net.Client;usingDemo.GrpcService.Protos;namespaceDemo.Grpc.Cmd
{
public classUser
{
public voidGetUserInfo()
{
//使用http
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);const string urlHttp = "http://localhost:5209";using (var channel =GrpcChannel.ForAddress(urlHttp))//使用https//const string urlHttps = "https://localhost:7149";//using (var channel = GrpcChannel.ForAddress(urlHttps))
{var client = newUserInfo.UserInfoClient(channel);

UserInfoResult userInfo
= client.GetUserInfo(newUserInfoRequest()
{
UserName
= "Admin",
Password
= "12345"});//打印服务方法返回的结果
Console.WriteLine($"{userInfo.UserName},{userInfo.Age},{userInfo.Name}");
Console.WriteLine( JsonSerializer.Serialize(userInfo));
}
//return string.Empty;
Console.ReadKey();
}
}
}

2.在目Visual Studio 2022,按F5或是点击工具栏上的“运行”按钮,启动“Demo.Grpc.Cmd”控制台程序。

到此,调用gRPC服务端提供的http地址就成功了。

运行效果如下:

前一段时间有个Java技术栈的朋友联系到我,需要快速对接现有的无人值守称重系统,这里的对接是指替代现有系统,而非软件层面的对接,也就是利用现有的硬件开发一套替代现有软件的自动化系统。主要设备包括地磅秤、道闸、红外对射传感器、摄像头、小票打印、LED显示屏等等,全程使用LED显示屏提示人员当前的操作状态。

业务流程:

①摄像头识别车牌号

②开启前入磅道闸

③红外监测是否抵达称重区域

④采集地磅重量,自动判断仪表读数稳定

⑤摄像头抓拍现场图像,同时并发采集多路摄像头形成现场档案

⑥数据打包上传到MES系统

⑦打印小票

⑧开启后出磅道闸

这位同学基于java技术栈研究了一段时间进展较慢,应该是通过园子联系到我。我们简单沟通了一下,确定使用IoTBrowser来开发,虽然前期没有界面的展示需求,但是保留了UI控制的扩展性,最主要是用html+js开发起来简单、高效。我这边提供硬件层的驱动和js接口,他来实现上层业务逻辑控制。

因为目前项目处于前期技术验证阶段,所以前期拿了2款硬件进行测试。第一款是地磅秤,据了解地磅秤仪表使用耀华A9,IoTBrowser已经自带实现,js示例也提供了不需要二次开发。第二个就是控制道闸的开启与关闭,这个还没有实现,所以重点就是打通这个设备。

要进行硬件对接首先要知道对接的接口形式和数据协议,通过以下三步:

第一步,找到设备的品牌和型号;

第二步,快速在官网找到说明书,通过了解这块设备是施耐德品牌C2000型号,一款以太网型开关量模块,向下使用RS485接入道闸的串口,向上提供Modbus-TCP协议可以远程控制。

第三步,通过说明书找到具体的控制协议,然鹅Modbus协议是使用原始的16进制描述,并没有线圈相关的介绍。

找到了对应的协议,下一步就算摞起袖子开工。因为对方在宁夏而我在长沙,需要代码开发调试不可能在对方机器上安装一套VS开发工具再远程到他电脑,这样很不方便,所以使用代理软件将设备的Modbus端口临时转发出来,这样跨越千里通过网络就可以在异地联调设备。

经过几个小时的摸索,成功实现了设备的开启和关闭。中间过程还算顺利,就是使用NModbus时是使用的Int参数需要进行进制转换,这里浪费了一点时间。

        // 开关控制
        function open(address, startAddress, value) {
            var $msgWrite = $('#msgWrite');
            dds.iot.com.exeCommand({ id: wid, name: "WriteSingleCoil", data: { slaveAddress: address, startAddress: startAddress, value: value } }, function (ar) {
                if (ar.Success) {
                    $msgWrite.text('操作成功')
                } else {
                    $msgWrite.text('操作失败:' + ar.Message)
                }
            })
        }
        //开关状态读取
        function readStatus(address, startAddress, num) {
            dds.iot.com.exeCommand({ id: wid, name: "ReadCoils", data: { slaveAddress: address, startAddress: startAddress, numberOfPoints: num } }, function (ar) {
                if (ar.Success) {
                    $msg.text('数据:' + ar.Data)
                } else {
                    $msg.text('操作失败:' + ar.Message)
                }
            })
        }

        // 启动称重采集服务
        function startWeight() {
            var $weight = $("#weight");

            var type = 'test';// 修改为实际型号
            //var type = 'yh_a9';// 耀华XK3190-A9:yh_a9

            var port = 1;
            var baudRate = 9600;
            // 调用电子秤
            dds.iot.weight.start({
                type: type,
                port: port,
                baudRate: baudRate,
                onUpdateWeight: function (data) {
                    // 重量回调事件
                    $weight.html(data.weight);
                    console.log('最新重量:'+ data.weight)
                },
                complete: function (ar) {
                    if (!ar.Success) {
                        alert(ar.Message);
                    }
                }
            })
        }

上层封装了js和简单的UI参考示例,我这边的工作就顺利交付了。

IoTBrowser平台开源地址:https://gitee.com/yizhuqing/IoTBrowser/