Unit 8 · Lesson 11

Motion Profiling: TrapezoidProfile and ProfiledPIDController

PID moves a mechanism toward a target. Motion profiling tells it exactly how fast to get there — and that difference is what keeps you from snapping gears, breaking chains, and oscillating all match long.

By the end of this lesson, you will:

  • Explain what a motion profile is and why PID alone is not sufficient for mechanisms with mass and momentum
  • Describe the three phases of a trapezoidal motion profile and what the robot physically experiences in each
  • Implement TrapezoidProfile.Constraints and TrapezoidProfile.State to generate a velocity-limited, acceleration-limited setpoint trajectory
  • Use ProfiledPIDController as the drop-in combination of PID and motion profiling for Command-Based subsystems
  • Identify the hardware preconditions that must be true before a motion profile can work reliably

Why PID Alone Isn't Enough for Mechanisms

At this point in Unit 8 you've built up a solid understanding of PID. You've seen how the proportional term drives toward the target, how the integral term cleans up steady-state error, and how the derivative term applies the brakes to prevent overshoot. For some mechanisms — like a swerve steering module tracking a slow angle — PID alone works fine.

But imagine you're programming a four-bar arm that weighs three pounds and has to swing from 0° to 90° as fast as possible to score in auto. If you feed a raw PID controller the error between current angle (0°) and target angle (90°), that error on loop 1 is huge — and the proportional term sends full (or near-full) voltage to the motor immediately. The arm launches itself toward 90°, builds too much momentum, overshoots, springs back, and then oscillates around the target. You can tune kD harder and harder to fight this, but you're fighting physics with a gain constant. Eventually you either detune the response into something sluggish, or you live with a mechanism that wobbles every time it moves.

The root cause is that PID has no concept of trajectory. It knows where you are and where you want to be. It doesn't know anything about how fast you should be moving at any given moment along the way — and on a physical mechanism with mass, momentum, chain stretch, and gravity, that matters.

🔍 What elite teams actually observe

Team 254's mentors have written about this directly: the reason their mechanisms feel smooth and decisive in matches isn't higher kP. It's that they've constrained every mechanism's motion to a profile the hardware can physically execute without fighting itself. When you watch a 254 or 2910 robot deploy an arm, it doesn't oscillate. It accelerates, cruises, and decelerates in a deliberate arc. That's motion profiling, not magic PID tuning.

The Shape of Controlled Motion

A trapezoidal motion profile describes exactly how a mechanism's velocity changes over time between two states. It's called trapezoidal because when you plot velocity versus time, the graph looks like a trapezoid: a ramp up, a flat section at maximum speed, and a ramp back down.

Click each phase below to understand what the mechanism is physically doing — and what the code is computing.

⬆ Acceleration
➡ Cruise
⬇ Deceleration
Phase 1 — t₀ to t₁
The mechanism accelerates toward maximum velocity

The profile ramps velocity up from the starting speed (often 0) toward the configured maximum velocity. The rate of the ramp is bounded by maxAcceleration — the constraint you provide. Every timestep, the profile generates a new intermediate setpoint that is slightly faster than the previous one, never jumping ahead by more than maxAcceleration × dt.

Physically, this is controlled energy delivery. The motor controller isn't told to spin at full speed immediately — it's told to reach a velocity that increases smoothly. This prevents the mechanical shock that breaks gears, snaps chains, and stalls motors. On an arm or elevator, it also prevents the jerking that can shift a game piece off a shelf or dump it out of an intake.

The feedforward controller is particularly valuable here: since the profile tells you the desired velocity at every timestep, kV can predict the required voltage precisely rather than waiting for a PID error to develop.

Phase 2 — t₁ to t₂
The mechanism holds maximum velocity

Once the mechanism reaches maxVelocity, the profile holds it there until it's time to begin decelerating. Not every motion has a cruise phase — if the distance is short enough, the mechanism may reach the deceleration point before it ever hits maximum velocity. In that case the profile becomes triangular instead of trapezoidal, and the code handles this automatically.

During cruise, the feedforward controller does most of the work. Velocity is constant and known, so kS + kV × targetVelocity predicts the exact required voltage. The PID component only needs to correct small velocity deviations caused by friction or load changes — it's doing very little work.

From the driver's perspective, this is when the arm or elevator is moving as fast as it's configured to move. Shortening the cruise phase by reducing maxVelocity makes the motion slower but gentler on hardware. Extending it (by increasing maxVelocity) makes cycle times faster but demands more from motors and gearboxes.

Phase 3 — t₂ to t₃
The mechanism decelerates toward the target

The profile begins ramping velocity back down as the mechanism approaches the final position, so that it arrives at the target with near-zero velocity. This is where motion profiling earns its reputation: instead of the PID's D term desperately fighting momentum at the last moment, the deceleration is planned. The mechanism arrives softly.

The profile calculates exactly when to begin decelerating based on current velocity and maxAcceleration. This is pure kinematics: v² = 2 × a × d, where d is the remaining distance and a is your max deceleration (the same as maxAcceleration in a symmetric profile). The profile begins braking at exactly the right distance to arrive at zero velocity at the target position.

On an arm: the arm doesn't slam into a hard stop or oscillate around the scoring position. On an elevator: it doesn't bounce. On a shooter pivot: the target angle is held precisely enough for an accurate shot. This controlled landing is the direct hardware benefit of a trapezoidal profile.

Profile Visualizer — Velocity vs. Time adjust constraints below
velocity setpoint
acceleration ramps
trapezoidal

TrapezoidProfile in WPILib

WPILib's TrapezoidProfile class is the direct implementation of the three phases described above. You configure it once with your hardware's physical limits, then call it every loop to get the next setpoint along the trajectory.

Step 1: Define the Constraints

The constraints tell the profile what the mechanism can physically do. These numbers come from your mechanism's gearing, motor, and whatever safety margin your mechanical team recommends — not from theoretical maximums.

Constants.java — ArmConstants
public static final TrapezoidProfile.Constraints ARM_CONSTRAINTS =
    new TrapezoidProfile.Constraints(
        90.0,   // maxVelocity: degrees per second
        180.0  // maxAcceleration: degrees per second squared
    );
// At these limits: from 0° to 90° takes about 1.2 seconds.
// If the arm slams into stops or trips current limits during profiling,
// reduce these values — the mechanism is telling you its actual limits.
💡 How to pick constraint values

Start conservative: set maxVelocity to about 60% of your theoretical free-speed-at-the-mechanism value. Set maxAcceleration to about 1.5× maxVelocity as a starting point. Deploy and watch the arm. If it feels sluggish and never trips a current limit, increase maxVelocity. If it trips current limits during acceleration, reduce maxAcceleration. These are hardware conversations, not software ones — talk to your mechanical lead.

Step 2: Define States

A TrapezoidProfile.State represents a moment in the motion: a position and a velocity at that position. You need a start state and a goal state.

ArmSubsystem.java — profile setup
// The arm is currently at 0 degrees, not moving
TrapezoidProfile.State currentState = new TrapezoidProfile.State(0, 0);

// We want to reach 90 degrees with zero final velocity (stopped at target)
TrapezoidProfile.State goalState = new TrapezoidProfile.State(90, 0);

// A non-zero final velocity is valid if this motion feeds into another profile.
// For scoring positions, always target zero — you want the arm still, not coasting.

Step 3: Calculate the Next Setpoint

Each loop iteration, you call profile.calculate(dt, currentState, goalState). This returns the next state along the trajectory — the position and velocity the mechanism should be at after dt seconds. You then feed that state to your feedforward and PID controllers.

ArmSubsystem.java — periodic() with manual TrapezoidProfile
private TrapezoidProfile m_profile = new TrapezoidProfile(ArmConstants.ARM_CONSTRAINTS);
private TrapezoidProfile.State m_setpoint = new TrapezoidProfile.State();
private TrapezoidProfile.State m_goal = new TrapezoidProfile.State();

@Override
public void periodic() {
    // dt = 0.02s for the standard 50Hz WPILib loop
    m_setpoint = m_profile.calculate(0.02, m_setpoint, m_goal);

    // Use the setpoint's velocity for feedforward, position for PID
    double feedforward = m_feedforward.calculate(m_setpoint.velocity);
    double pidOutput   = m_pid.calculate(getAngleDegrees(), m_setpoint.position);
    m_motor.setVoltage(feedforward + pidOutput);
}

public void setGoal(double degrees) {
    m_goal = new TrapezoidProfile.State(degrees, 0);
    // Do NOT reset m_setpoint here — we want the profile to continue
    // smoothly from wherever the arm currently is, not jump to zero.
}
💡 Why you preserve the setpoint state between calls

The profile calculates the next setpoint from the current setpoint, not from the current encoder position. This is intentional. If the driver changes goals mid-move — for example, calling a new arm position while the arm is still accelerating — the profile re-plans from wherever the setpoint currently is rather than from the physical position. This means the arm always follows a physically valid trajectory even during goal changes. If you reset the setpoint to the physical encoder position on every goal change, you can inadvertently command an acceleration spike that exceeds your hardware limits.

ProfiledPIDController: The Integrated Solution

Managing the profile and PID separately requires discipline. WPILib provides ProfiledPIDController as a single class that combines both — you set a goal, and it internally maintains the trajectory setpoint, advances it each loop, and computes the combined output. For most FRC mechanisms, this is the right starting point.

Compare the two approaches:

Aspect Manual TrapezoidProfile + PID ProfiledPIDController
State tracking You manage m_setpoint field manually Handled internally
Goal changes You must call setGoal() and not reset setpoint Call setGoal(); profile replans automatically
Loop call Call profile.calculate() then pid.calculate() Single controller.calculate(measurement)
Feedforward Access m_setpoint.velocity separately Access controller.getSetpoint().velocity
Use case When you need fine control of the trajectory object, or when integrating with state-space Standard Command-Based subsystems — arms, elevators, pivots

Implementing ProfiledPIDController in a Subsystem

ElevatorSubsystem.java — ProfiledPIDController
import edu.wpi.first.math.controller.ElevatorFeedforward;
import edu.wpi.first.math.controller.ProfiledPIDController;
import edu.wpi.first.math.trajectory.TrapezoidProfile;

public class ElevatorSubsystem extends SubsystemBase {

    private final TalonFX m_motor = new TalonFX(ElevatorConstants.MOTOR_ID);
    private final Encoder m_encoder = new Encoder(
        ElevatorConstants.ENCODER_A, ElevatorConstants.ENCODER_B);

    private final ElevatorFeedforward m_feedforward =
        new ElevatorFeedforward(
            ElevatorConstants.kS,
            ElevatorConstants.kG,   // gravity compensation — unique to ElevatorFF
            ElevatorConstants.kV,
            ElevatorConstants.kA);

    private final ProfiledPIDController m_controller =
        new ProfiledPIDController(
            ElevatorConstants.kP,
            ElevatorConstants.kI,
            ElevatorConstants.kD,
            new TrapezoidProfile.Constraints(
                ElevatorConstants.MAX_VELOCITY_M_PER_S,
                ElevatorConstants.MAX_ACCEL_M_PER_S2));

    @Override
    public void periodic() {
        double pidOutput = m_controller.calculate(getHeightMeters());

        // getSetpoint() returns the profile's current intermediate state
        double ffOutput = m_feedforward.calculate(
            m_controller.getSetpoint().velocity);

        m_motor.setVoltage(pidOutput + ffOutput);
    }

    public void setGoalHeight(double heightMeters) {
        m_controller.setGoal(heightMeters);
    }

    public boolean atGoal() {
        return m_controller.atGoal();
        // atGoal() is true when the profile is complete AND position is within tolerance.
        // Use this in Commands to know when to call isFinished().
    }

    private double getHeightMeters() {
        return m_encoder.getDistance();
    }
}
🔍 What atGoal() actually checks

atGoal() is not the same as the profile being complete. It returns true only when two conditions are both true: the profile's internal setpoint has reached the goal state, and the actual mechanism position is within the position and velocity tolerances you configured with setTolerance(). If you skip calling setTolerance(), the default is extremely tight. Always call m_controller.setTolerance(positionTolerance, velocityTolerance) in the subsystem constructor — otherwise atGoal() may never return true even when the mechanism is physically at the right position.

Wiring It Into a Command

The ProfiledPIDController lives in the subsystem. The command just sets the goal and waits for atGoal(). This is the correct division of responsibility in Command-Based architecture.

ElevatorToHeightCommand.java
public class ElevatorToHeightCommand extends Command {

    private final ElevatorSubsystem m_elevator;
    private final double m_targetMeters;

    public ElevatorToHeightCommand(ElevatorSubsystem elevator, double targetMeters) {
        m_elevator = elevator;
        m_targetMeters = targetMeters;
        addRequirements(elevator);
    }

    @Override
    public void initialize() {
        m_elevator.setGoalHeight(m_targetMeters);
        // That's it. The subsystem's periodic() advances the profile every loop.
    }

    @Override
    public boolean isFinished() {
        return m_elevator.atGoal();
    }

    @Override
    public void end(boolean interrupted) {
        if (interrupted) {
            // If interrupted mid-profile, hold current position rather than going limp
            m_elevator.setGoalHeight(m_elevator.getHeightMeters());
        }
    }
}
💡 Handle interruption explicitly

When a command gets interrupted mid-profile — say, a driver overrides the auto sequence — the mechanism is somewhere between its start and goal position with nonzero velocity. If your end() method does nothing, the subsystem's periodic() continues calling the controller, which will keep driving toward the original goal even though the command is technically done. Two patterns work: either set the goal to the current position in end() so the profile targets where the mechanism already is, or add a stop() method that sets motor voltage to zero. The first is usually safer for gravity-affected mechanisms like arms and elevators.

Common Mistakes and Their Physical Symptoms

Motion profiling bugs are usually visible before they show up in logs — the mechanism behaves in a specific wrong way. Each symptom below points to a specific mistake.

Arm lunges at full speed, then stops Constraints too high

The configured maxVelocity or maxAcceleration exceeds what the motor can deliver through the gearing. The motor trips current limits during the acceleration phase, the encoder reads inconsistently, and the feedback controller oscillates. Start at 50% of theoretical limits and tune up.

Mechanism doesn't reach target, stops short atGoal() tolerance too wide or kP too low

The profile is completing fine but the physical position isn't close enough to the goal. Check your setTolerance() values — if they're too wide, atGoal() returns true before the arm is actually at the target. If tolerance is correct, increase kP so the PID feedback closes the remaining gap.

Smooth on first move, jerky on second Setpoint reset on goal change

Something in the code is resetting m_setpoint to zero or to the encoder reading on every goal change. The second profile then starts from a discontinuous velocity, commanding an instantaneous acceleration that exceeds the hardware limit. Never reset the setpoint field externally — only the profile's calculate() call should write it.

Oscillation worsens over a match I-term windup during profile

If kI is nonzero and the mechanism can't reach intermediate setpoints quickly enough (due to load or friction), the integral accumulates during the profile. By the time the mechanism arrives at the goal, the I-term is already pushing hard. Set kI to 0 first — with a good feedforward, you usually don't need it.

Profile runs but mechanism doesn't follow it Encoder disconnected or misconfigured

The profile generates correct setpoints, but if the encoder reading is wrong, the PID feedback is fighting the wrong error. Check encoder port wiring, check that the encoder's distance-per-pulse conversion is correct, and verify you're calling the right getDistance() method. This is a hardware problem that no software fix will resolve.

Elevator sags during cruise phase Missing kG in ElevatorFeedforward

Standard SimpleMotorFeedforward doesn't compensate for gravity. For elevators and arms, you need ElevatorFeedforward or ArmFeedforward, which include a kG term that adds constant voltage to counteract gravity. Without it, the mechanism decelerates faster than the profile plans and arrives low.

🔍 LRI Pre-Inspection Perspective

One of the most consistent things I see at inspection from teams using motion profiling for the first time: they set their constraints based on free-speed motor math rather than measured mechanism behavior. The motor's no-load RPM doesn't account for gearing losses, bearing friction, chain stretch, or the arm's moment of inertia. The robot passes inspection fine but then trips the PDH breakers during the first auto run because the acceleration demand spikes current beyond what the breakers allow. If your mechanism is tripping 40A breakers during profiled motion, your maxAcceleration is higher than your hardware can support — not higher than your math says it can.

🔌 System Check

⚙️ Before You Run a Profiled Controller on Hardware

Motion profiling generates a physically bounded trajectory — but only if the hardware it's controlling can actually execute it. Before deploying code with ProfiledPIDController or TrapezoidProfile to a real mechanism:

  • Encoder is confirmed working. Call getDistance() on the encoder with the mechanism stationary. It should read a stable value with zero noise. Move the mechanism by hand and verify the reading changes in the correct direction and at the expected rate. A bad encoder is the single most common cause of profiled motion instability.
  • Motor controller CAN ID is correct and acknowledged. A TalonFX or SparkMax that isn't responding will silently ignore voltage commands. Open CTRE Tuner X or REV Hardware Client and confirm the device shows up and responds before running a profile.
  • Current limits are configured. Profiled acceleration can produce current spikes on the first loop. Configure your motor controller's stator current limit before enabling. For a typical arm motor, 40–60A stator limit is a reasonable starting point. Without this, you're relying on the PDH breaker as your protection — that's not acceptable.
  • Hard stops are either physically safe or have software limits. If the profile generates a setpoint beyond the mechanism's physical range, the mechanism will drive into its hard stop at profiled speed. Set software limits (m_controller.enableContinuousInput() for continuous mechanisms, or software position limits for bounded ones) before testing with real constraints.
  • Someone's hand is on the driver station disable button. First test: run the profile at 25% of your planned constraints. Watch the mechanism. Disable immediately if it moves in the wrong direction or makes unexpected sounds.

Knowledge Check

1. A programmer sets maxVelocity = 200 and maxAcceleration = 400 for an arm mechanism. During the first test, the arm reaches the target but vibrates and trips a current limit during the acceleration phase. What is the most likely root cause?

  • A The kP gain is too high on the PID controller
  • B The encoder distance-per-pulse conversion is wrong
  • C The maxAcceleration value exceeds what the motor can deliver through the gearing without tripping current limits
  • D The profile needs a kG gravity compensation term

2. You call controller.atGoal() and it returns false even though the arm visually appears to be at the correct angle. What is the most likely explanation?

  • A The profile is still in the acceleration phase
  • B setTolerance() was never called, leaving the default tolerance too tight for the physical mechanism
  • C The goal was set before the subsystem's periodic() ran for the first time
  • D ProfiledPIDController doesn't support atGoal() — only PIDController does

3. A team's elevator uses SimpleMotorFeedforward instead of ElevatorFeedforward. During a profiled move upward, the elevator consistently stops two inches short of its target. During a move downward, it overshoots. What component is missing?

  • A kS — the static friction compensation term
  • B kA — the acceleration feedforward term
  • C kG — the gravity compensation term present in ElevatorFeedforward but absent in SimpleMotorFeedforward
  • D A higher kP to close the steady-state error
💪 Practice Prompt

Profile a Real (or Simulated) Arm Mechanism

  1. Create a new subsystem class called ProfiledArmSubsystem. Add a ProfiledPIDController with placeholder constants and a TrapezoidProfile.Constraints configured for maxVelocity = 45.0 degrees/sec and maxAcceleration = 90.0 degrees/sec².
  2. Add a setGoal(double degrees) method and an atGoal() method. In periodic(), call the controller and add a placeholder for feedforward. Print the current setpoint position and velocity to SmartDashboard on every loop.
  3. Create a command MoveArmToAngleCommand that takes a target angle in degrees, calls setGoal() in initialize(), and returns atGoal() from isFinished(). Handle interruption in end() by holding the current position.
  4. If you have hardware access: deploy to a test arm. Observe the SmartDashboard output during a 0°→90° move. Verify the setpoint velocity rises, holds, then falls back to zero in the trapezoidal shape. If it doesn't — check your encoder direction and distance-per-pulse conversion first before touching gains.
  5. Bonus: Simulate what happens when you change the goal mid-profile. Command a move from 0° to 90°, then after 0.3 seconds command a move to 45°. Watch whether the setpoint transitions smoothly (it should) or jumps discontinuously (it shouldn't — if it does, you're resetting the setpoint incorrectly). Log both the setpoint position and the actual encoder position on the same SmartDashboard graph and compare the traces.