Chapter 12 / 24 · 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 — that says which step the machine is in;
  • a CASE on that variable, with one branch per state, containing only the conditions for leaving that state;
  • outputs 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 are gone, not guarded against.

Two conventions are worth adopting 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 — 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.

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 — a fact that surprises people the first time.

When a state has more than one way out, order the IFs by priority and use ELSIF, so exactly one transition fires. Put the safety-related exit 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;.

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 — a good place to reset things.

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. Notice that idle only starts a batch when the tank is empty. Every transition condition is also a check that the machine is where the program thinks it is.

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:

  • Stop — finish what you are doing, then rest. 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.
  • Abort — leave now and make everything safe. 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.
  • Fault — something went wrong that a person must look at. The sequence moves to a fault state, typically 90 or 900, that holds the outputs safe 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.

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 — it is the more maintainable one.

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).

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, sold as S7-GRAPH in TIA Portal and as SFC in Studio 5000 and CODESYS. The ideas — steps, transitions, one active step per sequence, actions on entry — are the ones you have just written by hand. Try them in the SFC lab.

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