Combining Feedforward and PID
Nine lessons of groundwork converge here. Feedforward predicts. PID corrects. Together they produce controllers that are fast without being unstable, accurate without fighting themselves, and consistent across battery levels, game piece loads, and competition wear. This is the 2910 standard for every controlled mechanism — and after this lesson it is yours.
By the end of this lesson, you will:
- State the combination formula:
V_total = V_ff + V_pidand explain what each term provides - Implement all three combined patterns: flywheel velocity, arm position, and elevator position
- Explain why kP can be 5–20× smaller in a combined controller than in a PID-only controller
- Use the split-output telemetry pattern to verify feedforward is carrying its load at steady state
- Detect and fix a sign mismatch between feedforward and PID outputs
- Recognize the combined FF+PID architecture already running in your swerve modules from Unit 7
The Architecture: Parallel Paths, One Output
Feedforward and PID run in parallel — both compute independently, then their outputs are summed into a single voltage command. The feedforward path is open-loop and stateless: it receives the setpoint, computes the predicted voltage from the physics model, and outputs it immediately. The PID path is closed-loop and stateful: it receives the measurement, computes the error, updates its accumulator, and outputs a correction. The combination: immediate physics-based drive from feedforward, precision from PID.
The combination formula
setVoltage(), not set() — feedforward outputs voltage, and mixing voltage with percent-output produces wrong behavior at every non-12V battery level.
FF Only vs PID Only vs FF + PID
Select a mode to see how each approach responds to the same step command.
The Three Complete Patterns
Color-coded lines: green = feedforward, purple = PID, blue = the combined setVoltage() call. Click highlighted tokens for explanations.
private final SimpleMotorFeedforward m_ff =
new SimpleMotorFeedforward(
Constants.Shooter.kS,
Constants.Shooter.kV,
Constants.Shooter.kA);
private final PIDController m_pid =
new PIDController(
Constants.Shooter.kP, 0, 0); // kI, kD usually 0 for velocity
// ── setVelocityRPS() — called from command execute() ──
public void setVelocityRPS(double targetRPS) {
double currentRPS = m_velSig.refresh().getValueAsDouble();
// Feedforward: predicts voltage for target speed
double ffVolts = m_ff.calculate(targetRPS);
// PID: corrects residual velocity error
double pidVolts = m_pid.calculate(currentRPS, targetRPS);
// Combined: sum → setVoltage()
m_motor.setVoltage(MathUtil.clamp(
ffVolts + pidVolts, -12, 12));
// Split telemetry — verify FF is carrying its load
SmartDashboard.putNumber("Shooter/FFVolts", ffVolts);
SmartDashboard.putNumber("Shooter/PIDVolts", pidVolts);
SmartDashboard.putNumber("Shooter/ActualRPS", currentRPS);
}
private final ArmFeedforward m_ff =
new ArmFeedforward(
Constants.Arm.kS,
Constants.Arm.kG, // gravity compensation
Constants.Arm.kV,
Constants.Arm.kA);
private final PIDController m_pid =
new PIDController(Constants.Arm.kP, 0, Constants.Arm.kD);
// ── driveToAngle() — called from command execute() ────
public void driveToAngle(double setpointRad) {
double currentRad = getAngleRadians();
// FF: gravity compensation at current angle + velocity term
// Velocity=0 at setpoint means this is gravity-hold only
double ffVolts = m_ff.calculate(currentRad, 0.0);
// PID: drives angle to setpoint, cleans up overshoot
double pidVolts = m_pid.calculate(currentRad, setpointRad);
m_motor.setVoltage(MathUtil.clamp(
ffVolts + pidVolts, -12, 12));
SmartDashboard.putNumber("Arm/FFVolts", ffVolts);
SmartDashboard.putNumber("Arm/PIDVolts", pidVolts);
}
private final ElevatorFeedforward m_ff =
new ElevatorFeedforward(
Constants.Elevator.kS,
Constants.Elevator.kG, // constant gravity hold
Constants.Elevator.kV,
Constants.Elevator.kA);
private final PIDController m_pid =
new PIDController(Constants.Elevator.kP, 0, Constants.Elevator.kD);
// ── driveToHeight() — called from command execute() ──
public void driveToHeight(double setpointM) {
double currentM = getPositionMeters();
// FF: constant gravity hold — velocity=0 for hold-at-setpoint
double ffVolts = m_ff.calculate(0.0);
// PID: drives height to setpoint
double pidVolts = m_pid.calculate(currentM, setpointM);
m_motor.setVoltage(MathUtil.clamp(
ffVolts + pidVolts, -12, 12));
SmartDashboard.putNumber("Elev/FFVolts", ffVolts);
SmartDashboard.putNumber("Elev/PIDVolts", pidVolts);
}
The Reduced kP Insight
This is the most practically important consequence of combining feedforward with PID. When feedforward provides 90–95% of the required output, the PID controller only needs to correct the remaining 5–10% of error. That means kP can be dramatically smaller than in a PID-only controller — often 5 to 20 times smaller.
Why does smaller kP matter? Smaller kP means:
- Less overshoot — a smaller proportional force at maximum error produces less momentum toward the setpoint
- Less oscillation risk — the kP threshold for oscillation is higher relative to the working gain
- Less need for kD — with modest kP, the D term needed to damp overshoot is smaller, reducing noise sensitivity
- No kI needed in most cases — feedforward eliminates the static friction error that kI was needed to correct
For a flywheel: a PID-only controller might need kP = 0.05 to drive 80 RPS. With feedforward providing the 8V base, kP = 0.002 corrects the residual ±2 RPS error. The PID contribution is never more than ±0.004V per RPS of error — tiny corrections on top of a stable base.
When I look at a team's split-output telemetry during competition practice, I'm watching the PIDVolts channel. On a well-tuned controller: at steady state, PIDVolts is near zero (±0.3V). FFVolts carries the load — 8V, 9V, whatever the physics requires. PIDVolts only spikes transiently when a disturbance occurs (game piece loaded, wheel slip) and then decays back to near zero in under a second. If PIDVolts is consistently 3–4V at steady state, feedforward is wrong and kP is doing heavy lifting it shouldn't be doing. Fix the feedforward; the PID output will drop immediately.
Split-Output Telemetry: Verifying FF Is Doing Its Job
The most important diagnostic for a combined controller is publishing FFVolts and PIDVolts as separate channels. This pattern reveals the health of the combined system in one look. Press the simulation buttons to see healthy and unhealthy patterns.
The Sign Convention Check
The feedforward and PID outputs must both point in the same direction for the system to work correctly. A sign mismatch — where FF is positive and PID correction is always negative — means they are fighting each other. The motor gets FF − PID instead of FF + PID, which can cause oscillation or inability to reach the setpoint.
How to check: command a positive setpoint (arm to 90°, flywheel to positive RPS). At steady state, both FFVolts and PIDVolts should be approximately the same sign (both positive for an upward/forward command). If PIDVolts is consistently large and opposite in sign to FFVolts, the PID's setpoint or measurement convention is inverted. Common causes:
- Encoder reads in the wrong direction (negative velocity when moving forward)
- Setpoint units mismatch between feedforward and PID (FF in RPS, PID in RPM)
- ArmFeedforward using radians but PIDController using degrees
If your flywheel feedforward uses RPS (as measured by TalonFX with withSensorToMechanismRatio()) but your PIDController receives RPM (computed as RPS × 60), the feedforward predicts for one unit and the PID corrects in a different unit. The PID error is 60× larger than it should be — PIDVolts is enormous, and the two terms fight. Always verify both FF and PID use the same measurement unit. The simplest check: publish the measurement that's passed to PID alongside the setpoint. If they're in the same units, they should converge toward each other when the mechanism is at target.
You've Already Used This Architecture — Since Unit 7
Every swerve module you built in Unit 7 uses the FF + PID combined architecture — but onboard the TalonFX at 1 kHz instead of roboRIO-side. Your Slot0 configuration:
cfg.Slot0.withKP(0.1).withKV(0.12).withKS(0.25);
This is exactly V_total = kS·sgn(v) + kV·v + kP·error — feedforward (kV and kS terms) plus PID (kP term) — computed onboard at 1 kHz. The only difference from the roboRIO pattern in this lesson: Phoenix 6 runs both in the same slot, on the same chip, at 20× higher frequency. The architecture is identical. The location is different.
After wiring FF + PID together:
- Split-output check: At steady state, FFVolts should be 85–95% of total output. PIDVolts should be ≤1.5V. If PIDVolts dominates, feedforward is insufficient — fix kV, kG, or kS before tuning kP further.
- Sign check: At a positive setpoint, both FFVolts and PIDVolts should be positive. If PIDVolts is consistently large and negative when FFVolts is positive, a unit or sign convention error exists. Publish the measurement value alongside the setpoint to identify which is wrong.
- Disturbance response: For a flywheel, physically brake it briefly and release. The RPM should recover to target within 0.3–0.5 seconds. FFVolts stays constant; PIDVolts spikes briefly and then decays. If PIDVolts doesn't decay back to near-zero after the disturbance, the integral is winding up — add setIntegratorRange().
- Battery invariance: Test at 12.5V and 11.0V battery. If the mechanism behaves identically (same settling time, same final error), setVoltage() is normalizing correctly. If behavior changes significantly with battery, check that setVoltage() is used everywhere — not set().
Knowledge Check
1. A flywheel's combined controller shows FFVolts = 9.2V and PIDVolts = −3.8V at steady state when targeting 80 RPS. The actual velocity is 80 RPS. What does this pattern reveal, and what should be fixed?
2. An arm uses ArmFeedforward (in radians) combined with a PIDController. At 45° (π/4 radians), FFVolts = 0.85V (correct gravity hold). PIDVolts = 4.2V (very large). The arm is holding at 45°. What is the most likely cause?
3. Why can kP be 10× smaller in a combined FF+PID controller than in a PID-only controller for the same mechanism?
Build the Complete Combined Controller
- Take any mechanism currently running PID-only (arm, elevator, or flywheel). Add the matching feedforward class. Set kP=0, kI=0, kD=0. Deploy and verify feedforward-only response (within 10% of target). This is the FF validation step from Lesson 9's sequence.
- Add the split-output telemetry: publish FFVolts and PIDVolts as separate SmartDashboard channels. Before adding any kP, observe FFVolts at steady state and confirm it's in the right ballpark (kS + kV × target for velocity, kG × cos(θ) for arm position, kG for elevator).
- Add kP = (previous PID-only kP) / 10 as your combined kP starting point. Deploy. Observe the split-output channels. PIDVolts should be small and transient — present during the approach, near zero at steady state. If PIDVolts is still large at steady state, your feedforward needs adjustment (not your kP).
- If the mechanism reaches within tolerance without steady-state error, stop. kI=0 is the goal. If steady-state error exists, increase kP slightly before adding kI. Record your final gains and compare to the PID-only gains you had before — the kP reduction is often dramatic.
- Stretch goal: Test battery invariance. Charge the battery fully (12.5V+). Run the mechanism 5 times, recording the settling time and final error. Now drain the battery to 11V (drive aggressively for 3–4 minutes). Run the mechanism 5 more times. Compare the two data sets. With correct feedforward and
setVoltage(), both sets should be nearly identical. If not, you have aset()somewhere that needs to becomesetVoltage().