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 // A measured wheel speed limit [rad/s], where the platform has one, supersedes the rating:
206 // ratings can be estimates (ARMAR-DE's are, its drive datasheet is unavailable), a
207 // measurement is what the wheels actually reach.
208 if (drive.contains("maxWheelVelocity"))
209 {
210 params.maxWheelVelocity = drive.at("maxWheelVelocity").get<float>();
211 }
212
213 // The motors are not the only bottleneck: the device command ramp is stricter, and it is
214 // what actually rate-limits the base. Without it the profile plans a stop the ramp
215 // cannot execute and the robot sails past the goal.
216 const nlohmann::json& ramp = config.at("commandRateLimit");
217 params.maxAcceleration = ramp.at("maxAcceleration").get<float>();
218 params.maxDeceleration = ramp.at("maxDeceleration").get<float>();
219 params.maxAngularAcceleration = ramp.at("maxAngularAcceleration").get<float>();
220 params.limitAngularAcceleration = ramp.value("limitAngularAcceleration", false);
221
222 return params;
223 }
224
225 bool
227 {
228#if NAVIGATION_TOPPRA_ENABLED
229 return true;
230#else
231 return false;
232#endif
233 }
234
235 std::string
237 {
238#if NAVIGATION_TOPPRA_ENABLED
239 return {};
240#else
241 return NAVIGATION_TOPPRA_DISABLED_REASON;
242#endif
243 }
244
246 {
247 public:
252
253#if NAVIGATION_TOPPRA_ENABLED
254 ~Impl()
255 {
256 if (not reparametrizer_)
257 {
258 return;
259 }
260
261 // Dropping a `py::object` decrements a Python refcount, which needs the GIL just as
262 // much as calling into Python does. A navigator that is torn down and rebuilt --
263 // which happens between navigation requests -- would otherwise corrupt the
264 // interpreter's bookkeeping from whatever thread did the teardown, and the crash
265 // would surface much later, in an unrelated call.
266 const py::gil_scoped_acquire gil;
267 reparametrizer_ = py::object{};
268 }
269#endif
270
273
274#if NAVIGATION_TOPPRA_ENABLED
275 /// Constructed lazily: building it loads the RBDL model, which should not happen while
276 /// the navigation stack is still being assembled.
277 py::object&
278 reparametrizer()
279 {
280 if (reparametrizer_)
281 {
282 return reparametrizer_;
283 }
284
285 const py::module_ module =
286 py::module_::import("armarx_navigation.reparametrization");
287
288 reparametrizer_ = module.attr("Reparametrizer")(
289 py::arg("robot") = module.attr("RobotSpec")(
290 py::arg("file") = drive.robotFile,
291 py::arg("node_set") = drive.nodeSet,
292 py::arg("configuration") = drive.configuration),
293 py::arg("kinematics") = kinematics(module),
294 py::arg("limits") = module.attr("DriveLimits")(
295 py::arg("max_velocity") = config.maxVel.linear,
296 py::arg("max_angular_velocity") = config.maxVel.angular,
297 py::arg("motor_max_torque") = drive.motorMaxTorque,
298 py::arg("gear_ratio") = drive.gearRatio,
299 py::arg("gearbox_efficiency") = drive.gearboxEfficiency,
300 py::arg("torque_fraction") = drive.torqueFraction,
301 py::arg("max_wheel_velocity") = drive.maxWheelVelocity,
302 py::arg("max_acceleration") = drive.maxAcceleration,
303 py::arg("max_deceleration") = drive.maxDeceleration,
304 py::arg("max_angular_acceleration") = drive.maxAngularAcceleration,
305 py::arg("limit_angular_acceleration") = drive.limitAngularAcceleration));
306
307 return reparametrizer_;
308 }
309
310 /// The time-parametrized profile of the most recent call, kept for introspection.
311 Eigen::MatrixXd lastSamples;
312 double lastDuration{0.0};
313
314 private:
315 /// Construct the drive-specific kinematics dataclass.
316 ///
317 /// Which one is a decision, not an inference: a mecanum robot has no omni geometry
318 /// configured at all, and passing the zero-initialised omni defaults gives the solver a
319 /// degenerate Jacobian and an "infeasible" that has nothing to do with the path.
320 py::object
321 kinematics(const py::module_& module) const
322 {
323 switch (drive.driveType)
324 {
326 return module.attr("MecanumKinematics")(
327 py::arg("gauge") = drive.mecanum.gauge,
328 py::arg("wheelbase") = drive.mecanum.wheelbase,
329 py::arg("wheel_radius") = drive.mecanum.wheelRadius);
330
332 return module.attr("OmniWheelKinematics")(
333 py::arg("body_radius") = drive.omniWheel.bodyRadius,
334 py::arg("wheel_radius") = drive.omniWheel.wheelRadius,
335 py::arg("delta") = drive.omniWheel.delta,
336 py::arg("relative_angle") = drive.omniWheel.relativeAngle,
337 py::arg("gear_ratio") = drive.omniWheel.gearRatio,
338 py::arg("invert") =
339 std::vector<bool>{drive.omniWheel.invertWheel.x() != 0,
340 drive.omniWheel.invertWheel.y() != 0,
341 drive.omniWheel.invertWheel.z() != 0});
342
343 default:
344 throw std::runtime_error("Unknown platform drive type.");
345 }
346 }
347
348 /// Deliberately left null rather than initialised to `py::none()`: the member
349 /// initializer runs before `ensureInterpreter()`, so `py::none()` would touch Python
350 /// before the interpreter exists and without the GIL. A null `py::object` is just a
351 /// null pointer, and is the "not built yet" state `reparametrizer()` tests for.
352 py::object reparametrizer_;
353#else
354 public:
355 Eigen::MatrixXd lastSamples;
356 double lastDuration{0.0};
357#endif
358 };
359
360 const Eigen::MatrixXd&
362 {
363 return impl_->lastSamples;
364 }
365
366 double
368 {
369 return impl_->lastDuration;
370 }
371
372 Toppra::Toppra(const core::GeneralConfig& config, const DriveParams& drive) :
373 impl_(std::make_unique<Impl>(config, drive))
374 {
375#if NAVIGATION_TOPPRA_ENABLED
376 // Warm up here rather than on the first `apply()`. Bringing up the interpreter,
377 // importing toppra/scipy and loading the RBDL model costs ~0.5 s and does not depend on
378 // the path, so leaving it lazy would put all of it on the first navigation request --
379 // exactly when the robot is meant to start moving. Paid once while the navigation stack
380 // is being constructed it is invisible, and every request then costs ~20 ms.
381 const auto started = std::chrono::steady_clock::now();
382
383 ensureInterpreter();
384
385 {
386 const py::gil_scoped_acquire gil;
387
388 try
389 {
390 impl_->reparametrizer();
391 }
392 catch (const py::error_already_set& error)
393 {
394 throw std::runtime_error(
395 std::string{"TOPP-RA reparametrization could not be initialised: "} +
396 error.what());
397 }
398 }
399
400 ARMARX_INFO << "TOPP-RA ready in "
401 << std::chrono::duration<double, std::milli>(
402 std::chrono::steady_clock::now() - started)
403 .count()
404 << " ms (interpreter, imports and robot model).";
405#endif
406 }
407
408 Toppra::~Toppra() = default;
409
410 void
412 [[maybe_unused]] float startVelocity) const
413 {
414#if not NAVIGATION_TOPPRA_ENABLED
415 throw std::runtime_error("TOPP-RA reparametrization not available because " +
417#else
418 const std::vector<core::GlobalTrajectoryPoint>& input = trajectory.points();
419
420 // [x, y, yaw, velocity_limit] -- the planner's obstacle-aware bound is carried through
421 // so the reparametrization cannot speed up next to an obstacle.
422 Eigen::MatrixXd waypoints(static_cast<Eigen::Index>(input.size()), 4);
423 for (Eigen::Index i = 0; i < waypoints.rows(); i++)
424 {
425 const core::GlobalTrajectoryPoint& point = input[static_cast<std::size_t>(i)];
426 const core::Position& position = point.waypoint.pose.translation();
427
428 waypoints(i, 0) = position.x();
429 waypoints(i, 1) = position.y();
430 waypoints(i, 2) = yawOf(point.waypoint.pose);
431 waypoints(i, 3) = point.velocity;
432 }
433
434 // Printed on stdout with the same marker the python side uses, so a caller filtering
435 // for it gets one continuous breakdown. Both are one-time costs in a long-lived
436 // process -- the interpreter and the imported modules outlive any single call -- so
437 // they are reported separately from the solve rather than folded into it.
438 const auto timed = [](const char* label, auto&& work)
439 {
440 const auto started = std::chrono::steady_clock::now();
441 work();
442 const double ms = std::chrono::duration<double, std::milli>(
443 std::chrono::steady_clock::now() - started)
444 .count();
445
446 std::cout << " [reparametrization] " << std::left << std::setw(32) << label
447 << std::right << std::setw(8) << std::fixed << std::setprecision(1) << ms
448 << " ms" << std::endl;
449 };
450
451 // Already warmed up in the constructor; this is a no-op unless something finalized the
452 // interpreter behind our back.
453 ensureInterpreter();
454
455 const py::gil_scoped_acquire gil;
456
457 Eigen::MatrixXd samples;
458 double duration = 0.0;
459
460 try
461 {
462 timed("solve + sample (total)",
463 [&]
464 {
465 const py::object result =
466 impl_->reparametrizer()(waypoints, impl_->drive.numSamples);
467
468 samples = result.attr("samples").cast<Eigen::MatrixXd>();
469 duration = result.attr("duration").cast<double>();
470 });
471 }
472 catch (const py::error_already_set& error)
473 {
474 throw std::runtime_error(std::string{"TOPP-RA reparametrization failed: "} +
475 error.what());
476 }
477
479
480 if (samples.rows() < 2)
481 {
482 throw std::runtime_error("TOPP-RA returned fewer than 2 waypoints.");
483 }
484
485 // Keep the time axis before it is dropped -- see `lastSamples()`.
486 impl_->lastSamples = samples;
487 impl_->lastDuration = duration;
488
489 std::vector<core::GlobalTrajectoryPoint> points;
490 points.reserve(static_cast<std::size_t>(samples.rows()));
491
492 // The velocity floor exists for one reason: a GlobalTrajectory assigns a velocity to a
493 // *position*, so `v(s = 0) = 0` is a fixed point -- the controller reads zero at the pose
494 // it is standing in, commands zero, and the robot never leaves the start.
495 //
496 // That argument is about the start and nothing else, but the floor used to be applied to
497 // the whole profile, which had two consequences, both seen on ARMAR-7:
498 //
499 // * at the goal the robot arrived still commanded at `boundaryVelocity` and overshot by
500 // `v^2 / 2a` once the controller switched to its final-segment law -- 45-59 mm on
501 // every recorded run;
502 // * mid-path it *overrode this solver's own answer*. Where the orientation profile turns
503 // sharply, TOPP-RA slows down so that `yaw'(s) * v` stays inside the angular limit;
504 // `max(v, boundaryVelocity)` threw that away. One recorded run had 47 consecutive
505 // samples pinned at exactly 150 mm/s across a stretch whose orientation profile then
506 // demanded 3.9 rad/s against a 0.8 rad/s limit, and the robot spent seven seconds
507 // saturated, alternating direction and crawling at the feed-forward's floor.
508 //
509 // So the floor is applied only to the leading stretch -- up to the first sample this
510 // solver's own profile reaches it -- and the rest of the profile stands as computed. The
511 // transition is continuous by construction: at that sample the two are equal. The tail
512 // keeps a much smaller floor (`goalVelocity`) so the fixed-point argument still cannot
513 // bite anywhere, while being far too low to override a turn-limited speed.
514 const double boundaryFloor = impl_->config.boundaryVelocity;
515 const double goalFloor =
516 std::min<double>(impl_->config.goalVelocity, impl_->config.boundaryVelocity);
517
518 Eigen::Index leadingEnd = 0;
519 while (leadingEnd < samples.rows() and samples(leadingEnd, VELOCITY) < boundaryFloor)
520 {
521 leadingEnd++;
522 }
523
524 for (Eigen::Index i = 0; i < samples.rows(); i++)
525 {
526 core::Pose pose = core::Pose::Identity();
527 pose.translation() << static_cast<float>(samples(i, X)),
528 static_cast<float>(samples(i, Y)), 0.F;
529 pose.linear() = Eigen::AngleAxisf(static_cast<float>(samples(i, YAW)),
530 Eigen::Vector3f::UnitZ())
531 .toRotationMatrix();
532
533 // See the floor's derivation above.
534 const double floorVelocity = i <= leadingEnd ? boundaryFloor : goalFloor;
535
536 points.push_back(core::GlobalTrajectoryPoint{
537 .waypoint = {.pose = pose},
538 .velocity = std::max(static_cast<float>(samples(i, VELOCITY)),
539 static_cast<float>(floorVelocity))});
540 }
541
542 ARMARX_INFO << "TOPP-RA: " << points.size() << " waypoints, " << duration << " s.";
543
544 // Rebuilt point by point rather than with `FromPath`, which derives intermediate
545 // headings from the movement direction and keeps only the given start and goal poses --
546 // that would discard the orientation profile the planner optimized and this carried
547 // through, and put a step at each end wherever the two disagree.
549#endif
550 }
551
552} // 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:248
double lastDuration() const
Duration [s] of the profile behind lastSamples(). Zero until apply() has run.
Definition Toppra.cpp:367
static bool available()
Whether the python side was found at configure time. False means apply() throws.
Definition Toppra.cpp:226
static std::string unavailableReason()
Why it is unavailable, as recorded at configure time. Empty when available.
Definition Toppra.cpp:236
Toppra(const core::GeneralConfig &config, const DriveParams &drive)
Definition Toppra.cpp:372
const Eigen::MatrixXd & lastSamples() const
The time-parametrized profile from the most recent apply(), (M, 8).
Definition Toppra.cpp:361
void apply(core::GlobalTrajectory &trajectory, float startVelocity) const override
Re-assign the velocities of trajectory in place.
Definition Toppra.cpp:411
#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:128
float maxAcceleration
Device command ramp. Infinite leaves the profile bounded by torque alone.
Definition Toppra.h:131
bool limitAngularAcceleration
Whether maxAngularAcceleration bounds the plan's yaw acceleration.
Definition Toppra.h:138
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