Unit 8 · Lesson 8

On-Controller PID vs roboRIO PID

You have two options for where a PID loop runs: on the roboRIO using WPILib's PIDController, or onboard the motor controller itself using Phoenix 6's Slot0 gains or SparkMax's closed-loop API. The choice is not arbitrary — it is an engineering decision based on control loop frequency, CAN bus latency, and how much live tunability you need. This lesson gives you the mental model to make that decision correctly for any mechanism.

By the end of this lesson, you will:

  • Trace the full signal path for a roboRIO PID loop and identify where CAN latency enters
  • Trace the full signal path for an onboard PID loop and explain why latency is eliminated
  • State the update rate difference: 50 Hz (roboRIO) vs 1 kHz (onboard TalonFX)
  • Configure Phoenix 6 Slot0 gains and issue a PositionVoltage or VelocityVoltage request
  • Use the decision framework to choose the correct approach for a given mechanism
  • Implement "at-target" detection without WPILib's atSetpoint() when using onboard PID

The Signal Path: Where the Loop Lives Determines Its Speed

Every PID loop has a feedback path — a chain of events that happen once per cycle. For a velocity controller, that chain is: read current speed → compute error → compute correction → apply voltage. The time this chain takes is the loop period. A shorter loop period means faster correction of disturbances.

The difference between roboRIO and onboard PID is entirely about where in the physical hardware that chain runs.

Feedback loop signal path — roboRIO PID (top) vs onboard PID (bottom)
roboRIO PID — 50 Hz loop, two CAN trips per cycle TalonFX (encoder read) CAN ~1ms roboRIO PIDController.calculate() 50 Hz (20 ms window) CAN ~1ms TalonFX (apply voltage) ~22 ms total CAN roboRIO compute (20ms) CAN Onboard PID — 1 kHz loop, no CAN in feedback path TalonFX Motor Controller Encoder read PID compute 1 kHz = 1 ms Apply voltage roboRIO sends setpoint only (50 Hz) not in feedback path setpoint via CAN ~1 ms total
The roboRIO loop: encoder data travels over CAN to the roboRIO (~1 ms), the PID runs on the roboRIO CPU at 50 Hz (20 ms window), and the correction travels back over CAN (~1 ms). Total feedback path: ~22 ms. The onboard loop: encoder, PID math, and voltage output all live on the same TalonFX chip. No CAN in the feedback path. Total: ~1 ms. The roboRIO still sends the setpoint, but only needs to do so at 50 Hz — not at 1 kHz.

Comparing the Two Approaches

PropertyroboRIO PIDControllerOnboard (Phoenix 6 Slot0)
Loop rate 50 Hz (20 ms) 1 kHz (1 ms) — 20× faster
CAN in feedback path Yes — two trips per cycle (~2 ms latency) No — encoder and PID on same chip
Live gain tuning Easy — SmartDashboard sliders, no redeploy Requires reconfiguration via Phoenix Tuner X or code redeploy
atSetpoint() equivalent Built-in — m_pid.atSetpoint() Manual — read ClosedLoopError signal from motor controller
WPILib integration Full — logging, simulation, SysId integration Partial — must manually log StatusSignals
Remote sensor support Via CAN (slower) CANcoder as remote feedback at 1 kHz (Phoenix Pro)
Best for Arm position, elevator height, turn-to-angle, any slow mechanism Swerve steering, swerve drive, shooter flywheel, any fast/high-bandwidth mechanism

Decision Framework

Select a mechanism to see the recommendation and reasoning.

Where should PID run? — select a mechanism
← select a mechanism

Phoenix 6 Onboard PID: Slot0 Configuration

You have already used onboard PID — both the swerve steering and swerve drive controllers from Unit 7 run on the TalonFX at 1 kHz. The pattern is the same for any mechanism. Gains go in the configuration object's Slot0, and a typed control request triggers the loop.

Same velocity PID — two ways
// roboRIO PID — 50 Hz, CAN in feedback path
private final TalonFX m_motor = new TalonFX(Constants.kMotorId);
private final PIDController m_pid = new PIDController(0.001, 0, 0);
private final StatusSignal<Double> m_velSig = m_motor.getVelocity();

public void setVelocityRPS(double targetRPS) {
  // Read encoder over CAN (trip 1)
  double currentRPS = m_velSig.refresh().getValueAsDouble();

  // Compute PID on roboRIO
  double pidOut = m_pid.calculate(currentRPS, targetRPS);

  // Send correction over CAN (trip 2)
  m_motor.setVoltage(MathUtil.clamp(pidOut, -12, 12));
}

public boolean isAtTarget() {
  return m_pid.atSetpoint(); // built-in tolerance check
}
← click a highlighted token
// Onboard PID — 1 kHz, no CAN in feedback path
private final TalonFX m_motor = new TalonFX(Constants.kMotorId);

// Control request — reused each loop (no GC)
private final VelocityVoltage m_req =
    new VelocityVoltage(0).withSlot(0);

// One-time configuration in constructor
private void configureMotor() {
  TalonFXConfiguration cfg = new TalonFXConfiguration();
  // Slot0: PID + feedforward gains, all computed onboard at 1 kHz
  cfg.Slot0
    .withKP(0.5)  // V per RPS of error
    .withKI(0.0)
    .withKD(0.0)
    .withKV(0.12) // feedforward: V·s/rot
    .withKS(0.25);// feedforward: static friction V
  m_motor.getConfigurator().apply(cfg);
}

// In execute() — roboRIO only sends setpoint
public void setVelocityRPS(double targetRPS) {
  m_motor.setControl(m_req.withVelocity(targetRPS));
  // Motor controller handles PID at 1 kHz autonomously
  // roboRIO does NOT call this loop at 1 kHz — just updates setpoint at 50 Hz
}
← click a highlighted token
// ── At-target without atSetpoint() ────────────────────
// When using onboard PID, you must read the motor controller's
// closed-loop error signal manually. No m_pid.atSetpoint() available.

// Cache the closed-loop error signal (units match your Slot0 units)
private final StatusSignal<Double> m_clError =
  m_motor.getClosedLoopError();

public boolean isAtTarget() {
  double error = Math.abs(m_clError.refresh().getValueAsDouble());
  return error < Constants.kToleranceRPS;
}

// ── Velocity-only check (simpler, no velocity tolerance) ──
private final StatusSignal<Double> m_velSig =
  m_motor.getVelocity();

public boolean isNearTarget(double targetRPS) {
  double actual = m_velSig.refresh().getValueAsDouble();
  return Math.abs(actual - targetRPS) < Constants.kToleranceRPS;
}

// ── Using in a command ────────────────────────────────────
// Commands.waitUntil(() -> m_shooter.isNearTarget(targetRPS))
// .andThen(new ShootCommand(m_shooter, m_intake))
← click a highlighted token
💡 Slot0 includes both PID and feedforward — they run together at 1 kHz

Phoenix 6's Slot0 configuration accepts both PID gains (kP, kI, kD) and feedforward gains (kV, kS, kA). When you issue a VelocityVoltage request, the TalonFX simultaneously applies kS·sgn(v) + kV·v (feedforward) and kP·error + kI·∫error + kD·d(error)/dt (feedback) at 1 kHz. This is the exact combined FF+PID approach from Lesson 10 — but running entirely onboard, with 20× higher frequency than any roboRIO implementation. This is why swerve drive velocity control is so responsive with Phoenix 6.

The Hybrid Pattern: FF on roboRIO, Small kP Onboard

A third option combines the strengths of both locations. For mechanisms where feedforward is doing most of the work (90%+) and only a small corrective P term is needed:

  • Run SimpleMotorFeedforward.calculate() on the roboRIO — this is pure arithmetic, no feedback needed, so latency doesn't matter
  • Add a small kP in Phoenix 6's Slot0 or as a separate DutyCycleOut command with a manually computed correction
  • The result: physics-accurate feedforward at 50 Hz accuracy + 1 kHz micro-corrections from onboard P

This is actually how 2910's swerve drive is configured: VelocityVoltage with onboard kV and kS (feedforward) plus a small onboard kP, all running at 1 kHz. The roboRIO sends the velocity setpoint at 50 Hz; the motor controller's onboard loop corrects for small deviations 20 times faster.

🔍 LRI Perspective: "Every swerve module on our robot already uses onboard PID — you've been using it since Unit 7"

When you configured cfg.Slot0.withKP(0.1).withKV(0.12).withKS(0.25) in the SwerveModule constructor and issued a VelocityVoltage request, you were configuring and using onboard PID with feedforward — running at 1 kHz inside the TalonFX. The roboRIO never touched the velocity feedback loop. It only sent the setpoint once per 20 ms loop. This is the correct architecture for any mechanism that needs fast, consistent velocity control. For arm position and elevator height, the slower roboRIO loop with WPILib's PIDController is fine — and gives you better logging and live tuning tools. Know which mechanisms need which location, and configure accordingly.

🔌 System Check — Verifying Onboard PID is Running

After configuring Slot0 and issuing a VelocityVoltage request:

  • Open Phoenix Tuner X in Plot mode. Connect to the robot and select the TalonFX. The "Closed Loop Error" signal should read near zero when the mechanism is at its target velocity. If it reads the full setpoint value (e.g., error = target when target is 80 RPS), the onboard PID is not controlling — check that you issued a VelocityVoltage request with the correct slot number, not a DutyCycleOut or PercentOutput.
  • Watch the "Closed Loop Output" signal — it should show the combined feedforward + PID output voltage. If it shows only the feedforward (kV × velocity) with no additional correction, kP is zero or the error signal is zero from the start (mechanism already at target when the test began).
  • Increase kP by 50% and confirm the steady-state velocity error shrinks. If increasing kP increases oscillation without reducing error, the mechanism may have significant inertia — add kD or reduce kP and accept the small steady-state error.

Knowledge Check

1. A swerve steering motor uses onboard PID (Phoenix 6 Slot0, PositionVoltage request). The roboRIO's 50 Hz loop sends new setpoints. How many times per second does the steering PID actually run its correction cycle?

  • A 50 Hz — limited by the roboRIO's control loop.
  • B 100 Hz — onboard PID runs at twice the roboRIO rate.
  • C 1,000 Hz (1 kHz) — the TalonFX runs its onboard PID loop every 1 ms regardless of how often the roboRIO sends setpoints. The roboRIO updates the target at 50 Hz, but the motor controller corrects toward that target 20 times between each setpoint update.
  • D 200 Hz — Phoenix 6 runs at 5 ms by default.

2. You switch a shooter flywheel from roboRIO PIDController to Phoenix 6 onboard VelocityVoltage with Slot0 gains. A command uses isFinished() { return m_shooter.isAtTarget(); } where isAtTarget() still calls m_pid.atSetpoint(). What is wrong?

  • A Nothing — PIDController.atSetpoint() reads the motor encoder directly, so it works with any control mode.
  • B The PIDController is no longer being called. Since you're using onboard PID, m_pid.calculate() is never called — the PIDController has no measurement, no error computation, and its integrator never updates. Its atSetpoint() either always returns false (error never computed) or returns stale data from the last time calculate() was called. Replace with a manual check against the motor's ClosedLoopError or Velocity StatusSignal.
  • C atSetpoint() works but returns true too early because onboard PID converges faster.
  • D The PIDController must still be called in execute() alongside the onboard PID for atSetpoint() to work.

3. An arm moves from 0° to 90° over about 0.8 seconds. Your team debates using onboard Phoenix 6 PID vs roboRIO PIDController. Which is the stronger argument for using roboRIO PIDController here?

  • A The arm moves slower than swerve steering, so latency doesn't matter — roboRIO is technically fine but onboard is always better.
  • B The arm position controller benefits from WPILib's tooling: atSetpoint() with velocity tolerance (so the command doesn't exit while the arm is still moving), live gain tuning via SmartDashboard without redeploying, and integrated SysId logging. For a slow mechanism where 50 Hz is sufficient, roboRIO PIDController's tooling advantages outweigh onboard's speed advantage.
  • C ArmFeedforward cannot be combined with onboard Phoenix 6 PID, so roboRIO is required.
  • D roboRIO PID is more accurate because it has access to more sensors than the TalonFX.
💪 Practice Prompt

Compare Both Approaches on the Same Mechanism

  1. Choose a TalonFX-driven mechanism (flywheel, elevator, or arm). If it currently uses roboRIO PIDController, add a boolean constant kUseOnboardPID in Constants. In the subsystem's setVelocityRPS() or driveToAngle(), use an if-statement to switch between roboRIO PIDController.calculate() → setVoltage() and onboard setControl(m_req.withVelocity()). This lets you toggle between both implementations in one deploy.
  2. Test roboRIO mode first. Tune kP, kI, kD to achieve good response. Publish the PIDController error and velocity to SmartDashboard. Record the settling time from step command to within tolerance.
  3. Switch to onboard mode. Configure matching gains in Slot0 (remember: onboard kP is in V/RPS, not in voltage/error — units may differ depending on how you measured). Test and compare settling time. Observe whether the onboard version is noticeably more responsive for your mechanism's speed range.
  4. Implement the isAtTarget() method using the getClosedLoopError() StatusSignal for the onboard mode. Verify it correctly detects when the mechanism is settled. Test by observing the SmartDashboard "AtTarget" boolean during a move command — it should only become true after the mechanism has fully settled.
  5. Stretch goal: Profile the roboRIO CPU usage of both implementations. In Robot.java periodic(), add: SmartDashboard.putNumber("LoopTime", TimedRobot.kDefaultPeriod - m_scheduler.getDefaultButtonPeriod()). If you have many mechanisms using roboRIO PID, you may observe measurable CPU reduction when switching fast mechanisms to onboard PID — the roboRIO is doing less math per loop.