简介:一份面向惯性导航初学者与嵌入式开发者的C语言轨迹发生器源码包,用于根据初始位置、速度与姿态信息,模拟生成陀螺仪和加速度计数据,进而连续推算载体位置、速度与姿态的实时运动轨迹,解决真实场景中传感器原始数据难获得、算法验证缺入口的问题。程序覆盖牛顿运动方程、四阶龙格-库塔微分方程求解、传感器误差建模、低通滤波与卡尔曼滤波等关键算法,并体现时间同步与系统集成思路,适合结合IMU原理学习,也可作为惯导/GPS组合导航算法的辅助参考。压缩包共2个文件,包含1个C核心程序与1个文本说明文件:C文件实现轨迹解算主逻辑,文本文件则可能提供程序出处、编译环境或依赖库指引,便于快速搭建运行环境。整体仅4KB,轻量且几乎无依赖,可直接阅读调试,尤其适合在真实项目中参考其积分递推、滤波去噪的代码实现。目前已有814人学习,读者既可对照源码理解从传感器原始数据处理到轨迹输出的完整流程,也可将其作为惯性导航课程设计或工程原型的起点,在现有基础上扩展更高精度算法。
1. 惯性导航轨迹发生器:仿真里最容易被低估的输入环节
做组合导航算法验证时,最缺的不是滤波器,而是一条物理自洽的轨迹。手工拼航迹只能得到位置点,很难同时保证速度、姿态、比力和角速度互相一致,而这恰恰是惯导解算和滤波器的“输入食粮”。惯性导航轨迹发生器程序要解决的,就是把这个输入侧的底牌补齐——让使用者通过机动段编排,生成一段六自由度的完整轨迹,并同步输出陀螺仪和加速度计的量测数据。
这套程序适合两类人:一类是做惯导解算、组合导航滤波研究的,需要反复生成不同机动特征的数据来调参;另一类是做半物理仿真或硬件在环测试的,需要一个能稳定输出高频IMU数据的前端。它的价值不是“画出一条平滑曲线”,而是保证位置、速度、姿态、角速度、比力在数学上自洽,让后端算法能真正跑起来。下面我把建模、结构、坑和验证方法逐一过一遍,新手可以照着做,熟手可以直接跳到避坑章节。
2. 运动学建模与坐标变换:先立住姿态四元数和轨迹微分方程
2.1 轨迹不能“画”出来,要“积”出来
见过不少同行提供的仿真数据:位置和速度对得上,姿态角和角速度之间却差得很远。这种数据拿给滤波器,模型一收紧就发散。原因在于,很多人把轨迹发生器做成了曲线插值器,而不是运动学积分器。
一条完整的惯导轨迹,应该由下面的状态向量描述:位置p(导航系下)、速度v(导航系下)、姿态四元数q(描述机体系相对导航系)、机体系角速度ω_b、导航系加速度a_n。它们之间的约束是微分方程:
- ṗ = v
- v̇ = a_n
- q̇ = 0.5 · q ⊗ [0, ω_b]
轨迹发生器的本质,就是给定机动段指令(期望的加速度、角速度),对这三个微分方程做正向积分。为什么不能用多项式拟合离散点来“生成轨迹”?因为位置连续不代表速度和加速度连续。位置曲线看起来平滑,一阶导、二阶导可能剧烈跳变,体现在IMU数据里就是比力跳变,滤波器的量测一致性直接崩溃。
坐标系我统一用NED(北东地),机体坐标系为x前、y右、z下。这样在水平静止状态下姿态四元数为单位四元数,方向余弦矩阵退化为单位阵,不容易产生符号混乱。
2.2 四元数姿态更新实现与数值细节
姿态更新是轨迹自洽的核心。欧拉角观感直观,但积分时会有万向节锁和顺序问题,四元数没有这些毛病,代价就是代码里多写几个函数。先给出基础的四元数乘法和姿态更新函数:
import numpy as np def quat_multiply(q, r): """四元数乘法,顺序 w, x, y, z""" qw, qx, qy, qz = q rw, rx, ry, rz = r return np.array([ qw*rw - qx*rx - qy*ry - qz*rz, qw*rx + qx*rw + qy*rz - qz*ry, qw*ry - qx*rz + qy*rw + qz*rx, qw*rz + qx*ry - qy*rx + qz*rw, ]) def update_attitude(q, omega_b, dt): """用机体系角速度增量更新姿态四元数(角增量法)""" norm = np.linalg.norm(omega_b) if norm < 1e-12: return q / np.linalg.norm(q) half_angle = 0.5 * norm * dt axis = omega_b / norm delta_q = np.array([ np.cos(half_angle), np.sin(half_angle) * axis[0], np.sin(half_angle) * axis[1], np.sin(half_angle) * axis[2], ]) q_new = quat_multiply(q, delta_q) return q_new / np.linalg.norm(q_new)这段的逻辑:把机体系角速度在dt内看作恒定值,用它构造一个增量四元数,再右乘到当前姿态上。角增量法假设小时间步内角速度恒定,dt取0.01秒(100Hz)时对常规机动足够;如果机动角速度超过每秒300度,建议升级为双子样或三子样圆锥补偿算法,否则姿态积分会出现不可忽略的漂移。
注意最后一步的归一化。四元数范数偏离1超过1e-8,积分几百步后姿态就会悄悄偏掉,这是最容易被忽略的坑。
2.3 位置与速度的梯形积分,以及转弯机动的数学底子
位置和速度推进我一般用梯形积分,而不是简单的前向欧拉:
def propagate_pos_vel(p, v, a_n, dt): """梯形积分:v取前后两步平均计算位置增量""" v_new = v + a_n * dt p_new = p + 0.5 * (v + v_new) * dt return p_new, v_new前向欧拉只有一阶精度,在持续加速段会产生二次项误差累积;梯形积分是二阶精度,实现成本几乎为零。高动态场景可以升级到龙格库塔四阶,但对轨迹生成器来说,梯形积分已经能保证和IMU数据解算结果对得齐。
转弯机动的参数设计经常要靠手动计算。协调转弯(constant-turn)是最常用的段类型,给定巡航速度v和偏航角速度ψ̇,有:
- 转弯半径 R = v / ψ̇
- 向心加速度 a_c = v · ψ̇
- 所需滚转角 φ = atan(a_c / g)
举个例子:速度20米每秒,偏航率每秒10度(约0.1745弧度),转弯半径约114.6米,向心加速度约3.49米每平方秒,对应滚转角约19.6度。这个滚转角数值在后面编排滚转段时直接能用,不用等仿真跑完再猜。
3. 六自由度轨迹生成器结构:机动编排、主循环与IMU采样
3.1 模块划分:状态对象、机动段定义与主循环
轨迹发生器不建议做成一大坨脚本,我通常拆成两个东西:一个机动段描述结构,一个轨迹生成器类。机动段用dataclass承载类型、时长和参数,状态推进和段切换由生成器类完成。
from dataclasses import dataclass, field from typing import List, Dict @dataclass class Segment: seg_type: str # 'accel', 'straight', 'coord_turn', 's_turn', 'climb' duration: float # 段持续时间,单位秒 params: Dict = field(default_factory=dict)生成器类的核心是主循环:先根据当前时刻找到所在机动段,再由机动段计算出期望的导航系加速度a_n和机体系角速度ω_b,然后推进位置、速度、姿态。下面是一个简化但结构完整的实现:
class TrajGen: def __init__(self, p0, v0, heading0, gamma0, segments, dt=0.01): self.p = np.array(p0, dtype=float) self.v = np.array(v0, dtype=float) self.heading = np.deg2rad(heading0) # 偏航角,弧度 self.gamma = np.deg2rad(gamma0) # 爬升角,弧度 self.q = euler_to_quat(0.0, self.gamma, self.heading) self.segments = segments self.dt = dt self.t = 0.0 def _pick_segment(self): t_accum = 0.0 for seg in self.segments: if self.t < t_accum + seg.duration: return seg, self.t - t_accum t_accum += seg.duration return None, 0.0 def _command(self, seg, t_local): """根据机动段类型计算加速度指令和角速度指令""" dt = self.dt v_old = self.v.copy() v_norm = np.linalg.norm(v_old) a_long = 0.0 heading_dot = 0.0 gamma_dot = 0.0 if seg.seg_type == 'accel': a_long = seg.params['a'] elif seg.seg_type == 'straight': pass elif seg.seg_type == 'coord_turn': heading_dot = np.deg2rad(seg.params['heading_rate']) elif seg.seg_type == 's_turn': # 正弦偏航机动:偏航角速度随时间正弦变化 amp = np.deg2rad(seg.params['A']) T = seg.params['T'] heading_dot = amp * (2 * np.pi / T) * np.cos(2 * np.pi * t_local / T) elif seg.seg_type == 'climb': gamma_dot = np.deg2rad(seg.params['gamma_rate']) # 由角度增量解析更新速度矢量 self.heading += heading_dot * dt self.gamma += gamma_dot * dt v_new_norm = v_norm + a_long * dt v_new = v_new_norm * np.array([ np.cos(self.gamma) * np.cos(self.heading), np.cos(self.gamma) * np.sin(self.heading), -np.sin(self.gamma), ]) self.v = v_new a_n = (self.v - v_old) / dt # 机体系角速度近似:低动态时直接用欧拉角速率 omega_b = np.array([0.0, gamma_dot, heading_dot]) return a_n, omega_b def step(self): seg, t_local = self._pick_segment() v_prev = self.v.copy() if seg is None: a_n = np.zeros(3) omega_b = np.zeros(3) else: a_n, omega_b = self._command(seg, t_local) self.p += 0.5 * (v_prev + self.v) * self.dt self.q = update_attitude(self.q, omega_b, self.dt) self.t += self.dt return { 't': self.t, 'p': self.p.copy(), 'v': self.v.copy(), 'q': self.q.copy(), 'a_n': a_n, 'omega_b': omega_b, }这段代码的要点有两个。第一,速度矢量直接由速度大小、偏航角、爬升角解析生成,加速度由速度差分反推,这样位置、速度、加速度三者严格自洽,不会出现“速度方向转了但加速度算不对”的问题。第二,ω_b这里用了低动态近似,把偏航角速度直接当作机体系z轴角速度,俯仰角速度当作机体系y轴角速度;高动态场景需要在此基础上补充滚转角速度投影。作为原型验证,这个近似够用了,但心里要清楚它的边界。
3.2 一个完整轨迹脚本:巡航、转弯、S机动的编排
有了类之后,生成一段轨迹只需要组织好机动段列表。下面这个脚本生成一段包含加速、匀速、协调转弯、S机动、爬升的80多秒轨迹:
segments = [ Segment('accel', 10.0, {'a': 2.0}), Segment('straight', 20.0, {}), Segment('coord_turn', 18.0, {'heading_rate': 10.0}), Segment('straight', 10.0, {}), Segment('s_turn', 20.0, {'A': 15.0, 'T': 12.0}), Segment('climb', 8.0, {'gamma_rate': 3.0}), ] gen = TrajGen( p0=[0.0, 0.0, -100.0], v0=[0.0, 0.0, 0.0], heading0=0.0, gamma0=0.0, segments=segments, dt=0.01, ) with open('track.csv', 'w') as f: f.write('t,x,y,z,vx,vy,vz,roll,pitch,yaw\n') while gen.t < 90.0: state = gen.step() roll, pitch, yaw = quat_to_euler(state['q']) f.write( f"{state['t']:.3f}," f"{state['p'][0]:.3f},{state['p'][1]:.3f},{state['p'][2]:.3f}," f"{state['v'][0]:.3f},{state['v'][1]:.3f},{state['v'][2]:.3f}," f"{np.rad2deg(roll):.3f},{np.rad2deg(pitch):.3f},{np.rad2deg(yaw):.3f}\n" )机动段参数决定了轨迹特征。这里给出我常用的推荐范围,方便初次上手时参考:
| 段类型 | 关键参数 | 输出特征 | 推荐范围 |
|---|---|---|---|
| accel | a | 切向加速度,速度线性变化 | 1~3 m/s² |
| straight | 无 | 匀速直线 | 无 |
| coord_turn | heading_rate | 恒定偏航率,速度大小不变 | 5~15 deg/s |
| s_turn | A, T | 正弦偏航机动,路径呈S形 | A: 10~30 deg, T: 8~20 s |
| climb | gamma_rate | 爬升角线性变化 | 1~5 deg/s |
总时长建议控制在60到120秒。太短轨迹特征不充分,滤波器还没收敛就结束了;太长单个数据文件会膨胀,后处理也麻烦。
3.3 采样率与插值:生成IMU数据时的工程选择
轨迹推进步长和IMU采样率是两个容易混淆的概念。我一般让轨迹推进步长等于IMU采样间隔,即用同一个dt,在一个循环里同时产出真值和量测值。这样时间戳天然对齐,省掉后续同步的麻烦。
采样率怎么定?关键看两个约束:载体动态频率和后续滤波器的更新频率。旋翼机和地面车辆的角运动通常在5Hz以内,100Hz采样足够;高机动固定翼或测试复杂机动时建议200Hz。如果IMU需要1000Hz而轨迹推进用100Hz,不要直接用零阶保持填充,因为零阶保持会产生角速度台阶,被滤波器当作量测跳变。常见做法是先推进出100Hz的稀疏轨迹,再用线性插值补位置、用球面线性插值(SLERP)补四元数,角速度用稀疏点的差分值保持。
4. 比力与陀螺模拟:把轨迹翻译成IMU量测数据
4.1 比力方程拆解:加速度计测的从来不是坐标加速度
轨迹生成器的真正价值在于把导航系下的轨迹翻译成加速度计和陀螺的输出。这里最容易出问题的是比力方程。加速度计测的不是坐标加速度,而是比力(specific force),即扣除重力后的非引力加速度。在NED坐标系下取重力矢量g_n = [0, 0, +9.80665],比力公式为:
f_b = C_n^b · (a_n - g_n)
其中C_n^b是从导航系到机体系的旋转矩阵。写代码前先把旋转矩阵函数准备好:
def quat_to_dcm(q): """四元数转方向余弦矩阵,返回从机体系到导航系的C_b^n""" w, x, y, z = q return np.array([ [1 - 2*(y*y + z*z), 2*(x*y - w*z), 2*(x*z + w*y)], [2*(x*y + w*z), 1 - 2*(x*x + z*z), 2*(y*z - w*x)], [2*(x*z - w*y), 2*(y*z + w*x), 1 - 2*(x*x + y*y)], ]) def sample_imu(state, accel_bias, gyro_bias, noise): """由轨迹状态生成IMU量测:比力和角速度""" C_bn = quat_to_dcm(state['q']) C_nb = C_bn.T # 导航系 -> 机体系 a_n = state['a_n'] - np.array([0.0, 0.0, 9.80665]) f_b = C_nb @ a_n omega_b = state['omega_b'] f_b = f_b + accel_bias + np.random.normal(0.0, noise['accel'], 3) omega_b = omega_b + gyro_bias + np.random.normal(0.0, noise['gyro'], 3) return f_b, omega_b关于符号约定,这里必须说清楚:上述公式中g_n取[0,0,+9.80665],那么静止水平状态下a_n = 0,f_b = C_nb · [0,0,-9.80665] = [0,0,-9.80665]。也就是说,按这个约定,静止时加速度计的z轴输出是负的。如果你的解算算法里约定静止输出为正,就把公式里的减号换成加号。两种约定都能自洽,但必须前后一致,否则垂直通道一定会飘。
提示:拿到任何轨迹发生器,先跑一段1秒静止轨迹做自检,看比力输出是否符合你前端解算的约定,再谈后续。
4.2 陀螺角速度生成:协调转弯与高动态机动的输出
第三章代码里的ω_b是低动态近似,适合原型。真实高动态场景下,协调转弯的陀螺输出需要包含滚转建立过程。协调转弯稳定段,理想情况下机体系角速度只有z轴分量等于偏航率;但在进入转弯前,飞机会有一个先滚转的过程,这个过程的滚转角速度ω_x必须体现在陀螺输出里。
常见的段编排是把一个转弯拆成三段:
segments = [ Segment('roll_in', 2.0, {'roll_rate': -9.8}), # 横滚角速度建立 Segment('coord_turn', 20.0, {'heading_rate': 10.0}), Segment('roll_out', 2.0, {'roll_rate': 9.8}), # 改平 ]滚转角速度的值由目标滚转角除以建立时间得到。如果目标滚转角是19.6度,2秒建立,滚转率大约每秒9.8度。这样编排出来的数据,陀螺x轴有真实的滚转脉冲,滤波器的姿态收敛行为才接近真实飞行。
4.3 传感器误差模型注入与参数设定
纯理想IMU数据只能用来验证解算算法本身,做滤波器鲁棒性测试时必须注入传感器误差。常见做法是常值偏置加白噪声,再加一阶马尔可夫过程模拟温变漂移。
def add_sensor_errors(f_b, omega_b, err_state, errors, dt): """注入常值偏置、白噪声和一阶马尔可夫误差""" f_b = f_b + errors['accel_bias'] + np.random.normal(0.0, errors['accel_noise'], 3) omega_b = omega_b + errors['gyro_bias'] + np.random.normal(0.0, errors['gyro_noise'], 3) # 一阶马尔可夫:模拟偏置的缓慢温变漂移 tau = errors['tau'] alpha = 1.0 - dt / tau err_state['accel_bias'] = alpha * err_state['accel_bias'] + \ np.random.normal(0.0, errors['accel_bias_std'], 3) err_state['gyro_bias'] = alpha * err_state['gyro_bias'] + \ np.random.normal(0.0, errors['gyro_bias_std'], 3) return f_b, omega_b参数怎么给才算合理?下面是一组中等精度MEMS的典型值,适合大多数仿真场景:
| 参数 | 含义 | 仿真设定参考 |
|---|---|---|
| gyro_bias | 陀螺常值零偏 | 1~10 deg/h |
| gyro_noise | 陀螺白噪声(角度随机游走) | 0.1~1 deg/√h |
| accel_bias | 加速度计常值零偏 | 1~10 mg |
| accel_noise | 加速度计白噪声(速度随机游走) | 50~500 μg/√Hz |
| tau | 马尔可夫时间常数 | 60~300 s |
噪声标准差需要按采样率换算。比如加速度计白噪声500μg/√Hz,采样率100Hz时噪声标准差约为500μg除以√100,即50μg左右。不换算直接填大噪声,数据会被淹没,滤波器反而好滤波,这个仿真就失去意义了。
5. 轨迹发生器的避坑与常见问题:四类高频翻车现场
5.1 机动切换瞬间轨迹跳变,滤波器直接发散
现象:加速段切巡航段后,解算速度曲线出现一个明显的台阶,滤波器状态估计在切换点附近剧烈波动。
原因:机动段之间参数不连续。加速段结束时速度已经达到某个值,但下一个匀速段的设定速度跟实际速度不一致,位置和速度在切换瞬间出现人为的不连续。上文代码里速度矢量是连续推进的,但如果手动拼段时没有继承上一段结束状态,就会踩这个坑。
解决:所有机动段的状态推进只在生成器内部完成,不要由外部传入下一段的初值;段概念只负责给出“指令方向”,实际速度、位置始终从上一时刻继续。需要明确过渡时,在段末尾加一小段斜坡过渡段,例如加速度从2降到0的过程写成一段3秒的减加速段。
5.2 欧拉角积分“掉向”与四元数范数漂移
现象:跑长轨迹时姿态角出现莫名其妙的跳变,特别是俯仰角接近90度时,航向角瞬间翻转180度。
原因:用了欧拉角做积分。欧拉角微分方程在奇异点附近数值不稳定,且积分误差会累积。我是亲眼见过只跑两分钟轨迹,航向角直接从0度跳到180度的。
解决:姿态全部用四元数推进,只在输出、可视化时才转成欧拉角。同时每次更新后检查四元数范数,偏离1超过1e-8就归一化一次。这个检查不要偷懒,数值误差是积累的,不清理几百步后姿态已经悄悄偏了。
5.3 比力符号和重力约定搞反,垂直通道异常
现象:静止段解算高度开始匀速下降或上升,加速度计输出的z轴数据和理论值方向相反。
原因:比力公式用了a_n - g_n,但前端解算包用的是a_n + g_n约定,或者坐标系Z轴方向理解不一致。NED下重力矢量是正的,机体Z轴向下,这两点组合起来的符号很容易绕晕。
解决:生成任何完整轨迹前,先输出一段1秒的静止段,检查比力是否为[0,0,-9.8]或[0,0,+9.8](取决于约定)。不一致就把公式里的加减号对调,然后重新生成全部数据。这个自检只需要几行代码,却能省掉后面排查滤波发散的一整天时间。
5.4 轨迹真值与IMU时间戳错位
现象:把轨迹真值和IMU量测喂给离线滤波器后,状态估计整体滞后一个或半个采样周期,位置误差曲线像被平移过一样。
原因:真值和量测不是同一时钟推进的。比如轨迹用100Hz循环生成,IMU数据单独用另一个时间戳数组缓存,两个文件写出来时时间基准不完全对齐,或者插值用了不同的参考时间。
解决:单线程统一时钟推进,在同一个step循环里既更新轨迹状态,又立即生成对应的IMU量测,输出的所有字段都打上同一个tick索引。如果要后处理插值,统一用tick索引作自变量,不要用自己拼的绝对时间。
6. 进阶:轨迹自洽性验证与传感器误差注入技巧
6.1 让生成器自己“验尸”:解算结果与真值轨迹对比
轨迹生成完,不要急着喂给滤波器。我一般会先生成一个不注入误差的干净数据,用几十行的“逆解算”验一遍自洽性——拿生成的IMU数据反推位置,再和真实轨迹做差分:
def verify_track(imu_f, imu_w, gt_p, dt=0.01): """用IMU量测做简单的惯性解算,对比参考轨迹位置""" q = np.array([1.0, 0.0, 0.0, 0.0]) v = np.zeros(3) p = np.zeros(3) for i in range(len(imu_f)): q = update_attitude(q, imu_w[i], dt) C_bn = quat_to_dcm(q) a_n = C_bn @ imu_f[i] + np.array([0.0, 0.0, 9.80665]) v += a_n * dt p += v * dt err = p - gt_p rms = np.sqrt(np.mean(err**2)) return rms干净数据下,100秒轨迹的RMS通常小于0.5米,数值积分误差在这个量级。如果RMS到了几十米甚至几百米,那说明轨迹生成器的比力、角速度或姿态更新里有系统性错误。注入误差后的数据再用这个脚本验证,会发现RMS显著放大,这是正常现象,不要反过来误判生成器有bug。
6.2 场景库与误差注入:喂给滤波器之前的最后一道工序
我习惯把机动段组合成若干标准场景:静止校准段、直线巡航段、城市工况段、S机动段、盘旋段。每个场景是一个segment列表,按需拼接,几行代码就能生成一组覆盖不同动态范围的数据。误差注入也按场景配置:低误差场景用于验证算法精度,温变偏置场景用于验证陀螺偏置估计能力,阶跃故障场景用于验证故障检测逻辑。
回顾我自己踩过多次的教训:每次生成新轨迹都跳过了自洽性验证,结果滤波器发散后回头排查,发现是轨迹数据本身的问题。从那以后,我每次跑轨迹生成器都会强制先过一遍静止段自检和位置RMS验证,再决定要不要把数据喂给滤波器——这个习惯帮我省掉的排查时间,比写验证脚本的时间多得多。希望帮到你。
本文还有配套的精品资源,点击获取