Profiled Drive to Point
Drive to Point turns distance into speed. Three meters out it asks for 30 m/s, and at the goal it asks for almost nothing. This version plans the whole trip before the robot moves, then follows the plan.
- Drive to Point. This lesson edits that one file.
- Swerve Calibration. The goal is an absolute field pose.
- Logging. Two of the checks below are graphs.
One file changes: commands/DriveToPoint.java, 87 lines to 120. A and B are already bound from the last lesson, and nothing else on the branch moves.
The trip planner
Phoenix 6 ships a straight-line trip planner called LinearPath. You hand it two pairs of limits, one for driving and one for turning, then ask it the same question every loop. At t seconds into the trip, where should the robot be, and how fast should it be going?
The answer is a LinearPath.State, holding a pose and a velocity. The pose is what PID measures against. The velocity is the feedforward: the speed the plan says the robot should be doing right now.
Plot that planned speed against time and you get a trapezoid, which is where TrapezoidProfile gets its name. The branch drives at 2.5 m/s with 3.0 m/s² of acceleration.
- Speed up
- Gain 3.0 m/s of speed every second. Reaching 2.5 m/s takes about 0.83 s and covers about 1.04 m.
- Cruise
- Hold 2.5 m/s for whatever distance is left in the middle. A short trip has no middle.
- Slow down
- Shed 3.0 m/s every second, timed to arrive at the goal with a planned speed of zero.
Speeding up and slowing down each need about 1.04 m, so a trip shorter than 2.1 m never reaches 2.5 m/s. Those come out as a triangle: speed up, then straight into slowing down. No special case to write.
Make the change
Three new fields and three rewritten methods, all in the one file. Three imports come with them: com.ctre.phoenix6.Utils, com.ctre.phoenix6.swerve.utility.LinearPath, and org.wpilib.math.trajectory.TrapezoidProfile.
// The trip planner. First pair of limits: top speed (m/s) and acceleration (m/s²) for// driving. Second pair: the same for turning (rad/s, rad/s²).// TODO: tune to what your drivetrain can do.private final LinearPath path = new LinearPath( new TrapezoidProfile.Constraints(2.5, 3.0), new TrapezoidProfile.Constraints(Math.PI, 2.0 * Math.PI));Math.PI is half a turn in radians, so the turning limits read as half a turn per second, and one full turn per second squared.
The command builds the plan once, from where the robot was and how fast it was moving when the button went down. That snapshot and a clock reading are the other two fields.
// Where the robot was, and how fast it was moving, when the command started. The whole// trip is planned from this one snapshot.private LinearPath.State startState = new LinearPath.State();// When the command started. (now - startTime) says how far into the trip we are.private double startTime; /** Takes the starting snapshot and starts the trip clock. */@Overrideprotected void initialize() { startState = new LinearPath.State(drivetrain.getPose(), drivetrain.getFieldVelocity()); startTime = Utils.getCurrentTimeSeconds(); xController.reset(); yController.reset(); headingController.reset();}A robot already rolling gets a plan that starts from the speed it has, not from a standstill. That is what getFieldVelocity() is doing there.
The new control loop
/** Runs every robot loop while the command is active. */@Overrideprotected void execute() { // Ask the plan where we should be, this many seconds into the trip. double t = Utils.getCurrentTimeSeconds() - startTime; LinearPath.State setpoint = path.calculate(t, startState, goal); Pose2d measuredPose = drivetrain.getPose(); // The plan's velocity does the driving. Each PID call below adds a small correction that // pulls the measured pose back onto the planned pose. ChassisVelocities feedforward = setpoint.velocity; double vx = feedforward.vx + xController.calculate(measuredPose.getX(), setpoint.pose.getX()); double vy = feedforward.vy + yController.calculate(measuredPose.getY(), setpoint.pose.getY()); double omega = feedforward.omega + headingController.calculate( measuredPose.getRotation().getRadians(), setpoint.pose.getRotation().getRadians()); drivetrain.setControl(driveRequest.withVelocity(new ChassisVelocities(vx, vy, omega)));}The planned velocity goes out as it is. Each controller adds a small correction on top, pulling the measured pose back onto the planned pose. On a perfect floor every correction would be zero and the plan would drive the trip alone.
The finish line is the plan's own clock. isFinished() returns path.isFinished(t), with t the seconds since the command started. No position tolerance anywhere.
A clock, not a tape measure
path.isFinished(t) asks whether the plan is over, not whether the robot arrived. Fall behind the plan and the command still ends on schedule, wherever the robot happens to be. That is the trade for a finish line that cannot hang.
The finished file, 120 lines. The GitHub Changes tab is PR #12: 59 lines added, 26 removed.
Lower the gains
On 5-DriveToPoint, PID output was the whole commanded velocity, and the error it measured was the distance to the goal. Meters of error, for most of the trip. A kP of 10 paid for that.
Now the error is the gap between the measured pose and setpoint.pose, and that stays in centimeters the whole way. X and Y come down to 3.0, heading to 4.0.
Leave them at 10 and the correction piles onto a plan that was already asking for the right speed. Ten centimeters of drift buys another meter per second. The robot hunts around the path instead of settling onto it.
Starting points, not answers
Nobody measured these gains or these limits for your robot. Raise kP if the robot lags the plan or stops short of the goal. Lower it, or add a little kD, if the robot wobbles.
The 2.5 m/s cruise is a little over half the kSpeedAt12Volts = 4.54 m/s in TunerConstants.java. That headroom is where the correction goes. On real hardware, run it in clear space with a hand on the disable.
Check your work
- Enable Teleop in the simulator and hold B. The robot eases away, holds a steady speed, and eases off at the end. From the origin that goal is 3.6 m out, past the 2.1 m needed for cruise.
- Keep holding B after it arrives. The robot stays stopped, because the command finished on its own. On
5-DriveToPointit would still be pushing. - Graph
Drivetrain/TranslationSpeedMpsfor that run. A ramp up, a flat top near 2.5, a ramp down to zero. The flat top is the proof the plan is in charge. - Hold A until the robot stops, sending it back to
Pose2d.kZero. The speed graph shows the same trapezoid the other way, andDrivetrain/Posesettles near zero. It turns toward 0° while it drives rather than spinning first.
It lunges off in the wrong direction. The startState line is missing from initialize(), so the plan is drawn from the empty new LinearPath.State() the field was given at declaration.
It weaves along the path. The gains are still 10 / 10 / 7. If they are already at 3.0 and 4.0, lower kP further or add a little kD.
It stops short of the goal. Graph the speed again. A trace that never reaches the flat top means the limits are past what the drivetrain can do, so lower 2.5 and 3.0. A trace that does reach it means the gains are too small.
A command with an ending is a step, and Command.sequence(...) takes steps. Dynamic Path Planning uses this one as the final approach, after a planner handles the trip across open field.
Check yourself
6-ProfiledToPoint drops the PID gains from 10 / 10 / 7 to 3.0 / 3.0 / 4.0. Why?
What does path.calculate(t, startState, goal) hand back each loop?
The driving limits are TrapezoidProfile.Constraints(2.5, 3.0). What do those two numbers mean?
How does isFinished() decide the command is done?
Why can this version sit inside Command.sequence(...) when the 5-DriveToPoint version could not?