Batch Control
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_TYPETYPE 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_VARCASE 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_VARIF 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_VARIF 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_VARIF 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_TYPEPROGRAM 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.