Chapter 12 / 32 · Part 3 · Time and counting

State machines

Turn a machine sequence into named states and transitions with CASE, timers and clear entry conditions.

How sequences turn into spaghetti

A mixing tank in a sauce kitchen runs the same cycle all day: open the inlet until the high-level switch makes, run the agitator for 90 seconds, open the drain until the tank is empty, start again. Three steps. Every engineer's first attempt at it looks something like this:

IF Start AND NOT Filling AND NOT Mixing AND NOT Draining THEN
    Filling := TRUE;
END_IF;
IF Filling AND LevelHigh THEN
    Filling := FALSE;
    Mixing := TRUE;
END_IF;
MixTimer(IN := Mixing, PT := T#90s);
IF MixTimer.Q THEN
    Mixing := FALSE;
    Draining := TRUE;
END_IF;
IF Draining AND TankEmpty THEN
    Draining := FALSE;
END_IF;
InletValve := Filling;
Agitator   := Mixing;
DrainValve := Draining;

It works on the first day. Then the requests come. Add a stop button that finishes the batch. Add an abort that closes everything now. Add a fill timeout in case the supply valve is shut. Add a heating step for one recipe only. Each change adds a flag and a few more AND NOTs, and each flag can be TRUE or FALSE independently. Three flags give eight combinations, of which four are legal. Six flags give 64. Sooner or later a power dip or an abort leaves Mixing and Draining both TRUE, the program happily runs the agitator with the drain open, and nobody can tell from the online view which step the machine thinks it is in.

The fix is to stop describing the sequence with independent flags and describe it with one variable that can only hold one value: the current state.

One state variable, one CASE

A state machine has three parts. A state variable, usually an INT, says which step the machine is in. A CASE on that variable has one branch per state, and each branch contains only the conditions for leaving that state. And the outputs are derived from the state, assigned once, after the CASE.

PROGRAM Main
VAR
    Start      : BOOL;   (* input: start push button *)
    LevelHigh  : BOOL;   (* input: high-level switch *)
    TankEmpty  : BOOL;   (* input: low-level switch, TRUE = empty *)
    State      : INT;    (* 0 = idle, 10 = filling, 20 = draining *)
    InletValve : BOOL;   (* output *)
    DrainValve : BOOL;   (* output *)
END_VAR

CASE State OF
    0:  (* IDLE *)
        IF Start THEN
            State := 10;
        END_IF;

    10: (* FILLING *)
        IF LevelHigh THEN
            State := 20;
        END_IF;

    20: (* DRAINING *)
        IF TankEmpty THEN
            State := 0;
        END_IF;
END_CASE;

InletValve := State = 10;
DrainValve := State = 20;

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

Press Start and release it: the state goes to 10 and the inlet opens. Turn LevelHigh on: state 20, inlet closed, drain open. Turn LevelHigh off and TankEmpty on: back to 0. The tank cannot be filling and draining at once, because State cannot be 10 and 20 at once. The illegal combinations no longer exist, so there is nothing to guard against.

Adopt two conventions from day one. Number states in steps of ten, so you can insert a heating step as 15 next year without renumbering everything. And keep the outputs out of the CASE. InletValve := State = 10; is assigned on every scan, so it can never be left latched by a branch that stopped running, and the missing-ELSE trap from chapter 5 cannot happen. When an output is on in several states, say so: Agitator := (State = 20) OR (State = 25);. One line per output, readable as a sentence, is worth more than any amount of cleverness inside the branches.

Draw the states before you write them

The code above was easy to write because the sequence was already clear. On a real machine it rarely is, and the cheapest place to find out what is missing is a table, before any code exists. One row per state, with what is on in it, what ends it and what can go wrong:

StateNameOutputs onLeaves whenGoes toGives up afterOn abort
0IdlenoneStart and tank empty10
10Fillinginlet valvehigh level2060 s → 900
20Mixingagitator90 s in state300
30Drainingdrain valvetank empty0120 s → 900
90Faultnone, alarm onReset0

Writing the table forces the questions that otherwise come up at 2 a.m. during commissioning. Every state that waits for the machine (filling, draining) needs a "gives up after" entry, because the machine may never deliver. Every state that is running needs an abort route. Idle's exit condition checks that the tank really is empty, so a batch never starts on top of the last one. And the outputs column makes conflicts visible: if one row ever lists both the inlet and the drain, someone has to say why.

Once the table is agreed, the code is mechanical. Each row becomes a branch, each "leaves when" an IF, the "gives up after" column a comparison on the state timer, the abort column a global transition, and the outputs column one assignment per output. A reviewer can check the program against the table line by line, and a technician can read the table without reading the code. Keep it in the project documentation, or as a comment block at the top of the program, and change it whenever the code changes.

The plant design book takes this further, deriving the states from a written behaviour contract and checking them against invariants; see the book for that method. This chapter stays with the ST that implements the table.

Transitions with conditions

Each branch holds the transitions out of its own state, and nothing else. A transition is an IF that assigns a new value to State. Because the CASE executes exactly one branch per scan, the machine moves at most one state per scan, which surprises people the first time they see it.

When a state has more than one way out, order the IFs by priority and use ELSIF, so exactly one transition fires. Put the exit that protects the plant first:

10: (* FILLING *)
    IF StateTimer.ET > T#60s THEN
        State := 90;            (* fill timeout: supply valve shut? *)
    ELSIF LevelHigh THEN
        State := 20;
    END_IF;

Write the condition so that it reads like the specification. "Leave filling when the level is high" is IF LevelHigh. If a condition needs a comment to explain it, it probably needs a named variable instead, calculated above the CASE: FillComplete := LevelHigh AND LevelHighConfirm.Q;. The online view then shows FillComplete as a single bit, and a technician standing at the tank can see which half of it is missing.

Entry actions and the first scan in a state

Some things should happen once, when a state is entered: add one to the batch counter as filling starts, store the start weight, write a message to the batch log. Doing them "while in the state" repeats them every scan. You need a flag that is TRUE for exactly one scan after every transition:

PROGRAM Main
VAR
    Start      : BOOL;
    LevelHigh  : BOOL;
    TankEmpty  : BOOL;
    State      : INT;
    LastState  : INT := -1;   (* differs from State on the very first scan *)
    StateEntry : BOOL;        (* TRUE for one scan after every transition *)
    Batches    : DINT;
    InletValve : BOOL;
    DrainValve : BOOL;
END_VAR

StateEntry := State <> LastState;
LastState  := State;

CASE State OF
    0:  IF Start THEN State := 10; END_IF;

    10: IF StateEntry THEN
            Batches := Batches + 1;   (* entry action: once per batch *)
        END_IF;
        IF LevelHigh THEN State := 20; END_IF;

    20: IF TankEmpty THEN State := 0; END_IF;
END_CASE;

InletValve := State = 10;
DrainValve := State = 20;

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

The two lines at the top compare the state with last scan's. Any transition, from anywhere, makes StateEntry TRUE on the following scan, in the new state's branch. Run a few batches and watch Batches go up by exactly one each time, however long the fill takes. Initialising LastState to −1 makes the first scan after a restart an entry into state 0, so state 0's entry actions run at power-up too, which makes it a good place to reset things.

The two lines must sit above the CASE and run on every scan. Move them below it and StateEntry fires on the transition scan itself, in the old state's branch, which is the opposite of what the name says.

A timer per state

Most sequences need time: mix for 90 s, wait 2 s for a valve to settle, give up filling after 60 s. You could declare a timer for each of those, but there is a neater pattern. A single TON that restarts on every state entry measures how long the machine has been in its current state:

StateTimer(IN := NOT StateEntry, PT := T#24h);

On the entry scan IN is FALSE, so the timer resets; from the next scan it runs, and StateTimer.ET is the state time. Every branch can compare against it: IF StateTimer.ET >= T#90s THEN to finish mixing, IF StateTimer.ET > T#60s THEN for the fill timeout. The PT is simply longer than any state will ever last. The HMI can show StateTimer.ET next to the step name, which operators love: "Mixing, 47 s".

Notice where the call is: at the top, outside the CASE, on every scan. The rule from chapter 9, call every timer on every scan, is broken more often inside state machines than anywhere else, because putting a timer in the branch that uses it looks so tidy.

Fill, mix, drain

Here is the complete tank sequence, with the entry flag, the state timer and a 3 s mix (90 s on the real tank, shortened so you can watch it):

PROGRAM Main
VAR
    Start      : BOOL;
    LevelHigh  : BOOL;
    TankEmpty  : BOOL;
    State      : INT;          (* 0 idle, 10 filling, 20 mixing, 30 draining *)
    LastState  : INT := -1;
    StateEntry : BOOL;
    StateTimer : TON;
    InletValve : BOOL;
    Agitator   : BOOL;
    DrainValve : BOOL;
END_VAR

StateEntry := State <> LastState;
LastState  := State;
StateTimer(IN := NOT StateEntry, PT := T#24h);

CASE State OF
    0:  (* IDLE *)
        IF Start AND TankEmpty THEN State := 10; END_IF;

    10: (* FILLING *)
        IF LevelHigh THEN State := 20; END_IF;

    20: (* MIXING *)
        IF StateTimer.ET >= T#3s THEN State := 30; END_IF;

    30: (* DRAINING *)
        IF TankEmpty THEN State := 0; END_IF;
END_CASE;

InletValve := State = 10;
Agitator   := State = 20;
DrainValve := State = 30;

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

Run a batch by hand: TankEmpty on, Start on and off, then TankEmpty off (the tank is filling), LevelHigh on. The agitator runs for 3 s by itself and the drain opens. Turn LevelHigh off and TankEmpty on to finish. Idle only starts a batch when the tank is empty, and that is a pattern to copy: every transition condition is also a check that the machine is where the program thinks it is.

The "two-hand start, already validated" comment in that starter hides a whole safety system. On a real press the two-hand control device, the guarding and the circuits that stop the ram belong to the safety system, built to the press's machine standard and to ISO 13849-1, with a safety relay or safety PLC deciding whether the ram may move. The state machine sequences the cycle inside the envelope that system allows. It never decides on its own that it is safe to close the press.

Outputs, interlocks and permissives

Deriving the outputs from the state answers when each output should be on during the sequence. It does not answer whether it is allowed to be on at this moment, and that second question belongs in the same line, outside the CASE:

InletValve := (State = 10) AND NOT TankHighHigh;
Agitator   := (State = 20) AND AgitatorReady AND NOT AgitatorOverload;
DrainValve := (State = 30) AND DownstreamReady;

The state says what the sequence wants; the rest of the line says what the plant permits. Keeping both in one assignment means there is one place to look when the agitator does not run in state 20, and the online view shows which term is FALSE.

Keep the two kinds of condition apart in your head. A permissive missing in the middle of a step usually should not change the state: if the agitator's drive drops out for a moment, the agitator stops, and the sequence waits in mixing while an alarm says why. Whether the sequence should then give up and go to a fault state is a separate decision, made inside the branch, usually by a timeout or by a fault input written as a transition.

There is also a question of timing. With the outputs after the CASE, a transition and its new outputs happen on the same scan: the scan that moves the state from 10 to 20 also closes the inlet and starts the agitator. Outputs written inside branches would change one scan later or not at all, depending on where in the branch they were written and whether the branch ran. That difference is one more reason to keep outputs out of the branches, and the plant design book discusses when the one-scan choice matters at all.

Stop, abort and fault states

A sequence that can only run forwards is half a sequence. Real machines need three ways off the happy path, and they are not the same thing.

A stop finishes what the machine is doing, then rests. The cycle-stop button on a palletiser completes the current layer. Usually this is just an extra condition in idle: don't start another cycle while CycleStop is set.

An abort leaves now and makes everything safe for the process. It is a global transition, written before the CASE, that sends any running state straight to idle, or to a dedicated aborting state if things must be done in order (close the inlet, then open the vent). Because the outputs are derived from the state, State := 0 switches them all off on the same scan.

A fault means something went wrong that a person must look at. The sequence moves to a fault state, typically 90 or 900, that holds the outputs in their safe positions and stays there until a deliberate reset. Step timeouts are the most useful fault source you will ever write: every state that waits for the machine should also give up after a reasonable time.

(* Global transitions: evaluated before the CASE, highest priority first *)
IF Abort AND State > 0 AND State < 90 THEN
    State := 0;
END_IF;

Emergency stops are not on that list on purpose. The e-stop circuit removes power through safety relays or a safety PLC, independently of this program. The sequence must notice the e-stop, usually by aborting, but it is never what makes the machine safe.

States the CASE does not know

State is an integer, and an integer can hold values your CASE has no branch for. It happens more often than you would think. Someone writes a step number from the HMI to "jump the sequence on" during commissioning and types 15 instead of 10. An online edit removes a state while the machine is in it. A copy-paste in a new branch sets State := 300; where the table says 30.

The defence is one branch:

CASE State OF
    0:  (* ... *)
    10: (* ... *)
    20: (* ... *)
    90: IF Reset THEN State := 0; END_IF;
ELSE
    BadStep := State;       (* keep the bad number for the fault message *)
    State   := 90;
END_CASE;

The ELSE keeps the bad number before overwriting it, because "sequence fault: unknown step 15" is a message that leads straight to the cause. An enumerated state variable (below) makes most of these values impossible to write from code, though an HMI writing a raw integer can still get one in on some platforms, so keep the ELSE there too.

Tell the operator what the sequence is waiting for

A stuck sequence is the commonest call-out on any automated machine, and most of the time nothing is broken in the PLC. The sequence is in a state, waiting for a condition that never came: a level switch that never made, a cylinder that never reached its end position, a downstream machine that never said it was ready. The technician who arrives at 3 a.m. needs to know which condition, without opening the program.

Give every waiting state two things. A waiting-for code that the HMI turns into text, and an overdue flag that comes on when the state has lasted longer than it normally does. Both are set inside the branch, where the knowledge is:

PROGRAM Main
VAR
    Start      : BOOL;   (* input *)
    LevelHigh  : BOOL;   (* input *)
    TankEmpty  : BOOL;   (* input *)
    State      : INT;    (* 0 idle, 10 filling, 20 draining *)
    LastState  : INT := -1;
    StateEntry : BOOL;
    StateTimer : TON;
    WaitingFor : INT;    (* HMI text index: what the step is waiting for *)
    Overdue    : BOOL;   (* the step is taking longer than it should *)
    InletValve : BOOL;
    DrainValve : BOOL;
END_VAR

StateEntry := State <> LastState;
LastState  := State;
StateTimer(IN := NOT StateEntry, PT := T#24h);

CASE State OF
    0:  WaitingFor := 1;                       (* 1 = "Press Start (tank must be empty)" *)
        Overdue := FALSE;
        IF Start AND TankEmpty THEN State := 10; END_IF;

    10: WaitingFor := 2;                       (* 2 = "Filling: waiting for high level" *)
        Overdue := StateTimer.ET > T#4s;
        IF LevelHigh THEN State := 20; END_IF;

    20: WaitingFor := 3;                       (* 3 = "Draining: waiting for tank empty" *)
        Overdue := StateTimer.ET > T#6s;
        IF TankEmpty THEN State := 0; END_IF;
END_CASE;

InletValve := State = 10;
DrainValve := State = 20;

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

Start a batch and leave LevelHigh off. After 4 s Overdue comes on while the state stays at 10: the machine is still allowed to wait, but someone should look, and the HMI can say exactly what at. The overdue time is shorter than the fault timeout from the table, so the operator sees a warning before the sequence gives up. Set it from commissioning data: if a fill normally takes 35 s, an overdue warning at 50 s and a fault at 60 s give the operator a chance to open the supply valve before the batch is lost.

A waiting-for code is worth having even where it repeats information in the state number. "Step 10" means something to the person who wrote the program. "Waiting for high level LS-201" means something to everyone, and the text table that maps codes to messages lives in the HMI project, where it can be translated and corrected without touching the PLC.

A field story: step 40, every Monday

A case erector on a packaging line stopped in step 40 roughly once a week, always early on a Monday shift. Step 40 waited for a vacuum switch to confirm that the suction cups had picked a flat blank from the magazine. The night crew reset the machine and it ran. The day crew called it a sensor fault, and the vacuum switch was replaced twice.

What found it was adding an overdue flag and logging the state number, the state time and the waiting-for code whenever it came on. The log showed step 40 overdue on most Monday mornings, by a fraction of a second, and only on the first few cycles. Over the weekend the compressed-air system was shut down, and on Monday the vacuum generator, an air-driven ejector, took longer to reach its switching point until the main receiver had come back up to pressure. The step timeout had been set at commissioning from a warm machine with full air. Raising the vacuum-confirm time a little for the first cycles after a start, and adding a supply-pressure permissive to the start conditions, ended the call-outs. None of that would have been visible from a step number on its own.

Hold and resume

Stop and abort both end the cycle. Many processes also need to pause one: an operator holds the mix to take a sample, a downstream machine stops for a minute and the tank must wait without losing the batch. A hold is its own state, entered from the state being held and left back into it. The interesting part is time. The state timer restarts on every entry, so a mix that is held and resumed starts its 90 s again from zero, and the batch gets more mixing than the recipe says. On some products that is harmless; on a shear-sensitive sauce or a reaction with a fixed time it is not.

The fix is to accumulate the time spent in the state across holds: add the state timer's ET to a running total when the state is left for the hold, and compare total plus current ET against the recipe time. The decision box at the end of chapter 10 called this time "accumulated across interruptions", and none of the IEC timers keeps it for you.

Each hold and resume costs a scan or two of untimed mixing, because the state timer resets on the scan after the transition. On this page's 100 ms scan that is visible in the case timings; on a 10 ms task it is noise next to a 90 s mix. Where a held process must also hold its conditions, such as temperature in a jacketed vessel, the hold state is also where you keep the heating loop running while the agitator stands still, and the outputs section says how: JacketValve := (State = 20) OR (State = 25);.

If your plant uses the PackML state model, its Holding, Held and Unholding states are this pattern written into a standard, alongside Suspended for a stop caused by the line rather than the operator. Chapter 29 describes the model.

Two sequences that talk

A tank rarely works alone. It feeds a filler, a pump skid or another vessel, each with its own sequence, and the two have to agree on when product moves. Put both sequences in one CASE and you get the flag problem back, because the combined state is every combination of the two. Give each machine its own state variable instead, and let each read the other's state while writing only its own:

PROGRAM Main
VAR
    Start       : BOOL;   (* input: start a batch *)
    LevelHigh   : BOOL;   (* input: tank high level *)
    TankEmpty   : BOOL;   (* input: tank empty *)
    BufferLow   : BOOL;   (* input: filler buffer needs product *)
    BufferFull  : BOOL;   (* input: filler buffer full *)
    TankState   : INT;    (* 0 idle, 10 filling, 20 ready, 30 discharging *)
    FillerState : INT;    (* 0 running from buffer, 10 receiving *)
    InletValve  : BOOL;   (* output: tank inlet *)
    DrainValve  : BOOL;   (* output: tank outlet to the filler *)
    BufferInlet : BOOL;   (* output: filler buffer inlet valve *)
END_VAR

(* The tank sequence: reads FillerState, writes only TankState *)
CASE TankState OF
    0:  IF Start AND TankEmpty THEN TankState := 10; END_IF;
    10: IF LevelHigh THEN TankState := 20; END_IF;
    20: IF FillerState = 10 THEN TankState := 30; END_IF;          (* filler is ready to receive *)
    30: IF TankEmpty THEN
            TankState := 0;
        ELSIF FillerState <> 10 THEN
            TankState := 20;                                         (* filler stopped receiving: wait *)
        END_IF;
END_CASE;

(* The filler sequence: reads TankState, writes only FillerState *)
CASE FillerState OF
    0:  IF BufferLow AND TankState = 20 THEN FillerState := 10; END_IF;
    10: IF BufferFull OR TankState = 0 THEN FillerState := 0; END_IF;
END_CASE;

InletValve  := TankState = 10;
DrainValve  := TankState = 30 AND FillerState = 10;
BufferInlet := TankState = 30 AND FillerState = 10;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Start, LevelHigh, TankEmpty, BufferLow, BufferFull.

Run it: TankEmpty on, Start on and off, then TankEmpty off and LevelHigh on. The tank waits in 20, ready. Turn BufferLow on and the filler moves to receiving; on the next scan the tank sees it and starts discharging, and both valves open together. Turn BufferLow off and BufferFull on: the filler leaves receiving, the valves close at once, and the tank goes back to waiting with product still in it. Swap them again and the transfer resumes. Finally turn LevelHigh off and TankEmpty on to end the batch.

Three rules make this work, and they scale to a whole line. Each state variable has exactly one writer: the tank never sets FillerState, and the filler never sets TankState. Each sequence reacts to the other's state, never to its internal flags, so the interface between them is two numbers that appear on the HMI. And outputs that move product between the two machines check both states, which is why DrainValve includes FillerState = 10. Without that term the tank would hold its valve open for one scan after the filler closed its inlet, because the tank's CASE runs first and only sees the filler's change on the next scan.

When the two machines sit in different controllers, the same pattern runs over the network, and the states become signals in a data exchange: the filler publishes "ready to receive", the tank publishes "discharging". The rules do not change, but you add one: each side watches a heartbeat from the other and treats a silent partner as not ready. ISA-88 for batch processes and PackML for packaging machines both describe interfaces like this in detail.

Restarting after a power cut

Should the state variable be retentive? Ask what the machine looks like when the power comes back. The tank may have drained by gravity, been emptied by hand, or still be full of half-mixed product. A cylinder may have dropped under its own weight. A sequence that resumes in the state it held before the power cut acts on a picture of the plant that may no longer be true, and if that state drives motion, it restarts the machine without anyone pressing start, which EN 60204-1 rules out where it could be hazardous (see chapter 8).

A pattern that has served me well is to retain the record of where the sequence was, and never the state itself:

VAR RETAIN
    StepAtPowerLoss : INT;     (* last state before the power cut, for the operator *)
    BatchInProgress : BOOL;
END_VAR

(* State is not retained: it starts at 0 after every power-up *)
IF FirstScan AND BatchInProgress THEN
    State := 5;                 (* 5 = power-up check: operator decides *)
END_IF;
IF State <> 5 THEN
    StepAtPowerLoss := State;  (* the record follows the sequence, except during the check *)
END_IF;
BatchInProgress := State <> 0;

State 5 holds every output off, shows "Batch interrupted in step 30 (draining)" on the HMI, and offers the operator a choice the program cannot make: abort the batch, or confirm the tank's condition and continue from a safe step. The program can help by checking what it can (levels, valve positions, weights), but a person decides. FirstScan stands for whatever your platform provides: a first-scan system bit (S:FS on Logix), a startup OB in TIA Portal, or a flag you clear yourself at the end of the first cycle.

Naming the states

State = 20 is fine for three states and awful for fifteen. Somebody reading branch 40 at 3 a.m. should not need a lookup table. The traditional fix is named constants:

VAR CONSTANT
    ST_IDLE     : INT := 0;
    ST_FILLING  : INT := 10;
    ST_MIXING   : INT := 20;
    ST_DRAINING : INT := 30;
    ST_FAULT    : INT := 90;
END_VAR

CASE State OF
    ST_IDLE:    IF Start THEN State := ST_FILLING; END_IF;
    ST_FILLING: IF LevelHigh THEN State := ST_MIXING; END_IF;
    (* ... *)
END_CASE;

CODESYS, TwinCAT and the engine on these pages all accept constants as CASE labels. The live examples above use numbers with comments only so the jumps are easy to follow; on a real controller, write the constants version, because it is the one the next engineer can maintain.

Better still is an enumerated type, which makes the state variable unable to hold anything but a named state, and shows the name, not the number, in the online view:

TYPE E_TankStep :
(
    IDLE     := 0,
    FILLING  := 10,
    DRAINING := 20
);
END_TYPE

PROGRAM Main
VAR
    Start      : BOOL;
    LevelHigh  : BOOL;
    TankEmpty  : BOOL;
    Step       : E_TankStep;
    InletValve : BOOL;
    DrainValve : BOOL;
END_VAR

CASE Step OF
    IDLE:     IF Start THEN Step := FILLING; END_IF;
    FILLING:  IF LevelHigh THEN Step := DRAINING; END_IF;
    DRAINING: IF TankEmpty THEN Step := IDLE; END_IF;
END_CASE;

InletValve := Step = FILLING;
DrainValve := Step = DRAINING;

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

Run it and the watch table reads FILLING, not 10. The explicit values keep the step numbers for the HMI and for anyone comparing with the old documentation. Chapter 15 covers ENUMs properly, including how vendors scope the names (E_TankStep.FILLING in CODESYS and TwinCAT when the type carries the qualified_only attribute).

Sequences in the vendors' graphical languages

When a sequence grows past a dozen states with branches running in parallel, the CASE starts to strain, and IEC 61131-3 has a graphical language built for exactly this: Sequential Function Chart. The ideas are the ones you have just written by hand: steps, transitions, one active step per sequence, actions on entry. Each vendor adds its own bookkeeping, and knowing the names helps when you meet one.

In CODESYS and TwinCAT, every SFC step gets implicit variables: StepName.x is TRUE while the step is active, and StepName.t is a TIME with how long it has been active, the built-in equivalent of the state timer in this chapter. Siemens sells SFC for the S7-1500 as S7-GRAPH, where each step can carry interlock and supervision conditions, and a supervision error stops the sequence until it is acknowledged, which is the step timeout written into the language. Studio 5000 has SFC routines whose steps are tags of type SFC_STEP, again with an active bit and an elapsed time. Try the ideas in the SFC lab.

Whether to use SFC or a CASE is mostly a plant-standard decision. SFC is easier to watch online, especially for parallel branches; a CASE in ST is easier to diff, review in text and move between vendors. A well-built CASE sequence with a state table, a state timer, waiting-for codes and an ELSE branch gives a technician most of what an SFC would.

Reviewing a state machine

When you review a sequence, start from the table, not the code. If there is no table, write one from the code. It is the fastest way to understand someone else's sequence, and the missing cells are the findings.

Next: many values at once

Everything so far has handled one tank, one press, one conveyor. A real plant has eight identical zones, twenty temperature readings and a recipe with forty parameters, and writing each one out by name stops scaling quickly. Chapter 13 introduces arrays and FOR loops: storing many values under one name and processing them all in a single scan.

Practise this chapter