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.
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
| 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" |
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 Type | Scope | Example |
|---|---|---|
| General | Company-wide master | "Vanilla Extract v2.1" |
| Site | Adapted for one factory | Adjusted for local water quality |
| Master | For specific equipment | Maps to Reactor-01 capabilities |
| Control | Active batch instance | "Batch #20240315-001" running 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
| 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 |
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
Built this way, a new product means a new recipe, and the control logic stays as it is.