Toppra.cpp
Go to the documentation of this file.
1/**
2 * This file is part of ArmarX.
3 *
4 * ArmarX is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License version 2 as
6 * published by the Free Software Foundation.
7 *
8 * ArmarX is distributed in the hope that it will be useful, but
9 * WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 *
16 * @author Fabian Reister ( fabian dot reister at kit dot edu )
17 * @date 2026
18 * @copyright http://www.gnu.org/licenses/gpl-2.0.txt
19 * GNU General Public License
20 */
21
22#include "Toppra.h"
23
24#include <algorithm>
25#include <chrono>
26#include <cmath>
27#include <fstream>
28#include <iomanip>
29#include <iostream>
30#include <sstream>
31#include <stdexcept>
32#include <vector>
33
34#include <Eigen/Geometry>
35
36#include <SimoxUtility/json/json.hpp>
37
41
42#if NAVIGATION_TOPPRA_ENABLED
43#include <pybind11/eigen.h>
44#include <pybind11/embed.h>
45#include <pybind11/stl.h>
46
47namespace py = pybind11;
48#endif
49
51{
52
53 namespace
54 {
55 float
56 yawOf(const core::Pose& pose)
57 {
58 return std::atan2(pose.linear()(1, 0), pose.linear()(0, 0));
59 }
60
61#if NAVIGATION_TOPPRA_ENABLED
62 /**
63 * @brief Brings up the one interpreter this process gets.
64 *
65 * `py::scoped_interpreter` may not be constructed twice, and something else in the
66 * process may have started Python already, so guard on `Py_IsInitialized()` and keep the
67 * guard alive for the process lifetime. `site-packages` has to be added by hand: an
68 * embedded interpreter does not pick up a virtual environment on its own.
69 *
70 * `site.addsitedir`, not `sys.path.append`: appending a directory to `sys.path` does not
71 * process the `.pth` files in it, and an editable install (`pip install -e`, which is
72 * what Axii does) puts *only* a `.pth` and a finder module in site-packages -- there is
73 * no package directory to find. Appending therefore leaves the package invisible with
74 * "No module named 'armarx_navigation'". It happens to work when the venv is activated,
75 * because then the interpreter processes the `.pth` at startup, which is exactly why
76 * this hid from every test run through an activated shell and only appeared in the
77 * navigator.
78 */
79 void
80 ensureInterpreter()
81 {
82 static const bool initialized = []
83 {
84 if (Py_IsInitialized() == 0)
85 {
86 // Leaked deliberately: finalizing while other holders still exist crashes.
87 new py::scoped_interpreter{};
88
89 // `scoped_interpreter` leaves the GIL held by whichever thread got here
90 // first and never gives it back. The analysis application never notices --
91 // it is the only thread there is -- but the navigator answers each request
92 // on an Ice dispatch thread, so the second request is served by a different
93 // thread, whose `gil_scoped_acquire` then waits on a lock nobody will ever
94 // release. Hand the GIL back here; every use site acquires it explicitly.
95 //
96 // Leaked for the same reason as the interpreter: this must outlive every
97 // use, and re-acquiring at process exit is exactly the finalization we are
98 // avoiding.
99 new py::gil_scoped_release{};
100 }
101
102 const py::gil_scoped_acquire gil;
103
104 py::module_::import("site").attr("addsitedir")(
105 NAVIGATION_TOPPRA_SITE_PACKAGES);
106
107 ARMARX_INFO << "Embedded Python for TOPP-RA, site-packages "
108 << NAVIGATION_TOPPRA_SITE_PACKAGES;
109
110 return true;
111 }();
112
113 (void)initialized;
114 }
115#endif
116 } // namespace
117
118 std::filesystem::path
119 DriveParamsPath(const std::string& robot)
120 {
121 return armarx::PackagePath("armarx_navigation",
122 "config/platform/PlatformDynamics" + robot + ".json")
123 .toSystemPath();
124 }
125
126 Toppra::DriveParams
127 LoadDriveParams(const std::filesystem::path& configFile)
128 {
129 if (not std::filesystem::exists(configFile))
130 {
131 throw std::runtime_error("No platform dynamics config at " + configFile.string());
132 }
133
134 std::ifstream stream{configFile};
135 if (not stream.good())
136 {
137 throw std::runtime_error("Cannot read platform dynamics config at " +
138 configFile.string());
139 }
140
141 const nlohmann::json config = nlohmann::json::parse(stream);
142
143 Toppra::DriveParams params;
144
145 // Which drive the platform has is a decision, not an inference: a mecanum robot has no
146 // omni geometry in its config at all, and the zero-initialised omni defaults would reach
147 // the solver as a degenerate Jacobian and an "infeasible" unrelated to the path.
148 const bool mecanum = config.value("model", std::string{}) == "mecanum_torque";
149
150 const nlohmann::json& drive =
151 mecanum ? config.at("mecanumTorque") : config.at("omniWheelTorque");
152
153 if (mecanum)
154 {
155 const nlohmann::json& geometry = config.at("mecanum");
156
158 params.mecanum.gauge = geometry.at("gauge").get<float>();
159 params.mecanum.wheelbase = geometry.at("wheelbase").get<float>();
160 params.mecanum.wheelRadius = geometry.at("wheelRadius").get<float>();
161 }
162 else
163 {
164 const nlohmann::json& geometry = config.at("omniWheel");
165
166 // The JSON carries degrees; `OmniWheelPlatformKinematicsParams` wants radians.
167 constexpr float degToRad = static_cast<float>(M_PI) / 180.F;
168
170 params.omniWheel.bodyRadius = geometry.at("bodyRadius").get<float>();
171 params.omniWheel.wheelRadius = geometry.at("wheelRadius").get<float>();
172 params.omniWheel.delta =
173 geometry.at("angularPositionFirstWheelDegrees").get<float>() * degToRad;
174 params.omniWheel.relativeAngle =
175 geometry.at("relativeAngleDegrees").get<float>() * degToRad;
176 params.omniWheel.gearRatio = geometry.value("gearRatio", 1.F);
177
178 const std::vector<bool> invert =
179 geometry.value("invertWheel", std::vector<bool>{false, false, false});
180 ARMARX_CHECK_EQUAL(invert.size(), 3U);
181 params.omniWheel.invertWheel =
182 Eigen::Vector3i{invert[0] ? 1 : 0, invert[1] ? 1 : 0, invert[2] ? 1 : 0};
183 }
184
185 const nlohmann::json& robot = drive.at("robot");
186 params.robotFile =
187 armarx::PackagePath(robot.at("package").get<std::string>(),
188 robot.at("file").get<std::string>())
189 .toSystemPath();
190 params.nodeSet = robot.at("nodeSet").get<std::string>();
191 params.configuration = robot.at("configuration").get<std::string>();
192
193 params.motorMaxTorque = drive.at("motorMaxTorque").get<float>();
194 params.gearRatio = drive.at("gearRatio").get<float>();
195 params.gearboxEfficiency = drive.value("gearboxEfficiency", 1.F);
196 params.torqueFraction = drive.value("torqueFraction", 1.F);
197
198 // Motor/gearbox speed rating referred to the wheel, always in rad/s regardless of the
199 // drive's own unit convention.
200 const float inputSpeedRpm = std::min(drive.at("motorNominalSpeedRpm").get<float>(),
201 drive.at("gearboxMaxInputSpeedRpm").get<float>());
202 params.maxWheelVelocity =
203 inputSpeedRpm / 60.F / params.gearRatio * 2.F * static_cast<float>(M_PI);
204
205 // The motors are not the only bottleneck: the device command ramp is stricter, and it is
206 // what actually rate-limits the base. Without it the profile plans a stop the ramp
207 // cannot execute and the robot sails past the goal.
208 const nlohmann::json& ramp = config.at("commandRateLimit");
209 params.maxAcceleration = ramp.at("maxAcceleration").get<float>();
210 params.maxDeceleration = ramp.at("maxDeceleration").get<float>();
211 params.maxAngularAcceleration = ramp.at("maxAngularAcceleration").get<float>();
212
213 return params;
214 }
215
216 bool
218 {
219#if NAVIGATION_TOPPRA_ENABLED
220 return true;
221#else
222 return false;
223#endif
224 }
225
226 std::string
228 {
229#if NAVIGATION_TOPPRA_ENABLED
230 return {};
231#else
232 return NAVIGATION_TOPPRA_DISABLED_REASON;
233#endif
234 }
235
237 {
238 public:
243
244#if NAVIGATION_TOPPRA_ENABLED
245 ~Impl()
246 {
247 if (not reparametrizer_)
248 {
249 return;
250 }
251
252 // Dropping a `py::object` decrements a Python refcount, which needs the GIL just as
253 // much as calling into Python does. A navigator that is torn down and rebuilt --
254 // which happens between navigation requests -- would otherwise corrupt the
255 // interpreter's bookkeeping from whatever thread did the teardown, and the crash
256 // would surface much later, in an unrelated call.
257 const py::gil_scoped_acquire gil;
258 reparametrizer_ = py::object{};
259 }
260#endif
261
264
265#if NAVIGATION_TOPPRA_ENABLED
266 /// Constructed lazily: building it loads the RBDL model, which should not happen while
267 /// the navigation stack is still being assembled.
268 py::object&
269 reparametrizer()
270 {
271 if (reparametrizer_)
272 {
273 return reparametrizer_;
274 }
275
276 const py::module_ module =
277 py::module_::import("armarx_navigation.reparametrization");
278
279 reparametrizer_ = module.attr("Reparametrizer")(
280 py::arg("robot") = module.attr("RobotSpec")(
281 py::arg("file") = drive.robotFile,
282 py::arg("node_set") = drive.nodeSet,
283 py::arg("configuration") = drive.configuration),
284 py::arg("kinematics") = kinematics(module),
285 py::arg("limits") = module.attr("DriveLimits")(
286 py::arg("max_velocity") = config.maxVel.linear,
287 py::arg("max_angular_velocity") = config.maxVel.angular,
288 py::arg("motor_max_torque") = drive.motorMaxTorque,
289 py::arg("gear_ratio") = drive.gearRatio,
290 py::arg("gearbox_efficiency") = drive.gearboxEfficiency,
291 py::arg("torque_fraction") = drive.torqueFraction,
292 py::arg("max_wheel_velocity") = drive.maxWheelVelocity,
293 py::arg("max_acceleration") = drive.maxAcceleration,
294 py::arg("max_deceleration") = drive.maxDeceleration,
295 py::arg("max_angular_acceleration") = drive.maxAngularAcceleration));
296
297 return reparametrizer_;
298 }
299
300 /// The time-parametrized profile of the most recent call, kept for introspection.
301 Eigen::MatrixXd lastSamples;
302 double lastDuration{0.0};
303
304 private:
305 /// Construct the drive-specific kinematics dataclass.
306 ///
307 /// Which one is a decision, not an inference: a mecanum robot has no omni geometry
308 /// configured at all, and passing the zero-initialised omni defaults gives the solver a
309 /// degenerate Jacobian and an "infeasible" that has nothing to do with the path.
310 py::object
311 kinematics(const py::module_& module) const
312 {
313 switch (drive.driveType)
314 {
316 return module.attr("MecanumKinematics")(
317 py::arg("gauge") = drive.mecanum.gauge,
318 py::arg("wheelbase") = drive.mecanum.wheelbase,
319 py::arg("wheel_radius") = drive.mecanum.wheelRadius);
320
322 return module.attr("OmniWheelKinematics")(
323 py::arg("body_radius") = drive.omniWheel.bodyRadius,
324 py::arg("wheel_radius") = drive.omniWheel.wheelRadius,
325 py::arg("delta") = drive.omniWheel.delta,
326 py::arg("relative_angle") = drive.omniWheel.relativeAngle,
327 py::arg("gear_ratio") = drive.omniWheel.gearRatio,
328 py::arg("invert") =
329 std::vector<bool>{drive.omniWheel.invertWheel.x() != 0,
330 drive.omniWheel.invertWheel.y() != 0,
331 drive.omniWheel.invertWheel.z() != 0});
332
333 default:
334 throw std::runtime_error("Unknown platform drive type.");
335 }
336 }
337
338 /// Deliberately left null rather than initialised to `py::none()`: the member
339 /// initializer runs before `ensureInterpreter()`, so `py::none()` would touch Python
340 /// before the interpreter exists and without the GIL. A null `py::object` is just a
341 /// null pointer, and is the "not built yet" state `reparametrizer()` tests for.
342 py::object reparametrizer_;
343#else
344 public:
345 Eigen::MatrixXd lastSamples;
346 double lastDuration{0.0};
347#endif
348 };
349
350 const Eigen::MatrixXd&
352 {
353 return impl_->lastSamples;
354 }
355
356 double
358 {
359 return impl_->lastDuration;
360 }
361
362 Toppra::Toppra(const core::GeneralConfig& config, const DriveParams& drive) :
363 impl_(std::make_unique<Impl>(config, drive))
364 {
365#if NAVIGATION_TOPPRA_ENABLED
366 // Warm up here rather than on the first `apply()`. Bringing up the interpreter,
367 // importing toppra/scipy and loading the RBDL model costs ~0.5 s and does not depend on
368 // the path, so leaving it lazy would put all of it on the first navigation request --
369 // exactly when the robot is meant to start moving. Paid once while the navigation stack
370 // is being constructed it is invisible, and every request then costs ~20 ms.
371 const auto started = std::chrono::steady_clock::now();
372
373 ensureInterpreter();
374
375 {
376 const py::gil_scoped_acquire gil;
377
378 try
379 {
380 impl_->reparametrizer();
381 }
382 catch (const py::error_already_set& error)
383 {
384 throw std::runtime_error(
385 std::string{"TOPP-RA reparametrization could not be initialised: "} +
386 error.what());
387 }
388 }
389
390 ARMARX_INFO << "TOPP-RA ready in "
391 << std::chrono::duration<double, std::milli>(
392 std::chrono::steady_clock::now() - started)
393 .count()
394 << " ms (interpreter, imports and robot model).";
395#endif
396 }
397
398 Toppra::~Toppra() = default;
399
400 void
402 [[maybe_unused]] float startVelocity) const
403 {
404#if not NAVIGATION_TOPPRA_ENABLED
405 throw std::runtime_error("TOPP-RA reparametrization not available because " +
407#else
408 const std::vector<core::GlobalTrajectoryPoint>& input = trajectory.points();
409
410 // [x, y, yaw, velocity_limit] -- the planner's obstacle-aware bound is carried through
411 // so the reparametrization cannot speed up next to an obstacle.
412 Eigen::MatrixXd waypoints(static_cast<Eigen::Index>(input.size()), 4);
413 for (Eigen::Index i = 0; i < waypoints.rows(); i++)
414 {
415 const core::GlobalTrajectoryPoint& point = input[static_cast<std::size_t>(i)];
416 const core::Position& position = point.waypoint.pose.translation();
417
418 waypoints(i, 0) = position.x();
419 waypoints(i, 1) = position.y();
420 waypoints(i, 2) = yawOf(point.waypoint.pose);
421 waypoints(i, 3) = point.velocity;
422 }
423
424 // Printed on stdout with the same marker the python side uses, so a caller filtering
425 // for it gets one continuous breakdown. Both are one-time costs in a long-lived
426 // process -- the interpreter and the imported modules outlive any single call -- so
427 // they are reported separately from the solve rather than folded into it.
428 const auto timed = [](const char* label, auto&& work)
429 {
430 const auto started = std::chrono::steady_clock::now();
431 work();
432 const double ms = std::chrono::duration<double, std::milli>(
433 std::chrono::steady_clock::now() - started)
434 .count();
435
436 std::cout << " [reparametrization] " << std::left << std::setw(32) << label
437 << std::right << std::setw(8) << std::fixed << std::setprecision(1) << ms
438 << " ms" << std::endl;
439 };
440
441 // Already warmed up in the constructor; this is a no-op unless something finalized the
442 // interpreter behind our back.
443 ensureInterpreter();
444
445 const py::gil_scoped_acquire gil;
446
447 Eigen::MatrixXd samples;
448 double duration = 0.0;
449
450 try
451 {
452 timed("solve + sample (total)",
453 [&]
454 {
455 const py::object result =
456 impl_->reparametrizer()(waypoints, impl_->drive.numSamples);
457
458 samples = result.attr("samples").cast<Eigen::MatrixXd>();
459 duration = result.attr("duration").cast<double>();
460 });
461 }
462 catch (const py::error_already_set& error)
463 {
464 throw std::runtime_error(std::string{"TOPP-RA reparametrization failed: "} +
465 error.what());
466 }
467
469
470 if (samples.rows() < 2)
471 {
472 throw std::runtime_error("TOPP-RA returned fewer than 2 waypoints.");
473 }
474
475 // Keep the time axis before it is dropped -- see `lastSamples()`.
476 impl_->lastSamples = samples;
477 impl_->lastDuration = duration;
478
479 std::vector<core::GlobalTrajectoryPoint> points;
480 points.reserve(static_cast<std::size_t>(samples.rows()));
481
482 for (Eigen::Index i = 0; i < samples.rows(); i++)
483 {
484 core::Pose pose = core::Pose::Identity();
485 pose.translation() << static_cast<float>(samples(i, X)),
486 static_cast<float>(samples(i, Y)), 0.F;
487 pose.linear() = Eigen::AngleAxisf(static_cast<float>(samples(i, YAW)),
488 Eigen::Vector3f::UnitZ())
489 .toRotationMatrix();
490
491 // A GlobalTrajectory assigns a velocity to a *position*, not to a time. TOPP-RA
492 // starts and ends at rest, and v(s = 0) = 0 is a fixed point of that
493 // representation: the controller looks up the velocity at the current position,
494 // commands zero and the robot never leaves the start. The stack's own answer is
495 // boundaryVelocity, which applyRamping uses for the same reason. Applied to the
496 // whole profile rather than the endpoints, because clamping only the last waypoint
497 // turns a sub-millimetre segment into a step of several thousand mm/s^2.
498 points.push_back(core::GlobalTrajectoryPoint{
499 .waypoint = {.pose = pose},
500 .velocity = std::max(static_cast<float>(samples(i, VELOCITY)),
501 impl_->config.boundaryVelocity)});
502 }
503
504 ARMARX_INFO << "TOPP-RA: " << points.size() << " waypoints, " << duration << " s.";
505
506 // Rebuilt point by point rather than with `FromPath`, which derives intermediate
507 // headings from the movement direction and keeps only the given start and goal poses --
508 // that would discard the orientation profile the planner optimized and this carried
509 // through, and put a step at each end wherever the two disagree.
511#endif
512 }
513
514} // namespace armarx::navigation::algorithms
#define M_PI
Definition MathTools.h:17
static std::filesystem::path toSystemPath(const data::PackagePath &pp)
Impl(const core::GeneralConfig &config, const DriveParams &drive)
Definition Toppra.cpp:239
double lastDuration() const
Duration [s] of the profile behind lastSamples(). Zero until apply() has run.
Definition Toppra.cpp:357
static bool available()
Whether the python side was found at configure time. False means apply() throws.
Definition Toppra.cpp:217
static std::string unavailableReason()
Why it is unavailable, as recorded at configure time. Empty when available.
Definition Toppra.cpp:227
Toppra(const core::GeneralConfig &config, const DriveParams &drive)
Definition Toppra.cpp:362
const Eigen::MatrixXd & lastSamples() const
The time-parametrized profile from the most recent apply(), (M, 8).
Definition Toppra.cpp:351
void apply(core::GlobalTrajectory &trajectory, float startVelocity) const override
Re-assign the velocities of trajectory in place.
Definition Toppra.cpp:401
#define ARMARX_CHECK_EQUAL(lhs, rhs)
This macro evaluates whether lhs is equal (==) rhs and if it turns out to be false it will throw an E...
#define ARMARX_INFO
The normal logging level.
Definition Logging.h:179
This file is part of ArmarX.
Toppra::DriveParams LoadDriveParams(const std::filesystem::path &configFile)
Read Toppra::DriveParams from a PlatformDynamics<Robot>.json.
Definition Toppra.cpp:127
std::filesystem::path DriveParamsPath(const std::string &robot)
Resolve config/platform/PlatformDynamics<robot>.json inside the armarx_navigation package.
Definition Toppra.cpp:119
Eigen::Isometry3f Pose
Definition basic_types.h:31
Eigen::Vector3f Position
Definition basic_types.h:36
Everything the python side needs that does not change between paths.
Definition Toppra.h:105
float maxWheelVelocity
Motor/gearbox speed rating referred to the wheel [rad/s], always in rad/s regardless of the drive's o...
Definition Toppra.h:126
float maxAcceleration
Device command ramp. Infinite leaves the profile bounded by torque alone.
Definition Toppra.h:129
DriveType driveType
Selects which of the two geometries below is used.
Definition Toppra.h:112
std::string robotFile
Robot model the inverse dynamics is built from.
Definition Toppra.h:107
float gauge
Half the lateral wheel spacing (l1).
Definition Toppra.h:95
float wheelbase
Half the longitudinal wheel spacing (l2).
Definition Toppra.h:98