Skip to content

Commit 4f2d9ac

Browse files
rjoomenclaude
andcommitted
Use clamp-then-shrink for trust-box construction at variable bounds
Near a variable bound the trust box was built by sliding the full-width box inside [lb, ub] (the "slide always" policy). That kept the 2*box_size width but let the QP step exceed box_size on the interior-facing side whenever a bound was active, breaking the trust-region invariant ||p||_inf <= box_size that the SQP merit-improve machinery relies on -- even for iterates strictly inside their bounds. Switch to clamp-then-shrink: clamp the iterate into [lb, ub], then strict-shrink the box against the bounds. For in-bounds iterates this restores ||p||_inf <= box_size; when the iterate has drifted outside its bounds (failed step, bad warm-start) the box still stays well-formed (non-empty, non-inverted), so the QP solver does not error out. Applied to both QP problem variants (IfoptQPProblem, TrajOptQPProblem) and the trajopt_sco optimizer. Verified by trust_box_floor_unit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 677c0c0 commit 4f2d9ac

6 files changed

Lines changed: 191 additions & 12 deletions

File tree

trajopt_ifopt/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,31 @@ While you can sometimes rewrite formulations so “scale constraints” and “s
4747
- **Use coefficients/weights** on the **slack penalty** to represent priority/importance of the soft constraint.
4848

4949

50+
## Trust-Box Construction Near Variable Bounds
51+
52+
The trust region in `TrajOptQPProblem` is an $L_\infty$ box on the NLP step, $|p_i| \le \Delta_i$. The QP solver sees it as variable bounds on $x_i + p_i$, intersected with the user-provided bounds $[l_i, u_i]$. Two awkward situations can arise at that intersection:
53+
54+
1. The iterate $x_i$ sits at or near a bound, so the naive box $[x_i - \Delta_i, x_i + \Delta_i]$ extends past the bound on one side.
55+
2. The iterate has drifted outside $[l_i, u_i]$ — after a step rejection, a bad warm-start, or in the recovery regime of a failed solve.
56+
57+
`TrajOptQPProblem::Implementation::updateNLPVariableBounds` handles both with one rule: **clamp $x_i$ into $[l_i, u_i]$, then strict-shrink the trust box against the bounds.**
58+
59+
$$x_i^{\text{eff}} \;=\; \mathrm{clamp}(x_i,\, l_i,\, u_i), \qquad \text{box}_i \;=\; [\,\max(x_i^{\text{eff}} - \Delta_i,\, l_i),\; \min(x_i^{\text{eff}} + \Delta_i,\, u_i)\,].$$
60+
61+
For $x_i$ strictly inside $[l_i, u_i]$ the clamp is a no-op and the box is the standard trust region — $\|p\|_\infty \le \Delta$ is preserved, which is the contract the rest of the SQP machinery relies on (the merit-improve ratio $\rho$ is only meaningful for steps within the radius the model was expanded for). As $x$ approaches a bound the box width shrinks monotonically:
62+
63+
| $x$ position relative to upper bound $u$ | Box width |
64+
|---|---|
65+
| $x \le u - \Delta$ | $2\Delta$ (centered) |
66+
| $u - \Delta < x < u$ | $\Delta + (u - x)$ (linear ramp from $2\Delta$ down to $\Delta$) |
67+
| $x = u$ | $\Delta$ |
68+
| $x > u$ | $\Delta$ (constant; $x^{\text{eff}} = u$) |
69+
70+
Past the bound the trust-region invariant is unavoidably violated — any step pulling the iterate back through the bound has magnitude $\ge |x - u|$ — but the QP box itself stays well-formed (non-empty, non-inverted), so the QP solver does not error out.
71+
72+
The earlier "slide always" policy instead kept the full $2\Delta$ width by sliding the box inside $[l, u]$. That preserved the bug-fix property but let the QP step exceed $\Delta$ on the side facing the interior whenever a bound was active, even with the iterate strictly inside its bounds (within a trust-radius of the bound) — breaking $\|p\|_\infty \le \Delta$ in the regime where the SQP merit-improve machinery needs it most. The clamp-then-shrink design keeps the bug-fix property and restores the trust-region invariant for in-bounds iterates.
73+
74+
5075
## Currently Supported Constraints
5176
* Joint Position
5277
* Joint Velocity

trajopt_optimizers/trajopt_sqp/src/ifopt_qp_problem.cpp

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -467,11 +467,13 @@ void IfoptQPProblem::updateNLPVariableBounds()
467467
var_bounds_upper[i] = bounds.getUpper();
468468
}
469469

470-
// Calculate box constraints, while limiting to variable bounds and maintaining the trust region size
471-
const Eigen::VectorXd var_bounds_lower_final =
472-
(x_initial.cwiseMin(var_bounds_upper - box_size_) - box_size_).cwiseMax(var_bounds_lower);
473-
const Eigen::VectorXd var_bounds_upper_final =
474-
(x_initial.cwiseMax(var_bounds_lower + box_size_) + box_size_).cwiseMin(var_bounds_upper);
470+
// Calculate box constraints, clamped to variable bounds. The iterate is first clamped into
471+
// [lb, ub] so the box stays non-empty when x has drifted outside its bounds (failed step, bad
472+
// warm-start); for x inside [lb, ub] this is a no-op and the box is the standard strict-shrink
473+
// trust region.
474+
const Eigen::VectorXd x_clamped = x_initial.cwiseMin(var_bounds_upper).cwiseMax(var_bounds_lower);
475+
const Eigen::VectorXd var_bounds_lower_final = (x_clamped - box_size_).cwiseMax(var_bounds_lower);
476+
const Eigen::VectorXd var_bounds_upper_final = (x_clamped + box_size_).cwiseMin(var_bounds_upper);
475477
bounds_lower_.block(num_nlp_cnts_, 0, var_bounds_lower_final.size(), 1) = var_bounds_lower_final;
476478
bounds_upper_.block(num_nlp_cnts_, 0, var_bounds_upper_final.size(), 1) = var_bounds_upper_final;
477479
}

trajopt_optimizers/trajopt_sqp/src/trajopt_qp_problem.cpp

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include <trajopt_sqp/expressions.h>
77
#include <trajopt_sqp/types.h>
88

9+
#include <algorithm>
910
#include <utility>
1011
#include <iostream>
1112
#include <cassert>
@@ -1098,16 +1099,19 @@ void TrajOptQPProblem::Implementation::updateNLPVariableBounds(const Eigen::Ref<
10981099
auto lower = cvp.bounds_lower.segment(idx, cvp.n_nlp_vars);
10991100
auto upper = cvp.bounds_upper.segment(idx, cvp.n_nlp_vars);
11001101

1101-
// Assumes x, var_bounds_lower/upper are size n (or you use matching segments)
1102+
// Calculate box constraints, clamped to variable bounds. The iterate is first
1103+
// clamped into [lb, ub] so the box stays non-empty when x has drifted outside
1104+
// its bounds (failed step, bad warm-start); for x inside [lb, ub] this is a
1105+
// no-op and the box is the standard strict-shrink trust region.
11021106
for (Eigen::Index i = 0; i < cvp.n_nlp_vars; ++i)
11031107
{
1104-
const double xi = nlp_values[i];
11051108
const double bi = box_size[i];
11061109
const double lb = var_bounds_lower[i];
11071110
const double ub = var_bounds_upper[i];
1111+
const double xi = std::clamp(nlp_values[i], lb, ub);
11081112

1109-
lower[i] = std::max(lb, std::min(xi, ub - bi) - bi);
1110-
upper[i] = std::min(ub, std::max(xi, lb + bi) + bi);
1113+
lower[i] = std::max(xi - bi, lb);
1114+
upper[i] = std::min(xi + bi, ub);
11111115
}
11121116
}
11131117

trajopt_optimizers/trajopt_sqp/test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ add_gtest(${PROJECT_NAME}_planning_unit planning_unit.cpp)
3737
add_gtest(${PROJECT_NAME}_simple_collision_unit simple_collision_unit.cpp)
3838
add_gtest(${PROJECT_NAME}_cart_position_optimization_unit cart_position_optimization_unit.cpp)
3939
add_gtest(${PROJECT_NAME}_hessian_gradient_unit hessian_gradient_unit.cpp)
40+
add_gtest(${PROJECT_NAME}_trust_box_floor_unit trust_box_floor_unit.cpp)
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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+
}

trajopt_sco/src/optimizers.cpp

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include <trajopt_common/macros.h>
22
TRAJOPT_IGNORE_WARNINGS_PUSH
33
#include <boost/format.hpp>
4+
#include <algorithm>
45
#include <cmath>
56
#include <chrono>
67
#include <cstdio>
@@ -155,11 +156,15 @@ void BasicTrustRegionSQP::setTrustBoxConstraints(const DblVec& x)
155156
const DblVec& ub = prob_->getUpperBounds();
156157
DblVec lbtrust(x.size());
157158
DblVec ubtrust(x.size());
159+
// Calculate box constraints, clamped to variable bounds. The iterate is first clamped into
160+
// [lb, ub] so the box stays non-empty when x has drifted outside its bounds (failed step, bad
161+
// warm-start); for x inside [lb, ub] this is a no-op and the box is the standard strict-shrink
162+
// trust region.
158163
for (std::size_t i = 0; i < x.size(); ++i)
159164
{
160-
// Calculate box constraints, while limiting to variable bounds and maintaining the trust region size
161-
lbtrust[i] = fmax(fmin(x[i], ub[i] - param_.trust_box_size) - param_.trust_box_size, lb[i]);
162-
ubtrust[i] = fmin(fmax(x[i], lb[i] + param_.trust_box_size) + param_.trust_box_size, ub[i]);
165+
const double xi = std::clamp(x[i], lb[i], ub[i]);
166+
lbtrust[i] = std::max(xi - param_.trust_box_size, lb[i]);
167+
ubtrust[i] = std::min(xi + param_.trust_box_size, ub[i]);
163168
}
164169
model_->setVarBounds(vars, lbtrust, ubtrust);
165170
}

0 commit comments

Comments
 (0)