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. With them, a program that talks about PumpRun[2] and Step = 20 becomes one that talks about Pumps[2].Run and State = Filling, and the second one is the one the night shift can read.
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). It is 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_PROGRAMSwitch 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 rather than 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.
What a structure is in memory
A structure is stored as its fields, one after another, in the order you declared them. That sentence is true on every platform. What differs is whether there are gaps between the fields, and the gaps come from alignment: most processors read a 4-byte value fastest when it starts at an address divisible by 4, so compilers insert padding bytes to put it there.
Here is ST_Pump on a target with natural alignment, which is what TwinCAT 3 uses for 4-byte types:
| Field | Type | Offset | Size | Notes |
|---|---|---|---|---|
Run | BOOL | 0 | 1 | |
| (padding) | 1 | 3 | so the REAL can start at 4 | |
Speed | REAL | 4 | 4 | |
Fault | BOOL | 8 | 1 | |
| (padding) | 9 | 3 | so the DINT can start at 12 | |
Hours | DINT | 12 | 4 | |
Starts | DINT | 16 | 4 | |
| total | 20 | 14 bytes of data, 6 of padding |
Put the BOOLs at the end, Speed, Hours, Starts, Run, Fault, and the same data takes 16 bytes: 12 for the three 4-byte fields, 2 for the BOOLs, and 2 bytes at the end to round the structure up to a multiple of 4, so that the next element of an array of them is aligned too. The saving looks trivial for one pump. For ARRAY[1..500] OF ST_AlarmRecord on a small CPU, ordering the fields from largest to smallest is a free 20 or 30 per cent.
The platforms differ in ways you can trip over:
- TwinCAT changed the rule between versions. Beckhoff documents 1-byte alignment for TwinCAT 2 on x86, 4 bytes for TwinCAT 2 on Arm and 8 bytes for TwinCAT 3. So the
ST_Pumpabove is 14 bytes on a TwinCAT 2 x86 controller and 20 bytes on TwinCAT 3. - CODESYS aligns according to the target platform, and both CODESYS and TwinCAT let you override it per type with
{attribute 'pack_mode' := '1'}above the declaration, which removes the padding. - Siemens S7-1500 blocks with optimized access do not promise any layout; the compiler places fields as it likes. Blocks with standard access follow the classic S7 rules, where BOOLs are packed into bytes and anything of 16 bits or more starts on an even byte. Chapter 27 covers why optimized access exists.
- Rockwell Logix packs consecutive BOOL members of a UDT into hidden SINT members as bits, which is why grouping the BOOLs together in a UDT keeps it small and scattering them between DINTs makes it larger.
Inside one controller, none of this is visible: you write P101.Speed and the compiler finds it. Layout becomes your problem the moment the bytes of a structure leave the controller as a block. That happens when a PC application reads a structure over ADS or OPC as raw bytes, when a structure is sent over a UDP or TCP socket to another device, when it is overlaid on a fieldbus image or a Modbus register map, or when the same retained data is read back by a newer firmware.
A field story: the migration that moved the speeds
A packaging line was migrated from TwinCAT 2 to TwinCAT 3. The PLC code came across almost untouched, and every screen on the machine HMI worked. A separate PC application, written years earlier to collect production data, read an array of drive structures from the controller as one block of bytes and picked the fields out by offset. After the migration it showed sensible values for the first field of each drive and nonsense for the rest: speeds of 0.0000001, hours in the billions. Nothing had changed in the structure's declaration. What had changed was the alignment rule, from 1-byte to 8-byte, so every field after the first BOOL had moved. The quick fix was {attribute 'pack_mode' := '1'} on the shared types; the better fix, done later, was to have the PC read the fields by symbol name and stop depending on offsets at all.
If a structure will ever leave the controller as bytes, write down its layout in the interface document, fix the packing explicitly, and put a version number in its first field.
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.
Whole-structure assignment is IEC 61131-3 and works in CODESYS, TwinCAT and TIA Portal SCL between variables of the same type. Studio 5000 Structured Text does not assign UDTs with :=; Rockwell code copies them with COP or CPS, as it does arrays. Comparison is harder everywhere: IF P101 = P102 THEN is not accepted for structures by the compilers you are likely to use, so a "has anything changed?" check compares the fields one by one. When a structure has thirty fields, that comparison is a good candidate for a function of its own (chapter 17).
A copy also costs time in proportion to the structure's size. Copying a 20-byte pump is nothing. Copying a 4 KB recipe structure every scan, because it was simpler than copying it only when it changes, shows up in the task's execution time, and passing a large structure by value into a function does the same copy on every call. Chapter 18 shows VAR_IN_OUT, which hands the function the original instead of a copy.
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
Combining the two ideas is where structures pay for themselves. 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_PROGRAMPump 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.
Compare the two layouts of the same data. Parallel arrays (PumpRun[3], PumpSpeed[3]) put all the run commands together in memory, then all the speeds. An array of structures puts everything about pump 1 together, then everything about pump 2. On a PLC the performance difference is negligible. The second layout makes the mistakes from the start of this chapter impossible to write. There is no way to resize three quarters of a pump.
Structures inside structures
A field can itself be a structure, and that is how larger projects keep a device readable. A common way to organise a device is by who writes each group of fields: the commands that the sequence or the HMI sends, the status that the device logic reports, and the configuration that the commissioning engineer sets once. Rockwell's PlantPAx library uses prefixes such as Cfg_ and Sts_ for the same split in its add-on instructions; many CODESYS and TIA projects use nested types:
TYPE ST_ValveCmd :
STRUCT
Open : BOOL; (* request from the sequence or the HMI *)
Reset : BOOL; (* fault reset *)
END_STRUCT
END_TYPE
TYPE ST_ValveSts :
STRUCT
Opened : BOOL;
Closed : BOOL;
Fault : BOOL; (* did not reach the commanded position in time *)
END_STRUCT
END_TYPE
TYPE ST_ValveCfg :
STRUCT
TravelTime : TIME := T#3s;
END_STRUCT
END_TYPE
TYPE ST_Valve :
STRUCT
Cmd : ST_ValveCmd;
Sts : ST_ValveSts;
Cfg : ST_ValveCfg;
Solenoid : BOOL; (* output to the coil *)
END_STRUCT
END_TYPE
PROGRAM Main
VAR
OpenReq : BOOL; (* input: sequence asks for XV-101 open *)
ResetPB : BOOL; (* input: fault reset *)
OpenLS : BOOL; (* input: open limit switch *)
ClosedLS : BOOL := TRUE; (* input: closed limit switch *)
XV101 : ST_Valve;
Travel : TON;
Moving : BOOL; (* not yet at the commanded end *)
END_VAR
XV101.Cmd.Open := OpenReq;
XV101.Cmd.Reset := ResetPB;
XV101.Sts.Opened := OpenLS AND NOT ClosedLS;
XV101.Sts.Closed := ClosedLS AND NOT OpenLS;
Moving := (XV101.Cmd.Open AND NOT XV101.Sts.Opened)
OR (NOT XV101.Cmd.Open AND NOT XV101.Sts.Closed);
Travel(IN := Moving, PT := XV101.Cfg.TravelTime);
(* latch the fault when travel takes too long; reset clears it *)
XV101.Sts.Fault := (XV101.Sts.Fault OR Travel.Q) AND NOT XV101.Cmd.Reset;
XV101.Solenoid := XV101.Cmd.Open AND NOT XV101.Sts.Fault;
END_PROGRAMSwitch OpenReq on. The solenoid energises and the travel timer starts. Within three seconds, switch ClosedLS off and OpenLS on, the way the limit switches change on a real stroke, and the valve reports Opened. Try again without touching the limit switches: after three seconds Fault latches and the solenoid drops out. Make the switches match the command, press ResetPB, and the fault clears.
The path names now say who owns each value. XV101.Cmd.Open is written by the sequence; XV101.Sts.Fault is written only by the valve logic; XV101.Cfg.TravelTime is set at commissioning. In a code review that split answers the first question you ask about any variable, which is who writes it. It also tells the HMI engineer which group to map read-only and which group a faceplate may write. Chapter 18 packages exactly this valve as a function block, where the split becomes inputs, outputs and parameters.
Two limits of this site's engine show up with nesting. It writes a field two levels down, such as XV101.Sts.Fault, only as a statement on its own; inside an IF or a FOR such a write does not stick yet, which is why the example latches the fault with one Boolean expression. And it does not support STRING fields inside a structure. Neither limit applies to CODESYS, TwinCAT or TIA Portal, where nesting several levels deep and mixing strings, arrays and structures in one type are routine.
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_PROGRAMSwitch 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.
Where does State start? IEC 61131-3 gives an enumerated variable the first value in its list as its initial value, unless the declaration says otherwise. Here that is Idle, which is what you want. Make it a habit: put the safe, at-rest value first in every enum. A type that lists Running first starts every cold boot in Running, and the first scan of the sequence acts on it.
Each name starts at position 0 unless you say otherwise, and that number is what an HMI or another controller sees 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_PROGRAMMake 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.
Without explicit values, the numbering follows the order of the list, and the order of a list is something people edit. This is the commonest enum accident on a running plant:
Why bother with an enum at all, 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, long before the night shift sees it. 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.
Numbers from outside, names inside
Strict typing protects the inside of your program. At the edges, values still arrive as plain integers: a mode request written by SCADA into a holding register, a state code read back from retained memory after a download, a status from another PLC over a fieldbus. The safe way across that edge is a CASE over the integer, with an ELSE for every value you did not expect:
TYPE E_Mode :
(
Off := 0,
Hand := 1,
Auto := 2,
Maint := 5
);
END_TYPE
PROGRAM Main
VAR
ModeReq : INT := 2; (* input: mode request from SCADA, as a number *)
Mode : E_Mode;
BadReq : BOOL; (* the last request was not a known mode *)
ModeCode : INT;
END_VAR
CASE ModeReq OF
0: Mode := E_Mode.Off; BadReq := FALSE;
1: Mode := E_Mode.Hand; BadReq := FALSE;
2: Mode := E_Mode.Auto; BadReq := FALSE;
5: Mode := E_Mode.Maint; BadReq := FALSE;
ELSE
BadReq := TRUE; (* keep the mode we had *)
END_CASE;
ModeCode := Mode;
END_PROGRAMRequest 2 and the mode is Auto. Request 5 and it is Maint. Now request 3, a value with no name: BadReq comes on and the mode stays at Maint. Request 1 and BadReq clears. The integer never reaches Mode directly, so Mode can only ever hold one of its four names. A cast such as Mode := ModeReq;, which the engine here and a non-strict CODESYS project would accept, lets the 3 straight through, and every CASE Mode OF in the program then falls into its ELSE, or does nothing at all if it has no ELSE.
What the ELSE should do is a design decision. For a mode request, keeping the current mode and raising an alarm is usually right. For a state read back from retained memory at start-up, the safe answer is usually a defined start state, because the program that wrote that value may not be the one now running. Which brings up the second field accident.
A field story: the state that no longer existed
A CIP skid kept its sequence state in a retained enum, so that after a power cut the operator could see where the clean had stopped. A software update removed one step and renumbered the others. On the first restart after the download, the retained value was a number that now had no name, the CASE over the state had no ELSE, and the skid sat in a state that did nothing: no outputs, no transitions, no alarm. The operators saw a blank status field and a machine that ignored its start button. The fix was a ELSE State := E_CipState.Aborted; in the CASE, which also gave the operator a message to act on, and a rule for the project: every CASE over an enum has an ELSE that leads somewhere defined.
What vendors call these types
The ideas are universal; the names and the tooling are not.
| Platform | Structure | Enumeration |
|---|---|---|
| CODESYS / TwinCAT 3 | DUT (Data Unit Type), TYPE … STRUCT exactly as above | DUT with ( … ), attributes qualified_only and strict |
| Siemens TIA Portal (S7-1200/1500) | PLC data type, often called a UDT, edited as a table | No classic enumeration type in SCL; projects use user constants in a tag table |
| Rockwell Studio 5000 | User-Defined Data Type (UDT), edited as a table | No 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.
Where the platform has no enum, the discipline still applies, just by hand. Declare the state numbers as named constants in one place, a tag table or a set of constant tags, never as literals in the code, and keep a CASE with an ELSE wherever the number is decoded.
Enum names can also become text. In TwinCAT 3 from build 4024, an enum type declared with {attribute 'to_string'} makes TO_STRING(Mode) return 'Auto' instead of '2'. That is convenient for a log line or a diagnostic string, and chapter 16 explains why it should stay on the describing side of the program and never feed back into a decision.
One more 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.
Designing types that last
A structure type is an interface. The HMI, the SCADA tag list, the recipe files and often another engineer's code are all built against it, so it changes far less freely than the code that uses it. A few rules have saved me from rework.
Model one thing per type. ST_Pump describes a pump. A structure called ST_Line that holds every pump, valve, alarm and recipe value of the line is a global variable list with a dot in front of it; it couples everything to everything and makes every online change touch the whole program.
Group fields by owner. Commands, status and configuration in separate sub-structures, as in the valve above, make it obvious who may write what, and they map cleanly onto HMI permissions.
Put units in field names where the type does not carry them: Speed_pct, TravelTime, Level_mm. A REAL called Speed will be read as rpm by someone.
Add fields at the end. Code that refers to fields by name does not care about order, but anything that reads the structure as bytes does, and so may retained data across a download on some platforms. Appending is the change least likely to move existing data.
Version the types that leave the controller. A Version : INT as the first field of a structure exchanged with a PC or another PLC lets the receiver refuse a layout it does not understand instead of misreading it.
And give every enum an explicit value for each name the moment the numbers are seen outside the PLC, with the safe value first and gaps for later states.
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. Nested structures split a device into commands, status and configuration, so the path of every value says who owns it. You have seen how the fields are laid out in memory and why it only becomes your concern once the bytes leave the controller. An enumeration gives states, modes and statuses names the compiler checks, with the safe value first and explicit values for anything the HMI or SCADA reads. Use qualified names, switch on strict typing where your tools offer it, decode numbers from outside with a CASE that has an ELSE, 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.