想象一下,当你操控无人机穿越狭窄的走廊时,或者让机械臂在精密装配线上工作时,粗糙的直线路径会导致什么后果?无人机可能会因为急转弯而失控,机械臂的震动可能损坏精密零件。这正是Minimum Snap轨迹规划技术要解决的核心问题。
Minimum Snap(最小加加速度)是一种基于多项式函数的轨迹优化方法,它通过最小化轨迹的"snap"(位置的四阶导数,即加加速度的变化率)来生成平滑、能量效率高的运动路径。这项技术最初由宾夕法尼亚大学的Daniel Mellinger教授在2011年提出,现已成为机器人运动规划领域的黄金标准。
对于机器人开发者而言,理解Minimum Snap的数学原理只是第一步,真正的挑战在于如何将其转化为高效、可靠的代码实现。本文将带你从零开始,用C++构建一个完整的Minimum Snap轨迹生成器,解决从理论推导到工程实现中的各种实际问题。
Minimum Snap的核心是矩阵运算,我们将使用Eigen库来处理线性代数运算。Eigen是一个高性能的C++模板库,特别适合处理矩阵和向量运算。
bash复制# Ubuntu系统安装Eigen
sudo apt-get install libeigen3-dev
对于QP(二次规划)问题的求解,我们选择OOQP(Object-Oriented Quadratic Programming)库:
bash复制# 安装OOQP
sudo apt-get install libooqp-dev
一个良好的项目结构能显著提高开发效率。建议采用如下目录结构:
code复制minimum_snap/
├── include/ # 头文件
│ ├── trajectory_generator.h
│ └── ...
├── src/ # 源文件
│ ├── trajectory_generator.cpp
│ └── ...
├── third_party/ # 第三方库
├── CMakeLists.txt # 构建配置
└── test/ # 测试代码
cmake复制cmake_minimum_required(VERSION 3.10)
project(minimum_snap)
set(CMAKE_CXX_STANDARD 17)
find_package(Eigen3 REQUIRED)
find_package(OOQP REQUIRED)
include_directories(
${EIGEN3_INCLUDE_DIR}
${OOQP_INCLUDE_DIRS}
include
)
add_library(minimum_snap STATIC
src/trajectory_generator.cpp
)
add_executable(test_trajectory test/test_trajectory.cpp)
target_link_libraries(test_trajectory minimum_snap ${OOQP_LIBRARIES})
Q矩阵是Minimum Snap问题的核心,它代表了snap平方的积分。对于每段轨迹,我们需要构造对应的Q矩阵。
cpp复制Eigen::MatrixXd TrajectoryGenerator::calculateQMatrix(int n, double T) {
Eigen::MatrixXd Q = Eigen::MatrixXd::Zero(n+1, n+1);
// 只填充右上三角部分(包括对角线)
for(int i = 4; i <= n; ++i) {
for(int j = 4; j <= n; ++j) {
int k = i + j - 7; // 指数部分
double coeff = (i*(i-1)*(i-2)*(i-3) * j*(j-1)*(j-2)*(j-3)) / k;
Q(i,j) = coeff * pow(T, k);
}
}
// 由于Q是对称矩阵,复制右上三角到左下三角
for(int i = 0; i <= n; ++i) {
for(int j = 0; j < i; ++j) {
Q(j,i) = Q(i,j);
}
}
return Q;
}
A矩阵用于将多项式系数与端点约束(位置、速度、加速度等)联系起来。
cpp复制Eigen::MatrixXd TrajectoryGenerator::calculateAMatrix(int n, double T) {
Eigen::MatrixXd A = Eigen::MatrixXd::Zero(2*(n/2+1), n+1);
// 起点约束 (t=0)
for(int k = 0; k <= n/2; ++k) {
for(int i = k; i <= n; ++i) {
if(i == k) {
A(k, i) = factorial(i) / factorial(i - k);
} else {
A(k, i) = 0;
}
}
}
// 终点约束 (t=T)
for(int k = 0; k <= n/2; ++k) {
for(int i = k; i <= n; ++i) {
A(k + n/2 + 1, i) = (factorial(i) / factorial(i - k)) * pow(T, i - k);
}
}
return A;
}
C矩阵用于处理连续性约束,将固定变量和自由变量分开。
cpp复制Eigen::MatrixXd TrajectoryGenerator::calculateCMatrix(int segments, int n) {
int constraints_per_segment = 2*(n/2+1);
int total_constraints = segments * constraints_per_segment;
int fixed_vars = 2*(n/2+1) + (segments-1)*(n/2);
int free_vars = (segments-1)*(n/2+1);
Eigen::MatrixXd C = Eigen::MatrixXd::Zero(total_constraints, fixed_vars + free_vars);
// 构建C矩阵的具体实现...
// 这里需要根据段数和多项式阶数来正确设置连续性约束
return C;
}
合理的时间分配对生成平滑轨迹至关重要。我们采用基于最大速度和加速度的启发式方法:
cpp复制std::vector<double> TrajectoryGenerator::allocateTime(
const std::vector<Eigen::Vector3d>& waypoints,
double max_vel, double max_acc) {
std::vector<double> time_intervals;
double dist, vel, acc_time, const_time;
for(size_t i = 0; i < waypoints.size()-1; ++i) {
dist = (waypoints[i+1] - waypoints[i]).norm();
// 计算加速阶段所需时间
vel = std::min(max_vel, std::sqrt(max_acc * dist));
acc_time = vel / max_acc;
const_time = (dist - max_acc * acc_time * acc_time) / vel;
// 总时间为加速、匀速和减速时间之和
time_intervals.push_back(2*acc_time + const_time);
}
return time_intervals;
}
在某些情况下,初始时间分配可能不够理想,我们可以实现一个简单的自适应调整策略:
cpp复制void TrajectoryGenerator::adjustTimeAllocation(
std::vector<double>& times,
const std::vector<Eigen::Vector3d>& waypoints,
double factor) {
for(size_t i = 0; i < times.size(); ++i) {
double dist = (waypoints[i+1] - waypoints[i]).norm();
double new_time = times[i] * factor;
// 确保时间调整不会导致速度/加速度超过限制
double min_time = std::sqrt(dist / max_acc_);
times[i] = std::max(new_time, min_time);
}
}
将Minimum Snap问题转化为标准的QP形式:
cpp复制void TrajectoryGenerator::setupQPProblem(
const std::vector<Eigen::Vector3d>& waypoints,
const std::vector<double>& times) {
int segments = waypoints.size() - 1;
int n = 2*(kDerivativeOrder + 1) - 1; // 多项式阶数
// 构造全局Q矩阵
Eigen::MatrixXd Q_total = Eigen::MatrixXd::Zero(segments*(n+1), segments*(n+1));
for(int i = 0; i < segments; ++i) {
Q_total.block(i*(n+1), i*(n+1), n+1, n+1) = calculateQMatrix(n, times[i]);
}
// 构造约束矩阵
Eigen::MatrixXd A_total = constructTotalConstraintMatrix(segments, n, times);
// 构造置换矩阵C
Eigen::MatrixXd C = calculateCMatrix(segments, n);
// 构造QP问题的H矩阵和g向量
H_ = C * A_total.inverse().transpose() * Q_total * A_total.inverse() * C.transpose();
g_ = Eigen::VectorXd::Zero(H_.rows());
// 设置固定约束
setupFixedConstraints(waypoints, n);
}
使用OOQP求解器解决构建好的QP问题:
cpp复制bool TrajectoryGenerator::solveQP() {
// 初始化OOQP问题
QpGenSparseMa27* qp = new QpGenSparseMa27(...);
// 设置QP参数
qp->copyHessian(H_);
qp->copyLinearTerm(g_);
// 设置约束
// ...
// 求解
QpGenData* prob = qp->copyData();
QpGenVars* vars = qp->makeVariables(prob);
QpGenResiduals* resid = qp->makeResiduals(prob);
GondzioSolver* s = new GondzioSolver(qp, prob);
int status = s->solve(prob, vars, resid);
// 处理结果
if(status == 0) {
extractSolution(vars);
return true;
}
return false;
}
高次多项式计算可能遇到数值不稳定问题,特别是当时间区间较大时:
cpp复制Eigen::VectorXd TrajectoryGenerator::evaluatePolynomial(
const Eigen::VectorXd& coeffs,
double t, int derivative) {
// 使用Horner's method提高数值稳定性
Eigen::VectorXd result = Eigen::VectorXd::Zero(3); // 三维空间
for(int dim = 0; dim < 3; ++dim) {
double val = 0.0;
for(int i = coeffs.rows()/3 - 1; i >= derivative; --i) {
int actual_i = dim * (coeffs.rows()/3) + i;
double scale = 1.0;
for(int d = 0; d < derivative; ++d) {
scale *= (i - d);
}
val = val * t + coeffs(actual_i) * scale;
}
result(dim) = val;
}
return result;
}
生成轨迹后,必须验证其是否符合动力学约束:
cpp复制bool TrajectoryGenerator::validateTrajectory(
const Trajectory& traj,
double max_vel,
double max_acc,
double dt) {
for(double t = 0; t <= traj.getDuration(); t += dt) {
Eigen::Vector3d vel = traj.evaluate(t, 1);
Eigen::Vector3d acc = traj.evaluate(t, 2);
if(vel.norm() > max_vel * 1.05 || acc.norm() > max_acc * 1.05) {
return false;
}
}
return true;
}
cpp复制typedef Eigen::SparseMatrix<double> SpMat;
SpMat TrajectoryGenerator::calculateSparseQMatrix(int n, double T) {
SpMat Q(n+1, n+1);
std::vector<Eigen::Triplet<double>> tripletList;
for(int i = 4; i <= n; ++i) {
for(int j = 4; j <= n; ++j) {
int k = i + j - 7;
double coeff = (i*(i-1)*(i-2)*(i-3) * j*(j-1)*(j-2)*(j-3)) / k;
tripletList.emplace_back(i, j, coeff * pow(T, k));
}
}
Q.setFromTriplets(tripletList.begin(), tripletList.end());
return Q;
}
cpp复制std::vector<Eigen::MatrixXd> TrajectoryGenerator::calculateAllQMatricesParallel(
const std::vector<double>& times) {
std::vector<Eigen::MatrixXd> Q_matrices(times.size());
int n = 2*(kDerivativeOrder + 1) - 1;
#pragma omp parallel for
for(size_t i = 0; i < times.size(); ++i) {
Q_matrices[i] = calculateQMatrix(n, times[i]);
}
return Q_matrices;
}
cpp复制class TrajectoryGenerator {
public:
struct Trajectory {
std::vector<Eigen::VectorXd> coefficients;
std::vector<double> segment_times;
Eigen::VectorXd evaluate(double t, int derivative = 0) const;
double getDuration() const;
};
TrajectoryGenerator(double max_vel, double max_acc);
Trajectory generateTrajectory(
const std::vector<Eigen::Vector3d>& waypoints,
const std::vector<double>& initial_times = {});
void setMaxVelocity(double vel) { max_vel_ = vel; }
void setMaxAcceleration(double acc) { max_acc_ = acc; }
private:
// 之前介绍的各种私有方法...
double max_vel_;
double max_acc_;
};
cpp复制int main() {
// 初始化轨迹生成器
TrajectoryGenerator traj_gen(2.0, 1.0); // 最大速度2m/s,最大加速度1m/s²
// 定义路径点
std::vector<Eigen::Vector3d> waypoints = {
{0, 0, 0},
{1, 1, 0.5},
{2, 0, 1},
{3, -1, 0.5}
};
// 生成轨迹
auto trajectory = traj_gen.generateTrajectory(waypoints);
// 评估轨迹
for(double t = 0; t <= trajectory.getDuration(); t += 0.1) {
auto pos = trajectory.evaluate(t);
auto vel = trajectory.evaluate(t, 1);
auto acc = trajectory.evaluate(t, 2);
std::cout << "t=" << t
<< " pos=[" << pos.transpose() << "]"
<< " vel=[" << vel.transpose() << "]"
<< " acc=[" << acc.transpose() << "]" << std::endl;
}
return 0;
}
Minimum Snap通常作为运动规划的后端,与前端的路径搜索算法(如A*、RRT*)结合使用:
cpp复制class MotionPlanner {
public:
MotionPlanner() {
path_finder_ = std::make_unique<AStar>();
traj_generator_ = std::make_unique<TrajectoryGenerator>(1.0, 0.5);
}
TrajectoryGenerator::Trajectory plan(
const Eigen::Vector3d& start,
const Eigen::Vector3d& goal,
const OccupancyGrid& map) {
// 1. 使用A*搜索粗略路径
auto rough_path = path_finder_->search(start, goal, map);
// 2. 简化路径,提取关键航点
auto waypoints = simplifyPath(rough_path);
// 3. 生成平滑轨迹
return traj_generator_->generateTrajectory(waypoints);
}
private:
std::unique_ptr<PathFinder> path_finder_;
std::unique_ptr<TrajectoryGenerator> traj_generator_;
};
当检测到轨迹与障碍物碰撞时,可以插入新的航点并重新规划:
cpp复制TrajectoryGenerator::Trajectory avoidObstacles(
const TrajectoryGenerator::Trajectory& original,
const std::vector<Obstacle>& obstacles) {
for(double t = 0; t <= original.getDuration(); t += 0.1) {
auto pos = original.evaluate(t);
for(const auto& obs : obstacles) {
if(obs.checkCollision(pos)) {
// 在碰撞点前后插入新航点
auto new_waypoint = calculateBypassPoint(pos, obs);
// 重新生成轨迹
return traj_generator_->generateTrajectory(
insertWaypoint(original.waypoints, new_waypoint));
}
}
}
return original;
}
对于多机器人系统,可以在代价函数中加入机器人间的避让约束:
cpp复制void addCollisionAvoidanceConstraints(
QpGenData* qp_data,
const std::vector<Trajectory>& other_robot_trajs,
double safety_margin) {
// 对于每个时间点,添加机器人间距离约束
// 这会将问题转化为QCQP(二次约束二次规划),需要支持QCQP的求解器
// 具体实现取决于所使用的QP求解器
}
轨迹不连续:
数值不稳定:
求解失败:
利用RViz或Matplotlib等工具可视化轨迹及其导数:
python复制# Python示例使用matplotlib可视化
import matplotlib.pyplot as plt
import numpy as np
def plot_trajectory(coefficients, times):
t = np.linspace(0, sum(times), 1000)
pos = [evaluate_poly(coefficients, ti) for ti in t]
vel = [evaluate_poly(coefficients, ti, 1) for ti in t]
acc = [evaluate_poly(coefficients, ti, 2) for ti in t]
plt.figure(figsize=(12, 8))
plt.subplot(3, 1, 1)
plt.plot(t, pos)
plt.title('Position')
plt.subplot(3, 1, 2)
plt.plot(t, vel)
plt.title('Velocity')
plt.subplot(3, 1, 3)
plt.plot(t, acc)
plt.title('Acceleration')
plt.tight_layout()
plt.show()
使用性能分析工具(如gprof或perf)识别热点函数:
bash复制# 使用gprof进行性能分析
g++ -pg -O2 -std=c++17 -o trajectory trajectory.cpp
./trajectory
gprof trajectory gmon.out > analysis.txt
常见优化方向:
cpp复制class DroneController {
public:
void executeTrajectory(const TrajectoryGenerator::Trajectory& traj) {
double t = 0;
while(t <= traj.getDuration()) {
auto target_pos = traj.evaluate(t);
auto target_vel = traj.evaluate(t, 1);
// 发送控制命令
sendControlCommand(target_pos, target_vel);
// 更新时间
t += control_period_;
// 处理实际位置与目标位置的偏差
handlePositionError();
}
}
private:
double control_period_ = 0.01; // 100Hz控制频率
};
cpp复制class RobotArmPlanner {
public:
void planJointSpaceTrajectory(
const std::vector<JointState>& waypoints) {
// 将关节空间坐标转换为Eigen向量
std::vector<Eigen::VectorXd> eigen_waypoints;
for(const auto& wp : waypoints) {
eigen_waypoints.push_back(wp.toVector());
}
// 为每个关节生成独立轨迹
for(int i = 0; i < num_joints_; ++i) {
std::vector<Eigen::Vector3d> single_joint_wps;
for(const auto& wp : eigen_waypoints) {
single_joint_wps.emplace_back(0, 0, wp(i)); // 只使用z轴表示关节角度
}
joint_trajectories_[i] = traj_gen_.generateTrajectory(single_joint_wps);
}
}
private:
TrajectoryGenerator traj_gen_;
std::vector<TrajectoryGenerator::Trajectory> joint_trajectories_;
int num_joints_ = 6;
};
将时间变量也作为优化变量,实现真正的时间最优轨迹:
cpp复制class TimeOptimalTrajectory : public TrajectoryGenerator {
public:
Trajectory generateTrajectory(
const std::vector<Eigen::Vector3d>& waypoints) override {
// 使用非线性优化同时优化时间和轨迹参数
// 这需要更高级的优化技术如SQP(序列二次规划)
}
};
考虑机器人动力学模型的高级规划:
cpp复制class DynamicsAwareTrajectory : public TrajectoryGenerator {
public:
Trajectory generateTrajectory(
const std::vector<Eigen::Vector3d>& waypoints) override {
// 在QP问题中添加动力学约束
// 例如:电机扭矩限制、推力限制等
}
};
使用机器学习模型预测最优时间分配:
cpp复制class MLTimeAllocator {
public:
std::vector<double> predictTimeAllocation(
const std::vector<Eigen::Vector3d>& waypoints) {
// 使用预训练模型预测每段时间
// 可以作为QP问题的初始猜测
}
};
| 求解器 | 求解时间(ms) | 内存使用(MB) | 支持问题规模 |
|---|---|---|---|
| OOQP | 12.3 | 45 | ~1000变量 |
| OSQP | 8.7 | 32 | ~5000变量 |
| Mosek | 5.2 | 28 | ~10000变量 |
| Eigen LDLT | 1.5 | 15 | ~500变量 |
| 阶数 | 平滑度 | 计算成本 | 最大加速度 |
|---|---|---|---|
| 3 | 低 | 低 | 高 |
| 5 | 中 | 中 | 中 |
| 7 | 高 | 高 | 低 |
| 9 | 很高 | 很高 | 很低 |
参数选择指南:
实时性考虑:
鲁棒性设计:
由于篇幅限制,这里展示核心QP问题构建的完整实现:
cpp复制bool MinimumSnapPlanner::solve() {
// 1. 构造每段轨迹的Q矩阵
std::vector<Eigen::MatrixXd> Q_matrices;
for(double T : segment_times_) {
Q_matrices.push_back(calculateQMatrix(poly_order_, T));
}
// 2. 构造全局Q矩阵
Eigen::MatrixXd Q_total = Eigen::MatrixXd::Zero(
(poly_order_+1)*segment_times_.size(),
(poly_order_+1)*segment_times_.size());
for(size_t i = 0; i < segment_times_.size(); ++i) {
Q_total.block(
i*(poly_order_+1),
i*(poly_order_+1),
poly_order_+1,
poly_order_+1) = Q_matrices[i];
}
// 3. 构造约束矩阵A
Eigen::MatrixXd A_total = constructConstraintMatrix();
// 4. 构造置换矩阵C
Eigen::MatrixXd C = constructCMatrix();
// 5. 构建QP问题
Eigen::MatrixXd H = C * A_total.inverse().transpose() * Q_total * A_total.inverse() * C.transpose();
Eigen::VectorXd g = Eigen::VectorXd::Zero(H.rows());
// 6. 设置固定约束
Eigen::VectorXd d_fixed = getFixedConstraints();
// 7. 调用QP求解器
if(!qp_solver_->solve(H, g, C, d_fixed)) {
return false;
}
// 8. 提取解并计算多项式系数
Eigen::VectorXd d_p = qp_solver_->getSolution();
Eigen::VectorXd d_full = C.transpose() * d_p;
coefficients_ = A_total.inverse() * d_full;
return true;
}
完善的测试是确保算法可靠性的关键:
cpp复制TEST(MinimumSnapTest, StraightLineTrajectory) {
TrajectoryGenerator traj_gen(1.0, 0.5);
std::vector<Eigen::Vector3d> waypoints = {
{0, 0, 0},
{1, 0, 0},
{2, 0, 0}
};
auto traj = traj_gen.generateTrajectory(waypoints);
// 验证起点和终点位置
EXPECT_LT((traj.evaluate(0) - waypoints[0]).norm(), 1e-6);
EXPECT_LT((traj.evaluate(traj.getDuration()) - waypoints.back()).norm(), 1e-6);
// 验证最大速度不超过限制
double max_vel = 0;
for(double t = 0; t <= traj.getDuration(); t += 0.01) {
double vel = traj.evaluate(t, 1).norm();
max_vel = std::max(max_vel, vel);
}
EXPECT_LE(max_vel, 1.0 * 1.05); // 允许5%的超调
}
cpp复制class MinimumSnapROS : public node_handle::NodeHandle {
public:
MinimumSnapROS() {
waypoints_sub_ = subscribe("/waypoints", 10, &MinimumSnapROS::waypointsCallback, this);
trajectory_pub_ = advertise<nav_msgs::Path>("/trajectory", 1);
}
void waypointsCallback(const geometry_msgs::PoseArray::ConstPtr& msg) {
std::vector<Eigen::Vector3d> waypoints;
for(const auto& pose : msg->poses) {
waypoints.emplace_back(pose.position.x, pose.position.y, pose.position.z);
}
auto traj = traj_gen_.generateTrajectory(waypoints);
publishTrajectory(traj);
}
private:
TrajectoryGenerator traj_gen_;
ros::Subscriber waypoints_sub_;
ros::Publisher trajectory_pub_;
};
cpp复制void simulateTrajectory(const TrajectoryGenerator::Trajectory& traj) {
GazeboSimulator sim;
sim.resetDrone();
for(double t = 0; t <= traj.getDuration(); t += 0.01) {
auto pos = traj.evaluate(t);
auto vel = traj.evaluate(t, 1);
sim.setDroneTarget(pos, vel);
sim.step(0.01);
if(sim.checkCollision()) {
std::cerr << "Collision detected at t=" << t << std::endl;
break;
}
}
}
利用Eigen的表达式模板和延迟求值:
cpp复制Eigen::MatrixXd optimizedQMatrix(int n, double T) {
Eigen::MatrixXd Q = Eigen::MatrixXd::Zero(n+1, n+1);
// 使用Eigen的向量化操作
Eigen::ArrayXd i_arr = Eigen::ArrayXd::LinSpaced(n-3, 4, n);
Eigen::ArrayXd j_arr = Eigen::ArrayXd::LinSpaced(n-3, 4, n);
for(int j = 4; j <= n; ++j) {
Q.col(j).segment(4, j-3) =
(i_arr * (i_arr-1) * (i_arr-2) * (i_arr-3) *
j * (j-1) * (j-2) * (j-3)) /
(i_arr + j - 7) * pow(T, i_arr + j - 7);
}
// 对称拷贝
Q.triangularView<Eigen::Lower>() = Q.transpose();
return Q;
}
避免动态内存分配带来的性能开销:
cpp复制class TrajectoryGenerator {
public:
void preallocateMemory(int max_segments) {
int n = 2*(kDerivativeOrder + 1) - 1;
int size = (n+1) * max_segments;
Q_total_.resize(size, size);
A_total_.resize(2*(n/2+1)*max_segments, size);
// 其他矩阵预分配...
}
private:
Eigen::MatrixXd Q_total_;
Eigen::MatrixXd A_total_;
// 其他矩阵...
};
cmake复制# 处理不同平台的依赖
if(UNIX AND NOT APPLE)
find_package(OOQP REQUIRED)
elseif(APPLE)
# MacOS特定设置
set(OOQP_DIR "/usr/local/opt/ooqp")
find_package(OOQP REQUIRED)
elseif(WIN32)
# Windows特定设置
set(OOQP_DIR "C:/libs/ooqp")
find_package(OOQP REQUIRED)
endif()
cpp复制#if defined(__AVX2__)
#define USE_SIMD 1
#include <immintrin.h>
#endif
void optimizedMatrixMultiply(const double* A, const double* B, double* C, int n) {
#if USE_SIMD
// 使用AVX2指令集优化
__m256d a, b, c;
for(int i = 0; i < n; i += 4) {
// SIMD矩阵乘法实现...
}
#else
// 标准实现
Eigen::Map<const Eigen::MatrixXd> A_eigen(A, n, n);
Eigen::Map<const Eigen::MatrixXd> B_eigen(B, n, n);
Eigen::Map<Eigen::MatrixXd> C_eigen(C, n, n);
C_eigen = A_eigen * B_eigen;
#endif
}
核心论文:
开源实现:
在线课程:
| 库名称 | 功能描述 | 网址 |
|---|---|---|
| Eigen | 线性代数运算 | https://eigen.tuxfamily.org/ |
| OSQP | 二次规划求解器 | https://osqp.org/ |
| NLopt | 非线性优化 | https://nlopt.readthedocs.io/ |
| Matplotlib | 轨迹可视化(Python) | https://matplotlib.org/ |
yaml复制name: CI
on: [push, pull_request]
jobs:
build:
runs