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.
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 matters a lot 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 — not rounded, cut off. 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.
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 next two sections.
Division by zero deserves one sentence here: it is a runtime error on every controller. Depending on the platform it stops the CPU with an exception, raises a fault, or sets an error flag and returns a meaningless value. Never let a divisor reach zero unchecked; you will write that guard with IF in chapter 5, or with MAX(Total, 1) in chapter 6.
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 — DINT to INT, REAL to INT, LREAL to REAL — is narrowing, may lose digits or range, and 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, so do not build logic that depends on it.
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.
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.
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, so it 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. 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 — not the result, the product.
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, not the arithmetic:
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)— magnitude without sign. Use it for deviations:ABS(Actual - Setpoint) > 5.0is "more than 5 away in either direction".SQRT(x)— square root. Flow through an orifice plate is proportional to the square root of the differential pressure.EXPT(x, y)orx ** y— 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 matters, but it is a good reminder that integer ranges are lopsided.
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.
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. 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.