498
Control Lab FRC Programming Curriculum
Control Systems · L12 of 15

Elevator Position Control

Prereqs: control-systems-07
Objectives 0 / 5

Hook

You command the elevator to 1.0 meter. It goes to 0.87 meters instead — consistently. You tune kP up. Now it reaches 1.0 m but oscillates. You add more feedforward. It overshoots. The underlying issue is not the gains — it is that the encoder reads in motor rotations and nobody converted them to meters. Every setpoint comparison is off, and the PID has been fighting a unit mismatch the entire time. Elevator control is straightforward once units are right and the mechanism is properly zeroed.

Core concept

Key Concept

An elevator needs four feedforward terms to model its physics: kS to overcome static friction, kG to hold position against gravity (constant — does not depend on height), kV to supply back-EMF voltage during motion, and kA for acceleration. Together these produce a voltage that closely tracks the desired motion, leaving PID only small corrections.

ElevatorFeedforward constants

ConstantMeaningTypical range
kSVoltage to overcome static friction (direction-signed)0.05–0.3 V
kGVoltage to hold the elevator at rest against gravity0.3–0.8 V
kVVoltage per unit velocity (back-EMF)1–4 V·s/m
kAVoltage per unit acceleration0.01–0.15 V·s^2/m

The feedforward equation:

voltage = kS * sign(velocity) + kG + kV * velocity + kA * acceleration

Note that kG has no position dependence — it is always added (for a single-stage elevator that goes straight up). Whether the carriage is at the bottom or at the top, gravity pulls it down with the same force.

At rest (holding position)

voltage = kG   (kS = 0 because sign(0) = 0, kV = 0, kA = 0)

The motor must supply exactly kG volts to keep the carriage from sliding down.

During upward motion at constant velocity

voltage = kS + kG + kV * velocity

kS overcoming starting friction, kG fighting gravity, kV matching the back-EMF load.

Using WPILib

// kS=0.12, kG=0.48, kV=2.9, kA=0.06
ElevatorFeedforward ff = new ElevatorFeedforward(0.12, 0.48, 2.9, 0.06);

// calculate() takes velocity and optionally acceleration
double voltage = ff.calculate(targetVelocityMetersPerSec);
// or, for a profiled controller:
double voltage = ff.calculate(currentVelocity, nextVelocity, dtSeconds);
Note

The three-argument ff.calculate(currentVel, nextVel, dt) estimates acceleration as (nextVel - currentVel) / dt and includes the kA term. This is more accurate during acceleration phases. The one-argument form assumes zero acceleration.

Unit conversion — the most important step

Motor encoders report rotations (or sometimes native ticks). You want meters of carriage travel.

meters = motorRotations * (spoolCircumference / gearRatio)

Where:

  • spoolCircumference = 2 * pi * spoolRadius (in meters)
  • gearRatio = motor rotations per spool rotation (e.g., 12.0 for a 12:1 gearbox)
double spoolRadiusMeters = 0.0254;   // 1 inch spool radius
double gearRatio = 12.0;             // 12:1 reduction
double rotToMeters = (2 * Math.PI * spoolRadiusMeters) / gearRatio;

encoder.setPositionConversionFactor(rotToMeters);       // position in meters
encoder.setVelocityConversionFactor(rotToMeters / 60);  // velocity in m/s (encoder gives RPM)

After this, encoder.getPosition() returns meters and encoder.getVelocity() returns meters per second. All setpoints, feedforward constants, and PID calculations can now use real physical units.

⚠ Heads up

Skipping the velocity conversion factor is a silent bug. getVelocity() returns meters per minute if you apply only the position factor (which divides by 2pir but not by 60 for the per-minute → per-second conversion). Your kV will be off by a factor of 60 and the feedforward will be wildly wrong.

Zeroing on a limit switch

Relative encoders (e.g., the built-in NEO encoder) accumulate error over time and have no memory of position after power cycle. You need a zero reference — a known physical position where you reset the encoder to 0.

The standard approach is a home limit switch at the bottom of the elevator travel. On robot initialization:

  1. Drive the elevator downward slowly.
  2. When the limit switch triggers, stop the motor and reset the encoder to 0.
  3. All subsequent position commands are relative to this zero.
DigitalInput bottomLimit = new DigitalInput(1);

// In a zeroing command or state machine:
public void runZeroing() {
    if (!bottomLimit.get()) {
        // Switch not triggered — creep downward
        motor.setVoltage(-1.5);
    } else {
        // Bottom reached — zero the encoder
        motor.setVoltage(0);
        encoder.setPosition(0.0);
        isZeroed = true;
    }
}
Note

DigitalInput.get() returns true when the switch is OPEN (not pressed) and false when CLOSED (pressed) — for a normally-open switch wired to ground. Many FRC limit switches are wired this way. Always check your wiring and verify by watching the SmartDashboard value while manually pressing the switch.

Zeroing on enable vs. every match

Some teams re-zero every time the robot enables (reliable but costs ~1 second). Others zero once in the pits and rely on the encoder holding position (risky if the robot is powered off between matches with the elevator not at the bottom). The safest strategy: always zero on robot enable before running any autonomous or teleop setpoints.

Soft limits vs. hard limits

TypeDescriptionWhen it stops
Hard limitPhysical stop — bolt, bearing block, frame memberWhen the mechanism crashes into it
Soft limit (code)Check in periodic() that blocks voltage when outside allowed rangeBefore the hard limit is reached
Motor controller soft limitSame concept, enforced in firmwareBefore hard limit, even if robot code freezes

You want all three layers:

// --- Soft limit in code ---
double MAX_HEIGHT = 1.5;   // meters
double MIN_HEIGHT = 0.0;   // meters

double voltage = ff.calculate(sp.velocity) + pid.calculate(measurement, sp.position);

if (encoder.getPosition() >= MAX_HEIGHT && voltage > 0) voltage = 0;
if (encoder.getPosition() <= MIN_HEIGHT && voltage < 0) voltage = 0;

motor.setVoltage(voltage);
// --- Motor controller soft limit ---
motor.setSoftLimit(CANSparkMax.SoftLimitDirection.kForward, (float)(MAX_HEIGHT / rotToMeters));
motor.setSoftLimit(CANSparkMax.SoftLimitDirection.kReverse, 0.0f);
motor.enableSoftLimit(CANSparkMax.SoftLimitDirection.kForward, true);
motor.enableSoftLimit(CANSparkMax.SoftLimitDirection.kReverse, true);

Profiled motion for smooth height changes

A step change in setpoint causes the elevator to accelerate instantly — stressing the chain, sprocket, and gearbox, and causing the carriage to bounce on overshoot. Use ProfiledPIDController:

ProfiledPIDController controller = new ProfiledPIDController(
    8.0, 0, 0,
    new TrapezoidProfile.Constraints(1.5, 3.0)  // max 1.5 m/s, 3 m/s^2
);

// New height command
controller.setGoal(new TrapezoidProfile.State(targetMeters, 0.0));

// In periodic
double measurement = encoder.getPosition();
double pidVolts = controller.calculate(measurement);
TrapezoidProfile.State sp = controller.getSetpoint();

// ElevatorFeedforward only needs velocity (not position)
double ffVolts = ff.calculate(sp.velocity);

motor.setVoltage(pidVolts + ffVolts);

Common pitfalls

1. Wrong motor direction

The positive direction of your motor must match the positive direction of your encoder. If you set motor.setInverted(true) but not encoder.setInverted(true) (or vice versa), the feedback loop is negative — the more voltage you apply, the further from the setpoint you get. This causes runaway.

Test: command the elevator manually at low power. Verify that positive voltage → positive encoder reading → upward physical motion. Fix inversions until all three agree.

2. Forgetting gear ratio

A 12:1 gearbox means the motor spins 12 times per one spool rotation. If you use encoder.getPosition() without dividing by the gear ratio, your “1 meter” setpoint is actually 12 meters of motor travel — the elevator would slam into the top of the frame.

3. Using the profiler before zeroing

If the elevator isn’t zeroed yet and you call controller.setGoal(0.0), the profiler tries to move the elevator to encoder position 0 — which may be wherever the encoder currently reads (e.g., 3.2 rotations from a previous session). Always complete the zeroing sequence before enabling position control.

4. Two motors, one encoder

If your elevator uses two motors mechanically coupled (common in FRC), designate one as the leader and the other as a follower. Only PID/feedforward from the leader. The follower mirrors the leader’s output:

CANSparkMax leader   = new CANSparkMax(1, MotorType.kBrushless);
CANSparkMax follower = new CANSparkMax(2, MotorType.kBrushless);
follower.follow(leader, /* invert = */ true);  // invert if motors face opposite directions

Full elevator subsystem

public class ElevatorSubsystem extends SubsystemBase {
    private final CANSparkMax leader   = new CANSparkMax(1, MotorType.kBrushless);
    private final CANSparkMax follower = new CANSparkMax(2, MotorType.kBrushless);
    private final RelativeEncoder encoder = leader.getEncoder();
    private final DigitalInput bottomLimit = new DigitalInput(1);

    private static final double SPOOL_RADIUS = 0.0254;   // m
    private static final double GEAR_RATIO   = 12.0;
    private static final double ROT_TO_M = (2 * Math.PI * SPOOL_RADIUS) / GEAR_RATIO;
    private static final double MAX_HEIGHT_M = 1.5;

    private final ElevatorFeedforward ff = new ElevatorFeedforward(0.12, 0.48, 2.9, 0.06);
    private final ProfiledPIDController controller = new ProfiledPIDController(
        8.0, 0, 0,
        new TrapezoidProfile.Constraints(1.5, 3.0)
    );

    private boolean zeroed = false;

    public ElevatorSubsystem() {
        follower.follow(leader, true);
        encoder.setPositionConversionFactor(ROT_TO_M);
        encoder.setVelocityConversionFactor(ROT_TO_M / 60.0);
        controller.setTolerance(0.01);
    }

    public void zero() {
        encoder.setPosition(0.0);
        controller.reset(0.0);
        zeroed = true;
    }

    public boolean isAtBottom() { return !bottomLimit.get(); }

    public void setHeight(double meters) {
        if (!zeroed) return;
        double clamped = MathUtil.clamp(meters, 0.0, MAX_HEIGHT_M);
        controller.setGoal(new TrapezoidProfile.State(clamped, 0.0));
    }

    public boolean atGoal() { return zeroed && controller.atGoal(); }

    @Override
    public void periodic() {
        if (!zeroed) return;

        double measurement = encoder.getPosition();
        double pidVolts = controller.calculate(measurement);
        TrapezoidProfile.State sp = controller.getSetpoint();
        double ffVolts = ff.calculate(sp.velocity);
        double total = pidVolts + ffVolts;

        // Soft limits
        if (measurement <= 0.0 && total < 0) total = 0;
        if (measurement >= MAX_HEIGHT_M && total > 0) total = 0;

        leader.setVoltage(total);
    }
}

Key takeaways

  • ElevatorFeedforward uses kS (static friction), kG (constant gravity hold), kV (velocity/back-EMF), and kA (acceleration inertia).
  • kG is constant — it does not change with elevator height, unlike ArmFeedforward where kG scales with cos(angle).
  • Always apply both a position and velocity conversion factor so encoder values are in meters and m/s.
  • Zero the encoder on the limit switch every time the robot initializes before running position control.
  • Use soft limits in code AND on the motor controller for defense-in-depth against code bugs.
  • Dual-motor elevators use .follow() on the secondary motor — only one encoder and one control loop.

Common confusions

“My elevator oscillates at the top but not the bottom.” At the top the PID error is near zero, but kG is pushing the carriage against gravity — small encoder noise causes repeated small corrections. Reduce kP, increase kG accuracy, or add a small deadband: if |error| < 0.01 m, output only kG.

“The elevator creeps upward when I command it to hold.” kG is too high. Tune kG by disabling PID and finding the voltage that barely holds the elevator still at mid-travel. That is your kG.

“After zeroing, my encoder reads 0 but the carriage is 5 cm above the floor.” Your limit switch is mounted 5 cm above the true bottom. Either adjust the mount or add an offset: encoder.setPosition(0.05) when the switch triggers.

Challenge

⚡ Try it yourself

Compute the total feedforward voltage for an elevator with: kS = 0.10 V, kG = 0.50 V, kV = 3.0 Vs/m, kA = 0.08 Vs^2/m

Evaluate at three motion states:

  1. Holding still at any height (velocity = 0, acceleration = 0)
  2. Moving up at 0.5 m/s constant velocity (acceleration = 0)
  3. Accelerating upward at 0.5 m/s with acceleration = 2.0 m/s^2

Formula: voltage = kS * sign(velocity) + kG + kV * velocity + kA * acceleration (sign(0) = 0 by convention)

Code EditorJavaCtrl+Enter to run
Stuck? Show hint

For 'hold': sign(0) = 0, so only kG = 0.50. For 'cruise': kS*1 + kG + kV*0.5 + kA*0 = 0.10 + 0.50 + 1.50 = 2.10. For 'accel': 0.10 + 0.50 + 1.50 + 0.08*2.0 = 2.26.

⚡ Check your understanding

You apply the position conversion factor (ROT_TO_M) to your NEO encoder but forget to apply a velocity conversion factor. What does encoder.getVelocity() return?

What’s next

The next lesson shows how to measure feedforward constants experimentally using WPILib’s SysId tool, so you don’t have to guess kS, kV, and kA — you can derive them from real data collected on your robot.