Packaging
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:
| Component | I/O Type | Description |
|---|---|---|
| Main conveyor motor | DO | VFD-controlled belt |
| Infeed photoeye | DI | Detects product arrival |
| Height sensor | AI | Measures product height |
| Weight scale | AI | In-motion weighing |
| Barcode scanner | Serial/Ethernet | Product identification |
| Diverter 1-3 | DO | Pneumatic pusher solenoids |
| Diverter confirm 1-3 | DI | Pusher extended feedback |
| Lane full sensors 1-4 | DI | Downstream backup detection |
| E-Stop | DI | Emergency stop circuit |
| Stack light | DO × 3 | Green/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
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.