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.

Understanding ISA-88 (S88) for PLC Programmers

ISA-88 (also called S88 or IEC 61512) is the international standard for batch process control. It defines a clear hierarchy that separates what you want to make (the recipe) from how equipment makes it (the control logic). This separation is the single most important concept in batch automation — it lets you run hundreds of different products on the same equipment without rewriting PLC code.

The ISA-88 Hierarchy

| Layer | Purpose | Example | | Procedure | Complete batch | "Make 500L of Product A" | | Unit Procedure | Steps within one unit | "Charge → Heat → React → Drain" | | Operation | Major activity | "Heat to 80°C and hold 30 min" | | Phase | Smallest executable | "Open valve, start agitator, ramp temp" |

The PLC programmer's main job is implementing phases — the lowest level. Higher levels are typically orchestrated by a batch server (like Wonderware InBatch or Rockwell FactoryTalk Batch), but smaller systems implement everything in the PLC.

Recipe Data Structure

A recipe is a set of parameters (setpoints, times, quantities) and a procedure (sequence of steps). In the PLC, we store recipe parameters in structured arrays:

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

Recipe Storage Array

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

This approach stores recipes directly in the PLC — practical for systems with up to ~50 recipes. Larger installations typically store recipes in a SCADA/MES database and download the active recipe to the PLC at batch start.

ISA-88 Phase State Machine

Every phase in ISA-88 follows a standard state model. This is non-negotiable — it's what makes S88 systems interoperable and maintainable:

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

Phase State Machine Implementation

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;

Implementing Phases: Real Examples

Phase 1: Charge (Material Addition)

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 fast-fill / dribble pattern is universal in gravimetric dosing. The dribble valve (smaller pipe) gives precision at the end of the charge, compensating for material in-flight when the main valve closes.

Phase 2: Heat with Ramp and Hold

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;

Unit Procedure Sequencer

The sequencer walks through recipe steps, instantiating the correct phase for each step:

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;

Recipe Parameter Scaling for Batch Size

Real production requires running the same recipe at different batch sizes. Scale parameters proportionally:

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;

What Scales and What Doesn't

| Parameter | Scales? | Why | | Material Weight | Yes | Direct proportion to batch size | | Temperature | No | Determined by chemistry, not volume | | Hold Time | Sometimes | Reaction kinetics may vary with mass | | Agitator Speed | Sometimes | Depends on vessel geometry and scale-up rules | | Ramp Rate | Sometimes | Heat transfer changes with volume |

This is a critical engineering decision — incorrect scale-up assumptions have caused real-world batch failures. Always validate scaled recipes with process engineering.

Batch Reporting and Traceability

ISA-88 requires a batch record — a timestamped log of every parameter, transition, and alarm during the batch. In the PLC, capture key events:

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

For regulatory industries (pharma, food, chemicals), this batch record must be tamper-evident and stored for years. The PLC captures the raw data; the MES/SCADA system formats it into compliant reports (FDA 21 CFR Part 11 for pharma).

Equipment Module Abstraction

ISA-88 separates equipment capability from recipe logic. An Equipment Module (EM) encapsulates a piece of equipment's functionality:

FUNCTION_BLOCK FB_EM_AgitatorUnit
VAR_INPUT
    CMD_Start      : BOOL;
    CMD_Stop       : BOOL;
    SP_Speed       : REAL;     // 0–100%
    SP_Direction   : INT;      // 0=CW, 1=CCW
END_VAR
VAR_OUTPUT
    STS_Running    : BOOL;
    STS_AtSpeed    : BOOL;
    STS_Faulted    : BOOL;
    PV_Speed       : REAL;
    PV_Current     : REAL;
END_VAR
VAR
    // Internal: VFD interface, interlocks, diagnostics
    vfd            : FB_VFD_FaultHandler;
    speedRamp      : FB_SpeedRamp;
    torqueMon      : FB_TorqueMonitor;

// Interlocks LidClosed : BOOL; MinLevel : BOOL; // Don't run dry END_VAR

// Interlock check IF NOT LidClosed OR NOT MinLevel THEN CMD_Start := FALSE; STS_Faulted := TRUE; RETURN; END_IF;

// Speed ramping speedRamp( TargetSpeed := SP_Speed, AccelRate := 20.0, DecelRate := 30.0, Enable := CMD_Start );

// Torque monitoring torqueMon( ActualCurrent := PV_Current, RatedCurrent := 12.5, OverloadPct := 110.0, WarningPct := 90.0, OverloadTime := T#10S );

STS_Running := CMD_Start AND NOT STS_Faulted; STS_AtSpeed := speedRamp.AtTarget; STS_Faulted := torqueMon.Trip OR vfd.Lockout;

The beauty of this pattern: the recipe phase just says "start agitator at 60%." The EM handles all the messy details — VFD communication, interlocks, ramp rates, torque protection. Change the physical equipment and you only update the EM, not every recipe.

Summary

| Concept | Implementation | | Recipe Storage | Structured arrays with RecipeHeader + RecipeStep types | | Phase State Machine | Standard ISA-88 states: Idle → Running → Complete / Held / Stopped | | Material Charging | Fast-fill / dribble pattern with gravimetric verification | | Temperature Control | Ramp-to-setpoint with hold timer | | Batch Sequencer | Walks through recipe steps, dispatching to phase FBs | | Recipe Scaling | Proportional weight scaling; temperature and time need engineering review | | Equipment Modules | Encapsulate hardware behind a clean command/status interface | | Batch Records | Event logging for traceability and regulatory compliance |

ISA-88 isn't just a standard — it's a design philosophy. Once you structure your PLC code this way, adding new products means adding a recipe, not rewriting control logic. That's the payoff.