Chapter 15 / 24 · Part 4 · Data

STRUCT and ENUM types

Group related values into a STRUCT, name states with an ENUM, and build arrays of structures for real equipment.

Four arrays that should be one

A pumping station has three pumps. Using what you know so far, the data for them lives in parallel arrays:

VAR
    PumpRun   : ARRAY[1..3] OF BOOL;
    PumpSpeed : ARRAY[1..3] OF REAL;    (* % of rated speed *)
    PumpFault : ARRAY[1..3] OF BOOL;
    PumpHours : ARRAY[1..3] OF DINT;
END_VAR

It works until it doesn't. Someone adds a fourth pump and resizes three of the four arrays. Someone writes a loop over PumpSpeed with the bounds of PumpHours. Someone passes "pump 2" to a routine by handing it four separate values and gets one of them from pump 3. The program describes a pump as four unrelated facts that happen to share an index, and nothing in the language knows they belong together.

A real pump is one thing with several properties. Structured Text lets you say exactly that with a STRUCT, and it lets you give the values of a state or a mode proper names with an ENUM. Together they are the difference between a program that talks about PumpRun[2] and Step = 20 and one that talks about Pumps[2].Run and State = Filling.

TYPE … STRUCT … END_TYPE

A structure is a data type you define yourself. It lists named fields, each with its own type:

TYPE ST_Pump :
STRUCT
    Run    : BOOL;     (* command to the contactor *)
    Speed  : REAL;     (* % of rated speed *)
    Fault  : BOOL;     (* drive or overload trip *)
    Hours  : DINT;     (* running hours *)
    Starts : DINT;     (* number of starts *)
END_STRUCT
END_TYPE

The TYPE … END_TYPE wrapper declares a new type called ST_Pump. It sits outside any PROGRAM, and once it exists you declare variables of that type like any other: P101 : ST_Pump;. Each variable gets its own copy of all five fields. The ST_ prefix is a common convention (E_ for enums, FB_ for function blocks), not a rule; pick a convention and keep it across the project.

You reach a field with a dot: P101.Run, P101.Speed. Fields behave exactly like ordinary variables of their type. You can assign them, compare them, pass them to timers and use them in expressions:

TYPE ST_Pump :
STRUCT
    Run    : BOOL;
    Speed  : REAL;
    Fault  : BOOL;
    Hours  : DINT;
    Starts : DINT;
END_STRUCT
END_TYPE

PROGRAM Main
VAR
    RunCmd  : BOOL;            (* input: run request from the sequence *)
    Trip    : BOOL;            (* input: overload relay contact *)
    SpeedSp : REAL := 45.0;    (* input: speed setpoint, % *)
    P101    : ST_Pump;         (* everything about pump P-101 *)
    StartEdge : R_TRIG;
END_VAR

P101.Fault := Trip;
P101.Run   := RunCmd AND NOT P101.Fault;

IF P101.Run THEN
    P101.Speed := LIMIT(20.0, SpeedSp, 100.0);
ELSE
    P101.Speed := 0.0;
END_IF;

StartEdge(CLK := P101.Run);
IF StartEdge.Q THEN
    P101.Starts := P101.Starts + 1;
END_IF;

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

Switch RunCmd on and off a few times: the pump runs at 45 %, and Starts counts each start. Switch Trip on and the run command drops while the request is still there. Notice that the watch list names fields, not the whole structure — the online watch in CODESYS or TIA Portal does the same thing, showing a structure as an expandable row of its fields.

Fields can have default values in the type: Speed : REAL := 50.0; gives every new ST_Pump a starting speed of 50. That is useful for things like a default alarm limit that each instance may later change. The running-hours field is left alone here; chapter 16 counts it properly with a timer.

Copying a structure

Assigning one structure to another copies every field at once:

P102 := P101;            (* all five fields copied *)

It is a copy, not a link. After the assignment the two variables are independent again, and a change to one does not reach the other. That catches out programmers who come from languages where objects are passed around by reference.

One limitation of the engine on this site: copying a whole structure out of an array element — P102 := Pumps[1]; — does not work here, although it is valid IEC 61131-3 and works on every real controller. Copying field by field (P102.Speed := Pumps[1].Speed;) works everywhere, and the examples below do that. The engine also ignores structure initialisers written in a VAR section, such as P101 : ST_Pump := (Speed := 50.0);, which CODESYS and TwinCAT accept. Put defaults in the TYPE instead.

Arrays of structures

The real payoff comes when you combine the two ideas. ARRAY[1..3] OF ST_Pump is three complete pumps, and Pumps[i].Hours is the hours of pump i. All of chapter 13's loops now work on whole devices. This station runs the healthy pump with the fewest hours, a common way of spreading wear across a duty/standby set:

TYPE ST_Pump :
STRUCT
    Run   : BOOL;
    Fault : BOOL;
    Hours : DINT;
END_STRUCT
END_TYPE

PROGRAM Main
VAR
    Demand  : BOOL := TRUE;    (* input: level controller asks for flow *)
    Fault1  : BOOL;            (* inputs: drive faults *)
    Fault2  : BOOL;
    Fault3  : BOOL;
    Pumps   : ARRAY[1..3] OF ST_Pump;
    Lead    : INT;             (* chosen pump, 0 = none available *)
    Healthy : INT;
    TotalHours : DINT;
    i       : INT;
END_VAR

Pumps[1].Hours := 4120;        (* would come from the hour counters *)
Pumps[2].Hours := 3870;
Pumps[3].Hours := 4405;
Pumps[1].Fault := Fault1;
Pumps[2].Fault := Fault2;
Pumps[3].Fault := Fault3;

Lead := 0;
Healthy := 0;
TotalHours := 0;
FOR i := 1 TO 3 DO
    TotalHours := TotalHours + Pumps[i].Hours;
    IF NOT Pumps[i].Fault THEN
        Healthy := Healthy + 1;
        IF Lead = 0 THEN
            Lead := i;
        ELSIF Pumps[i].Hours < Pumps[Lead].Hours THEN
            Lead := i;
        END_IF;
    END_IF;
END_FOR;

FOR i := 1 TO 3 DO
    Pumps[i].Run := Demand AND (i = Lead);
END_FOR;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Fault1, Fault2, Fault3, Demand.

Pump 2 has the fewest hours, so it runs. Fault it and pump 1 takes over; fault pump 1 as well and pump 3 runs. Fault all three and Lead drops to 0 with every Run off. The second loop deserves a look: it writes every pump's Run on every scan, so the pump that just lost duty is switched off in the same scan the new one is switched on. Writing only Pumps[Lead].Run := TRUE would leave the old pump running too.

This is also how equipment is modelled on large projects. A water utility might have ARRAY[1..40] OF ST_Pump mapped one-to-one onto a SCADA faceplate template, so the HMI engineer builds one faceplate and points it at Pumps[n]. Get the structure right early; changing a structure that 40 HMI screens depend on is a project of its own.

ENUM: names for states and modes

In chapter 7 a mode selector used the numbers 0, 1 and 2, and in chapter 12 a sequence used step numbers. Both worked, and both relied on comments to say what the numbers meant. An enumerated type replaces those numbers with names the compiler knows about:

TYPE E_FillState :
(
    Idle,
    Filling,
    Settling,
    Done
);
END_TYPE

A variable of type E_FillState can hold one of those four values and you write them by name. The online watch shows Settling, not 2. Here is a bottle filler written with one:

TYPE E_FillState :
(
    Idle,
    Filling,
    Settling,
    Done
);
END_TYPE

PROGRAM Main
VAR
    Start       : BOOL;        (* input: bottle in place, start filling *)
    Full        : BOOL;        (* input: level probe in the bottle neck *)
    State       : E_FillState;
    SettleTimer : TON;
    FillValve   : BOOL;        (* output *)
    DoneLamp    : BOOL;        (* output *)
END_VAR

CASE State OF
    Idle:
        IF Start THEN State := E_FillState.Filling; END_IF;
    Filling:
        IF Full THEN State := E_FillState.Settling; END_IF;
    Settling:
        IF SettleTimer.Q THEN State := E_FillState.Done; END_IF;
    Done:
        IF NOT Start THEN State := E_FillState.Idle; END_IF;
END_CASE;

SettleTimer(IN := State = E_FillState.Settling, PT := T#2s);

FillValve := State = E_FillState.Filling;
DoneLamp  := State = E_FillState.Done;

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

Switch Start on, then Full: the state walks Idle → Filling → Settling, waits two seconds for the foam to settle, and reaches Done. Switch Start off to return to Idle. Nothing in the program says what number Settling is, and nothing needs to.

Two spellings appear in that program. E_FillState.Filling is the qualified name, type and value together; Filling alone is the bare name. IEC 61131-3 allows the bare name wherever the type is clear, as in a CASE label where the selector already fixes it. TwinCAT 3 and recent CODESYS versions put {attribute 'qualified_only'} on new enum types by default, and then you must write E_FillState.Filling: in CASE labels too. Qualified names are the better habit in assignments and comparisons, because two enums can share a value name — E_Valve.Fault and E_Drive.Fault — and the reader should never have to guess. The engine on these pages accepts both spellings in CASE labels.

Each name starts at position 0 unless you say otherwise. That matters as soon as an enum value leaves the PLC.

Explicit values and impossible values

When the numbers matter — an HMI faceplate, a SCADA alarm list, a fieldbus status word agreed with another supplier — write them into the type:

TYPE E_ValveState :
(
    Closed     := 0,
    Open       := 1,
    Travelling := 2,
    Discrepant := 9
);
END_TYPE

PROGRAM Main
VAR
    OpenLS   : BOOL;               (* input: open limit switch *)
    ClosedLS : BOOL;               (* input: closed limit switch *)
    Valve    : E_ValveState;
    HmiCode  : INT;                (* status word for the SCADA faceplate *)
    Alarm    : BOOL;
END_VAR

IF OpenLS AND ClosedLS THEN
    Valve := E_ValveState.Discrepant;     (* both made: a switch has failed *)
ELSIF OpenLS THEN
    Valve := E_ValveState.Open;
ELSIF ClosedLS THEN
    Valve := E_ValveState.Closed;
ELSE
    Valve := E_ValveState.Travelling;
END_IF;

HmiCode := Valve;
Alarm   := Valve = E_ValveState.Discrepant;

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

Make both limit switches and HmiCode reads 9, the value the SCADA supplier's faceplate shows as "position discrepancy". The code 9 is now written down in exactly one place, the type, instead of being scattered through the program and the HMI documentation. Explicit values also make gaps possible, which is how you leave room — 10, 20, 30 — for states added later without renumbering anything the HMI depends on. In CODESYS and TwinCAT you can fix the storage type too, ( … ) INT; after the closing bracket, when the value has to match a 16-bit word exactly.

Why bother, when integer constants would carry the same numbers? Because an enum narrows what the variable can mean. With Step : INT, Step := 37; compiles, and so does Step := RecipeNumber;. With strict typing ({attribute 'strict'}, the default for new DUTs in TwinCAT 3), assigning a plain integer to an enum variable is a build error, arithmetic on enums is refused, and comparing a mode with a valve state does not compile. The impossible value is caught at the desk, not by the night shift. Without the attribute, CODESYS still accepts integer assignments to enums for backwards compatibility — and so does the engine on this page, which does not check them. Treat the strict attribute as part of the type.

What vendors call these types

The ideas are universal; the names and the tooling are not.

PlatformStructureEnumeration
CODESYS / TwinCAT 3DUT (Data Unit Type), TYPE … STRUCT exactly as aboveDUT with ( … ), attributes qualified_only and strict
Siemens TIA Portal (S7-1200/1500)PLC data type, often called a UDT, edited as a tableNo classic enumeration type in SCL; projects use user constants in a tag table
Rockwell Studio 5000User-Defined Data Type (UDT), edited as a tableNo enum type; DINT with constants or tag descriptions

In TIA Portal and Studio 5000 you never type TYPE … END_TYPE; you fill in a table of fields, and the access syntax in ST is the same dotted form: "Pumps"[2].Speed in SCL, Pumps[2].Speed in Studio 5000. Both platforms let a structure contain other structures and arrays, so a UDT_Pump can hold a UDT_Motor and an ARRAY[1..4] OF REAL of bearing temperatures.

A last practical point about this engine: it reads only the multi-line form of STRUCT shown in this chapter, with TYPE X :, STRUCT, each field and END_STRUCT on lines of their own. A one-line structure declaration is legal IEC but fails here.

From data types to text and time

A structure gathers everything about one device under one name, and an array of structures turns a whole station into one variable you can loop over. An enumeration gives states, modes and statuses names the compiler checks, with explicit values for anything the HMI or SCADA reads. Use qualified names, switch on strict typing where your tools offer it, and keep the numbers in the type rather than in your head.

Two kinds of data are still missing from your toolkit. The next chapter covers STRING, for building the messages and alarm texts an operator reads, and TIME, for measuring how long a cycle took and how many hours a pump has run — including the Hours field that this chapter left at zero.

Practise this chapter