|
| 1 | +/** |
| 2 | + * @file trust_box_floor_unit.cpp |
| 3 | + * @brief Unit tests for TrajOptQPProblem trust-box construction near and past variable bounds. |
| 4 | + * |
| 5 | + * Policy: clamp the iterate into its variable bounds [lb, ub], then strict-shrink the trust box |
| 6 | + * [x-Δ, x+Δ] against [lb, ub]. For x inside [lb, ub] this is a no-op and yields the standard |
| 7 | + * trust-region box with |p|_∞ ≤ Δ. For x drifted outside [lb, ub], the clamp keeps the box |
| 8 | + * non-empty (width Δ, flush against the violated bound) so the next QP step pulls the iterate |
| 9 | + * back through the bound. |
| 10 | + * |
| 11 | + * Replaces the earlier PR #472 "slide always" behavior, which kept full 2·Δ width even when the |
| 12 | + * iterate sat on a bound — at the cost of letting the QP step exceed Δ on the open side. |
| 13 | + */ |
| 14 | +#include <trajopt_common/macros.h> |
| 15 | +TRAJOPT_IGNORE_WARNINGS_PUSH |
| 16 | +#include <gtest/gtest.h> |
| 17 | +#include <Eigen/Core> |
| 18 | +TRAJOPT_IGNORE_WARNINGS_POP |
| 19 | + |
| 20 | +#include <trajopt_sqp/trajopt_qp_problem.h> |
| 21 | +#include <trajopt_ifopt/variable_sets/nodes_variables.h> |
| 22 | +#include <trajopt_ifopt/variable_sets/node.h> |
| 23 | + |
| 24 | +namespace |
| 25 | +{ |
| 26 | +constexpr double kLb = -2.5; |
| 27 | +constexpr double kUb = 2.5; |
| 28 | +constexpr double kBi = 0.0015; // trust half-width Δ |
| 29 | + |
| 30 | +std::shared_ptr<trajopt_sqp::TrajOptQPProblem> makeProblem(double x_init, double lb, double ub) |
| 31 | +{ |
| 32 | + auto node = std::make_unique<trajopt_ifopt::Node>("Joints"); |
| 33 | + const std::vector<std::string> names{ "j0" }; |
| 34 | + const Eigen::VectorXd values = (Eigen::VectorXd(1) << x_init).finished(); |
| 35 | + const std::vector<trajopt_ifopt::Bounds> bounds{ trajopt_ifopt::Bounds(lb, ub) }; |
| 36 | + node->addVar("position", names, values, bounds); |
| 37 | + |
| 38 | + std::vector<std::unique_ptr<trajopt_ifopt::Node>> nodes; |
| 39 | + nodes.push_back(std::move(node)); |
| 40 | + auto variables = std::make_shared<trajopt_ifopt::NodesVariables>("trajectory", std::move(nodes)); |
| 41 | + |
| 42 | + auto qp = std::make_shared<trajopt_sqp::TrajOptQPProblem>(variables); |
| 43 | + qp->setup(); |
| 44 | + return qp; |
| 45 | +} |
| 46 | + |
| 47 | +// Read the variable trust-box segment. With no constraints, it occupies bounds_lower.head(n_nlp_vars). |
| 48 | +struct TrustBox |
| 49 | +{ |
| 50 | + double lower; |
| 51 | + double upper; |
| 52 | + double width() const { return upper - lower; } |
| 53 | +}; |
| 54 | +TrustBox readBox(const trajopt_sqp::TrajOptQPProblem& qp) |
| 55 | +{ |
| 56 | + const Eigen::Index n = qp.getNumNLPVars(); |
| 57 | + return { qp.getBoundsLower().head(n)[0], qp.getBoundsUpper().head(n)[0] }; |
| 58 | +} |
| 59 | +} // namespace |
| 60 | + |
| 61 | +// Iterate well inside [lb, ub]: standard centered box [x-Δ, x+Δ], width 2·Δ. |
| 62 | +TEST(TrajOptQPTrustBox, InteriorIterateGetsCenteredBox) // NOLINT |
| 63 | +{ |
| 64 | + auto qp = makeProblem(0.0, kLb, kUb); |
| 65 | + qp->setBoxSize(Eigen::VectorXd::Constant(1, kBi)); |
| 66 | + const auto box = readBox(*qp); |
| 67 | + |
| 68 | + EXPECT_NEAR(box.lower, 0.0 - kBi, 1e-12); |
| 69 | + EXPECT_NEAR(box.upper, 0.0 + kBi, 1e-12); |
| 70 | + EXPECT_NEAR(box.width(), 2.0 * kBi, 1e-12); |
| 71 | +} |
| 72 | + |
| 73 | +// Iterate within Δ of the upper bound: box clamped at ub, lower side asymmetric. Width transitions |
| 74 | +// from 2·Δ down to Δ as x rises from (ub-Δ) to ub. Verifying the midpoint case x = ub - Δ/2. |
| 75 | +TEST(TrajOptQPTrustBox, IterateNearUpperBoundShrinksAsymmetrically) // NOLINT |
| 76 | +{ |
| 77 | + const double x_val = kUb - (kBi / 2.0); |
| 78 | + auto qp = makeProblem(x_val, kLb, kUb); |
| 79 | + qp->setBoxSize(Eigen::VectorXd::Constant(1, kBi)); |
| 80 | + const auto box = readBox(*qp); |
| 81 | + |
| 82 | + EXPECT_NEAR(box.lower, x_val - kBi, 1e-12); |
| 83 | + EXPECT_NEAR(box.upper, kUb, 1e-12); |
| 84 | + EXPECT_NEAR(box.width(), 1.5 * kBi, 1e-12); |
| 85 | + // TR invariant: max(|lower - x|, |upper - x|) = max(Δ, Δ/2) = Δ. Held. |
| 86 | + EXPECT_LE(std::max(std::abs(box.lower - x_val), std::abs(box.upper - x_val)), kBi + 1e-12); |
| 87 | +} |
| 88 | + |
| 89 | +// Iterate at the upper bound exactly: box [ub-Δ, ub], width Δ, fully TR-compliant. |
| 90 | +TEST(TrajOptQPTrustBox, IterateAtUpperBoundGetsBoxOfWidthBi) // NOLINT |
| 91 | +{ |
| 92 | + auto qp = makeProblem(kUb, kLb, kUb); |
| 93 | + qp->setBoxSize(Eigen::VectorXd::Constant(1, kBi)); |
| 94 | + const auto box = readBox(*qp); |
| 95 | + |
| 96 | + EXPECT_NEAR(box.lower, kUb - kBi, 1e-12); |
| 97 | + EXPECT_NEAR(box.upper, kUb, 1e-12); |
| 98 | + EXPECT_NEAR(box.width(), kBi, 1e-12); |
| 99 | +} |
| 100 | + |
| 101 | +// PR #472 bug case: iterate past the upper bound by >> Δ. The clamp keeps the box flush against |
| 102 | +// the violated bound with width Δ — no inversion, no OSQP error. The QP step will pull x back |
| 103 | +// through the bound (step magnitude ≈ |x - ub|, unavoidable for recovery). |
| 104 | +TEST(TrajOptQPTrustBox, IterateFarPastUpperBoundStaysAtWidthBi) // NOLINT |
| 105 | +{ |
| 106 | + const double x_val = 2.6; // ub = 2.5, violation = 0.1, Δ = 0.0015 |
| 107 | + auto qp = makeProblem(x_val, kLb, kUb); |
| 108 | + qp->setBoxSize(Eigen::VectorXd::Constant(1, kBi)); |
| 109 | + const auto box = readBox(*qp); |
| 110 | + |
| 111 | + EXPECT_NEAR(box.lower, kUb - kBi, 1e-12); |
| 112 | + EXPECT_NEAR(box.upper, kUb, 1e-12); |
| 113 | + EXPECT_NEAR(box.width(), kBi, 1e-12); |
| 114 | + EXPECT_GT(box.width(), 0.0); // explicit non-inversion guard |
| 115 | +} |
| 116 | + |
| 117 | +// Mirror: iterate past the lower bound. Box flush at lb with width Δ. |
| 118 | +TEST(TrajOptQPTrustBox, IterateFarPastLowerBoundStaysAtWidthBi) // NOLINT |
| 119 | +{ |
| 120 | + auto qp = makeProblem(-2.6, kLb, kUb); |
| 121 | + qp->setBoxSize(Eigen::VectorXd::Constant(1, kBi)); |
| 122 | + const auto box = readBox(*qp); |
| 123 | + |
| 124 | + EXPECT_NEAR(box.lower, kLb, 1e-12); |
| 125 | + EXPECT_NEAR(box.upper, kLb + kBi, 1e-12); |
| 126 | + EXPECT_NEAR(box.width(), kBi, 1e-12); |
| 127 | +} |
| 128 | + |
| 129 | +// Variable range narrower than the trust radius: box is clamped to the full natural range |
| 130 | +// [lb, ub], which is the best we can do. |
| 131 | +TEST(TrajOptQPTrustBox, BoundsNarrowerThanBiClampToRange) // NOLINT |
| 132 | +{ |
| 133 | + const double narrow_lb = 0.0; |
| 134 | + const double narrow_ub = 0.1; |
| 135 | + const double bi = 0.5; // Δ wider than the variable's allowed range |
| 136 | + auto qp = makeProblem(0.05, narrow_lb, narrow_ub); |
| 137 | + qp->setBoxSize(Eigen::VectorXd::Constant(1, bi)); |
| 138 | + const auto box = readBox(*qp); |
| 139 | + |
| 140 | + EXPECT_NEAR(box.lower, narrow_lb, 1e-12); |
| 141 | + EXPECT_NEAR(box.upper, narrow_ub, 1e-12); |
| 142 | +} |
0 commit comments