Arithmetic on numbers that have a type
An expression is anything that produces a value: Start AND NOT Stop, Layers * 6, INT_TO_REAL(Raw) / 10.0. The right-hand side of every assignment is an expression, and so is every condition you will write in an IF. Structured Text gives you the arithmetic operators you expect:
| Operator | Meaning | Example |
|---|---|---|
+ - | add, subtract | Bottles + Rejects |
* | multiply | Cases * 12 |
/ | divide | Total / Batches |
MOD | remainder after integer division | Bottles MOD 12 |
** or EXPT | power | Radius ** 2.0 |
- (unary) | negate | -Offset |
So far this looks like school maths. The difference is the one from the last chapter: every value has a type, and the type of the operands decides the type of the result. Two INT values multiply into an INT. Two REAL values divide into a REAL. An INT divided by an INT is an INT, which means the fraction is gone before you ever see it. Almost every arithmetic bug in PLC code comes from forgetting that sentence.
Here is a palletiser calculation. Each layer holds CasesPerLayer cases of 12 bottles, plus some loose bottles in a tray on top:
PROGRAM Main
VAR
Layers : DINT := 4; (* input: full layers on the pallet *)
LooseBottles : DINT := 5; (* input: bottles in the top tray *)
CasesPerLayer : DINT := 6;
Bottles : DINT; (* total bottles on the pallet *)
Wrong : DINT; (* the same formula, parenthesised badly *)
END_VAR
Bottles := Layers * CasesPerLayer * 12 + LooseBottles;
Wrong := Layers * CasesPerLayer * (12 + LooseBottles);
END_PROGRAMWith 4 layers and 5 loose bottles, Bottles is 4 × 6 × 12 + 5 = 293. Wrong puts the parentheses around 12 + LooseBottles and multiplies the loose bottles by every case on the pallet: 408. Both lines build; only one describes the pallet.
How the controller works through an expression
The compiler turns an expression into a sequence of single operations, each with two operands and one result, and each result is held in a temporary location until the next operation needs it. You never see those temporaries in a watch table, but they have types, and most of this chapter is about what happens inside them. Here is Bottles := Layers * CasesPerLayer * 12 + LooseBottles; taken apart for the values above:
| Step | Operation | Operand types | Intermediate result | Type of the result |
|---|---|---|---|---|
| 1 | Layers * CasesPerLayer | DINT, DINT | 24 | DINT |
| 2 | step 1 * 12 | DINT, literal | 288 | DINT |
| 3 | step 2 + LooseBottles | DINT, DINT | 293 | DINT |
| 4 | store in Bottles | DINT | 293 |
The literal 12 has no type of its own until it meets an operand; the compiler gives it the type the operation needs, here DINT. That is why Count * 100 and Count * 100.0 are different calculations: the second literal is a real number, and depending on the platform it either forces the multiplication into REAL or is refused as a type mismatch.
Two rules from the standard decide the order. Precedence, in the next section, decides which operation is done first. Within one level, operations run left to right, and the 2003 edition of IEC 61131-3 adds that the left operand of an operator is evaluated before the right one. For arithmetic on plain variables the order of evaluation never changes the answer. It starts to matter when an operand is a function call with a side effect, and with boolean operators, where the standard allows a compiler to stop evaluating once the answer is known; chapter 4 comes back to that.
Precedence: which operator goes first
When an expression has several operators, IEC 61131-3 evaluates them in a fixed order of precedence, from strongest to weakest:
| Order | Operators |
|---|---|
| 1 | parentheses ( ) |
| 2 | function calls: ABS(x), INT_TO_REAL(x) |
| 3 | power ** |
| 4 | unary minus -, NOT |
| 5 | * / MOD |
| 6 | + - |
| 7 | comparisons < > <= >= |
| 8 | = <> |
| 9 | AND (also written &) |
| 10 | XOR |
| 11 | OR |
Operators on the same row are evaluated from left to right. So A - B + C is (A - B) + C, and A / B * C is (A / B) * C, a detail that decides the answer with integers, as you are about to see.
You do not need to memorise rows 7 to 11 yet; chapter 4 uses them. You do need rows 5 and 6 cold, because the classic mistake is the average:
Integer division truncates, MOD keeps the rest
Dividing two integers gives an integer, and the fractional part is cut off. It is not rounded. 7 / 2 is 3. 599 / 100 is 5. For negative numbers the cut goes toward zero, so -7 / 2 is −3, not −4. The operator that goes with it is MOD, the remainder: 7 MOD 2 is 1, and the sign of a MOD result follows the number being divided, so -7 MOD 4 is −3. In the standard, MOD takes integers only; there is no remainder of a REAL.
That pair is exactly what you need for anything that comes in whole units:
PROGRAM Main
VAR
Bottles : DINT := 100; (* input: bottles off the filler *)
FullCases : DINT; (* complete cases of 12 *)
LooseInCase : DINT; (* bottles waiting in the open case *)
END_VAR
FullCases := Bottles / 12;
LooseInCase := Bottles MOD 12;
END_PROGRAM100 bottles make 8 full cases and 4 loose. Type 11, 12 and 13 into Bottles and watch the boundary: the case count only steps when the twelfth bottle arrives. MOD is also how you make something happen every nth item (a sample bottle every 50, a label change every 500) because Count MOD 50 returns to zero once per fifty.
Truncation becomes a real bug when it happens in the middle of an expression. Here is the percentage calculation that appears in almost every first OEE screen:
(* Static listing: 37 good parts out of 50 *)
Percent := Count / Total * 100; (* 37 / 50 = 0, then 0 * 100 = 0 -> 0 % *)
Percent := Count * 100 / Total; (* 3700 / 50 = 74 -> 74 % *)
On a PLC the first line shows 0 % for every value of Count below Total and 100 % when they are equal, because Count / Total is evaluated first and truncates to zero. The second line multiplies first and divides last, so only the final result is truncated. The rule: in integer arithmetic, multiply before you divide. Then check the multiplication still fits: Count * 100 overflows an INT once Count passes 327, which brings us to the sections on conversion and overflow.
Rounding on purpose
Integer division always truncates toward zero, and the "add half the divisor" trick from the prediction above only works for positive numbers. Take a temperature in tenths of a degree, rounded to whole degrees by (T_x10 + 5) / 10. For +14 (1.4 °C) it gives 19 / 10 = 1, which is right. For −14 (−1.4 °C) it gives −9 / 10, which truncates toward zero to 0, and −1.4 °C has just been rounded to zero. The negative half of the range needs (T_x10 - 5) / 10.
The cleaner route for anything signed is to do the arithmetic in REAL and choose the conversion that states what you want. The standard gives you two: REAL_TO_INT (and its DINT and LINT cousins) rounds to the nearest integer, and TRUNC cuts toward zero. Siemens SCL adds ROUND, CEIL (up, toward +∞) and FLOOR (down, toward −∞). Studio 5000 ST has TRN for truncation; assigning a REAL to a DINT there rounds.
PROGRAM Main
VAR
Value : REAL := 2.5; (* input: try 2.4, 2.5, 3.5, -2.5 and -2.6 *)
Rounded : DINT; (* nearest integer *)
Truncated: DINT; (* toward zero *)
END_VAR
Rounded := REAL_TO_DINT(Value);
Truncated := TRUNC(Value);
END_PROGRAMTry the values in the comment. For 2.4 and −2.6 nothing surprises you. The halves are where platforms differ. The engine on this page rounds an exact half to the even neighbour, so 2.5 gives 2 and 3.5 gives 4; this is the IEEE 754 default, sometimes called banker's rounding, and it stops rounding errors from all leaning the same way over thousands of operations. Other runtimes round halves away from zero, giving 3 and 4. The difference is one count on one value in a thousand, which is harmless for a display and fatal for a test that compares exact results. Never build logic that depends on how a half rounds.
Mixing types: conversions are your job
IEC 61131-3 lets the compiler convert automatically only in the direction that cannot lose information: SINT to INT to DINT to LINT, INT to REAL, REAL to LREAL. These are widening conversions. Anything in the other direction, such as DINT to INT, REAL to INT or LREAL to REAL, is narrowing: it may lose digits or range, and it must be written explicitly with a conversion function named SOURCE_TO_TARGET:
PROGRAM Main
VAR
Raw_x10 : INT := 235; (* input: temperature in tenths, 23.5 degC *)
SpeedSetpoint_rpm : REAL := 1437.8; (* input: speed setpoint from a recipe *)
Temp_degC : REAL;
SpeedRef_rounded : INT; (* to a drive that takes whole rpm *)
SpeedRef_truncated : DINT;
END_VAR
Temp_degC := INT_TO_REAL(Raw_x10) / 10.0;
SpeedRef_rounded := REAL_TO_INT(SpeedSetpoint_rpm);
SpeedRef_truncated := TRUNC(SpeedSetpoint_rpm);
END_PROGRAMINT_TO_REAL(Raw_x10) / 10.0 converts first and divides in REAL, so 235 becomes 23.5. Had you written Raw_x10 / 10, you would get the integer 23. The two narrowing conversions show the other choice you must make: REAL_TO_INT rounds to the nearest whole number (1438), TRUNC cuts the fraction off (1437). Exactly how a value ending in .5 rounds differs between vendors, as the previous section showed.
Now break it on purpose. In the editor, replace REAL_TO_INT(SpeedSetpoint_rpm) with plain SpeedSetpoint_rpm. The program no longer builds: the compiler refuses to put a REAL into an INT without being told how. That refusal is the IEC rule doing its job.
A narrowing conversion of a value that does not fit is its own trap. DINT_TO_INT of 40,000 does not give 32,767; on most platforms it keeps the low 16 bits and gives −25,536. REAL_TO_INT of 1.0E6 cannot produce a valid INT at all; the standard treats it as an error and leaves the result to the implementation, and runtimes differ. If a value can be out of range for its target, clamp it first, with LIMIT from chapter 6, and convert the clamped value.
Real environments are stricter or looser than the standard, and this is one of the places where moving code between them hurts:
- CODESYS and TwinCAT 3 compile implicit narrowing assignments but warn about them ("implicit conversion … possible loss of information"). Treat those warnings as errors. A project with two hundred of them hides the one that matters.
- TIA Portal converts many types implicitly in S7-1500 blocks. Ticking IEC check in the block properties makes the compiler much stricter about mixed types; turn it on for new code.
- Studio 5000 is the most permissive: in Logix ST you can assign a
REALstraight to aDINT, and the controller rounds it. Code that relies on that will not build anywhere stricter.
What the standard says about implicit conversion
The 1993 and 2003 editions of IEC 61131-3 said little about implicit conversion, and the vendors filled the gap in different ways, which is why the list above looks the way it does. The 2013 edition added explicit tables of which conversions may happen implicitly, essentially the widening ones listed at the start of this section, and which must be written out. Compilers that claim third-edition conformance move toward those tables, but each vendor keeps its own compatibility settings for older code. The safe position does not depend on the edition: write every conversion you mean, and none will be done for you that you did not mean.
Overflow in the middle of an expression
The last chapter showed that a stored value wraps when it exceeds its type. The same thing can happen to an intermediate result, a value that is never stored anywhere and so never appears in a watch table. This is the most common analog scaling bug on Siemens controllers, where raw values are INT from 0 to 27,648:
(* Static listing: drive speed feedback, 0..27648 raw = 0..1500 rpm *)
VAR
Raw : INT; (* 27648 at full speed *)
Speed : INT;
END_VAR
Speed := Raw * 1500 / 27648; (* Raw * 1500 = 41,472,000 -- an INT holds 32,767 *)
Strictly by the standard, Raw * 1500 is an INT times an INT, so the product is an INT. At full speed it should be 41,472,000. In 16 bits it wraps to −12,288, and −12,288 / 27,648 truncates to 0: the drive is at 1,500 rpm and the screen says 0. Whether you see this depends on the compiler. TIA Portal SCL evaluates the product in the operands' type and wraps. Logix does all arithmetic in DINT internally, and CODESYS evaluates intermediates in the processor's 32-bit registers, so both happen to get 1,500. The engine on this page also keeps intermediates wide, which is why this is a static listing. Code that is correct on only some compilers is not correct. Widen before the multiplication, so every intermediate has room:
PROGRAM Main
VAR
Raw : INT := 27648; (* input: 0..27648 from the drive *)
Speed_rpm : DINT; (* integer result, widened first *)
Speed_r : REAL; (* or do the whole thing in REAL *)
END_VAR
Speed_rpm := INT_TO_DINT(Raw) * 1500 / 27648;
Speed_r := INT_TO_REAL(Raw) * 1500.0 / 27648.0;
END_PROGRAMBoth lines are safe on every platform: the largest intermediate, 41,472,000, fits comfortably in a DINT and trivially in a REAL. Try 13824 (half scale, 750 rpm) and 100 (5 rpm, truncated from 5.4 in the DINT line). Before you write any multiplication, ask what the largest possible product is; the final result can fit when the product does not.
The average that overflows
Averaging is the other place intermediates bite, because the sum is bigger than either value. Two pressure readings held as INT in hundredths of a bar, 300.00 and 310.00 bar, are stored as 30,000 and 31,000. Both fit; their sum does not:
(* Static listing: on a compiler that keeps INT intermediates in 16 bits *)
A := 30000;
B := 31000;
Avg := (A + B) / 2; (* 61,000 wraps to -4,536; Avg = -2,268 *)
Mid := A + (B - A) / 2; (* 30,000 + 500 = 30,500: no intermediate is too big *)
A + (B - A) / 2 is the textbook fix and it works when both values have the same sign, which readings from one transmitter range do. If they can have opposite signs, B - A can overflow instead. Widening is the fix that needs no thought: (INT_TO_DINT(A) + INT_TO_DINT(B)) / 2, converted back to INT at the end. When an average runs over an array of readings, the same applies to the running total, which needs a type with room for all of them added together; chapter 13 shows that loop.
Fixed-point: integers that carry a decimal
Not every value with a decimal point is a REAL. Devices and protocols often send measurements as integers with an agreed scale factor: a temperature of 23.5 °C as 235 (tenths), a pressure of 4.25 bar as 425 (hundredths), a drive frequency in hundredths of a hertz. This is fixed-point: the decimal point is fixed by convention instead of being stored. You met it already in Raw_x10 and in the Modbus challenge of the last chapter.
Fixed-point is exact. A totaliser in whole millilitres held in a DINT adds 1 mL a million times and arrives at exactly 1,000,000, which a REAL totaliser in litres would not. It is also how most fieldbus data is defined, because a 16-bit register cannot hold a REAL. The cost is that every value carries a factor you must remember, so put it in the name (_x10, _x100, _mL) and convert to REAL in exactly one place.
The PROFIdrive profile, used by SINAMICS drives on PROFINET, has a fixed-point convention of its own that is worth knowing by heart. In standard telegram 1, the speed setpoint word NSOLL_A is a signed 16-bit value in which 16#4000 (16,384) means 100 % of the drive's reference speed, parameter p2000. The actual-speed word NIST_A uses the same scale. With p2000 set to 1,500 rpm, 750 rpm is 8,192, and −300 rpm is −3,277. The scale goes to 16#7FFF, about 200 %, and negative values mean reverse.
PROGRAM Main
VAR CONSTANT
RefSpeed_rpm : REAL := 1500.0; (* must match p2000 in the drive *)
Full : REAL := 16384.0; (* 16#4000 = 100 % of p2000 *)
END_VAR
VAR
Speed_rpm : REAL := 750.0; (* input: speed the program wants *)
NIST_A : INT := 8100; (* input: actual speed word from the drive *)
NSOLL_A : INT; (* output: setpoint word to the drive *)
Actual_rpm: REAL;
END_VAR
NSOLL_A := REAL_TO_INT(Speed_rpm / RefSpeed_rpm * Full);
Actual_rpm := INT_TO_REAL(NIST_A) / Full * RefSpeed_rpm;
END_PROGRAM750 rpm gives 8,192; the actual-speed word 8,100 reads back as 741.6 rpm, a drive still accelerating. The constant RefSpeed_rpm is the weak point of every such interface. If someone changes p2000 in the drive to 3,000 rpm and nobody changes the PLC, every speed is out by a factor of two, and the drive does exactly what it was told. Write the value of p2000 next to the constant, in the comment and in the commissioning record.
Most vendor drive libraries (Siemens' SINA_SPEED block, for one) do this scaling for you. You still need to know the convention to read a raw telegram in a watch table or a Wireshark capture.
Dividing by zero
Division by zero is a runtime error on every controller. What happens next depends on the platform: the CPU stops with an exception, a fault is logged and the result is meaningless, or a status flag is set and the program runs on with a number nobody should use. A REAL division by zero usually produces an IEEE 754 infinity, which then spreads through every calculation that uses it and turns a PID output into nonsense.
None of those is something to discover on a running line, and the divisor is often zero at exactly the moment nobody tests: the first scan of a shift, before any parts have been counted. An OEE screen that computes Good * 100 / Total will divide by zero at 06:00 every morning until the first part arrives.
CODESYS and TwinCAT offer a debugging aid: "POUs for implicit checks". Add a function such as CheckDivDInt or CheckDivReal to the project and the compiler calls it before every division of that type, so you can catch a zero divisor and log where it came from. The checks cost time on every division, so they are a commissioning tool, not a design.
The design is a guard. Here the guard is MAX, which returns the larger of its two arguments; it gets a full treatment in chapter 6, and chapter 5 shows the same guard written with IF:
PROGRAM Main
VAR
Good : DINT; (* input: good parts this shift *)
Total : DINT; (* input: all parts this shift *)
Yield_pct : DINT; (* 0..100 for the OEE screen *)
END_VAR
Yield_pct := Good * 100 / MAX(Total, 1);
END_PROGRAMWith both inputs at zero, MAX(Total, 1) is 1 and the yield reads 0 %, which is honest: no parts, no yield. Type 37 and 50 and it reads 74. The guard costs one function call and documents the case where Total is zero.
REAL is approximate: never compare with =
A 32-bit REAL stores about seven significant digits in binary. Many decimal fractions, including 0.1, have no exact binary form, so every addition of 0.1 carries a tiny error, and the errors accumulate. A dosing totaliser that adds 0.1 litres per scan does not reach exactly 1.0 after ten scans.
The fix belongs to the comparison; the arithmetic is fine as it is:
PROGRAM Main
VAR
Step_l : REAL := 0.1; (* input: litres dosed per scan *)
Target_l : REAL := 1.0;
Volume_l : REAL;
DoneEqual : BOOL; (* the bug *)
DoneAtLeast: BOOL; (* the fix *)
END_VAR
Volume_l := Volume_l + Step_l;
DoneEqual := DoneEqual OR (Volume_l = Target_l);
DoneAtLeast := DoneAtLeast OR (Volume_l >= Target_l);
END_PROGRAMDoneAtLeast latches and DoneEqual never does. Now reload the example and type 0.25 into Step_l before it runs far: 0.25 is exactly representable in binary, so this time DoneEqual works too. That is precisely why the = bug survives commissioning: it works with the test value and fails with the production one. Notice also that DoneAtLeast latches on scan 11 here, not 10, because the sum after ten steps is a hair under 1.0. On a real dosing system you would close the valve slightly early anyway, to allow for the product still falling.
Seven digits also means that adding a small number to a big one can do nothing at all. As chapter 2 showed, a REAL totaliser stalls at 16,777,216. Keep totals in integers or LREAL.
ABS, SQRT and EXPT
The standard library has the mathematical functions you need for process work. They take and return REAL (or LREAL), except ABS, which works on any number:
ABS(x)is the magnitude without its sign. Use it for deviations:ABS(Actual - Setpoint) > 5.0is "more than 5 away in either direction".SQRT(x)is the square root. Flow through an orifice plate is proportional to the square root of the differential pressure.EXPT(x, y)orx ** yraises to a power.EXPT(Diameter_m, 2.0)for areas,EXPT(10.0, Exponent)for logarithmic sensors.LN,LOG,EXP,SIN,COS,TANand the arc functions are there too, with angles in radians.
PROGRAM Main
VAR
dP_mbar : REAL := 64.0; (* input: differential pressure across the orifice *)
K : REAL := 12.5; (* meter factor from the orifice datasheet *)
Flow_m3h : REAL;
Setpoint_m3h : REAL := 90.0;
Deviation : REAL;
PipeArea_m2 : REAL;
END_VAR
Flow_m3h := K * SQRT(dP_mbar);
Deviation := ABS(Flow_m3h - Setpoint_m3h);
PipeArea_m2 := 3.14159 * EXPT(0.08 / 2.0, 2.0); (* DN80 pipe *)
END_PROGRAMAt 64 mbar the flow is 12.5 × 8 = 100 m³/h, ten above setpoint. Quarter the pressure to 16 mbar and the flow only halves, to 50 m³/h. That square-root shape is why orifice flow meters read poorly at the bottom of their range. Be careful at zero: a transmitter that drifts slightly negative gives SQRT a negative argument, which is undefined. Real code clamps the input first, with MAX(dP_mbar, 0.0).
A small vendor note: ABS of the most negative integer has no positive equivalent. ABS of an INT holding −32,768 stays −32,768 on most controllers, because +32,768 does not fit. It almost never comes up, but it is a good reminder that integer ranges are lopsided.
Durations follow arithmetic rules of their own. The standard allows TIME + TIME, TIME - TIME, and a TIME multiplied or divided by a number, so T#2s * 3 is T#6s and CycleTime / 2 is half a cycle. TIME * TIME is meaningless and will not build. Chapter 16 covers the conversions between TIME and plain numbers.
A scaling formula done right
Most of the arithmetic in a real program is scaling: turning raw counts into engineering units and back. Every linear scaling is the same formula, the straight line between two known points:
(* Static listing: the general linear scaling *)
Out := (In - InMin) * (OutMax - OutMin) / (InMax - InMin) + OutMin;
A pressure transmitter for −1 to +9 bar on an S7 analog module has InMin = 0, InMax = 27,648, OutMin = −1.0 and OutMax = 9.0. Write it in REAL, convert the raw value at the start, and keep the span (OutMax - OutMin) as a span. The two classic mistakes are using OutMax instead of the span and forgetting + OutMin; both give correct readings at one end of the range only, which is exactly the end people test first.
Check any scaling formula at three points before you trust it: the bottom of the range, the top, and one point in the middle. For the −1 to +9 bar transmitter:
| Raw | (In - InMin) | × span 10.0 / 27,648 | + OutMin | Result |
|---|---|---|---|---|
| 0 | 0 | 0.0 | −1.0 | −1.0 bar |
| 13,824 | 13,824 | 5.0 | −1.0 | 4.0 bar |
| 27,648 | 27,648 | 10.0 | −1.0 | 9.0 bar |
The middle row is the one that catches a missing span: without it, a formula that only uses OutMax gives 4.5 bar instead of 4.0 and looks almost right. Two lines on a calibration sheet, or three cases in a test, find it.
Reviewing arithmetic
When you review someone else's calculations, read each expression twice: once for the formula and once for the types. The second reading is where the bugs are. Look for every / between two integers and ask whether the fraction it throws away was wanted. Look for every multiplication and work out the largest product, using the largest input the hardware can deliver rather than the largest one the process normally sees. Find every = between two REALs. Find every divisor and ask what it is on the first scan of a shift, after a recipe change and when a sensor has failed. Check every scaling formula at its two ends and its middle. And find the conversions: there should be few of them, each at the edge of a calculation, each with a name that says whether it rounds or cuts.
What to take into the next chapter
The type of the operands decides the type of the result, so an integer division throws its fraction away before anything else can use it. Multiply before you divide, widen before you multiply, and ask what the largest intermediate value can be. Convert deliberately, once at each end of a calculation, and choose between rounding and truncating on purpose; halves round differently on different platforms, and integer tricks for rounding break on negative numbers. Fixed-point integers are exact but carry a scale factor that must be written down, PROFIdrive's 16#4000 included. Guard every divisor. Compare REAL values with >=, <= or a tolerance, never with =. And scaling is always the same straight line: span, then offset.
Precedence rows 7 to 11 (comparisons, AND, XOR, OR) are where expressions stop computing numbers and start making decisions. The next chapter turns them into the permissives, seal-ins and interlocks that decide whether a motor is allowed to run at all.