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.
Comparing the Two Approaches
| Property | roboRIO PIDController | Onboard (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.
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.
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
}
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
}
// 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))
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
DutyCycleOutcommand 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.
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.
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?
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?
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?
Compare Both Approaches on the Same Mechanism
- Choose a TalonFX-driven mechanism (flywheel, elevator, or arm). If it currently uses roboRIO PIDController, add a boolean constant
kUseOnboardPIDin Constants. In the subsystem'ssetVelocityRPS()ordriveToAngle(), use an if-statement to switch between roboRIOPIDController.calculate() → setVoltage()and onboardsetControl(m_req.withVelocity()). This lets you toggle between both implementations in one deploy. - 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.
- 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.
- Implement the
isAtTarget()method using thegetClosedLoopError()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. - 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.