Unit 9 · Lesson 3

Sensor-Based Movement

Timed movement asks the robot to run blind for a fixed duration. Sensor-based movement asks the robot to run until reality confirms it's done. That single difference is what separates a routine that works in the shop from one that works at a World Championship.

By the end of this lesson, you will:

  • Implement DriveDistanceCommand using encoder feedback and explain how encoder reset in initialize() ensures correctness across multiple uses
  • Implement TurnToAngleCommand using gyroscope feedback and a PIDController for smooth, overshoot-free turns
  • Implement a mechanism command triggered by a digital sensor (beam break or limit switch)
  • Configure setDistancePerPulse() correctly for a given wheel diameter and encoder CPR, using the calculator in this lesson
  • Explain why resetting encoders in initialize() rather than storing a start position is more reliable
  • Combine multiple sensor-based commands into a reliable autonomous sequence

The Core Shift: From Duration to Condition

In Lesson 2 you learned that timed movement fails because the robot has no way to know if its assumptions about battery voltage, friction, and position are correct. Sensor-based movement eliminates those assumptions by replacing them with measurements.

The change in the command structure is minimal — the lifecycle methods are identical, the requirements work the same way. The only thing that changes is what drives isFinished():

  • Timed: return m_timer.hasElapsed(m_duration);
  • Sensor-based: return m_drive.getDistanceMeters() >= m_targetMeters;

That one line substitution is the difference. The robot no longer asks "have enough seconds passed?" It asks "has the encoder confirmed I've traveled the requested distance?" If the battery sags, the motors run a little longer — and the encoder still reports when the robot has actually arrived. If another robot bumps you sideways, a gyro-based turn command will correct for the displacement. The sensor doesn't lie about where the robot is. The clock doesn't know where the robot is at all.

The Three Sensors That Drive Autonomous

Virtually every sensor-based autonomous command on a competitive FRC robot uses one of three physical sensors. Each solves a different problem.

📏 Drive Encoder Encoder / TalonFX.getPosition()

Measures how far a wheel has turned. Configured to report in meters (or rotations) via setDistancePerPulse(). The primary sensor for any command that needs to travel a specific linear distance. Must be reset at the start of each command via resetEncoders() in initialize().

🧭 Gyroscope Pigeon2 / NavX / ADIS

Measures the robot's heading in degrees. The primary sensor for turn commands. Unlike encoders, gyros accumulate error over time (drift), but across a 15-second autonomous period the drift is typically small enough to be negligible. Does not need to be reset for turn commands if you use a relative angle target.

💡 Digital Sensor DigitalInput / beam break / limit switch

Returns a boolean — either the sensor is tripped or it isn't. Used to detect game piece acquisition (beam break), mechanism limits (limit switch), or physical contact. The simplest possible isFinished(): return m_sensor.get();. No configuration needed beyond the correct DIO port.

Side-by-Side: Timed vs. Sensor-Based

Select a motion task below to see the same action implemented both ways. Read both versions — the structure is nearly identical. The difference is entirely in initialize() and isFinished().

Command comparator select a task
Drive forward
Turn 90°
Run intake

DriveDistanceCommand: The Full Implementation

The distance drive is the most fundamental sensor-based command. Here is the complete, production-quality implementation with all the nuances explained.

DriveDistanceCommand.java — encoder-based, with PID straightening
import edu.wpi.first.math.controller.PIDController;
import edu.wpi.first.wpilibj2.command.Command;

public class DriveDistanceCommand extends Command {

    private final DriveSubsystem m_drive;
    private final double m_targetMeters;
    private final double m_speed;

    // PID controller to keep the robot tracking straight.
    // Even with encoders on both sides, the robot can drift left/right
    // if one side has slightly more friction. A small kP on heading error
    // from the gyro corrects this without needing a full TurnToAngleCommand.
    private final PIDController m_straightController =
        new PIDController(0.02, 0, 0);
    private double m_initialHeading;

    public DriveDistanceCommand(DriveSubsystem drive, double meters, double speed) {
        m_drive        = drive;
        m_targetMeters = meters;
        m_speed        = speed;
        addRequirements(drive);
    }

    @Override
    public void initialize() {
        m_drive.resetEncoders();
        // Capture heading at start so we can correct drift.
        // We want to maintain this heading throughout the drive.
        m_initialHeading = m_drive.getHeadingDegrees();
        m_straightController.reset();
        m_straightController.setSetpoint(m_initialHeading);
    }

    @Override
    public void execute() {
        // Heading correction: PID output pushes the rotation input
        // to keep the robot pointing in the same direction it started.
        double headingError = m_drive.getHeadingDegrees() - m_initialHeading;
        double rotationCorrection = -m_straightController.calculate(headingError);
        m_drive.arcadeDrive(m_speed, rotationCorrection);
    }

    @Override
    public boolean isFinished() {
        // getAverageDistanceMeters() returns the average of left and right
        // encoder readings after the reset in initialize().
        return Math.abs(m_drive.getAverageDistanceMeters()) >= m_targetMeters;
    }

    @Override
    public void end(boolean interrupted) {
        m_drive.stop();
    }
}
💡 Why reset encoders in initialize(), not in the constructor

The constructor runs during robotInit() — potentially minutes before autonomous begins. Resetting there just means the encoder starts counting from boot time, not from when the command actually starts moving the robot. More importantly: this command may be reused multiple times in a sequence (drive forward, do something, drive forward again). Each call needs its own fresh encoder baseline. Resetting in initialize() gives each use of the command an independent starting reference, no matter how many times it runs or how much encoder drift accumulated before it started.

💡 The heading correction PID is a "straight line" insurance policy

Even with encoders, the robot can drift left or right if one side has slightly more friction or if the carpet is slightly uneven. The m_straightController measures how far the robot has rotated from its starting heading and applies a small rotational correction. With a small kP (0.01–0.03), this is barely noticeable during normal operation but prevents the 5–10° of heading drift that accumulates over a 2-meter drive. Use Math.abs() in isFinished() so the command works for both forward (positive) and reverse (negative) speeds.

Configuring setDistancePerPulse()

Before your DriveDistanceCommand can work correctly, the drivetrain's encoders must be configured to report distances in meaningful units. The setDistancePerPulse() method on WPILib's Encoder class tells it how many meters (or other units) each encoder pulse represents.

The formula is: Distance per pulse = (π × wheel diameter) ÷ (encoder CPR × gear ratio)

Use the calculator below to find the correct value for your robot's drivetrain.

setDistancePerPulse() calculator fill in your robot's values
Your robot's hardware
Generated code
Fill in values and click Calculate

TurnToAngleCommand: Gyro-Based Heading Control

A turn command that simply applies a fixed rotation speed for a time — or even one that stops when the gyro hits a target — will overshoot. The robot has momentum, and momentum carries it past the setpoint before the motors can respond. The correct solution is a PID controller on the gyro heading: it applies less and less rotational output as the robot approaches the target, landing softly at the correct angle instead of swinging through it.

TurnToAngleCommand.java — gyro PID heading control
public class TurnToAngleCommand extends Command {

    private final DriveSubsystem m_drive;
    private final double m_targetDegrees;

    // kP: how aggressively to correct heading error.
    // 0.01 means 1% motor output per degree of error.
    // At 10° error → 10% output. At 1° error → 1% output.
    // Start here and increase until you see overshoot, then reduce.
    private final PIDController m_turnController =
        new PIDController(0.01, 0, 0.001);

    public TurnToAngleCommand(DriveSubsystem drive, double degrees) {
        m_drive         = drive;
        m_targetDegrees = degrees;
        addRequirements(drive);
        // Tolerance: finish when within ±2° of target AND not moving fast
        m_turnController.setTolerance(2.0, 5.0); // degrees, degrees/sec
    }

    @Override
    public void initialize() {
        m_turnController.reset();
        m_turnController.setSetpoint(m_targetDegrees);
        // Note: we do NOT reset the gyro here.
        // The gyro measures absolute field heading. Resetting it mid-auto
        // would corrupt odometry and any subsequent heading-based commands.
        // Instead, pass the gyro's current reading and the desired absolute heading.
    }

    @Override
    public void execute() {
        double rotationOutput = m_turnController.calculate(m_drive.getHeadingDegrees());
        // Clamp output so the robot doesn't turn at full speed even when far off target
        rotationOutput = Math.copySign(Math.min(Math.abs(rotationOutput), 0.6), rotationOutput);
        m_drive.arcadeDrive(0, rotationOutput);
    }

    @Override
    public boolean isFinished() {
        // atSetpoint() is true only when BOTH position AND velocity are within tolerance.
        // This prevents finishing while the robot is still rotating through the target.
        return m_turnController.atSetpoint();
    }

    @Override
    public void end(boolean interrupted) {
        m_drive.stop();
    }
}
🔍 atSetpoint() vs. checking angle alone

A common mistake: isFinished() checks only Math.abs(heading - target) < tolerance, with no velocity check. The robot swings through the setpoint at speed, isFinished() returns true at the exact moment the heading passes through the target, and end() stops the motors — but momentum carries the robot several more degrees past where it stopped. PIDController.atSetpoint() checks both the position tolerance and the velocity tolerance you set with setTolerance(position, velocity). It doesn't return true until the robot is both close to the target heading and nearly stopped, which is exactly the condition you want.

💡 Don't reset the gyro in initialize()

Resetting the gyro mid-autonomous — even in a command's initialize() — corrupts odometry, field-oriented drive, and any subsequent commands that rely on absolute heading. Instead, pass the desired absolute heading to TurnToAngleCommand. If you want to turn 90° to the right from wherever the robot is now, compute the target as m_drive.getHeadingDegrees() + 90.0 in RobotContainer when building the sequence. The gyro's existing heading reference stays intact for the rest of the routine.

Mechanism Commands With Digital Sensors

For mechanisms like intakes, conveyors, and indexers, the exit condition isn't a position — it's a state change detected by a beam break or limit switch. These are the simplest sensor-based commands because there's no math: the sensor is either tripped or it isn't.

IntakeUntilSensorCommand.java — beam break triggered
public class IntakeUntilSensorCommand extends Command {

    private final IntakeSubsystem m_intake;

    public IntakeUntilSensorCommand(IntakeSubsystem intake) {
        m_intake = intake;
        addRequirements(intake);
    }

    @Override
    public void initialize() {
        m_intake.runIntake(0.6);
        // Start the intake motors in initialize() rather than execute()
        // because the speed is constant. This is acceptable here —
        // the motor command doesn't depend on any loop calculation.
    }

    @Override
    public void execute() {
        // Nothing needed — motors are running at constant speed.
        // You could add dashboard telemetry here for debugging.
        SmartDashboard.putBoolean("Intake/SensorTripped", m_intake.hasPiece());
    }

    @Override
    public boolean isFinished() {
        return m_intake.hasPiece();
        // hasPiece() returns true when the beam break is tripped.
        // The command ends immediately when the game piece interrupts the beam.
        // No timer needed. No guess about how long intake takes.
    }

    @Override
    public void end(boolean interrupted) {
        m_intake.stopIntake();
        // If interrupted (e.g., auto period ended before piece was acquired),
        // stop the motors — don't leave them running.
    }
}
💡 Always pair sensor-based commands with .withTimeout()

What happens if the beam break fails and the sensor never trips? The intake runs forever, the sequence hangs, and the rest of autonomous never executes. Pair every sensor-based command with a timeout safety net when assembling the sequence: new IntakeUntilSensorCommand(m_intake).withTimeout(3.0). If the game piece is acquired in 0.8 seconds, the command ends normally. If the sensor fails and 3 seconds pass, the command is interrupted and the sequence moves on. This is the standard practice used by every championship team.

A Complete Sensor-Based Autonomous Sequence

These three command types — distance drive, angle turn, and sensor-triggered mechanism — are the core vocabulary of every FRC autonomous routine that doesn't use path following. Here's a complete sequence showing how they compose:

RobotContainer.java — sensor-based auto sequence
public Command getAutonomousCommand() {
    double startHeading = m_drive.getHeadingDegrees();
    // Capture starting heading once so relative turns are computed correctly.

    return Commands.sequence(

        // 1. Drive 2 meters forward at 50% speed, with 4s timeout safety net
        new DriveDistanceCommand(m_drive, 2.0, 0.5).withTimeout(4.0),

        // 2. Run intake until piece acquired, or 3s max
        new IntakeUntilSensorCommand(m_intake).withTimeout(3.0),

        // 3. Turn 90° right from starting heading (absolute target, not reset)
        new TurnToAngleCommand(m_drive, startHeading + 90.0).withTimeout(2.5),

        // 4. Drive 1.5 meters forward to scoring zone at 40% speed
        new DriveDistanceCommand(m_drive, 1.5, 0.4).withTimeout(4.0),

        // 5. Score (runs intake in reverse for 0.5 seconds — timed is fine here)
        Commands.run(() -> m_intake.runIntake(-0.8), m_intake).withTimeout(0.5)
    );
    // Note: the score step uses a timed command — and that's appropriate!
    // Ejecting a game piece doesn't have a sensor to detect "done."
    // The hybrid approach (sensor-based where possible, timed as fallback)
    // is exactly the pattern used by championship teams.
}
🔍 The hybrid approach is the real standard

Almost no competition auto routine is 100% sensor-based. Scoring actions often don't have sensors. Some mechanisms — particularly in early build season — aren't equipped with sensors yet. The real standard is: use sensor-based control for every movement where a sensor exists and the position matters for the next step, and use timed commands for actions where there's no sensor and the exact completion doesn't affect subsequent positioning. The key distinction is whether getting the timing wrong on that step will cause the next step to start from the wrong place. If yes, use a sensor. If no, timed is acceptable.

🔌 System Check

⚙️ Before You Trust Any Sensor-Based Command

Sensor-based commands are only as reliable as the sensors they depend on. Verify each one independently before running a full sequence:

  • Encoder reads in the correct direction. Push the robot forward by hand. The encoder reading should increase. If it decreases, either invert the encoder in software (setInverted(true)) or flip the wiring. Don't skip this — a backward encoder means isFinished() never returns true and the robot drives forever.
  • setDistancePerPulse() is configured and verified. Push the robot exactly one wheel circumference (π × diameter) by hand. The encoder should read approximately 1.0 rotations after the ratio conversion. Use the calculator above to compute the value, then physically verify it before trusting any distance-based command.
  • Gyro reads positive when turning counterclockwise (WPILib convention). Rotate the robot counterclockwise by hand and confirm the heading increases. If it decreases, negate the gyro output in your getHeadingDegrees() method. An inverted gyro causes turn commands to drive away from their target instead of toward it.
  • Beam break / limit switch reads the correct state when untriggered. Many beam breaks and limit switches read true when not tripped and false when tripped (normally-closed wiring). If your hasPiece() method returns true when the sensor is clear, the command finishes immediately without running the intake. Check the datasheet and invert with ! if needed.
  • Test each command individually with a timeout before combining into a sequence. Run new DriveDistanceCommand(m_drive, 1.0, 0.4).withTimeout(3.0) as the only auto command first. Confirm the robot travels approximately 1 meter and stops. Only then add the next command. Testing individual steps before combining them is what championship teams do and everyone else skips.

Knowledge Check

1. A DriveDistanceCommand resets encoders in initialize(). The same command is used twice in a sequence — once to drive 2 m forward, and again (later) to drive 1.5 m forward. Why is resetting in initialize() essential for this reuse to work correctly?

  • A Because the encoder accumulates noise between uses and must be cleared to prevent false readings
  • B Because after the first use the encoder reads approximately 2.0 m — if not reset, the second command's isFinished() would compare against a starting value of 2.0 m, finishing immediately or requiring 3.5 m total travel instead of 1.5 m
  • C Because WPILib automatically resets encoders between commands in a sequence
  • D Because encoders overheat if not reset between uses

2. A TurnToAngleCommand uses PIDController.atSetpoint() for isFinished() with setTolerance(2.0, 5.0). The robot reaches 89° (target: 90°) but is still rotating at 8°/s. What does the command do?

  • A Finishes immediately because 89° is within 2° of the 90° target
  • B Continues running because although the position tolerance is met (within 2°), the velocity tolerance is not met (8°/s exceeds the 5°/s threshold) — atSetpoint() requires both conditions
  • C Throws an exception because the velocity exceeded the tolerance
  • D Finishes and leaves motors at their current output since the command ends before end() is called

3. A robot's drivetrain uses TalonFX motors (integrated encoders reporting in rotations) with a 6-inch wheel and a 7.5:1 gear ratio. A programmer implements DriveDistanceCommand but the robot consistently drives exactly 7.5× further than commanded. What is the most likely cause?

  • A The PID straightening gains are too high, causing the robot to spiral forward
  • B The gear ratio is not applied in the distance conversion — the encoder reports motor shaft rotations, and without dividing by 7.5, each motor rotation (not wheel rotation) is counted as one unit of distance, making every command 7.5× too large
  • C The encoder is set to count in the reverse direction
  • D The wheel diameter is set in inches but the target distance is in meters
💪 Practice Prompt

Build and Verify a Three-Command Sensor-Based Sequence

  1. Use the encoder calculator above to compute the correct setDistancePerPulse() value for your drivetrain's actual hardware. Add it to your DriveSubsystem constructor. Physically verify it by rolling the robot exactly one wheel circumference by hand and confirming the encoder reads approximately 1 wheel rotation worth of distance in your chosen units.
  2. Implement DriveDistanceCommand as shown in this lesson, including the heading-correction PID. Add SmartDashboard output in execute() that logs current distance, target distance, heading, and heading error every loop. Deploy and run with the robot elevated — confirm the encoder readings increase as the wheels spin and that isFinished() triggers at the right value.
  3. Implement TurnToAngleCommand. Add a SmartDashboard number for current heading and a boolean for atSetpoint(). Test with a target of your robot's current heading + 90°. Does the robot land within 2° of the target? If it overshoots, reduce kP. If it never reaches tolerance and oscillates, reduce kP or increase the position tolerance.
  4. Assemble a sequence: drive 1.5 m → turn 90° right → drive 0.75 m. Add .withTimeout() to each step. Run it three times on carpet and measure the final landing position each time. Record the variance. Compare it qualitatively to the timed routine you ran in Lesson 2. Is the landing position more repeatable?
  5. Bonus: Add a DriveSubsystem method called getAverageDistanceMeters() that averages left and right encoder readings and returns the value in meters. Explain in a code comment why averaging both sides is more accurate than using only one encoder for the distance check, and what physical scenario would cause the left and right readings to diverge significantly.