Chapter 30 / 32 · Part 8 · Industrial patterns

Recipes and parameters

Store recipes in arrays of structures, validate a download, apply it at a safe moment and fall back cleanly when values are wrong.

A recipe is a row in a table

A bottle filler in a soft-drinks plant runs perhaps a dozen products a week. Between a 500 ml still water and a 1 litre juice, almost nothing in the program changes. The sequence is the same: index a bottle, lower the valve, fill fast, switch to slow fill just before the target, close, cap, release. What changes is a handful of numbers: the target volume, the point at which the fast fill hands over to the slow fill, the capping torque, the line speed. That set of numbers, with a name and a number on it, is a recipe.

Different industries use the word differently. In batch processing, ISA-88 distinguishes the formula (the parameters) from the procedure (the steps), and talks about master and control recipes. On most discrete machines a recipe is only the formula: the procedure is your state machine, and the recipe feeds it numbers. Format parameters on a case packer, cut lengths on a flying saw, temperature profiles on a heat-sealer and dosing quantities on a mixer are all recipes in this sense, and they all have the same software shape.

That shape is a table. Each row is a product, each column is a parameter. In Structured Text the column list is a STRUCT (chapter 15) and the table is an ARRAY of it:

No.NameFill volume (ml)Pre-close (ml)Cap torque (Ncm)Speed (bottles/min)
1Still water 50050025150120
2Juice 100010004018080
3Still water 33033015140150
4Syrup 7507506020045

The pre-close value is a good example of why recipes exist. A filling valve takes time to close, and the product keeps flowing while it does. On a thin product at high flow you close the fast-fill path 25 ml before the target; on a viscous syrup that sheets down the bottle wall, you need 60 ml. Nobody could guess these numbers in advance. They are tuned during commissioning, product by product, and then stored — which is the other half of this chapter.

TYPE ST_Recipe :
STRUCT
    Name     : STRING;   (* a bounded length on a real target *)
    FillVol  : REAL;     (* ml *)
    PreClose : REAL;     (* ml before target: fast fill -> slow fill *)
    Torque   : REAL;     (* capping torque, Ncm *)
    Speed    : INT;      (* bottles per minute *)
END_STRUCT
END_TYPE

PROGRAM Main
VAR
    RecipeNo : INT := 1;                  (* input: selected on the HMI *)
    (* the stored table *)
    Recipes  : ARRAY[1..4] OF ST_Recipe;
    Loaded   : BOOL;
    Active   : ST_Recipe;                 (* what the machine uses *)
    BadNo    : BOOL;
END_VAR

(* Stands for recipes kept in retained memory or loaded from a file *)
IF NOT Loaded THEN
    Recipes[1].Name := 'Still water 500';
    Recipes[1].FillVol := 500.0;   Recipes[1].PreClose := 25.0;
    Recipes[1].Torque := 150.0;    Recipes[1].Speed := 120;
    Recipes[2].Name := 'Juice 1000';
    Recipes[2].FillVol := 1000.0;  Recipes[2].PreClose := 40.0;
    Recipes[2].Torque := 180.0;    Recipes[2].Speed := 80;
    Recipes[3].Name := 'Still water 330';
    Recipes[3].FillVol := 330.0;   Recipes[3].PreClose := 15.0;
    Recipes[3].Torque := 140.0;    Recipes[3].Speed := 150;
    Recipes[4].Name := 'Syrup 750';
    Recipes[4].FillVol := 750.0;   Recipes[4].PreClose := 60.0;
    Recipes[4].Torque := 200.0;    Recipes[4].Speed := 45;
    Loaded := TRUE;
END_IF;

(* Never index with a number you have not checked *)
IF RecipeNo >= 1 AND RecipeNo <= 4 THEN
    Active.Name     := Recipes[RecipeNo].Name;
    Active.FillVol  := Recipes[RecipeNo].FillVol;
    Active.PreClose := Recipes[RecipeNo].PreClose;
    Active.Torque   := Recipes[RecipeNo].Torque;
    Active.Speed    := Recipes[RecipeNo].Speed;
    BadNo := FALSE;
ELSE
    BadNo := TRUE;                        (* keep the last valid recipe *)
END_IF;

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

Type 2, 3 or 4 into RecipeNo and the active fields follow. Type 7 and BadNo comes on while the active recipe stays exactly as it was. Without that bounds check, Recipes[7] is an out-of-range array access: this engine raises a runtime fault, a CODESYS target with the implicit bounds checks enabled does the same, an S7-1500 sets an access error and, depending on how the block handles it, may stop, and an older controller without checks reads whatever happens to sit in the memory after the array. A recipe number arrives from an operator or an MES. It is exactly the kind of value the previous chapter told you never to trust.

The copy is written field by field. On a real controller Active := Recipes[RecipeNo]; copies the whole structure in one line, and that is what you would normally write in TIA Portal, Studio 5000, CODESYS or TwinCAT. The engine on this site does not support copying a whole structure out of (or into) an array element, so every example in this chapter spells out the fields. It has one advantage as a teaching device: you can see exactly what gets copied, and when.

This program also has a deliberate flaw. It copies the selected recipe into Active on every scan, so changing RecipeNo in the middle of a fill changes the target volume of the bottle under the valve. For a display that would be fine. For a machine it is the most common recipe bug there is, and most of this chapter is about not doing it.

Three copies of every recipe

A recipe system that works on site keeps three copies of the data, each with a different owner:

  • Stored recipes — the table. Owned by the recipe management: written only by a validated save or a validated download, read when a recipe is loaded. It lives in retained memory or in a file.
  • The edit buffer — one recipe's worth of fields the HMI is allowed to write. The operator or technician opens a recipe into the buffer, changes values, and either saves or discards. Nothing the HMI writes goes anywhere else.
  • The active recipe — the values the sequence actually reads. Owned by the program. Written only at a safe moment, from a stored recipe that passed validation.

This is the "request versus truth" rule from the previous chapter scaled up to a whole set of values. An operator can spend ten minutes tuning a new product in the edit buffer while the machine fills bottles at full speed on the active recipe, and nothing they type touches production until they save it, and it is selected, and the machine reaches a point where it can take it.

Many vendors build exactly this structure into their tools. WinCC recipe views, FactoryTalk View's RecipePlus, the CODESYS Recipe Manager and TwinCAT's recipe management all keep a recipe store separate from the PLC tags the machine uses, and have explicit "load to PLC" and "save from PLC" operations. When you write the recipe handling yourself, in ST, you reproduce it with three variables and three commands.

TYPE ST_Recipe :
STRUCT
    FillVol  : REAL;   (* ml *)
    PreClose : REAL;   (* ml *)
END_STRUCT
END_TYPE

PROGRAM Main
VAR
    Open        : BOOL;   (* input: HMI "open recipe 2 for editing" *)
    HMI_FillVol : REAL;   (* input: the field the operator types into *)
    Save        : BOOL;   (* input: HMI "save" *)
    Activate    : BOOL;   (* input: HMI "load to machine" *)
    LastHmi     : REAL;
    Recipes     : ARRAY[1..4] OF ST_Recipe;
    Edit        : ST_Recipe;
    Active      : ST_Recipe;
    OpenEdge    : R_TRIG;
    SaveEdge    : R_TRIG;
    ActEdge     : R_TRIG;
    Init        : BOOL;
END_VAR

IF NOT Init THEN
    Recipes[2].FillVol := 1000.0;
    Recipes[2].PreClose := 40.0;
    Init := TRUE;
END_IF;

OpenEdge(CLK := Open);
SaveEdge(CLK := Save);
ActEdge(CLK := Activate);

(* The HMI only ever writes the edit buffer, and only when the field changes *)
IF HMI_FillVol <> LastHmi THEN
    Edit.FillVol := HMI_FillVol;
    LastHmi := HMI_FillVol;
END_IF;

IF OpenEdge.Q THEN            (* stored -> edit *)
    Edit.FillVol  := Recipes[2].FillVol;
    Edit.PreClose := Recipes[2].PreClose;
END_IF;
IF SaveEdge.Q THEN            (* edit -> stored (validation comes next) *)
    Recipes[2].FillVol  := Edit.FillVol;
    Recipes[2].PreClose := Edit.PreClose;
END_IF;
IF ActEdge.Q THEN             (* stored -> active (safe point comes later) *)
    Active.FillVol  := Recipes[2].FillVol;
    Active.PreClose := Recipes[2].PreClose;
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Open, HMI_FillVol, Save, Activate.

Switch Open on and off: the edit buffer shows the stored 1000 ml. Type 1050 into HMI_FillVol: only Edit.FillVol changes. Toggle Activate: the machine gets the stored 1000, not your unsaved 1050. Toggle Save and then Activate again, and 1050 reaches all three copies. Each copy changes only on its own command.

Validate the whole recipe, then accept it

Before any recipe enters the stored table, from an operator's save or from an MES download, every field is checked. "Every field" is the key phrase, and it is where most home-made recipe code goes wrong. The tempting way to write the check is per field: if the fill volume is in range, take it; if the torque is in range, take it; and so on. Run that against a download with one bad value and you get a recipe in which three fields are new and one is old — a combination nobody designed, nobody tested, and nobody will recognise when the bottles come out wrong.

Whole-recipe validation is naturally a FUNCTION (chapter 17): a recipe goes in, a verdict comes out, and there is no memory. The verdict should say which check failed, not just that one did, because "Recipe rejected" sends a technician hunting through 30 fields while "Recipe rejected: pre-close ≥ 10 % of fill volume" takes ten seconds to fix.

Three kinds of check belong in it:

  1. Field ranges. Each value within the limits of the machine: a fill volume the valve and the bottle can handle, a torque the capping head can produce, a speed the line is rated for.
  2. Cross-field consistency. Values that are individually fine but impossible together. The pre-close volume must be smaller than the fill volume — in practice well under 10 % of it. A slow-fill flow must be lower than the fast-fill flow. A temperature profile's soak temperature must lie between its preheat and peak temperatures.
  3. Identity. The recipe number is in the table's range, the name is not empty, the version is newer than the one it replaces (more on versions below).
TYPE ST_Recipe :
STRUCT
    FillVol  : REAL;
    PreClose : REAL;
    Torque   : REAL;
    Speed    : INT;
END_STRUCT
END_TYPE

(* 0 = valid, otherwise the number of the first failed check *)
FUNCTION ValidateRecipe : INT
VAR_INPUT
    R : ST_Recipe;
END_VAR
IF R.FillVol < 100.0 OR R.FillVol > 1500.0 THEN
    ValidateRecipe := 1;                       (* fill volume range *)
ELSIF R.PreClose <= 0.0 OR R.PreClose >= R.FillVol * 0.1 THEN
    ValidateRecipe := 2;                       (* pre-close vs fill volume *)
ELSIF R.Torque < 80.0 OR R.Torque > 250.0 THEN
    ValidateRecipe := 3;                       (* capping head range *)
ELSIF R.Speed < 20 OR R.Speed > 180 THEN
    ValidateRecipe := 4;                       (* line speed range *)
ELSE
    ValidateRecipe := 0;
END_IF;
END_FUNCTION

PROGRAM Main
VAR
    DlFill       : REAL := 330.0;   (* inputs: fields written by the MES *)
    DlPreClose   : REAL := 15.0;
    DlTorque     : REAL := 140.0;
    DlSpeed      : INT := 150;
    DownloadDone : BOOL;            (* input: MES "all fields written" *)
    DoneEdge     : R_TRIG;
    Download     : ST_Recipe;
    Recipes      : ARRAY[1..4] OF ST_Recipe;
    Verdict      : INT;
    Accepted     : DINT;
    Rejected     : DINT;
END_VAR

DoneEdge(CLK := DownloadDone);

IF DoneEdge.Q THEN
    Download.FillVol  := DlFill;
    Download.PreClose := DlPreClose;
    Download.Torque   := DlTorque;
    Download.Speed    := DlSpeed;
    Verdict := ValidateRecipe(R := Download);
    IF Verdict = 0 THEN
        Recipes[3].FillVol  := Download.FillVol;
        Recipes[3].PreClose := Download.PreClose;
        Recipes[3].Torque   := Download.Torque;
        Recipes[3].Speed    := Download.Speed;
        Accepted := Accepted + 1;
    ELSE
        Rejected := Rejected + 1;              (* slot 3 untouched *)
    END_IF;
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: DlFill, DlPreClose, DlTorque, DlSpeed, DownloadDone.

Toggle DownloadDone with the default values: verdict 0 and slot 3 fills. Now change DlPreClose to 40 and toggle again: verdict 2, because 40 ml is more than 10 % of 330 ml, and slot 3 keeps the previous good values in every field. Try a torque of 900 or a speed of 300. Notice that nothing is checked or copied until DownloadDone rises: the MES writes the fields one by one, and only its "done" tells the PLC the set is complete. That is the same "value, then apply" rule that fixed the torn Modbus write in the last chapter.

The limits sit inside the function here to keep the example short. On a real machine they come from a limits table, which is the subject of a later section, and the function takes it as a second input.

Checksums and version stamps

Validation catches values that are wrong. It does not catch values that are different from what was sent. A download that lost its last two registers, a retained memory area partly overwritten by a program change, a technician who "just tweaked" a stored value from the engineering laptop with an online write: in each case every field may be within its limits, and the recipe is still not the one the quality department approved.

The answer is to store, with every recipe, a value computed from its contents: a checksum. Whoever writes the recipe calculates it; whoever reads the recipe calculates it again and compares. If the two disagree, the contents changed in between.

The simplest checksum is the sum of all the fields. It is better than nothing and easy to compute on both ends — MES, HMI script and PLC — which matters more than mathematical elegance. Two refinements make it far more useful:

  • Scale to integers first. Sum REAL values directly and the PLC, the MES and a spreadsheet will disagree in the last digit, because they round differently. Convert each field to an integer in its smallest unit (tenths of a millilitre, tenths of a Newton-centimetre) and sum integers, which every system adds the same way.
  • Weight each field by its position. A plain sum cannot tell Torque = 150, Speed = 120 from Torque = 120, Speed = 150, which is precisely the error you get when two columns are mapped the wrong way round in an MES interface. Multiply each field by its position (1, 2, 3, …) before adding and a swap changes the result.

Real systems often use a CRC-16 or CRC-32 instead, which also catches more subtle corruption; libraries such as OSCAT include CRC functions for most IEC targets, and some vendors ship their own. A position-weighted sum is the version you can explain to the MES team in one email and verify by hand, and for recipe integrity that is usually enough.

Put a version number next to the checksum. Every accepted save or download increments it. The active recipe's number and version go into the batch record and the SCADA display, so "which recipe filled pallet 4471?" has an exact answer: Juice 1000, version 7. Include the version in the checksum too, so a recipe restored from an old backup is recognisably old.

TYPE ST_Recipe :
STRUCT
    FillVol  : REAL;   (* ml *)
    PreClose : REAL;   (* ml *)
    Torque   : REAL;   (* Ncm *)
    Speed    : INT;    (* bottles per minute *)
    Version  : INT;
    Checksum : DINT;
END_STRUCT
END_TYPE

FUNCTION RecipeSum : DINT
VAR_INPUT
    R : ST_Recipe;
END_VAR
(* Integers in the smallest unit, weighted by position *)
RecipeSum := 1 * TRUNC(R.FillVol * 10.0)
           + 2 * TRUNC(R.PreClose * 10.0)
           + 3 * TRUNC(R.Torque * 10.0)
           + 4 * R.Speed
           + 5 * R.Version;
END_FUNCTION

PROGRAM Main
VAR
    Corrupt    : BOOL;      (* input: someone changes a value behind our back *)
    Stored     : ST_Recipe;
    Init       : BOOL;
    CalcSum    : DINT;
    ChecksumOK : BOOL;
END_VAR

IF NOT Init THEN            (* saved properly: checksum written with the data *)
    Stored.FillVol := 500.0;  Stored.PreClose := 25.0;
    Stored.Torque := 150.0;   Stored.Speed := 120;
    Stored.Version := 3;
    Stored.Checksum := RecipeSum(R := Stored);
    Init := TRUE;
END_IF;

(* An online write that bypasses the save logic *)
IF Corrupt THEN
    Stored.Torque := 165.0;
ELSE
    Stored.Torque := 150.0;
END_IF;

(* Every load re-calculates and compares *)
CalcSum    := RecipeSum(R := Stored);
ChecksumOK := CalcSum = Stored.Checksum;

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

With Corrupt off, the stored checksum is 5000 + 500 + 4500 + 480 + 15 = 10495 and the recalculation agrees. Switch it on: the torque is still a perfectly valid 165 Ncm, but the sum is 450 higher and ChecksumOK drops. A load that sees ChecksumOK = FALSE must refuse the recipe and say why — the next section covers what the machine runs instead.

Applying a recipe at a safe point

The first example's flaw was that Active followed the selection on every scan. The fix is to treat a recipe change as a request that the sequence picks up when it is ready, which means the recipe code has to know what the machine is doing. This is where recipe handling meets the state machine.

For each parameter, decide when a change may take effect:

ChangeSafe pointWhy
Fill volume, pre-closeBetween bottles: before the next valve opensA target that changes mid-fill gives a bottle that matches neither recipe
Line speedAny time, rampedDrives handle a ramped speed change in production; a step change can tip bottles
Capping torqueBetween capsThe torque limit is set before the head engages
Bottle format (height, neck size)Only with the machine empty and stoppedFormat parts are changed by hand; the recipe change is part of a changeover procedure
Product (water to juice)Only after line clearance and a flush or CIPMixing products is a quality or food-safety incident, not a parameter change

The last two rows are the important ones. A product change is not "copy new numbers into Active". It is a sequence of its own — stop the infeed, run out the bottles in the machine, flush or clean the product path, have the operator confirm the line clearance, and only then load the new recipe and allow a start. That sequence lives in your state machine. The recipe code's job is to make sure no new recipe sneaks in any other way.

For the simpler case, a pending change applied between machine cycles looks like this:

PROGRAM Main
VAR
    BottleIn      : BOOL;              (* input: bottle under the valve *)
    RequestNo     : INT := 2;          (* input: recipe chosen on the HMI *)
    Load          : BOOL;              (* input: HMI "load" *)
    LoadEdge      : R_TRIG;
    FillVols      : ARRAY[1..4] OF REAL := [500.0, 1000.0, 330.0, 750.0];
    Step          : INT;               (* 0 waiting, 10 filling, 20 capping *)
    FillTimer     : TON;
    CapTimer      : TON;
    ActiveNo      : INT := 1;
    ActiveFill    : REAL := 500.0;
    PendingNo     : INT;
    ChangePending : BOOL;
END_VAR

LoadEdge(CLK := Load);
FillTimer(IN := Step = 10, PT := T#2s);
CapTimer(IN := Step = 20, PT := T#1s);

(* A request is only remembered here, never applied *)
IF LoadEdge.Q AND RequestNo >= 1 AND RequestNo <= 4 THEN
    PendingNo     := RequestNo;
    ChangePending := TRUE;
END_IF;

CASE Step OF
    0:  (* between bottles: the safe point *)
        IF ChangePending THEN
            ActiveNo      := PendingNo;
            ActiveFill    := FillVols[PendingNo];
            ChangePending := FALSE;
        END_IF;
        IF BottleIn THEN
            Step := 10;
        END_IF;
    10: (* filling to ActiveFill *)
        IF FillTimer.Q THEN
            Step := 20;
        END_IF;
    20: (* capping *)
        IF CapTimer.Q AND NOT BottleIn THEN
            Step := 0;
        END_IF;
END_CASE;

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

Switch BottleIn on: the machine fills for 2 s and caps for 1 s, and stays in step 20 until you switch BottleIn off again, which stands for the bottle leaving. Toggle Load while it is filling. ChangePending comes on and PendingNo shows 2, but ActiveFill stays at 500 until the cycle returns to step 0. Request recipe 3 and then recipe 4 during one fill: only the last request is applied, which is what an operator who changed their mind expects.

Notice where the application happens: inside the state the safe point belongs to, as part of that state's logic. Not in a separate block that checks Step = 0 from somewhere else. When a later engineer adds a step 5 for "waiting for upstream", the apply logic is visibly attached to the state it belongs to and cannot quietly start running in the new one.

Two variations come up on almost every project. When the machine is stopped, a load should apply at once — the stopped state is a safe point too, and an operator setting up a new product should not have to start the machine to see the new values. And when a change is pending for a long time — the machine is running a 4-hour batch — the HMI must show it, with the pending recipe's name, so nobody wonders why the screen says Juice and the machine is filling water.

Fallback to the last good recipe

Validation and checksums tell you a recipe is bad. The machine still needs to know what to run. The answer that works is boring: keep running the last recipe that was good, and say so loudly.

That means the program holds one more copy, last good — the recipe that was most recently validated, checksummed and applied. When a load fails, the active recipe is simply not touched, so "last good" is the active recipe. Where it earns its keep is after a power cycle or a program download. On start-up, a machine typically re-loads its active recipe from the stored table. If that stored recipe fails its checksum — retained memory damaged, or a download that reinitialised part of the table — the program falls back to the separately retained last-good copy, raises an alarm naming the recipe and the reason, and refuses to start production until someone acknowledges it.

Three things must never happen:

  • Running on zeros. A recipe table that has just been reinitialised by a download is full of zeros. A fill volume of 0 ml is outside the limits, so validation catches it — if validation runs on start-up as well as on download. Make it run on start-up.
  • Running on defaults silently. "If the recipe is bad, use the defaults" sounds sensible until the defaults are the commissioning values for a product the plant stopped making two years ago. Defaults may be the fallback of last resort, but only with an alarm and an operator confirmation.
  • Half-applied changes. An apply must copy the complete recipe in one scan. On a controller where the copy cannot be interrupted by communication, that is automatic. On a Logix controller, where an HMI write can land between two lines, the apply happens in the program, from data the program owns — never by the HMI writing fields into the active recipe one by one.

The CODESYS and TwinCAT equivalent of that story is a RETAIN area that gets reinitialised on a download that changes its layout; TIA Portal will reinitialise a retentive data block if you add a variable and do not use the "download without reinitialization" option. The details differ, the lesson does not: the PLC is the place where a recipe is used, not the safest place for it to be kept.

Units, scaling and limits per machine type

A recipe value without a unit is an accident waiting for a date. Fill volume in millilitres or litres? Temperature in °C or °F — and is the MES in the US plant sending the same as the one in Germany? Torque in Ncm or Nm? Every recipe definition should be written down as a table that the PLC team, the HMI team and the MES team all sign off:

FieldType in PLCUnitScale on the wireMinMax
FillVolREALmlDINT, 0.1 ml100.01500.0
PreCloseREALmlDINT, 0.1 ml> 0< 10 % of FillVol
TorqueREALNcmINT, 0.1 Ncm80.0250.0
SpeedINTbottles/minINT, 120180

The "scale on the wire" column matters whenever recipes travel over Modbus, a CSV file or an older SCADA interface. Storing 1050.5 ml as the integer 10505 with an agreed scale of 0.1 avoids REAL rounding in checksums and fits two Modbus registers without a float-format argument. The PLC converts once, on accept, and stores engineering units internally. If you have to support several units — a machine sold in the US and Europe — store the unit code with the recipe and convert on accept, so that the stored table is always in one unit and the checksum is computed on that.

Fill by volume versus fill by weight is the classic units trap on fillers. A flowmeter-based valve doses millilitres; a weigh-cell filler doses grams. Syrup at 1.3 g/ml means a "750" recipe is 975 g. If the MES sends grams to a volumetric filler, every bottle of syrup is 30 % over-filled, and every check in this chapter passes, because 975 is within the volume limits. Units are a contract, not a comment.

The other half of the table — the limits — usually depends on the machine, not on the product. The same software runs on two filler sizes: an FL-24 with 24 valves and a small capping head, and an FL-40 with larger valves and a heavier head. A 1500 ml fill is fine on one and overflows the bottle guides on the other. Keep the limits in their own table, indexed by a machine type that is set once at commissioning, and validate every recipe against the row for this machine.

TYPE ST_Limits :
STRUCT
    FillMin   : REAL;   (* ml *)
    FillMax   : REAL;
    TorqueMin : REAL;   (* Ncm *)
    TorqueMax : REAL;
END_STRUCT
END_TYPE

PROGRAM Main
VAR
    MachineType  : INT := 1;        (* input: 1 = FL-24, 2 = FL-40, set at commissioning *)
    RecipeFill   : REAL := 1200.0;  (* input: a recipe being checked *)
    RecipeTorque : REAL := 220.0;   (* input *)
    Limits       : ARRAY[1..2] OF ST_Limits;
    Lim          : ST_Limits;       (* the row for this machine *)
    Init         : BOOL;
    Verdict      : INT;             (* 0 ok, 1 fill, 2 torque, 9 bad machine type *)
    Usable       : BOOL;
END_VAR

IF NOT Init THEN
    Limits[1].FillMin := 100.0;   Limits[1].FillMax := 1000.0;   (* FL-24 *)
    Limits[1].TorqueMin := 80.0;  Limits[1].TorqueMax := 200.0;
    Limits[2].FillMin := 250.0;   Limits[2].FillMax := 2000.0;   (* FL-40 *)
    Limits[2].TorqueMin := 120.0; Limits[2].TorqueMax := 300.0;
    Init := TRUE;
END_IF;

IF MachineType >= 1 AND MachineType <= 2 THEN
    Lim.FillMin   := Limits[MachineType].FillMin;
    Lim.FillMax   := Limits[MachineType].FillMax;
    Lim.TorqueMin := Limits[MachineType].TorqueMin;
    Lim.TorqueMax := Limits[MachineType].TorqueMax;
    IF RecipeFill < Lim.FillMin OR RecipeFill > Lim.FillMax THEN
        Verdict := 1;
    ELSIF RecipeTorque < Lim.TorqueMin OR RecipeTorque > Lim.TorqueMax THEN
        Verdict := 2;
    ELSE
        Verdict := 0;
    END_IF;
ELSE
    Verdict := 9;   (* an unconfigured machine accepts nothing *)
END_IF;

Usable := Verdict = 0;

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

On machine type 1 the 1200 ml recipe fails on fill volume. Switch MachineType to 2 and it passes. Now set RecipeTorque to 100: verdict 2, because the FL-40's heavier head cannot control torque below 120 Ncm — a value the FL-24 would accept. Set MachineType to 0 and nothing is usable, which is the right answer for a machine whose commissioning engineer forgot a step.

Limits are themselves parameters, but they are not recipe parameters. Operators must not be able to edit them; they are changed by an engineer, with the same validation, versioning and audit as a recipe, and often only from the engineering tool. Keeping them in a separate table makes that separation of permissions natural.

Where recipes live: retained memory and files

Most of the vendor detail about retained variables is in chapter 2. For recipes the question is less "does it survive a power cut?" — on every current controller it can — than "does it survive a download, a firmware update, a CPU replacement, and how do we get it back?"

The usual options, from the PLC outwards:

  • Retained controller memory. In CODESYS and TwinCAT, recipes in a PERSISTENT variable list survive a download as well as a power cycle; TwinCAT writes persistent data to a file on the IPC at shutdown, so a UPS matters. In TIA Portal, a retentive data block; for many recipes, a data block flagged "only store in load memory" that lives on the memory card and is copied in and out of work memory with READ_DBL and WRIT_DBL. In a Logix controller, an array of UDTs in controller tags — retained by design, but replaced by a download.
  • Files on the controller. The S7-1500 RecipeExport and RecipeImport instructions write a recipe data block to a CSV file on the SIMATIC memory card and read it back. CODESYS has a Recipe Manager object that stores recipe files, and the CAA File library for writing your own. TwinCAT has file function blocks in its system library. A CSV the plant can open in a spreadsheet is a surprisingly good recipe format.
  • Outside the controller. HMI recipe storage (WinCC recipes, FactoryTalk View RecipePlus), a SCADA database, or the MES. The PLC receives a recipe through a download handshake and keeps only what it needs.
(* CODESYS V3: a persistent variable list for the recipe table *)
VAR_GLOBAL PERSISTENT RETAIN
    gRecipes      : ARRAY[1..50] OF ST_Recipe;
    gLastGood     : ST_Recipe;
    gLimitsTable  : ARRAY[1..4] OF ST_Limits;
END_VAR

(* TwinCAT 3: the same idea, declared PERSISTENT *)
VAR_GLOBAL PERSISTENT
    gRecipes      : ARRAY[1..50] OF ST_Recipe;
END_VAR

One warning applies to every file-based option: flash memory wears out. Siemens documents a limited number of write cycles for SIMATIC memory cards and warns explicitly against calling WRIT_DBL cyclically. Write recipe files when a recipe is saved, not on every scan, and never from a block that a stuck HMI bit could retrigger 100 times a second.

Audit trail: who changed what, and when

In pharmaceuticals and many food plants, a recipe change is a regulated event. FDA 21 CFR Part 11 and EU GMP Annex 11 require an audit trail for electronic records: who changed the value, when, what the old and new values were, and often why. Even where nobody regulates it, an audit trail turns "somebody changed the torque" into a name and a time, which settles most arguments in one look.

The PLC is usually not where the audit trail lives. It does not know who is logged in unless the HMI tells it, and it has no comfortable place to keep thousands of records. HMI and SCADA packages have audit functions built for this (WinCC's audit option, FactoryTalk's audit logging and AssetCentre). What the PLC can and should do is stamp each recipe with enough to cross-check: the version, the user ID the HMI passed with the save, the time from the controller's real-time clock (chapter 28), and a running change counter. A typical recipe header looks like this:

TYPE ST_RecipeHeader :
STRUCT
    RecipeNo    : INT;
    Name        : STRING[20];
    Version     : INT;        (* +1 on every accepted save or download *)
    ChangedBy   : INT;        (* user ID passed by the HMI with the save *)
    ChangedAt   : DT;         (* controller real-time clock at the save *)
    ChangeCount : DINT;       (* never reset; gaps reveal lost records *)
    Checksum    : DINT;       (* over header and data, version included *)
END_STRUCT
END_TYPE

The change counter is the subtle field. If the audit trail in SCADA shows changes 1 to 41 and the recipe says 43, two changes happened that the audit system never saw — a write from the engineering laptop, perhaps. That gap is exactly what an auditor looks for.

From parameters to motion

A recipe system done properly is mostly discipline, and very little clever code. One structure describes the parameters; an array holds the products. Three copies — stored, edit, active — keep drafts away from production. Every download is validated as a whole, with a reason code for the first failure. A checksum and a version make silent changes visible. Changes are requests that the state machine applies at a point it chooses, and when anything is wrong the machine keeps the last good recipe and says so. Units and limits are written down as a contract, and limits belong to the machine, not the product. And the recipe's real home is wherever it survives the next download.

Many recipe parameters on modern machines are not valve times or torques but positions, velocities and accelerations: a format change that moves a guide rail 42 mm, a cut length that sets a flying saw's target. Those numbers end up in motion commands. The next chapter shows how to power, home and move a servo axis with PLCopen function blocks — and how to apply a new target only when the axis is ready to take it.

Practise this chapter