// Copyright 2022 DeepMind Technologies Limited // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Tests for engine/engine_derivative.c. #include "src/engine/engine_derivative.h" #include #include #include #include #include #include #include #include #include "src/engine/engine_core_smooth.h" #include "src/engine/engine_derivative_fd.h" #include "src/engine/engine_forward.h" #include "src/engine/engine_io.h" #include "src/engine/engine_util_blas.h" #include "test/fixture.h" namespace mujoco { namespace { using ::std::vector; using ::testing::Pointwise; using ::testing::DoubleNear; using ::testing::Eq; using ::testing::Each; using ::testing::NotNull; using DerivativeTest = MujocoTest; // errors smaller than this are ignored #ifdef mjUSESINGLE static const mjtNum absolute_tolerance = 1e-3; #else static const mjtNum absolute_tolerance = 1e-9; #endif // corrected relative error static mjtNum RelativeError(mjtNum a, mjtNum b) { mjtNum nominator = mjMAX(0, mju_abs(a-b) - absolute_tolerance); mjtNum denominator = (mju_abs(a) + mju_abs(b) + absolute_tolerance); return nominator / denominator; } // expect two 2D arrays to have elementwise relative error smaller than eps // return maximum absolute error static mjtNum CompareMatrices(mjtNum* Actual, mjtNum* Expected, int nrow, int ncol, mjtNum eps) { mjtNum max_error = 0; for (int i=0; i < nrow; i++) { for (int j=0; j < ncol; j++) { mjtNum actual = Actual[i*ncol+j]; mjtNum expected = Expected[i*ncol+j]; EXPECT_LT(RelativeError(actual, expected), eps) << "error at position (" << i << ", " << j << ")" << "\nexpected = " << expected << "\nactual = " << actual << "\ndiff = " << expected-actual; max_error = mjMAX(mju_abs(actual-expected), max_error); } } return max_error; } static const char* const kEnergyConservingPendulumPath = "engine/testdata/derivative/energy_conserving_pendulum.xml"; static const char* const kTumblingThinObjectPath = "engine/testdata/derivative/tumbling_thin_object.xml"; static const char* const kTumblingThinObjectEllipsoidPath = "engine/testdata/derivative/tumbling_thin_object_ellipsoid.xml"; static const char* const kDampedActuatorsPath = "engine/testdata/derivative/damped_actuators.xml"; static const char* const kDamperActuatorsPath = "engine/testdata/actuation/damper.xml"; static const char* const kDampedPendulumPath = "engine/testdata/derivative/damped_pendulum.xml"; static const char* const kLinearPath = "engine/testdata/derivative/linear.xml"; static const char* const kDCMotorPath = "engine/testdata/derivative/dcmotor.xml"; static const char* const kModelPath = "testdata/model.xml"; // compare analytic and finite-difference d_smooth/d_qvel TEST_F(DerivativeTest, SmoothDvel) { // run test on all models for (const char* local_path : {kEnergyConservingPendulumPath, kTumblingThinObjectPath, kDampedActuatorsPath, kDamperActuatorsPath, kDCMotorPath}) { const std::string xml_path = GetTestDataFilePath(local_path); char error[1024] = ""; mjModel* model = mj_loadXML(xml_path.c_str(), nullptr, error, sizeof(error)); ASSERT_THAT(model, testing::NotNull()) << "Failed to load model: " << error; int nD = model->nD; mjData* data = mj_makeData(model); for (mjtJacobian sparsity : {mjJAC_DENSE, mjJAC_SPARSE}) { // set sparsity model->opt.jacobian = sparsity; // take 100 steps so we have some velocities, then call forward mj_resetData(model, data); if (model->nu) { data->ctrl[0] = 0.1; } for (int i=0; i < 100; i++) { mj_step(model, data); } mj_forward(model, data); // construct sparse structure in d->D_xxx, compute analytical qDeriv mju_zero(data->qDeriv, nD); mjd_smooth_vel(model, data, /*flg_bias=*/true); // expect derivatives to be non-zero, make copy of qDeriv as a vector EXPECT_GT(mju_norm(data->qDeriv, nD), 0); vector qDerivAnalytic = AsVector(data->qDeriv, nD); // compute finite-difference derivatives mjtNum eps = MjTol(1e-7, 1e-3); mju_zero(data->qDeriv, nD); mjd_smooth_velFD(model, data, eps); // expect FD and analytic derivatives to be numerically different EXPECT_NE(mju_norm(data->qDeriv, nD), mju_norm(qDerivAnalytic.data(), nD)); // expect FD and analytic derivatives to be similar to eps precision EXPECT_THAT(AsVector(data->qDeriv, nD), Pointwise(MjNear(1e-7, 3e-3), qDerivAnalytic)); } mj_deleteData(data); mj_deleteModel(model); } } // disabled actuators do not contribute to d_qfrc_actuator/d_qvel TEST_F(DerivativeTest, DisabledActuators) { // model with only a position actuator static constexpr char xml1[] = R"( )"; char error[1024]; mjModel* m1 = LoadModelFromString(xml1, error, sizeof(error)); ASSERT_THAT(m1, NotNull()) << error; mjData* d1 = mj_makeData(m1); d1->ctrl[0] = 6; while (d1->time < 1) mj_step(m1, d1); // model with a position actuator and an intvelocity actuator static constexpr char xml2[] = R"( )"; mjModel* m2 = LoadModelFromString(xml2); mjData* d2 = mj_makeData(m2); d2->ctrl[0] = 6; d2->ctrl[1] = 6; while (d2->time < 1) mj_step(m2, d2); // expect same qvel in both models EXPECT_EQ(d1->qvel[0], d2->qvel[0]); mj_deleteData(d2); mj_deleteModel(m2); mj_deleteData(d1); mj_deleteModel(m1); } // actuator order has no effect TEST_F(DerivativeTest, ActuatorOrder) { // model with stateful actuator first static constexpr char xml1[] = R"( )"; char error[1024]; mjModel* m1 = LoadModelFromString(xml1, error, sizeof(error)); ASSERT_THAT(m1, NotNull()) << "Failed to load model: " << error; mjData* d1 = mj_makeData(m1); d1->ctrl[0] = 6; d1->ctrl[1] = 6; while (d1->time < 1) mj_step(m1, d1); // model with stateful actuator second static constexpr char xml2[] = R"( )"; mjModel* m2 = LoadModelFromString(xml2, error, sizeof(error)); ASSERT_THAT(m2, NotNull()) << "Failed to load model: " << error; mjData* d2 = mj_makeData(m2); d2->ctrl[0] = 6; d2->ctrl[1] = 6; while (d2->time < 1) mj_step(m2, d2); // expect same qvel in both models EXPECT_EQ(d1->qvel[0], d2->qvel[0]); EXPECT_EQ(d1->qvel[1], d2->qvel[1]); mj_deleteData(d2); mj_deleteModel(m2); mj_deleteData(d1); mj_deleteModel(m1); } // compare analytic and fin-diff d_qfrc_passive/d_qvel TEST_F(DerivativeTest, PassiveDvel) { for (const char* local_path : {kTumblingThinObjectPath, kTumblingThinObjectEllipsoidPath}) { // load model const std::string xml_path = GetTestDataFilePath(local_path); mjModel* model = mj_loadXML(xml_path.c_str(), nullptr, nullptr, 0); int nD = model->nD; mjData* data = mj_makeData(model); // allocate Jacobians mjtNum* qDerivAnalytic = (mjtNum*) mju_malloc(sizeof(mjtNum)*nD); mjtNum* qDerivFD = (mjtNum*) mju_malloc(sizeof(mjtNum)*nD); for (mjtJacobian sparsity : {mjJAC_DENSE, mjJAC_SPARSE}) { // set sparsity model->opt.jacobian = sparsity; // take 100 steps so we have some velocities, then call forward mj_resetData(model, data); for (int i=0; i < 100; i++) { mj_step(model, data); } mj_forward(model, data); // get analytic derivatives mju_zero(data->qDeriv, model->nD); mjd_passive_vel(model, data); mju_copy(qDerivAnalytic, data->qDeriv, nD); // clear qDeriv, get finite-difference derivatives mju_zero(data->qDeriv, nD); mju_zero(qDerivFD, nD); mjtNum eps = MjTol(1e-6, 1e-4); mjd_passive_velFD(model, data, eps); // expect FD and analytic derivatives to be similar to tol precision EXPECT_THAT(AsVector(data->qDeriv, nD), Pointwise(MjNear(1e-6, 1e-4), AsVector(qDerivAnalytic, nD))); } mju_free(qDerivFD); mju_free(qDerivAnalytic); mj_deleteData(data); mj_deleteModel(model); } } // ----------------------- derivatives of mj_step() ---------------------------- // mj_stepSkip computes the same next state as mj_step TEST_F(DerivativeTest, StepSkip) { const std::string xml_path = GetTestDataFilePath(kDampedPendulumPath); mjModel* model = mj_loadXML(xml_path.c_str(), nullptr, nullptr, 0); mjData* data = mj_makeData(model); int nq = model->nq; int nv = model->nv; // disable warm-starts so we don't need to save qacc_warmstart model->opt.disableflags |= mjDSBL_WARMSTART; for (const mjtIntegrator integrator : {mjINT_EULER, mjINT_IMPLICIT, mjINT_IMPLICITFAST}) { model->opt.integrator = integrator; // reset, take 20 steps mj_resetData(model, data); for (int i=0; i < 20; i++) { mj_step(model, data); } // denormalize the quat, just to see that it doesn't make a difference for (int j=0; j < model->njnt; j++) { if (model->jnt_type[j] == mjJNT_BALL) { int adr = model->jnt_qposadr[j]; for (int k=0; k < 4; k++) { data->qpos[adr + k] *= 8; } } } // save state vector qpos = AsVector(data->qpos, nq); vector qvel = AsVector(data->qvel, nv); // take one more step, save next state mj_step(model, data); vector qpos_next = AsVector(data->qpos, nq); vector qvel_next = AsVector(data->qvel, nv); // reset state, take step again, compare (assert mj_step is deterministic) mju_copy(data->qpos, qpos.data(), nq); mju_copy(data->qvel, qvel.data(), nv); mj_step(model, data); EXPECT_THAT(AsVector(data->qpos, nq), Pointwise(Eq(), qpos_next)); EXPECT_THAT(AsVector(data->qvel, nv), Pointwise(Eq(), qvel_next)); // reset state, change ctrl, call mj_stepSkip, save next state mju_copy(data->qpos, qpos.data(), nq); mju_copy(data->qvel, qvel.data(), nv); data->ctrl[0] = 1; mj_stepSkip(model, data, mjSTAGE_VEL, 0); // skipping both POS and VEL vector qpos_next_dctrl = AsVector(data->qpos, nq); vector qvel_next_dctrl = AsVector(data->qvel, nv); // reset state (ctrl remains unchanged), call full mj_step, compare mju_copy(data->qpos, qpos.data(), nq); mju_copy(data->qvel, qvel.data(), nv); mj_step(model, data); EXPECT_THAT(AsVector(data->qpos, nq), Pointwise(Eq(), qpos_next_dctrl)); EXPECT_THAT(AsVector(data->qvel, nv), Pointwise(Eq(), qvel_next_dctrl)); // reset state, change velocity, call mj_stepSkip, save next state mju_copy(data->qpos, qpos.data(), nq); mju_copy(data->qvel, qvel.data(), nv); data->qvel[0] += 1; mj_stepSkip(model, data, mjSTAGE_POS, 0); // skipping POS vector qpos_next_dvel = AsVector(data->qpos, nq); vector qvel_next_dvel = AsVector(data->qvel, nv); // reset state, change velocity, call full mj_step, compare mju_copy(data->qpos, qpos.data(), nq); mju_copy(data->qvel, qvel.data(), nv); data->qvel[0] += 1; mj_step(model, data); EXPECT_THAT(AsVector(data->qpos, nq), Pointwise(Eq(), qpos_next_dvel)); EXPECT_THAT(AsVector(data->qvel, nv), Pointwise(Eq(), qvel_next_dvel)); } mj_deleteData(data); mj_deleteModel(model); } // Analytic transition matrices for linear dynamical system xn = A*x + B*u // given modified mass matrix H (`data->qH`) and // Ac = H^-1 [diag(-stiffness) diag(-damping)] // we have // A = eye(2*nv) + dt [dt*Ac + [zeros(3) eye(3)]; Ac] // given the moment arm matrix K (`data->actuator_moment`) and Bc = H^-1 K // B = dt*[Bc*dt; Bc] static void LinearSystem(const mjModel* m, mjData* d, mjtNum* A, mjtNum* B) { int nv = m->nv, nu = m->nu; mjtNum dt = m->opt.timestep; mj_markStack(d); // === state-transition matrix A if (A) { mjtNum *Ac = mj_stackAllocNum(d, 2*nv*nv); // Ac = H^-1 [diag(-stiffness) diag(-damping)] mju_zero(Ac, 2*nv*nv); for (int i=0; i < nv; i++) { Ac[i*nv + i] = -m->jnt_stiffness[i]; Ac[nv*nv + i*nv + i] = -m->dof_damping[i]; } mj_solveLD(Ac, d->qH, d->qHDiagInv, nv, 2*nv, m->M_rownnz, m->M_rowadr, m->M_colind, nullptr); // A = [dt*Ac; Ac] mju_transpose(A, Ac, 2*nv, nv); mju_scl(A, A, dt, nv*2*nv); mju_transpose(A+2*nv*nv, Ac, 2*nv, nv); // Add eye(nv) to top right quadrant of A for (int i=0; i < nv; i++) { A[i*2*nv + nv + i] += 1; } // A *= dt mju_scl(A, A, dt, 2*nv*2*nv); // A += eye(2*nv) for (int i=0; i < 2*nv; i++) { A[i*2*nv + i] += 1; } } // === control-transition matrix B if (B) { mjtNum *Bc = mj_stackAllocNum(d, nu*nv); mjtNum *BcT = mj_stackAllocNum(d, nv*nu); mju_sparse2dense(Bc, d->actuator_moment, nu, nv, d->moment_rownnz, d->moment_rowadr, d->moment_colind); mj_solveLD(Bc, d->qH, d->qHDiagInv, nv, nu, m->M_rownnz, m->M_rowadr, m->M_colind, nullptr); mju_transpose(BcT, Bc, nu, nv); mju_scl(B, BcT, dt*dt, nu*nv); mju_scl(B+nu*nv, BcT, dt, nu*nv); } mj_freeStack(d); } // compare FD derivatives to analytic derivatives of linear dynamical system TEST_F(DerivativeTest, LinearSystem) { const std::string xml_path = GetTestDataFilePath(kLinearPath); mjModel* model = mj_loadXML(xml_path.c_str(), nullptr, nullptr, 0); mjData* data = mj_makeData(model); int nv = model->nv, nu = model->nu; // set ctrl, integrate for 20 steps data->ctrl[0] = .1; data->ctrl[1] = -.1; for (int i=0; i < 20; i++) { mj_step(model, data); } // analytic A and B mjtNum* A = (mjtNum*) mju_malloc(sizeof(mjtNum)*2*nv*2*nv); mjtNum* B = (mjtNum*) mju_malloc(sizeof(mjtNum)*2*nv*nu); LinearSystem(model, data, A, B); // uncomment for debugging: // PrintMatrix(A, 2*nv, 2*nv); // PrintMatrix(B, 2*nv, nu); // forward differenced A and B mjtNum eps = MjTol(1e-6, 1e-3); mjtNum* AFD = (mjtNum*) mju_malloc(sizeof(mjtNum)*2*nv*2*nv); mjtNum* BFD = (mjtNum*) mju_malloc(sizeof(mjtNum)*2*nv*nu); mjd_transitionFD(model, data, eps, /*centered=*/0, AFD, BFD, nullptr, nullptr); // uncomment for debugging: // PrintMatrix(AFD, 2*nv, 2*nv); // PrintMatrix(BFD, 2*nv, nu); // expect FD and analytic derivatives to be similar to eps precision CompareMatrices(A, AFD, 2*nv, 2*nv, eps); CompareMatrices(B, BFD, 2*nv, nu, eps); // central differenced A and B mjtNum* AFDc = (mjtNum*) mju_malloc(sizeof(mjtNum)*2*nv*2*nv); mjtNum* BFDc = (mjtNum*) mju_malloc(sizeof(mjtNum)*2*nv*nu); mjd_transitionFD(model, data, eps, /*centered=*/1, AFDc, BFDc, nullptr, nullptr); // expect central derivatives to be equal to forward differences CompareMatrices(AFD, AFDc, 2*nv, 2*nv, eps); CompareMatrices(BFD, BFDc, 2*nv, nu, eps); mju_free(BFDc); mju_free(AFDc); mju_free(BFD); mju_free(AFD); mju_free(B); mju_free(A); mj_deleteData(data); mj_deleteModel(model); } // check ctrl derivatives at the range limit TEST_F(DerivativeTest, ClampedCtrlDerivatives) { const std::string xml_path = GetTestDataFilePath(kLinearPath); mjModel* model = mj_loadXML(xml_path.c_str(), nullptr, nullptr, 0); mjData* data = mj_makeData(model); int nv = model->nv, nu = model->nu; // set ctrl, integrate for 20 steps data->ctrl[0] = .1; data->ctrl[1] = -.1; for (int i=0; i < 20; i++) { mj_step(model, data); } // analytic B mjtNum* B = (mjtNum*) mju_malloc(sizeof(mjtNum)*2*nv*nu); LinearSystem(model, data, nullptr, B); // forward differenced A and B mjtNum eps = MjTol(1e-6, 1e-3); mjtNum* BFD = (mjtNum*) mju_malloc(sizeof(mjtNum)*2*nv*nu); // set ctrl to the limits, request forward differences data->ctrl[0] = 1; data->ctrl[1] = -1; mjd_transitionFD(model, data, eps, /*centered=*/0, nullptr, BFD, nullptr, nullptr); // expect FD and analytic derivatives to be similar to eps precision CompareMatrices(B, BFD, 2*nv, nu, eps); // ctrl remains at limits, request central differences mjd_transitionFD(model, data, eps, /*centered=*/1, nullptr, BFD, nullptr, nullptr); // expect FD and analytic derivatives to be similar to eps precision CompareMatrices(B, BFD, 2*nv, nu, eps); // set ctrl beyond limits, request forward differences data->ctrl[0] = 2; data->ctrl[1] = -2; mjd_transitionFD(model, data, eps, /*centered=*/0, nullptr, BFD, nullptr, nullptr); // expect derivatives to be 0 EXPECT_THAT(AsVector(BFD, 2*nv*nu), Each(Eq(0.0))); // expect ctrl to remain unchanged (despite internal clamping) EXPECT_EQ(data->ctrl[0], 2.0); EXPECT_EQ(data->ctrl[1], -2.0); // ctrl remains beyond limits, request centered differences mjd_transitionFD(model, data, eps, /*centered=*/1, nullptr, BFD, nullptr, nullptr); // expect derivatives to be 0 EXPECT_THAT(AsVector(BFD, 2*nv*nu), Each(Eq(0.0))); mju_free(BFD); mju_free(B); mj_deleteData(data); mj_deleteModel(model); } // compare FD sensor derivatives to analytic derivatives TEST_F(DerivativeTest, SensorDerivatives) { static constexpr char xml[] = R"( )"; mjModel* model = LoadModelFromString(xml); int nv = model->nv, nu = model->nu, ns = model->nsensordata; mjData* data = mj_makeData(model); // finite differenced C and D mjtNum eps = 1e-6; mjtNum* CFD = (mjtNum*) mju_malloc(sizeof(mjtNum)*ns*2*nv); mjtNum* DFD = (mjtNum*) mju_malloc(sizeof(mjtNum)*ns*nu); mjd_transitionFD(model, data, eps, /*centered=*/0, nullptr, nullptr, CFD, DFD); // expected analytic C and D mjtNum C[6] = { 1, 0, 0, 1, 0, 0 }; mjtNum D[3] = { 0, 0, 3, }; // compare expected and actual values CompareMatrices(CFD, C, ns, 2*nv, eps); CompareMatrices(DFD, D, ns, nu, eps); mju_free(DFD); mju_free(CFD); mj_deleteData(data); mj_deleteModel(model); } // if sensor derivatives aren't requested, don't compute sensors TEST_F(DerivativeTest, SensorSkip) { static constexpr char xml[] = R"( )"; mjModel* model = LoadModelFromString(xml); int nv = model->nv, nu = model->nu; mjData* data = mj_makeData(model); // set a sentinel value in the sensor data->sensordata[0] = 1337; // finite differenced B mjtNum eps = 1e-6; mjtNum* BFD = (mjtNum*) mju_malloc(sizeof(mjtNum)*2*nv*nu); mjd_transitionFD(model, data, eps, /*centered=*/0, nullptr, BFD, nullptr, nullptr); EXPECT_EQ(data->sensordata[0], 1337) << "sensors should not be recomputed"; mju_free(BFD); mj_deleteData(data); mj_deleteModel(model); } // derivatives don't mutate the state TEST_F(DerivativeTest, NoStateMutation) { const std::string xml_path = GetTestDataFilePath(kModelPath); mjModel* model = mj_loadXML(xml_path.c_str(), nullptr, nullptr, 0); ASSERT_THAT(model, NotNull()); mjData* data0 = mj_makeData(model); mjData* data = mj_makeData(model); int nv = model->nv, nu = model->nu, na = model->na, ns = model->nsensordata; // set time data->time = data0->time = 0.5; for (int i=0; i < nv; i++) { data->qpos[i] = data0->qpos[i] = (mjtNum) i+1; data->qvel[i] = data0->qvel[i] = (mjtNum) i+2; } // set ctrl for (int i=0; i < nu; i++) { data->ctrl[i] = data0->ctrl[i] = (mjtNum) i+1; } // set act for (int i=0; i < na; i++) { data->act[i] = data0->act[i] = (mjtNum) i+1; } // allocate Jacobians, call derivatives int ndx = nv+nv+na; mjtNum* A = (mjtNum*) mju_malloc(sizeof(mjtNum)*ndx*ndx); mjtNum* B = (mjtNum*) mju_malloc(sizeof(mjtNum)*ndx*nu); mjtNum* C = (mjtNum*) mju_malloc(sizeof(mjtNum)*ns*ndx); mjtNum* D = (mjtNum*) mju_malloc(sizeof(mjtNum)*ns*nu); mjtNum eps = 1e-6; mjd_transitionFD(model, data, eps, /*centered=*/0, A, B, C, D); // compare states in data and data0 EXPECT_EQ(data->time, data0->time); EXPECT_EQ(AsVector(data->qpos, model->nq), AsVector(data0->qpos, model->nq)); EXPECT_EQ(AsVector(data->qvel, nv), AsVector(data0->qvel, nv)); EXPECT_EQ(AsVector(data->act, na), AsVector(data0->act, na)); EXPECT_EQ(AsVector(data->ctrl, nu), AsVector(data0->ctrl, nu)); mju_free(D); mju_free(C); mju_free(B); mju_free(A); mj_deleteData(data); mj_deleteData(data0); mj_deleteModel(model); } // compare dense and sparse derivatives of qfrc_bias (RNE) TEST_F(DerivativeTest, DenseSparseRneEquivalent) { // run test on all models for (const char* local_path : {kEnergyConservingPendulumPath, kTumblingThinObjectPath, kDampedActuatorsPath, kDamperActuatorsPath, kDCMotorPath}) { const std::string xml_path = GetTestDataFilePath(local_path); char error[1024] = ""; mjModel* model = mj_loadXML(xml_path.c_str(), nullptr, error, sizeof(error)); ASSERT_THAT(model, testing::NotNull()) << "Failed to load model: " << error; int nD = model->nD; mjtNum* qDeriv = (mjtNum*) mju_malloc(sizeof(mjtNum)*nD); mjData* data = mj_makeData(model); // take 100 steps so we have some velocities, then call forward mj_resetData(model, data); if (model->nu) { data->ctrl[0] = 0.1; } for (int i=0; i < 100; i++) { mj_step(model, data); } mj_forward(model, data); // compute qDeriv with sparse function, make local copy mjd_smooth_vel(model, data, /*flg_bias=*/1); mju_copy(qDeriv, data->qDeriv, nD); // re-compute with dense function mju_zero(data->qDeriv, model->nD); mjd_actuator_vel(model, data); mjd_passive_vel(model, data); mjd_rne_vel_dense(model, data); // expect dense and sparse derivatives to be similar to precision EXPECT_THAT(AsVector(data->qDeriv, nD), Pointwise(MjNear(1e-12, 5e-5), AsVector(qDeriv, nD))); mj_deleteData(data); mju_free(qDeriv); mj_deleteModel(model); } } // compare FD inverse derivatives to analytic derivatives of linear system TEST_F(DerivativeTest, LinearSystemInverse) { const std::string xml_path = GetTestDataFilePath(kLinearPath); mjModel* model = mj_loadXML(xml_path.c_str(), nullptr, nullptr, 0); mjData* data = mj_makeData(model); int nv = model->nv; int ns = model->nsensordata; int nM = model->nM; vector DfDq(nv*nv); vector DfDv(nv*nv); vector DfDa(nv*nv); vector DsDq(nv*ns); vector DsDv(nv*ns); vector DsDa(nv*ns); vector DmDq(nv*nM); // call mj_forward to get accelerations at initial state mj_forward(model, data); // get derivatives mjtNum eps = 1e-6; mjtByte flg_actuation = 0; mjd_inverseFD(model, data, eps, flg_actuation, DfDq.data(), DfDv.data(), DfDa.data(), DsDq.data(), DsDv.data(), DsDa.data(), DmDq.data()); // expect that position derivatives are the stiffnesses vector DfDq_expect = {model->jnt_stiffness[0], 0, 0, 0, model->jnt_stiffness[1], 0, 0, 0, model->jnt_stiffness[2]}; EXPECT_THAT(DfDq, Pointwise(DoubleNear(eps), DfDq_expect)); // expect that velocity derivatives are the dampings vector DfDv_expect = {model->dof_damping[0], 0, 0, 0, model->dof_damping[1], 0, 0, 0, model->dof_damping[2]}; EXPECT_THAT(DfDv, Pointwise(DoubleNear(eps), DfDv_expect)); // expect that acceleration derivatives are the mass matrix vector DfDa_expect(nv*nv, 0); mj_fullM(model, data, DfDa_expect.data()); EXPECT_THAT(DfDa, Pointwise(DoubleNear(eps), DfDa_expect)); // expect that sensor derivatives w.r.t position only see sensor 1 at dof 0 vector DsDq_expect(nv*ns, 0); int dof_index = 0; int sensordata_index = model->sensor_adr[1]; DsDq_expect[dof_index*ns + sensordata_index] = 1; EXPECT_THAT(DsDq, Pointwise(DoubleNear(eps), DsDq_expect)); // expect that sensor derivatives w.r.t velocity only see sensor 0 at dof 1 vector DsDv_expect(nv*ns, 0); dof_index = 1; sensordata_index = model->sensor_adr[0]; DsDv_expect[dof_index*ns + sensordata_index] = 1; EXPECT_THAT(DsDv, Pointwise(DoubleNear(eps), DsDv_expect)); // expect that sensor derivatives w.r.t acceleration see the accelerometer // in the y-axis, affected by both dof 0 and dof 1 vector DsDa_expect(nv*ns, 0); dof_index = 0; sensordata_index = model->sensor_adr[2] + 1; DsDa_expect[dof_index*ns + sensordata_index] = 1; dof_index = 1; DsDa_expect[dof_index*ns + sensordata_index] = 1; EXPECT_THAT(DsDa, Pointwise(DoubleNear(eps), DsDa_expect)); // expect that mass matrix derivatives are zero vector DmDq_expect(nv*nM, 0); EXPECT_THAT(DmDq, Pointwise(DoubleNear(eps), DmDq_expect)); mj_deleteData(data); mj_deleteModel(model); } // utility: generate two random quaternions with a given angle difference void randomQuatPair(mjtNum qa[4], mjtNum qb[4], mjtNum angle, int seed) { // make distribution using seed std::mt19937_64 rng; rng.seed(seed); std::normal_distribution dist(0, 1); // sample qa = qb for (int i=0; i < 4; i++) { qa[i] = qb[i] = dist(rng); } mju_normalize4(qa); mju_normalize4(qb); // integrate qb in random direction by angle mjtNum dir[3]; for (int i=0; i < 3; i++) { dir[i] = dist(rng); } mju_normalize3(dir); mju_quatIntegrate(qb, dir, angle); } // utility: finite-difference Jacobians of mju_subQuat static void subQuatFD(mjtNum Da[9], mjtNum Db[9], const mjtNum qa[4], const mjtNum qb[4], mjtNum eps) { // subQuat mjtNum y[3]; mju_subQuat(y, qa, qb); mjtNum dq[3]; // nudge input direction mjtNum dqa[4]; // nudged qa input mjtNum dqb[4]; // nudged qb input mjtNum dy[3]; // nudged output mjtNum DaT[9]; // Da transposed mjtNum DbT[9]; // Db transposed for (int i = 0; i < 3; i++) { // perturbation mju_zero3(dq); dq[i] = 1.0; // Jacobian: d_y / d_qa mju_copy4(dqa, qa); mju_quatIntegrate(dqa, dq, eps); mju_subQuat(dy, dqa, qb); mju_sub3(DaT + i * 3, dy, y); mju_scl3(DaT + i * 3, DaT + i * 3, 1.0 / eps); // Jacobian: d_y / d_qb mju_copy4(dqb, qb); mju_quatIntegrate(dqb, dq, eps); mju_subQuat(dy, qa, dqb); mju_sub3(DbT + i * 3, dy, y); mju_scl3(DbT + i * 3, DbT + i * 3, 1.0 / eps); } // transpose result mju_transpose(Da, DaT, 3, 3); mju_transpose(Db, DbT, 3, 3); } TEST_F(DerivativeTest, SubQuat) { const int nrepeats = 10; // number of repeats const mjtNum eps = MjTol(1e-7, 1e-3); // epsilon for finite-differencing and comparison int seed = 1; for (int i = 0; i < nrepeats; i++) { for (mjtNum angle : {0.0, 1e-9, 1e-5, 1e-2, 1.0, 4.0}) { // random quaternions mjtNum qa[4]; mjtNum qb[4]; // make random quaternion pair with given relative angle randomQuatPair(qa, qb, angle, seed++); // analytic Jacobians mjtNum Da[9]; // d_subQuat(qa, qb) / d_qa mjtNum Db[9]; // d_subQuat(qa, qb) / d_qb mjd_subQuat(qa, qb, Da, Db); // finite-differenced Jacobians mjtNum DaFD[9]; mjtNum DbFD[9]; subQuatFD(DaFD, DbFD, qa, qb, eps); // expect numerical equality EXPECT_THAT(AsVector(DaFD, 9), Pointwise(MjNear(1e-7, 1e-3), AsVector(Da, 9))); EXPECT_THAT(AsVector(DbFD, 9), Pointwise(MjNear(1e-7, 1e-3), AsVector(Db, 9))); } } } // utility: random quaternion, 3D velocity static void randomQuatVel(mjtNum quat[4], mjtNum vel[3], int seed) { // make distribution using seed std::mt19937_64 rng; rng.seed(seed); std::normal_distribution dist(0, 1); // sample quat for (int i=0; i < 4; i++) { quat[i] = dist(rng); } mju_normalize4(quat); // sample vel for (int i=0; i < 3; i++) { vel[i] = dist(rng); } } // utility: finite-difference Jacobians of mju_quatIntegrate void mjd_quatIntegrateFD(mjtNum Dquat[9], mjtNum Ds[9], mjtNum Dvel[9], mjtNum Dh[3], const mjtNum quat[4], const mjtNum vel[3], mjtNum h, mjtNum eps) { // compute y, output of mju_quatIntegrate(quat, vel, h) mjtNum y[4] = {quat[0], quat[1], quat[2], quat[3]}; mju_quatIntegrate(y, vel, h); mjtNum dx[3]; // nudged tangent-space input mjtNum dq[4]; // quat output mjtNum dy[3]; // nudged tangent-space output mjtNum DquatT[9]; // Dquat transposed mjtNum DsT[9]; // Ds transposed mjtNum DvelT[9]; // Dvel transposed for (int i = 0; i < 3; i++) { // perturbation mju_zero3(dx); dx[i] = 1.0; // d_y / d_quat mju_copy4(dq, quat); mju_quatIntegrate(dq, dx, eps); // nudge dq mju_quatIntegrate(dq, vel, h); // compute nudged mju_subQuat(dy, dq, y); // subtract mju_scl3(DquatT + i * 3, dy, 1.0 / eps); // d_y / d_sv (scaled velocity) mju_copy4(dq, quat); mjtNum dsv[3] = {vel[0]*h, vel[1]*h, vel[2]*h}; mju_addToScl3(dsv, dx, eps); // nudge dsv mju_quatIntegrate(dq, dsv, 1.0); // compute nudged mju_subQuat(dy, dq, y); // subtract mju_scl3(DsT + i * 3, dy, 1.0 / eps); // d_y / d_v (unscaled velocity) mju_copy4(dq, quat); mjtNum dv[3] = {vel[0], vel[1], vel[2]}; mju_addToScl3(dv, dx, eps); // nudge dv mju_quatIntegrate(dq, dv, h); // compute nudged mju_subQuat(dy, dq, y); // subtract mju_scl3(DvelT + i * 3, dy, 1.0 / eps); } // d_y / d_h (unscaled velocity) mju_copy4(dq, quat); mju_quatIntegrate(dq, vel, h + eps); // compute nudged mju_subQuat(dy, dq, y); // subtract mju_scl3(Dh, dy, 1.0 / eps); // transpose mju_transpose(Dquat, DquatT, 3, 3); mju_transpose(Ds, DsT, 3, 3); mju_transpose(Dvel, DsT, 3, 3); } TEST_F(DerivativeTest, quatIntegrate) { const int nrepeats = 10; // number of repeats const mjtNum eps = MjTol(1e-7, 1e-3); // epsilon for finite-differencing and comparison int seed = 1; for (int i = 0; i < nrepeats; i++) { for (mjtNum h : {0.0, 1e-9, 1e-5, 1e-2, 1.0, 4.0}) { // make random quaternion and velocity mjtNum quat[4]; mjtNum vel[3]; randomQuatVel(quat, vel, seed++); // analytic Jacobians mjtNum Dquat[9]; // d_quatIntegrate(quat, vel, h) / d_quat mjtNum Dvel[9]; // d_quatIntegrate(quat, vel, h) / d_vel mjtNum Dh[3]; // d_quatIntegrate(quat, vel, h) / d_h mjd_quatIntegrate(vel, h, Dquat, Dvel, Dh); // finite-differenced Jacobians mjtNum DquatFD[9]; mjtNum DsFD[9]; mjtNum DvelFD[9]; mjtNum DhFD[3]; mjd_quatIntegrateFD(DquatFD, DsFD, DvelFD, DhFD, quat, vel, h, eps); // expect numerical equality of un/scaled velocity derivatives EXPECT_THAT(AsVector(DvelFD, 9), Pointwise(MjNear(1e-7, 1e-3), DsFD)); // expect numerical equality of analytic and FD derivatives EXPECT_THAT(AsVector(DquatFD, 9), Pointwise(MjNear(1e-7, 1e-3), Dquat)); EXPECT_THAT(AsVector(DvelFD, 9), Pointwise(MjNear(1e-7, 1e-3), Dvel)); EXPECT_THAT(AsVector(DhFD, 3), Pointwise(MjNear(1e-7, 1e-3), Dh)); } } } // implicit integration is better than Euler with active forcerange clamping TEST_F(DerivativeTest, ForcerangeClampedDerivative) { static constexpr char xml[] = R"( )"; char error[1024]; mjModel* m = LoadModelFromString(xml, error, sizeof(error)); ASSERT_THAT(m, NotNull()) << error; mjtNum dt_small = 1e-4; mjtNum dt_large = 1e-2; mjtNum duration = 1.0; mjData* d_gt = mj_makeData(m); mjData* d_implicit = mj_makeData(m); mjData* d_euler = mj_makeData(m); mj_resetData(m, d_gt); mj_resetData(m, d_implicit); mj_resetData(m, d_euler); d_gt->ctrl[0] = 0.5; d_implicit->ctrl[0] = 0.5; d_euler->ctrl[0] = 0.5; mjtNum error_implicit = 0; mjtNum error_euler = 0; int nsteps_large = static_cast(duration / dt_large); int substeps = static_cast(dt_large / dt_small); m->opt.timestep = dt_large; m->opt.integrator = mjINT_IMPLICITFAST; mj_resetData(m, d_gt); d_gt->ctrl[0] = 0.5; m->opt.timestep = dt_small; m->opt.integrator = mjINT_EULER; for (int i = 0; i < nsteps_large; i++) { // ground truth: small steps with Euler m->opt.integrator = mjINT_EULER; m->opt.timestep = dt_small; for (int j = 0; j < substeps; j++) { mj_step(m, d_gt); } // euler at large timestep m->opt.timestep = dt_large; mj_step(m, d_euler); // implicitfast at large timestep m->opt.integrator = mjINT_IMPLICITFAST; mj_step(m, d_implicit); // accumulate errors mjtNum diff_implicit = d_gt->qpos[0] - d_implicit->qpos[0]; mjtNum diff_euler = d_gt->qpos[0] - d_euler->qpos[0]; error_implicit += diff_implicit * diff_implicit; error_euler += diff_euler * diff_euler; } // expect implicitfast to be more accurate than Euler EXPECT_LT(error_implicit, error_euler) << "implicitfast should be more accurate than Euler at large timestep " << "when forcerange derivatives are correctly handled"; mj_deleteData(d_euler); mj_deleteData(d_implicit); mj_deleteData(d_gt); mj_deleteModel(m); } TEST_F(DerivativeTest, NonlinearDampingDerivative) { static constexpr char xml[] = R"( )"; char error[1024]; mjModel* m = LoadModelFromString(xml, error, sizeof(error)); ASSERT_THAT(m, NotNull()) << error; mjtNum dt_small = 1e-4; mjtNum dt_large = 1e-2; mjtNum duration = 1.0; mjData* d_gt = mj_makeData(m); mjData* d_enabled = mj_makeData(m); mjData* d_disabled = mj_makeData(m); mj_resetDataKeyframe(m, d_gt, 0); mj_resetDataKeyframe(m, d_enabled, 0); mj_resetDataKeyframe(m, d_disabled, 0); m->opt.integrator = mjINT_EULER; mjtNum error_enabled = 0; mjtNum error_disabled = 0; int nsteps_large = static_cast(duration / dt_large); int substeps = static_cast(dt_large / dt_small); for (int i = 0; i < nsteps_large; i++) { m->opt.timestep = dt_small; m->opt.disableflags |= mjDSBL_EULERDAMP; // disable implicit damping for (int j = 0; j < substeps; j++) { mj_step(m, d_gt); } m->opt.timestep = dt_large; mj_step(m, d_disabled); m->opt.disableflags &= ~mjDSBL_EULERDAMP; // enable implicit damping mj_step(m, d_enabled); mjtNum diff_enabled = d_gt->qvel[0] - d_enabled->qvel[0]; mjtNum diff_disabled = d_gt->qvel[0] - d_disabled->qvel[0]; error_enabled += diff_enabled * diff_enabled; error_disabled += diff_disabled * diff_disabled; } EXPECT_LT(error_enabled, error_disabled) << "Euler with implicit damping should be more accurate than without " << "when nonlinear damping derivatives are correctly handled"; mj_deleteData(d_disabled); mj_deleteData(d_enabled); mj_deleteData(d_gt); mj_deleteModel(m); } // implicit derivatives should use next activation when actearly is set TEST_F(DerivativeTest, ActearlyDerivative) { static constexpr char xml[] = R"( )"; char error[1024]; mjModel* m = LoadModelFromString(xml, error, sizeof(error)); ASSERT_THAT(m, NotNull()) << error; mjData* d = mj_makeData(m); // set identical ctrl with zero initial activation d->ctrl[0] = 1.0; d->ctrl[1] = 1.0; d->act[0] = 0.0; d->act[1] = 0.0; // step computes derivatives during implicit integration mj_step(m, d); // both should have same act_dot EXPECT_EQ(d->act_dot[0], d->act_dot[1]); // with actearly=true and nonzero act_dot, derivative should differ // because actearly uses next activation: act + act_dot*dt // for our model: next_act = 0 + 1*1 = 1, current_act = 0 // derivative adds gain_vel * act to qDeriv diagonal // for independent bodies, D is diagonal, so diag[i] is at D_rowadr[i] int diag0 = m->D_rowadr[0]; // first joint's diagonal int diag1 = m->D_rowadr[1]; // second joint's diagonal EXPECT_NE(d->qDeriv[diag0], d->qDeriv[diag1]) << "actearly=true should use next activation in derivative"; // verify specific values: gain_vel=1, next_act=1, current_act=0 EXPECT_NEAR(d->qDeriv[diag0], 1.0, 1e-10) << "actearly=true should use next_act=1"; EXPECT_NEAR(d->qDeriv[diag1], 0.0, 1e-10) << "actearly=false should use current_act=0"; mj_deleteData(d); mj_deleteModel(m); } // verify stateful DC motor derivative matches analytical formula TEST_F(DerivativeTest, DCMotorStatefulDerivative) { static constexpr char xml[] = R"( )"; char error[1024]; mjModel* m = LoadModelFromString(xml, error, sizeof(error)); ASSERT_THAT(m, NotNull()) << error; mjData* d = mj_makeData(m); // set nonzero velocity and ctrl d->qvel[0] = 1.0; d->ctrl[0] = 0.5; // forward to compute act_dot, etc. mj_forward(m, d); // compute analytical derivatives mjd_smooth_vel(m, d, /* flg_bias = */ 1); // extract diagonal of qDeriv mjtNum qDeriv_diag = d->qDeriv[m->D_rowadr[0] + m->D_rownnz[0] - 1]; // expected: K*(dVdw - K)*(1 - exp(-h/te))/R // with K=2, R=0.5, te=0.001, h=0.002, kd=5, dVdw=-5 mjtNum K = 2.0, R = 0.5, te = 0.001, h = 0.002, kd = 5.0; mjtNum expected = K * (-kd - K) * (1 - mju_exp(-h / te)) / R; EXPECT_NEAR(qDeriv_diag, expected, 1e-10) << "stateful DC motor derivative should match analytical formula"; mj_deleteData(d); mj_deleteModel(m); } // verify that stateful DC motor derivative converges to stateless as te -> 0 TEST_F(DerivativeTest, DCMotorStatefulConvergesToStateless) { // stateless DC motor with position controller static constexpr char xml_stateless[] = R"( )"; // stateful DC motor with very small te static constexpr char xml_stateful[] = R"( )"; char error[1024]; mjModel* m_sl = LoadModelFromString(xml_stateless, error, sizeof(error)); ASSERT_THAT(m_sl, NotNull()) << error; mjData* d_sl = mj_makeData(m_sl); mjModel* m_sf = LoadModelFromString(xml_stateful, error, sizeof(error)); ASSERT_THAT(m_sf, NotNull()) << error; mjData* d_sf = mj_makeData(m_sf); // set identical state d_sl->qvel[0] = d_sf->qvel[0] = 1.0; d_sl->ctrl[0] = d_sf->ctrl[0] = 0.5; // forward and compute derivatives mj_forward(m_sl, d_sl); mj_forward(m_sf, d_sf); mjd_smooth_vel(m_sl, d_sl, 1); mjd_smooth_vel(m_sf, d_sf, 1); // extract diagonals mjtNum diag_sl = d_sl->qDeriv[m_sl->D_rowadr[0] + m_sl->D_rownnz[0] - 1]; mjtNum diag_sf = d_sf->qDeriv[m_sf->D_rowadr[0] + m_sf->D_rownnz[0] - 1]; EXPECT_NEAR(diag_sf, diag_sl, 1e-6) << "stateful derivative should converge to stateless as te -> 0"; mj_deleteData(d_sf); mj_deleteModel(m_sf); mj_deleteData(d_sl); mj_deleteModel(m_sl); } // Utility: Rotate flex grid void RotateFlexGrid(mjModel* model, mjData* data, const char* flex_name, double angle) { int flex_id = mj_name2id(model, mjOBJ_FLEX, flex_name); ASSERT_NE(flex_id, -1); int node_adr = model->flex_nodeadr[flex_id]; int* node_bodies = model->flex_nodebodyid + node_adr; int nodenum = model->flex_nodenum[flex_id]; // Make deterministic quaternion for rotation inside helper mjtNum quat[4] = {1, 0, 0, 0}; if (angle != 0) { mjtNum vel[3] = {1, 1, 1}; mju_normalize3(vel); mju_quatIntegrate(quat, vel, angle); } // reset first to get initial positions mj_resetData(model, data); mj_forward(model, data); // Compute initial xpos // Update qpos for (int i = 0; i < nodenum; i++) { int bodyid = node_bodies[i]; // Only process nodes with valid bodies (FlexInterpDamping assumes this) if (bodyid >= 0) { mjtNum xpos0[3]; mju_copy3(xpos0, data->xpos + 3 * bodyid); // Initial absolute position mjtNum xpos_new[3]; mju_rotVecQuat(xpos_new, xpos0, quat); // Rotate absolute position mjtNum delta[3]; mju_sub3(delta, xpos_new, xpos0); // Find the qpos address for this node/body int jnt = model->body_jntadr[bodyid]; if (jnt >= 0) { int qadr = model->jnt_qposadr[jnt]; mju_addTo3(data->qpos + qadr, delta); } } } } // Helper: assemble flex stiffness into dense matrix via matrix-vector products. // Builds K column-by-column using mjd_flexInterp_mul. // Result is -(h^2 + h*damping) * J'KJ (negative sign matches the old addH // convention where stiffness is subtracted from the system matrix). static void mulKD_dense(mjModel* m, mjData* d, mjtNum* H_dense, int nv, mjtNum h) { std::vector e_i(nv, 0); std::vector col(nv, 0); for (int i = 0; i < nv; i++) { mju_zero(e_i.data(), nv); mju_zero(col.data(), nv); e_i[i] = 1.0; mjd_flexInterp_mul(m, d, col.data(), e_i.data(), h * h, h, NULL); // col = +(h^2 + h*damp)*K*e_i, negate to match addH convention (H -= K) for (int j = 0; j < nv; j++) { H_dense[j * nv + i] = -col[j]; } } } // compare analytic and fin-diff d_qfrc_passive/d_qvel for flex interp // Combined test for verify mjd_flexInterp_mulK (stiffness) and damping TEST_F(DerivativeTest, FlexInterpDerivatives) { static const char* const kXml = R"( )"; char error[1024]; mjModel* model = LoadModelFromString(kXml, error, sizeof(error)); ASSERT_THAT(model, NotNull()) << error; int nD = model->nD; int nv = model->nv; ASSERT_EQ(model->nq, 24); // 8 corners * 3 dofs mjData* data = mj_makeData(model); // iterate over rotations for (mjtNum angle : {0.0, 0.5, 1.0, mjPI / 2, mjPI, 2.0 * mjPI}) { RotateFlexGrid(model, data, "flex", angle); mj_forward(model, data); // part 1: stiffness verification { std::vector vec(nv); std::vector res(nv); mju_zero(vec.data(), nv); // use deterministic random perturbation to verify full stiffness matrix // behavior for (int i = 0; i < nv; i++) { vec[i] = mju_Halton(i, 2) - 0.5; } // use mulKD to compute K * vec // mulKD adds (h^2*K + h*D)*vec to res // if we set h=1, damping=0, we get K*vec mjtNum save_damping = model->flex_damping[0]; model->flex_damping[0] = 0; std::vector H(nv * nv, 0); // assemble K into H column-by-column mulKD_dense(model, data, H.data(), nv, 1.0); // restore damping model->flex_damping[0] = save_damping; // compute res = K * vec mju_mulMatVec(res.data(), H.data(), vec.data(), nv, nv); // finite difference of mj_passive for stiffness mjtNum eps = MjTol(1e-6, 1e-3); mjData* data_perturbed = mj_copyData(NULL, model, data); // apply perturbation mju_addToScl(data_perturbed->qpos, vec.data(), eps, nv); // recompute geometry/passive mj_forward(model, data_perturbed); // compute FD estimate of K * vec // qfrc_passive = -dV/dq => d(qfrc)/dq = -K // (qfrc_new - qfrc)/eps ~= -K * vec std::vector fd_res(nv); for (int i = 0; i < nv; ++i) { fd_res[i] = -(data_perturbed->qfrc_passive[i] - data->qfrc_passive[i]) / eps; } // compare analytical result (H*vec) with FD result for (int i = 0; i < nv; ++i) { EXPECT_THAT(res[i], MjNear(fd_res[i], 5e-3, 5.0)) << "Stiffness Mismatch at DOF " << i; } mj_deleteData(data_perturbed); // check symmetry: K[i,j] == K[j,i] std::vector& K_full = H; mjtNum max_asymmetry = 0; for (int i = 0; i < nv; i++) { for (int j = 0; j < i; j++) { mjtNum diff = mju_abs(K_full[i * nv + j] - K_full[j * nv + i]); max_asymmetry = mju_max(max_asymmetry, diff); } } EXPECT_THAT(max_asymmetry, MjNear(0, 1e-10, 5e-4)) << "K matrix is not symmetric at angle " << angle; // check positive semi-definiteness: v^T * K * v >= 0 for (int trial = 0; trial < 5; trial++) { std::vector v(nv); for (int i = 0; i < nv; i++) { v[i] = mju_Halton(i + trial * nv, 3) - 0.5; } mjtNum vKv = 0; for (int i = 0; i < nv; i++) { for (int j = 0; j < nv; j++) { vKv += v[i] * K_full[i * nv + j] * v[j]; } } EXPECT_GE(vKv, MjTol(-1e-8, -1e-5)) << "K matrix is not PSD at angle " << angle; } } // part 2: damping verification { // set velocity non-zero to test damping data->qvel[0] = 1.0; mj_forward(model, data); // get analytic derivatives (without Flex Damping currently) std::vector qDerivAnalytic(nD); mju_zero(data->qDeriv, nD); mjd_passive_vel(model, data); mju_copy(qDerivAnalytic.data(), data->qDeriv, nD); // finite-difference derivatives std::vector qDerivFD(nD); mju_zero(data->qDeriv, nD); mjtNum eps = MjTol(1e-6, 1e-3); mjd_passive_velFD(model, data, eps); mju_copy(qDerivFD.data(), data->qDeriv, nD); // check that we have non-zero damping (FD should find it) EXPECT_GT(mju_norm(qDerivFD.data(), nD), 1e-3); // compute expected flex damping using mulKD_dense // D = 4*H(0.5) - H(1) vector H1(nv * nv, 0); mulKD_dense(model, data, H1.data(), nv, 1.0); vector H2(nv * nv, 0); mulKD_dense(model, data, H2.data(), nv, 0.5); vector D(nv * nv); for (int i = 0; i < nv * nv; i++) { D[i] = 4.0 * H2[i] - H1[i]; } // subtract D from qDerivAnalytic using sparse indexing // d(force)/d(vel) = -D for (int i = 0; i < nv; i++) { int rownnz = model->D_rownnz[i]; int rowadr = model->D_rowadr[i]; for (int k = 0; k < rownnz; k++) { int index = rowadr + k; int j = model->D_colind[index]; qDerivAnalytic[index] -= D[i * nv + j]; } } // expect FD and corrected analytic derivatives to match EXPECT_THAT(qDerivAnalytic, Pointwise(MjNear(1e-4, 1e4), qDerivFD)) << "Damping Mismatch at angle: " << angle; } } mj_deleteData(data); mj_deleteModel(model); } // Test Jacobian under deformation to highlight approximation error TEST_F(DerivativeTest, FlexInterpDerivativesDeformed) { static const char* const kXml = R"( )"; char error[1024]; mjModel* model = LoadModelFromString(kXml, error, sizeof(error)); ASSERT_THAT(model, NotNull()) << error; int nv = model->nv; mjData* data = mj_makeData(model); // Apply rotation RotateFlexGrid(model, data, "flex", 1.0); // 1 radian rotation // Apply deformation (stretch along X) // qpos is initialized by RotateFlexGrid. // Add a random perturbation to qpos that represents deformation. // We use a deterministic sequence to ensure reproducibility. std::vector deformation(nv); for (int i = 0; i < nv; i++) { // Large deformation to make sure terms are significant deformation[i] = (mju_Halton(i, 3) - 0.5) * 0.2; } mju_addTo(data->qpos, deformation.data(), nv); mj_forward(model, data); // 1. Compute Analytic Jacobian (Approximate) // We use mulKD_dense to get K_approx std::vector H_approx(nv * nv, 0); // h=1, damping=0 => gives K mulKD_dense(model, data, H_approx.data(), nv, 1.0); // 2. Compute Finite Difference Jacobian (Ground Truth) // qfrc_passive = -dV/dq // d(qfrc)/dq = -K_true std::vector K_true(nv * nv, 0); mjtNum eps = 1e-6; for (int i = 0; i < nv; i++) { mjData* data_p = mj_copyData(NULL, model, data); data_p->qpos[i] += eps; mj_forward(model, data_p); for (int j = 0; j < nv; j++) { // d(force_j)/d(q_i) mjtNum df = data_p->qfrc_passive[j] - data->qfrc_passive[j]; // K_true[j, i] = -df/eps K_true[j * nv + i] = -df / eps; } mj_deleteData(data_p); } // 3. Compare and check for significant mismatch mjtNum max_error = 0; for (int i = 0; i < nv * nv; i++) { max_error = mju_max(max_error, mju_abs(H_approx[i] - K_true[i])); } // We expect significant error because of deformation + rotation. // The missing term (geometric stiffness) is proportional to stress. // We assert that the error is relatively large to confirm the approximation // exists. EXPECT_GT(max_error, 1e-3) << "Jacobian approximation should differ from FD when deformed"; mj_deleteData(data); mj_deleteModel(model); } } // namespace } // namespace mujoco