Chapter 5 / 32 · Part 2 · Decisions

IF, ELSIF and ELSE

Choose between paths with IF, order ELSIF branches by priority, and avoid the missing-ELSE trap that latches outputs.

The program has to choose

Up to now every statement you wrote ran the same way on every scan. Horn := DoorOpen AND NOT MaintKey; is evaluated top to bottom, every 10 ms, and the answer changes only because the inputs change. That carries you a surprisingly long way, since most interlocks are one boolean expression. Sooner or later, though, a machine has to do different things in different situations. A filling valve opens while the tank is low and closes when it is full. A drive runs at the recipe speed in automatic, at a crawl in jog, and at zero when it is faulted. That is what IF is for.

The shape is the same in every IEC 61131-3 environment, from CODESYS and TwinCAT 3 to Siemens SCL in TIA Portal and Structured Text routines in Studio 5000:

IF condition THEN
    (* statements that run when the condition is TRUE *)
ELSE
    (* statements that run when it is FALSE *)
END_IF;

The condition is any expression that produces a BOOL: a single input, a comparison like Level < 90.0, or a full interlock built from AND, OR and NOT as in chapter 4. THEN opens the branch, END_IF; closes the whole statement, and the semicolon after END_IF is required. Inside the branches you can put as many statements as you like, including other IFs.

Here is a filling valve on a 2,000-litre buffer tank. The level transmitter reports percent; the valve should be open below 90 % and closed at or above it.

PROGRAM Main
VAR
    Level     : REAL := 40.0;   (* input: tank level in %, 0..100 *)
    FillValve : BOOL;           (* output: solenoid on the inlet valve *)
END_VAR

IF Level < 90.0 THEN
    FillValve := TRUE;
ELSE
    FillValve := FALSE;
END_IF;

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

Type a new value into Level and watch the valve on the next scan. Try 89.9, 90 and 95. The IF is re-evaluated on every scan, exactly like the one-line assignments in the previous chapters. Nothing about an IF makes it "happen once". There is no event and no waiting: each scan the controller asks the question again and takes one branch.

(A real filling valve switched at a single threshold would chatter open and shut as the surface ripples around 90 %. The cure is hysteresis, and it is the main subject of chapter 6. For now, a single threshold keeps the IF easy to see.)

What the controller does with an IF

It helps to know what an IF turns into, because it explains most of the behaviour in this chapter. The compiler translates the condition into ordinary code that leaves one BOOL result, followed by a conditional jump: if the result is FALSE, jump over the THEN branch to the ELSE branch, or past END_IF if there is no ELSE. At the end of the THEN branch sits an unconditional jump over the ELSE branch. In pseudo-machine code the valve above looks like this:

    load   Level
    compare with 90.0, less-than
    jump-if-false  L_else
    store  TRUE  -> FillValve
    jump           L_end
L_else:
    store  FALSE -> FillValve
L_end:

Two things follow. A branch that is not taken costs nothing except the test: its statements are not "evaluated and discarded", they are simply jumped over. And because of that, a statement inside a branch that is not taken has no effect at all on this scan. It does not write its variable, it does not call its function block, it does not update anything. Every bug later in this chapter comes from forgetting that.

The second consequence is about time. A program full of IFs does not take the same time on every scan. When a recipe download or an alarm-handling branch with a large FOR loop is taken, that scan is longer. The controller's watchdog only cares about the slowest scan. TIA Portal sets the maximum cycle time of an S7-1500 to 150 ms by default, and CODESYS and TwinCAT let you set a watchdog per task; whichever you use, the number that counts is the worst path through the code, not the average. When you commission a machine, look at the maximum cycle time the controller has recorded (TIA Portal shows shortest, current and longest in the online diagnostics), not just the current one. A routine that is quick for eight hours and slow for one scan at shift change is still a routine that can trip the watchdog.

Reading the condition once

The condition is computed once, at the top, from the values the variables have at that moment. If a statement inside the THEN branch changes a variable the condition used, the IF does not notice: it has already chosen. The branch runs to the end, then execution continues after END_IF.

IF Count < 10 THEN
    Count := Count + 5;      (* Count may now be 12 *)
    Overfill := Count > 10;  (* this line still runs: the IF chose before Count changed *)
END_IF;

That sounds obvious written down. It stops being obvious inside a long branch that someone has edited five times.

ELSIF: the first true branch wins

When there are more than two cases, chain them with ELSIF. The controller tests the conditions from the top. The first one that is TRUE runs its branch, and every condition below it is skipped: not evaluated, not run. If none is TRUE, the ELSE branch runs, if there is one.

That makes the order of an ELSIF chain a priority list. Write the most important condition first. On a conveyor drive the priority is nearly always the same: a fault stops it, a jog request overrides normal running, and only then does the automatic speed apply.

PROGRAM Main
VAR
    Fault         : BOOL;           (* input: drive or motor fault *)
    Jog           : BOOL;           (* input: jog push-button, held *)
    Run           : BOOL;           (* input: automatic run request *)
    SpeedSetpoint : REAL := 75.0;   (* input: recipe speed in % *)
    SpeedCmd      : REAL;           (* output: speed reference for the VFD in % *)
END_VAR

IF Fault THEN
    SpeedCmd := 0.0;
ELSIF Jog THEN
    SpeedCmd := 10.0;              (* crawl speed for threading the belt *)
ELSIF Run THEN
    SpeedCmd := SpeedSetpoint;
ELSE
    SpeedCmd := 0.0;
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Fault, Jog, Run, SpeedSetpoint.

Switch on Run, then Jog, then Fault, in any combination. Whatever else is on, Fault forces zero, because its branch is tested first. Jog beats Run because it sits above it. Read the chain aloud from the top and you have the specification: "if faulted, zero; otherwise if jogging, ten percent; otherwise if running, the setpoint; otherwise zero."

The chain, scan by scan

A scan table shows the same thing with nothing hidden. Each row is one scan; the inputs are what the controller read at the start of it.

ScanFaultJogRunConditions testedBranch takenSpeedCmd
1FALSEFALSEFALSEFault, Jog, RunELSE0.0
2FALSEFALSETRUEFault, Jog, RunRun75.0
3FALSETRUETRUEFault, JogJog10.0
4TRUETRUETRUEFaultFault0.0
5FALSETRUETRUEFault, JogJog10.0
6FALSEFALSETRUEFault, Jog, RunRun75.0

Look at the "conditions tested" column. On scan 4 only one condition is evaluated; the other two are never looked at. Now look at scan 5. The fault bit dropped, and on the very next scan the belt is back at jog speed with no reset and no operator action. The chain has no memory. Each scan it answers "what should the speed be right now?" from the inputs alone. For a jog button that is exactly right. For a drive fault it usually is not: most specifications want a fault to stop the machine until someone acknowledges it, and that needs a latch, which is chapter 8, and an alarm lifecycle, which is chapter 22. A scan table is the quickest way to find that kind of gap before the machine finds it for you.

One more property follows from "first true wins": the branches of a chain are mutually exclusive. Exactly one of them runs per scan, or none if there is no ELSE. Two separate IF statements behave differently. Both can run in the same scan, and the second one has the last word. Keep that difference in mind; it comes back below.

ELSIF, not ELSE IF

The keyword is one word. Write ELSE IF with a space and you have not written a chain at all: you have written an ELSE branch that contains a brand-new IF, and that inner IF needs its own END_IF;.

IF Fault THEN
    SpeedCmd := 0.0;
ELSE IF Jog THEN            (* a new IF nested inside the ELSE *)
    SpeedCmd := 10.0;
END_IF;                     (* closes the inner IF only *)
                            (* the outer IF is still open here *)

A strict compiler reports the missing END_IF at the bottom of the routine, often on a line that has nothing wrong with it, which is why this one costs people twenty minutes the first time. If you do add the second END_IF, the code compiles and behaves like the chain, but every further ELSE IF adds another level of nesting and another END_IF. Siemens SCL, Studio 5000 ST and CODESYS all spell the keyword ELSIF. If you write C or Python by day (else if, elif), this is the typo your fingers will make.

Nesting, and when to flatten it

An IF can contain another IF. Nesting reads naturally when the outer condition is a mode and the inner conditions only make sense inside it:

IF AutoMode THEN
    IF Level < 90.0 THEN
        FillValve := TRUE;
    ELSE
        FillValve := FALSE;
    END_IF;
ELSE
    FillValve := ManualOpen;
END_IF;

Each IF needs its own END_IF;, and indentation is the only thing that tells a human which ELSE belongs to which IF. The compiler matches them by structure and ignores the indentation, so a mis-indented block compiles fine and misleads the next reader. Indent every level by the same amount (four spaces is common) and let the editor do it for you. CODESYS, TwinCAT and TIA Portal can all re-indent a block on demand; Studio 5000's ST editor is less helpful, so on Rockwell projects it is worth being disciplined by hand.

Two levels are fine. Three are a warning sign. By four you are writing a maze: the condition for the innermost line is the AND of everything above it, and nobody can see it at a glance. Most deep nests flatten into a short ELSIF chain with combined conditions:

IF NOT AutoMode THEN
    FillValve := ManualOpen;
ELSIF Level < 90.0 THEN
    FillValve := TRUE;
ELSE
    FillValve := FALSE;
END_IF;

Same behaviour, one level, and each line reads as a full rule. You can prove the two are the same with a small table: list every combination of the conditions and check that both versions give the same output.

AutoModeLevel < 90.0Nested versionFlat version
FALSEeitherManualOpenManualOpen
TRUETRUETRUETRUE
TRUEFALSEFALSEFALSE

Three rows here; with three conditions it is eight, and with four it is sixteen. That growth is the real argument against deep nesting. When a nest grows because of machine modes (off, manual, automatic, maintenance), the right tool is usually CASE, which you will meet in chapter 7.

The missing-ELSE trap

Here is the most common IF bug in PLC code, and it compiles without a warning. Someone writes a high-level alarm like this:

IF Level > 80.0 THEN
    HighAlarm := TRUE;
END_IF;

It looks complete. Raise the level above 80 % and the alarm comes on. Now let the level fall back to 50 %, and the alarm stays on. Forever, or until the PLC restarts.

The reason is the scan. When Level > 80.0 is FALSE, the IF jumps over its only branch. No statement writes HighAlarm, so it keeps the value it had at the end of the previous scan: TRUE. A variable in a PLC is memory. It holds its value until something writes a new one. An IF without an ELSE is, in effect, a latch: it can set the output, and nothing ever clears it.

Try it live. The program computes the same alarm twice, once with the missing ELSE and once correctly.

PROGRAM Main
VAR
    Level       : REAL := 50.0;   (* input: tank level in % *)
    HighLatched : BOOL;           (* the bug: IF with no ELSE *)
    HighAlarm   : BOOL;           (* correct: IF with ELSE *)
END_VAR

IF Level > 80.0 THEN
    HighLatched := TRUE;
END_IF;

IF Level > 80.0 THEN
    HighAlarm := TRUE;
ELSE
    HighAlarm := FALSE;
END_IF;

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

Set Level to 85: both turn on. Set it back to 50: HighAlarm clears, HighLatched stays TRUE. In a plant this shows up as a pump that never stops, a lamp that will not go out, or a valve that "sticks" in software. The engineer on site sees an output that is on when every condition in the IF is false, and the IF looks perfectly innocent.

The trap is easy to miss in a review because the logic you see is right. What is wrong is the logic you do not see: the path where the condition is false and nothing happens.

That comparison with ladder is worth keeping. A normal coil in LD is an assignment: it writes the rung result every scan. A Set coil (-(S)-) is an IF without ELSE. If you came from ladder, an IF with no ELSE should look to you like a Set coil, because that is what it is.

The same idea in each vendor's ladder

EnvironmentWritten every scan (like := outside an IF)Written only when the rung is true (like an IF with no ELSE)
Studio 5000OTE, Output EnergizeOTL, Output Latch; OTU, Output Unlatch
TIA Portalassignment coil --( )--set coil --(S)--, reset coil --(R)--
CODESYS, TwinCAT 3coilset coil (S), reset coil (R)

When you convert a ladder routine to ST, or read someone else's conversion, this table is the check. Every OTE that becomes an IF … THEN x := TRUE; END_IF; has turned into an OTL without anyone deciding it should.

Studio 5000 adds one wrinkle of its own in ST. Rockwell's documentation calls the ordinary := the retentive assignment and offers a second operator, [:=], the non-retentive assignment. A tag written with [:=] is reset to zero when the controller enters Run mode, and when an SFC step that is configured for automatic reset is left. It is tempting to read "non-retentive" as "fixes the missing ELSE". It does not: between those events the tag behaves exactly like one written with :=, so an IF that writes it only on TRUE still leaves it TRUE once the condition goes FALSE. [:=] is vendor syntax; the engine on this site does not accept it.

The IF that freezes on a mode change

The missing ELSE has a second form that survives code reviews even better, because the assignment inside the IF looks like a plain, latch-proof assignment. The IF is a mode:

PROGRAM Main
VAR
    Auto        : BOOL := TRUE;   (* input: mode selector in Auto *)
    Level       : REAL := 50.0;   (* input: tank level in % *)
    ValveFrozen : BOOL;           (* the bug: only written while in Auto *)
    ValveOwned  : BOOL;           (* written on every path *)
END_VAR

IF Auto THEN
    ValveFrozen := Level < 90.0;
END_IF;

IF Auto THEN
    ValveOwned := Level < 90.0;
ELSE
    ValveOwned := FALSE;          (* leaving Auto closes the valve *)
END_IF;

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

The program starts in Auto with the tank at 50 %, so both valves are open. Now switch Auto off. ValveOwned closes. ValveFrozen stays open: the line that wrote it is no longer executed, so it keeps the value Auto last gave it. Type 95 into Level and it is still open. Nothing in the program is filling the tank on purpose, and yet the inlet valve is open, in a mode where the operator expects to be in control.

This is the version I have seen most often on site. A machine is switched from Auto to Manual in the middle of a cycle, and one output carries on doing whatever the automatic sequence last told it to. The operator blames the valve, maintenance changes the solenoid, and the fault comes back the next time someone switches mode at the same point in the cycle. The clue is that it depends on when the mode was changed. When an output misbehaves only after a mode change, search for every write to it and check what happens to each one in the mode you are now in.

The same freeze happens with anything that is only called inside an IF: a function block (a timer stops timing, an edge detector stops seeing edges), a scaling calculation, a counter. Chapter 9 shows it with a timer, where it is just as common.

Default first, then override

There are two clean ways to make sure an output is written on every scan. The first is the full IF/ELSE you have already seen: every branch writes every output. The second is to write a default at the top, unconditionally, and let later IFs override it:

PROGRAM Main
VAR
    LowAir      : BOOL;   (* input: air pressure switch below 5 bar *)
    DoorOpen    : BOOL;   (* input: panel door switch *)
    FilterDirty : BOOL;   (* input: differential pressure switch on the filter *)
    Beacon      : BOOL;   (* output: amber warning beacon *)
END_VAR

Beacon := FALSE;                (* default: no warning *)

IF LowAir THEN
    Beacon := TRUE;
END_IF;
IF DoorOpen THEN
    Beacon := TRUE;
END_IF;
IF FilterDirty THEN
    Beacon := TRUE;
END_IF;

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

Each IF on its own has no ELSE, and yet nothing latches. The first line resets Beacon at the start of every scan, so the output is TRUE only if some IF set it in this scan. Remember from chapter 1 that only the final value reaches the terminal, so the brief FALSE at the top of the scan never causes a flicker.

There is one qualification. The output terminal only sees the end-of-scan value, because the controller copies the output image to the module after the program has finished. Anything that reads the variable in the middle of the scan sees the intermediate value. On a single-task PLC that is just your own code further down. On a controller with several tasks it can be a higher-priority task that interrupts this one between the default and the override, reads Beacon, and sees FALSE. Chapter 26 covers that properly; for now, do not let another task read a variable that this task writes in two steps.

Both patterns are correct. They suit different situations.

The order of the overrides is a priority, like an ELSIF chain read upside down: with separate IFs, the one written last wins. The beacon does not care, because every override writes the same value. A speed reference does. If one IF writes 10 % for jog and a later one writes 0 % for a fault, the fault wins because it is lower down. If someone moves the fault IF up the routine "to keep the faults together", jog wins. With overrides that write different values, I prefer an ELSIF chain, where the priority reads from the top and cannot be changed by moving code around.

When you do not need an IF at all

Look again at the corrected high-level alarm:

IF Level > 80.0 THEN
    HighAlarm := TRUE;
ELSE
    HighAlarm := FALSE;
END_IF;

Five lines to say something the comparison already says. Level > 80.0 is a BOOL, so you can assign it directly:

HighAlarm := Level > 80.0;

It is shorter, and it also cannot latch, because it writes the output on every scan by construction. There is no ELSE to forget. Likewise, the beacon above is simply Beacon := LowAir OR DoorOpen OR FilterDirty;. An IF whose only job is to write TRUE in one branch and FALSE in the other should almost always be an assignment. Plenty of in-house coding standards make that a review rule.

Two related habits mark code written by someone still thinking in other languages. The first is comparing a BOOL with TRUE: IF PumpRunning = TRUE THEN works, but IF PumpRunning THEN says the same with less to read, and IF NOT PumpRunning THEN is clearer than = FALSE. The second is the = and := mix-up. In ST, := assigns and = compares, and an assignment is a statement, not an expression, so IF Mode := 2 THEN does not compile. That is a real advantage over C, where if (mode = 2) compiles and assigns without a word. The reverse slip, = typed where you meant :=, is the one to look for when an output "never changes": read its assignment character by character.

Keep IF for real choices: when a branch does several things, when the outputs are not booleans (speeds, setpoints, step numbers), or when you need to protect a calculation from running with bad data. That last case deserves a section of its own.

Guarding a calculation

Division is the classic calculation that must not run with bad data. Divide by zero on a controller and you do not get a polite error message. What you get depends on the platform and the data type. A Logix controller records a minor fault (type 4, code 4) and sets its overflow status flag, then keeps running with a result you should not trust. On CODESYS-based runtimes an integer division by zero depends on the processor: on many targets it raises an exception that stops the application, which is why CODESYS offers optional implicit-check functions (CheckDivInt and its siblings) that you can add to a project to catch it first. A REAL division on a target with IEEE floating point produces infinity or NaN instead, and that value then spreads through everything calculated from it. The engine on this site treats any division by zero as a runtime fault, which is the honest choice: it makes the bug impossible to miss.

The guard is an IF around the calculation, with an ELSE that gives the result a defined, harmless value:

PROGRAM Main
VAR
    TotalLitres : REAL := 1250.0;   (* input: litres dispensed this shift *)
    Batches     : INT := 0;         (* input: batches completed this shift *)
    AvgLitres   : REAL;             (* output: average litres per batch *)
    AvgValid    : BOOL;             (* output: TRUE when AvgLitres means something *)
END_VAR

IF Batches > 0 THEN
    AvgLitres := TotalLitres / INT_TO_REAL(Batches);
    AvgValid  := TRUE;
ELSE
    AvgLitres := 0.0;
    AvgValid  := FALSE;
END_IF;

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

At the start of a shift Batches is 0, and the program runs happily. Type 5 into Batches and the average appears. Two details are deliberate. The guard tests Batches > 0 and not Batches <> 0, because a negative batch count is just as meaningless as zero. And the ELSE writes both outputs, with AvgValid telling the HMI and the rest of the program that 0.0 is a placeholder and not a measurement. A guard that just skips the division leaves the old average sitting in AvgLitres, which is the missing-ELSE trap in a new costume.

The same pattern protects square roots of negative numbers, array indexes that might be out of range (chapter 13), and any scaling that divides by a span an operator can type in.

Do not rely on AND to skip the division

A guard is often written on one line, the way you would in C or Java:

IF (Batches > 0) AND (TotalLitres / INT_TO_REAL(Batches) > 300.0) THEN
    BigBatches := TRUE;
END_IF;

In those languages && stops as soon as its left side is false, so the division never runs with a zero. That is called short-circuit evaluation, and Structured Text does not promise it. CODESYS states in its documentation that its AND always evaluates every operand; for the short-circuit behaviour it adds two operators of its own, AND_THEN and OR_ELSE, as an extension to the standard (TwinCAT 3 has them too). Other compilers may or may not stop early, and whatever yours does today is not something to build a guard on.

The nested form costs two extra lines and works on every controller:

IF Batches > 0 THEN
    BigBatches := TotalLitres / INT_TO_REAL(Batches) > 300.0;
ELSE
    BigBatches := FALSE;
END_IF;

Notice that it also fixes the missing ELSE of the one-liner.

One output, one owner

Everything in this chapter assumes that the output you are reasoning about is written in one place. In real projects that assumption breaks all the time. The fill valve is opened by the automatic sequence in one routine, by a manual-mode IF in another, and closed by a safety IF somewhere near the bottom of a third. Each IF is fine on its own. Together they produce behaviour that depends on routine execution order, and the engineer reading any one of them has no idea the others exist.

(* Routine A: automatic filling *)
IF AutoMode AND Level < 90.0 THEN
    FillValve := TRUE;
ELSE
    FillValve := FALSE;
END_IF;

(* Routine B, 400 lines later: manual mode *)
IF ManualOpen THEN
    FillValve := TRUE;     (* silently overrides routine A *)
END_IF;

Here routine B has the last word whenever ManualOpen is on, and it also contains a latent latch. The fix is structural: give FillValve one owner. Work out the requests wherever they belong (AutoFillRequest, ManualFillRequest, FillPermissive), then write the output in exactly one place, with one ELSIF chain or one expression that a reviewer can read in full. Most cross-reference tools (TIA Portal's, Studio 5000's, CODESYS's) will list every write to a tag. If an output has more than one write, ask why. Chapter 21 builds a whole program structure on this rule.

Here is what the one-owner version of that valve looks like. The requests are computed where they belong; the output is written once, and the priority reads from the top:

(* computed in the routines that own them *)
AutoFillRequest   := AutoMode AND Level < 90.0;
ManualFillRequest := ManualMode AND ManualOpen;
FillPermissive    := NOT PumpFault AND Level < 95.0;

(* the only write to FillValve in the whole project *)
IF NOT FillPermissive THEN
    FillValve := FALSE;
ELSIF ManualFillRequest OR AutoFillRequest THEN
    FillValve := TRUE;
ELSE
    FillValve := FALSE;
END_IF;

When the valve misbehaves at three in the morning, the technician opens one routine, watches four BOOLs online and knows which one is wrong. That is what the rule buys you, and it is in almost every company coding standard I have worked to.

None of this is a safety function. An overflow cut-out written in the standard PLC program, like the Level < 95.0 permissive above, protects product and equipment. Where an overflow could hurt someone (hot, toxic or flammable contents), the protection belongs in an independent safety system designed to IEC 61511 or ISO 13849-1, with its own level switch and its own way of closing the valve.

Reviewing an IF: the branch table

When you review someone else's IF logic, or your own a week later, the fastest check I know is a branch table. Write the branches down the side and every output the statement touches across the top, then fill in what each branch writes. Any empty cell is an output that keeps its old value on that path.

Take this drive logic from a real-looking project:

IF Fault THEN
    SpeedCmd  := 0.0;
    FaultLamp := TRUE;
ELSIF Jog THEN
    SpeedCmd  := 10.0;
    RunLamp   := TRUE;
ELSIF Run THEN
    SpeedCmd  := SpeedSetpoint;
    RunLamp   := TRUE;
ELSE
    SpeedCmd  := 0.0;
END_IF;
BranchSpeedCmdRunLampFaultLamp
Fault0.0(not written)TRUE
Jog10.0TRUE(not written)
RunSpeedSetpointTRUE(not written)
ELSE0.0(not written)(not written)

Five empty cells, and each one is a real fault report waiting to happen. FaultLamp is set once and never cleared, so the red lamp stays on after the fault has been reset. RunLamp stays on when the drive faults out of Run, and stays on when Run is simply switched off. The speed column is complete, which is why the drive itself behaves and the lamps get blamed on the HMI.

The table takes two minutes to draw and it does not depend on reading skill or on being awake. It works the same for a CASE in chapter 7, where the rows are the labels. For an IF without ELSE, add an "IF not taken" row; it is always entirely empty, which is the missing-ELSE trap made visible.

From choices to boundaries

The habit to carry forward from this chapter is the branch table: for every IF you write, know what each output is on every path, including the path where nothing runs. Write a default first or give every branch every output, guard calculations with a nested IF rather than a clever AND, and give each output one owner.

Most of the conditions you wrote here were comparisons against a number: Level < 90.0, Level >= 95.0, Strokes > 0. Those look trivial and are not. Which side of the setpoint does "exactly 90 %" belong to? What happens when a noisy level sits right on the line? Why does = on a REAL almost never mean what you think? The next chapter is about comparisons and thresholds, and about making them behave at the boundary.

Practise this chapter