A conveyor needs a memory
A bottling line runs 24,000 bottles an hour, about 6.7 a second. A vision camera checks the fill level and the cap of each one. Six bottle pitches further down, a pneumatic pusher kicks rejects into a bin. Between the camera and the pusher there are always five or six bottles on the belt, each with its own verdict, and the PLC has to remember which verdict belongs to which bottle until that bottle reaches the pusher.
That is part tracking, and every line that inspects at one place and acts at another needs it. A palletiser that must know which layer pattern the next case belongs to. A parcel sorter that read a barcode at the infeed and has to open the right chute 30 metres later. A pharmaceutical blister line where a missing tablet detected at station 3 must be punched out at station 9, and a regulator will ask you to prove that the right blister was punched.
Three things decide how you build it.
- How the product moves. On an indexing machine (a rotary table, a walking beam, a cam-driven pitch chain) every product moves exactly one pitch per machine cycle. Each position is a fixed slot, and a shift register with one cell per slot fits perfectly. On a free-flowing belt the gaps between products vary, so a fixed slot no longer means anything; there you track products as a queue of records, added at one sensor and removed at another.
- What you must remember. A single good/bad bit is enough for a reject gate. A sorter needs a destination. Traceability needs an ID, a result and a time.
- What happens when reality and memory disagree. Somebody lifts a bottle off the belt to look at it. A bottle falls over. A jam is cleared by hand. The tracking must notice and resynchronise, or every verdict after that point belongs to the wrong product.
The first instinct of many programmers is a timer: the camera says bad, start a 900 ms timer, fire the pusher when it finishes. It works on the bench and fails on the line, because the belt is not always at the same speed. It ramps up after a stop, slows when the filler starves, and stops dead with bottles between the camera and the pusher. After every stop, the timer-based reject hits the wrong bottle. Tie the tracking to the movement of the product, never to the clock.
This chapter builds on arrays and loops from chapter 13 and on edges from chapter 8. One engine note before the code: array declarations here need literal bounds, ARRAY[0..7], so each program also declares a constant such as LAST : INT := 7 for its loops. On CODESYS, TwinCAT and TIA Portal you can write ARRAY[0..LAST] directly and keep the two in step automatically; here, keep them in step yourself.
The shift register: one cell per pitch
A shift register is an array where cell 0 is the product at the inspection station, cell 1 the product one pitch further on, and so on. Every time the machine moves one pitch, every value moves one cell along, and the new product's value goes into cell 0. The cell number is the distance from the camera, measured in pitches.
Two things make it work, and both are classic bugs when they are missing.
Shift on the edge, not the level. The index pulse comes from a cam switch, a proximity sensor on the pitch chain, or the "index complete" bit of a servo. It is TRUE for some milliseconds, several scans long. Shift while it is TRUE and the data runs through several cells per index. Shift on its rising edge, with an R_TRIG, and it moves exactly one cell.
Shift from the far end backwards. To move cell 6 into cell 7, then 5 into 6, and so on down to 0 into 1, the loop must run from the top down. Run it upwards and cell 0 is copied into cell 1, then the new cell 1 into cell 2, and one value floods the whole register.
PROGRAM Main
VAR CONSTANT
LAST : INT := 7; (* must match the array bound below *)
GATE_POS : INT := 5; (* the pusher is 5 pitches after the camera *)
END_VAR
VAR
Index : BOOL; (* input: one pulse per machine pitch *)
Bad : BOOL; (* input: camera verdict for the bottle at the camera *)
IndexEdge : R_TRIG;
Reg : ARRAY[0..7] OF BOOL; (* TRUE = this bottle is a reject *)
i : INT;
Gate : BOOL; (* output: pusher *)
END_VAR
IndexEdge(CLK := Index);
IF IndexEdge.Q THEN
FOR i := LAST TO 1 BY -1 DO (* top down, or one value floods the array *)
Reg[i] := Reg[i - 1];
END_FOR;
Reg[0] := Bad; (* the new bottle enters at the camera *)
END_IF;
Gate := Reg[GATE_POS];
END_PROGRAMTurn Bad on, pulse Index once, turn Bad off. The TRUE sits in cell 0 of Reg. Pulse Index five more times and watch it walk down the array; when it reaches cell 5, Gate comes on, and the next pulse moves it on to cell 6 and the pusher retracts. Hold Index on for several seconds: nothing moves after the first scan, because only the edge shifts.
Tracking a reject from the camera to the gate
The register is the easy part. Getting the right bottle in the right cell is where commissioning time goes, and almost all of it is about when.
The camera does not inspect continuously. A trigger sensor fires it as a bottle passes, the image is processed, and the result arrives 20 to 80 ms later on a digital output or over PROFINET or EtherNet/IP. On an indexing machine it is simplest when the result is ready before the next index, so you can load it at the index edge, as above. On a faster machine the result of bottle n may only arrive after the machine has already indexed bottle n one pitch on. Then you write the result into cell 1, not cell 0, or you latch it and load it on the next index. Neither is wrong; what is wrong is not knowing which one your code does.
Count it on paper before you count it in code. For the program above, with the gate five pitches downstream:
| Index edge | Bottle at camera | Bad bottle's cell | Gate |
|---|---|---|---|
| 1 | bad (enters cell 0) | 0 | off |
| 2 | good | 1 | off |
| 3 | good | 2 | off |
| 4 | good | 3 | off |
| 5 | good | 4 | off |
| 6 | good | 5 | on |
| 7 | good | 6 | off |
GATE_POS is the number of pitches between camera and gate, counted on the machine with a tape measure or by counting bottles, not read off a drawing that predates the last modification. Make it a constant with a comment, or better, a parameter the commissioning engineer can adjust from the HMI, because the first time you run the line you will discover it is one off.
A register of BOOL can only say good or bad. Real lines need more: an empty pitch (no bottle), a sample for the laboratory, a destination lane. Use an ARRAY OF INT with named codes. Then the register can also check itself against reality. A presence sensor at the gate is cheap, and it lets the program notice when the memory and the belt disagree: a bottle the register thinks is there has gone (somebody took it off), or a bottle has appeared that the register does not know about.
PROGRAM Main
VAR CONSTANT
LAST : INT := 9;
GATE_POS : INT := 6;
EMPTY : INT := 0; (* no bottle in this pitch *)
GOOD : INT := 1;
REJECT : INT := 2;
END_VAR
VAR
Index : BOOL; (* input: index complete, bottles standing still *)
AtCamera : BOOL; (* input: bottle present at the camera *)
CameraBad : BOOL; (* input: camera verdict *)
AtGate : BOOL; (* input: bottle present at the pusher *)
IndexEdge : R_TRIG;
Slot : ARRAY[0..9] OF INT;
i : INT;
Kick : BOOL; (* output: pusher *)
Missing : INT; (* expected a bottle, found none *)
Unexpected : INT; (* found a bottle nobody inspected *)
END_VAR
IndexEdge(CLK := Index);
IF IndexEdge.Q THEN
FOR i := LAST TO 1 BY -1 DO
Slot[i] := Slot[i - 1];
END_FOR;
IF NOT AtCamera THEN
Slot[0] := EMPTY;
ELSIF CameraBad THEN
Slot[0] := REJECT;
ELSE
Slot[0] := GOOD;
END_IF;
(* The sensor at the gate is the truth; the register must agree with it *)
IF AtGate AND Slot[GATE_POS] = EMPTY THEN
Slot[GATE_POS] := REJECT; (* uninspected bottle: reject it *)
Unexpected := Unexpected + 1;
ELSIF NOT AtGate AND Slot[GATE_POS] <> EMPTY THEN
Slot[GATE_POS] := EMPTY; (* taken off by hand *)
Missing := Missing + 1;
END_IF;
END_IF;
Kick := Slot[GATE_POS] = REJECT;
END_PROGRAMFeed a few bottles with AtCamera on (and CameraBad on for one of them), indexing each time. When the first bottle reaches cell 6, have AtGate on so the sensor agrees. Then index once with AtGate off while cell 6 holds a bottle: Missing counts, the cell is cleared, the pusher stays in. Index with AtGate on while cell 6 is empty: Unexpected counts and the bottle is kicked.
That last rule is the one that matters most: an unknown product is a reject. A bottle that nobody inspected must not reach the customer just because the tracking lost it. On a pharmaceutical line this is not a choice; it is in the validation documents. On a food line it is still the cheap answer, because one wasted bottle costs less than one complaint. Count the mismatches, too. One a shift is an operator taking samples. Twenty in an hour is a sensor that needs cleaning, or a GATE_POS that is one off.
Shifting a word instead of an array
When one bit per pitch is enough and the gate is within 32 pitches of the camera, a single DWORD does the same job with one instruction. Shift it left by one at each index, put the new verdict in bit 0, and read the gate position as a bit:
PROGRAM Main
VAR
Index : BOOL; (* input: pitch pulse *)
Bad : BOOL; (* input: camera verdict *)
IndexEdge : R_TRIG;
Track : DWORD; (* bit n = bottle n pitches after the camera *)
Gate : BOOL; (* output: pusher, 5 pitches downstream *)
END_VAR
IndexEdge(CLK := Index);
IF IndexEdge.Q THEN
Track := SHL(Track, 1) OR BOOL_TO_DWORD(Bad);
END_IF;
Gate := Track.5;
END_PROGRAMThe watch table shows Track in decimal: a single bad bottle reads 1, 2, 4, 8, 16, 32 as it moves, and Gate comes on at 32, bit 5. This is exactly how the Rockwell BSL instruction and the old relay-era shift registers worked, and you will find it in a lot of existing code. Chapter 25 covers SHL and bit access in depth. It is compact and fast, but it carries one bit per pitch and nothing else. As soon as you need an ID or a destination, go back to an array.
(Writing a single bit with Track.0 := Bad; after the shift is the obvious alternative, and it works just as well. The example uses OR BOOL_TO_DWORD(Bad) because it keeps the shift and the new bit in one statement, so nothing can sit between them by accident.)
Where the shift pulse comes from
The index edge is the heartbeat of every shift register, and a bad one corrupts everything downstream. The sources, from best to worst:
- The machine's own cycle. On a servo-driven indexer the "move done" of the index axis (chapter 31) or a virtual master's cycle count. Exact, no wear, never misses.
- A cam or proximity sensor on the drive shaft or pitch chain. One pulse per pitch. Reliable if it is debounced and if the target is long enough to be seen at full speed: a 10 mm target at 1 m/s is under the sensor for 10 ms, which a 20 ms scan can miss entirely. Put such a sensor on a fast input or an interrupt, or make the target longer.
- An encoder on the conveyor. The belt moves, the encoder counts, and the program generates a pitch every N counts. Good for belts where the product pitch is fixed by a timing screw or star wheel upstream.
- Product sensors. Shifting on "bottle passed the photo-eye" is tempting and wrong for a shift register: a gap in the product stream means no shift, and every cell after the gap is now one pitch off. Product sensors belong to queue-based tracking, later in this chapter.
With an encoder, compute the pitch from the count, and compare by difference rather than by absolute value:
PROGRAM Main
VAR CONSTANT
PITCH : DINT := 1200; (* counts per bottle pitch: 150 mm at 8 counts/mm *)
LAST : INT := 7;
GATE_POS : INT := 5;
END_VAR
VAR
Encoder : DINT; (* input: conveyor encoder count *)
Bad : BOOL; (* input: camera verdict *)
NextShift : DINT := 1200; (* count at which the next shift is due *)
Reg : ARRAY[0..7] OF BOOL;
i : INT;
Pitches : DINT;
Gate : BOOL;
END_VAR
(* Difference, not absolute: survives the counter wrapping round *)
IF Encoder - NextShift >= 0 THEN
NextShift := NextShift + PITCH; (* the next one is due one pitch later *)
FOR i := LAST TO 1 BY -1 DO
Reg[i] := Reg[i - 1];
END_FOR;
Reg[0] := Bad;
Pitches := Pitches + 1;
END_IF;
Gate := Reg[GATE_POS];
END_PROGRAMType 1300 into Encoder: one shift, and NextShift moves on to 2400. Type 1350: nothing, still inside the same pitch. Type 6100: the program catches up one pitch per scan until NextShift is past the count. On a real line the belt should never move more than a fraction of a pitch per scan (at 1 m/s and a 10 ms scan it moves 10 mm against a 150 mm pitch), so if it ever catches up like this, either the scan is far too slow or the encoder jumped.
Two habits are worth stealing from that code. NextShift := NextShift + PITCH rather than NextShift := Encoder + PITCH, so the small error of each scan does not accumulate into a drift of one pitch every few hundred bottles. And Encoder - NextShift >= 0 rather than Encoder >= NextShift: on a real 32-bit counter that wraps from 2,147,483,647 to −2,147,483,648, the subtraction wraps the same way and the comparison stays correct, while the direct comparison fails once every wrap. A belt at 1 m/s with 8 counts/mm wraps a DINT after about three days, which is precisely long enough to get through the factory acceptance test.
The ring buffer: a FIFO without shifting
A shift register moves every value on every index. That is the right model when every slot is a physical position. When products arrive at irregular times, or when you only care about the order of things, it is cheaper and simpler to leave the data where it is and move two indexes instead. That is a ring buffer, and it is the standard way to build a FIFO (first in, first out) queue in a PLC.
The buffer is an array of SIZE elements and three numbers:
- Head: where the next item will be written.
- Tail: where the oldest item is waiting to be read.
- Count: how many items are in the queue.
To push, write at Head, then Head := (Head + 1) MOD SIZE and Count := Count + 1. To pop, read at Tail, then Tail := (Tail + 1) MOD SIZE and Count := Count - 1. The MOD is what makes it a ring: after the last element, the index wraps back to 0. Nothing is ever copied, so a push or pop costs the same whether the queue holds 4 entries or 4,000.
Two checks are not optional. Never push when Count = SIZE, or you overwrite the oldest item that has not been read yet. Never pop when Count = 0, or you read stale data and Count goes negative. What to do instead is a design decision, and it differs by application: a part-tracking queue that is full means more parts are on the belt than can physically fit, which is a fault; an event log that is full should overwrite its oldest entry, which is normal.
PROGRAM Main
VAR CONSTANT
SIZE : INT := 6; (* must match the array: 0..5 *)
END_VAR
VAR
Push : BOOL; (* input: a carton enters the buffer conveyor *)
Pop : BOOL; (* input: a carton leaves at the labeller *)
InId : INT; (* input: ID read by the scanner *)
PushEdge : R_TRIG;
PopEdge : R_TRIG;
Buf : ARRAY[0..5] OF INT;
Head : INT;
Tail : INT;
Count : INT;
OutId : INT; (* the ID the labeller should print *)
Full : BOOL;
Empty : BOOL;
END_VAR
PushEdge(CLK := Push);
PopEdge(CLK := Pop);
IF PushEdge.Q AND Count < SIZE THEN
Buf[Head] := InId;
Head := (Head + 1) MOD SIZE;
Count := Count + 1;
END_IF;
IF PopEdge.Q AND Count > 0 THEN
OutId := Buf[Tail];
Tail := (Tail + 1) MOD SIZE;
Count := Count - 1;
END_IF;
Full := Count = SIZE;
Empty := Count = 0;
END_PROGRAMType an ID such as 501 into InId and pulse Push; repeat with 502, 503. Pulse Pop: OutId is 501, the oldest. Keep going until Head wraps from 5 back to 0 and watch the data stay put in Buf while the two indexes chase each other round. Fill it up: once Full is on, further pushes are refused.
On this engine the starter's runaway index is caught as a runtime fault, "array out-of-bounds". Real controllers differ, and the difference is worth knowing. A Rockwell controller takes a major fault (type 4, code 20) and stops. An S7-1500 raises a programming error, handled in OB121 if you have one. A CODESYS or TwinCAT controller does not check at all unless you add the implicit-check function CheckBounds to the project; without it, Buf[4] quietly writes into whatever variable lies next in memory. That last case is the dangerous one: the program keeps running, and some unrelated value changes for no visible reason.
Tracking records: a STRUCT per product
A bit or a code is enough for a reject gate. Traceability wants more: the product ID, the result of each check, when it was made. Put that in a STRUCT (chapter 15) and make the queue an ARRAY OF that struct.
On a free-flowing belt this is how tracking works. There is no pitch to shift on, so the queue is driven by product sensors: the camera's trigger pushes a record, and the photo-eye at the gate pops one. The order of products on a single-lane belt cannot change, so the oldest record always belongs to the next product at the gate — as long as no product is added or removed in between.
That "as long as" is the weak point, and the record can protect itself with a time stamp. If products take between 1 and 3 seconds to travel from the camera to the gate, then a record older than 3 s when the gate sees a product belongs to a product that never arrived: somebody took it off. Throw that record away and look at the next one. A record younger than 1 s cannot belong to the product at the gate either: that product was never seen by the camera, so treat it as unknown and reject it.
The time stamp here is a scan count, which is enough on a cyclic task with a fixed period: 10 scans are 1 s at 100 ms. In a real program use a millisecond counter that does not depend on the scan time, for example the elapsed time of a free-running timer or the controller's system time, so the windows stay right when the task's cycle time is changed.
TYPE ST_Part :
STRUCT
Id : DINT; (* sequence number given at the camera *)
Bad : BOOL; (* camera verdict *)
Stamp : DINT; (* scan count when it was inspected *)
END_STRUCT
END_TYPE
PROGRAM Main
VAR CONSTANT
SIZE : INT := 8; (* must match the array: 0..7 *)
MIN_AGE : DINT := 10; (* 1.0 s: fastest possible travel to the gate *)
MAX_AGE : DINT := 30; (* 3.0 s: slowest *)
END_VAR
VAR
CameraEye : BOOL; (* input: trigger at the camera *)
CameraBad : BOOL; (* input: verdict *)
GateEye : BOOL; (* input: photo-eye just before the pusher *)
CamEdge : R_TRIG;
GateEdge : R_TRIG;
Q : ARRAY[0..7] OF ST_Part;
Head : INT;
Tail : INT;
Count : INT;
NextId : DINT;
Scan : DINT;
Age : DINT;
k : INT;
Matched : BOOL;
RejectNow : BOOL; (* output: verdict for the product at the gate *)
LastId : DINT; (* 0 = unknown product *)
Discarded : INT; (* records whose product never arrived *)
Unknown : INT; (* products nobody inspected *)
END_VAR
Scan := Scan + 1;
CamEdge(CLK := CameraEye);
GateEdge(CLK := GateEye);
(* Camera: one record per product, written field by field *)
IF CamEdge.Q AND Count < SIZE THEN
NextId := NextId + 1;
Q[Head].Id := NextId;
Q[Head].Bad := CameraBad;
Q[Head].Stamp := Scan;
Head := (Head + 1) MOD SIZE;
Count := Count + 1;
END_IF;
(* Gate: take the oldest record that could belong to this product *)
IF GateEdge.Q THEN
Matched := FALSE;
FOR k := 1 TO SIZE DO (* bounded: at most SIZE stale records *)
IF Count = 0 THEN EXIT; END_IF;
Age := Scan - Q[Tail].Stamp;
IF Age < MIN_AGE THEN EXIT; END_IF; (* too young: not this product's *)
LastId := Q[Tail].Id;
RejectNow := Q[Tail].Bad;
Tail := (Tail + 1) MOD SIZE;
Count := Count - 1;
IF Age <= MAX_AGE THEN
Matched := TRUE;
EXIT;
END_IF;
Discarded := Discarded + 1; (* too old: that product was removed *)
END_FOR;
IF NOT Matched THEN
Unknown := Unknown + 1;
LastId := 0;
RejectNow := TRUE; (* unknown product: reject *)
END_IF;
END_IF;
END_PROGRAMPulse CameraEye three times (with CameraBad on for the second), then, about two seconds later, pulse GateEye three times: the IDs come out 1, 2, 3 and the second is rejected. Now pulse CameraEye twice and wait more than three seconds before pulsing GateEye: both records are too old, Discarded goes up by two, and the product at the gate is treated as unknown and rejected. Pulse GateEye with nothing in the queue: unknown again.
The loop deserves a look. It is a FOR with a fixed upper bound, not a WHILE Count > 0, because a loop in a PLC must have a provable end (chapter 14). At most SIZE records can be discarded in one go, and the EXIT statements leave early in the normal case. Also note the field-by-field writes: Q[Head].Id := NextId; and so on. Copying whole structures in and out of arrays is legal IEC and works on every major platform, but writing the fields is never wrong, and on this engine it is required.
Event logs, overwriting and LIFO
Not every FIFO tracks products. An event log keeps the last N things that happened: alarms coming and going, mode changes, recipe downloads, operator actions. It is a ring buffer with one difference: when it is full, the newest entry overwrites the oldest. Nobody pops from it; the HMI reads it, newest first.
The arithmetic for "the k-th newest entry" is the one place where people get the MOD wrong. The newest entry is the one just before Head, so it sits at Head - 1, and the k-th newest at Head - 1 - k. Those can be negative, and MOD of a negative number is negative in ST on most platforms, so add SIZE first: (Head - 1 - k + SIZE) MOD SIZE.
TYPE ST_Event :
STRUCT
Code : INT; (* alarm or event number *)
Stamp : DINT; (* scan count when it was logged *)
END_STRUCT
END_TYPE
PROGRAM Main
VAR CONSTANT
SIZE : INT := 8; (* must match the array: 0..7 *)
END_VAR
VAR
LogIt : BOOL; (* input: an event occurs *)
Code : INT; (* input: its code *)
LogEdge : R_TRIG;
Log : ARRAY[0..7] OF ST_Event;
Head : INT;
Total : DINT; (* events ever logged, not only the ones kept *)
Scan : DINT;
Newest : INT;
Second : INT;
Third : INT;
NewestAge : DINT; (* scans since the newest event *)
END_VAR
Scan := Scan + 1;
LogEdge(CLK := LogIt);
IF LogEdge.Q THEN
Log[Head].Code := Code; (* overwrite the oldest when full: no Count check *)
Log[Head].Stamp := Scan;
Head := (Head + 1) MOD SIZE;
Total := Total + 1;
END_IF;
(* Newest first: add SIZE before MOD so the index never goes negative *)
Newest := Log[(Head - 1 + SIZE) MOD SIZE].Code;
Second := Log[(Head - 2 + SIZE) MOD SIZE].Code;
Third := Log[(Head - 3 + SIZE) MOD SIZE].Code;
NewestAge := Scan - Log[(Head - 1 + SIZE) MOD SIZE].Stamp;
END_PROGRAMLog codes 101, 102, 103 by typing each into Code and pulsing LogIt. Newest, Second and Third read 103, 102, 101. Log ten more and the oldest entries are overwritten while Total keeps the true count, so the HMI can show "13 events, last 8 kept". Chapter 22 builds the alarms that feed a log like this one.
A LIFO (last in, first out), or stack, is the same array with a single index. Push writes at Top and increments it; pop decrements it and reads. It is rarer in machine code than the FIFO, but it turns up wherever things are physically stacked: a magazine of trays loaded from the top and unloaded from the top, a stack of pallets, an undo history on an HMI.
(* LIFO: one index, Top = number of items on the stack *)
IF PushEdge.Q AND Top < SIZE THEN
Stack[Top] := InTray;
Top := Top + 1;
END_IF;
IF PopEdge.Q AND Top > 0 THEN
Top := Top - 1;
OutTray := Stack[Top];
END_IF;
When the data and the product disagree
Every tracking system on a real line will lose sync. The question is whether it notices, and what it does then. These are the situations to design for, with the answers that usually work:
- A product removed by hand. Quality takes a sample, an operator lifts off a fallen bottle. With a shift register and a presence sensor at the gate, the cell is cleared when the sensor sees nothing. With a record queue, the travel-time window discards the orphaned record. Without either, every verdict from that point on is one product late, and the line rejects good products and ships bad ones until somebody notices.
- A product added by hand. A bottle put back on the belt after inspection, a case placed on the conveyor to "help". It reaches the gate with no record. Reject it.
- A jam cleared by hand. The operator opens the guard, pulls out three crushed cartons and pushes the rest into line. The tracking between the jam and the gate is now fiction. The safe rule, and the usual one on pharmaceutical and food lines: when a guard on the tracked section is opened, mark every tracked product as reject, or purge the tracking and reject whatever arrives at the gate until the first product inspected after the restart.
- A power cut or a controller stop. Declaring the tracking array
RETAINkeeps it across a power cycle, which is right only if nobody can touch the products while the line is down. On most lines somebody will. Retain the data but treat it as unconfirmed: on restart, reject what is between the camera and the gate unless the operator confirms the belt was not touched. Never let a restart silently trust a register that was written hours ago. - The reject that did not happen. A pusher that fires but whose cylinder is stuck, or a flap that is too slow at full speed. A confirmation sensor in the reject bin, or on the cylinder's extended position, checked within a time window, turns a silent miss into an alarm. On lines with a regulator behind them, a missed reject usually stops the line.
The common thread is that sensors are the truth and the array is a hypothesis. Every place where a sensor can confirm the hypothesis is a place to check it, count the disagreements, and fall back to the safe answer. Log those counts in the event log above: a rising count of unexpected products at the gate is often the first sign that a camera trigger sensor is dirty or that a guide rail has moved.
What the vendors provide
Every platform has some ready-made queue or shift instruction. They save typing, but you still have to choose the trigger, the size and the full/empty behaviour, which is most of the work.
Rockwell Studio 5000. The ladder instructions FFL (FIFO load) and FFU (FIFO unload) work on an array and a CONTROL structure: .LEN is the size, .POS the number of items, .DN means full and .EM empty. FFL/FFU accept a UDT as the element type, so a whole part record can be queued. LFL/LFU are the LIFO pair, and BSL/BSR are bit shift registers over a DINT array, with .UL holding the bit that falls off the end. They are ladder instructions: in a Studio 5000 Structured Text routine you write the queue yourself, as in this chapter, or keep the FIFO in a small ladder routine. They are also triggered by a false-to-true rung transition, so the edge is built in — one of the few places ladder hides a bug that ST makes you handle.
// Studio 5000 ladder, as rung text: load at the scanner, unload at the labeller
// XIC(ScanDone) FFL(ScannedId, IdQueue[0], IdQueueCtl, 20, 0);
// XIC(AtLabeller) FFU(IdQueue[0], LabelId, IdQueueCtl, 20, 0);
// IdQueueCtl.DN = full, IdQueueCtl.EM = empty, IdQueueCtl.POS = items queued
Siemens TIA Portal. There is no FIFO among the basic instructions. The Library of General Functions (LGF), a free download from Siemens' support site, includes a generic FIFO block that works on an array passed as a VARIANT, so the same block queues INTs or whole structures. Bit shift registers are written with SHL/SHR on a DWORD or a loop, as above. Classic STEP 7 had table functions for the same job in its converted TI-S7 blocks, which you will meet in older S7-300 projects.
CODESYS. The standard library has no FIFO. The OSCAT BASIC library, widely used in CODESYS projects, has FIFO_16 and FIFO_32 blocks and the matching STACK_16 and STACK_32, for 16 and 32 entries of DWORD data.
Beckhoff TwinCAT 3. Tc2_Utilities has FB_MemRingBuffer and FB_StringRingBuffer, which queue variable-length entries in a byte buffer. They are passed addresses and sizes with ADR and SIZEOF, so using them means working with pointers (chapter 27).
Most experienced engineers end up with their own small FIFO function block for each element type: thirty lines, fully understood, tested once with plant cases and reused for years. That is a reasonable outcome, and chapter 18 showed how to write it. Whichever you use, the decisions in this chapter stay yours.
Next: beyond the course
That completes the advanced chapters. You can now move a servo axis through a sequence and know which product is where on the belt beneath it — the two things that separate a machine program from a logic exercise. The fastest way to make it stick is to build something with it: go back to the capstone filling line and add a camera, a reject gate four pitches downstream and a ring-buffer event log, then test it with a stream of bottles back to back. For the design side — how to plan the states, the interlocks and the tests before you write the ST — the design book picks up where this course leaves off.