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
160 // A derating rather than a hardware property: it exists so a platform whose real
161 // torque is unknown or implausible can be made to behave like a known one. See
162 // PlatformDynamicsArmarDE.json.
163 drive.motorMaxTorque *= jTorque.value("torqueFraction", 1.F);
164
168
169 if (jTorque.contains("robot"))
170 {
171 const nlohmann::json& jRobot = jTorque.at("robot");
172 drive.robot.robotPackage = jRobot.value("package", drive.robot.robotPackage);
173 drive.robot.robotFile = jRobot.value("file", drive.robot.robotFile);
174 drive.robot.nodeSet = jRobot.value("nodeSet", drive.robot.nodeSet);
175 drive.robot.configuration =
176 jRobot.value("configuration", drive.robot.configuration);
177 }
178 }
179
181 {
182 ARMARX_INFO << "Simulated platform: omni-wheel drive with per-motor torque limit "
183 << dynamics.omniWheelTorque.motorMaxTorque << " N m, gear "
184 << dynamics.omniWheelTorque.gearRatio << ":1.";
185 }
186 else if (dynamics.model == PlatformModel::OmniWheelVelocity)
187 {
188 ARMARX_INFO << "Simulated platform: omni-wheel drive, per-wheel limits "
189 << dynamics.omniWheel.maxWheelAcceleration << " / "
190 << dynamics.omniWheel.maxWheelDeceleration << " rev/s^2.";
191 }
192 else
193 {
194 ARMARX_INFO << "Simulated platform: Cartesian limits "
195 << dynamics.maxLinearAcceleration << " mm/s^2, "
196 << dynamics.maxAngularAcceleration << " rad/s^2.";
197 }
198
199 return dynamics;
200 }
201
202 namespace
203 {
204
205 float
206 yawOf(const core::Pose& pose)
207 {
208 return std::atan2(pose.linear()(1, 0), pose.linear()(0, 0));
209 }
210
211 /**
212 * @brief One axis of the platform device's command ramp.
213 *
214 * Ported from `LinearLimitedAccelerationController::update` in
215 * `armar7_omni/joint_controller/Velocity.cpp`, including its quirks: acceleration and
216 * deceleration are distinguished by *magnitude* (the sign flip mirrors the problem for
217 * negative velocities), and standstill takes the deceleration branch because the test
218 * is `currentValue <= 0`.
219 */
220 class AxisRateLimit
221 {
222 public:
223 AxisRateLimit(const float maxVelocity,
224 const float maxAcceleration,
225 const float maxDeceleration) :
226 maxVelocity_(maxVelocity),
227 maxAcceleration_(maxAcceleration),
228 maxDeceleration_(maxDeceleration)
229 {
230 }
231
232 float
233 update(const float value, const float dt)
234 {
235 float delta = value - current_;
236 float sign = 1.F;
237
238 if (current_ <= 0.F)
239 {
240 delta = -delta;
241 sign = -1.F;
242 }
243
244 delta = delta < 0.F ? -std::min(maxDeceleration_ * dt, -delta)
245 : std::min(maxAcceleration_ * dt, delta);
246
247 current_ = std::clamp(current_ + delta * sign, -maxVelocity_, maxVelocity_);
248
249 return current_;
250 }
251
252 private:
253 float maxVelocity_;
254
255 float maxAcceleration_;
256
257 float maxDeceleration_;
258
259 /// Persistent, exactly as on the device: the ramp resumes from the last command.
260 float current_{0.F};
261 };
262
263 /// Port of `PlanarLimitedAccelerationController` from the `armar7_omni` device.
264 class PlanarRateLimit
265 {
266 public:
267 PlanarRateLimit(const float maxVelocity,
268 const float maxAcceleration,
269 const float maxDeceleration) :
270 maxVelocity_(maxVelocity),
271 maxAcceleration_(maxAcceleration),
272 maxDeceleration_(maxDeceleration)
273 {
274 }
275
276 Eigen::Vector2f
277 update(const Eigen::Vector2f& value, const float dt)
278 {
279 const Eigen::Vector2f target = limitNorm(value);
280 const Eigen::Vector2f delta = target - current_;
281 const float distance = delta.norm();
282
283 if (distance > 0.F)
284 {
285 const float bound =
286 (target.norm() >= current_.norm() ? maxAcceleration_ : maxDeceleration_) *
287 dt;
288
289 current_ += delta * std::min(1.F, bound / distance);
290 }
291
292 current_ = limitNorm(current_);
293
294 return current_;
295 }
296
297 private:
298 Eigen::Vector2f
299 limitNorm(const Eigen::Vector2f& v) const
300 {
301 const float norm = v.norm();
302
303 return norm > maxVelocity_ ? Eigen::Vector2f{v * (maxVelocity_ / norm)} : v;
304 }
305
306 float maxVelocity_;
307
308 float maxAcceleration_;
309
310 float maxDeceleration_;
311
312 Eigen::Vector2f current_{Eigen::Vector2f::Zero()};
313 };
314
316 poseFrom(const Eigen::Vector2f& position, const float yaw)
317 {
318 core::Pose pose = core::Pose::Identity();
319 pose.translation() << position.x(), position.y(), 0.F;
320 pose.linear() = Eigen::AngleAxisf(yaw, Eigen::Vector3f::UnitZ()).toRotationMatrix();
321
322 return pose;
323 }
324
325 } // namespace
326
328 params_(params)
329 {
330 ARMARX_CHECK_GREATER(params_.dt, 0.F);
331 ARMARX_CHECK_GREATER(params_.maxDuration, 0.F);
332 }
333
336 const core::Pose& start) const
337 {
338 ARMARX_CHECK(not trajectory.points().empty());
339
340 // The controller is stateful (PID terms and the trajectory projection index), so a single
341 // instance has to be used for the whole run, exactly as the real controller does.
343
344 const Eigen::Vector2f goalPosition =
345 trajectory.points().back().waypoint.pose.translation().head<2>();
346
347 // Direction of the last segment, used to tell "still approaching" from "past the end".
348 const Eigen::Vector2f goalDirection =
349 (goalPosition -
350 trajectory.points().at(trajectory.points().size() - 2).waypoint.pose.translation()
351 .head<2>())
352 .normalized();
353
354 std::size_t settledSteps = 0;
355
356 Eigen::Vector2f position = start.translation().head<2>();
357 float yaw = yawOf(start);
358
359 // Mirrors `Twist2D filteredTwist` of the platform controller, including its zero
360 // initialization in `rtPreActivateController()`.
361 Eigen::Vector2f filteredLinear = Eigen::Vector2f::Zero();
362 float filteredAngular = 0.F;
363
364 Eigen::Vector2f previousVelocityGlobal = Eigen::Vector2f::Zero();
365 float previousAngularVelocity = 0.F;
366
367 // The device ramp runs in the RT loop (1 kHz), the controller in its additional task
368 // (10 ms). Step the ramp at its own rate, otherwise it is an order of magnitude coarser
369 // than the real one.
370 const CommandRateLimit& rateLimit = params_.rateLimit;
371 AxisRateLimit rampX(
372 rateLimit.maxVelocity, rateLimit.maxAcceleration, rateLimit.maxDeceleration);
373 AxisRateLimit rampY(
374 rateLimit.maxVelocity, rateLimit.maxAcceleration, rateLimit.maxDeceleration);
375 PlanarRateLimit rampLinear(
376 rateLimit.maxVelocity, rateLimit.maxAcceleration, rateLimit.maxDeceleration);
377 AxisRateLimit rampYaw(rateLimit.maxAngularVelocity,
378 rateLimit.maxAngularAcceleration,
379 rateLimit.maxAngularDeceleration);
380
381 const auto rampSubSteps =
382 std::max<std::size_t>(1, static_cast<std::size_t>(std::lround(
383 params_.dt / std::max(rateLimit.dt, 1e-6F))));
384
385 if (rateLimit.enabled)
386 {
387 ARMARX_INFO << "Modelling the device command ramp: " << rateLimit.maxAcceleration
388 << " / " << rateLimit.maxDeceleration << " mm/s^2, "
389 << rateLimit.maxAngularAcceleration << " / "
390 << rateLimit.maxAngularDeceleration << " rad/s^2, " << rampSubSteps
391 << " sub-steps of " << rateLimit.dt << " s per control cycle, "
392 << (rateLimit.directionPreserving ? "direction-preserving"
393 : "per-axis")
394 << ".";
395 }
396 else
397 {
398 ARMARX_INFO << "Device command ramp disabled: the raw controller twist goes "
399 "straight to the platform.";
400 }
401
402 // What the mass is actually doing, as opposed to what it was told to do.
403 Eigen::Vector2f velocityGlobal = Eigen::Vector2f::Zero();
404 float angularVelocity = 0.F;
405
406 const float maxDeltaV = params_.dynamics.maxLinearAcceleration * params_.dt;
407 const float maxDeltaOmega = params_.dynamics.maxAngularAcceleration * params_.dt;
408
409 const bool usesWheels = params_.dynamics.model != PlatformModel::Cartesian;
410 const bool usesMecanum = params_.dynamics.model == PlatformModel::MecanumTorque;
411
412 // Both drives reduce to the same pair of matrices, so nothing below this point needs to
413 // know how many wheels there are or which platform it is.
414 //
415 // jacobianMM (3, N) wheel -> [mm/s, mm/s, rad/s]
416 // inverseJacobianMM (N, 3) [mm/s, mm/s, rad/s] -> wheel
417 //
418 // Simox supplies both directions explicitly for the mecanum drive (J and J_inv, which
419 // satisfy J * J_inv = I), so no pseudo-inverse is involved even though the matrices are
420 // not square. The omni drive's C() is square and its inverse is the inverse model.
421 //
422 // Unit conventions differ between the two and are absorbed here: C() takes wheel
423 // **rev/s**, J() takes **rad/s**.
424 Eigen::MatrixXf jacobianMM;
425 Eigen::MatrixXf inverseJacobianMM;
426
427 if (usesMecanum)
428 {
429 jacobianMM = params_.dynamics.mecanum.kinematics.J();
430 inverseJacobianMM = params_.dynamics.mecanum.kinematics.J_inv();
431 }
432 else
433 {
434 jacobianMM = params_.dynamics.omniWheel.kinematics.C();
435 inverseJacobianMM = jacobianMM.inverse();
436 }
437
438 const Eigen::Index wheelCount = jacobianMM.cols();
439
440 const float maxWheelVelocity = usesMecanum
441 ? params_.dynamics.mecanum.maxWheelVelocity
442 : params_.dynamics.omniWheel.maxWheelVelocity;
443 Eigen::VectorXf wheelVelocities = Eigen::VectorXf::Zero(wheelCount);
444
445 // --- Torque model setup -------------------------------------------------------------
446 const OmniWheelTorqueDrive& drive = params_.dynamics.omniWheelTorque;
447 const bool usesTorque = params_.dynamics.model == PlatformModel::OmniWheelTorque or
448 params_.dynamics.model == PlatformModel::MecanumTorque;
449
450 std::optional<PlatformInertia> inertia;
451 Eigen::MatrixXd wheelJacobianT = Eigen::MatrixXd::Identity(3, 3);
452 Eigen::MatrixXd inverseWheelJacobianT = Eigen::MatrixXd::Identity(3, 3);
453 float maxWheelSpeedFromDrive = 0.F;
454
455 if (usesTorque)
456 {
457 inertia.emplace(drive.robot);
458
459 // Promote both matrices to SI: wheel rate -> body twist [m/s, m/s, rad/s]. Only the
460 // two linear rows carry the mm->m factor. The omni drive additionally needs the
461 // 2*pi that turns its rev/s into rad/s; the mecanum drive is already in rad/s.
462 const Eigen::Matrix3d mmToM = Eigen::Vector3d(1e-3, 1e-3, 1.0).asDiagonal();
463 const double toRadians = usesMecanum ? 1.0 : (2.0 * M_PI);
464
465 const Eigen::MatrixXd jacobianSI =
466 mmToM * jacobianMM.cast<double>() / toRadians;
467 const Eigen::MatrixXd inverseJacobianSI =
468 inverseJacobianMM.cast<double>() * mmToM.inverse() * toRadians;
469
470 // Virtual work: tau_wheel = J^T F_body, and dually F_body = J_inv^T tau_wheel.
471 // Taking the second from `inverseJacobianSI` rather than inverting the first is what
472 // makes this work for a non-square (four-wheel) drive.
473 wheelJacobianT = jacobianSI.transpose();
474 inverseWheelJacobianT = inverseJacobianSI.transpose();
475
476 // The gearbox input rating binds before the motor on ARMAR-7, so take the min
477 // rather than assuming which one is lower.
478 const float inputSpeedRpm =
479 std::min(drive.motorNominalSpeedRpm, drive.gearboxMaxInputSpeedRpm);
480 // rpm at the gearbox input -> wheel speed in the drive's *native* unit. The
481 // division yields rev/s, which is already what the omni model wants; the mecanum
482 // model works in rad/s and needs the 2*pi. Note this is the reciprocal of the
483 // `toRadians` used for the Jacobian above -- that one converts the wheel unit *into*
484 // rad/s, this one converts *out of* rev/s.
485 const double toNativeWheelUnit = usesMecanum ? (2.0 * M_PI) : 1.0;
486 maxWheelSpeedFromDrive = inputSpeedRpm / 60.F / drive.gearRatio *
487 static_cast<float>(toNativeWheelUnit);
488
489 ARMARX_IMPORTANT << (usesMecanum ? "Mecanum" : "Omni-wheel") << " torque model: "
490 << wheelCount << " wheels, gear " << drive.gearRatio << ":1, "
491 << drive.motorMaxTorque << " N m per motor, wheel speed limit "
492 << maxWheelSpeedFromDrive << (usesMecanum ? " rad/s (" : " rev/s (")
493 << inputSpeedRpm << " rpm at the gearbox input).";
494 }
495
496 Result result;
497
498 if (inertia.has_value())
499 {
500 result.massMatrix = inertia->massMatrix(Eigen::Vector3d::Zero());
501 result.platformMass = static_cast<float>(result.massMatrix(0, 0));
502 result.platformYawInertia = static_cast<float>(result.massMatrix(2, 2));
503 }
504
505 const auto steps = static_cast<std::size_t>(params_.maxDuration / params_.dt);
506 result.samples.reserve(steps);
507
508 for (std::size_t step = 0; step < steps; step++)
509 {
510 const float time = static_cast<float>(step) * params_.dt;
511 const core::Pose global_T_robot = poseFrom(position, yaw);
512
514 controller.control(trajectory, global_T_robot);
515
516 // Low-pass filter, as in `Controller::additionalTask()`.
517 const float alpha = params_.controller.alpha;
518 filteredLinear =
519 alpha * filteredLinear + (1.F - alpha) * controlResult.twist.linear.head<2>();
520 filteredAngular =
521 alpha * filteredAngular + (1.F - alpha) * controlResult.twist.angular.z();
522
523 // The device ramps the command per Cartesian axis before the inverse kinematics.
524 // The controller holds its output for a whole control cycle, so the ramp sees the
525 // same target for every sub-step.
526 if (rateLimit.enabled)
527 {
528 const Eigen::Vector2f targetLinear = filteredLinear;
529 const float targetAngular = filteredAngular;
530
531 for (std::size_t sub = 0; sub < rampSubSteps; sub++)
532 {
533 if (rateLimit.directionPreserving)
534 {
535 filteredLinear = rampLinear.update(targetLinear, rateLimit.dt);
536 }
537 else
538 {
539 filteredLinear.x() = rampX.update(targetLinear.x(), rateLimit.dt);
540 filteredLinear.y() = rampY.update(targetLinear.y(), rateLimit.dt);
541 }
542
543 filteredAngular = rampYaw.update(targetAngular, rateLimit.dt);
544 }
545 }
546
547 // The controller returns a twist in the robot frame; the holonomic platform executes
548 // it there. Rotate it into the global frame to integrate the position.
549 const Eigen::Rotation2Df global_R_robot(yaw);
550 const Eigen::Vector2f commandedVelocityGlobal = global_R_robot * filteredLinear;
551
552 // What matching the command within one cycle would take, ignoring any limit.
553 const Eigen::Vector2f deltaV = commandedVelocityGlobal - velocityGlobal;
554 const float deltaOmega = filteredAngular - angularVelocity;
555
556 Sample sample;
557
558 // Sized up front: the Cartesian model never touches these, and an unsized VectorXf
559 // would make every consumer's element access an out-of-range Eigen assertion.
560 sample.commandedWheelVelocities = Eigen::VectorXf::Zero(wheelCount);
561 sample.wheelVelocities = Eigen::VectorXf::Zero(wheelCount);
562 sample.requiredWheelAccelerations = Eigen::VectorXf::Zero(wheelCount);
563 sample.requiredMotorTorques = Eigen::VectorXf::Zero(wheelCount);
564 sample.appliedMotorTorques = Eigen::VectorXf::Zero(wheelCount);
565 sample.wheelSaturated = Eigen::ArrayXi::Zero(wheelCount);
566 sample.motorSaturated = Eigen::ArrayXi::Zero(wheelCount);
567 sample.wheelSpeedSaturated = Eigen::ArrayXi::Zero(wheelCount);
568 sample.requiredLinearAcceleration = deltaV.norm() / params_.dt;
569 sample.requiredAngularAcceleration = std::abs(deltaOmega) / params_.dt;
570
571 if (usesTorque)
572 {
573 // Everything here is SI; the rest of the simulation works in mm.
574 constexpr double mmToM = 1e-3;
575
576 const Eigen::Vector3d q{position.x() * mmToM, position.y() * mmToM, yaw};
577 const Eigen::Vector3d qdot{velocityGlobal.x() * mmToM,
578 velocityGlobal.y() * mmToM,
579 angularVelocity};
580
581 const Eigen::Vector3d desiredAcceleration{deltaV.x() * mmToM / params_.dt,
582 deltaV.y() * mmToM / params_.dt,
583 deltaOmega / params_.dt};
584
585 const Eigen::Matrix3d massMatrix = inertia->massMatrix(q);
586 const Eigen::Vector3d biasForce = inertia->bias(q, qdot);
587
588 // Generalized force needed to follow the command exactly, in the world frame.
589 const Eigen::Vector3d requiredWrenchWorld =
590 massMatrix * desiredAcceleration + biasForce;
591
592 const Eigen::Rotation2Dd worldFromRobot(yaw);
593 Eigen::Vector3d requiredWrenchBody;
594 requiredWrenchBody.head<2>() =
595 worldFromRobot.inverse() * requiredWrenchWorld.head<2>();
596 requiredWrenchBody.z() = requiredWrenchWorld.z();
597
598 const Eigen::VectorXd requiredWheelTorque = wheelJacobianT * requiredWrenchBody;
599 const Eigen::VectorXd requiredMotorTorque =
600 requiredWheelTorque / (drive.gearRatio * drive.gearboxEfficiency);
601
602 // Each motor saturates on its own. Clipping them independently is what rotates
603 // the achieved acceleration away from the commanded direction.
604 Eigen::VectorXd appliedMotorTorque = requiredMotorTorque;
605 for (Eigen::Index motor = 0; motor < wheelCount; motor++)
606 {
607 if (std::abs(appliedMotorTorque[motor]) > drive.motorMaxTorque)
608 {
609 appliedMotorTorque[motor] =
610 std::copysign(drive.motorMaxTorque, appliedMotorTorque[motor]);
611 sample.motorSaturated[motor] = 1;
612 }
613 }
614
615 const Eigen::Vector3d appliedWrenchBody =
616 inverseWheelJacobianT *
617 (appliedMotorTorque * drive.gearRatio * drive.gearboxEfficiency);
618
619 Eigen::Vector3d appliedWrenchWorld;
620 appliedWrenchWorld.head<2>() = worldFromRobot * appliedWrenchBody.head<2>();
621 appliedWrenchWorld.z() = appliedWrenchBody.z();
622
623 const Eigen::Vector3d appliedAcceleration =
624 massMatrix.ldlt().solve(appliedWrenchWorld - biasForce);
625
626 velocityGlobal.x() +=
627 static_cast<float>(appliedAcceleration.x() / mmToM * params_.dt);
628 velocityGlobal.y() +=
629 static_cast<float>(appliedAcceleration.y() / mmToM * params_.dt);
630 angularVelocity += static_cast<float>(appliedAcceleration.z() * params_.dt);
631
632 // Wheel speed bound, applied in wheel space so that one slow wheel also bends
633 // the achieved twist rather than merely scaling it.
634 Eigen::Vector3f bodyTwist;
635 bodyTwist.head<2>() = Eigen::Rotation2Df(yaw).inverse() * velocityGlobal;
636 bodyTwist.z() = angularVelocity;
637
638 Eigen::VectorXf wheels = inverseJacobianMM * bodyTwist;
639 bool speedClamped = false;
640 for (Eigen::Index wheel = 0; wheel < wheelCount; wheel++)
641 {
642 if (std::abs(wheels[wheel]) > maxWheelSpeedFromDrive)
643 {
644 wheels[wheel] =
645 std::copysign(maxWheelSpeedFromDrive, wheels[wheel]);
646 sample.wheelSpeedSaturated[wheel] = 1;
647 speedClamped = true;
648 }
649 }
650
651 if (speedClamped)
652 {
653 const Eigen::Vector3f clampedTwist =
654 jacobianMM * wheels;
655 velocityGlobal = Eigen::Rotation2Df(yaw) * clampedTwist.head<2>();
656 angularVelocity = clampedTwist.z();
657 }
658
659 wheelVelocities = wheels;
660
661 sample.requiredMotorTorques = requiredMotorTorque.cast<float>();
662 sample.appliedMotorTorques = appliedMotorTorque.cast<float>();
664 inverseJacobianMM * (Eigen::Vector3f{
665 filteredLinear.x(), filteredLinear.y(), filteredAngular});
666 sample.wheelVelocities = wheels;
667 sample.accelerationSaturated =
668 sample.motorSaturated.any() or sample.wheelSpeedSaturated.any();
669
671 std::max(result.maxRequiredMotorTorque,
672 static_cast<float>(requiredMotorTorque.cwiseAbs().maxCoeff()));
673 result.motorTorqueSaturatedCycles += sample.motorSaturated.any() ? 1 : 0;
674 result.wheelSpeedSaturatedCycles += sample.wheelSpeedSaturated.any() ? 1 : 0;
675 }
676 else if (params_.dynamics.model == PlatformModel::OmniWheelVelocity)
677 {
678 // Work in wheel space: a single wheel that cannot keep up changes the direction
679 // of the achieved motion, not only its magnitude.
680 const Eigen::Vector3f commandedTwistLocal{
681 filteredLinear.x(), filteredLinear.y(), filteredAngular};
682 const Eigen::VectorXf commandedWheels =
683 inverseJacobianMM * commandedTwistLocal;
684
685 Eigen::VectorXf appliedDeltaW = commandedWheels - wheelVelocities;
686 sample.requiredWheelAccelerations = appliedDeltaW / params_.dt;
687
688 for (Eigen::Index wheel = 0; wheel < wheelCount; wheel++)
689 {
690 // Slowing a wheel down is limited by the deceleration bound, speeding it up
691 // by the acceleration bound.
692 const bool decelerating = std::abs(commandedWheels[wheel]) <
693 std::abs(wheelVelocities[wheel]);
694 const float bound =
695 (decelerating ? (usesMecanum
696 ? params_.dynamics.mecanum.maxWheelDeceleration
697 : params_.dynamics.omniWheel.maxWheelDeceleration)
698 : (usesMecanum
699 ? params_.dynamics.mecanum.maxWheelAcceleration
700 : params_.dynamics.omniWheel.maxWheelAcceleration)) *
701 params_.dt;
702
703 if (std::abs(appliedDeltaW[wheel]) > bound)
704 {
705 appliedDeltaW[wheel] = std::copysign(bound, appliedDeltaW[wheel]);
706 sample.wheelSaturated[wheel] = 1;
707 }
708 }
709
710 wheelVelocities += appliedDeltaW;
711 wheelVelocities = wheelVelocities.cwiseMax(-maxWheelVelocity)
712 .cwiseMin(maxWheelVelocity);
713
714 const Eigen::Vector3f achievedTwistLocal =
715 jacobianMM * wheelVelocities;
716
717 velocityGlobal = global_R_robot * achievedTwistLocal.head<2>();
718 angularVelocity = achievedTwistLocal.z();
719
720 sample.commandedWheelVelocities = commandedWheels;
721 sample.wheelVelocities = wheelVelocities;
722 sample.accelerationSaturated = sample.wheelSaturated.any();
723 }
724 else
725 {
726 const bool linearSaturated = deltaV.norm() > maxDeltaV;
727 const bool angularSaturated = std::abs(deltaOmega) > maxDeltaOmega;
728
729 // Follow the command only as fast as the mass allows.
730 const Eigen::Vector2f appliedDeltaV =
731 linearSaturated ? (deltaV.normalized() * maxDeltaV).eval() : deltaV;
732 const float appliedDeltaOmega =
733 std::clamp(deltaOmega, -maxDeltaOmega, maxDeltaOmega);
734
735 velocityGlobal += appliedDeltaV;
736 angularVelocity += appliedDeltaOmega;
737
738 sample.accelerationSaturated = linearSaturated or angularSaturated;
739 }
740
741 sample.time = time;
742 sample.global_T_robot = global_T_robot;
743 sample.commandedLinearLocal = filteredLinear;
744 sample.commandedAngular = filteredAngular;
745 sample.commandedVelocityGlobal = commandedVelocityGlobal;
746 sample.velocityGlobal = velocityGlobal;
747 sample.speed = velocityGlobal.norm();
748 // What the base actually delivered this cycle, however it was limited.
749 sample.linearAcceleration =
750 (velocityGlobal - previousVelocityGlobal).norm() / params_.dt;
751 sample.angularAcceleration =
752 std::abs(angularVelocity - previousAngularVelocity) / params_.dt;
753 sample.dropPointVelocity = controlResult.dropPoint.velocity;
754 sample.trackingError =
755 (controlResult.dropPoint.waypoint.pose.translation().head<2>() - position).norm();
756 sample.distanceToGoal = controlResult.positionError;
757 sample.orientationError = controlResult.orientationError;
758
759 result.maxRequiredLinearAcceleration = std::max(
761 result.maxRequiredAngularAcceleration = std::max(
763 result.maxTrackingError = std::max(result.maxTrackingError, sample.trackingError);
764 result.saturatedCycles += sample.accelerationSaturated ? 1 : 0;
765
766 result.samples.push_back(sample);
767
768 previousVelocityGlobal = velocityGlobal;
769 previousAngularVelocity = angularVelocity;
770
771 // Integrate the achieved velocity, not the commanded one.
772 position += velocityGlobal * params_.dt;
773 yaw += angularVelocity * params_.dt;
774
775 const float distanceToGoal = (position - goalPosition).norm();
776 result.finalDistanceToGoal = distanceToGoal;
777
778 // Only count it as overshoot once the robot is past the end of the path, not while
779 // it is still approaching from the start.
780 if ((position - goalPosition).dot(goalDirection) > 0.F)
781 {
782 result.maxGoalOvershoot = std::max(result.maxGoalOvershoot, distanceToGoal);
783 }
784
785 if (distanceToGoal < params_.goalDistanceThreshold)
786 {
787 result.reachedGoal = true;
788 break;
789 }
790
791 // The final segment is a pure P law, so the robot can converge to a standstill just
792 // outside the threshold and creep there for the rest of maxDuration.
793 settledSteps = (velocityGlobal.norm() < params_.settledSpeedThreshold) ? settledSteps + 1 : 0;
794
795 if (static_cast<float>(settledSteps) * params_.dt > params_.settledDuration)
796 {
797 result.settledShortOfGoal = true;
798 break;
799 }
800 }
801
802 if (result.settledShortOfGoal)
803 {
804 ARMARX_WARNING << "Simulation stopped: the robot came to rest "
805 << result.finalDistanceToGoal << " mm from the goal, outside the "
806 << params_.goalDistanceThreshold << " mm threshold.";
807 }
808 else if (not result.reachedGoal)
809 {
810 ARMARX_WARNING << "Simulation did not reach the goal within " << params_.maxDuration
811 << " s. Final distance to goal: " << result.finalDistanceToGoal
812 << " mm.";
813 }
814
815 if (result.maxGoalOvershoot > params_.goalDistanceThreshold)
816 {
817 ARMARX_WARNING << "Overshot the end of the path by up to " << result.maxGoalOvershoot
818 << " mm.";
819 }
820
821 ARMARX_INFO << "Simulated " << result.samples.size() << " control cycles ("
822 << (static_cast<float>(result.samples.size()) * params_.dt)
823 << " s). Peak required acceleration: "
824 << result.maxRequiredLinearAcceleration << " mm/s^2 (limit "
825 << params_.dynamics.maxLinearAcceleration << "), "
826 << result.maxRequiredAngularAcceleration << " rad/s^2 (limit "
827 << params_.dynamics.maxAngularAcceleration << "). Saturated in "
828 << result.saturatedCycles << " of " << result.samples.size()
829 << " cycles. Peak tracking error: " << result.maxTrackingError << " mm.";
830
831 return result;
832 }
833
834} // 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.