Chapter 23 / 32 · Part 6 · Professional ST

Testing and debugging

Test ST with plant cases before the machine exists, watch values online, and debug from a hypothesis instead of guessing.

Every bug happens on a particular scan

A machine misbehaves "sometimes". The capper skips a bottle once every few hundred, the pump restarts "on its own", the batch total is wrong "after a recipe change". Those descriptions are true and useless. Underneath every one of them is a specific scan on which a specific variable took a value you did not expect.

Testing and debugging Structured Text is the craft of finding that scan. The tools (watch tables, forcing, simulators, trace recorders) come second. They only help if you already think of the program as a sequence of scans, each one reading a snapshot, executing top to bottom, and writing outputs. Chapter 1 asked you to step a program one scan at a time. In this chapter that habit becomes a method.

The method has three parts. Before the code exists, write down what the machine must do as plant cases: given these inputs, after this much time, these outputs. While the code is being written, run those cases against it, including the awkward ones at the boundaries. And when something fails, on the bench or on the plant floor, debug from a hypothesis you can check instead of a change you hope will help.

Online watch, write and force

Every IDE lets you go online with a running controller and watch variables change: CODESYS and TwinCAT show live values next to every variable in the editor, TIA Portal has watch tables and the glasses icon for monitoring SCL, and Studio 5000 shows tag values in the Monitor Tags view and inline in the ST editor. Watching is safe. Use it constantly.

Watching has one limit that catches everybody once. The IDE does not see every scan. It polls the controller over the network and refreshes the display a few times a second, so what you see is a sample of the variable, taken whenever the next poll happened to arrive. A level, a count or a step number changes slowly enough for that to be fine. A BOOL that is TRUE for one 10 ms scan will almost never appear in a watch window at all. When a fault "never shows up online", suspect a pulse shorter than the refresh, and reach for a trace (later in this chapter).

Changing values online comes in two very different strengths.

A write (CODESYS calls it Write values, TIA Portal Modify) puts a value into a variable once. If the program assigns that variable on every scan, your value lasts until the next assignment, a few milliseconds, and you will never see it. Writes are for variables the program only reads or only changes occasionally: a setpoint, a recipe number, a latch.

A force pins a value. The controller re-applies it on every scan, overriding both the input terminal and anything your program writes. It is the most powerful debugging tool in the IDE and the most dangerous.

Forcing a physical output bypasses every interlock you wrote. Force a fill valve open and it stays open when the tank is full, when the E-stop logic in the PLC drops, when the high-level alarm trips. Force a conveyor motor on and the jam detection is irrelevant. People have been injured by forced outputs that someone forgot to remove.

Forces also outlive the debugging session. On an S7-1500 a force job stays active in the CPU after you close TIA Portal and even through a power cycle. In Studio 5000, I/O forces are stored in the controller and the project, and the controller's FORCE LED is the only hint on the cabinet door that one is active. CODESYS asks when you log out whether to keep forced values; the easy answer is the wrong one.

The platforms also differ in what they let you force at all:

PlatformWhat can be forcedWhere forces liveHow you know one is active
S7-1200 / S7-1500 (TIA Portal)Peripheral inputs and outputs only (addresses with :P, such as %I0.3:P); not DB variables or memoryIn the CPU; the force job survives power off and stays until it is stopped in the force tableForce table, CPU status, diagnostic buffer
ControlLogix / CompactLogix (Studio 5000)I/O tags (and aliases to them), not internal tagsIn the controller and saved with the projectFORCE LED on the controller and the force status in the toolbar
CODESYS / TwinCAT 3Practically any variable, internal ones includedIn the runtime, while the session lasts or as long as you choose to keep them on logoutForced values are marked in the editor and listed in the watch views

The Siemens and Rockwell restriction is deliberate: a force is a test of the wiring and the field, applied at the I/O boundary. CODESYS's freedom to force any internal variable is convenient on the bench and a trap on a machine, because a forced internal latch is invisible to anyone who only checks the I/O.

On these pages, the input panels beside every live program are forces: the program reads them but cannot overwrite them. That is exactly how the graded challenges test your code.

Breakpoints on a running machine

CODESYS and TwinCAT support breakpoints and single-stepping in ST, and TIA Portal supports breakpoints in SCL on S7-1500 CPUs. Studio 5000 has none: Logix code is debugged by watching, trending and adding logic. On the bench, a breakpoint is a fine tool. On a running machine it is a bad one, because when the task stops, nothing in that task is computed any more: not the timers, not the interlocks, not the outputs. Depending on the platform and the configuration, the outputs freeze at their last value or the I/O goes to its configured stop state, while drives, robots and air cylinders that do not need the PLC to keep moving carry on doing whatever they were last told.

CODESYS and TwinCAT have a safer tool for most of what people use breakpoints for, called flow control. With it switched on, the editor marks which lines of the POU actually executed in the current cycle and shows the values at each point of execution, while the task keeps running. It answers "did this IF branch run?" without stopping anything. It costs some cycle time, so switch it off when you have your answer.

Trace: seeing every scan

A trace records values inside the controller, scan by scan or at a fixed sample rate, and uploads the record afterwards. It sees what the watch window misses. Each platform has one: the Trace object in CODESYS, TwinCAT Scope, the trace function of the S7-1200 and S7-1500 in TIA Portal, and Trends in Studio 5000. A trace has a trigger ("start recording when Count changes by more than 1") and records some time before and after it, so you capture the moments around a fault that happens once an hour without sitting there watching for it.

When the vendor trace is not available, too slow, or not allowed on a production controller, build a small recorder in ST. It is a few lines of code and it lives in the program until the fault is found.

PROGRAM Main
VAR CONSTANT
    DEPTH      : INT := 6;   (* scans kept; a real recorder keeps hundreds *)
    POST_SCANS : INT := 2;   (* scans still recorded after the trigger *)
END_VAR
VAR
    Eye      : BOOL;   (* input: the signal under suspicion *)
    Trigger  : BOOL;   (* input: the symptom, e.g. "count jumped by 2" *)
    Rearm    : BOOL;   (* input: start recording again *)
    Scan     : DINT;
    Hist     : ARRAY[1..6] OF BOOL;   (* Hist[1] = newest scan *)
    Frozen   : BOOL;
    FrozenAt : DINT;
    PostLeft : INT := -1;            (* -1 = waiting for a trigger *)
    TrigEdge : R_TRIG;
    i        : INT;
END_VAR

Scan := Scan + 1;
TrigEdge(CLK := Trigger);

IF Rearm THEN
    Frozen   := FALSE;
    PostLeft := -1;
END_IF;

IF NOT Frozen THEN
    (* Shift the history one place and store this scan's value *)
    FOR i := DEPTH TO 2 BY -1 DO
        Hist[i] := Hist[i - 1];
    END_FOR;
    Hist[1] := Eye;

    (* After the trigger, record a few more scans, then freeze *)
    IF TrigEdge.Q AND PostLeft < 0 THEN
        PostLeft := POST_SCANS;
    ELSIF PostLeft > 0 THEN
        PostLeft := PostLeft - 1;
    END_IF;
    IF PostLeft = 0 THEN
        Frozen   := TRUE;
        FrozenAt := Scan;
    END_IF;
END_IF;

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

Toggle Eye on and off a few times, then switch Trigger on. Two scans later the record freezes: Hist[1] is the last scan recorded, Hist[6] the oldest, and FrozenAt says which scan the record ends on. You now have the three scans before the symptom and the two after it, which is exactly what you could not see online. Switch Rearm on and off to record again.

A production version keeps a few hundred samples of several signals in an array of structures, stores a timestamp per sample, and uses a ring index instead of shifting the whole array every scan, because copying 500 entries every scan costs cycle time. The trigger is the symptom stated as logic: a count that jumps by two, a step that changes twice in one second, a valve output that turns on while its permissive is FALSE. Writing the trigger is often the moment the bug becomes obvious.

Write the plant cases before the code

A plant case is a requirement you can run. Each one says: set these inputs, let this many scans and this much time pass, then check these outputs. A list of them, run in order on one controller, is a test of the whole behaviour, including memory, because state carries from one case to the next.

Every challenge in this course is graded exactly like that. The cases are JSON objects with five fields:

[
  { "description": "Start pressed",
    "setInputs": { "Start": true, "Stop": false },
    "expect": { "Motor": true } },
  { "description": "Start released, still running (seal-in)",
    "setInputs": { "Start": false },
    "scans": 5,
    "expect": { "Motor": true } },
  { "description": "Overload for 2.1 s trips it",
    "setInputs": { "Overload": true },
    "scans": 21, "advanceMs": 2100,
    "expect": { "Motor": false, "Tripped": true } }
]

The fields work like this:

  • setInputs forces inputs. They stay forced until a later case changes them, so the second case above still has Stop FALSE.
  • scans (default 1) is how many scans run before checking; advanceMs spreads that much virtual time across them (default 100 ms per scan), so a timer sees its elapsed time grow scan by scan.
  • expect lists the outputs to compare after the last scan. Anything not listed is not checked.
  • All cases run in order on one engine. The third case only makes sense because the first one started the motor.

Writing these before the program does three things. It forces you to decide behaviour you would otherwise leave vague: "what does the motor do if Start is held while Stop is pressed?" is a requirement, and it should be decided before anyone writes the implementation. It gives you a definition of done that does not depend on how confident you feel. And it is a regression suite: after every change, run all the cases again, because the change that fixes case 7 is often the one that breaks case 3.

Each case is a given, when, then sentence with the words taken out. "Given the motor is running, when the overload is present for 2.1 s, then the motor stops and Tripped is TRUE." Write the sentence first if the JSON feels abstract; a customer can read the sentence and tell you it is wrong before anything is built. Number the requirements in the specification and put the requirement number in each case description, and you have the traceability table that a factory acceptance test asks for. The design book goes further into building that table and keeping tests independent of the implementation.

A test that cannot fail is not a test

Every challenge in this course checks one more thing you may not have noticed: the starter code must fail the cases. If the starter passed, the cases would not be testing anything. Apply the same rule to your own tests. Before you trust a new case, break the code on purpose (flip a >= to >, delete the seal-in) and watch the case go red. A suite that has never failed has never been tested itself.

The same trap shows up in a quieter form. A test that only checks outputs which happen to start at their default value passes against an empty program: if Tripped is FALSE by default, a case expecting Tripped = FALSE proves nothing until another case has made it TRUE first. The grader on these pages rejects a challenge where an output has the same expected value in every case, for exactly that reason.

Unit test frameworks on real platforms

On a real project the same cases go into the factory acceptance test (FAT) document, run against a simulator before the machine ships. IEC 62381 describes the FAT, the site acceptance test (SAT) and the site integration test for process automation systems, and many customer specifications refer to it. For code-level tests, each platform has its own tooling:

PlatformTest toolingRuns against
TwinCAT 3TcUnit, an open-source framework: test suites are function blocks that extend FB_TestSuite, with assertions such as AssertEqualsThe TwinCAT runtime on the engineering PC or a target
CODESYSCODESYS Test Manager (a commercial add-on); CfUnit, an open-source framework built the same way as TcUnitCODESYS Control Win or a target
TIA PortalTIA Portal Test Suite Advanced: an application test for S7-1500 blocks, plus a style-guide checkerS7-PLCSIM Advanced (S7-1500 only)
Studio 5000No built-in unit test framework; tests are usually written as test routines and run on an emulated controllerFactoryTalk Logix Echo (ControlLogix 5580, CompactLogix 5380 and their GuardLogix versions)

A TcUnit test looks like this. It is vendor syntax, built on the object-oriented extensions of IEC 61131-3 third edition (EXTENDS, METHOD), so it is shown here as a listing and cannot run on these pages:

// TwinCAT 3 + TcUnit, vendor syntax (not runnable on this site)
FUNCTION_BLOCK FB_PackCounter_Test EXTENDS TcUnit.FB_TestSuite
VAR
    fbCounter : FB_PackCounter;   // the block under test
END_VAR

ThreeBottlesFillAPack();          // the body calls each test method

METHOD ThreeBottlesFillAPack
VAR
    i : INT;
END_VAR
TEST('ThreeBottlesFillAPack');
FOR i := 1 TO 3 DO
    fbCounter(Eye := TRUE);
    fbCounter(Eye := FALSE);
END_FOR
AssertEquals(Expected := TRUE, Actual := fbCounter.PackFull,
             Message := 'pack not full after three bottles');
TEST_FINISHED();

The shape is the same as a plant case: set inputs, call the block, check the outputs. Each test is a method that starts with TEST() and ends with TEST_FINISHED(), which lets a single test span several PLC cycles when it has to wait for a timer. A program calls TcUnit.RUN(), and the results appear in the TwinCAT error list. Whatever the tool, the hard part is the same: deciding the cases.

Boundary cases

Most logic bugs live at the edges of a range, not in the middle. A comparison written > that should be >=, a counter that fires one part late, a timer case that checks at 0.99 s and passes by luck. For every threshold, count and time in your requirements, write cases at the boundary and one step either side of it.

ThingCases worth writing
Threshold 80.0 °C79.9, exactly 80.0, 80.1, and the way back down through the hysteresis band
Counter preset 1211 (not yet), 12 (done), 13 (what now?), and reset in the middle
Timer 2 sjust under (1.9 s), just over (2.1 s), and input dropping at 1.9 s then returning
Analog input0, full scale, below 0 (wire break), above full scale (overload)
Integers0, 1, the maximum (32,767 for INT), negative values
Sequencethe first scan after power-up, and every input arriving in the "wrong" order

Timers deserve particular care, because their boundary depends on when they started.

Cover every state and every way out of it

For a state machine (chapter 12), boundary testing means coverage of the graph. Draw the states and transitions, and write at least one case for each of these:

Coverage itemExample for a fill, mix, drain sequence
Every state enteredIdle, Filling, Mixing, Draining, Faulted each reached at least once
Every transition takenFilling to Mixing on the high level; Mixing to Draining on the timer
Every timeout firedFilling with the inlet blocked runs into the fill timeout
Every abort from every stateStop pressed in Filling, in Mixing and in Draining
Every recoveryFrom Faulted, through reset, back to Idle, and a fresh start
Inputs that arrive early or twiceThe high-level switch already made when Filling starts

The abort row is the one that gets skipped. Testing "stop while filling" and assuming "stop while mixing" behaves the same misses the agitator that keeps running because its output is written from the Mixing branch. Five states and four aborts is twenty minutes of extra cases, and it is twenty minutes that catches real bugs.

Simulate the plant inside the program

You will rarely get the real machine to test on. It is still being built, or it is in production, or it is a 20-tonne press nobody will let you cycle for fun. The answer is to simulate it: a small piece of code that behaves like the plant, reading your outputs and writing your inputs.

For a tank, that is two lines: the level rises while the inlet valve is open and falls while the outlet is open, limited between empty and full. Put the model in its own clearly marked section, enabled by a constant, so it can never run on the real machine by accident.

PROGRAM Main
VAR CONSTANT
    SIMULATION   : BOOL := TRUE;   (* FALSE on the real machine *)
    TANK_MAX_L   : REAL := 500.0;
    INFLOW_L_SCAN  : REAL := 4.0;  (* 40 L/s at 100 ms per scan *)
    OUTFLOW_L_SCAN : REAL := 2.5;
END_VAR
VAR
    AutoFill   : BOOL;    (* input: operator selects automatic fill *)
    DrainOpen  : BOOL;    (* input: downstream draws from the tank *)
    LowSwitch  : BOOL;    (* plant input: level switch at 100 L *)
    HighSwitch : BOOL;    (* plant input: level switch at 400 L *)
    InletValve : BOOL;    (* output *)
    Level_L    : REAL := 250.0;   (* simulated plant state *)
END_VAR

(* ===== Controller: the code that would ship ===== *)
IF AutoFill AND LowSwitch THEN
    InletValve := TRUE;
ELSIF HighSwitch OR NOT AutoFill THEN
    InletValve := FALSE;
END_IF;

(* ===== Plant model: only while simulating ===== *)
IF SIMULATION THEN
    IF InletValve THEN
        Level_L := Level_L + INFLOW_L_SCAN;
    END_IF;
    IF DrainOpen THEN
        Level_L := Level_L - OUTFLOW_L_SCAN;
    END_IF;
    Level_L    := LIMIT(0.0, Level_L, TANK_MAX_L);
    LowSwitch  := Level_L <= 100.0;
    HighSwitch := Level_L >= 400.0;
END_IF;

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

Switch on AutoFill and DrainOpen. The level falls until the low switch makes, the inlet opens, and because inflow beats outflow the level climbs back to 400 L and the valve closes. The controller section has no idea it is talking to a model. Turn AutoFill off and you can watch the tank drain to zero, the kind of test you would never be allowed to run on a real product tank.

A plant model does not need to be accurate; it needs to be qualitatively right and able to misbehave on demand. Add an input that sticks the high switch, a parameter that halves the inflow to simulate a blocked strainer, a fault that freezes the level. Those are the cases where controllers fail, and a model lets you produce them at will.

A model that can misbehave

Here is the same tank with two fault switches in the model and a fill timeout in the controller. A normal fill with the drain open takes about 20 s (300 L at a net 15 L/s), so the timeout is set to 30 s.

PROGRAM Main
VAR CONSTANT
    SIMULATION     : BOOL := TRUE;   (* FALSE on the real machine *)
    TANK_MAX_L     : REAL := 500.0;
    INFLOW_L_SCAN  : REAL := 4.0;    (* 40 L/s at 100 ms per scan *)
    OUTFLOW_L_SCAN : REAL := 2.5;
    FILL_TIMEOUT   : TIME := T#30s;  (* normal fill with the drain open: about 20 s *)
END_VAR
VAR
    AutoFill       : BOOL;   (* input: operator selects automatic fill *)
    DrainOpen      : BOOL;   (* input: downstream draws from the tank *)
    FaultHighStuck : BOOL;   (* model fault: high switch never makes *)
    FaultHalfFlow  : BOOL;   (* model fault: blocked strainer, half inflow *)
    LowSwitch      : BOOL;
    HighSwitch     : BOOL;
    InletValve     : BOOL;   (* output *)
    FillT          : TON;
    FillTimeout    : BOOL;   (* alarm, closes the inlet *)
    Level_L        : REAL := 250.0;
    Inflow_L       : REAL;
    Overflow       : BOOL;   (* model: product on the floor *)
END_VAR

(* ===== Controller ===== *)
FillT(IN := InletValve, PT := FILL_TIMEOUT);
IF FillT.Q THEN
    FillTimeout := TRUE;
END_IF;
IF NOT AutoFill THEN
    FillTimeout := FALSE;
END_IF;

IF AutoFill AND LowSwitch AND NOT FillTimeout THEN
    InletValve := TRUE;
ELSIF HighSwitch OR NOT AutoFill OR FillTimeout THEN
    InletValve := FALSE;
END_IF;

(* ===== Plant model with fault injection ===== *)
IF SIMULATION THEN
    Inflow_L := INFLOW_L_SCAN;
    IF FaultHalfFlow THEN
        Inflow_L := INFLOW_L_SCAN / 2.0;
    END_IF;
    IF InletValve THEN
        Level_L := Level_L + Inflow_L;
    END_IF;
    IF DrainOpen THEN
        Level_L := Level_L - OUTFLOW_L_SCAN;
    END_IF;
    Level_L    := LIMIT(0.0, Level_L, TANK_MAX_L);
    LowSwitch  := Level_L <= 100.0;
    HighSwitch := Level_L >= 400.0 AND NOT FaultHighStuck;
    Overflow   := Level_L >= TANK_MAX_L;
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: AutoFill, DrainOpen, FaultHighStuck, FaultHalfFlow.

Start with AutoFill and DrainOpen on and let the tank cycle normally. Then switch on FaultHalfFlow: the inflow drops below the outflow, the level falls even with the inlet open, and after 30 s FillTimeout closes the inlet and raises the alarm. The timeout does its job.

Now switch FaultHalfFlow and DrainOpen off, switch on FaultHighStuck, and toggle AutoFill off and on to clear the alarm. The inlet opens and never closes. With nothing drawing from the tank it fills at 40 L/s, and Overflow goes TRUE about 12 s later, long before the 30 s timeout. The drain does not save it either: with the drain open, a fill that starts at the low switch reaches 500 L about 27 s in, three seconds before the timeout would have closed the inlet.

That is a design finding, and the model found it in two minutes. A timeout sized for the slowest normal fill cannot protect against a stuck high switch on a tank that can fill fast. The protection that works is a second, independent high-high level switch wired to close the inlet, and on a hazardous product a separate protection layer outside the PLC. You would rather discover that on a laptop than on a Saturday night with a mop.

For bigger machines, the same idea scales up. S7-PLCSIM simulates S7-1200 and S7-1500 CPUs on the engineering PC, and S7-PLCSIM Advanced adds an API so a separate plant simulation can read outputs and write inputs. FactoryTalk Logix Echo emulates ControlLogix 5580 and CompactLogix 5380 controllers for Studio 5000 projects; the older Studio 5000 Logix Emulate covered the 5570 family, and Logix Designer version 37 and later no longer support emulated 5560 and 5570 controllers. TwinCAT runs its real-time on the engineering PC itself, and CODESYS has CODESYS Control Win, a soft PLC for Windows. Each of them runs your real code, so a model built for one of them is still in the same project, behind the same SIMULATION switch.

Debug from a hypothesis

When the bench or the plant shows a failure, the tempting move is to change something and see if it helps. On a live machine that is how one bug becomes three. Work like this instead:

  1. Observe precisely. Which output did what, on which event? "The case packer counted 13 bottles into a 12-pack" is a fact you can work with.
  2. Form a hypothesis that names a variable and a scan. "Count incremented twice for one bottle because the eye flickered at the bottle's edge."
  3. Predict what you would see if it were true. "If I trace the eye input, I will see two rising edges less than 20 ms apart for one bottle."
  4. Check it, with a watch, a trace (TwinCAT Scope, the CODESYS trace, TIA Portal's trace function, or a Studio 5000 trend), or by reproducing it in a plant case.
  5. Change one thing, then run all the cases again.

If the prediction is wrong, the hypothesis was wrong, and you have learned something without touching the code. That is still progress. A debounce timer added "just in case" is not progress: it hides the real problem for three months.

When the scan is the suspect

Some faults depend on the scan time itself, and the hypothesis has to include a number. Here is one from a bottling line.

A conveyor carries 65 mm bottles at 0.5 m/s, nearly touching, with gaps of about 10 mm between them. A photo-eye counts bottles on the falling edge of the gap, and the count is used to group them into packs. The eye sees each gap for 10 mm at 0.5 m/s, which is 20 ms. The counting program runs in the main task, which normally takes 8 ms. Once every few hundred bottles, two are counted as one.

The hypothesis names the scan: "when the main task scan exceeds the gap time, the PLC can read the eye blocked on two consecutive scans and never see the gap." The prediction is checkable: the controller's maximum cycle time should be above 20 ms, and the miscounts should cluster at moments when the scan is long.

To check it, read the cycle-time statistics. TIA Portal shows the current, shortest and longest cycle time in the CPU's online and diagnostics view. In Studio 5000, each task's properties have a Monitor tab with the last and maximum scan time, and logic can read the same values with GSV from the Task object (LastScanTime and MaxScanTime, in microseconds). The CODESYS task configuration has a monitor view with each task's cycle time and jitter when you are online. Reset the maximum, run the line for an hour, and compare it with the shortest signal you have to see. A table like this one, filled in for the fast signals on a machine, belongs in the commissioning record:

SignalShortest pulseTaskMax scan + I/O updateSafe?
Bottle gap on the count eye20 msMain26 ms + 2 msNo: move to the fast task or a counter module
Reject confirm sensor60 msMain26 ms + 2 msYes, with margin
Encoder mark on the capper3 msFast (2 ms)2 ms + 1 msNo: needs a hardware latch

The I/O update column is there for networked I/O: a PROFINET or EtherNet/IP device updates at its own cycle (or RPI), and the worst-case time for an input change to reach the program is roughly the device's cycle plus the task's cycle.

The usual suspects

Most ST bugs you will ever meet are one of a handful of patterns. Learn to recognise them on sight.

1. The missing ELSE

An IF that sets an output TRUE and never sets it FALSE has built an accidental latch. Chapter 5 covered it; it is still the most common bug in code reviews.

PROGRAM Main
VAR
    Level_High : BOOL;   (* input: high level switch *)
    Beacon     : BOOL;   (* output: should follow the switch *)
END_VAR

IF Level_High THEN
    Beacon := TRUE;      (* no ELSE: once on, nothing switches it off *)
END_IF;

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

Switch Level_High on and off. The beacon stays on forever. Either add the ELSE or, better, write Beacon := Level_High;. A plain assignment cannot latch.

2. The timer inside an IF

A timer only updates when it is called. Put the call inside an IF and, when the condition goes FALSE, the timer is simply not called: it never sees IN go FALSE, never resets, and its Q sticks.

3. Edge versus level

A counter that adds one "while the eye is blocked" counts scans, not bottles. At 10 ms per scan, a bottle that blocks the eye for 80 ms counts as eight. Count on an R_TRIG edge (chapter 8 and chapter 11).

4. The total that is never cleared

A FOR loop that sums an array must start from zero on every scan. Forget the Sum := 0; line and the sum grows by the whole array total every scan, a bug that looks right on the very first scan and nowhere else.

PROGRAM Main
VAR
    Weights : ARRAY[1..4] OF INT := [250, 248, 252, 251];  (* grams *)
    Sum     : INT;    (* bug: never cleared *)
    SumOk   : INT;
    i       : INT;
END_VAR

FOR i := 1 TO 4 DO
    Sum := Sum + Weights[i];
END_FOR;

SumOk := 0;                     (* cleared before every pass *)
FOR i := 1 TO 4 DO
    SumOk := SumOk + Weights[i];
END_FOR;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it.

Pause and step: after one scan both read 1001 g. After two, Sum reads 2002. After 33 scans the INT overflows and goes negative. A test that runs only one scan will never catch it, which is another reason the cases run several.

5. Comparing REALs with =

Floating-point values are almost never exactly equal to a decimal literal. Ten additions of 0.1 do not make exactly 1.0 on a PLC's 32-bit REAL, and they do not here either.

PROGRAM Main
VAR
    Total      : REAL;
    ExactlyOne : BOOL;
    CloseToOne : BOOL;
    i          : INT;
END_VAR

Total := 0.0;
FOR i := 1 TO 10 DO
    Total := Total + 0.1;
END_FOR;

ExactlyOne := Total = 1.0;                 (* never use this *)
CloseToOne := ABS(Total - 1.0) < 0.0001;   (* compare with a tolerance *)

END_PROGRAM
Live example — open this page with JavaScript to run and edit it.

The digits you see here come from this engine's 64-bit arithmetic; a 32-bit PLC REAL misses by a different amount. The lesson is the same on both: compare REALs with >=, <= or a tolerance, never =. A fill that stops "when weight = 500.0" may never stop.

6. Wrong precedence

AND binds tighter than OR, so Run := Start OR Run AND NOT Stop; means Start OR (Run AND NOT Stop). While Start is held, Stop does nothing. Write Run := (Start OR Run) AND NOT Stop; and the stop wins. When in doubt, add parentheses; they cost nothing and the next reader does not have to remember the table.

7. The state that writes an output

An output assigned inside one branch of a CASE keeps its value when the sequence moves to a branch that does not mention it. The valve opened in Filling stays open through Mixing and Draining until some other branch happens to close it. Chapter 12 showed the fix: compute each output after the CASE, from the step, in one line.

8. A forced or written value nobody removed

Before you hunt a bug in the code, check the force table and ask who was last online. A setpoint someone wrote during commissioning, a forced input left on from last week's I/O check, or a simulation switch left TRUE all look exactly like logic faults.

On a real machine you would leave a diagnostic like this in the program after the fault is found, with its count on a maintenance screen. A glitch count that starts rising again next year is the first sign of a loose connector or a failing sensor, long before the machine stops.

Ready to build something whole

A commissioning engineer's toolkit is now in your hands: thinking in scans, watching without disturbing, forcing inputs rather than outputs, tracing what the watch window cannot show, writing plant cases before the code and making sure they can fail, attacking the boundaries and every path out of every state, simulating the plant with faults on demand, and debugging from a hypothesis that names a variable, a scan and sometimes a scan time. You also have eight bugs you will now spot in other people's code within seconds.

The final chapter of this part puts all of it to work on one machine. The capstone is a bottle filling line with modes, a fill sequence, a simulated scale, counters, a fill timeout, alarms with acknowledge, and a set of plant cases that grade the whole cell.

Practise this chapter