☰
轨迹优化实战:GPOPS-4.1伪谱法原理、配置与软着陆求解
2026/10/11 21:15:31 网站建设 项目流程

简介:这是一款面向科研工作者的MATLAB轨迹优化工具箱,基于庞特里亚金最大值原理构建哈密顿函数,可高效求解航天航空、机器人导航等领域的大规模非线性优化问题,不依赖其他外部求解器即可独立运行。资源共291个文件,压缩包约3.22MB,其中250个m脚本构成算法与示例主体,另有pdf文档、tex源码、mat数据、readme说明及多平台mex编译文件,便于跨平台调用与二次开发。内置丰富示例覆盖多种实际应用场景,用户可快速掌握问题定义、参数设置与求解流程,也可参考示例修改自定义成本函数和约束条件。已有459人学习浏览,适合正在研究弹道优化、最优控制或需要高效数值求解能力的科研人员与工程技术人员使用。

1. GPOPS-4.1 轨迹优化软件:不是又一个求解器,而是把“最优控制问题”翻译成机器能算的 NLP

做过轨迹优化的人都有这种经历:数学模型早就写好了,状态方程、边界条件、性能指标都对,但拿去算就是发散、不收敛、初值玄学。问题往往不在“求解”,而在“转录”——把连续时间的最优控制问题变成非线性规划(NLP)这一步。GPOPS-4.1 就是专门解决这层问题的开源轨迹优化工具箱:它用高斯伪谱法把微分方程约束离散成代数约束,自动做网格细化、自动求雅可比,再把规模已经可控的 NLP 扔给 IPOPT、SNOPT 或 fmincon。你只需要按它的接口写清动力学、成本函数和边界,剩下的事它基本包圆。这篇笔记适合两类人:一是被打靶法初值折磨、想换一种更稳转录方式的开发者,二是刚接触轨迹优化、想知道“配点法到底怎么落地”的新手。

2. 先把原理立住:GPOPS-4.1 里的配点、NLP 与 hp 网格细化

2.1 从 Bolza 最优控制到 NLP:GPOPS-4.1 替你完成了哪三步

轨迹优化问题在数学上通常写成 Bolza 形式:目标函数包含一个积分项(Lagrange 项)加一个端点项(Mayer 项),状态满足一组常微分方程,控制有上下限,端点有初末约束,中间可以有路径约束。这个形式看起来很规整,但计算机不会算“函数空间里的极值”,它只会算有限维的数值优化。所以第一步必须离散。

GPOPS-4.1 沿用的是高斯伪谱法的套路:把时间轴切分成若干区间,在每个区间内选取配点,状态变量在配点上的取值变成 NLP 的决策变量,状态的时间导数用微分矩阵作用在这些配点值上来近似。这样一来,“状态必须满足微分方程”这个连续约束,被改写成“在每个配点上,微分矩阵乘以状态值等于动力学右端函数”这种代数约束。配点上的控制值也一并成为决策变量,目标函数里的积分项则用高斯求积公式算成配点值的加权和。

第二步,是把所有约束和指标汇成一个标准的 NLP 问题。注意 GPOPS 这里不只做离散,它还会用自动微分(AD)把你写的动力学函数和成本函数对状态、控制的偏导算出来,交给 NLP 求解器用。这一点非常省事:如果你自己写直接转录法,雅可比矩阵要么用有限差分硬凑,要么手推解析导数,前者慢且容易在边界处失真,后者在你改一版动力学之后就要重推一遍。

第三步才是调用 NLP 求解器。GPOPS 内部维护了一个求解器抽象层,你可以不改任何模型代码,只把setup.nlp.solver从'ipopt'换成'snopt'或'fmincon',就能在几种求解策略之间切换。这步在刚上手时尤其有用,因为 IPOPT 的编译接口不一定就绪,而 MATLAB 自带的 fmincon 永远在,可以先把模型跑通再换高性能求解器。

2.2 hp 自适应网格:为什么能比固定网格省一个数量级的节点

固定配点法的痛点是:你不知道最优轨迹哪里陡、哪里平。如果全轨迹用低阶多项式,碰到 bang-bang 控制这类带切换的问题,解会被光滑化,切换点附近的梯度全被抹掉;如果全轨迹用高阶多项式,节点数暴涨不说,还会出现龙格振荡,边界附近数值极度不稳定。做轨迹优化的人应该都有这种体验:明明物理上就是推力和关机的一次切换,固定网格解出来却在切换点附近抖成锯齿。

GPOPS-4.1 的做法是 hp 自适应网格。它把轨迹切成若干区间,每个区间内可以独立选择配点数量(p-refinement,升阶)和区间长度(h-refinement,加密)。每轮算完之后,它会基于当前离散解估计每个区间的残差或局部截断误差,误差大的区间要么切细、要么升阶,误差已经够小的区间保持不变,直到所有区间的误差估计都低于你设定的mesh.tolerance。

这样做的好处非常直接:同样是 1e-8 的精度需求,固定网格可能要 800 个配点,hp 自适应大概只需要 100 多个点,而且这些点会自动堆积在切换点、路径约束激活段这些“曲率大”的地方。代价是每一轮细化都要重新解一遍 NLP,迭代次数通常在 4 到 10 轮之间。实际使用中,你会看到 GPOPS 每轮细化之后节点明显集中到动力学剧烈变化的位置,这就是它省节点的关键。

2.3 求解器怎么选:IPOPT、SNOPT 还是 fmincon

在 GPOPS-4.1 里,setup.nlp.solver决定底层用谁。我自己常用的选型逻辑如下表:

求解器算法类型需要额外准备适合场景
IPOPT内点法需要编译好的 mex 接口大规模稀疏 NLP,默认首选,内存占用可控
SNOPTSQP/可行化方法需要单独安装接口约束较多且希望先找可行解的问题
fmincon序列二次规划/内点无需准备,MATLAB 自带小规模验证、模型调试、快速跑通流程

新手常犯的错误是第一步就上 IPOPT,结果报错说不认识这个函数,以为自己装坏了,其实只是 mex 接口没编译。更稳妥的路径是先用'fmincon'把模型跑通,确认动力学、成本、边界写得没问题,再换'ipopt'去追精度和速度。SNOPT 我对它的经验是:它在可行性驱动的问题上表现稳定,但需要单独的授权和编译,一般团队里有人维护才值得引入。IPOPT 是开源默认项,社区资料最多,遇到报错也最好查。

3. 最小可复现例子:用 GPOPS-4.1 解一段软着陆轨迹

这里用一个经典的软着陆最省燃料问题跑通全流程,模型很简单,但包含了轨迹优化的所有核心要素:状态微分方程、控制幅值约束、初末状态约束、Mayer 型性能指标。

问题设定:一个着陆器从高度 100 m、速度 0 开始垂直下落,目标是在高度 0、速度 0 处着陆,推力大小需要在 0 到 20 N 之间调节,初始质量 1 kg,目标是最小化燃料消耗,等价于最大化终端质量。状态取高度 h、速度 v、质量 m,控制取推力占空比 u∈[0,1]。微分方程为:

  • h' = v
  • v' = -g + T*u/m
  • m' = -αTu

其中 g=1.62、T=20、α=1e-3,单位保持一致即可。下面按 GPOPS-4.1 的习惯写法逐块给出代码。

3.1 写 continuous 函数:把动力学和质量变化交给伪谱法

function cont = softLandingCont(input) % 状态: [高度 h(m), 速度 v(m/s), 质量 m(kg)] h = input.phase.state(:, 1); v = input.phase.state(:, 2); m = input.phase.state(:, 3); u = input.phase.control(:, 1); % 推力占空比 0~1 g = 1.62; % 星表重力加速度常量 T = 20; % 最大推力常量 alpha = 1e-3; % 燃料消耗系数 hdot = v; vdot = -g + T * u ./ m; mdot = -alpha * T * u; cont.dynamics = [hdot, vdot, mdot]; % 本问题没有路径约束,可省去 cont.path 字段 end

这段代码是 GPOPS 系列里 continuous 回调的标准结构。input.phase.state是一个 N×3 的矩阵,N 是当前网格里所有配点的个数,每一列对应一个状态分量;input.phase.control同理,N×1 矩阵。函数要返回cont.dynamics,即每个配点上的状态导数。

注意三点:第一,u ./ m用的是数组点除,因为u和m都是列向量,必须逐点相除。第二,动力学函数里不要写 if-else 这类不可导逻辑,GPOPS 要做自动微分,分支会让导数计算失真甚至报错。第三,动力学里的常量(g、T、alpha)直接写在函数内部即可,批量换参数再声明成全局或函数句柄参数。

3.2 写 cost 与 bounds:指标、初值、路径约束的落法

function cost = softLandingCost(input) % 只用 Mayer 项:最大化终端质量(等价最小化燃料消耗) cost.integrand = 0; cost.mayer = -input.phase.finalstate(:, 3); end

GPOPS 的成本函数需要返回两个字段:integrand是积分项,也就是 Bolza 形式里的 L;mayer是端点项,也就是 M。这里指标只有终端质量,所以integrand为 0,mayer取终端质量的负值,因为 NLP 求解器默认最小化目标。

接下来在求解脚本里设置所有边界。这一步最容易写错,GPOPS 的setup.bounds结构分phase.initial、phase.terminal、phase.state、phase.control几块,一一对应端点约束、全程状态界和控制界。

setup.bounds.phase.initial.time.lower = 0; setup.bounds.phase.initial.time.upper = 0; setup.bounds.phase.terminal.time.lower = 1; setup.bounds.phase.terminal.time.upper = 8; setup.bounds.phase.initial.state.lower = [100, 0, 1]; setup.bounds.phase.initial.state.upper = [100, 0, 1]; setup.bounds.phase.terminal.state.lower = [0, 0, 0.1]; setup.bounds.phase.terminal.state.upper = [0, 0, 1]; setup.bounds.phase.state.lower = [0, -30, 0.1]; setup.bounds.phase.state.upper = [110, 30, 1]; setup.bounds.phase.control.lower = [0]; setup.bounds.phase.control.upper = [1];

初始时间固定在 0,终端时间下界 1、上界 8,表示允许求解器在 1 到 8 秒之间自由选择着陆时间。初始状态三列都锁定,终端状态前三列锁定位和速,质量自由落在一个区间里。全程状态界要比初末值略宽,但不要宽到离谱,否则 NLP 可行域太松,求解器会在毫无物理意义的高速度上试探。

3.3 在主脚本里装配 setup 并调用求解

% 初始猜测:三条很粗糙的线性线 setup.guess.phase.time = [0; 4]; setup.guess.phase.state = [100, 0, 1 0, 0, 0.6]; setup.guess.phase.control = [0.3; 0.3]; setup.name = 'softLanding_demo'; setup.functions.continuous = @(input) softLandingCont(input); setup.functions.cost = @(input) softLandingCost(input); setup.mesh.method = 'hp'; setup.mesh.tolerance = 1e-7; setup.mesh.phase.fraction = 1; setup.mesh.phase.colpoints = 8; setup.nlp.solver = 'ipopt'; setup.nlp.solverOptions.ipopt.print_level = 5; % 求解并输出结果 [solution, output] = gpops2(setup);

setup.guess只要给一个合理的粗糙猜测即可,GPOPS 的自适应网格会在迭代中不断修正它。这里时间猜 0 到 4 秒,状态从初值线性插值到终值附近,控制猜 0.3。setup.mesh.phase.fraction = 1表示初始只用一个区间,colpoints = 8表示该区间先用 8 个配点,之后交给 hp 细化去调整。

调用gpops2之后,解放在solution.phase{1}里,solution.phase{1}.time、.state、.control分别对应时间节点、状态轨迹和控制轨迹。output里有关迭代次数和网格细化的统计信息,排查时很有用。第一次跑通后,直接把控制曲线画出来看形状是不是“全推力-关机-再调”的 bang-bang 结构,如果锯齿明显,说明网格还不够细,把mesh.tolerance收紧即可。

4. 参数调到能用的水平:网格、求解器选项与缩放初值

4.1 mesh 参数:tolerance、method 与初始配点数的取舍

setup.mesh.tolerance是 GPOPS 自适应网格的收敛容差,它决定每一轮细化要不要继续。经验上限是 1e-9,一般工程问题设 1e-6 到 1e-8 比较合理。对精度要求不高、只想要一条初步可行轨迹时,先设 1e-5,能让迭代轮数大幅减少,模型调试周期缩短一半。

setup.mesh.method建议直接用'hp',GPOPS 会同时做区间加密和阶数提升。如果你明确知道问题很光滑,没有开关切换,可以试'p',只升阶不切区,节点总数少、收敛更快;反过来,如果问题里路径约束频繁激活,'h'更稳。不过对多数人来说,默认'hp'是最省心的选择。

初始网格设置setup.mesh.phase.fraction和setup.mesh.phase.colpoints也有讲究。fraction = [1]表示整个时间轴是一个区间,colpoints = 8表示第一轮给 8 个配点。如果动力学很简单,这个配置足够;如果初值很离谱,把colpoints提到 10 到 12 能减少前几轮发散的概率。注意不要一开始就上几百个配点,那会拖慢第一轮 NLP 求解,反而不利于快速迭代。

4.2 nlp.solverOptions:IPOPT 里最常调的四个旋钮

IPOPT 的选项通过setup.nlp.solverOptions.ipopt.前缀传递,每个选项名与原生 IPOPT 一致。我一般固定调这四个:

setup.nlp.solverOptions.ipopt.max_iter = 1000; setup.nlp.solverOptions.ipopt.tol = 1e-8; setup.nlp.solverOptions.ipopt.print_level = 5; setup.nlp.solverOptions.ipopt.linear_solver = 'ma57';

max_iter默认可能只有几百,复杂问题很容易触顶,触顶时会看到Maximum Number of Iterations Exceeded,但不代表失败,把解提取出来作为新 guess 再跑一轮往往就收敛了。tol是 NLP 求解器自身的最优性容差,和mesh.tolerance是两回事,前者管非线性规划的 KKT 残差,后者管离散误差;两者不在一个量级时,会出现“网格已经够细但 NLP 没解准”的假象。linear_solver选ma57通常性能和稳定性最好,但需要系统里有对应的稀疏线性代数库;如果没装,改用'mumps'。print_level = 5能让你看到每轮迭代的残差下降过程,排错时比默认输出有用得多。

4.3 缩放、初值与量纲:让 NLP 求解器不发疯的准备工作

轨迹优化里大量“不收敛”根本不是算法问题,而是数值缩放问题。NLP 求解器对决策变量的尺度非常敏感:如果高度是 1e6 量级、速度是 1e4 量级、质量是 1 量级,KKT 系统的条件数会差到几个数量级,IPOPT 即使能算,迭代次数也会暴涨。一个人为的坏例子是把轨道高度写成米、推进剂写成克,结果所有梯度都集中在质量变量上,状态变量几乎不动。

我的习惯是:先把物理量纲归一化,长度用某个特征长度、时间用某个特征时间、质量用初始质量,让所有状态都落在 0.01 到 100 之间。这个习惯省掉的麻烦,远远大于换算时花掉的 10 分钟。bounds的上界和下界之差也不要跨越 8 个数量级以上,至少对状态变量要保持这个纪律。

初值方面,铁律是:guess必须严格落在bounds的内部,不要等于边界。把猜测放在边界上,NLP 求解器第一步的可行性恢复就会碰到边界上的不可导点。另外,guess不一定非要有物理意义,但最好和真实最优解线性相关。解完一轮之后,把solution.phase{1}直接放回setup.guess.phase再跑一次,是一种简单有效的 warm-start,很多收敛问题的最后一根稻草就是这一步。

5. GPOPS-4.1 避坑记录:3 类高发问题与排查方法

5.1 一上来就 NaN,或者 IPOPT 直接报 Invalid number

现象:求解日志出现Invalid number in NLP,或者 IPOPT 迭代几步之后所有变量变成 NaN,整个任务中止。如果是 fmincon,则会提示目标函数或约束返回了NaN/Inf。

原因:最常见的是动力学里有除法,除到了零或负数。软着陆例子里T * u ./ m,如果初值里质量被猜成 0,或者网格细化过程某个配点上的质量越过下界,一旦 m 接近 0,导数直接爆炸。另一个原因是在 continuous 函数里写了不该写的分支逻辑,让自动微分产生了 NaN 梯度。

解决:第一步检查所有分母变量的bounds下限,质量这类物理量下限不要设 0,给 0.1 这类安全值;第二步检查初值是否落在bounds内部;第三步把setup.nlp.solver临时切成'fmincon',它报错信息更直白,能定位到具体是哪个函数返回异常。等 fmincon 能跑出结果,再换回 IPOPT,多数情况下问题就消失了。

5.2 bang-bang 控制变成了锯齿波:网格配点不够

现象:控制曲线解出来是一串高频振荡,物理上应该是“最大推力-零推力-再最大推力”这种干脆的开关切换,数值解却像 PWM 波形。轨迹的目标函数值离理论最优不远,但控制形状明显不对。

原因:这是配点转录法的典型现象。开关切换点处的控制不连续,需要网格在切换点附近足够密才能用一段陡峭的斜率逼近理想阶跃;固定网格或过粗的 hp 网格会把切换点光滑化,用振荡来补偿逼近误差。如果mesh.tolerance设得比较松,比如 1e-4,GPOPS 会认为这个震荡解已经“够好”了。

解决:把setup.mesh.tolerance收紧到 1e-7 或 1e-8,然后重点检查最终网格节点是否集中在切换时刻附近。可以画出每轮细化之后的配点分布,正常情况下节点会在切换点两侧明显加密。如果加密之后仍然震荡,把初始colpoints提高到 12 以上,给第一轮更多逼近能力。

5.3 解算“先加速后减速”,物理直觉明显不对

现象:某一次求解出来的轨迹满足所有边界条件,目标函数值也确实更小,但控制律是先加速再减速,甚至有一段奇怪的滑行弧,怎么看都不像工程上会用的方案。

原因:这通常是局部最优。GPOPS 不保证找到全局最优,NLP 求解器从某个初值出发收敛到的只是它附近的局部解。我遇到过一个更隐蔽的情况:问题本身有多个局部解,初值给在中间平淡区域,求解器就卡在了一个没有实际意义的奇异弧上。

解决:把初值往物理直觉上靠。软着陆就直接给“全推力减速到接近停机,再小推力修正”的控制轮廓;轨道转移就按工程经验把轨道分成几段,每段给一个常值控制。更系统化的做法是 continuation:先固定终端时间为较短的值,解出一个简单解,再把它作为 guess 放到终端时间自由的问题里继续求解。这个过程可以迭代多次,每一轮都在扩大解空间,最终得到的解通常远离局部陷阱。

5.4 换了一台机器就报 Undefined function,连模型都跑不起来

现象:同一个脚本,在一台机器上跑得好好的,换到另一台机器报Undefined function 'ipopt',但 MATLAB 路径里明明有 GPOPS-4.1 的文件夹。

原因:IPOPT 的 mex 接口是编译出来的二进制文件,跟操作系统、MATLAB 版本、编译器选项强相关。GPOPS-4.1 只是把 NLP 问题包装好,真正求解还要靠外部 IPOPT 可执行接口;接口没配好,就会在调用求解器时直接失败。

解决:把“先编译好 IPOPT 接口”当作部署流程的一部分,和 GPOPS 代码分开准备。临时应急方案是先把setup.nlp.solver改成'fmincon',毕竟 MATLAB 自带的求解器在任何机器上都在。如果项目长期要用大规模轨迹优化,建议在团队内部统一容器或基础镜像,把编译好的求解器接口固化下来,避免每次换机器都重新踩一遍编译的坑。

6. 离“可信”差一步:闭环仿真、交叉验证与 warm-start 续延

得到一个看起来漂亮的 open-loop 解之后,别急着拿去用。先做一次闭环仿真:把 GPOPS 算出来的控制序列喂给 ode45,重新积分一遍动力学,看终端状态是否还满足约束。这一步能暴露配点离散的误差,因为 GPOPS 只在配点上保证约束成立,配点之间是插值出来的,节点稀疏时插值误差可能让终端状态偏离。代码上这样写:

sol_time = solution.phase{1}.time; sol_state = solution.phase{1}.state; sol_control = solution.phase{1}.control; u_func = @(t) interp1(sol_time, sol_control, t, 'linear', 'extrap'); [t_cl, x_cl] = ode45(@(t, x) closedLoopDynamics(t, x, u_func), ... [0, sol_time(end)], [100, 0, 1]); % 对比终端状态 fprintf('闭环终端高度: %.4f m\n', x_cl(end, 1)); fprintf('闭环终端速度: %.4f m/s\n', x_cl(end, 2));

closedLoopDynamics里写的是与 continuous 函数相同的动力学,控制量用插值函数读取。如果闭环终端高度和速度偏差超过工程容差,说明网格还不够细,回到mesh.tolerance再收紧一档。这一步是很多团队在实际项目里必做的验证动作,做过之后才敢把轨迹交给底层跟踪控制器。

交叉验证我习惯再用第二套参数重算一遍:比如 tol 分别设 1e-6 和 1e-8,比较两条轨迹的最终燃料消耗。如果两个解的指标差值小于 1e-4 这个量级,说明离散误差已经不是主导因素,这个解可以放心使用;如果差距明显,说明其中一个还没收敛,继续细化网格。GPOPS 自带的协态输出也可以看,但我更推荐这种“双解对比”的方式,它对模型代码没有额外要求,也更容易解释给不熟悉最优控制的同事。

最后一个技巧是 warm-start 续延。复杂问题不要一上来就全自由度求解,先固定终端时间为一个偏短的估计值,解出来,然后把solution.phase{1}塞进setup.guess.phase,放开时间上界再算。这样做每轮求解都从上一轮的可行解附近起步,NLP 迭代次数能少 30% 以上,还大幅降低局部最优风险。这是我自己项目里翻车次数最多的地方——总想一口气解到底,后来养成了固定时间-续延-放开的习惯,批量跑参数扫描时才真正稳下来。希望你也能从一开始就把这步写进脚本,替自己省掉后面的排错时间,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询