simplesim is a little ROS2 + SFML exercise in implementing a good pure pursuit controller.
This project is designed to have an emphasis on being clear and readable, so you can learn to implement one too!
Have ROS2 Jazzy installed and sourced.
cd ~/src/ros2_ws # or wherever your colcon workspace is
git clone [email protected]:nabeelsherazi/simplesim src/simplesim
rosdep install --from-paths src --ignore-src -r -y
colcon build
Run:
. install/setup.bash
ros2 launch src/simplesim/launch/all.launch # properly
# or, with all topics un-namespaced
ros2 run simplesim simplesim_node
The launch file can also spin up a Foxglove Bridge node for you with --foxglove
xhost +local:docker # Let docker talk to your X server
docker build -t simplesim:latest .
docker run --rm --device=/dev/dri -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix simplesim:latest
simplesim models a quadrotor with double integrator dynamics in 2D. Double integrator dynamics means that kinematic motion is completely determined by the instantaneous acceleration at any given point in time. The quadrotor can accelerate instantaneously in any direction at any point in time. This makes control problems much simpler.
The quadrotor simulation is modeled in a decoupled way from the rest of the program, meaning that you can drop in your own controller to try controlling it! It can be controlled in either velocity or acceleration mode, meaning it will accept a velocity and instantaneously move at that velocity (simpler!) or accept an acceleration an instantaneously start accelerating at that rate (more realistic).
The built-in controller is a cascaded PD controller, which, given a goal, commands the quadcopter to accelerate. The setting of the goal is where pure pursuit comes in. When you click on the screen to add a waypoint to the path, the controller uses a pure pursuit algorithm to determine the goal (setpoint) for the cascaded PD controller. Thus you get smooth path following.
Pure pursuit control was first illustrated in this 1992 paper by R. Craig Coulter. While the paper is readable, it leaves a lot of things unclear, which this implementation should illuminate.
The basic idea of pure pursuit is that you have a path you would like your vehicle to follow. Like how when driving a car you don't stare at exactly what's in front of you, but rather ahead and towards where you want to go, pure pursuit puts a "carrot" in front of the vehicle, following the path, that allows the vehicle to make smooth motions.
The way the algorithm works is like this:
- Define some lookahead distance
$L_D$ . When we are on the path, we ideally want to track a point that is always exactly one lookahead distance away from us (like a stick we are holding with a carrot on it -- it should sweep a circle around the vehicle). - Start by finding the nearest point on the path to the vehicle. If we're far away from the path, this is our best lookahead point.
- Find the intersections of our lookahead radius with the path. The best lookahead point is the one furthest along the path.
There's a gotcha though. As we're looking for intersections of the path with the lookahead circle, we could catch paths that are far ahead of where we are now. So we limit ourselves to either the current segment we're on, or any following segments so long as their starting point is within our lookahead radius. This prevents us from accidentally jumping ahead.
The math works like this. Let the current path segment be between waypoints
If we're far away from the path, our best waypoint is the point on the segment closest to the vehicle, which we get by taking the projection of the vehicle position onto the segment.
In code that's:
// Vector from p1 to p2
auto p1 = waypointList[currentWaypointIndex];
auto p2 = waypointList[currentWaypointIndex + 1];
sf::Vector2f segment = p2 - p1;
sf::Vector2f currentToP1 = currentPosition - p1;
float segmentLength = simplesim::norm(segment);
// Find the projection point
float t = simplesim::dot(currentToP1, segment) / (segmentLength * segmentLength);
// Clamp t to [0, 1] to stay within segment bounds
t = simplesim::clamp(t, 0.0F, 1.0F);
// Initial best lookahead point is the projection point
currentSetpoint = p1 + t * segment;
If we're close to the path, we need to find the lookahead point. We can do this by finding the intersections of the lookahead circle with the path. The lookahead circle is centered at the vehicle and has radius
In vector form, the equation of a circle is:
where
If we parameterize our line segment as:
then we can substitute this into the equation of the circle to find points that both lie on our segment and in the circle.
Let
and expands to:
which is (collecting the dot products):
and can be written as a quadratic equation in
If we let:
then we can solve the quadratic equation for
One very nice trick of solving in this parameterized form it that it makes it very easy for us to tell which intersections are further along the path. Since the segment is parameterized from beginning to end, we take the largest t as our lookahead point. We just convert it back into a point with
// Find the lookahead point along the path.
// If we haven't reached the first waypoint yet, the segment is from the first waypoint to the second.
// Otherwise, the segment is from the last waypoint to the current one.
auto segmentStartIndex = currentWaypointIndex == 0 ? 0 : currentWaypointIndex - 1;
for (int i = segmentStartIndex; i < waypointList.size(); i++) {
p1 = waypointList[i];
p2 = waypointList[i + 1];
// After the current segment, we only check following segments if their start point is within the lookahead distance circle
if (i > segmentStartIndex && !simplesim::pointWithinCircle(currentPosition, lookaheadDistance, p1)) {
break;
}
auto intersections =
simplesim::intersectCircleAndLineParameterized(currentPosition, lookaheadDistance, p1, p2);
if (!intersections.empty()) {
auto furthestIntersection = std::max_element(intersections.begin(), intersections.end());
currentSetpoint = p1 + *furthestIntersection * (p2 - p1);
}
}
One problem with pure pursuit control is that when traveling in long straight lines, you want to be able to speed up. With a fixed lookahead distance, your carrot is a fixed radius from the vehicle, and thus the PID controller will have some maximum output command that won't be exceeded. Adaptive pure pursuit is illustrated in this 2007 paper by S. Campbell. The simple idea is to scale the lookahead distance by the current speed. By providing a minimum and maximum lookahead, and a lookahead gain
Given the current vehicle position and an (x, y) goal we would like to go to. Let's find the curvature required to hit that point. Curvature is defined as
The straight line of length
The equation of a circle must hold. That means:
Cool! If our quadrotor didn't have double integrator dynamics, we would use this as our commanded steering angle!
Now in our case,
The wind vector in this simulation is modeled as a 2D vector with randomly walking components. The independent random walks really work well for simulating wind. At each time step, the current wind vector is treated like an acceleration, meaning that the windIntensity
parameter (very) roughly is analogous to the maximum jerk at any timestep.
Drag is modeled like this: the real equations are quadratic in velocity, but since the Reynolds number quadraticDragThreshold
about which it lerps into quadratic drag. This seems to help with some weird diverging behavior, but you might want to disable it by setting the threshold to zero and working with just quadratic drag.
The choice of a realistic pressure drag coefficient can be determined from a few real world properties. Quadrotors have a maximum force that their motors can apply, and so a maximum lateral acceleration when they're pointing sideways at max throttle. In doing this, at some point they reach some terminal lateral velocity, which is when quadratic drag exactly balances out the force from the motors. Since in simplesim we keep things simple and the mass of the quadrotor is 1, the max acceleration is the same as the max force, and already given as a parameter of the simulation. A good value is about 5g, which is typical of a quad like the S500. If we know what terminal lateral velocity we expect (a good value is about 30 m/s), then we have:
[ ] I think there is a minor bug in how I select the segment to use for the initial pure pursuit guess. It initializes with segment = waypointList[currentWaypointIndex + 1] - waypointList[currentWaypointIndex]
which is correct when we have not reached our very first waypoint yet but incorrect after that. This also causes the behavior where once we reach the second to last endpoint, the lookahead is set directly to the last endpoint.
[ ] Test and verify that all on-the-fly parameter changing works
[ ] Add faster than realtime support for simulations