PLC Recipe Management & Batch Control: Implementing ISA-88 in Structured Text

Build production-grade recipe and batch control systems following the ISA-88 standard — with full Structured Text implementations for phase logic, recipe storage, and equipment modules.

What ISA-88 (S88) gives a PLC programmer

ISA-88 (also called S88 or IEC 61512) is the international standard for batch process control. It gives you one framework for designing, programming and operating batch plants, from pharmaceutical reactors to food processing lines. Its central idea is a split between what you want to make (the recipe) and how the equipment makes it (the control logic). With that split in place you can run hundreds of different products on the same equipment without rewriting PLC code.

In practice that buys you several things. You change products by loading a new recipe, so the PLC program stays the same. You write the equipment logic once and use it for any recipe, and the same architecture works for 1 reactor or 100. ISA-88 is also widely used in regulated batch industries such as pharma, where the batch records the system produces must meet electronic-records rules like FDA 21 CFR Part 11.

The three ISA-88 models

ISA-88 describes a batch plant with three models that work together: the equipment you have, the procedure you run on it, and the recipe that fills in the numbers.

Physical model: where the PLC logic lives

Enterprise
  └── Site
        └── Area
              └── Process Cell
                    └── Unit ← (This is where PLC logic lives)
                          ├── Equipment Module (EM)
                          │     └── Control Module (CM)
                          └── Equipment Module
                                └── Control Module

In PLC terms, a unit is a reactor, mixer or tank that can carry out a complete batch phase. An equipment module is a functional group inside it, such as the agitator assembly or the heating jacket. A control module is a single actuator or sensor: a valve, a temperature sensor, a motor.

Procedural model: from the whole batch down to a phase

LayerPurposeExample
ProcedureComplete batch"Make 500L of Product A"
Unit ProcedureSteps within one unit"Charge → Heat → React → Drain"
OperationMajor activity"Heat to 80°C and hold 30 min"
PhaseSmallest executable"Open valve, start agitator, ramp temp"
Operations are the major steps (Charge, React, Discharge). Phases are the smallest actions the equipment can execute, with names like Open_Valve, Heat_To_Temp, Agitate or Transfer_Material.

Your main job as the PLC programmer is the phases, at the bottom of this list. On larger plants a batch server such as Wonderware InBatch or Rockwell FactoryTalk Batch runs the levels above. Smaller systems put everything in the PLC, which is what the code in this article does.

Recipe model: from company master to the running batch

Recipe TypeScopeExample
GeneralCompany-wide master"Vanilla Extract v2.1"
SiteAdapted for one factoryAdjusted for local water quality
MasterFor specific equipmentMaps to Reactor-01 capabilities
ControlActive batch instance"Batch #20240315-001" running now
The master recipe is the one mapped to your equipment's capabilities; the control recipe is the instance of it running as a batch right now.

A recipe as the process engineer writes it

Before any data structures, here is a small recipe for a cleaning solution. It is a list of phases, and each phase is a type plus a few parameters:

Recipe: "Cleaning Solution Batch v1.2"
Product Code: CLN-001
Batch Size: 500 L

Phase 1: CHARGE_WATER
  - Type: Charge
  - Material: DI_WATER
  - Amount: 400 L

Phase 2: HEAT_WATER
  - Type: Heat
  - Target: 60°C
  - Hold: 0 min (just reach temp)

Phase 3: ADD_CHEMICAL_A
  - Type: Charge
  - Material: CHEM_A
  - Amount: 50 L

Phase 4: MIX
  - Type: Agitate
  - Speed: 200 RPM
  - Duration: 15 min

Phase 5: ADD_CHEMICAL_B
  - Type: Charge
  - Material: CHEM_B
  - Amount: 50 L

Phase 6: REACT
  - Type: Heat
  - Target: 75°C
  - Hold: 30 min

Phase 7: COOL_AND_TRANSFER
  - Type: Heat (cooling)
  - Target: 25°C
  - Hold: 0 min

Notice that the same phase types come back with different parameters: three charges, and three heats (one of them cooling). That is why each phase type becomes one function block, and the recipe only supplies numbers.

Recipe data structures in Structured Text

A recipe is a set of parameters (setpoints, times, quantities) and a procedure (the sequence of steps). In the PLC you hold the parameters in structured types, one header per recipe and one record per step:

TYPE RecipeHeader :
STRUCT
    RecipeID       : INT;
    RecipeName     : STRING[32];
    Version        : INT;
    NumSteps       : INT;
    TotalWeight    : REAL;      // kg
    BatchSize      : REAL;      // liters
END_STRUCT;
END_TYPE

TYPE RecipeStep :
STRUCT
    StepNumber     : INT;
    PhaseType      : INT;       // 1=Charge, 2=Heat, 3=Mix, 4=React, 5=Cool, 6=Drain
    MaterialID     : INT;
    TargetWeight   : REAL;      // kg
    TargetTemp     : REAL;      // °C
    HoldTime       : TIME;
    AgitatorSpeed  : REAL;      // RPM or %
    TransitionType : INT;       // 0=Auto, 1=Operator confirm
END_STRUCT;
END_TYPE

PhaseType tells the sequencer which phase function block to run for the step. TransitionType decides whether the sequencer moves on by itself or waits for the operator to confirm.

Storing recipes in the PLC

PROGRAM RecipeManager
VAR
    Recipes        : ARRAY[1..20] OF RecipeHeader;
    RecipeSteps    : ARRAY[1..20, 1..15] OF RecipeStep;  // 20 recipes × 15 steps max

    ActiveRecipe   : INT := 0;
    ActiveStep     : INT := 0;
    BatchCount     : DINT := 0;

    // Currently loaded recipe
    CurrentHeader  : RecipeHeader;
    CurrentSteps   : ARRAY[1..15] OF RecipeStep;
END_VAR

Storing recipes directly in the PLC works for systems with up to about 50 recipes. Larger installations usually keep them in a SCADA or MES database and download the active recipe to the PLC at batch start. Either way, recipe parameters never belong in the logic itself: keep them in tables of structs or download them from the MES or batch server, so operators can create a new product without an engineer.

When the operator selects a recipe, check the recipe ID is in range (1 to 20 here), copy the header and its steps into CurrentHeader and CurrentSteps, and treat the recipe as valid only if it has at least one step and a batch size above zero.

The ISA-88 phase state model

Every ISA-88 phase follows the same standard state model. That shared model is what makes S88 systems interoperable and maintainable, so implement it in full rather than inventing your own states:

         ┌──────────┐
   ┌────►│  IDLE    │◄─── Reset
   │     └────┬─────┘
   │          │ Start
   │     ┌────▼─────┐
   │     │ RUNNING  │◄─── Restart
   │     └──┬───┬───┘
   │        │   │ Hold
   │  Done  │   ▼
   │     ┌──┘ ┌──────────┐
   │     │    │ HOLDING  │
   │     │    └────┬─────┘
   │     │         │ Held
   │     │    ┌────▼─────┐
   │     │    │  HELD    │───► Restart (back to RUNNING)
   │     │    └──────────┘
   │     │
   │     ▼
   │  ┌──────────┐      ┌──────────┐
   │  │COMPLETING│─────►│ COMPLETE │
   │  └──────────┘      └────┬─────┘
   │                         │ Reset
   └─────────────────────────┘
   
   Any state ──► STOPPING ──► STOPPED
   Any state ──► ABORTING ──► ABORTED

HOLDING, STOPPING and ABORTING are the states in which the phase carries out its hold, stop or abort actions: ramping down to a safe state on hold, closing valves and stopping the agitator on stop, and dumping, venting or de-energising on abort. The function block below folds those actions into the transition and goes straight to HELD, STOPPED or ABORTED. If your hold or stop actions take more than one scan, give them their own states as in the diagram.

           ┌──────────┐
     ┌────►│  IDLE    │◄────────────────┐
     │     └────┬─────┘                 │
     │     Start│                       │Reset
     │     ┌────▼─────┐                 │
     │     │ RUNNING  ├───Complete──►┌──┴──────┐
     │     └──┬───┬───┘              │COMPLETE │
     │  Hold  │   │Stop              └─────────┘
     │  ┌─────▼┐  │
     │  │HELD  │  │
     │  └──┬───┘  │
     │Restart│    │
     │  ┌───▼──┐  │
     │  │RUNNING│  │
     │  └──────┘  │
     │            │
     │     ┌──────▼──┐
     └─────┤STOPPED  │
           └─────────┘

The phase sets PhaseComplete when its own logic is done. The state machine owns everything else, including the hold and restart path. Always implement hold and restart: operators need to pause a batch for sampling, maintenance or an alarm, and a defined hold → restart path lets them do that without aborting the batch.

Phase logic: charge, heat and mix

Each phase type (Charge, Heat, Mix, Transfer) is one reusable function block. The sequencer only passes it the parameters from the current recipe step.

Charge: fast fill, then dribble

FUNCTION_BLOCK FB_PhaseStateMachine
VAR_INPUT
    CMD_Start     : BOOL;
    CMD_Hold      : BOOL;
    CMD_Restart   : BOOL;
    CMD_Stop      : BOOL;
    CMD_Abort     : BOOL;
    CMD_Reset     : BOOL;
    PhaseComplete : BOOL;   // Set by phase logic when done
END_VAR
VAR_OUTPUT
    State         : INT;
    // State constants:
    // 0=IDLE, 1=RUNNING, 2=COMPLETE, 3=HELD
    // 4=STOPPED, 5=ABORTED
    IsRunning     : BOOL;
    IsHeld        : BOOL;
    IsDone        : BOOL;
END_VAR

CASE State OF
    0: // IDLE
        IsRunning := FALSE;
        IsHeld := FALSE;
        IsDone := FALSE;
        IF CMD_Start THEN
            State := 1;
        END_IF;

    1: // RUNNING
        IsRunning := TRUE;
        IF CMD_Stop THEN
            State := 4;
        ELSIF CMD_Abort THEN
            State := 5;
        ELSIF CMD_Hold THEN
            State := 3;
        ELSIF PhaseComplete THEN
            State := 2;
        END_IF;

    2: // COMPLETE
        IsRunning := FALSE;
        IsDone := TRUE;
        IF CMD_Reset THEN
            State := 0;
        END_IF;

    3: // HELD
        IsRunning := FALSE;
        IsHeld := TRUE;
        IF CMD_Restart THEN
            State := 1;
            IsHeld := FALSE;
        ELSIF CMD_Stop THEN
            State := 4;
        ELSIF CMD_Abort THEN
            State := 5;
        END_IF;

    4: // STOPPED
        IsRunning := FALSE;
        IF CMD_Reset THEN
            State := 0;
        END_IF;

    5: // ABORTED
        IsRunning := FALSE;
        IF CMD_Reset THEN
            State := 0;
        END_IF;
END_CASE;

The fast-fill / dribble pattern is standard in gravimetric dosing. At 90 % of the target weight the main inlet closes and the dribble valve, on a smaller pipe, takes over. The slow feed gives you the accuracy at the end of the charge and compensates for material still in flight when the main valve closes. An out-of-tolerance charge still completes here, so log the deviation for the operator rather than letting it pass silently.

Heat: ramp to setpoint, then hold

FUNCTION_BLOCK FB_Phase_Charge
VAR_INPUT
    Enable        : BOOL;
    SP_Weight     : REAL;       // Target weight in kg
    SP_MaterialID : INT;
    PV_Weight     : REAL;       // Actual weight from load cell
    Tolerance     : REAL := 0.5; // Acceptable deviation kg
END_VAR
VAR_OUTPUT
    InletValve    : BOOL;
    DribbleValve  : BOOL;       // Slow-feed valve for accuracy
    Complete      : BOOL;
    ActualWeight  : REAL;
END_VAR
VAR
    State         : INT := 0;
    PreactWeight  : REAL;       // Weight at which to switch to dribble
    StartWeight   : REAL;
    DribblePct    : REAL := 0.90;  // Switch to dribble at 90% of target
END_VAR

IF NOT Enable THEN
    InletValve := FALSE;
    DribbleValve := FALSE;
    State := 0;
    Complete := FALSE;
    RETURN;
END_IF;

CASE State OF
    0: // INIT
        StartWeight := PV_Weight;
        PreactWeight := StartWeight + (SP_Weight * DribblePct);
        Complete := FALSE;
        InletValve := TRUE;
        DribbleValve := FALSE;
        State := 1;

    1: // FAST FILL
        InletValve := TRUE;
        IF PV_Weight >= PreactWeight THEN
            InletValve := FALSE;
            DribbleValve := TRUE;
            State := 2;
        END_IF;

    2: // DRIBBLE FILL
        DribbleValve := TRUE;
        ActualWeight := PV_Weight - StartWeight;
        IF ActualWeight >= (SP_Weight - Tolerance) THEN
            DribbleValve := FALSE;
            State := 3;
        END_IF;

    3: // SETTLE & VERIFY
        ActualWeight := PV_Weight - StartWeight;
        IF ABS(ActualWeight - SP_Weight) <= Tolerance THEN
            Complete := TRUE;
        ELSE
            // Out of tolerance — flag for operator
            Complete := TRUE;  // Still complete, but log deviation
        END_IF;
END_CASE;

The ramp moves the setpoint towards SP_Temp at SP_RampRate °C per minute, and the hold timer only starts once the temperature is within the deadband of the target. The ramp step assumes a 10 ms scan (dt := 0.01), so match dt to your task period. The on/off heating and cooling is a placeholder: in production, feed TempSetpoint to a PID.

Mix: agitate at speed for a time

FUNCTION_BLOCK FB_Phase_Agitate
VAR_INPUT
    Execute       : BOOL;
    TargetSpeed   : REAL;     // RPM
    Duration      : TIME;     // How long to agitate
END_VAR
VAR_OUTPUT
    Done          : BOOL;
    MotorCmd      : BOOL;
    SpeedSetpoint : REAL;     // To VFD
    Elapsed       : TIME;
END_VAR
VAR
    RunTimer      : TON;
END_VAR

IF Execute AND NOT Done THEN
    MotorCmd := TRUE;
    SpeedSetpoint := TargetSpeed;
    
    RunTimer(IN := TRUE, PT := Duration);
    Elapsed := RunTimer.ET;
    
    IF RunTimer.Q THEN
        MotorCmd := FALSE;
        SpeedSetpoint := 0.0;
        Done := TRUE;
    END_IF;
ELSIF NOT Execute THEN
    Done := FALSE;
    MotorCmd := FALSE;
    SpeedSetpoint := 0.0;
    RunTimer(IN := FALSE, PT := T#0s);
END_IF;
END_FUNCTION_BLOCK

This one runs the agitator at the recipe speed for the recipe duration and drops the speed setpoint to zero when the timer is done. It uses an Execute/Done handshake where the charge and heat phases use Enable/Complete; pick one convention for all your phase blocks. The sequencer below dispatches charge and heat only, so a mix step (PhaseType 3) needs its own branch that calls a block like this one.

The unit procedure sequencer

The sequencer walks through the recipe steps and runs the right phase for each one:

FUNCTION_BLOCK FB_Phase_Heat
VAR_INPUT
    Enable       : BOOL;
    SP_Temp      : REAL;        // Target temperature °C
    SP_HoldTime  : TIME;        // Hold duration at setpoint
    SP_RampRate  : REAL;        // °C per minute
    PV_Temp      : REAL;        // Actual temperature
    Deadband     : REAL := 1.0; // °C
END_VAR
VAR_OUTPUT
    HeatingOn    : BOOL;
    CoolingOn    : BOOL;
    TempSetpoint : REAL;        // Ramped setpoint for PID
    Complete     : BOOL;
END_VAR
VAR
    State        : INT := 0;
    RampTarget   : REAL;
    StartTemp    : REAL;
    tmrHold      : TON;
    dt           : REAL := 0.01; // 10ms scan
END_VAR

IF NOT Enable THEN
    HeatingOn := FALSE;
    CoolingOn := FALSE;
    State := 0;
    Complete := FALSE;
    tmrHold(IN := FALSE);
    RETURN;
END_IF;

CASE State OF
    0: // INIT
        StartTemp := PV_Temp;
        RampTarget := StartTemp;
        Complete := FALSE;
        State := 1;

    1: // RAMPING
        // Increment ramp target
        IF RampTarget < SP_Temp THEN
            RampTarget := RampTarget + (SP_RampRate * dt / 60.0);
            IF RampTarget > SP_Temp THEN RampTarget := SP_Temp; END_IF;
        END_IF;

        TempSetpoint := RampTarget;

        // Simple on/off control (replace with PID output in production)
        HeatingOn := PV_Temp < (TempSetpoint - Deadband);
        CoolingOn := PV_Temp > (TempSetpoint + Deadband);

        // Check if we've reached setpoint
        IF RampTarget >= SP_Temp AND ABS(PV_Temp - SP_Temp) <= Deadband THEN
            State := 2;
        END_IF;

    2: // HOLDING
        TempSetpoint := SP_Temp;
        HeatingOn := PV_Temp < (SP_Temp - Deadband);
        CoolingOn := PV_Temp > (SP_Temp + Deadband);

        tmrHold(IN := TRUE, PT := SP_HoldTime);
        IF tmrHold.Q THEN
            tmrHold(IN := FALSE);
            Complete := TRUE;
            State := 3;
        END_IF;

    3: // DONE
        HeatingOn := FALSE;
        Complete := TRUE;
END_CASE;

When a phase reports complete, a step with TransitionType = 0 advances on its own. Any other value waits for CMD_NextStep from the operator. On COMPLETE or STOPPED the sequencer switches every output off.

Scaling a recipe for batch size

Production often runs the same recipe at different batch sizes, so the weights have to scale with it:

PROGRAM BatchSequencer
VAR
    // Recipe
    Header         : RecipeHeader;
    Steps          : ARRAY[1..15] OF RecipeStep;
    CurrentStep    : INT := 0;
    BatchState     : INT := 0;  // 0=Idle, 1=Running, 2=Complete, 3=Stopped

    // Commands
    CMD_StartBatch : BOOL;
    CMD_HoldBatch  : BOOL;
    CMD_StopBatch  : BOOL;
    CMD_NextStep   : BOOL;      // Auto or operator-confirmed

    // Phase instances
    phCharge       : FB_Phase_Charge;
    phHeat         : FB_Phase_Heat;
    phaseSM        : FB_PhaseStateMachine;

    // Equipment I/O
    PV_Weight      : REAL;
    PV_Temperature : REAL;
    InletValve     : BOOL;
    DribbleValve   : BOOL;
    HeatingOn      : BOOL;
    CoolingOn      : BOOL;

    StepPhaseType  : INT;
    StepComplete   : BOOL;
END_VAR

CASE BatchState OF
    0: // IDLE — waiting for batch start
        IF CMD_StartBatch AND Header.NumSteps > 0 THEN
            CurrentStep := 1;
            BatchState := 1;
            BatchCount := BatchCount + 1;
        END_IF;

    1: // RUNNING
        IF CMD_StopBatch THEN
            BatchState := 3;
            RETURN;
        END_IF;

        IF CurrentStep > Header.NumSteps THEN
            BatchState := 2;  // All steps complete
            RETURN;
        END_IF;

        StepPhaseType := Steps[CurrentStep].PhaseType;
        StepComplete := FALSE;

        // Execute current phase based on type
        CASE StepPhaseType OF
            1: // CHARGE
                phCharge(
                    Enable := TRUE,
                    SP_Weight := Steps[CurrentStep].TargetWeight,
                    SP_MaterialID := Steps[CurrentStep].MaterialID,
                    PV_Weight := PV_Weight,
                    Tolerance := 0.5
                );
                InletValve := phCharge.InletValve;
                DribbleValve := phCharge.DribbleValve;
                StepComplete := phCharge.Complete;

            2: // HEAT
                phHeat(
                    Enable := TRUE,
                    SP_Temp := Steps[CurrentStep].TargetTemp,
                    SP_HoldTime := Steps[CurrentStep].HoldTime,
                    SP_RampRate := 2.0,
                    PV_Temp := PV_Temperature
                );
                HeatingOn := phHeat.HeatingOn;
                CoolingOn := phHeat.CoolingOn;
                StepComplete := phHeat.Complete;
        END_CASE;

        // Step transition
        IF StepComplete THEN
            IF Steps[CurrentStep].TransitionType = 0 THEN
                // Auto-advance
                CurrentStep := CurrentStep + 1;
            ELSE
                // Wait for operator confirmation
                IF CMD_NextStep THEN
                    CurrentStep := CurrentStep + 1;
                    CMD_NextStep := FALSE;
                END_IF;
            END_IF;
        END_IF;

    2: // COMPLETE
        InletValve := FALSE;
        DribbleValve := FALSE;
        HeatingOn := FALSE;
        CoolingOn := FALSE;

    3: // STOPPED
        InletValve := FALSE;
        DribbleValve := FALSE;
        HeatingOn := FALSE;
        CoolingOn := FALSE;
END_CASE;

What scales and what does not

ParameterScales?Why
Material WeightYesDirect proportion to batch size
TemperatureNoDetermined by chemistry, not volume
Hold TimeSometimesReaction kinetics may vary with mass
Agitator SpeedSometimesDepends on vessel geometry and scale-up rules
Ramp RateSometimesHeat transfer changes with volume
Treat this as an engineering decision. Wrong scale-up assumptions have caused real batch failures, so have process engineering validate every scaled recipe before it runs.

Batch records and traceability

An ISA-88 system keeps a batch record: a timestamped log of every parameter, transition, setpoint, actual value, alarm and operator action during the batch. In the PLC you capture the events:

FUNCTION FC_ScaleRecipe : BOOL
VAR_INPUT
    MasterSteps    : ARRAY[1..15] OF RecipeStep;   // Original recipe
    MasterBatchSize: REAL;                           // Original batch size
    TargetBatchSize: REAL;                           // Desired batch size
    NumSteps       : INT;
END_VAR
VAR_IN_OUT
    ScaledSteps    : ARRAY[1..15] OF RecipeStep;
END_VAR
VAR
    ScaleFactor    : REAL;
    i              : INT;
END_VAR

IF MasterBatchSize <= 0.0 THEN
    FC_ScaleRecipe := FALSE;
    RETURN;
END_IF;

ScaleFactor := TargetBatchSize / MasterBatchSize;

FOR i := 1 TO NumSteps DO
    ScaledSteps[i] := MasterSteps[i];

    // Scale weight-based parameters
    ScaledSteps[i].TargetWeight := MasterSteps[i].TargetWeight * ScaleFactor;

    // Temperature is NOT scaled — it's process-dependent
    // Hold time may scale for reaction kinetics (application-specific)
    // Agitator speed may need adjustment for vessel geometry
END_FOR;

FC_ScaleRecipe := TRUE;

In regulated industries (pharma, food, chemicals) the batch record is required, it must be tamper-evident, and it is kept for years. The PLC captures the raw data. The MES or SCADA system turns it into the compliant report, and in pharma an electronic batch record must also satisfy FDA 21 CFR Part 11, the rule for electronic records and electronic signatures.

Equipment modules: hiding the hardware

ISA-88 separates equipment capability from recipe logic. An equipment module (EM) wraps up what one piece of equipment can do:

TYPE BatchEvent :
STRUCT
    Timestamp     : STRING[20];   // 'YYYY-MM-DD HH:MM:SS'
    EventType     : INT;          // 1=StepStart, 2=StepEnd, 3=ParamChange, 4=Alarm, 5=Operator
    StepNumber    : INT;
    Description   : STRING[64];
    Value         : REAL;
END_STRUCT;
END_TYPE

PROGRAM BatchLogger
VAR
    EventLog      : ARRAY[1..200] OF BatchEvent;
    EventIndex    : INT := 0;
END_VAR

// Call this function to log an event
// In production, also write to SCADA historian via OPC

The recipe phase only says "start agitator at 60%". The EM deals with the VFD communication, the interlocks, the ramp rates and the torque protection. When the physical equipment changes, you update the EM and leave every recipe alone.

Checklist for an ISA-88 PLC project

  • Keep recipe parameters out of the code: header and step structs in arrays, or a download from the MES or batch server.
  • Write one function block per phase type and let the sequencer pass it the step's parameters.
  • Run every phase through the standard state model, including hold and restart, stop and abort.
  • Charge with fast fill and dribble against a load cell, and log any charge outside tolerance.
  • Heat with a ramped setpoint and start the hold timer only at setpoint.
  • Scale material weights with batch size; get temperature, hold time, agitator speed and ramp rate reviewed by process engineering.
  • Log phase transitions, setpoints, actuals, alarms and operator actions for the batch record.
  • Put each piece of equipment behind an equipment module with a command/status interface.
  • If you have several identical units (Reactor A, B, C), let the batch system allocate a unit by availability instead of hardcoding the equipment into the recipe.
  • Built this way, a new product means a new recipe, and the control logic stays as it is.