Configuring PID Controllers for Drivetrain

So the team I was working with last weekend is running a standard tank drive and was looking to use two encoders and a gyro to provide more reliable autonomous movement. What we ended up something like this.

DriveTrain Subsystem Class


public class Drivetrain extends Subsystem {
	
	private Talon frontLeft = new Talon(RobotMap.DRIVETRAIN_FRONT_LEFT);
	private Talon frontRight = new Talon(RobotMap.DRIVETRAIN_FRONT_RIGHT);
	private Talon rearLeft = new Talon(RobotMap.DRIVETRAIN_REAR_LEFT);
	private Talon rearRight = new Talon(RobotMap.DRIVETRAIN_REAR_RIGHT);
	
	private Encoder leftEncoder = new Encoder(RobotMap.DRIVETRAIN_ENCODER_LEFT_A, RobotMap.DRIVETRAIN_ENCODER_LEFT_B);
	private Encoder rightEncoder = new Encoder(RobotMap.DRIVETRAIN_ENCODER_RIGHT_A, RobotMap.DRIVETRAIN_ENCODER_RIGHT_B);
	
	private ADXRS450_Gyro gyro = new ADXRS450_Gyro(SPI.Port.kOnboardCS0);
	
	private RobotDrive drive = new RobotDrive(frontLeft, rearLeft, frontRight, rearRight);
	
    public void initDefaultCommand() {
    	final double WHEEL_CIRCUMFERENCE = 0.1524 * Math.PI; // 6 inch diameter wheel
    	final double GEAR_RATIO = 4.0; // Reduction from encoder shaft and output shaft
    	final double PULSES_PER_REVOLUTION = 1440.0; // Number of encoder counts per revolution
 
    	leftEncoder.setDistancePerPulse(WHEEL_CIRCUMFERENCE * GEAR_RATIO / PULSES_PER_REVOLUTION);
    	rightEncoder.setDistancePerPulse(WHEEL_CIRCUMFERENCE * GEAR_RATIO / PULSES_PER_REVOLUTION);

    	drive.setSafetyEnabled(true);
    	
        setDefaultCommand(new DrivetrainArcadeDrive());
    }
    
    public void stop() {
    	setLeftMotors(0.0);
    	setRightMotors(0.0);
    }
    
    public void arcadeDrive(double move, double rotate) {
    	drive.arcadeDrive(move, rotate);
    }
    
    public void setLeftMotors(double speed) {
    	frontLeft.set(speed);
    	rearLeft.set(speed);
    }
    
    public void setRightMotors(double speed) {
    	frontRight.set(speed);
    	rearRight.set(speed);
    }
    
    public void rotate(double speed) {
    	drive.arcadeDrive(0, speed);
    }
    
    // Get Sensor Values
    
    public void resetLeftEncoder() {
    	leftEncoder.reset();
    }
    
    public void resetRightEncoder() {
    	rightEncoder.reset();
    }
    
    public void resetGyro() {
    	gyro.reset();
    }
    
    public double getAngle() {
    	return gyro.getAngle();
    }
    
    public double getLeftDistance() {
    	return leftEncoder.getDistance();
    }
    
    public double getRightDistance() {
    	return rightEncoder.getDistance();
    }
    
}

DriveToDistanceCommand (use encoders to drive roughly straight to distance)


public class DrivetrainForwardToDistance extends Command {
	private double distance;
	private double tolerence;
	
	private PIDController leftMotorController;
	private PIDController rightMotorController;
	
	// distance : the distance in meters to move
	// tolerance: the distance in meters of acceptable error
    public DrivetrainForwardToDistance(double distance, double tolerance) {
    	this.distance = distance;
    	this.tolerence = tolerance;
        requires(Robot.drivetrain);
    }

    // Called just before this Command runs the first time
    protected void initialize() {
    	Robot.drivetrain.resetLeftEncoder();
    	Robot.drivetrain.resetRightEncoder();
    	
    	PIDSource leftMotorSource = new PIDSource() {		
			@Override
			public void setPIDSourceType(PIDSourceType pidSource) {}
			@Override
			public double pidGet() {
				return Robot.drivetrain.getLeftDistance();
			}
			@Override
			public PIDSourceType getPIDSourceType() {
				return PIDSourceType.kDisplacement;
			}
		};
		
		PIDSource rightMotorSource = new PIDSource() {		
			@Override
			public void setPIDSourceType(PIDSourceType pidSource) {}
			@Override
			public double pidGet() {
				return Robot.drivetrain.getLeftDistance();
			}
			@Override
			public PIDSourceType getPIDSourceType() {
				return PIDSourceType.kDisplacement;
			}
		};
		
		PIDOutput leftMotorOutput = new PIDOutput() {
			@Override
			public void pidWrite(double output) {
				Robot.drivetrain.setLeftMotors(output);
			}
		};
		
		PIDOutput rightMotorOutput = new PIDOutput() {
			@Override
			public void pidWrite(double output) {
				Robot.drivetrain.setRightMotors(output);
			}
		};
		
		final double Kp = 0.3;
		final double Ki = 0.0;
		final double Kd = 0.001;
		
		leftMotorController = new PIDController(Kp, Ki, Kd, leftMotorSource, leftMotorOutput);
		rightMotorController = new PIDController(Kp, Ki, Kd, rightMotorSource, rightMotorOutput);
		
		leftMotorController.setAbsoluteTolerance(tolerence);
		rightMotorController.setAbsoluteTolerance(tolerence);
		
		final double MIN_SPEED = 0.1;
		final double MAX_SPEED = 0.5;
		
		if (distance > 0) {
			leftMotorController.setOutputRange(MIN_SPEED, MAX_SPEED);
			rightMotorController.setOutputRange(MIN_SPEED, MAX_SPEED);
		}
		else {
			leftMotorController.setOutputRange(-MIN_SPEED, -MAX_SPEED);
			rightMotorController.setOutputRange(-MIN_SPEED, -MAX_SPEED);
		}
		
		leftMotorController.setSetpoint(distance);
		rightMotorController.setSetpoint(distance);
		
		leftMotorController.enable();
		rightMotorController.enable();
    }

    // Called repeatedly when this Command is scheduled to run
    protected void execute() {
    	SmartDashboard.putNumber("Left Distance", Robot.drivetrain.getLeftDistance());
    	SmartDashboard.putNumber("Right Distance", Robot.drivetrain.getRightDistance());
    }

    // Make this return true when this Command no longer needs to run execute()
    protected boolean isFinished() {
        return leftMotorController.onTarget() && rightMotorController.onTarget();
    }

    // Called once after isFinished returns true
    protected void end() {
    	leftMotorController.disable();
    	rightMotorController.disable();
    	Robot.drivetrain.stop();
    }

    // Called when another command which requires one or more of the same
    // subsystems is scheduled to run
    protected void interrupted() {
    	end();
    }
}

RotateToAngle (use gyro to rotate to certain angle)


public class DrivetrainRotateToAngle extends Command {

	private double angle;
	private double tolerance;
	
	private PIDController angleMotorController;
	
    public DrivetrainRotateToAngle(double angle, double tolerance) {
    	this.angle = angle;
    	this.tolerance = tolerance;
        requires(Robot.drivetrain);
    }

    // Called just before this Command runs the first time
    protected void initialize() {
    	Robot.drivetrain.resetGyro();
    	
    	PIDSource angleSource = new PIDSource() {		
			@Override
			public void setPIDSourceType(PIDSourceType pidSource) {}
			@Override
			public double pidGet() {
				return Robot.drivetrain.getAngle();
			}
			@Override
			public PIDSourceType getPIDSourceType() {
				return PIDSourceType.kDisplacement;
			}
		};
		
		PIDOutput angleOutput = new PIDOutput() {
			@Override
			public void pidWrite(double output) {
				Robot.drivetrain.rotate(output);
			}
		};
		
		final double Kp = 0.3;
		final double Ki = 0.01;
		final double Kd = 0.05;
		
		angleMotorController = new PIDController(Kp, Ki, Kd, angleSource, angleOutput);
		
		angleMotorController.setAbsoluteTolerance(tolerance);
		
		final double MIN_SPEED = 0.5;
		final double MAX_SPEED = 1.0;
		
		if (angle > 0) {
			angleMotorController.setOutputRange(MIN_SPEED, MAX_SPEED);
		}
		else {
			angleMotorController.setOutputRange(-MIN_SPEED, -MAX_SPEED);
		}
		
		angleMotorController.setSetpoint(angle);
		angleMotorController.enable();
    }

    // Called repeatedly when this Command is scheduled to run
    protected void execute() {
    	SmartDashboard.putNumber("Angle", Robot.drivetrain.getAngle());
    }

    // Make this return true when this Command no longer needs to run execute()
    protected boolean isFinished() {
        return angleMotorController.onTarget();
    }

    // Called once after isFinished returns true
    protected void end() {
    	angleMotorController.disable();
    	Robot.drivetrain.stop();
    }

    // Called when another command which requires one or more of the same
    // subsystems is scheduled to run
    protected void interrupted() {
    	end();
    }
}

We had some success with this code, but it is still far from perfect.

Questions:

  1. Is this a recommended way to have multiple PID controllers on a single subsystem?
  2. Do I need to have multiple PID controllers to get this functionality (a command to turn precisely and a command to drive to distance)? Is there a more simple and elegant way possibly utilizing the RobotDrive.arcadeDrive() method I am overlooking?
  3. How do you add the controller to the Smart Dashboard so that the gains can be tuned without the need to re-deploy code on every change?
  4. Can you give an example explaining what the PIDController.setContinuous() method does? I have read the Javadoc, but still don’t understand how it could be useful.

I think I can answer the easier questions:

How do you add the controller to the Smart Dashboard so that the gains can be tuned without the need to re-deploy code on every change?


SmartDashboard.putData("Left Encoder PID", leftMotorController);

Can you give an example explaining what the PIDController.setContinuous() method does? I have read the Javadoc, but still don’t understand how it could be useful.

This is typically used for things like a rotation PID when your Gyro “wraps” around (goes from 360 to 0 and vice versa). If the PID has continuous enabled and the robot it pointing at 10 degrees and you tell it to rotate to 350, if will take the short way and rotate 20 degrees. If continuous were false, it would take the long way and rotate 340 degrees.

As far as the other two questions, they are a bit subjective.

Is this a recommended way to have multiple PID controllers on a single subsystem?

I think this is a reasonable approach to move the PIDController out of the Subsystem and into the Command as you have done. It keeps the Subsystem simpler and cleaner (just provides access to the motors) while allowing you to experiment with different PIDControllers. If you don’t like one of your implementations, its no big loss, just delete the Command and the Subsystem doesn’t need to change.

Do I need to have multiple PID controllers to get this functionality (a command to turn precisely and a command to drive to distance)? Is there a more simple and elegant way possibly utilizing the RobotDrive.arcadeDrive() method I am overlooking?

Motion profiling is often recommended for creating curved paths. Unfortunately, that is a non-trivial subject and one that I’m not able to offer help on. We still typically join multiple commands together into a command group on our team (drive X feet, turn Y degrees, drive Z feet, …).

However, with your drive distance PID controller, you could start tweaking the PIDOutput values. For example, if you notice that your robot drifts/shifts when you tell it to drive straight a certain distance, you might be able to straighten out a bit by experimenting with your pidWrite() methods to tweak the output values slightly based on feedback from the gyro or difference between the encoder counts.

As far as multiple PID controllers goes, a method that has been working for me is to put a distance and an angle PIDController in the same “drivetrain” subsystem. The distance PIDController, reading from the average of the encoders, writes its output (-1.0 to 1.0) to a variable, and then the angle PIDController (with a gyro as input) writes its output plus the speed variable to drivetrain.setLeftRightMotorOutputs(). I’m sure this is not the ideal, cleanest solution, but it seems to work.

This has been working for keeping the robot on a straight heading as it drives, but it could also in theory allow for driving along an arc of a set radius. Also something to note, drivetrain.arcadeDrive() seemed to take enough time to complete per call that it hampered the angle PID’s ability to settle at an angle. Using the simpler setLeftRightMotorOutputs() keeps the motor output safely within the -1.0 -> 1.0 bounds, while allowing for good performance. Hope this helps!