ISA-88 Batch Control & Recipe Management in Structured Text: Complete Implementation Guide

Build a complete ISA-88 batch control system — from recipe parameter tables and procedural state machines to unit allocation and phase logic in Structured Text.

What Is ISA-88 (S88)?

ISA-88 (also known as S88 or IEC 61512) is the international standard for batch process control. It provides a consistent framework for designing, programming, and operating batch manufacturing systems — from pharmaceutical reactors to food processing lines.

Why ISA-88 Matters for PLC Programmers

  • Separation of concerns: Recipe (what to make) is independent of equipment (how to make it)
  • Flexibility: Change products without changing PLC code
  • Reusability: Write equipment logic once, use it for any recipe
  • Compliance: Required in FDA-regulated industries (21 CFR Part 11)
  • Scalability: Same architecture works for 1 reactor or 100
  • The ISA-88 Models

    ISA-88 defines three key models that work together:

    1. Physical Model (Equipment Hierarchy)

    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:

  • Unit = A reactor, mixer, or tank that can execute a complete batch phase
  • Equipment Module = A functional group (e.g., agitator assembly, heating jacket)
  • Control Module = Individual actuator/sensor (e.g., valve, temperature sensor, motor)
  • 2. Procedural Model (What Happens)

    Procedure (= complete batch recipe)
      └── Unit Procedure (= what happens in one unit)
            └── Operation (= a major step: Charge, React, Discharge)
                  └── Phase (= smallest executable action)
                        Examples: Open_Valve, Heat_To_Temp, 
                                  Agitate, Transfer_Material
    

    3. Recipe Model (Product Definition)

    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

    Recipe Data Structure in Structured Text

    Defining Recipe Parameters

    TYPE RecipeHeader :
    STRUCT
        RecipeID      : INT;
        RecipeName    : STRING(40);
        Version       : STRING(10);
        ProductCode   : STRING(20);
        TotalPhases   : INT;
        BatchSize     : REAL;         // kg or liters
        BatchSizeUnit : STRING(10);   // 'kg', 'L', 'gal'
    END_STRUCT;
    END_TYPE

    TYPE PhaseParameter : STRUCT PhaseID : INT; // Sequential phase number PhaseName : STRING(30); // 'CHARGE_WATER', 'HEAT', 'MIX' PhaseType : INT; // 1=Charge, 2=Heat, 3=Mix, 4=React, 5=Transfer // Setpoints TargetTemp : REAL; // °C TargetPressure: REAL; // bar TargetVolume : REAL; // Liters TargetSpeed : REAL; // RPM (agitator) HoldTime : TIME; // Duration at setpoint // Material MaterialCode : STRING(20); MaterialAmount: REAL; MaterialUnit : STRING(10); // 'kg', 'L' // Limits TempHiLimit : REAL; TempLoLimit : REAL; PressHiLimit : REAL; END_STRUCT; END_TYPE

    Recipe Storage Table

    PROGRAM RecipeManager
    VAR
        // Recipe database — holds up to 20 recipes, each with up to 15 phases
        RecipeHeaders   : ARRAY[1..20] OF RecipeHeader;
        RecipePhases    : ARRAY[1..20, 1..15] OF PhaseParameter;
        
        // Active recipe
        ActiveRecipeID  : INT := 0;
        ActiveHeader    : RecipeHeader;
        ActivePhases    : ARRAY[1..15] OF PhaseParameter;
        
        // Batch tracking
        BatchNumber     : STRING(20);
        BatchStartTime  : DATE_AND_TIME;
        CurrentPhaseIdx : INT := 0;
        TotalPhases     : INT := 0;
        
        RecipeLoaded    : BOOL := FALSE;
        RecipeValid     : BOOL := FALSE;
    END_VAR

    // ── Load Recipe by ID ── IF LoadRecipeCmd AND ActiveRecipeID >= 1 AND ActiveRecipeID <= 20 THEN ActiveHeader := RecipeHeaders[ActiveRecipeID]; TotalPhases := ActiveHeader.TotalPhases; FOR i := 1 TO TotalPhases DO ActivePhases[i] := RecipePhases[ActiveRecipeID, i]; END_FOR; RecipeLoaded := TRUE; RecipeValid := (TotalPhases > 0) AND (ActiveHeader.BatchSize > 0.0); CurrentPhaseIdx := 0; END_IF; END_PROGRAM

    The ISA-88 State Machine

    Every phase in ISA-88 follows a standard state model. This is the heart of batch control:

             ┌──────────┐
       ┌────►│  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
    

    Implementing the State Machine in Structured Text

    TYPE BatchPhaseState : (
        PHASE_IDLE,
        PHASE_RUNNING,
        PHASE_COMPLETE,
        PHASE_HOLDING,
        PHASE_HELD,
        PHASE_STOPPING,
        PHASE_STOPPED,
        PHASE_ABORTING,
        PHASE_ABORTED
    );
    END_TYPE

    FUNCTION_BLOCK FB_BatchPhase VAR_INPUT CMD_Start : BOOL; CMD_Hold : BOOL; CMD_Restart : BOOL; CMD_Stop : BOOL; CMD_Abort : BOOL; CMD_Reset : BOOL; PhaseComplete: BOOL; // Set TRUE when phase logic finishes END_VAR VAR_OUTPUT State : BatchPhaseState; StateCode : INT; // Numeric state for HMI display IsActive : BOOL; // TRUE when RUNNING IsDone : BOOL; // TRUE when COMPLETE InHold : BOOL; // TRUE when HELD END_VAR VAR PrevState : BatchPhaseState; END_VAR

    PrevState := State;

    CASE State OF PHASE_IDLE: IsActive := FALSE; IsDone := FALSE; InHold := FALSE; IF CMD_Start THEN State := PHASE_RUNNING; END_IF; PHASE_RUNNING: IsActive := TRUE; IF CMD_Abort THEN State := PHASE_ABORTING; ELSIF CMD_Stop THEN State := PHASE_STOPPING; ELSIF CMD_Hold THEN State := PHASE_HOLDING; ELSIF PhaseComplete THEN State := PHASE_COMPLETE; END_IF; PHASE_HOLDING: IsActive := FALSE; // Perform hold actions (ramp down, safe state) State := PHASE_HELD; // Transition immediately or after hold logic PHASE_HELD: InHold := TRUE; IF CMD_Abort THEN State := PHASE_ABORTING; ELSIF CMD_Stop THEN State := PHASE_STOPPING; ELSIF CMD_Restart THEN InHold := FALSE; State := PHASE_RUNNING; END_IF; PHASE_COMPLETE: IsActive := FALSE; IsDone := TRUE; IF CMD_Reset THEN IsDone := FALSE; State := PHASE_IDLE; END_IF; PHASE_STOPPING: IsActive := FALSE; // Perform stop actions (close valves, stop agitator) State := PHASE_STOPPED; PHASE_STOPPED: IF CMD_Reset THEN State := PHASE_IDLE; END_IF; PHASE_ABORTING: IsActive := FALSE; // Emergency actions (dump, vent, de-energize) State := PHASE_ABORTED; PHASE_ABORTED: IF CMD_Reset THEN State := PHASE_IDLE; END_IF; END_CASE;

    // Numeric state code for HMI CASE State OF PHASE_IDLE: StateCode := 0; PHASE_RUNNING: StateCode := 1; PHASE_COMPLETE: StateCode := 2; PHASE_HOLDING: StateCode := 3; PHASE_HELD: StateCode := 4; PHASE_STOPPING: StateCode := 5; PHASE_STOPPED: StateCode := 6; PHASE_ABORTING: StateCode := 7; PHASE_ABORTED: StateCode := 8; END_CASE; END_FUNCTION_BLOCK

    Phase Logic Examples

    Phase: Charge Material

    FUNCTION_BLOCK FB_Phase_Charge
    VAR_INPUT
        Execute       : BOOL;
        TargetVolume  : REAL;     // Liters to charge
        MaterialValve : BOOL;    // Valve feedback (open confirmed)
    END_VAR
    VAR_OUTPUT
        Done          : BOOL;
        ValveCmd      : BOOL;    // Open inlet valve
        ActualVolume  : REAL;
    END_VAR
    VAR
        FlowTotalizer : REAL := 0.0;
        Charging      : BOOL := FALSE;
    END_VAR

    IF Execute AND NOT Done THEN IF FlowTotalizer < TargetVolume THEN ValveCmd := TRUE; Charging := TRUE; // In real PLC, FlowTotalizer comes from a flow meter // Simulated here: IF MaterialValve THEN FlowTotalizer := FlowTotalizer + 0.5; // 0.5 L per scan END_IF; ELSE ValveCmd := FALSE; Charging := FALSE; Done := TRUE; END_IF; ELSIF NOT Execute THEN Done := FALSE; FlowTotalizer := 0.0; ValveCmd := FALSE; Charging := FALSE; END_IF; END_FUNCTION_BLOCK

    Phase: Heat to Temperature

    FUNCTION_BLOCK FB_Phase_HeatToTemp
    VAR_INPUT
        Execute       : BOOL;
        TargetTemp    : REAL;     // °C
        ActualTemp    : REAL;     // From sensor
        Tolerance     : REAL;     // ±°C for "at setpoint"
        HoldTime      : TIME;     // Time to hold at setpoint
    END_VAR
    VAR_OUTPUT
        Done          : BOOL;
        HeatingOn     : BOOL;
        AtSetpoint    : BOOL;
        RemainingHold : TIME;
    END_VAR
    VAR
        HoldTimer     : TON;
        ReachedTemp   : BOOL := FALSE;
    END_VAR

    IF Execute AND NOT Done THEN // Check if temperature is within tolerance AtSetpoint := ABS(ActualTemp - TargetTemp) <= Tolerance; // Control heating element IF ActualTemp < (TargetTemp - Tolerance) THEN HeatingOn := TRUE; ELSIF ActualTemp > (TargetTemp + Tolerance) THEN HeatingOn := FALSE; // Overshoot — turn off END_IF; // Hold timer — counts while at setpoint HoldTimer(IN := AtSetpoint, PT := HoldTime); RemainingHold := HoldTime - HoldTimer.ET; IF HoldTimer.Q THEN HeatingOn := FALSE; Done := TRUE; END_IF; ELSIF NOT Execute THEN Done := FALSE; HeatingOn := FALSE; AtSetpoint := FALSE; HoldTimer(IN := FALSE, PT := T#0s); END_IF; END_FUNCTION_BLOCK

    Phase: Agitate / Mix

    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

    Batch Sequencer — Orchestrating Phases

    The sequencer walks through recipe phases in order, advancing when each phase completes:

    PROGRAM BatchSequencer
    VAR
        // Recipe data
        ActivePhases    : ARRAY[1..15] OF PhaseParameter;
        TotalPhases     : INT := 5;
        CurrentPhase    : INT := 0;
        
        // Phase instances
        PhaseState      : FB_BatchPhase;
        ChargePhase     : FB_Phase_Charge;
        HeatPhase       : FB_Phase_HeatToTemp;
        AgitatePhase    : FB_Phase_Agitate;
        
        // Commands
        BatchStart      : BOOL := FALSE;
        BatchHold       : BOOL := FALSE;
        BatchStop       : BOOL := FALSE;
        BatchAbort      : BOOL := FALSE;
        
        // Status
        BatchRunning    : BOOL := FALSE;
        BatchComplete   : BOOL := FALSE;
        PhaseComplete   : BOOL := FALSE;
        
        // Process feedback
        ActualTemp      : REAL;
        ValveFeedback   : BOOL;
    END_VAR

    // ── State machine for current phase ── PhaseState( CMD_Start := BatchStart AND (CurrentPhase > 0), CMD_Hold := BatchHold, CMD_Restart := NOT BatchHold, CMD_Stop := BatchStop, CMD_Abort := BatchAbort, CMD_Reset := FALSE, PhaseComplete := PhaseComplete );

    // ── Start batch ── IF BatchStart AND NOT BatchRunning AND CurrentPhase = 0 THEN CurrentPhase := 1; BatchRunning := TRUE; BatchComplete := FALSE; PhaseComplete := FALSE; END_IF;

    // ── Execute current phase based on type ── IF BatchRunning AND PhaseState.IsActive THEN CASE ActivePhases[CurrentPhase].PhaseType OF 1: // CHARGE ChargePhase( Execute := TRUE, TargetVolume := ActivePhases[CurrentPhase].TargetVolume, MaterialValve := ValveFeedback ); PhaseComplete := ChargePhase.Done; 2: // HEAT HeatPhase( Execute := TRUE, TargetTemp := ActivePhases[CurrentPhase].TargetTemp, ActualTemp := ActualTemp, Tolerance := 2.0, HoldTime := ActivePhases[CurrentPhase].HoldTime ); PhaseComplete := HeatPhase.Done; 3: // MIX / AGITATE AgitatePhase( Execute := TRUE, TargetSpeed := ActivePhases[CurrentPhase].TargetSpeed, Duration := ActivePhases[CurrentPhase].HoldTime ); PhaseComplete := AgitatePhase.Done; END_CASE; END_IF;

    // ── Advance to next phase ── IF PhaseComplete AND PhaseState.IsDone THEN IF CurrentPhase < TotalPhases THEN CurrentPhase := CurrentPhase + 1; PhaseComplete := FALSE; // Reset phase FBs ChargePhase(Execute := FALSE, TargetVolume := 0.0, MaterialValve := FALSE); HeatPhase(Execute := FALSE, TargetTemp := 0.0, ActualTemp := 0.0, Tolerance := 0.0, HoldTime := T#0s); AgitatePhase(Execute := FALSE, TargetSpeed := 0.0, Duration := T#0s); ELSE BatchComplete := TRUE; BatchRunning := FALSE; CurrentPhase := 0; END_IF; END_IF; END_PROGRAM

    Example Recipe: Simple Chemical Mixing

    Here's how a real recipe maps to the data structures:

    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

    Loading This Recipe in ST

    // Recipe #1: Cleaning Solution
    RecipeHeaders[1].RecipeID := 1;
    RecipeHeaders[1].RecipeName := 'Cleaning Solution v1.2';
    RecipeHeaders[1].ProductCode := 'CLN-001';
    RecipeHeaders[1].TotalPhases := 7;
    RecipeHeaders[1].BatchSize := 500.0;
    RecipeHeaders[1].BatchSizeUnit := 'L';

    // Phase 1: Charge Water RecipePhases[1,1].PhaseID := 1; RecipePhases[1,1].PhaseName := 'CHARGE_WATER'; RecipePhases[1,1].PhaseType := 1; RecipePhases[1,1].MaterialCode := 'DI_WATER'; RecipePhases[1,1].MaterialAmount := 400.0; RecipePhases[1,1].MaterialUnit := 'L';

    // Phase 2: Heat Water RecipePhases[1,2].PhaseID := 2; RecipePhases[1,2].PhaseName := 'HEAT_WATER'; RecipePhases[1,2].PhaseType := 2; RecipePhases[1,2].TargetTemp := 60.0; RecipePhases[1,2].HoldTime := T#0s; RecipePhases[1,2].TempHiLimit := 65.0;

    // Phase 3: Add Chemical A RecipePhases[1,3].PhaseID := 3; RecipePhases[1,3].PhaseName := 'ADD_CHEMICAL_A'; RecipePhases[1,3].PhaseType := 1; RecipePhases[1,3].MaterialCode := 'CHEM_A'; RecipePhases[1,3].MaterialAmount := 50.0; RecipePhases[1,3].MaterialUnit := 'L';

    // Phase 4: Mix RecipePhases[1,4].PhaseID := 4; RecipePhases[1,4].PhaseName := 'MIX'; RecipePhases[1,4].PhaseType := 3; RecipePhases[1,4].TargetSpeed := 200.0; RecipePhases[1,4].HoldTime := T#15m;

    // Phase 5: Add Chemical B RecipePhases[1,5].PhaseID := 5; RecipePhases[1,5].PhaseName := 'ADD_CHEMICAL_B'; RecipePhases[1,5].PhaseType := 1; RecipePhases[1,5].MaterialCode := 'CHEM_B'; RecipePhases[1,5].MaterialAmount := 50.0; RecipePhases[1,5].MaterialUnit := 'L';

    // Phase 6: React RecipePhases[1,6].PhaseID := 6; RecipePhases[1,6].PhaseName := 'REACT'; RecipePhases[1,6].PhaseType := 2; RecipePhases[1,6].TargetTemp := 75.0; RecipePhases[1,6].HoldTime := T#30m; RecipePhases[1,6].TempHiLimit := 80.0;

    // Phase 7: Cool and Transfer RecipePhases[1,7].PhaseID := 7; RecipePhases[1,7].PhaseName := 'COOL_TRANSFER'; RecipePhases[1,7].PhaseType := 2; RecipePhases[1,7].TargetTemp := 25.0; RecipePhases[1,7].HoldTime := T#0s;

    Best Practices for ISA-88 in PLCs

    1. Keep Recipes Out of PLC Code

    Never hardcode recipe parameters. Use recipe tables (arrays of structs) or download from an MES/batch server. This lets operators create new products without an engineer.

    2. One Phase = One Function Block

    Each phase type (Charge, Heat, Mix, Transfer) should be a reusable FB. The sequencer just passes parameters from the recipe to the FB.

    3. Always Implement Hold/Restart

    Operators need to pause batches for sampling, maintenance, or alarms. The ISA-88 state machine guarantees a safe hold → restart path.

    4. Log Everything

    Record phase transitions, setpoints, actuals, and operator actions for batch reports. In regulated industries, this is legally required (21 CFR Part 11).

    5. Unit Allocation

    If you have multiple identical units (Reactor A, B, C), the batch system should dynamically allocate units based on availability — not hardcode equipment.

    Summary

    ISA-88 batch control transforms complex, multi-step manufacturing processes into organized, reusable, and auditable PLC programs. The key insight is separation: recipes define what to make (parameters), while equipment phases define how to make it (control logic). This separation means a single PLC program can manufacture hundreds of different products just by loading different recipe parameters. Combined with the standard state machine (Idle → Running → Complete, with Hold and Abort paths), ISA-88 gives both engineers and operators a predictable, safe framework for batch manufacturing.