Chapter 13 / 32 · Part 4 · Data

Arrays and FOR loops

Store many readings in an ARRAY and process them with FOR: totals, averages, minimum and maximum, all inside one scan.

Eight tanks, one name

A tank farm has eight storage tanks, each with a level transmitter. Without arrays you declare Level1, Level2 … Level8, then write the high-level check eight times, the total eight times and the average eight times. When the plant adds two tanks, someone edits twenty-four lines and misses one. Every ladder programmer has inherited a program like that, usually with one tank whose alarm limit was copied from its neighbour and never corrected.

An array is one variable that holds many values of the same type, numbered by an index:

VAR
    Level : ARRAY[1..8] OF REAL;      (* tank levels in %, one per tank *)
    Zone  : ARRAY[0..11] OF INT;      (* 12 oven zone temperatures *)
END_VAR

ARRAY[1..8] OF REAL reads exactly as it sounds: eight REALs, indexed 1 to 8. Level[3] is tank 3. The bounds are yours to choose, so choose them on purpose. Number the array the way the machine is numbered. If the P&ID says tanks T-101 to T-108 and the operators say "tank 1" to "tank 8", declare [1..8] so that Level[3] is tank 3. Zero-based arrays are a habit from C, and on a PLC they leave a permanent off-by-one between the code and the drawings. Every alarm text, every HMI faceplate and every phone call from the night shift then has to translate "tank 3" into "element 2", and sooner or later somebody translates it the wrong way.

Vendors differ here. CODESYS, TwinCAT and TIA Portal let you pick any lower bound, including negative ones. Studio 5000 tag arrays always start at 0: a DINT[8] is elements 0 to 7. Rockwell programmers either live with zero-based numbering or declare one spare element and ignore index 0. Both are common, and neither is wrong as long as the whole project does the same thing. The mixed project, where the conveyor programs are one-based and the filler programs zero-based because two people wrote them, is the one that bites.

What an array is in memory

An array is a block of identical slots laid end to end, with no gaps and no labels. The controller keeps only three facts about it: where the first element starts, how big one element is, and the bounds. To find an element it computes an address:

address of Level[i] = start of Level + (i − lower bound) × element size

For Level : ARRAY[1..8] OF REAL, a REAL is 4 bytes, so the whole array is 32 bytes. Suppose the compiler places it at byte 100 of a data block. The layout is then:

ElementOffset calculationBytes
Level[1]100 + (1 − 1) × 4100–103
Level[2]100 + (2 − 1) × 4104–107
Level[3]100 + (3 − 1) × 4108–111
………
Level[8]100 + (8 − 1) × 4128–131
Level[9] (does not exist)100 + (9 − 1) × 4132–135, which belong to the next variable

Two things follow from that table. The lower bound costs nothing: [1..8] and [0..7] produce the same 32 bytes, and the subtraction of the lower bound is folded into the address calculation by the compiler. So there is no performance argument for zero-based arrays on any modern controller, only the habit.

The other is the last row. The formula works for any integer you feed it. Index 9 gives a perfectly valid address; it just happens to be the address of whatever was declared after Level. Whether anything stops that access depends entirely on the platform, which is the subject of the next section.

Element sizes are where the vendors show through. An array of BOOL on CODESYS or TwinCAT uses one byte per element, because a BOOL there occupies a whole byte. Logix stores BOOL arrays packed as bits inside 32-bit words, so a BOOL[32] takes the same four bytes as one DINT, and a COP from a DINT into a BOOL[32] spreads its 32 bits over the 32 elements. A STRING element costs its full declared length whether or not it is used, which chapter 16 puts numbers on. When an array of 500 alarm records will not fit in a small CPU's work memory, the element size is the first thing to look at.

Initialisers and indexing

An array can be given starting values in its declaration with a list in square brackets. A filling machine that runs five bottle sizes keeps its fill weights in a small table, and the product number from the HMI selects one:

PROGRAM Main
VAR
    Product   : INT := 2;    (* input: product selected on the HMI, 1..5 *)
    FillGrams : ARRAY[1..5] OF INT := [250, 500, 750, 1000, 2000];
    Target    : INT;         (* fill target sent to the weigh controller *)
END_VAR

Target := FillGrams[Product];

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Product.

Change Product to 1, 4 or 5 and the target follows. The index does not have to be a literal. Any integer expression works, and that is why arrays earn their place: one line of code serves every product.

Now type 6 into Product. The engine stops the scan with an array out-of-bounds fault, because FillGrams[6] does not exist. Real controllers react in very different ways, and you need to know which one you are standing in front of:

PlatformWhat an out-of-range index does
Siemens S7-1500Programming error. The CPU calls OB121; if that OB is not in the project, it goes to STOP.
Rockwell ControlLogix / CompactLogixMajor fault, type 4 code 20 ("array subscript too large"). The controller faults the whole program.
CODESYS / TwinCAT 3By default, nothing is checked. The write lands in whatever memory sits next to the array. Add the CheckBounds implicit-check function during commissioning to catch it.

The CODESYS row is the dangerous one. A stopped CPU is loud. A silently overwritten neighbour variable is a bug that appears weeks later in a part of the program nobody touched. Look back at the memory table: FillGrams[6] on a CODESYS target is simply the next two bytes after the array, which might be Target itself, or a setpoint, or the first half of a REAL.

Two vendor details are worth knowing before you rely on the table.

On an S7-1500 a block can take over the error itself. If a block contains GET_ERR_ID or GET_ERROR, the CPU does not call OB121 for a programming error inside that block; the program has to read the error ID and decide what to do. That is useful in a block that indexes with data from outside, and dangerous in a block where somebody added the instruction once for a test and never looked at its output again. On Logix, a major fault of type 4 can be handled by the program's fault routine, which may clear it and let the controller carry on. A fault routine that clears every type 4 fault without logging which one hides exactly the bug the fault was trying to show you.

On CODESYS and TwinCAT, read what CheckBounds actually does once you add it. The implementation the IDE generates for you is a clamp: if the index is below the lower bound it returns the lower bound, if it is above the upper bound it returns the upper bound. Nothing stops. So with CheckBounds in place, FillGrams[6] := 0; writes, without any warning, into FillGrams[5], the 2000 g product. That is better than corrupting an unrelated variable, and much worse than a fault you can see. Edit the function so it also records the event: set a global flag, count the hits, or call the runtime's exception routine if your target provides one. Also note that CODESYS does not apply implicit checks inside library blocks by default, so an index error inside a vendor library is not caught by your CheckBounds at all.

Whatever the platform, the rule is the same: an index that comes from outside the program must be checked before it is used. An HMI field, a barcode, a recipe number from a SCADA system: all of them will one day hold 0 or 99.

IF Product >= 1 AND Product <= 5 THEN
    Target := FillGrams[Product];
ELSE
    Target := 0;          (* and raise a "bad product number" alarm *)
END_IF;

A field story: product zero

A bottling line ran for two years without trouble on a CODESYS-based controller. Then the HMI was replaced, and the new numeric entry field for the product number defaulted to 0 when an operator cleared it. Nobody noticed on the first night, because product 0 did not stop anything. The program read FillGrams[0], which on that controller was the last two bytes of a REAL declared just before the array, and sent a fill target of a few grams to the weigh controller. The filler ran, the checkweigher rejected every bottle, and the operators spent an hour looking at the weigh cells. The fix took one IF statement of the kind shown above, plus an alarm text that said "invalid product number", so the next time it would be the HMI that got the blame.

Two smaller rules about initialisers. First, the list must match the array length on this engine. IEC 61131-3 also allows a short list (the rest are zero) and a repeat factor such as [8(0)], which CODESYS and TwinCAT accept, but don't rely on either in portable code. Second, like any initial value, the list is applied once when the controller starts the program, not on every scan. If the program writes FillGrams[2] := 520;, the table keeps 520 until the next cold start. That makes an initialised table a poor home for anything an operator must be able to trust after a download: chapter 30 keeps recipe tables in retained memory with a validation step instead.

FOR … TO … BY

Arrays become powerful when you combine them with a loop. The FOR loop runs its body once for each value of a counter:

FOR i := 1 TO 8 DO
    (* body runs with i = 1, 2, 3 ... 8 *)
END_FOR;

The counter i must be an integer variable you declare like any other. Add BY to change the step: BY 2 visits every second element, BY -1 counts down. A rotary filler with eight heads can run all of them, or only the odd ones during a half-speed changeover:

PROGRAM Main
VAR
    OddOnly  : BOOL;                  (* input: use heads 1, 3, 5, 7 only *)
    HeadOn   : ARRAY[1..8] OF BOOL;   (* one valve command per filling head *)
    StepSize : INT;
    i        : INT;
    Active   : INT;                   (* heads switched on this scan *)
    Scans    : DINT;
    Passes   : DINT;                  (* loop iterations since start *)
END_VAR

Scans := Scans + 1;

FOR i := 1 TO 8 DO
    HeadOn[i] := FALSE;               (* start from a known state every scan *)
END_FOR;

IF OddOnly THEN StepSize := 2; ELSE StepSize := 1; END_IF;

Active := 0;
FOR i := 1 TO 8 BY StepSize DO
    HeadOn[i] := TRUE;
    Active := Active + 1;
    Passes := Passes + 1;
END_FOR;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: OddOnly.

Watch Passes against Scans. With all heads in use, Passes is exactly eight times Scans: the loop runs all eight iterations every scan, then the scan ends. Flip OddOnly and Passes grows by four per scan. Notice the first loop too. Before choosing which heads to switch on, the program switches them all off. That is the array version of "every output gets a value on every scan", and it stops a head from staying on after it was dropped from the pattern.

What the loop does, pass by pass

It helps to see a FOR loop the way the controller executes it, because every rule below comes out of this sequence. For FOR i := 1 TO 8 BY 2 DO:

  1. Assign the start value: i := 1.
  2. Test: with a positive step, is i ≤ 8? If not, leave the loop.
  3. Run the body.
  4. Add the step: i := i + 2.
  5. Go back to step 2.
Passi at the testTest i <= 8Body runs?
11TRUEyes
23TRUEyes
35TRUEyes
47TRUEyes
59FALSEno, loop ends

The test runs once more than the body. That fifth test is where the next few rules come from. After the loop, the counter has been stepped past the end, and on most compilers it holds 9. And the step in line 4 has to be able to carry the counter past the end value, which it cannot do if the step is zero or the end value is the largest number the counter's type can hold.

That last case is easy to write by accident. CODESYS documents it directly: if the end value equals the upper limit of the counter's data type, the loop never ends. FOR i := 0 TO 32767 DO with i : INT steps from 32,767 to the next value, which wraps to −32,768, and the test i <= 32767 is TRUE again. A SINT counter running to 127 does the same. The loop runs until the watchdog stops the controller. A loop over a full 16-bit range needs a DINT counter.

Three rules for FOR loops that keep you out of trouble on every platform:

  • Never write to the counter inside the loop. IEC 61131-3 forbids it, and controllers disagree on what happens if you do. CODESYS honours the change and skips iterations; this engine ignores it. Code that depends on either is a trap.
  • Don't use the counter after the loop. Whether i ends at 8 or 9 after FOR i := 1 TO 8 is implementation-dependent. CODESYS leaves 9; this engine leaves 8. If you need a result from the loop, store it in a variable of its own.
  • Never compute a step that can be zero. BY 0 is an endless loop on CODESYS (this engine treats it as 1 without a warning). A step taken from an HMI value must be clamped first.

The standard's wording. IEC 61131-3 asks that the control variable, its initial value and its final value are expressions of the same integer type, and that none of them is altered by the statements inside the loop. The second half is the rule people break: changing the end variable inside the loop, as well as the counter. Most compilers accept it without a warning and then behave in their own way, so treat both as read-only once the loop has started.

Totals: clear before you add

The most common array bug in PLC code is also the simplest. Here is a total of four tank levels, with a switch that lets you remove the line that clears it:

PROGRAM Main
VAR
    ClearFirst : BOOL := TRUE;     (* input: switch off to see the bug *)
    Level      : ARRAY[1..4] OF INT := [120, 340, 95, 410];   (* litres *)
    Total      : DINT;
    i          : INT;
END_VAR

IF ClearFirst THEN
    Total := 0;
END_IF;

FOR i := 1 TO 4 DO
    Total := Total + Level[i];
END_FOR;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: ClearFirst.

With ClearFirst on, Total sits at 965 litres. Switch it off and Total climbs by 965 every scan: ten times a second here, a hundred times a second on a 10 ms task. In a desktop program, a sum written this way runs once and is correct. On a PLC the same lines run again on the next scan, and the next, adding the same tanks on top of the last answer.

The fix is one line, and it belongs immediately above the loop: Total := 0;. The declaration is the wrong place, because an initial value runs once at start-up, and so is a reset button. Put it right before the FOR. The same applies to every accumulator: counts of faulted sensors, sums for averages, "any alarm active" flags (start them at FALSE, then OR into them). The bug survives testing surprisingly often, because a programmer who looks at the value just after a download sees the right number for the first scan.

Average, minimum and maximum

Six probes in a blast freezer give six temperatures. The control system needs the average for display, the warmest reading for the alarm, and the coldest one to spot an iced-up evaporator:

PROGRAM Main
VAR CONSTANT
    PROBES : INT := 6;
END_VAR
VAR
    Temp    : ARRAY[1..6] OF REAL := [-18.5, -19.2, -17.8, -21.0, -18.9, -16.4];  (* °C *)
    Sum     : REAL;
    Avg     : REAL;
    MinT    : REAL;
    MaxT    : REAL;
    Warmest : INT;     (* probe number of the warmest reading *)
    i       : INT;
END_VAR

Sum := 0.0;
MinT := Temp[1];          (* start from a real reading, not from 0 *)
MaxT := Temp[1];
Warmest := 1;

FOR i := 1 TO PROBES DO
    Sum := Sum + Temp[i];
    IF Temp[i] < MinT THEN
        MinT := Temp[i];
    END_IF;
    IF Temp[i] > MaxT THEN
        MaxT := Temp[i];
        Warmest := i;
    END_IF;
END_FOR;

Avg := Sum / INT_TO_REAL(PROBES);

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Temp[3].

Type a new value for probe 3, −12.0 and then −25.0, and watch the three results move together. (The average shows more decimals than a real PLC would: this engine calculates REAL in 64-bit, where a controller's 32-bit REAL carries about seven significant digits.)

Three details carry the whole technique. All three results are reset before the loop, every scan, for exactly the reason in the previous section. The minimum and maximum start from the first element instead of a made-up value. And Warmest is updated in the same IF as MaxT, so the index and the value can never disagree. Because the comparison is >, a tie keeps the lower probe number; write >= and it keeps the higher one. Decide which one the operator expects and write it down.

The made-up starting value is a bug worth seeing once:

On a real freezer one more question comes before the arithmetic: is every probe worth averaging? A probe with a broken wire reads −50 °C or +150 °C, depending on the transmitter's failure setting, and one such value drags the average and wins the minimum or maximum outright. Keep a validity flag per probe beside the reading and skip invalid ones; chapter 14 shows the CONTINUE statement doing exactly that, and chapter 19 shows where the validity flag comes from.

Finding an index

Often you don't need a value but a position: which tank is low, which station holds the reject, which heating zone failed. Search for the first match and remember where it was:

PROGRAM Main
VAR CONSTANT
    TANKS   : INT := 6;
    LOW_PCT : INT := 15;          (* low-level alarm threshold, % *)
END_VAR
VAR
    Level    : ARRAY[1..6] OF INT := [62, 48, 71, 33, 90, 55];
    FirstLow : INT;               (* first tank below LOW_PCT, 0 = none *)
    LowCount : INT;
    i        : INT;
END_VAR

FirstLow := 0;
LowCount := 0;
FOR i := 1 TO TANKS DO
    IF Level[i] < LOW_PCT THEN
        LowCount := LowCount + 1;
        IF FirstLow = 0 THEN
            FirstLow := i;        (* keep the first one only *)
        END_IF;
    END_IF;
END_FOR;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Level[2], Level[5].

Set tank 5 to 10 and FirstLow becomes 5. Now set tank 2 to 8: FirstLow moves to 2, LowCount reads 2. Raise both back above 15 and FirstLow returns to 0, because it is cleared before every search. That "0 means none" convention works only because the array starts at 1, which is one more argument for numbering arrays like the plant. On a zero-based Logix array, "none" needs another value, and −1 is the usual choice; make sure every consumer of the result checks for it before using it as an index.

The loop still visits all six tanks after it has found the first one. For six elements that costs nothing. For a 500-entry lookup table it is wasted work, and the next chapter's EXIT statement lets you leave the loop as soon as you have the answer.

Copying, clearing and comparing whole arrays

Some operations work on the whole array at once, and here the vendors part company.

IEC 61131-3 allows assignment between two arrays of exactly the same type: Active := Edit; copies every element. CODESYS, TwinCAT and TIA Portal SCL all accept it, and so does this engine. Studio 5000 does not: in Logix Structured Text an array copy is an instruction, COP(Edit[0], Active[0], 6);, where the length counts elements of the destination. Rockwell also has CPS, the same copy made uninterruptible so that an I/O update or a higher-priority task cannot see it half done, and FLL to fill an array with one value. TIA Portal offers MOVE_BLK and FILL_BLK for the same jobs when you need part of an array. Whatever the tool, a whole-array copy costs as much as a loop over the elements, because underneath it is one.

Comparison has no shortcut anywhere. IF Edit = Active THEN is refused for arrays by the compilers you will meet; you compare element by element. A furnace's zone setpoints are edited on the HMI in one table and copied to the active table only when the operator presses Accept:

PROGRAM Main
VAR
    EditZ3     : INT := 185;     (* input: operator's setpoint for zone 3, °C *)
    Accept     : BOOL;           (* input: Accept button on the HMI *)
    Edit       : ARRAY[1..6] OF INT := [180, 180, 185, 190, 190, 185];
    Active     : ARRAY[1..6] OF INT := [180, 180, 185, 190, 190, 185];
    Diffs      : INT;            (* zones where Edit and Active disagree *)
    AcceptEdge : R_TRIG;
    i          : INT;
END_VAR

Edit[3] := EditZ3;

Diffs := 0;
FOR i := 1 TO 6 DO
    IF Edit[i] <> Active[i] THEN
        Diffs := Diffs + 1;
    END_IF;
END_FOR;

AcceptEdge(CLK := Accept);
IF AcceptEdge.Q AND Diffs > 0 THEN
    Active := Edit;              (* whole-array copy *)
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: EditZ3, Accept.

Type 200 into EditZ3. Diffs reads 1, which is what an HMI would use to light a "changes pending" lamp. Press Accept: the copy runs on the edge, and on the next scan Diffs is back to 0 because the tables agree. The counter is calculated before the copy, so on the scan of the copy it still shows the old difference for one scan. That is harmless here and worth knowing when another part of the program reacts to Diffs.

The same split between an edit table and an active table is the core of every recipe system. Chapter 30 adds the validation that belongs between them.

One instance per element: arrays of timers

Arrays are not only for numbers. An array element can be a function block instance, and that solves a problem every multi-zone machine has: each zone needs its own timer.

A conveyor with four accumulation zones raises a jam alarm when a zone's photo-eye stays blocked for two seconds. Four zones need four TONs, because a timer's elapsed time is memory that belongs to one zone:

PROGRAM Main
VAR
    Blocked  : ARRAY[1..4] OF BOOL;   (* inputs: zone photo-eyes, TRUE = blocked *)
    (* one timer per zone *)
    JamTimer : ARRAY[1..4] OF TON;
    Jam      : ARRAY[1..4] OF BOOL;   (* outputs: jam alarm per zone *)
    JamCount : INT;
    i        : INT;
END_VAR

JamCount := 0;
FOR i := 1 TO 4 DO
    JamTimer[i](IN := Blocked[i], PT := T#2s);
    Jam[i] := JamTimer[i].Q;
    IF Jam[i] THEN
        JamCount := JamCount + 1;
    END_IF;
END_FOR;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Blocked[1], Blocked[2], Blocked[3], Blocked[4].

Block zone 2 and watch JamTimer[2].ET climb to two seconds before Jam[2] sets. Block zone 4 a second later and it times on its own. Each instance keeps its own ET between scans, and every instance is called on every scan, which is the rule from chapter 9 applied to a whole array at once.

Chapter 9's other rule, never put a timer inside a loop expecting it to progress, still holds. The timers here do not progress during the loop. Each one is called once per scan and advances by one scan's worth of time, exactly as it would outside a loop. The FOR only saves you writing four calls.

What does not work is one shared instance:

The same applies to edge detectors, counters and every block you write yourself in chapter 18. If a block remembers something, each thing it remembers about needs its own instance.

The loop finishes inside one scan

A FOR loop is not a background job. All of its iterations run inside the scan, one after another, before the next line of the program. The outputs are not written, the inputs are not refreshed and no other part of the program runs until the loop is done. That is what makes the totals above trustworthy, because every tank is read from the same input snapshot. It also means every iteration is paid for in scan time.

The cost is small but real. Siemens rates an S7-1511 at roughly 0.4 µs per floating-point instruction. A loop body with five REAL operations costs about 2 µs per element before you count the indexing and the loop test, so the numbers grow like this:

ElementsFloating-point work at 0.4 µs × 5 per elementShare of a 10 ms task
8 tanks16 µs0.16 %
64 alarms128 µs1.3 %
1,000 table rows2 ms20 %
20,000 logged samples40 msfour times the task
80,000 samples160 mspast the S7-1500's default 150 ms cycle limit

Those figures leave out the index calculation and the loop test on every pass, which on a small CPU can cost as much again as the body. Faster CPUs such as an S7-1517 or a ControlLogix 5580 move the rows down the table; none of them makes the shape go away. Rockwell and CODESYS tasks have watchdogs too, and all of them stop the controller when one trips.

The rules I work to:

  • Loop over the I/O-sized arrays you have (8 tanks, 24 zones, 64 alarms) without a second thought.
  • For thousands of elements, ask whether the work must be done every scan. A daily total can be built a little at a time: process 100 elements per scan with an index that survives between scans, and set a Done flag when it wraps round. Chapter 14 builds exactly that as a live example.
  • Measure before you guess. TIA Portal's RUNTIME instruction, the task monitor in CODESYS and TwinCAT, and the MaxScanTime attribute of a Logix task (read with GSV) all give you real numbers on the real CPU.
  • Never put a wait inside a loop, or a timer or edge detector you expect to advance between iterations. The machine does not move while your loop runs. Chapter 14 shows what happens when people try.

Constants for bounds, and two-dimensional arrays

The arrays above repeat their size in two places: in the declaration and in the TO of every loop. When the plant grows from six tanks to eight, both must change. The tidy solution is a named constant used for both:

VAR CONSTANT
    TANKS : INT := 8;
END_VAR
VAR
    Level : ARRAY[1..TANKS] OF REAL;
END_VAR

FOR i := 1 TO TANKS DO ...

CODESYS, TwinCAT and TIA Portal (with a global user constant) all accept a constant as an array bound. This engine does not yet, so the live examples write the literal size in the declaration and use the constant for the loop: two places to edit instead of one per loop. Forget the declaration and nothing warns you at build time; the first scan that reaches the new tank faults. When you resize an array, search the project for the old number. Studio 5000 array sizes are fixed numbers in the tag editor, and Rockwell code usually reads the size from the array's dimension instead, with the SIZE instruction. CODESYS and TwinCAT also offer UPPER_BOUND(Level, 1) and LOWER_BOUND(Level, 1), which ask the array itself. Those two functions come into their own with arrays whose size the caller decides, ARRAY[*], which chapter 27 covers for CODESYS and the S7-1500.

When data has rows and columns (a pallet layer, a tray of 4 × 6 vials, a 2-axis lookup table) use a two-dimensional array and nest two loops:

PROGRAM Main
VAR
    Row1Full : BOOL;                         (* input: last carton of row 1 placed *)
    Layer    : ARRAY[1..2, 1..3] OF BOOL;    (* 2 rows x 3 cartons on a pallet layer *)
    Row      : INT;
    Col      : INT;
    Placed   : INT;
    LayerDone : BOOL;
END_VAR

Layer[1, 1] := TRUE;
Layer[1, 2] := TRUE;
Layer[2, 1] := TRUE;
Layer[2, 2] := TRUE;
Layer[2, 3] := TRUE;
Layer[1, 3] := Row1Full;

Placed := 0;
FOR Row := 1 TO 2 DO
    FOR Col := 1 TO 3 DO
        IF Layer[Row, Col] THEN
            Placed := Placed + 1;
        END_IF;
    END_FOR;
END_FOR;
LayerDone := Placed = 6;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Row1Full.

Switch Row1Full on and the count reaches six, completing the layer. The inner loop runs completely for each pass of the outer one: six iterations in total, Layer[1,1] through Layer[2,3]. Keep the outer loop on the slower-changing index, rows outside and columns inside, so the code reads like the layer looks from above.

Nested loops multiply. Two loops of 3 are 9 passes; a 24 × 24 vial tray is 576 passes; a 100 × 100 lookup table is 10,000 passes, which puts you in the 1,000-row line of the cost table above several times over. Before you nest, multiply the bounds and check the result against the table.

Logix allows up to three dimensions, and you will rarely need even that. Two dimensions cover almost everything on a machine. If you find yourself reaching for three, an array of structures (chapter 15) usually describes the data better: Tray[5].Vial[3].Weight says what it means, and Data[5, 3, 2] does not.

Reviewing array code

When I review someone else's array code, I read it in this order. Most of the bugs in this chapter turn up in the first two questions.

Where does every index come from? A literal or a FOR counter over the declared bounds is safe. Anything else (an HMI value, a recipe field, a calculation such as Pos + Offset) needs a range check before it reaches the brackets, and the check should use the same constants as the declaration.

What is reset before each loop? Every accumulator, count, flag and search result that the loop writes must be given a starting value on the line before the FOR, every scan. Then check that minimum and maximum searches start from a real element.

Do the loop bounds match the declaration? Look for a TO 7 on an eight-element array, or a TO 8 left behind after the array was cut to six. A constant used in both places removes the question.

Is the counter used after the loop, or written inside it? Either is a portability bug even if it works on today's controller.

What does the loop cost? Multiply the bounds of nested loops, and look for loops over arrays that grow in service, such as logs, recipe tables and part histories. Those are the ones that are fast at the factory acceptance test and slow two years later.

Does every element of an output array get a value on every scan? The rotary filler above clears all heads before setting some. Code that only ever sets HeadOn[i] := TRUE leaves heads on after the pattern changes.

A field story: the ninth station

A Studio 5000 project for an indexing table had eight stations, stored as DINT[8] and numbered 0 to 7 in the code. The HMI numbered them 1 to 8, as the machine's labels did, and converted with a - 1 in each faceplate. A ninth station was added during a rebuild. The array was resized to DINT[9], but one routine still converted with a hard-coded + 1 somewhere else, and the first time station 9 was selected for manual jogging, the controller took a major fault type 4 code 20 and stopped the whole cell. Online, the fault record pointed straight at the rung. The fix was a single constant for the station count, used in the tag size, every loop and every HMI conversion, and one clamp at the point where the HMI value entered the program.

Where this leads

You can now hold a whole row of equipment in one variable and process it with a FOR loop. You can write totals that are cleared before they are summed, averages, minimum and maximum searches that start from a real reading, and index searches that report "none" honestly. You have seen what an array looks like in memory, why an index from outside must be checked, what each platform does when it is not, and why each zone of a machine needs its own timer instance. And you know that the loop runs to completion inside one scan, so its cost is paid in scan time.

FOR is the loop to reach for when you know how many elements there are. The next chapter covers the two loops that run until a condition changes, WHILE and REPEAT, together with EXIT for leaving early, and the one way of using them that brings a running machine to a halt.

Practise this chapter