PLC Conveyor Sorting System: Complete Structured Text Tutorial with Sensor Logic & Diverter Control

Program a multi-lane conveyor sorting system from scratch — with photoelectric sensor logic, product tracking arrays, pneumatic diverter timing, and reject bin management in Structured Text.

How a conveyor sorter works

You find automated sorters in logistics, food processing, packaging and manufacturing. A typical system uses sensors to identify each product, then fires a diverter (a pneumatic pusher, pop-up wheels or a tilt tray) to send it to the correct lane.

Layout of the example system

Products enter on the feed belt, pass the scanner station and leave by one of four lanes: three sorted by size and one for rejects.

                    ┌─── Lane 1 (Small)
                    │
Feed ──► Scanner ──►├─── Lane 2 (Medium)
Belt     Station    │
                    ├─── Lane 3 (Large)
                    │
                    └─── Lane 4 (Reject)

I/O for the example

These are the devices the code below reads and drives:

ComponentI/O TypeDescription
Main conveyor motorDOVFD-controlled belt
Infeed photoeyeDIDetects product arrival
Height sensorAIMeasures product height
Weight scaleAIIn-motion weighing
Barcode scannerSerial/EthernetProduct identification
Diverter 1-3DOPneumatic pusher solenoids
Diverter confirm 1-3DIPusher extended feedback
Lane full sensors 1-4DIDownstream backup detection
E-StopDIEmergency stop circuit
Stack lightDO × 3Green/Yellow/Red status

Data structures

Product tracking

Each product on the belt gets a ProductInfo record. Diverters and counters get their own structures, so maintenance data such as CycleCount lives with the device it describes:

TYPE ProductInfo :
STRUCT
    ID            : INT;
    Barcode       : STRING(20);
    Height        : REAL;          // mm
    Weight        : REAL;          // grams
    Category      : INT;           // 1=Small, 2=Medium, 3=Large, 0=Unknown
    TargetLane    : INT;           // 1-3, 4=reject
    Position      : REAL;          // mm from scanner station
    Sorted        : BOOL;
    Valid         : BOOL;          // Tracking slot in use
END_STRUCT;
END_TYPE

TYPE DiverterStatus :
STRUCT
    SolenoidCmd   : BOOL;
    Extended      : BOOL;
    Retracted     : BOOL;
    CycleCount    : DINT;         // Maintenance tracking
    FaultFlag     : BOOL;
END_STRUCT;
END_TYPE

TYPE SystemCounters :
STRUCT
    TotalProducts : DINT;
    SortedLane1   : DINT;
    SortedLane2   : DINT;
    SortedLane3   : DINT;
    Rejected      : DINT;
    Throughput    : REAL;         // Products per minute
END_STRUCT;
END_TYPE

Product detection and classification

The classifier catches the rising edge of the infeed photoeye, scales the height and weight readings (0–27648 raw counts to 0–500 mm and 0–5000 g), and picks a category and target lane from the limits declared in the block:

FUNCTION_BLOCK FB_ProductClassifier
VAR_INPUT
    InfeedSensor    : BOOL;
    HeightRaw       : INT;         // Analog 0-27648
    WeightRaw       : INT;
    BarcodeReady    : BOOL;
    BarcodeData     : STRING(20);
END_VAR
VAR_OUTPUT
    NewProduct      : BOOL;
    ProductData     : ProductInfo;
END_VAR
VAR
    PrevSensor      : BOOL := FALSE;
    RisingEdge      : BOOL;
    Height_mm       : REAL;
    Weight_g        : REAL;
    NextID          : INT := 1;
    SmallMaxHeight  : REAL := 100.0;
    SmallMaxWeight  : REAL := 500.0;
    MediumMaxHeight : REAL := 250.0;
    MediumMaxWeight : REAL := 2000.0;
END_VAR

RisingEdge := InfeedSensor AND NOT PrevSensor;
PrevSensor := InfeedSensor;
NewProduct := FALSE;

IF RisingEdge THEN
    Height_mm := (INT_TO_REAL(HeightRaw) / 27648.0) * 500.0;
    Weight_g  := (INT_TO_REAL(WeightRaw) / 27648.0) * 5000.0;
    
    ProductData.ID := NextID;
    NextID := NextID + 1;
    IF NextID > 32767 THEN NextID := 1; END_IF;
    
    ProductData.Height := Height_mm;
    ProductData.Weight := Weight_g;
    ProductData.Position := 0.0;
    ProductData.Sorted := FALSE;
    ProductData.Valid := TRUE;
    
    IF Height_mm <= SmallMaxHeight AND Weight_g <= SmallMaxWeight THEN
        ProductData.Category := 1;
        ProductData.TargetLane := 1;
    ELSIF Height_mm <= MediumMaxHeight AND Weight_g <= MediumMaxWeight THEN
        ProductData.Category := 2;
        ProductData.TargetLane := 2;
    ELSIF Height_mm > MediumMaxHeight OR Weight_g > MediumMaxWeight THEN
        ProductData.Category := 3;
        ProductData.TargetLane := 3;
    ELSE
        ProductData.Category := 0;
        ProductData.TargetLane := 4;
    END_IF;
    
    IF BarcodeReady THEN
        ProductData.Barcode := BarcodeData;
    END_IF;
    
    NewProduct := TRUE;
END_IF;
END_FUNCTION_BLOCK

Product position tracking

Products move along the conveyor at belt speed. The tracker adds the distance travelled each scan to every product's position, and fires a diverter when the product is within the trigger window of its station:

PROGRAM ProductTracker
VAR
    TrackBuffer     : ARRAY[0..31] OF ProductInfo;
    BeltSpeed       : REAL := 500.0;   // mm/s
    ScanInterval    : REAL := 0.01;    // 10ms PLC scan
    
    Diverter1_Pos   : REAL := 2000.0;  // mm from scanner
    Diverter2_Pos   : REAL := 3500.0;
    Diverter3_Pos   : REAL := 5000.0;
    TriggerWindow   : REAL := 50.0;    // mm tolerance
    
    FireDiverter1   : BOOL := FALSE;
    FireDiverter2   : BOOL := FALSE;
    FireDiverter3   : BOOL := FALSE;
    
    i               : INT;
    TargetPos       : REAL;
END_VAR

FOR i := 0 TO 31 DO
    IF TrackBuffer[i].Valid AND NOT TrackBuffer[i].Sorted THEN
        TrackBuffer[i].Position := TrackBuffer[i].Position + (BeltSpeed * ScanInterval);
        
        CASE TrackBuffer[i].TargetLane OF
            1: TargetPos := Diverter1_Pos;
            2: TargetPos := Diverter2_Pos;
            3: TargetPos := Diverter3_Pos;
        ELSE
            TargetPos := 6000.0;
        END_CASE;
        
        IF ABS(TrackBuffer[i].Position - TargetPos) <= TriggerWindow THEN
            CASE TrackBuffer[i].TargetLane OF
                1: FireDiverter1 := TRUE;
                2: FireDiverter2 := TRUE;
                3: FireDiverter3 := TRUE;
            END_CASE;
            TrackBuffer[i].Sorted := TRUE;
        END_IF;
        
        IF TrackBuffer[i].Position > 7000.0 THEN
            TrackBuffer[i].Valid := FALSE;
        END_IF;
    END_IF;
END_FOR;
END_PROGRAM

Pneumatic diverter control with fault detection

Each diverter is a small state machine: idle, extending, hold, retracting. If the extended or retracted feedback does not arrive within its timeout, the block raises Fault and returns to idle. It will not fire into a lane whose full sensor is on:

FUNCTION_BLOCK FB_DiverterControl
VAR_INPUT
    Fire            : BOOL;
    ExtendedFB      : BOOL;
    RetractedFB     : BOOL;
    LaneFullSensor  : BOOL;
    Enable          : BOOL;
END_VAR
VAR_OUTPUT
    SolenoidOut     : BOOL;
    Fault           : BOOL;
    Busy            : BOOL;
    CycleCount      : DINT;
END_VAR
VAR
    State           : INT := 0;
    ExtendTimer     : TON;
    HoldTimer       : TON;
    RetractTimer    : TON;
    ExtendTimeout   : TIME := T#500ms;
    HoldTime        : TIME := T#300ms;
    RetractTimeout  : TIME := T#500ms;
END_VAR

Fault := FALSE;

IF NOT Enable THEN
    SolenoidOut := FALSE;
    State := 0;
    Busy := FALSE;
    RETURN;
END_IF;

CASE State OF
    0: // IDLE
        SolenoidOut := FALSE;
        Busy := FALSE;
        IF Fire AND NOT LaneFullSensor THEN
            State := 1;
            Busy := TRUE;
            CycleCount := CycleCount + 1;
        END_IF;
    
    1: // EXTENDING
        SolenoidOut := TRUE;
        ExtendTimer(IN := TRUE, PT := ExtendTimeout);
        IF ExtendedFB THEN
            ExtendTimer(IN := FALSE, PT := T#0s);
            State := 2;
        ELSIF ExtendTimer.Q THEN
            Fault := TRUE;
            SolenoidOut := FALSE;
            ExtendTimer(IN := FALSE, PT := T#0s);
            State := 0;
        END_IF;
    
    2: // HOLD
        SolenoidOut := TRUE;
        HoldTimer(IN := TRUE, PT := HoldTime);
        IF HoldTimer.Q THEN
            SolenoidOut := FALSE;
            HoldTimer(IN := FALSE, PT := T#0s);
            State := 3;
        END_IF;
    
    3: // RETRACTING
        SolenoidOut := FALSE;
        RetractTimer(IN := TRUE, PT := RetractTimeout);
        IF RetractedFB THEN
            RetractTimer(IN := FALSE, PT := T#0s);
            State := 0;
        ELSIF RetractTimer.Q THEN
            Fault := TRUE;
            RetractTimer(IN := FALSE, PT := T#0s);
            State := 0;
        END_IF;
END_CASE;
END_FUNCTION_BLOCK

Throughput monitoring

The monitor counts products in one-second buckets and sums the last 60 of them, so ProductsPerMin is a rolling one-minute figure. It also keeps the total count and the peak rate:

FUNCTION_BLOCK FB_ThroughputMonitor
VAR_INPUT
    ProductDetected : BOOL;
    ResetCounters   : BOOL;
END_VAR
VAR_OUTPUT
    ProductsPerMin  : REAL;
    ProductsPerHour : REAL;
    TotalCount      : DINT;
    PeakRate        : REAL;
END_VAR
VAR
    WindowCounts    : ARRAY[0..59] OF INT;
    WindowIndex     : INT := 0;
    SecondTimer     : TON;
    WindowTotal     : INT;
    CurrentSecCount : INT := 0;
    PrevDetected    : BOOL := FALSE;
    i               : INT;
END_VAR

IF ProductDetected AND NOT PrevDetected THEN
    CurrentSecCount := CurrentSecCount + 1;
    TotalCount := TotalCount + 1;
END_IF;
PrevDetected := ProductDetected;

SecondTimer(IN := TRUE, PT := T#1s);
IF SecondTimer.Q THEN
    WindowCounts[WindowIndex] := CurrentSecCount;
    CurrentSecCount := 0;
    WindowIndex := WindowIndex + 1;
    IF WindowIndex > 59 THEN WindowIndex := 0; END_IF;
    SecondTimer(IN := FALSE, PT := T#0s);
    
    WindowTotal := 0;
    FOR i := 0 TO 59 DO
        WindowTotal := WindowTotal + WindowCounts[i];
    END_FOR;
    
    ProductsPerMin := INT_TO_REAL(WindowTotal);
    ProductsPerHour := ProductsPerMin * 60.0;
    
    IF ProductsPerMin > PeakRate THEN
        PeakRate := ProductsPerMin;
    END_IF;
END_IF;

IF ResetCounters THEN
    TotalCount := 0;
    PeakRate := 0.0;
    FOR i := 0 TO 59 DO WindowCounts[i] := 0; END_FOR;
END_IF;
END_FUNCTION_BLOCK

Main program for the complete sorter

The main program ties the blocks together and drives the stack light. Green means running with no fault and no full lane, yellow means running with a full lane or a diverter fault, and red means stopped or a diverter fault.

PROGRAM ConveyorSortingSystem
VAR
    Classifier      : FB_ProductClassifier;
    Diverter        : ARRAY[1..3] OF FB_DiverterControl;
    Throughput      : FB_ThroughputMonitor;
    
    ConveyorMotor   : BOOL := FALSE;
    InfeedPhotoeye  : BOOL;
    HeightSensor    : INT;
    WeightSensor    : INT;
    EStop           : BOOL;
    
    DiverterFB_Ext  : ARRAY[1..3] OF BOOL;
    DiverterFB_Ret  : ARRAY[1..3] OF BOOL;
    LaneFull        : ARRAY[1..4] OF BOOL;
    
    Light_Green     : BOOL;
    Light_Yellow    : BOOL;
    Light_Red       : BOOL;
    
    SystemRunning   : BOOL := FALSE;
    AnyDiverterFault: BOOL := FALSE;
    AnyLaneFull     : BOOL := FALSE;
    i               : INT;
END_VAR

IF EStop THEN
    SystemRunning := FALSE;
    ConveyorMotor := FALSE;
    Light_Red := TRUE;
    Light_Green := FALSE;
    Light_Yellow := FALSE;
    RETURN;
END_IF;

ConveyorMotor := SystemRunning;

Classifier(
    InfeedSensor := InfeedPhotoeye,
    HeightRaw    := HeightSensor,
    WeightRaw    := WeightSensor
);

Throughput(ProductDetected := Classifier.NewProduct);

FOR i := 1 TO 3 DO
    Diverter[i](
        ExtendedFB    := DiverterFB_Ext[i],
        RetractedFB   := DiverterFB_Ret[i],
        LaneFullSensor := LaneFull[i],
        Enable        := SystemRunning
    );
END_FOR;

AnyDiverterFault := Diverter[1].Fault OR Diverter[2].Fault OR Diverter[3].Fault;
AnyLaneFull := LaneFull[1] OR LaneFull[2] OR LaneFull[3] OR LaneFull[4];

Light_Green  := SystemRunning AND NOT AnyDiverterFault AND NOT AnyLaneFull;
Light_Yellow := SystemRunning AND (AnyLaneFull OR AnyDiverterFault);
Light_Red    := NOT SystemRunning OR AnyDiverterFault;
END_PROGRAM

The EStop input only tells this program that the emergency stop has operated, so it can stop the belt logic and show red. It is not the safety function. The E-stop circuit itself must be hardwired or built on a safety relay or safety PLC, and must remove power without depending on this program.

Making the sorter more accurate

1. Encoder-based tracking

Use a conveyor encoder instead of estimating position from belt speed. An encoder accounts for belt slip and for VFD speed changes:

Product.Position := Product.Position + 
    (INT_TO_REAL(EncoderDelta) * mmPerPulse);

2. Diverter pre-trigger compensation

A pneumatic cylinder takes 50-150ms to extend, so fire the diverter early to make up for it:

TriggerPos := DiverterPos - (BeltSpeed * 0.100);  // 100ms advance

3. Gap control

Keep a minimum spacing between products so each diverter has time to retract before the next one arrives:

MinGap_mm := 0.800 * BeltSpeed;  // 800ms cycle × speed
IF GapToNextProduct < MinGap_mm THEN
    SlowInfeedConveyor := TRUE;
END_IF;

Checklist for your own sorter

  • Detect product arrival on the rising edge of the infeed photoeye.
  • Scale the analog height and weight inputs before you classify.
  • Track each product in an array slot and clear the slot once it has passed the last station.
  • Run each diverter as a state machine with extend and retract timeouts, and block it when its lane is full.
  • Fire early by the cylinder's extend time (50-150ms) and keep the product gap wide enough for the diverter to retract.
  • Monitor throughput for your production KPIs.
  • Keeping classification, tracking, diverter control and monitoring in separate FBs makes the program easier to maintain and test, whether it runs a simple 2-lane system or a multi-tier sorter.