Chapter 6 / 24 · Part 2 · Decisions

Comparisons and thresholds

Compare at the right boundary, add hysteresis and dead bands, and use LIMIT, MIN, MAX and SEL instead of long IF chains.

Six operators that return a BOOL

Most decisions in a machine program come down to a number against a limit. Is the pressure above 6 bar? Has the batch reached 25 cartons? Is the oven within 2 °C of its setpoint? Structured Text has six comparison operators, and every one of them produces a BOOL:

OperatorMeaningExample
=equal toStepNo = 3
<>not equal toRecipe <> 0
<less thanLevel < 20.0
<=less than or equalCount <= 99
>greater thanTemp > 85.0
>=greater than or equalElapsed >= T#5s

Because the result is a BOOL, a comparison can sit anywhere a BOOL can: in an IF condition, combined with AND and OR, or assigned straight to an output. LowPressure := Pressure < 4.5; is a complete, latch-proof alarm, as you saw in chapter 5. Comparisons bind more loosely than arithmetic and more tightly than AND/OR, so Level + Margin > 90.0 AND Auto means what it reads. Brackets still help the reader; use them when a condition mixes more than two ideas.

Compare values of the same type. INT with INT, REAL with REAL, TIME with TIME. CODESYS and TwinCAT usually accept an INT compared with a REAL and convert it for you; TIA Portal is stricter, especially with its IEC check switched on, and the standard itself expects you to convert explicitly. Write the conversion yourself — INT_TO_REAL(Count) > Limit — and the line means the same on every platform.

Here is a compressed-air header with a 6.0 bar setpoint. All six comparisons run every scan:

PROGRAM Main
VAR
    Pressure  : REAL := 6.0;   (* input: header pressure in bar *)
    Equal     : BOOL;
    NotEqual  : BOOL;
    Below     : BOOL;
    AtOrBelow : BOOL;
    Above     : BOOL;
    AtOrAbove : BOOL;
END_VAR

Equal     := Pressure = 6.0;
NotEqual  := Pressure <> 6.0;
Below     := Pressure < 6.0;
AtOrBelow := Pressure <= 6.0;
Above     := Pressure > 6.0;
AtOrAbove := Pressure >= 6.0;

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

Type 5.9, 6.0 and 6.1 into Pressure. At exactly 6.0, Below and Above are both FALSE while AtOrBelow and AtOrAbove are both TRUE. That single value — the setpoint itself — is where most threshold bugs live.

Which side owns the setpoint

Every threshold splits the number line in two, and the setpoint has to belong to one side. > puts it on the low side; >= puts it on the high side. With a REAL from a transmitter the difference rarely matters in practice, because a noisy analog value is almost never exactly 90.000. With integers, counters and timers it matters every time.

Write the comparison from the specification, word for word. "Alarm above 85 °C" is Temp > 85.0. "Stop at 25 cartons" is Count >= 25. "Open the valve while the level is below 90 %" is Level < 90.0, which means it closes at 90 %. When the specification is vague — and it often is — decide, write the decision in a comment, and make the two thresholds in a pair use opposite operators so there is no value where both or neither apply. Level < 90.0 for "open" and Level >= 90.0 for "full" cover every value exactly once.

The more dangerous mistake is using = for something that passes through a value. A target count compared with = is only true for the scans where the count is exactly equal. If the count ever jumps past the target, the condition is never true at all.

Hysteresis: two thresholds and a memory

A buffer tank is fed by a transfer pump. You want the pump to run when the tank is low and stop when it is full. The obvious code is Pump := Level < 80.0; — and on a real plant it destroys the pump contactor.

The level signal is never perfectly still. Ripples on the surface, turbulence from the inlet and electrical noise on the 4–20 mA loop make it jitter by a fraction of a percent. When the level sits near 80 %, the comparison flips every time the noise crosses the line. The pump starts, stops and starts again several times a second. A typical 7.5 kW motor is rated for perhaps ten to fifteen starts per hour; chattering burns through that in seconds, and the contactor tips weld long before the motor overheats.

The cure is hysteresis: two thresholds with a band between them. Start the pump when the level falls below 20 %. Stop it when the level rises above 80 %. Between 20 % and 80 %, do nothing — the pump keeps whatever state it already had.

IF Level < 20.0 THEN
    Pump := TRUE;
ELSIF Level > 80.0 THEN
    Pump := FALSE;
END_IF;

That is an IF without an ELSE — the very pattern chapter 5 warned about. Here it is correct, and on purpose. The missing ELSE is the memory: inside the band, nothing writes Pump, so it holds. The difference from the trap is that there are two conditions, one that sets and one that resets, and together they cover both directions. Put a comment on it so the next engineer does not "fix" it.

The program below feeds both versions with the same noisy level. The noise is a fake ±0.6 % wobble added on alternate scans, so you can see what a real transmitter does. The two counters record how many times each pump has started.

PROGRAM Main
VAR
    BaseLevel    : REAL := 50.0;   (* input: the "true" level in % *)
    Level        : REAL;           (* what the transmitter reports: true level + noise *)
    ScanCount    : DINT;
    SimplePump   : BOOL;           (* single threshold *)
    BandPump     : BOOL;           (* hysteresis 20 % / 80 % *)
    SimplePrev   : BOOL;
    BandPrev     : BOOL;
    SimpleStarts : DINT;
    BandStarts   : DINT;
END_VAR

(* Fake noise: +0.6 % on even scans, -0.6 % on odd scans *)
ScanCount := ScanCount + 1;
IF ScanCount MOD 2 = 0 THEN
    Level := BaseLevel + 0.6;
ELSE
    Level := BaseLevel - 0.6;
END_IF;

SimplePump := Level < 80.0;

(* Hysteresis: no ELSE on purpose - between the thresholds the pump holds *)
IF Level < 20.0 THEN
    BandPump := TRUE;
ELSIF Level > 80.0 THEN
    BandPump := FALSE;
END_IF;

(* Count starts: output TRUE now, FALSE last scan - see chapter 8 *)
IF SimplePump AND NOT SimplePrev THEN
    SimpleStarts := SimpleStarts + 1;
END_IF;
IF BandPump AND NOT BandPrev THEN
    BandStarts := BandStarts + 1;
END_IF;
SimplePrev := SimplePump;
BandPrev   := BandPump;

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

Set BaseLevel to 15, so the tank is low, then to 80, right on the upper threshold. SimpleStarts climbs by one every other scan — five starts a second at this scan rate. BandStarts moves once and stops. Then set BaseLevel to 50 and notice that BandPump stays off: inside the band, it remembers that the last thing that happened was "full".

That memory raises a question you must answer for every hysteresis output: what state is it in when the PLC starts and the level is inside the band? Here BandPump starts FALSE because that is its initial value, so a tank at 50 % waits until it drains below 20 %. For a transfer pump that is usually right. For a heater keeping a tank of product above its freezing point it may not be. Choose the initial value deliberately.

How wide should the band be? Wide enough that noise and normal process movement cannot cross it, narrow enough that the process stays in spec. On a level, 5–10 % of span is common. On a temperature loop with on/off heating, a band of ±1 to ±2 °C around the setpoint is typical. When the band is expressed around a setpoint, it is usually called a dead band:

IF Temp < Setpoint - DeadBand THEN
    Heater := TRUE;
ELSIF Temp > Setpoint + DeadBand THEN
    Heater := FALSE;
END_IF;

Siemens, Beckhoff and CODESYS libraries all ship a ready-made hysteresis block, and you can wrap your own as a function block once you reach chapter 18. Underneath, every one of them is these five lines.

Never compare REALs with =

REAL values are binary floating point. Most decimal fractions — 0.1, 0.2, 0.3 — have no exact binary representation, so they are stored as the nearest value the format can hold. Add a few of them and the small errors accumulate. A dosing scale that adds 0.1 kg ten times does not, in general, arrive at exactly 1.0 kg.

PROGRAM Main
VAR
    Doses : INT;
    Total : REAL;
    Exact : BOOL;    (* Total = 1.0 *)
    Close : BOOL;    (* Total within 0.001 of 1.0 *)
END_VAR

(* Add ten doses of 0.1 kg, one per scan *)
IF Doses < 10 THEN
    Total := Total + 0.1;
    Doses := Doses + 1;
END_IF;

Exact := Total = 1.0;
Close := ABS(Total - 1.0) < 0.001;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it.

After ten scans Doses is 10 and Total is a hair under 1.0. Exact stays FALSE forever; Close is TRUE. The digits you see here come from the browser's 64-bit arithmetic. A PLC's 32-bit REAL holds only about seven significant digits, so its error is larger and lands on a different digit — but on both, Total = 1.0 is FALSE. (LREAL, the 64-bit type, shrinks the error; it does not remove it.)

The fix is to compare with a tolerance: ABS(A - B) < Tolerance. Choose the tolerance from the physics, not from habit. If the load cell resolves 5 g, a tolerance of 0.002 kg is meaningless and 0.5 kg is sloppy; half the resolution, 0.0025 kg, says "equal as far as this instrument can tell". For setpoint arrival on a process value, the tolerance is a process decision — "within 1 °C" — and usually belongs in a parameter, not a literal.

Often you do not need equality at all. A dosing step does not end when the weight equals the target; it ends when the weight reaches it: Weight >= Target - Preact, where the preact accounts for the material still falling from the valve. <, <=, > and >= work perfectly well on REALs. Only = and <> are the trap.

LIMIT, MIN, MAX and SEL

A lot of IF chains exist only to keep a number inside bounds or to pick one of two values. IEC 61131-3 has standard functions for exactly that, and they make the intent obvious at a glance.

  • MIN(A, B) returns the smaller value; MAX(A, B) the larger. MAX(0.0, Demand) stops a negative demand. MIN(Demand, PumpCapacity) caps it.
  • LIMIT(MN, IN, MX) clamps IN between MN and MX. It is MIN(MAX(IN, MN), MX) in one call. The argument order is minimum, value, maximum — the value is in the middle. Swap them and the function still compiles; it just returns the wrong thing.
  • SEL(G, IN0, IN1) returns IN0 when G is FALSE and IN1 when G is TRUE. The FALSE choice comes first, which surprises nearly everyone once.

An operator types a conveyor speed on the HMI. The drive must never be commanded below 10 % (the motor's cooling fan is shaft-mounted) or above 100 %. In manual mode a fixed jog speed replaces the recipe value.

PROGRAM Main
VAR
    OperatorSpeed : REAL := 60.0;   (* input: HMI speed entry in % *)
    Manual        : BOOL;           (* input: manual mode selected *)
    JogSpeed      : REAL := 15.0;   (* input: manual jog speed in % *)
    Clamped       : REAL;           (* operator value, kept inside 10..100 % *)
    SpeedCmd      : REAL;           (* output: speed reference for the drive *)
END_VAR

Clamped  := LIMIT(10.0, OperatorSpeed, 100.0);
SpeedCmd := SEL(Manual, Clamped, JogSpeed);   (* FALSE -> Clamped, TRUE -> JogSpeed *)

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

Type 250 or -40 into OperatorSpeed: Clamped holds at 100 or 10. Flip Manual and SpeedCmd switches to the jog speed. Written as IFs, the same logic is a nested chain of eight or nine lines with three places to get a boundary wrong.

Two cautions. First, clamping an operator entry hides the bad value; if the operator typed 250, the HMI should show it was rejected rather than silently running at 100. Clamp to protect the machine, and validate the entry in the HMI too. Second, function names do not always port. Rockwell's ladder LIM instruction is a range test that returns a BOOL, not a clamp — confuse the two when converting between platforms and you get a very different machine.

Range checks: is this number believable?

A comparison can also ask whether a value makes physical sense at all. A level transmitter on a 4–20 mA loop should read between 0 % and 100 %. A reading of -12 % does not mean the tank is emptier than empty; it means the wire is broken, the transmitter has lost power or someone is calibrating it. Under the NAMUR NE 43 convention, many transmitters drive the loop below 3.6 mA or above 21 mA on purpose to report a fault, and your scaled value will land well outside the 0–100 % span.

A plausibility check is a pair of comparisons with a little margin either side of the span:

PROGRAM Main
VAR
    Level      : REAL := 55.0;   (* input: primary level transmitter in % *)
    LevelB     : REAL := 56.0;   (* input: redundant transmitter on the same tank *)
    LevelOk    : BOOL;           (* primary reading is physically possible *)
    Agree      : BOOL;           (* both transmitters tell the same story *)
    PumpAllowed: BOOL;           (* the pump may only run on a believable level *)
END_VAR

LevelOk     := (Level >= -2.0) AND (Level <= 105.0);
Agree       := ABS(Level - LevelB) <= 3.0;
PumpAllowed := LevelOk AND Agree;

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

Set Level to -15 or 130 and LevelOk drops. Set the two transmitters 5 % apart and Agree drops. In both cases the pump loses its permission. The margins (-2 % and 105 %) leave room for normal calibration error near the ends of the span, so a transmitter that reads 100.4 % in a full tank does not trip the plant.

The design rule behind this: a control decision is only as good as the number it compares. A hysteresis pump fed by a broken transmitter reading -12 % will run until the tank overflows, perfectly obeying Level < 20.0. Check the signal first, then compare it. Chapter 19 builds this out into full 4–20 mA scaling with wire-break detection and delayed alarms.

Thresholds, then choices among many

You can now put a comparison exactly where the specification says, keep = for values that are set rather than passed through, stop outputs chattering with hysteresis, compare REALs with a tolerance, and let LIMIT, MIN, MAX and SEL replace IF chains whose only job was bounding or picking a number. You also check that a number is believable before you trust it.

Many decisions are not about thresholds at all. A mode selector has three positions. A recipe number picks one of twelve parameter sets. A sequence step is 10, 20 or 30. You could write those as long ELSIF chains of = comparisons — and the next chapter shows why CASE does that job better.

Practise this chapter