TrajectoryFollowingSimulation.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
23
24#include <algorithm>
25#include <cmath>
26#include <filesystem>
27#include <fstream>
28#include <stdexcept>
29#include <string>
30#include <optional>
31#include <vector>
32
33#include <Eigen/Geometry>
34
35#include <SimoxUtility/json/json.hpp>
36#include <VirtualRobot/MathTools.h>
37
41
43{
44
46 PlatformDynamics::FromPackagePath(const std::string& robot)
47 {
48 const armarx::PackagePath packagePath(
49 "armarx_navigation", "config/platform/PlatformDynamics" + robot + ".json");
50 const std::string filename = packagePath.toSystemPath();
51
52 ARMARX_CHECK(std::filesystem::exists(filename))
53 << "PlatformDynamics config file does not exist: " << filename;
54
55 std::ifstream ifs{filename};
56 const nlohmann::json j = nlohmann::json::parse(ifs);
57
58 PlatformDynamics dynamics;
59 dynamics.maxLinearAcceleration =
60 j.at("maxLinearAcceleration").get<float>(); // [mm/s^2]
61 dynamics.maxAngularAcceleration =
62 j.at("maxAngularAcceleration").get<float>(); // [rad/s^2]
63
66
67 const std::string model = j.value("model", std::string{"cartesian"});
68 if (model == "cartesian")
69 {
71 }
72 else if (model == "omni_wheel_velocity")
73 {
75 }
76 else if (model == "omni_wheel_torque")
77 {
79 }
80 else if (model == "mecanum_torque")
81 {
83 }
84 else
85 {
86 throw std::invalid_argument("Unknown platform model `" + model +
87 "`. Expected `cartesian`, `omni_wheel_velocity`, "
88 "`omni_wheel_torque` or `mecanum_torque`.");
89 }
90
91 if (j.contains("omniWheel"))
92 {
93 const nlohmann::json& jWheel = j.at("omniWheel");
94 OmniWheelDrive& drive = dynamics.omniWheel;
95
96 const std::vector<bool> invert = jWheel.at("invertWheel");
97 ARMARX_CHECK_EQUAL(invert.size(), 3);
98
99 drive.kinematics.L = jWheel.at("bodyRadius").get<float>();
100 drive.kinematics.R = jWheel.at("wheelRadius").get<float>();
101 drive.kinematics.delta = VirtualRobot::MathTools::deg2rad(
102 jWheel.at("angularPositionFirstWheelDegrees").get<float>());
103 drive.kinematics.n = jWheel.at("gearRatio").get<float>();
104 drive.kinematics.relativeAngle = VirtualRobot::MathTools::deg2rad(
105 jWheel.at("relativeAngleDegrees").get<float>());
106 drive.kinematics.wheelFactor = Eigen::Vector3f{invert[0] ? -1.F : 1.F,
107 invert[1] ? -1.F : 1.F,
108 invert[2] ? -1.F : 1.F};
109
110 drive.maxWheelVelocity = jWheel.at("maxWheelVelocity").get<float>();
111 drive.maxWheelAcceleration = jWheel.at("maxWheelAcceleration").get<float>();
112 drive.maxWheelDeceleration = jWheel.at("maxWheelDeceleration").get<float>();
113
116 }
117
118 if (j.contains("mecanum"))
119 {
120 const nlohmann::json& jWheel = j.at("mecanum");
121 MecanumDrive& drive = dynamics.mecanum;
122
123 // l1/l2 are the *half* spacings, matching Doroftei et al. and ARMAR-6's
124 // platformHalfWidth / platformHalfHeight.
125 drive.kinematics.l1 = jWheel.at("gauge").get<float>();
126 drive.kinematics.l2 = jWheel.at("wheelbase").get<float>();
127 drive.kinematics.R = jWheel.at("wheelRadius").get<float>();
128
129 drive.maxWheelVelocity = jWheel.value("maxWheelVelocity", drive.maxWheelVelocity);
131 jWheel.value("maxWheelAcceleration", drive.maxWheelAcceleration);
133 jWheel.value("maxWheelDeceleration", drive.maxWheelDeceleration);
134
135 ARMARX_CHECK_GREATER(drive.kinematics.R, 0.F);
138 }
139
140 // The drive-train block is wheel-count agnostic, so both platforms share the struct.
141 // Mecanum robots name it `mecanumTorque` for symmetry with their geometry block.
142 const std::string torqueKey =
143 j.contains("mecanumTorque") ? "mecanumTorque" : "omniWheelTorque";
144
145 if (j.contains(torqueKey))
146 {
147 const nlohmann::json& jTorque = j.at(torqueKey);
148 OmniWheelTorqueDrive& drive = dynamics.omniWheelTorque;
149
150 drive.gearRatio = jTorque.value("gearRatio", drive.gearRatio);
151 drive.motorMaxTorque = jTorque.value("motorMaxTorque", drive.motorMaxTorque);
152 drive.motorRatedTorque = jTorque.value("motorRatedTorque", drive.motorRatedTorque);
154 jTorque.value("motorNominalSpeedRpm", drive.motorNominalSpeedRpm);
156 jTorque.value("gearboxMaxInputSpeedRpm", drive.gearboxMaxInputSpeedRpm);
157 drive.gearboxEfficiency =
158 jTorque.value("gearboxEfficiency", drive.gearboxEfficiency);
159 drive.maxWheelVelocity = jTorque.value("maxWheelVelocity", drive.maxWheelVelocity);
160
161 // A derating rather than a hardware property: it exists so a platform whose real
162 // torque is unknown or implausible can be made to behave like a known one. See
163 // PlatformDynamicsArmarDE.json.
164 drive.motorMaxTorque *= jTorque.value("torqueFraction", 1.F);
165
169
170 if (jTorque.contains("robot"))
171 {
172 const nlohmann::json& jRobot = jTorque.at("robot");
173 drive.robot.robotPackage = jRobot.value("package", drive.robot.robotPackage);
174 drive.robot.robotFile = jRobot.value("file", drive.robot.robotFile);
175 drive.robot.nodeSet = jRobot.value("nodeSet", drive.robot.nodeSet);
176 drive.robot.configuration =
177 jRobot.value("configuration", drive.robot.configuration);
178 }
179 }
180
182 {
183 ARMARX_INFO << "Simulated platform: omni-wheel drive with per-motor torque limit "
184 << dynamics.omniWheelTorque.motorMaxTorque << " N m, gear "
185 << dynamics.omniWheelTorque.gearRatio << ":1.";
186 }
187 else if (dynamics.model == PlatformModel::OmniWheelVelocity)
188 {
189 ARMARX_INFO << "Simulated platform: omni-wheel drive, per-wheel limits "
190 << dynamics.omniWheel.maxWheelAcceleration << " / "
191 << dynamics.omniWheel.maxWheelDeceleration << " rev/s^2.";
192 }
193 else
194 {
195 ARMARX_INFO << "Simulated platform: Cartesian limits "
196 << dynamics.maxLinearAcceleration << " mm/s^2, "
197 << dynamics.maxAngularAcceleration << " rad/s^2.";
198 }
199
200 return dynamics;
201 }
202
203 namespace
204 {
205
206 float
207 yawOf(const core::Pose& pose)
208 {
209 return std::atan2(pose.linear()(1, 0), pose.linear()(0, 0));
210 }
211
212 /**
213 * @brief One axis of the platform device's command ramp.
214 *
215 * Ported from `LinearLimitedAccelerationController::update` in
216 * `armar7_omni/joint_controller/Velocity.cpp`, including its quirks: acceleration and
217 * deceleration are distinguished by *magnitude* (the sign flip mirrors the problem for
218 * negative velocities), and standstill takes the deceleration branch because the test
219 * is `currentValue <= 0`.
220 */
221 class AxisRateLimit
222 {
223 public:
224 AxisRateLimit(const float maxVelocity,
225 const float maxAcceleration,
226 const float maxDeceleration) :
227 maxVelocity_(maxVelocity),
228 maxAcceleration_(maxAcceleration),
229 maxDeceleration_(maxDeceleration)
230 {
231 }
232
233 float
234 update(const float value, const float dt)
235 {
236 float delta = value - current_;
237 float sign = 1.F;
238
239 if (current_ <= 0.F)
240 {
241 delta = -delta;
242 sign = -1.F;
243 }
244
245 delta = delta < 0.F ? -std::min(maxDeceleration_ * dt, -delta)
246 : std::min(maxAcceleration_ * dt, delta);
247
248 current_ = std::clamp(current_ + delta * sign, -maxVelocity_, maxVelocity_);
249
250 return current_;
251 }
252
253 private:
254 float maxVelocity_;
255
256 float maxAcceleration_;
257
258 float maxDeceleration_;
259
260 /// Persistent, exactly as on the device: the ramp resumes from the last command.
261 float current_{0.F};
262 };
263
264 /// Port of `PlanarLimitedAccelerationController` from the `armar7_omni` device.
265 class PlanarRateLimit
266 {
267 public:
268 PlanarRateLimit(const float maxVelocity,
269 const float maxAcceleration,
270 const float maxDeceleration) :
271 maxVelocity_(maxVelocity),
272 maxAcceleration_(maxAcceleration),
273 maxDeceleration_(maxDeceleration)
274 {
275 }
276
277 Eigen::Vector2f
278 update(const Eigen::Vector2f& value, const float dt)
279 {
280 const Eigen::Vector2f target = limitNorm(value);
281 const Eigen::Vector2f delta = target - current_;
282 const float distance = delta.norm();
283
284 if (distance > 0.F)
285 {
286 const float bound =
287 (target.norm() >= current_.norm() ? maxAcceleration_ : maxDeceleration_) *
288 dt;
289
290 current_ += delta * std::min(1.F, bound / distance);
291 }
292
293 current_ = limitNorm(current_);
294
295 return current_;
296 }
297
298 private:
299 Eigen::Vector2f
300 limitNorm(const Eigen::Vector2f& v) const
301 {
302 const float norm = v.norm();
303
304 return norm > maxVelocity_ ? Eigen::Vector2f{v * (maxVelocity_ / norm)} : v;
305 }
306
307 float maxVelocity_;
308
309 float maxAcceleration_;
310
311 float maxDeceleration_;
312
313 Eigen::Vector2f current_{Eigen::Vector2f::Zero()};
314 };
315
317 poseFrom(const Eigen::Vector2f& position, const float yaw)
318 {
319 core::Pose pose = core::Pose::Identity();
320 pose.translation() << position.x(), position.y(), 0.F;
321 pose.linear() = Eigen::AngleAxisf(yaw, Eigen::Vector3f::UnitZ()).toRotationMatrix();
322
323 return pose;
324 }
325
326 } // namespace
327
329 params_(params)
330 {
331 ARMARX_CHECK_GREATER(params_.dt, 0.F);
332 ARMARX_CHECK_GREATER(params_.maxDuration, 0.F);
333 }
334
337 const core::Pose& start) const
338 {
339 ARMARX_CHECK(not trajectory.points().empty());
340
341 // The controller is stateful (PID terms and the trajectory projection index), so a single
342 // instance has to be used for the whole run, exactly as the real controller does.
344
345 const Eigen::Vector2f goalPosition =
346 trajectory.points().back().waypoint.pose.translation().head<2>();
347
348 // Direction of the last segment, used to tell "still approaching" from "past the end".
349 const Eigen::Vector2f goalDirection =
350 (goalPosition -
351 trajectory.points().at(trajectory.points().size() - 2).waypoint.pose.translation()
352 .head<2>())
353 .normalized();
354
355 std::size_t settledSteps = 0;
356
357 Eigen::Vector2f position = start.translation().head<2>();
358 float yaw = yawOf(start);
359
360 // Mirrors `Twist2D filteredTwist` of the platform controller, including its zero
361 // initialization in `rtPreActivateController()`.
362 Eigen::Vector2f filteredLinear = Eigen::Vector2f::Zero();
363 float filteredAngular = 0.F;
364
365 Eigen::Vector2f previousVelocityGlobal = Eigen::Vector2f::Zero();
366 float previousAngularVelocity = 0.F;
367
368 // The device ramp runs in the RT loop (1 kHz), the controller in its additional task
369 // (10 ms). Step the ramp at its own rate, otherwise it is an order of magnitude coarser
370 // than the real one.
371 const CommandRateLimit& rateLimit = params_.rateLimit;
372 AxisRateLimit rampX(
373 rateLimit.maxVelocity, rateLimit.maxAcceleration, rateLimit.maxDeceleration);
374 AxisRateLimit rampY(
375 rateLimit.maxVelocity, rateLimit.maxAcceleration, rateLimit.maxDeceleration);
376 PlanarRateLimit rampLinear(
377 rateLimit.maxVelocity, rateLimit.maxAcceleration, rateLimit.maxDeceleration);
378 AxisRateLimit rampYaw(rateLimit.maxAngularVelocity,
379 rateLimit.maxAngularAcceleration,
380 rateLimit.maxAngularDeceleration);
381
382 const auto rampSubSteps =
383 std::max<std::size_t>(1, static_cast<std::size_t>(std::lround(
384 params_.dt / std::max(rateLimit.dt, 1e-6F))));
385
386 if (rateLimit.enabled)
387 {
388 ARMARX_INFO << "Modelling the device command ramp: " << rateLimit.maxAcceleration
389 << " / " << rateLimit.maxDeceleration << " mm/s^2, "
390 << rateLimit.maxAngularAcceleration << " / "
391 << rateLimit.maxAngularDeceleration << " rad/s^2, " << rampSubSteps
392 << " sub-steps of " << rateLimit.dt << " s per control cycle, "
393 << (rateLimit.directionPreserving ? "direction-preserving"
394 : "per-axis")
395 << ".";
396 }
397 else
398 {
399 ARMARX_INFO << "Device command ramp disabled: the raw controller twist goes "
400 "straight to the platform.";
401 }
402
403 // What the mass is actually doing, as opposed to what it was told to do.
404 Eigen::Vector2f velocityGlobal = Eigen::Vector2f::Zero();
405 float angularVelocity = 0.F;
406
407 const float maxDeltaV = params_.dynamics.maxLinearAcceleration * params_.dt;
408 const float maxDeltaOmega = params_.dynamics.maxAngularAcceleration * params_.dt;
409
410 const bool usesWheels = params_.dynamics.model != PlatformModel::Cartesian;
411 const bool usesMecanum = params_.dynamics.model == PlatformModel::MecanumTorque;
412
413 // Both drives reduce to the same pair of matrices, so nothing below this point needs to
414 // know how many wheels there are or which platform it is.
415 //
416 // jacobianMM (3, N) wheel -> [mm/s, mm/s, rad/s]
417 // inverseJacobianMM (N, 3) [mm/s, mm/s, rad/s] -> wheel
418 //
419 // Simox supplies both directions explicitly for the mecanum drive (J and J_inv, which
420 // satisfy J * J_inv = I), so no pseudo-inverse is involved even though the matrices are
421 // not square. The omni drive's C() is square and its inverse is the inverse model.
422 //
423 // Unit conventions differ between the two and are absorbed here: C() takes wheel
424 // **rev/s**, J() takes **rad/s**.
425 Eigen::MatrixXf jacobianMM;
426 Eigen::MatrixXf inverseJacobianMM;
427
428 if (usesMecanum)
429 {
430 jacobianMM = params_.dynamics.mecanum.kinematics.J();
431 inverseJacobianMM = params_.dynamics.mecanum.kinematics.J_inv();
432 }
433 else
434 {
435 jacobianMM = params_.dynamics.omniWheel.kinematics.C();
436 inverseJacobianMM = jacobianMM.inverse();
437 }
438
439 const Eigen::Index wheelCount = jacobianMM.cols();
440
441 const float maxWheelVelocity = usesMecanum
442 ? params_.dynamics.mecanum.maxWheelVelocity
443 : params_.dynamics.omniWheel.maxWheelVelocity;
444 Eigen::VectorXf wheelVelocities = Eigen::VectorXf::Zero(wheelCount);
445
446 // --- Torque model setup -------------------------------------------------------------
447 const OmniWheelTorqueDrive& drive = params_.dynamics.omniWheelTorque;
448 const bool usesTorque = params_.dynamics.model == PlatformModel::OmniWheelTorque or
449 params_.dynamics.model == PlatformModel::MecanumTorque;
450
451 std::optional<PlatformInertia> inertia;
452 Eigen::MatrixXd wheelJacobianT = Eigen::MatrixXd::Identity(3, 3);
453 Eigen::MatrixXd inverseWheelJacobianT = Eigen::MatrixXd::Identity(3, 3);
454 float maxWheelSpeedFromDrive = 0.F;
455
456 if (usesTorque)
457 {
458 inertia.emplace(drive.robot);
459
460 // Promote both matrices to SI: wheel rate -> body twist [m/s, m/s, rad/s]. Only the
461 // two linear rows carry the mm->m factor. The omni drive additionally needs the
462 // 2*pi that turns its rev/s into rad/s; the mecanum drive is already in rad/s.
463 const Eigen::Matrix3d mmToM = Eigen::Vector3d(1e-3, 1e-3, 1.0).asDiagonal();
464 const double toRadians = usesMecanum ? 1.0 : (2.0 * M_PI);
465
466 const Eigen::MatrixXd jacobianSI =
467 mmToM * jacobianMM.cast<double>() / toRadians;
468 const Eigen::MatrixXd inverseJacobianSI =
469 inverseJacobianMM.cast<double>() * mmToM.inverse() * toRadians;
470
471 // Virtual work: tau_wheel = J^T F_body, and dually F_body = J_inv^T tau_wheel.
472 // Taking the second from `inverseJacobianSI` rather than inverting the first is what
473 // makes this work for a non-square (four-wheel) drive.
474 wheelJacobianT = jacobianSI.transpose();
475 inverseWheelJacobianT = inverseJacobianSI.transpose();
476
477 // The gearbox input rating binds before the motor on ARMAR-7, so take the min
478 // rather than assuming which one is lower.
479 const float inputSpeedRpm =
480 std::min(drive.motorNominalSpeedRpm, drive.gearboxMaxInputSpeedRpm);
481 // rpm at the gearbox input -> wheel speed in the drive's *native* unit. The
482 // division yields rev/s, which is already what the omni model wants; the mecanum
483 // model works in rad/s and needs the 2*pi. Note this is the reciprocal of the
484 // `toRadians` used for the Jacobian above -- that one converts the wheel unit *into*
485 // rad/s, this one converts *out of* rev/s.
486 const double toNativeWheelUnit = usesMecanum ? (2.0 * M_PI) : 1.0;
487 maxWheelSpeedFromDrive = inputSpeedRpm / 60.F / drive.gearRatio *
488 static_cast<float>(toNativeWheelUnit);
489
490 ARMARX_IMPORTANT << (usesMecanum ? "Mecanum" : "Omni-wheel") << " torque model: "
491 << wheelCount << " wheels, gear " << drive.gearRatio << ":1, "
492 << drive.motorMaxTorque << " N m per motor, wheel speed limit "
493 << maxWheelSpeedFromDrive << (usesMecanum ? " rad/s (" : " rev/s (")
494 << inputSpeedRpm << " rpm at the gearbox input).";
495 }
496
497 Result result;
498
499 if (inertia.has_value())
500 {
501 result.massMatrix = inertia->massMatrix(Eigen::Vector3d::Zero());
502 result.platformMass = static_cast<float>(result.massMatrix(0, 0));
503 result.platformYawInertia = static_cast<float>(result.massMatrix(2, 2));
504 }
505
506 const auto steps = static_cast<std::size_t>(params_.maxDuration / params_.dt);
507 result.samples.reserve(steps);
508
509 for (std::size_t step = 0; step < steps; step++)
510 {
511 const float time = static_cast<float>(step) * params_.dt;
512 const core::Pose global_T_robot = poseFrom(position, yaw);
513
515 controller.control(trajectory, global_T_robot);
516
517 // Low-pass filter, as in `Controller::additionalTask()`.
518 const float alpha = params_.controller.alpha;
519 filteredLinear =
520 alpha * filteredLinear + (1.F - alpha) * controlResult.twist.linear.head<2>();
521 filteredAngular =
522 alpha * filteredAngular + (1.F - alpha) * controlResult.twist.angular.z();
523
524 // The device ramps the command per Cartesian axis before the inverse kinematics.
525 // The controller holds its output for a whole control cycle, so the ramp sees the
526 // same target for every sub-step.
527 if (rateLimit.enabled)
528 {
529 const Eigen::Vector2f targetLinear = filteredLinear;
530 const float targetAngular = filteredAngular;
531
532 for (std::size_t sub = 0; sub < rampSubSteps; sub++)
533 {
534 if (rateLimit.directionPreserving)
535 {
536 filteredLinear = rampLinear.update(targetLinear, rateLimit.dt);
537 }
538 else
539 {
540 filteredLinear.x() = rampX.update(targetLinear.x(), rateLimit.dt);
541 filteredLinear.y() = rampY.update(targetLinear.y(), rateLimit.dt);
542 }
543
544 filteredAngular = rampYaw.update(targetAngular, rateLimit.dt);
545 }
546 }
547
548 // The controller returns a twist in the robot frame; the holonomic platform executes
549 // it there. Rotate it into the global frame to integrate the position.
550 const Eigen::Rotation2Df global_R_robot(yaw);
551 const Eigen::Vector2f commandedVelocityGlobal = global_R_robot * filteredLinear;
552
553 // What matching the command within one cycle would take, ignoring any limit.
554 const Eigen::Vector2f deltaV = commandedVelocityGlobal - velocityGlobal;
555 const float deltaOmega = filteredAngular - angularVelocity;
556
557 Sample sample;
558
559 // Sized up front: the Cartesian model never touches these, and an unsized VectorXf
560 // would make every consumer's element access an out-of-range Eigen assertion.
561 sample.commandedWheelVelocities = Eigen::VectorXf::Zero(wheelCount);
562 sample.wheelVelocities = Eigen::VectorXf::Zero(wheelCount);
563 sample.requiredWheelAccelerations = Eigen::VectorXf::Zero(wheelCount);
564 sample.requiredMotorTorques = Eigen::VectorXf::Zero(wheelCount);
565 sample.appliedMotorTorques = Eigen::VectorXf::Zero(wheelCount);
566 sample.wheelSaturated = Eigen::ArrayXi::Zero(wheelCount);
567 sample.motorSaturated = Eigen::ArrayXi::Zero(wheelCount);
568 sample.wheelSpeedSaturated = Eigen::ArrayXi::Zero(wheelCount);
569 sample.requiredLinearAcceleration = deltaV.norm() / params_.dt;
570 sample.requiredAngularAcceleration = std::abs(deltaOmega) / params_.dt;
571
572 if (usesTorque)
573 {
574 // Everything here is SI; the rest of the simulation works in mm.
575 constexpr double mmToM = 1e-3;
576
577 const Eigen::Vector3d q{position.x() * mmToM, position.y() * mmToM, yaw};
578 const Eigen::Vector3d qdot{velocityGlobal.x() * mmToM,
579 velocityGlobal.y() * mmToM,
580 angularVelocity};
581
582 const Eigen::Vector3d desiredAcceleration{deltaV.x() * mmToM / params_.dt,
583 deltaV.y() * mmToM / params_.dt,
584 deltaOmega / params_.dt};
585
586 const Eigen::Matrix3d massMatrix = inertia->massMatrix(q);
587 const Eigen::Vector3d biasForce = inertia->bias(q, qdot);
588
589 // Generalized force needed to follow the command exactly, in the world frame.
590 const Eigen::Vector3d requiredWrenchWorld =
591 massMatrix * desiredAcceleration + biasForce;
592
593 const Eigen::Rotation2Dd worldFromRobot(yaw);
594 Eigen::Vector3d requiredWrenchBody;
595 requiredWrenchBody.head<2>() =
596 worldFromRobot.inverse() * requiredWrenchWorld.head<2>();
597 requiredWrenchBody.z() = requiredWrenchWorld.z();
598
599 const Eigen::VectorXd requiredWheelTorque = wheelJacobianT * requiredWrenchBody;
600 const Eigen::VectorXd requiredMotorTorque =
601 requiredWheelTorque / (drive.gearRatio * drive.gearboxEfficiency);
602
603 // Each motor saturates on its own. Clipping them independently is what rotates
604 // the achieved acceleration away from the commanded direction.
605 Eigen::VectorXd appliedMotorTorque = requiredMotorTorque;
606 for (Eigen::Index motor = 0; motor < wheelCount; motor++)
607 {
608 if (std::abs(appliedMotorTorque[motor]) > drive.motorMaxTorque)
609 {
610 appliedMotorTorque[motor] =
611 std::copysign(drive.motorMaxTorque, appliedMotorTorque[motor]);
612 sample.motorSaturated[motor] = 1;
613 }
614 }
615
616 const Eigen::Vector3d appliedWrenchBody =
617 inverseWheelJacobianT *
618 (appliedMotorTorque * drive.gearRatio * drive.gearboxEfficiency);
619
620 Eigen::Vector3d appliedWrenchWorld;
621 appliedWrenchWorld.head<2>() = worldFromRobot * appliedWrenchBody.head<2>();
622 appliedWrenchWorld.z() = appliedWrenchBody.z();
623
624 const Eigen::Vector3d appliedAcceleration =
625 massMatrix.ldlt().solve(appliedWrenchWorld - biasForce);
626
627 velocityGlobal.x() +=
628 static_cast<float>(appliedAcceleration.x() / mmToM * params_.dt);
629 velocityGlobal.y() +=
630 static_cast<float>(appliedAcceleration.y() / mmToM * params_.dt);
631 angularVelocity += static_cast<float>(appliedAcceleration.z() * params_.dt);
632
633 // Wheel speed bound, applied in wheel space so that one slow wheel also bends
634 // the achieved twist rather than merely scaling it.
635 Eigen::Vector3f bodyTwist;
636 bodyTwist.head<2>() = Eigen::Rotation2Df(yaw).inverse() * velocityGlobal;
637 bodyTwist.z() = angularVelocity;
638
639 Eigen::VectorXf wheels = inverseJacobianMM * bodyTwist;
640 bool speedClamped = false;
641 for (Eigen::Index wheel = 0; wheel < wheelCount; wheel++)
642 {
643 if (std::abs(wheels[wheel]) > maxWheelSpeedFromDrive)
644 {
645 wheels[wheel] =
646 std::copysign(maxWheelSpeedFromDrive, wheels[wheel]);
647 sample.wheelSpeedSaturated[wheel] = 1;
648 speedClamped = true;
649 }
650 }
651
652 if (speedClamped)
653 {
654 const Eigen::Vector3f clampedTwist =
655 jacobianMM * wheels;
656 velocityGlobal = Eigen::Rotation2Df(yaw) * clampedTwist.head<2>();
657 angularVelocity = clampedTwist.z();
658 }
659
660 wheelVelocities = wheels;
661
662 sample.requiredMotorTorques = requiredMotorTorque.cast<float>();
663 sample.appliedMotorTorques = appliedMotorTorque.cast<float>();
665 inverseJacobianMM * (Eigen::Vector3f{
666 filteredLinear.x(), filteredLinear.y(), filteredAngular});
667 sample.wheelVelocities = wheels;
668 sample.accelerationSaturated =
669 sample.motorSaturated.any() or sample.wheelSpeedSaturated.any();
670
672 std::max(result.maxRequiredMotorTorque,
673 static_cast<float>(requiredMotorTorque.cwiseAbs().maxCoeff()));
674 result.motorTorqueSaturatedCycles += sample.motorSaturated.any() ? 1 : 0;
675 result.wheelSpeedSaturatedCycles += sample.wheelSpeedSaturated.any() ? 1 : 0;
676 }
677 else if (params_.dynamics.model == PlatformModel::OmniWheelVelocity)
678 {
679 // Work in wheel space: a single wheel that cannot keep up changes the direction
680 // of the achieved motion, not only its magnitude.
681 const Eigen::Vector3f commandedTwistLocal{
682 filteredLinear.x(), filteredLinear.y(), filteredAngular};
683 const Eigen::VectorXf commandedWheels =
684 inverseJacobianMM * commandedTwistLocal;
685
686 Eigen::VectorXf appliedDeltaW = commandedWheels - wheelVelocities;
687 sample.requiredWheelAccelerations = appliedDeltaW / params_.dt;
688
689 for (Eigen::Index wheel = 0; wheel < wheelCount; wheel++)
690 {
691 // Slowing a wheel down is limited by the deceleration bound, speeding it up
692 // by the acceleration bound.
693 const bool decelerating = std::abs(commandedWheels[wheel]) <
694 std::abs(wheelVelocities[wheel]);
695 const float bound =
696 (decelerating ? (usesMecanum
697 ? params_.dynamics.mecanum.maxWheelDeceleration
698 : params_.dynamics.omniWheel.maxWheelDeceleration)
699 : (usesMecanum
700 ? params_.dynamics.mecanum.maxWheelAcceleration
701 : params_.dynamics.omniWheel.maxWheelAcceleration)) *
702 params_.dt;
703
704 if (std::abs(appliedDeltaW[wheel]) > bound)
705 {
706 appliedDeltaW[wheel] = std::copysign(bound, appliedDeltaW[wheel]);
707 sample.wheelSaturated[wheel] = 1;
708 }
709 }
710
711 wheelVelocities += appliedDeltaW;
712 wheelVelocities = wheelVelocities.cwiseMax(-maxWheelVelocity)
713 .cwiseMin(maxWheelVelocity);
714
715 const Eigen::Vector3f achievedTwistLocal =
716 jacobianMM * wheelVelocities;
717
718 velocityGlobal = global_R_robot * achievedTwistLocal.head<2>();
719 angularVelocity = achievedTwistLocal.z();
720
721 sample.commandedWheelVelocities = commandedWheels;
722 sample.wheelVelocities = wheelVelocities;
723 sample.accelerationSaturated = sample.wheelSaturated.any();
724 }
725 else
726 {
727 const bool linearSaturated = deltaV.norm() > maxDeltaV;
728 const bool angularSaturated = std::abs(deltaOmega) > maxDeltaOmega;
729
730 // Follow the command only as fast as the mass allows.
731 const Eigen::Vector2f appliedDeltaV =
732 linearSaturated ? (deltaV.normalized() * maxDeltaV).eval() : deltaV;
733 const float appliedDeltaOmega =
734 std::clamp(deltaOmega, -maxDeltaOmega, maxDeltaOmega);
735
736 velocityGlobal += appliedDeltaV;
737 angularVelocity += appliedDeltaOmega;
738
739 sample.accelerationSaturated = linearSaturated or angularSaturated;
740 }
741
742 sample.time = time;
743 sample.global_T_robot = global_T_robot;
744 sample.commandedLinearLocal = filteredLinear;
745 sample.commandedAngular = filteredAngular;
746 sample.commandedVelocityGlobal = commandedVelocityGlobal;
747 sample.velocityGlobal = velocityGlobal;
748 sample.speed = velocityGlobal.norm();
749 // What the base actually delivered this cycle, however it was limited.
750 sample.linearAcceleration =
751 (velocityGlobal - previousVelocityGlobal).norm() / params_.dt;
752 sample.angularAcceleration =
753 std::abs(angularVelocity - previousAngularVelocity) / params_.dt;
754 sample.dropPointVelocity = controlResult.dropPoint.velocity;
755 sample.trackingError =
756 (controlResult.dropPoint.waypoint.pose.translation().head<2>() - position).norm();
757 sample.distanceToGoal = controlResult.positionError;
758 sample.orientationError = controlResult.orientationError;
759
760 result.maxRequiredLinearAcceleration = std::max(
762 result.maxRequiredAngularAcceleration = std::max(
764 result.maxTrackingError = std::max(result.maxTrackingError, sample.trackingError);
765 result.saturatedCycles += sample.accelerationSaturated ? 1 : 0;
766
767 result.samples.push_back(sample);
768
769 previousVelocityGlobal = velocityGlobal;
770 previousAngularVelocity = angularVelocity;
771
772 // Integrate the achieved velocity, not the commanded one.
773 position += velocityGlobal * params_.dt;
774 yaw += angularVelocity * params_.dt;
775
776 const float distanceToGoal = (position - goalPosition).norm();
777 result.finalDistanceToGoal = distanceToGoal;
778
779 // Only count it as overshoot once the robot is past the end of the path, not while
780 // it is still approaching from the start.
781 if ((position - goalPosition).dot(goalDirection) > 0.F)
782 {
783 result.maxGoalOvershoot = std::max(result.maxGoalOvershoot, distanceToGoal);
784 }
785
786 if (distanceToGoal < params_.goalDistanceThreshold)
787 {
788 result.reachedGoal = true;
789 break;
790 }
791
792 // The final segment is a pure P law, so the robot can converge to a standstill just
793 // outside the threshold and creep there for the rest of maxDuration.
794 settledSteps = (velocityGlobal.norm() < params_.settledSpeedThreshold) ? settledSteps + 1 : 0;
795
796 if (static_cast<float>(settledSteps) * params_.dt > params_.settledDuration)
797 {
798 result.settledShortOfGoal = true;
799 break;
800 }
801 }
802
803 if (result.settledShortOfGoal)
804 {
805 ARMARX_WARNING << "Simulation stopped: the robot came to rest "
806 << result.finalDistanceToGoal << " mm from the goal, outside the "
807 << params_.goalDistanceThreshold << " mm threshold.";
808 }
809 else if (not result.reachedGoal)
810 {
811 ARMARX_WARNING << "Simulation did not reach the goal within " << params_.maxDuration
812 << " s. Final distance to goal: " << result.finalDistanceToGoal
813 << " mm.";
814 }
815
816 if (result.maxGoalOvershoot > params_.goalDistanceThreshold)
817 {
818 ARMARX_WARNING << "Overshot the end of the path by up to " << result.maxGoalOvershoot
819 << " mm.";
820 }
821
822 ARMARX_INFO << "Simulated " << result.samples.size() << " control cycles ("
823 << (static_cast<float>(result.samples.size()) * params_.dt)
824 << " s). Peak required acceleration: "
825 << result.maxRequiredLinearAcceleration << " mm/s^2 (limit "
826 << params_.dynamics.maxLinearAcceleration << "), "
827 << result.maxRequiredAngularAcceleration << " rad/s^2 (limit "
828 << params_.dynamics.maxAngularAcceleration << "). Saturated in "
829 << result.saturatedCycles << " of " << result.samples.size()
830 << " cycles. Peak tracking error: " << result.maxTrackingError << " mm.";
831
832 return result;
833 }
834
835} // namespace armarx::navigation::simulation
#define M_PI
Definition MathTools.h:17
constexpr T dt
static std::filesystem::path toSystemPath(const data::PackagePath &pp)
Result run(const core::GlobalTrajectory &trajectory, const core::Pose &start) const
Follow trajectory starting from start, which need not be on the trajectory.
T min(T t1, T t2)
Definition gdiam.h:44
#define ARMARX_CHECK_GREATER(lhs, rhs)
This macro evaluates whether lhs is greater (>) than rhs and if it turns out to be false it will thro...
#define ARMARX_CHECK(expression)
Shortcut for ARMARX_CHECK_EXPRESSION.
#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
#define ARMARX_IMPORTANT
The logging level for always important information, but expected behaviour (in contrast to ARMARX_WAR...
Definition Logging.h:188
#define ARMARX_WARNING
The logging level for unexpected behaviour, but not a serious problem.
Definition Logging.h:191
#define q
bool update(mongocxx::collection &coll, const nlohmann::json &query, const nlohmann::json &update)
Definition mongodb.cpp:68
double v(double t, double v0, double a0, double j)
Definition CtrlUtil.h:39
Eigen::Isometry3f Pose
Definition basic_types.h:31
This file is part of ArmarX.
@ MecanumTorque
As OmniWheelTorque, but for a four-wheel mecanum drive.
@ OmniWheelVelocity
Bound each wheel's angular acceleration separately, so one saturating wheel also distorts the directi...
@ Cartesian
Bound the magnitude of the twist change. Direction of the change is preserved.
@ OmniWheelTorque
Bound each motor's torque and speed, using the robot's real inertia.
T sign(T t)
Definition algorithm.h:214
Vertex target(const detail::edge_base< Directed, Vertex > &e, const PCG &)
Point sub(const Point &x, const Point &y)
Definition point.hpp:46
double norm(const Point &a)
Definition point.hpp:102
double distance(const Point &a, const Point &b)
Definition point.hpp:95
double dot(const Point &x, const Point &y)
Definition point.hpp:57
The per-axis command ramp the platform device applies below the navigation stack.
float dt
Control cycle of the RT loop [s]. RTUnit runs at 1 kHz and clamps dt to <= 2 ms.
bool directionPreserving
Ramp the two linear axes together instead of independently.
Limits of the four-wheel mecanum drive, as on ARMAR-6 and ARMAR-DE.
VirtualRobot::MecanumPlatformKinematicsParams kinematics
Limits of the omni-directional wheel drive, as on ARMAR-7.
VirtualRobot::OmniWheelPlatformKinematicsParams kinematics
What the ARMAR-7 drive train can actually deliver.
float motorMaxTorque
Torque bound per motor [N m]. Default is the 7.0 A MaxCurrent cap x 62.4 mNm/A.
float gearboxMaxInputSpeedRpm
Gearbox continuous input speed [rpm]. Binds before the motor on ARMAR-7.
float motorRatedTorque
Torque at RatedCurrent (7.34 A x 62.4 mNm/A) [N m]. Recorded for reference.
float motorNominalSpeedRpm
Continuous motor speed [rpm], IDX 56 M at 48 V.
float gearboxEfficiency
Ignored losses are modelled as 1.0, i.e. optimistic.
float gearRatio
Motor revolutions per wheel revolution. 65536 counts/wheel-rev / 4096 counts/motor-rev.
static PlatformDynamics FromPackagePath(const std::string &robot="Armar7")
Read the dynamics from config/platform/PlatformDynamics<Robot>.json.
std::string nodeSet
Node set holding exactly the platform DoFs, in [x, y, yaw] order.
std::string configuration
Robot configuration to lump the upper body at.
float finalDistanceToGoal
Distance to the last trajectory point when the run ended [mm].
Eigen::Matrix3d massMatrix
Full mass matrix at the Home configuration, [x, y, yaw].
float maxRequiredMotorTorque
Largest motor torque demanded over the run [N m]. OmniWheelTorque only.
float maxRequiredAngularAcceleration
Largest requiredAngularAcceleration over the run [rad/s^2].
std::size_t motorTorqueSaturatedCycles
Cycles in which a motor torque bound was binding. OmniWheelTorque only.
std::size_t wheelSpeedSaturatedCycles
Cycles in which a wheel speed bound was binding. OmniWheelTorque only.
float platformMass
Platform mass and yaw inertia from the robot model. OmniWheelTorque only.
float maxRequiredLinearAcceleration
Largest requiredLinearAcceleration over the run [mm/s^2].
float maxGoalOvershoot
Furthest the robot ever got past the last trajectory point [mm].
std::size_t saturatedCycles
Number of control cycles in which any limit was binding.
bool settledShortOfGoal
Set when the run ended because the robot stopped moving short of the goal.
float trackingError
Distance from the robot to the trajectory point it is tracking [mm].
Eigen::VectorXf commandedWheelVelocities
Wheel speeds the command asks for [rev/s]. Only set for the omni-wheel model.
float linearAcceleration
Acceleration actually applied, bounded by PlatformDynamics.
bool accelerationSaturated
True when a limit was binding, i.e. the base could not follow the command.
Eigen::VectorXf requiredWheelAccelerations
Per-wheel acceleration the command asks for [rev/s^2], ignoring the limit.
Eigen::ArrayXi wheelSpeedSaturated
Which wheels hit their speed bound. Only set for OmniWheelTorque.
Eigen::VectorXf wheelVelocities
Wheel speeds actually reached [rev/s]. Only set for the omni-wheel model.
Eigen::Vector2f commandedLinearLocal
Filtered twist as commanded to the platform, in the robot frame.
Eigen::ArrayXi motorSaturated
Which motors hit their torque bound. Only set for OmniWheelTorque.
float dropPointVelocity
Velocity of the trajectory point the controller is currently tracking.
Eigen::ArrayXi wheelSaturated
Which wheels hit their acceleration bound. Only set for OmniWheelVelocity.
Eigen::Vector2f commandedVelocityGlobal
Commanded linear velocity in the global frame, i.e. what the mass is asked for.
Eigen::Vector2f velocityGlobal
Achieved linear velocity in the global frame, after the acceleration limit.
Eigen::VectorXf requiredMotorTorques
Motor torque the command asks for [N m]. Only set for OmniWheelTorque.
float requiredLinearAcceleration
Acceleration needed to match the command within one cycle, ignoring the limit.
Eigen::VectorXf appliedMotorTorques
Motor torque after clipping [N m]. Only set for OmniWheelTorque.