PLC PID Control Loop Tuning: Implementation & Examples in Structured Text

Implement and tune PID controllers in Structured Text — from basic proportional control to full PID with anti-windup, used in temperature, pressure, and flow regulation.

What a PID controller does

PID (Proportional-Integral-Derivative) control is the most widely used feedback control algorithm in industrial automation. Every cycle it calculates the error between the setpoint you want and the process variable you measure, then applies a correction made of three terms:

TermSymbolActionCorrects
ProportionalKpReacts to current errorPresent error
IntegralKiAccumulates past errorSteady-state offset
DerivativeKdPredicts future errorOvershoot/oscillation

The PID formula

In continuous form:

Output = Kp × Error + Ki × ∫Error dt + Kd × dError/dt

In a PLC the controller is sampled once per task cycle, so the integral becomes a running sum and the derivative a difference between two samples:

Output = Kp × Error + Ki × ErrorSum × dt + Kd × (Error - PrevError) / dt

A basic PID loop in Structured Text

This version holds everything in one program, with the sample time dt fixed at 0.1 s and the output clamped to 0–100%:

PROGRAM BasicPID
VAR
    // Process
    Setpoint : REAL := 75.0;      // Desired temperature °C
    ProcessValue : REAL := 20.0;  // Measured temperature °C
    Output : REAL := 0.0;         // Control output 0-100%
    
    // Tuning parameters
    Kp : REAL := 2.0;
    Ki : REAL := 0.5;
    Kd : REAL := 0.1;
    
    // Internal
    Error : REAL := 0.0;
    PrevError : REAL := 0.0;
    ErrorSum : REAL := 0.0;
    ErrorDiff : REAL := 0.0;
    dt : REAL := 0.1;            // Sample time in seconds
    
    // Output limits
    OutMin : REAL := 0.0;
    OutMax : REAL := 100.0;
END_VAR

// Calculate error
Error := Setpoint - ProcessValue;

// Proportional term
// Integral term (accumulated error)
ErrorSum := ErrorSum + (Error * dt);

// Derivative term (rate of change).
// dt is the sample period in seconds. It MUST be > 0 — set it to your
// actual cyclic task period (e.g. 0.1 for a 100 ms task) and never expose
// it as a tunable that can land on zero. Real PLCs fault on dt = 0.
ErrorDiff := (Error - PrevError) / dt;

// PID output
Output := (Kp * Error) + (Ki * ErrorSum) + (Kd * ErrorDiff);

// Clamp output
IF Output > OutMax THEN
    Output := OutMax;
ELSIF Output < OutMin THEN
    Output := OutMin;
END_IF;

// Store previous error
PrevError := Error;

A reusable PID function block

In a real project you wrap the same calculation in a function block, so every loop shares one tested implementation. This one adds manual mode with bumpless transfer, a reset input and anti-windup:

FUNCTION_BLOCK FB_PID
VAR_INPUT
    Enable : BOOL := FALSE;
    Setpoint : REAL := 0.0;
    ProcessValue : REAL := 0.0;
    Kp : REAL := 1.0;
    Ki : REAL := 0.0;
    Kd : REAL := 0.0;
    dt : REAL := 0.1;
    OutMin : REAL := 0.0;
    OutMax : REAL := 100.0;
    ManualMode : BOOL := FALSE;
    ManualOutput : REAL := 0.0;
    Reset : BOOL := FALSE;
END_VAR
VAR_OUTPUT
    Output : REAL := 0.0;
    Error : REAL := 0.0;
    IsActive : BOOL := FALSE;
END_VAR
VAR
    PrevError : REAL := 0.0;
    Integral : REAL := 0.0;
    Derivative : REAL := 0.0;
    RawOutput : REAL := 0.0;
END_VAR

IF Reset THEN
    Integral := 0.0;
    PrevError := 0.0;
    Output := 0.0;
    RETURN;
END_IF;

IF ManualMode THEN
    Output := ManualOutput;
    // Bumpless transfer: pre-seed Integral so the first Auto-mode output
    // equals ManualOutput. Rearranging Output = Kp·Error + Ki·Integral
    // gives Integral = (ManualOutput - Kp·Error) / Ki. The divisor is Ki,
    // so guard Ki — not Kp. A P-only controller (Ki = 0) has no integral
    // term to track, so leave Integral cleared on every Manual scan.
    IF Ki <> 0.0 THEN
        Integral := (ManualOutput - Kp * (Setpoint - ProcessValue)) / Ki;
    ELSE
        Integral := 0.0;
    END_IF;
    RETURN;
END_IF;

IF NOT Enable THEN
    Output := 0.0;
    IsActive := FALSE;
    RETURN;
END_IF;

IsActive := TRUE;

// Error
Error := Setpoint - ProcessValue;

// Integral with anti-windup
Integral := Integral + (Error * dt);

// Derivative — dt is a VAR_INPUT with default 0.1, but the caller can
// override it. Bind dt to the actual task period; if the FB is wired
// up with dt = 0 it will fault here on every scan.
Derivative := (Error - PrevError) / dt;

// Raw PID output
RawOutput := (Kp * Error) + (Ki * Integral) + (Kd * Derivative);

// Clamp and anti-windup
IF RawOutput > OutMax THEN
    Output := OutMax;
    // Anti-windup: stop integrating when saturated
    Integral := Integral - (Error * dt);
ELSIF RawOutput < OutMin THEN
    Output := OutMin;
    Integral := Integral - (Error * dt);
ELSE
    Output := RawOutput;
END_IF;

PrevError := Error;

How the anti-windup works

Integral windup happens when the output is saturated (at min or max) but the integral term keeps accumulating. When the error finally reverses, all that stored integral has to unwind first, and the result is a large overshoot.

The function block above stops accumulating the integral while the output is clamped: on a saturated scan it subtracts the step it has just added. The technique is called conditional integration or clamping anti-windup.

Example: oven temperature control

An oven held at 180 °C, with the heater forced off and the PID disabled if the thermocouple reads above 220 °C:

PROGRAM OvenControl
VAR
    // Inputs
    TempSensor : REAL := 25.0;     // °C from thermocouple
    TempSetpoint : REAL := 180.0;  // Target °C
    Enable : BOOL := TRUE;
    
    // PID controller
    TempPID : FB_PID;
    HeaterOutput : REAL := 0.0;
    
    // Safety
    OverTempAlarm : BOOL := FALSE;
    MaxTemp : REAL := 220.0;
END_VAR

// Safety check first
OverTempAlarm := TempSensor > MaxTemp;

// Run PID
TempPID(
    Enable := Enable AND NOT OverTempAlarm,
    Setpoint := TempSetpoint,
    ProcessValue := TempSensor,
    Kp := 3.0,
    Ki := 0.2,
    Kd := 0.5,
    dt := 0.1,
    OutMin := 0.0,
    OutMax := 100.0
);

HeaterOutput := TempPID.Output;

// Override on alarm
IF OverTempAlarm THEN
    HeaterOutput := 0.0;
END_IF;

Manual tuning with Ziegler-Nichols

The Ziegler-Nichols method is the best-known manual tuning approach:

  • Set Ki = 0 and Kd = 0
  • Increase Kp until the system oscillates with constant amplitude
  • Record this Ku (ultimate gain) and Tu (oscillation period)
  • Calculate tuning parameters:
  • ControllerKpKiKd
    P only0.5 × Kunot usednot used
    PI0.45 × Ku1.2 × Kp / Tunot used
    PID0.6 × Ku2 × Kp / TuKp × Tu / 8

    Worked example

    If Ku = 4.0 and Tu = 2.0 seconds, a full PID works out as:

  • Kp = 0.6 × 4.0 = 2.4
  • Ki = 2 × 2.4 / 2.0 = 2.4
  • Kd = 2.4 × 2.0 / 8 = 0.6
  • Tuning tips from the field

  • Start with P only, and get the response into the right ballpark before you add I and D.
  • Add I slowly. Too much integral causes oscillation, so start at 1/10th of the calculated value.
  • D is optional. Many industrial loops work fine with PI only; add D only for fast processes.
  • Watch the output. If it is banging between 0% and 100%, reduce Kp.
  • Set dt to match your PLC task cycle time exactly, or the integral and derivative terms are scaled wrong.
  • Filter the PV. A noisy sensor makes the derivative term jump around, so put a low-pass filter on ProcessValue.
  • Use bumpless transfer. When you switch between Auto and Manual, track the integral so the output does not jump.
  • Common PID mistakes

  • A wrong dt value. It must match the actual scan time, not an approximation of it.
  • No anti-windup, which causes large overshoot after big setpoint changes.
  • Taking the derivative of the error. Use the derivative of the PV instead, so a setpoint change does not cause "derivative kick".
  • Tuning at the wrong operating point. A system tuned at 50°C may oscillate at 200°C.
  • Ignoring actuator limits. A valve can only open 0-100%, and the PID cannot get more out of it.
  • Cascaded PID loops

    Some processes need an inner and an outer loop. Here a slow temperature loop (1 s) writes the setpoint for a fast flow loop (100 ms), and the flow loop drives the valve:

    PROGRAM CascadeControl
    VAR
        // Outer loop: Temperature (slow)
        OuterPID : FB_PID;
        TempSetpoint : REAL := 180.0;
        Temperature : REAL := 25.0;
        
        // Inner loop: Flow (fast)
        InnerPID : FB_PID;
        FlowSetpoint : REAL := 0.0;
        FlowRate : REAL := 0.0;
        ValveOutput : REAL := 0.0;
    END_VAR
    
    // Outer loop sets flow setpoint
    OuterPID(
        Enable := TRUE,
        Setpoint := TempSetpoint,
        ProcessValue := Temperature,
        Kp := 2.0, Ki := 0.1, Kd := 0.3,
        dt := 1.0,  // Slow loop: 1 second
        OutMin := 0.0, OutMax := 100.0
    );
    FlowSetpoint := OuterPID.Output;
    
    // Inner loop controls valve
    InnerPID(
        Enable := TRUE,
        Setpoint := FlowSetpoint,
        ProcessValue := FlowRate,
        Kp := 1.5, Ki := 0.8, Kd := 0.0,
        dt := 0.1,  // Fast loop: 100ms
        OutMin := 0.0, OutMax := 100.0
    );
    ValveOutput := InnerPID.Output;
    

    Trying PID tuning in the browser

    You can experiment with PID tuning in the online ST editor: adjust Kp, Ki and Kd in real time and watch how the process responds. The motion control lessons have guided PID exercises.