Add SO3 transmission and native orientation actuator.

https://youtu.be/17XpwnqyCXs

New transmission type mjTRN_SO3: a relative orientation, targeting a ball
joint or a site+refsite pair. It is the first transmission with more than
one force output: its length is the norm of the expmap vector of the
relative rotation and its moment axes are the 3 rows of the
relative rotational Jacobian, without projecting onto per-actuator gears.

New force law mjGAIN_SO3/mjBIAS_SO3: a geodesic PD servo, force =
kp * log(q_current^-1 * q_target) - kv * velocity, exact for arbitrary axis
combinations with a unique equilibrium at every commanded orientation.
Error, moment rows and velocity all live in the child frame (joint or
site): the right-difference error is the gradient of the geodesic
potential in that frame. The parent-frame (left) error is not: driving
child-frame torques with it pumps energy at large angles, settling into
steady-spinning limit cycles (the SO3LargeAngleConvergence test). The
integrator variant stores the 3D orientation setpoint in act (actnum = 3,
re-anchored to a bounded representative at integration time). Exposed in
MJCF as <orientation joint=|site=+refsite= kp kv|dampratio>, or via
<general gaintype="so3" biastype="so3">.

The setpoint input has two charts: an expmap target (3 controls, default)
or a quaternion target (4 controls) -- <orientation input="quat">, the
first actuator with different input and output widths. The signature is
recorded in a new per-actuator field actuator_ctrlspec (mjtCtrlChart),
whose meaning is scoped by the gain type the way gain/bias parameters are;
ctrlnum is derived from it at compile time and remains the layout
authority. An explicit field rather than width inference or a prm slot:
width-as-chart cannot express same-width signatures (upcoming servo input
subsets), and prm slots are the input_mode pattern this stack retires.
The force law normalizes the commanded quaternion, making it scale- and
antipodally-invariant. The all-zero ctrl still maps to the identity via
mju_normalize4, but it is a degenerate point (a nudge of any component
commands a half-turn), so quat inputs reset to the identity quaternion:
new mj_resetCtrl sets neutral ctrl values (zero, except qw = 1), called
by mj_resetData and the viewers' Clear All. The quat chart is
restricted to dyntype 'none': integrating a quaternion setpoint linearly
is not meaningful on the manifold. New mjsActuator.ctrlspec field carries
the signature through the spec and XML round-trip.

Actuator sensors (actuatorpos/vel/frc) now report one value per force
output; dim = 3 on an SO3 actuator.

As the first actuator with nu != nactuator, this commit also makes the
viewers multi-input aware: the control sliders in simulate and studio,
which indexed per-actuator arrays by control index (out of bounds on
this model class), are generated per control and labeled with the
actuator name plus an input suffix ("orient/qw"), via the new
introspection helper mj_actuatorInputName -- the single source of truth
for input names, extended by each new multi-input type (quaternion
components are w-first: qw, qx, qy, qz). Slider ranges now honor a
defined ctrlrange even when ctrllimited is false: range is the UI hint,
limited is the clamp -- wrapped and expmap setpoints are unbounded but
still want finite sliders, while quat components are truly bounded.

The rotational demo model is orientation.xml under
test/engine/testdata/actuation/, upgraded to a three-way contrast:
per-axis wrapped servos vs an expmap-commanded vs a quat-commanded
orientation actuator, on identical checker-textured boxes. It is loaded
by the mixed-axis contrast and input-name tests, and doubles as the
viewer test model (slider groups of 3 independent, 3 grouped, 4 grouped).

PiperOrigin-RevId: 951607063
Change-Id: If235dba8e2f2ca72672e7c62531a27e967c6a373
This commit is contained in:
Yuval Tassa
2026-07-21 11:35:28 -07:00
committed by Copybara-Service
parent a8545ac7cc
commit 072e963fa0
49 changed files with 1772 additions and 104 deletions
+596
View File
@@ -38,6 +38,8 @@ namespace {
using ::std::string;
using ::std::vector;
using ::testing::Each;
using ::testing::HasSubstr;
using ::testing::IsNull;
using ::testing::ElementsAre;
using ::testing::Not;
using ::testing::NotNull;
@@ -782,6 +784,600 @@ TEST_F(CoreSmoothTest, ForwardDoesNotMutateAct) {
mj_deleteData(data);
}
// expmap (axis-angle) vector to quaternion
static void Expmap2Quat(mjtNum quat[4], const mjtNum v[3]) {
mjtNum angle = mju_norm3(v);
if (angle < mjMINVAL) {
quat[0] = 1;
quat[1] = quat[2] = quat[3] = 0;
} else {
mjtNum axis[3] = {v[0]/angle, v[1]/angle, v[2]/angle};
mju_axisAngle2Quat(quat, axis, angle);
}
}
// geodesic distance between the orientations given by expmap vectors u and v
static mjtNum GeodesicError(const mjtNum u[3], const mjtNum v[3]) {
mjtNum q_tgt[4], q_cur[4], q_err[4], e[3];
Expmap2Quat(q_tgt, u);
Expmap2Quat(q_cur, v);
mju_negQuat(q_cur, q_cur);
mju_mulQuat(q_err, q_tgt, q_cur);
mju_quat2Vel(e, q_err, 1);
return mju_norm3(e);
}
// mixed model: three scalar translation servos and one SO3 orientation servo
static constexpr char kSO3RefsiteXml[] = R"(
<mujoco>
<option integrator="implicitfast">
<flag contact="disable" gravity="disable"/>
</option>
<worldbody>
<site name="reference"/>
<body name="box">
<freejoint/>
<geom type="box" size=".05 .07 .03"/>
<site name="end_effector"/>
</body>
</worldbody>
<actuator>
<position name="x" site="end_effector" refsite="reference" gear="1 0 0 0 0 0"
kp="100" dampratio="1"/>
<orientation name="orient" site="end_effector" refsite="reference" kp="1" dampratio="1"/>
<position name="y" site="end_effector" refsite="reference" gear="0 1 0 0 0 0"
kp="100" dampratio="1"/>
<position name="z" site="end_effector" refsite="reference" gear="0 0 1 0 0 0"
kp="100" dampratio="1"/>
</actuator>
<sensor>
<actuatorpos actuator="orient"/>
<actuatorfrc actuator="orient"/>
</sensor>
</mujoco>
)";
// Layout of a mixed model: the SO3 actuator owns 3-wide control and output
// blocks, misaligning nu/nout/nactuator with the actuator index; sensors on it
// are 3-dimensional; the model round-trips through XML.
TEST_F(CoreSmoothTest, SO3MixedModelLayout) {
char error[1024];
MjModelPtr model = LoadModelFromString(kSO3RefsiteXml, error, sizeof(error));
ASSERT_THAT(model.get(), NotNull()) << error;
// counts: 4 actuators, 3+3 controls, 3+3 force outputs
EXPECT_EQ(model->nactuator, 4);
EXPECT_EQ(model->nu, 6);
EXPECT_EQ(model->nout, 6);
// the orientation actuator is second, exercising address accumulation
int orient = mj_name2id(model.get(), mjOBJ_ACTUATOR, "orient");
ASSERT_EQ(orient, 1);
EXPECT_EQ(model->actuator_ctrladr[orient], 1);
EXPECT_EQ(model->actuator_ctrlnum[orient], 3);
EXPECT_EQ(model->actuator_outadr[orient], 1);
EXPECT_EQ(model->actuator_outnum[orient], 3);
EXPECT_EQ(model->actuator_trntype[orient], mjTRN_SO3);
// actuator sensors report one value per force output
EXPECT_EQ(model->sensor_dim[0], 3);
EXPECT_EQ(model->sensor_dim[1], 3);
EXPECT_EQ(model->sensor_adr[1], 3);
// XML round-trip preserves the layout
std::string saved = SaveAndReadXml(model.get());
MjModelPtr model2 = LoadModelFromString(saved.c_str(), error, sizeof(error));
ASSERT_THAT(model2.get(), NotNull()) << error;
EXPECT_EQ(model2->nactuator, 4);
EXPECT_EQ(model2->nu, 6);
EXPECT_EQ(model2->nout, 6);
EXPECT_EQ(model2->actuator_trntype[orient], mjTRN_SO3);
}
// A mixed-axis orientation target beyond the pi shell is a true equilibrium:
// zero force when the body is at the commanded orientation, convergence to it
// from the initial state.
TEST_F(CoreSmoothTest, SO3RefsiteMixedAxisEquilibrium) {
char error[1024];
MjModelPtr model = LoadModelFromString(kSO3RefsiteXml, error, sizeof(error));
ASSERT_THAT(model.get(), NotNull()) << error;
mjData* data = mj_makeData(model.get());
int orient = mj_name2id(model.get(), mjOBJ_ACTUATOR, "orient");
int uadr = model->actuator_ctrladr[orient];
int oadr = model->actuator_outadr[orient];
// target: 5.66 rad rotation about the mixed axis (1,1,0)/sqrt(2), beyond pi;
// canonical (shortest) expmap is u*(1 - 2*pi/norm(u)) = (-.4429, -.4429, 0)
mjtNum target[3] = {4, 4, 0};
mjtNum shrink = 1 - 2*mjPI/mju_norm3(target);
mjtNum canonical[3] = {target[0]*shrink, target[1]*shrink, target[2]*shrink};
mju_copy3(data->ctrl + uadr, target);
// place the body exactly at the commanded orientation: force must vanish
Expmap2Quat(data->qpos + 3, target);
mj_forward(model.get(), data);
for (int k=0; k < 3; k++) {
EXPECT_LT(mju_abs(data->actuator_force[oadr + k]), MjTol(1e-10, 1e-6));
EXPECT_LT(mju_abs(data->actuator_length[oadr + k] - canonical[k]),
MjTol(1e-10, 1e-6));
// sensors: actuatorpos = canonical expmap, actuatorfrc = 0
EXPECT_LT(mju_abs(data->sensordata[k] - canonical[k]), MjTol(1e-10, 1e-6));
EXPECT_LT(mju_abs(data->sensordata[3 + k]), MjTol(1e-10, 1e-6));
}
// from the initial state, converge to the commanded orientation
mj_resetData(model.get(), data);
mju_copy3(data->ctrl + uadr, target);
while (data->time < 10) {
mj_step(model.get(), data);
}
for (int k=0; k < 3; k++) {
EXPECT_LT(mju_abs(data->actuator_length[oadr + k] - canonical[k]), 1e-3);
EXPECT_LT(mju_abs(data->actuator_velocity[oadr + k]), 1e-3);
}
mj_deleteData(data);
}
// Test smooth tracking while winding one axis with another axis held nonzero:
// the regime where per-axis servo errors cannot work and only the geodesic
// error on SO(3) tracks correctly.
TEST_F(CoreSmoothTest, SO3RefsiteTracksMixedWindingTarget) {
char error[1024];
MjModelPtr model = LoadModelFromString(kSO3RefsiteXml, error, sizeof(error));
ASSERT_THAT(model.get(), NotNull()) << error;
mjData* data = mj_makeData(model.get());
int orient = mj_name2id(model.get(), mjOBJ_ACTUATOR, "orient");
int uadr = model->actuator_ctrladr[orient];
int oadr = model->actuator_outadr[orient];
// hold rx at 1 rad, let the servo settle
data->ctrl[uadr] = 1;
while (data->time < 2) {
mj_step(model.get(), data);
}
// ramp the rz target from 0 to 2*pi
const mjtNum rate = 0.5; // rad/s
mjtNum start = data->time;
while (data->time - start < 2*mjPI / rate) {
data->ctrl[uadr + 2] = rate * (data->time - start);
mj_step(model.get(), data);
// geodesic distance between commanded and current orientation
mjtNum err = GeodesicError(data->ctrl + uadr, data->actuator_length + oadr);
ASSERT_LT(err, 0.5) << "tracking lost at time " << data->time
<< ", target rz " << data->ctrl[uadr + 2];
}
mj_deleteData(data);
}
// Mixed-axis target beyond the pi shell on ball joints: the SO3 actuator has
// an exact equilibrium at the commanded orientation, per-axis wrapped servos
// do not.
TEST_F(CoreSmoothTest, SO3BallMixedAxisContrast) {
constexpr char kOrientationPath[] =
"engine/testdata/actuation/orientation.xml";
const std::string xml_path = GetTestDataFilePath(kOrientationPath);
char error[1024];
MjModelPtr model(mj_loadXML(xml_path.c_str(), nullptr, error, sizeof(error)));
ASSERT_THAT(model.get(), NotNull()) << "Failed to load model: " << error;
mjData* data = mj_makeData(model.get());
// place both joints exactly at the target orientation, expmap (4, 4, 0)
mjtNum target[3] = {4, 4, 0};
mjtNum quat[4];
Expmap2Quat(quat, target);
for (const char* name : {"peraxis", "expmap"}) {
int jnt = mj_name2id(model.get(), mjOBJ_JOINT, name);
mju_copy4(data->qpos + model->jnt_qposadr[jnt], quat);
}
int rx_peraxis = mj_name2id(model.get(), mjOBJ_ACTUATOR, "rx_peraxis");
int orient = mj_name2id(model.get(), mjOBJ_ACTUATOR, "expmap");
int uadr = model->actuator_ctrladr[orient];
int oadr = model->actuator_outadr[orient];
data->ctrl[rx_peraxis] = data->ctrl[rx_peraxis + 1] = 4;
data->ctrl[uadr] = data->ctrl[uadr + 1] = 4;
mj_forward(model.get(), data);
// SO3: zero force at the commanded orientation
for (int k=0; k < 3; k++) {
EXPECT_LT(mju_abs(data->actuator_force[oadr + k]), MjTol(1e-10, 1e-6));
}
// per-axis: residual force kp*(4.443 - 2*pi) = -1.84 on the wound members
EXPECT_GT(mju_abs(data->actuator_force[rx_peraxis]), 1);
EXPECT_GT(mju_abs(data->actuator_force[rx_peraxis + 1]), 1);
mj_deleteData(data);
}
// Geodesic servo converges to large mixed-axis targets from rest: no limit
// cycles. Regression test: a parent-frame error driving child-frame torques is
// non-gradient feedback which pumps energy at large angles, settling into
// steady spinning.
TEST_F(CoreSmoothTest, SO3LargeAngleConvergence) {
constexpr char kOrientationPath[] =
"engine/testdata/actuation/orientation.xml";
const std::string xml_path = GetTestDataFilePath(kOrientationPath);
char error[1024];
MjModelPtr model(mj_loadXML(xml_path.c_str(), nullptr, error, sizeof(error)));
ASSERT_THAT(model.get(), NotNull()) << "Failed to load model: " << error;
mjData* data = mj_makeData(model.get());
int expmap = mj_name2id(model.get(), mjOBJ_ACTUATOR, "expmap");
int jnt = mj_name2id(model.get(), mjOBJ_JOINT, "expmap");
int uadr = model->actuator_ctrladr[expmap];
int dofadr = model->jnt_dofadr[jnt];
mjtNum targets[4][3] = {{1, 1, 1}, {-1, -1, 0}, {1, -1, 1}, {2.2, 2.2, 2.2}};
for (const auto& u : targets) {
SCOPED_TRACE(testing::Message()
<< "target (" << u[0] << ", " << u[1] << ", " << u[2] << ")");
mj_resetData(model.get(), data);
mju_copy3(data->ctrl + uadr, u);
for (int i = 0; i < 4000; i++) {
mj_step(model.get(), data);
}
// orientation error and angular velocity vanish
mjtNum q_tgt[4], q_cur[4], e[3];
mjtNum axis[3] = {u[0], u[1], u[2]};
mjtNum angle = mju_normalize3(axis);
mju_axisAngle2Quat(q_tgt, axis, angle);
mju_copy4(q_cur, data->qpos + model->jnt_qposadr[jnt]);
mju_normalize4(q_cur);
mju_subQuat(e, q_tgt, q_cur);
EXPECT_LT(mju_norm3(e), MjTol(1e-4, 1e-2));
EXPECT_LT(mju_norm3(data->qvel + dofadr), MjTol(1e-4, 1e-2));
}
mj_deleteData(data);
}
// Neutral ctrl: reset zeroes all controls except quat (to the identity).
TEST_F(CoreSmoothTest, SO3QuatNeutralCtrl) {
constexpr char kOrientationPath[] =
"engine/testdata/actuation/orientation.xml";
const std::string xml_path = GetTestDataFilePath(kOrientationPath);
char error[1024];
MjModelPtr model(mj_loadXML(xml_path.c_str(), nullptr, error, sizeof(error)));
ASSERT_THAT(model.get(), NotNull()) << "Failed to load model: " << error;
mjData* data = mj_makeData(model.get());
int quat = mj_name2id(model.get(), mjOBJ_ACTUATOR, "quat");
int uadr = model->actuator_ctrladr[quat];
for (int trial = 0; trial < 2; trial++) {
for (int j = 0; j < model->nu; j++) {
EXPECT_EQ(data->ctrl[j], j == uadr ? 1 : 0)
<< "ctrl " << j << " trial " << trial;
}
mju_fill(data->ctrl, 0.5, model->nu);
mj_resetData(model.get(), data);
}
mj_deleteData(data);
}
// Input names: NULL for single-input actuators, chart components for SO3.
TEST_F(CoreSmoothTest, ActuatorInputNames) {
constexpr char kOrientationPath[] =
"engine/testdata/actuation/orientation.xml";
const std::string xml_path = GetTestDataFilePath(kOrientationPath);
char error[1024];
MjModelPtr model(mj_loadXML(xml_path.c_str(), nullptr, error, sizeof(error)));
ASSERT_THAT(model.get(), NotNull()) << "Failed to load model: " << error;
int rx_peraxis = mj_name2id(model.get(), mjOBJ_ACTUATOR, "rx_peraxis");
int expmap = mj_name2id(model.get(), mjOBJ_ACTUATOR, "expmap");
int quat = mj_name2id(model.get(), mjOBJ_ACTUATOR, "quat");
EXPECT_EQ(mj_actuatorInputName(model.get(), rx_peraxis, 0), nullptr);
EXPECT_STREQ(mj_actuatorInputName(model.get(), expmap, 0), "rx");
EXPECT_STREQ(mj_actuatorInputName(model.get(), expmap, 2), "rz");
EXPECT_EQ(mj_actuatorInputName(model.get(), expmap, 3),
nullptr); // out of range
EXPECT_STREQ(mj_actuatorInputName(model.get(), quat, 0), "qw");
EXPECT_STREQ(mj_actuatorInputName(model.get(), quat, 3), "qz");
}
// SO3 integrator variant: act is the 3D orientation setpoint; constant ctrl
// produces steady rotation over many periods with bounded activation.
TEST_F(CoreSmoothTest, SO3IntVelocityWindsWithBoundedAct) {
static constexpr char xml[] = R"(
<mujoco>
<option integrator="implicitfast">
<flag contact="disable" gravity="disable"/>
</option>
<worldbody>
<body>
<joint name="ball" type="ball"/>
<geom type="box" size=".05 .07 .03"/>
</body>
</worldbody>
<actuator>
<general name="rot" joint="ball" dyntype="integrator"
gaintype="so3" biastype="so3" gainprm="1" biasprm="0 -1 -1"/>
</actuator>
</mujoco>
)";
char error[1024];
MjModelPtr model = LoadModelFromString(xml, error, sizeof(error));
ASSERT_THAT(model.get(), NotNull()) << error;
// one actuator: 3 controls, 3 outputs, 3 activations
EXPECT_EQ(model->nu, 3);
EXPECT_EQ(model->nout, 3);
EXPECT_EQ(model->na, 3);
mjData* data = mj_makeData(model.get());
// spin about z for 4 full turns
const mjtNum rate = 1.0; // rad/s
data->ctrl[2] = rate;
while (data->time < 8*mjPI / rate) {
mj_step(model.get(), data);
ASSERT_LT(mju_norm3(data->act), mjPI + 0.1) << "act unbounded";
}
// steady rotation at the commanded rate about z
EXPECT_NEAR(data->actuator_velocity[2], rate, 0.02);
mj_deleteData(data);
}
// Compile-time validation of the SO3 actuator.
TEST_F(CoreSmoothTest, SO3CompileErrors) {
char error[1024];
// hinge joint target: rejected
static constexpr char hinge_xml[] = R"(
<mujoco>
<worldbody>
<body>
<joint name="hinge"/>
<geom size=".05"/>
</body>
</worldbody>
<actuator>
<orientation joint="hinge" kp="1"/>
</actuator>
</mujoco>
)";
MjModelPtr model = LoadModelFromString(hinge_xml, error, sizeof(error));
EXPECT_THAT(model.get(), IsNull());
EXPECT_THAT(error, HasSubstr("ball"));
// site without refsite: rejected
static constexpr char nosite_xml[] = R"(
<mujoco>
<worldbody>
<body>
<freejoint/>
<geom size=".05"/>
<site name="ee"/>
</body>
</worldbody>
<actuator>
<orientation site="ee" kp="1"/>
</actuator>
</mujoco>
)";
model = LoadModelFromString(nosite_xml, error, sizeof(error));
EXPECT_THAT(model.get(), IsNull());
EXPECT_THAT(error, HasSubstr("refsite"));
// mismatched gaintype/biastype: rejected
static constexpr char mismatch_xml[] = R"(
<mujoco>
<worldbody>
<body>
<joint name="ball" type="ball"/>
<geom type="box" size=".05 .07 .03"/>
</body>
</worldbody>
<actuator>
<general joint="ball" gaintype="so3" biastype="affine" gainprm="1"/>
</actuator>
</mujoco>
)";
model = LoadModelFromString(mismatch_xml, error, sizeof(error));
EXPECT_THAT(model.get(), IsNull());
EXPECT_THAT(error, HasSubstr("both"));
// forcerange with nonzero lower bound: rejected (force clamped on the norm)
static constexpr char forcerange_xml[] = R"(
<mujoco>
<worldbody>
<body>
<joint name="ball" type="ball"/>
<geom type="box" size=".05 .07 .03"/>
</body>
</worldbody>
<actuator>
<orientation joint="ball" kp="1" forcerange="-4 4"/>
</actuator>
</mujoco>
)";
model = LoadModelFromString(forcerange_xml, error, sizeof(error));
EXPECT_THAT(model.get(), IsNull());
EXPECT_THAT(error, HasSubstr("lower bound must be 0"));
}
// forcerange clamps the norm of the SO3 output torque, preserving direction.
TEST_F(CoreSmoothTest, SO3ForcerangeClampsNorm) {
static constexpr char xml[] = R"(
<mujoco>
<option>
<flag contact="disable" gravity="disable"/>
</option>
<worldbody>
<body>
<joint name="unclamped" type="ball"/>
<geom type="box" size=".05 .07 .03"/>
</body>
<body pos="0 0 .3">
<joint name="clamped" type="ball"/>
<geom type="box" size=".05 .07 .03"/>
</body>
</worldbody>
<actuator>
<orientation name="unclamped" joint="unclamped" kp="1"/>
<orientation name="clamped" joint="clamped" kp="1" forcerange="0 .5"/>
</actuator>
</mujoco>
)";
char error[1024];
MjModelPtr model = LoadModelFromString(xml, error, sizeof(error));
ASSERT_THAT(model.get(), NotNull()) << error;
mjData* data = mj_makeData(model.get());
// command the same mixed-axis target, error norm sqrt(5) > 0.5
int unclamped = mj_name2id(model.get(), mjOBJ_ACTUATOR, "unclamped");
int clamped = mj_name2id(model.get(), mjOBJ_ACTUATOR, "clamped");
for (int i : {unclamped, clamped}) {
int uadr = model->actuator_ctrladr[i];
data->ctrl[uadr + 0] = 1;
data->ctrl[uadr + 1] = 2;
data->ctrl[uadr + 2] = 0;
}
mj_forward(model.get(), data);
// clamped force has norm forcerange[1], parallel to the unclamped force
const mjtNum* f_unclamped =
data->actuator_force + model->actuator_outadr[unclamped];
const mjtNum* f_clamped =
data->actuator_force + model->actuator_outadr[clamped];
mjtNum norm_unclamped = mju_norm3(f_unclamped);
EXPECT_GT(norm_unclamped, 0.5);
EXPECT_NEAR(mju_norm3(f_clamped), 0.5, MjTol(1e-12, 1e-6));
mjtNum scale = 0.5 / norm_unclamped;
for (int k = 0; k < 3; k++) {
EXPECT_NEAR(f_clamped[k], scale * f_unclamped[k], MjTol(1e-12, 1e-6));
}
mj_deleteData(data);
}
// Quat-setpoint variant of the SO3 servo: 4 inputs, 3 outputs -- the first
// actuator with different input and output widths.
TEST_F(CoreSmoothTest, SO3QuatSetpoint) {
static constexpr char xml[] = R"(
<mujoco>
<option integrator="implicitfast">
<flag contact="disable" gravity="disable"/>
</option>
<worldbody>
<body pos="-.15 0 .2">
<joint name="ball_scalar" type="ball"/>
<geom type="box" size=".05 .07 .03"/>
</body>
<body pos=".15 0 .2">
<joint name="ball_quat" type="ball"/>
<geom type="box" size=".05 .07 .03"/>
</body>
</worldbody>
<actuator>
<position name="rz" joint="ball_scalar" gear="0 0 1" kp="1" dampratio="1"/>
<orientation name="orient" joint="ball_quat" kp="1" dampratio="1" input="quat"/>
</actuator>
</mujoco>
)";
char error[1024];
MjModelPtr model = LoadModelFromString(xml, error, sizeof(error));
ASSERT_THAT(model.get(), NotNull()) << error;
// rectangular layout: 2 actuators, 1+4 controls, 1+3 force outputs
EXPECT_EQ(model->nactuator, 2);
EXPECT_EQ(model->nu, 5);
EXPECT_EQ(model->nout, 4);
int orient = mj_name2id(model.get(), mjOBJ_ACTUATOR, "orient");
int uadr = model->actuator_ctrladr[orient];
int oadr = model->actuator_outadr[orient];
EXPECT_EQ(uadr, 1);
EXPECT_EQ(model->actuator_ctrlnum[orient], 4);
EXPECT_EQ(oadr, 1);
EXPECT_EQ(model->actuator_outnum[orient], 3);
// XML round-trip preserves the input chart
std::string saved = SaveAndReadXml(model.get());
MjModelPtr model2 = LoadModelFromString(saved.c_str(), error, sizeof(error));
ASSERT_THAT(model2.get(), NotNull()) << error;
EXPECT_EQ(model2->nu, 5);
EXPECT_EQ(model2->nout, 4);
mjData* data = mj_makeData(model.get());
// zero ctrl commands the identity orientation: zero force at qpos0
mj_forward(model.get(), data);
for (int k=0; k < 3; k++) {
EXPECT_LT(mju_abs(data->actuator_force[oadr + k]), MjTol(1e-10, 1e-6));
}
// target beyond the pi shell, mixed axis
mjtNum target[3] = {4, 4, 0};
mjtNum q_tgt[4];
Expmap2Quat(q_tgt, target);
int jnt = mj_name2id(model.get(), mjOBJ_JOINT, "ball_quat");
// scale and antipodal invariance: q, 2q and -q command the same orientation
mjtNum ctrl_variants[3][4];
mju_copy4(ctrl_variants[0], q_tgt);
for (int k=0; k < 4; k++) {
ctrl_variants[1][k] = 2*q_tgt[k];
ctrl_variants[2][k] = -q_tgt[k];
}
for (int v=0; v < 3; v++) {
mj_resetData(model.get(), data);
mju_copy4(data->qpos + model->jnt_qposadr[jnt], q_tgt);
mju_copy4(data->ctrl + uadr, ctrl_variants[v]);
mj_forward(model.get(), data);
for (int k=0; k < 3; k++) {
EXPECT_LT(mju_abs(data->actuator_force[oadr + k]), MjTol(1e-10, 1e-6))
<< "variant " << v;
}
}
// from the initial state, converge to the commanded orientation
mj_resetData(model.get(), data);
mju_copy4(data->ctrl + uadr, q_tgt);
while (data->time < 10) {
mj_step(model.get(), data);
}
mjtNum shrink = 1 - 2*mjPI/mju_norm3(target);
for (int k=0; k < 3; k++) {
EXPECT_LT(mju_abs(data->actuator_length[oadr + k] - target[k] * shrink),
1e-3);
EXPECT_LT(mju_abs(data->actuator_velocity[oadr + k]), 1e-3);
}
mj_deleteData(data);
}
// The quat input chart requires stateless dynamics.
TEST_F(CoreSmoothTest, SO3QuatSetpointRequiresStateless) {
static constexpr char xml[] = R"(
<mujoco>
<worldbody>
<body>
<joint name="ball" type="ball"/>
<geom type="box" size=".05 .07 .03"/>
</body>
</worldbody>
<actuator>
<general joint="ball" dyntype="integrator" input="quat"
gaintype="so3" biastype="so3" gainprm="1" biasprm="0 -1 -1"/>
</actuator>
</mujoco>
)";
char error[1024];
MjModelPtr model = LoadModelFromString(xml, error, sizeof(error));
EXPECT_THAT(model.get(), IsNull());
EXPECT_THAT(error, HasSubstr("dyntype"));
}
static const char* const kInertiaPath = "engine/testdata/inertia.xml";
TEST_F(CoreSmoothTest, FactorI) {
+53
View File
@@ -1226,6 +1226,59 @@ TEST_F(DerivativeTest, ForcerangeClampedDerivative) {
<< "when forcerange derivatives are correctly handled";
}
// forcelimited actuator following a multi-output SO3 actuator: the derivative
// skip for saturated actuators must index forcerange per actuator, not per
// output.
TEST_F(DerivativeTest, ForcerangeClampedAfterSO3) {
static constexpr char xml[] = R"(
<mujoco>
<option>
<flag contact="disable" gravity="disable"/>
</option>
<worldbody>
<body>
<joint name="ball" type="ball"/>
<geom type="box" size=".05 .07 .03"/>
</body>
<body pos="0 0 .3">
<joint name="hinge"/>
<geom size=".05"/>
</body>
</worldbody>
<actuator>
<orientation joint="ball" kp="1" kv="1"/>
<velocity joint="hinge" kv="10" forcerange="-1 1"/>
</actuator>
</mujoco>
)";
char error[1024];
MjModelPtr model = LoadModelFromString(xml, error, sizeof(error));
ASSERT_THAT(model.get(), NotNull()) << error;
MjDataPtr data = MakeData(model);
mjModel* m = model.get();
mjData* d = data.get();
// spin the hinge so the velocity actuator saturates: force -50, clamped -1
mjtNum qvel[4] = {0.1, 0.2, 0.3, 5};
mju_copy(d->qvel, qvel, 4);
mj_forward(m, d);
ASSERT_EQ(d->actuator_force[3], -1);
// analytic qDeriv
mju_zero(d->qDeriv, m->nD);
mjd_smooth_vel(m, d, /*flg_bias=*/1);
vector<mjtNum> qDerivAnalytic = AsVector(d->qDeriv, m->nD);
EXPECT_GT(mju_norm(qDerivAnalytic.data(), m->nD), 0);
// expect match with finite differences: the saturated actuator contributes
// nothing, the SO3 actuator's damping is unaffected by its neighbor
mjtNum eps = MjTol(1e-7, 1e-3);
mju_zero(d->qDeriv, m->nD);
mjd_smooth_velFD(m, d, eps);
EXPECT_THAT(AsVector(d->qDeriv, m->nD),
Pointwise(MjNear(1e-7, 3e-3), qDerivAnalytic));
}
TEST_F(DerivativeTest, NonlinearDampingDerivative) {
static constexpr char xml[] = R"(
<mujoco>
+78
View File
@@ -0,0 +1,78 @@
<mujoco>
<compiler autolimits="true"/>
<option integrator="implicitfast">
<flag contact="disable" gravity="disable"/>
</option>
<statistic meansize=".05" extent=".9" center="0 0 .15"/>
<visual>
<headlight diffuse=".1 .1 .1" ambient=".15 .15 .15"/>
</visual>
<!--
Three identical ball-jointed boxes, illustrating the three rotational setpoint semantics.
The "per-axis" box has three independent position servos: each setpoint is interpreted on
its own circle (wrapped), exact for rotations about a single gear axis. The "expmap" and
"quat" boxes each have a single orientation actuator: the setpoints are interpreted
jointly as an orientation on SO(3), exact for any combination of axes, commanded in the
exponential-map and quaternion charts respectively.
-->
<asset>
<texture name="checker" type="cube" builtin="checker" rgb1=".75 .55 .28" rgb2=".22 .18 .14" width="32" height="32"/>
<material name="checker" texture="checker"/>
<texture name="grid" type="2d" builtin="checker" width="512" height="512" rgb1=".1 .2 .3" rgb2=".2 .3 .4"/>
<material name="grid" texture="grid" texrepeat="1 1" texuniform="true" reflectance=".2"/>
</asset>
<default>
<geom type="box" size=".07 .09 .05" material="checker"/>
<position kp="5" dampratio="1"/>
<default class="angle">
<position ctrlrange="-6.2832 6.2832" ctrllimited="false"/>
</default>
<default class="quat">
<position ctrlrange="-1 1" ctrllimited="true"/>
</default>
<site size="1e-5"/>
</default>
<worldbody>
<light pos="0 0 10" type="point" diffuse=".75 .72 .65" intensity="1200000" range="20" attenuation="1 0 .04"/>
<light pos="-.3 -.4 1.3" dir="0 .34 -.94" diffuse=".75 .72 .65" intensity="100000" range="4" cutoff="13" attenuation="1 0 0" exponent="60"/>
<light pos="0 -.4 1.3" dir="0 .34 -.94" diffuse=".75 .72 .65" intensity="100000" range="4" cutoff="16" exponent="60" attenuation="1 0 .5"/>
<light pos=".3 -.4 1.3" dir="0 .34 -.94" diffuse=".75 .72 .65" intensity="100000" range="4" cutoff="16" exponent="60" attenuation="1 0 .5"/>
<light type="point" pos="0 -1.5 2" diffuse=".3 .35 .45" intensity="15000" range="8" castshadow="false" attenuation="1 0 .3"/>
<geom name="floor" type="plane" size="2 2 .025" material="grid"/>
<site name="per-axis" pos="-.3 0 .4"/>
<body name="peraxis" pos="-.3 0 .2">
<joint name="peraxis" type="ball"/>
<geom/>
</body>
<site name="expmap" pos="0 0 .4"/>
<body name="expmap" pos="0 0 .2">
<joint name="expmap" type="ball"/>
<geom/>
</body>
<site name="quat" pos=".3 0 .4"/>
<body name="quat" pos=".3 0 .2">
<joint name="quat" type="ball"/>
<geom/>
</body>
</worldbody>
<actuator>
<position name="rx_peraxis" joint="peraxis" gear="1 0 0" class="angle"/>
<position name="ry_peraxis" joint="peraxis" gear="0 1 0" class="angle"/>
<position name="rz_peraxis" joint="peraxis" gear="0 0 1" class="angle"/>
<orientation name="expmap" joint="expmap" kp="1" dampratio="1" class="angle"/>
<orientation name="quat" joint="quat" input="quat" kp="1" dampratio="1" class="quat"/>
</actuator>
</mujoco>
+30
View File
@@ -483,6 +483,36 @@ TEST_F(MujocoTest, SetToDCMotorLuGre) {
mj_deleteSpec(spec);
}
TEST_F(MujocoTest, SetToOrientation) {
mjSpec* spec = mj_makeSpec();
mjsActuator* actuator = mjs_addActuator(spec, 0);
// kv variant, default (expmap) chart
double kv = 2.0;
const char* err = mjs_setToOrientation(actuator, 5.0, &kv, nullptr, 0);
EXPECT_STREQ(err, "");
EXPECT_EQ(actuator->gaintype, mjGAIN_SO3);
EXPECT_EQ(actuator->biastype, mjBIAS_SO3);
EXPECT_EQ(actuator->dyntype, mjDYN_NONE);
EXPECT_EQ(actuator->gainprm[0], 5.0);
EXPECT_EQ(actuator->biasprm[1], -5.0);
EXPECT_EQ(actuator->biasprm[2], -2.0);
EXPECT_EQ(actuator->ctrlspec, 0);
// dampratio variant, quat chart
double dampratio = 1.0;
err = mjs_setToOrientation(actuator, 5.0, nullptr, &dampratio, mjCHART_QUAT);
EXPECT_STREQ(err, "");
EXPECT_EQ(actuator->biasprm[2], 1.0);
EXPECT_EQ(actuator->ctrlspec, mjCHART_QUAT);
// kv and dampratio are mutually exclusive
err = mjs_setToOrientation(actuator, 5.0, &kv, &dampratio, 0);
EXPECT_STREQ(err, "kv and dampratio cannot both be defined");
mj_deleteSpec(spec);
}
static constexpr char xml_plugin_1[] = R"(
<mujoco model="MuJoCo Model">
<worldbody>