#include "drake/solvers/mixed_integer_rotation_constraint.h" #include #include #include "drake/common/symbolic/expression.h" #include "drake/common/test_utilities/eigen_matrix_compare.h" #include "drake/math/gray_code.h" #include "drake/math/random_rotation.h" #include "drake/math/rotation_matrix.h" #include "drake/solvers/gurobi_solver.h" #include "drake/solvers/integer_optimization_util.h" #include "drake/solvers/mathematical_program.h" #include "drake/solvers/mosek_solver.h" #include "drake/solvers/rotation_constraint.h" #include "drake/solvers/solve.h" using Eigen::Matrix3d; using Eigen::Vector3d; using drake::math::RotationMatrixd; using drake::symbolic::Expression; using std::sqrt; namespace drake { namespace solvers { namespace { bool IsFeasibleCheck( const MathematicalProgram& prog, const std::shared_ptr& feasibility_constraint, const Eigen::Ref& R_sample) { Eigen::Map> R_sample_vec(R_sample.data()); feasibility_constraint->UpdateLowerBound(R_sample_vec); feasibility_constraint->UpdateUpperBound(R_sample_vec); return Solve(prog).is_success(); } class TestMixedIntegerRotationConstraint { public: DRAKE_NO_COPY_NO_MOVE_NO_ASSIGN(TestMixedIntegerRotationConstraint) TestMixedIntegerRotationConstraint( MixedIntegerRotationConstraintGenerator::Approach approach, int num_intervals_per_half_axis) : prog_(), R_(NewRotationMatrixVars(&prog_)), approach_(approach), num_intervals_per_half_axis_(num_intervals_per_half_axis), feasibility_constraint_{prog_ .AddLinearEqualityConstraint( Eigen::Matrix::Identity(), Eigen::Matrix::Zero(), {R_.col(0), R_.col(1), R_.col(2)}) .evaluator()} {} bool IsFeasible(const Eigen::Ref& R_to_check) { return IsFeasibleCheck(prog_, feasibility_constraint_, R_to_check); } bool IsFeasible(const RotationMatrixd& R_to_check) { return IsFeasible(R_to_check.matrix()); } void TestExactRotationMatrix() { // If R is exactly on SO(3), test whether it also satisfies our relaxation. // Test a few valid rotation matrices. RotationMatrixd R_test; // Identity matrix. EXPECT_TRUE(IsFeasible(R_test)); R_test = RotationMatrixd::MakeZRotation(M_PI_4) * R_test; EXPECT_TRUE(IsFeasible(R_test)); R_test = RotationMatrixd::MakeYRotation(M_PI_4) * R_test; EXPECT_TRUE(IsFeasible(R_test)); R_test = RotationMatrixd::MakeZRotation(M_PI_2); EXPECT_TRUE(IsFeasible(R_test)); R_test = RotationMatrixd::MakeZRotation(-M_PI_2); EXPECT_TRUE(IsFeasible(R_test)); R_test = RotationMatrixd::MakeYRotation(M_PI_2); EXPECT_TRUE(IsFeasible(R_test)); R_test = RotationMatrixd::MakeYRotation(-M_PI_2); EXPECT_TRUE(IsFeasible(R_test)); // This one caught a bug (in the loop finding the most conservative linear // constraint for a given region) during random testing. Matrix3d R_check; R_check << 0.17082017792981191, 0.65144498431260445, -0.73921573253413542, -0.82327804434149443, -0.31781600529013027, -0.47032568342231595, -0.54132589862048197, 0.68892119955432829, 0.48203096610835455; EXPECT_TRUE(IsFeasible(R_check)); std::mt19937 generator(41); for (int i = 0; i < 40; i++) { R_test = math::UniformlyRandomRotationMatrix(&generator); EXPECT_TRUE(IsFeasible(R_test)); } } void TestInexactRotationMatrix() { // If R is not exactly on SO(3), test whether it is infeasible for our SO(3) // relaxation. // Checks the dot product constraints. Eigen::Matrix3d R_test = Matrix3d::Constant(1.0 / sqrt(3.0)); // All rows and columns are // on the unit sphere. EXPECT_FALSE(IsFeasible(R_test)); // All in different octants, all unit length, but not orthogonal. // R.col(0).dot(R.col(1)) = 1/3; R_test(0, 1) *= -1.0; R_test(2, 1) *= -1.0; R_test(0, 2) *= -1.0; R_test(1, 2) *= -1.0; // Requires 2 intervals per half axis to catch. if (num_intervals_per_half_axis_ == 1 && approach_ == MixedIntegerRotationConstraintGenerator::Approach:: kBoxSphereIntersection) EXPECT_TRUE(IsFeasible(R_test)); else EXPECT_FALSE(IsFeasible(R_test)); // Checks the det(R)=-1 case. // (only ruled out by the cross-product constraint). R_test = Matrix3d::Identity(); R_test(2, 2) = -1; EXPECT_FALSE(IsFeasible(R_test)); R_test = RotationMatrixd::MakeZRotation(M_PI_4).matrix() * R_test; EXPECT_FALSE(IsFeasible(R_test)); R_test = RotationMatrixd::MakeYRotation(M_PI_4).matrix() * R_test; EXPECT_FALSE(IsFeasible(R_test)); // Checks a few cases just outside the L1 ball. If we use the formulation // that replaces the bilinear term with another variable in the McCormick // envelope, then it should always be infeasible. Otherwise should be // feasible for num_intervals_per_half_axis_=1, but infeasible for // num_intervals_per_half_axis_>1. R_test = RotationMatrixd::MakeYRotation(M_PI_4).matrix(); R_test(2, 0) -= 0.1; EXPECT_GT(R_test.col(0).lpNorm<1>(), 1.0); EXPECT_GT(R_test.row(2).lpNorm<1>(), 1.0); if (num_intervals_per_half_axis_ == 1 && approach_ == MixedIntegerRotationConstraintGenerator::Approach:: kBoxSphereIntersection) EXPECT_TRUE(IsFeasible(R_test)); else EXPECT_FALSE(IsFeasible(R_test)); } virtual ~TestMixedIntegerRotationConstraint() = default; protected: MathematicalProgram prog_; MatrixDecisionVariable<3, 3> R_; MixedIntegerRotationConstraintGenerator::Approach approach_; int num_intervals_per_half_axis_; std::shared_ptr feasibility_constraint_; }; class TestMixedIntegerRotationConstraintGenerator : public TestMixedIntegerRotationConstraint, public ::testing::TestWithParam< std::tuple> { public: DRAKE_NO_COPY_NO_MOVE_NO_ASSIGN(TestMixedIntegerRotationConstraintGenerator) TestMixedIntegerRotationConstraintGenerator() : TestMixedIntegerRotationConstraint(std::get<0>(GetParam()), std::get<1>(GetParam())), interval_binning_(std::get<2>(GetParam())), rotation_generator_(approach_, num_intervals_per_half_axis_, interval_binning_), ret(rotation_generator_.AddToProgram(R_, &prog_)) {} ~TestMixedIntegerRotationConstraintGenerator() = default; protected: IntervalBinning interval_binning_; MixedIntegerRotationConstraintGenerator rotation_generator_; MixedIntegerRotationConstraintGenerator::ReturnType ret; }; TEST_P(TestMixedIntegerRotationConstraintGenerator, TestConstructor) { EXPECT_TRUE(CompareMatrices( rotation_generator_.phi(), Eigen::VectorXd::LinSpaced(2 * num_intervals_per_half_axis_ + 1, -1, 1), 1E-12)); EXPECT_TRUE(CompareMatrices( rotation_generator_.phi_nonnegative(), Eigen::VectorXd::LinSpaced(num_intervals_per_half_axis_ + 1, 0, 1), 1E-12)); } TEST_P(TestMixedIntegerRotationConstraintGenerator, TestBinaryAssignment) { const RotationMatrixd R_test = RotationMatrixd::MakeZRotation(0.1); auto b_constraint = prog_.AddBoundingBoxConstraint(0, 0, ret.B_[0][0]); auto UpdateBConstraint = [&b_constraint](const Eigen::Ref& b_val) { b_constraint.evaluator()->UpdateLowerBound(b_val); b_constraint.evaluator()->UpdateUpperBound(b_val); }; switch (interval_binning_) { case IntervalBinning::kLinear: { switch (num_intervals_per_half_axis_) { case 1: UpdateBConstraint(Eigen::Vector2d(0, 1)); EXPECT_TRUE(IsFeasible(R_test)); UpdateBConstraint(Eigen::Vector2d(1, 0)); EXPECT_FALSE(IsFeasible(R_test)); break; case 2: UpdateBConstraint(Eigen::Vector4d(0, 0, 0, 1)); EXPECT_TRUE(IsFeasible(R_test)); UpdateBConstraint(Eigen::Vector4d(0, 0, 1, 0)); EXPECT_FALSE(IsFeasible(R_test)); break; default: GTEST_FAIL() << "Unsuppored num_intervals_per_half_axis_."; } break; } case IntervalBinning::kLogarithmic: { switch (num_intervals_per_half_axis_) { case 1: { UpdateBConstraint(Vector1d(1)); EXPECT_TRUE(IsFeasible(R_test)); UpdateBConstraint(Vector1d(0)); EXPECT_FALSE(IsFeasible(R_test)); break; } case 2: { UpdateBConstraint(Eigen::Vector2d(1, 0)); EXPECT_TRUE(IsFeasible(R_test)); UpdateBConstraint(Eigen::Vector2d(0, 1)); EXPECT_FALSE(IsFeasible(R_test)); break; } default: throw std::runtime_error("Unsupported num_intervals_per_half_axis_."); } break; } } } TEST_P(TestMixedIntegerRotationConstraintGenerator, ExactRotationMatrix) { TestExactRotationMatrix(); } TEST_P(TestMixedIntegerRotationConstraintGenerator, InexactRotationMatrix) { TestInexactRotationMatrix(); } INSTANTIATE_TEST_SUITE_P( RotationTest, TestMixedIntegerRotationConstraintGenerator, ::testing::Combine( ::testing::ValuesIn< std::vector>( {MixedIntegerRotationConstraintGenerator::Approach:: kBilinearMcCormick, MixedIntegerRotationConstraintGenerator::Approach:: kBoxSphereIntersection, MixedIntegerRotationConstraintGenerator::Approach::kBoth}), ::testing::ValuesIn>({1, 2}), ::testing::ValuesIn>( {IntervalBinning::kLinear, IntervalBinning::kLogarithmic}))); class TestRotationMatrixBoxSphereIntersection : public TestMixedIntegerRotationConstraint, public ::testing::TestWithParam { public: DRAKE_NO_COPY_NO_MOVE_NO_ASSIGN(TestRotationMatrixBoxSphereIntersection) TestRotationMatrixBoxSphereIntersection() : TestMixedIntegerRotationConstraint( MixedIntegerRotationConstraintGenerator::Approach:: kBoxSphereIntersection, GetParam()) { AddRotationMatrixBoxSphereIntersectionMilpConstraints( R_, num_intervals_per_half_axis_, &prog_); } ~TestRotationMatrixBoxSphereIntersection() override {} }; TEST_P(TestRotationMatrixBoxSphereIntersection, ExactRotationMatrix) { TestExactRotationMatrix(); } TEST_P(TestRotationMatrixBoxSphereIntersection, InexactRotationMatrix) { TestInexactRotationMatrix(); } INSTANTIATE_TEST_SUITE_P(RotationTest, TestRotationMatrixBoxSphereIntersection, ::testing::ValuesIn>({1, 2})); // Make sure that no two row or column vectors in R, which satisfies the // mixed-integer relaxation, can lie in either the same or the opposite orthant. class TestOrthant : public ::testing::TestWithParam< std::tuple, bool, MixedIntegerRotationConstraintGenerator::Approach>> { public: DRAKE_NO_COPY_NO_MOVE_NO_ASSIGN(TestOrthant) TestOrthant() : prog_(), R_(NewRotationMatrixVars(&prog_)) { const int num_bin = std::get<0>(GetParam()); const int orthant = std::get<1>(GetParam()); const bool is_row_vector = std::get<2>(GetParam()); const int idx0 = std::get<3>(GetParam()).first; const int idx1 = std::get<3>(GetParam()).second; const bool is_same_orthant = std::get<4>(GetParam()); const auto approach = std::get<5>(GetParam()); DRAKE_DEMAND(idx0 != idx1); DRAKE_DEMAND(idx0 >= 0); DRAKE_DEMAND(idx1 >= 0); DRAKE_DEMAND(idx0 <= 2); DRAKE_DEMAND(idx1 <= 2); MixedIntegerRotationConstraintGenerator rotation_generator( approach, num_bin, IntervalBinning::kLinear); rotation_generator.AddToProgram(R_, &prog_); MatrixDecisionVariable<3, 3> R_hat = R_; if (is_row_vector) { R_hat = R_.transpose(); } Eigen::Vector3d vec0_lb = Eigen::Vector3d::Constant(-std::numeric_limits::infinity()); Eigen::Vector3d vec0_ub = Eigen::Vector3d::Constant(std::numeric_limits::infinity()); // positive or negative x axis? if (orthant & (1 << 2)) { vec0_lb(0) = 1E-3; } else { vec0_ub(0) = -1E-3; } // positive or negative y axis? if (orthant & (1 << 1)) { vec0_lb(1) = 1E-3; } else { vec0_ub(1) = -1E-3; } // positive or negative z axis? if (orthant & (1 << 0)) { vec0_lb(2) = 1E-3; } else { vec0_ub(2) = -1E-3; } // If we want to verify vec1 and vec2 cannot be in the SAME orthant, // then set vec1_lb = vec0_lb, vec1_ub = vec0_ub; // otherwise if we want to verify vec1 and vec2 cannot be in the OPPOSITE // orthant, then set vec1_lb = -vec1_ub, vec1_ub = -vec0_lb. Eigen::Vector3d vec1_lb = vec0_lb; Eigen::Vector3d vec1_ub = vec0_ub; if (!is_same_orthant) { vec1_lb = -vec0_ub; vec1_ub = -vec0_lb; } prog_.AddBoundingBoxConstraint(vec0_lb, vec0_ub, R_hat.col(idx0)); prog_.AddBoundingBoxConstraint(vec1_lb, vec1_ub, R_hat.col(idx1)); } ~TestOrthant() override {} protected: MathematicalProgram prog_; MatrixDecisionVariable<3, 3> R_; }; TEST_P(TestOrthant, test) { GurobiSolver gurobi_solver; if (gurobi_solver.available()) { MathematicalProgramResult result; gurobi_solver.Solve(prog_, {}, {}, &result); SolutionResult sol_result = result.get_solution_result(); // Since no two row or column vectors in R can lie in either the same of the // opposite orthant, the program should be infeasible. EXPECT_TRUE(sol_result == SolutionResult::kInfeasibleOrUnbounded || sol_result == SolutionResult::kInfeasibleConstraints); } } std::array, 3> vector_indices() { std::array, 3> idx = {{{0, 1}, {0, 2}, {1, 2}}}; return idx; } INSTANTIATE_TEST_SUITE_P( RotationTest, TestOrthant, ::testing::Combine( ::testing::ValuesIn>( {1}), // # of intervals per half axis ::testing::ValuesIn>({0, 1, 2, 3, 4, 5, 6, 7}), // orthant index ::testing::ValuesIn>( {false, true}), // row vector or column vector ::testing::ValuesIn, 3>>( vector_indices()), // vector indices ::testing::ValuesIn>( {false, true}), // same or opposite orthant ::testing::ValuesIn< std::vector>( {MixedIntegerRotationConstraintGenerator::Approach:: kBoxSphereIntersection, MixedIntegerRotationConstraintGenerator::Approach:: kBoxSphereIntersection}))); // box-sphere intersection or // bilinear McCormick. } // namespace } // namespace solvers } // namespace drake int main(int argc, char** argv) { // Ensure that we have the MOSEK license for the entire duration of this test, // so that we do not have to release and re-acquire the license for every // test. auto mosek_license = drake::solvers::MosekSolver::AcquireLicense(); ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); }