Advanced
PLC CIP (Clean-in-Place) Systems for Food & Beverage: Structured Text Programming Guide
Implement a complete CIP clean-in-place system — with multi-step wash sequences, chemical concentration control, temperature/flow validation, and regulatory compliance logging in Structured Text.
What CIP (clean-in-place) is
Clean-in-place (CIP) is the automated cleaning of the interior surfaces of pipes, vessels and equipment without taking them apart. It is standard practice in the food, beverage, dairy, pharmaceutical and brewery industries, where hygiene standards require validated, repeatable cleaning cycles.
What an automated CIP system gives the plant
Its first job is food safety: it prevents bacterial contamination, and cross-contamination between batches. It supports regulatory compliance with FDA food safety rules (21 CFR Part 117), the EHEDG guidelines and the 3-A Sanitary Standards. Automated CIP is much faster than manual cleaning, and every cycle follows exactly the same sequence, time, temperature and concentration. Each wash cycle also leaves a complete audit trail.
The CIP cycle, step by step
A standard CIP cycle runs in this sequence:
Step 1: PRE-RINSE (Water flush — remove loose debris)
│ 5-10 min, ambient temp, drain to waste
▼
Step 2: CAUSTIC WASH (NaOH — dissolve fats/proteins)
│ 15-20 min, 70-80°C, 1.5-2.0% concentration
▼
Step 3: INTERMEDIATE RINSE (Water — flush caustic)
│ 5-10 min, verify pH < 8.5
▼
Step 4: ACID WASH (HNO₃ or H₃PO₄ — remove minerals)
│ 10-15 min, 60-70°C, 0.5-1.0% concentration
▼
Step 5: FINAL RINSE (Water — flush acid)
│ 5-10 min, verify pH 6.5-7.5, conductivity < 100 µS/cm
▼
Step 6: SANITIZE (Hot water or peracetic acid)
│ 10-20 min, 85°C+ or 200 ppm PAA
▼
Step 7: DRAIN/BLOWDOWN (Air blow to drain remaining liquid)
2-5 min
Data structures for steps and recipes
TYPE CIP_StepType : (
CIP_IDLE,
CIP_PRE_RINSE,
CIP_CAUSTIC_WASH,
CIP_INTERMEDIATE_RINSE,
CIP_ACID_WASH,
CIP_FINAL_RINSE,
CIP_SANITIZE,
CIP_DRAIN,
CIP_COMPLETE,
CIP_FAULT
);
END_TYPE
TYPE CIP_StepParams :
STRUCT
StepName : STRING(25);
Duration : TIME; // Minimum step duration
TargetTemp : REAL; // °C setpoint
TempTolerance : REAL; // ±°C
ChemConcentration: REAL; // % target
ConcTolerance : REAL; // ±%
FlowRateMin : REAL; // L/min minimum flow
DrainToWaste : BOOL; // TRUE = drain, FALSE = return to tank
RecordRequired : BOOL; // TRUE = log for FDA audit
END_STRUCT;
END_TYPE
TYPE CIP_ProcessData :
STRUCT
Temperature : REAL; // °C actual
FlowRate : REAL; // L/min
Conductivity : REAL; // µS/cm (chemical detection)
pH : REAL; // pH of return flow
Concentration : REAL; // % calculated from conductivity
Pressure : REAL; // bar
TotalVolume : REAL; // Liters used in current step
END_STRUCT;
END_TYPE
TYPE CIP_BatchRecord :
STRUCT
RecipeID : INT;
CircuitName : STRING(30);
StartTime : DATE_AND_TIME;
EndTime : DATE_AND_TIME;
StepCount : INT;
AllStepsPassed : BOOL;
OperatorID : STRING(20);
StepResults : ARRAY[1..8] OF CIP_StepResult;
END_STRUCT;
END_TYPE
TYPE CIP_StepResult :
STRUCT
StepName : STRING(25);
Duration : TIME;
AvgTemp : REAL;
MinTemp : REAL;
MaxTemp : REAL;
AvgConc : REAL;
AvgFlow : REAL;
Passed : BOOL;
FailReason : STRING(50);
END_STRUCT;
END_TYPE
Configuring a CIP recipe
PROGRAM CIP_RecipeSetup
VAR
// Standard dairy CIP recipe (7 steps)
Recipe : ARRAY[1..7] OF CIP_StepParams;
END_VAR
// Step 1: Pre-Rinse
Recipe[1].StepName := 'PRE_RINSE';
Recipe[1].Duration := T#8m;
Recipe[1].TargetTemp := 40.0;
Recipe[1].TempTolerance := 5.0;
Recipe[1].FlowRateMin := 200.0;
Recipe[1].DrainToWaste := TRUE;
Recipe[1].RecordRequired := TRUE;
// Step 2: Caustic Wash
Recipe[2].StepName := 'CAUSTIC_WASH';
Recipe[2].Duration := T#20m;
Recipe[2].TargetTemp := 75.0;
Recipe[2].TempTolerance := 3.0;
Recipe[2].ChemConcentration := 1.5;
Recipe[2].ConcTolerance := 0.3;
Recipe[2].FlowRateMin := 250.0;
Recipe[2].DrainToWaste := FALSE;
Recipe[2].RecordRequired := TRUE;
// Step 3: Intermediate Rinse
Recipe[3].StepName := 'INTER_RINSE';
Recipe[3].Duration := T#7m;
Recipe[3].TargetTemp := 40.0;
Recipe[3].TempTolerance := 10.0;
Recipe[3].FlowRateMin := 200.0;
Recipe[3].DrainToWaste := TRUE;
Recipe[3].RecordRequired := TRUE;
// Step 4: Acid Wash
Recipe[4].StepName := 'ACID_WASH';
Recipe[4].Duration := T#15m;
Recipe[4].TargetTemp := 65.0;
Recipe[4].TempTolerance := 3.0;
Recipe[4].ChemConcentration := 0.8;
Recipe[4].ConcTolerance := 0.2;
Recipe[4].FlowRateMin := 250.0;
Recipe[4].DrainToWaste := FALSE;
Recipe[4].RecordRequired := TRUE;
// Step 5: Final Rinse
Recipe[5].StepName := 'FINAL_RINSE';
Recipe[5].Duration := T#8m;
Recipe[5].TargetTemp := 25.0;
Recipe[5].TempTolerance := 10.0;
Recipe[5].FlowRateMin := 200.0;
Recipe[5].DrainToWaste := TRUE;
Recipe[5].RecordRequired := TRUE;
// Step 6: Sanitize (hot water)
Recipe[6].StepName := 'SANITIZE';
Recipe[6].Duration := T#15m;
Recipe[6].TargetTemp := 85.0;
Recipe[6].TempTolerance := 2.0;
Recipe[6].FlowRateMin := 200.0;
Recipe[6].DrainToWaste := FALSE;
Recipe[6].RecordRequired := TRUE;
// Step 7: Drain
Recipe[7].StepName := 'DRAIN';
Recipe[7].Duration := T#3m;
Recipe[7].TargetTemp := 0.0;
Recipe[7].FlowRateMin := 0.0;
Recipe[7].DrainToWaste := TRUE;
Recipe[7].RecordRequired := FALSE;
END_PROGRAM
The step executor
The core sequence
FUNCTION_BLOCK FB_CIP_Controller
VAR_INPUT
CMD_Start : BOOL;
CMD_Abort : BOOL;
CMD_Skip : BOOL; // Skip current step (supervisor)
ProcessData : CIP_ProcessData;
Recipe : ARRAY[1..7] OF CIP_StepParams;
TotalSteps : INT := 7;
END_VAR
VAR_OUTPUT
CurrentStep : CIP_StepType;
StepNumber : INT;
StepName : STRING(25);
StepTimeRemaining : TIME;
SupplyPumpCmd : BOOL;
ReturnPumpCmd : BOOL;
HeatCmd : BOOL; // Steam valve
CausticValve : BOOL; // Chemical injection
AcidValve : BOOL;
DrainValve : BOOL;
SupplyValve : BOOL;
ReturnValve : BOOL;
AirBlowValve : BOOL;
CycleComplete : BOOL;
CycleFault : BOOL;
FaultMessage : STRING(50);
BatchRecord : CIP_BatchRecord;
END_VAR
VAR
StepTimer : TON;
ConditionsMet : BOOL;
TempOK : BOOL;
ConcOK : BOOL;
FlowOK : BOOL;
StepStarted : BOOL := FALSE;
QualifyTimer : TON;
QualifyTime : TIME := T#30s; // Conditions must hold 30s
// Step result tracking
TempSum : REAL;
TempMin : REAL;
TempMax : REAL;
ConcSum : REAL;
FlowSum : REAL;
SampleCount : DINT;
END_VAR
// ── Abort handling ──
IF CMD_Abort THEN
CurrentStep := CIP_FAULT;
SupplyPumpCmd := FALSE;
ReturnPumpCmd := FALSE;
HeatCmd := FALSE;
CausticValve := FALSE;
AcidValve := FALSE;
DrainValve := TRUE; // Safe drain
FaultMessage := 'OPERATOR ABORT';
CycleFault := TRUE;
RETURN;
END_IF;
CASE CurrentStep OF
CIP_IDLE:
// All outputs off
SupplyPumpCmd := FALSE;
ReturnPumpCmd := FALSE;
HeatCmd := FALSE;
CausticValve := FALSE;
AcidValve := FALSE;
DrainValve := FALSE;
AirBlowValve := FALSE;
CycleComplete := FALSE;
CycleFault := FALSE;
IF CMD_Start THEN
StepNumber := 1;
CurrentStep := CIP_PRE_RINSE;
StepStarted := FALSE;
SampleCount := 0;
END_IF;
CIP_PRE_RINSE:
StepName := Recipe[1].StepName;
SupplyPumpCmd := TRUE;
SupplyValve := TRUE;
DrainValve := Recipe[1].DrainToWaste;
ReturnValve := NOT Recipe[1].DrainToWaste;
HeatCmd := ProcessData.Temperature < Recipe[1].TargetTemp;
// Validate conditions
TempOK := ABS(ProcessData.Temperature - Recipe[1].TargetTemp) <= Recipe[1].TempTolerance;
FlowOK := ProcessData.FlowRate >= Recipe[1].FlowRateMin;
ConditionsMet := TempOK AND FlowOK;
// Timer only counts while conditions are met
StepTimer(IN := ConditionsMet, PT := Recipe[1].Duration);
StepTimeRemaining := Recipe[1].Duration - StepTimer.ET;
// Track stats
IF ConditionsMet THEN
SampleCount := SampleCount + 1;
TempSum := TempSum + ProcessData.Temperature;
END_IF;
IF StepTimer.Q OR CMD_Skip THEN
StepTimer(IN := FALSE, PT := T#0s);
StepNumber := 2;
CurrentStep := CIP_CAUSTIC_WASH;
SampleCount := 0;
END_IF;
CIP_CAUSTIC_WASH:
StepName := Recipe[2].StepName;
SupplyPumpCmd := TRUE;
ReturnPumpCmd := TRUE;
SupplyValve := TRUE;
ReturnValve := TRUE;
DrainValve := FALSE;
CausticValve := TRUE; // Inject caustic
HeatCmd := ProcessData.Temperature < Recipe[2].TargetTemp;
TempOK := ABS(ProcessData.Temperature - Recipe[2].TargetTemp) <= Recipe[2].TempTolerance;
ConcOK := ABS(ProcessData.Concentration - Recipe[2].ChemConcentration) <= Recipe[2].ConcTolerance;
FlowOK := ProcessData.FlowRate >= Recipe[2].FlowRateMin;
ConditionsMet := TempOK AND ConcOK AND FlowOK;
StepTimer(IN := ConditionsMet, PT := Recipe[2].Duration);
StepTimeRemaining := Recipe[2].Duration - StepTimer.ET;
IF StepTimer.Q OR CMD_Skip THEN
CausticValve := FALSE;
StepTimer(IN := FALSE, PT := T#0s);
StepNumber := 3;
CurrentStep := CIP_INTERMEDIATE_RINSE;
END_IF;
CIP_INTERMEDIATE_RINSE:
StepName := Recipe[3].StepName;
SupplyPumpCmd := TRUE;
SupplyValve := TRUE;
DrainValve := TRUE;
ReturnValve := FALSE;
CausticValve := FALSE;
HeatCmd := FALSE;
// Rinse until conductivity drops (caustic flushed)
TempOK := TRUE;
FlowOK := ProcessData.FlowRate >= Recipe[3].FlowRateMin;
ConditionsMet := FlowOK AND (ProcessData.Conductivity < 200.0);
StepTimer(IN := ConditionsMet, PT := Recipe[3].Duration);
StepTimeRemaining := Recipe[3].Duration - StepTimer.ET;
IF StepTimer.Q OR CMD_Skip THEN
StepTimer(IN := FALSE, PT := T#0s);
StepNumber := 4;
CurrentStep := CIP_ACID_WASH;
END_IF;
CIP_ACID_WASH:
StepName := Recipe[4].StepName;
SupplyPumpCmd := TRUE;
ReturnPumpCmd := TRUE;
SupplyValve := TRUE;
ReturnValve := TRUE;
DrainValve := FALSE;
AcidValve := TRUE;
HeatCmd := ProcessData.Temperature < Recipe[4].TargetTemp;
TempOK := ABS(ProcessData.Temperature - Recipe[4].TargetTemp) <= Recipe[4].TempTolerance;
ConcOK := ABS(ProcessData.Concentration - Recipe[4].ChemConcentration) <= Recipe[4].ConcTolerance;
FlowOK := ProcessData.FlowRate >= Recipe[4].FlowRateMin;
ConditionsMet := TempOK AND ConcOK AND FlowOK;
StepTimer(IN := ConditionsMet, PT := Recipe[4].Duration);
StepTimeRemaining := Recipe[4].Duration - StepTimer.ET;
IF StepTimer.Q OR CMD_Skip THEN
AcidValve := FALSE;
StepTimer(IN := FALSE, PT := T#0s);
StepNumber := 5;
CurrentStep := CIP_FINAL_RINSE;
END_IF;
CIP_FINAL_RINSE:
StepName := Recipe[5].StepName;
SupplyPumpCmd := TRUE;
SupplyValve := TRUE;
DrainValve := TRUE;
ReturnValve := FALSE;
AcidValve := FALSE;
HeatCmd := FALSE;
// Must achieve clean rinse: conductivity AND pH
FlowOK := ProcessData.FlowRate >= Recipe[5].FlowRateMin;
ConditionsMet := FlowOK AND
(ProcessData.Conductivity < 100.0) AND
(ProcessData.pH > 6.5 AND ProcessData.pH < 7.5);
StepTimer(IN := ConditionsMet, PT := Recipe[5].Duration);
StepTimeRemaining := Recipe[5].Duration - StepTimer.ET;
IF StepTimer.Q OR CMD_Skip THEN
StepTimer(IN := FALSE, PT := T#0s);
StepNumber := 6;
CurrentStep := CIP_SANITIZE;
END_IF;
CIP_SANITIZE:
StepName := Recipe[6].StepName;
SupplyPumpCmd := TRUE;
ReturnPumpCmd := TRUE;
SupplyValve := TRUE;
ReturnValve := TRUE;
DrainValve := FALSE;
HeatCmd := ProcessData.Temperature < Recipe[6].TargetTemp;
TempOK := ProcessData.Temperature >= (Recipe[6].TargetTemp - Recipe[6].TempTolerance);
FlowOK := ProcessData.FlowRate >= Recipe[6].FlowRateMin;
ConditionsMet := TempOK AND FlowOK;
StepTimer(IN := ConditionsMet, PT := Recipe[6].Duration);
StepTimeRemaining := Recipe[6].Duration - StepTimer.ET;
IF StepTimer.Q OR CMD_Skip THEN
HeatCmd := FALSE;
StepTimer(IN := FALSE, PT := T#0s);
StepNumber := 7;
CurrentStep := CIP_DRAIN;
END_IF;
CIP_DRAIN:
StepName := Recipe[7].StepName;
SupplyPumpCmd := FALSE;
ReturnPumpCmd := FALSE;
DrainValve := TRUE;
AirBlowValve := TRUE; // Air blow to clear lines
HeatCmd := FALSE;
StepTimer(IN := TRUE, PT := Recipe[7].Duration);
StepTimeRemaining := Recipe[7].Duration - StepTimer.ET;
IF StepTimer.Q THEN
AirBlowValve := FALSE;
DrainValve := FALSE;
StepTimer(IN := FALSE, PT := T#0s);
CurrentStep := CIP_COMPLETE;
END_IF;
CIP_COMPLETE:
SupplyPumpCmd := FALSE;
ReturnPumpCmd := FALSE;
HeatCmd := FALSE;
CausticValve := FALSE;
AcidValve := FALSE;
DrainValve := FALSE;
AirBlowValve := FALSE;
CycleComplete := TRUE;
StepName := 'COMPLETE';
CIP_FAULT:
SupplyPumpCmd := FALSE;
ReturnPumpCmd := FALSE;
HeatCmd := FALSE;
CausticValve := FALSE;
AcidValve := FALSE;
DrainValve := TRUE;
CycleFault := TRUE;
END_CASE;
END_FUNCTION_BLOCK
Chemical concentration control
The clean only works if the chemical concentration stays in range, so the dosing block holds it there:
FUNCTION_BLOCK FB_CIP_ChemDosing
VAR_INPUT
Enable : BOOL;
TargetConc : REAL; // % (e.g., 1.5% NaOH)
ActualConc : REAL; // % from conductivity calculation
TankVolume : REAL; // Liters in CIP tank
ReturnFlow : REAL; // L/min return from circuit
END_VAR
VAR_OUTPUT
DosingPumpCmd : BOOL;
DosingPumpSpeed : REAL; // 0-100%
ChemTankLow : BOOL;
ConcentrationOK : BOOL;
END_VAR
VAR
Error : REAL;
Integral : REAL := 0.0;
Kp : REAL := 10.0;
Ki : REAL := 0.5;
Tolerance : REAL := 0.2; // ±0.2%
END_VAR
IF NOT Enable THEN
DosingPumpCmd := FALSE;
DosingPumpSpeed := 0.0;
Integral := 0.0;
RETURN;
END_IF;
Error := TargetConc - ActualConc;
ConcentrationOK := ABS(Error) <= Tolerance;
IF Error > 0.05 THEN
// Need more chemical
DosingPumpCmd := TRUE;
Integral := Integral + (Error * Ki);
IF Integral > 40.0 THEN Integral := 40.0; END_IF;
IF Integral < 0.0 THEN Integral := 0.0; END_IF;
DosingPumpSpeed := (Error * Kp) + Integral;
IF DosingPumpSpeed > 100.0 THEN DosingPumpSpeed := 100.0; END_IF;
IF DosingPumpSpeed < 10.0 THEN DosingPumpSpeed := 10.0; END_IF;
ELSE
DosingPumpCmd := FALSE;
DosingPumpSpeed := 0.0;
Integral := 0.0;
END_IF;
END_FUNCTION_BLOCK
Circuit routing through a valve matrix
A CIP system serves several circuits (tank, filler, pasteurizer). A valve matrix routes the cleaning solution to the selected circuit:
TYPE CIP_Circuit : (
CIRCUIT_NONE,
CIRCUIT_TANK_1,
CIRCUIT_TANK_2,
CIRCUIT_FILLER,
CIRCUIT_PASTEURIZER,
CIRCUIT_PIPING_RUN_A,
CIRCUIT_PIPING_RUN_B
);
END_TYPE
FUNCTION_BLOCK FB_CIP_ValveRouting
VAR_INPUT
SelectedCircuit : CIP_Circuit;
CIP_Active : BOOL;
END_VAR
VAR_OUTPUT
// Supply side valves
V_Supply_Tank1 : BOOL;
V_Supply_Tank2 : BOOL;
V_Supply_Filler : BOOL;
V_Supply_Pasteur : BOOL;
V_Supply_PipeA : BOOL;
V_Supply_PipeB : BOOL;
// Return side valves
V_Return_Tank1 : BOOL;
V_Return_Tank2 : BOOL;
V_Return_Filler : BOOL;
V_Return_Pasteur : BOOL;
V_Return_PipeA : BOOL;
V_Return_PipeB : BOOL;
CircuitSelected : BOOL;
END_VAR
// Close all first
V_Supply_Tank1 := FALSE; V_Return_Tank1 := FALSE;
V_Supply_Tank2 := FALSE; V_Return_Tank2 := FALSE;
V_Supply_Filler := FALSE; V_Return_Filler := FALSE;
V_Supply_Pasteur := FALSE; V_Return_Pasteur := FALSE;
V_Supply_PipeA := FALSE; V_Return_PipeA := FALSE;
V_Supply_PipeB := FALSE; V_Return_PipeB := FALSE;
CircuitSelected := FALSE;
IF NOT CIP_Active THEN RETURN; END_IF;
CASE SelectedCircuit OF
CIRCUIT_TANK_1:
V_Supply_Tank1 := TRUE;
V_Return_Tank1 := TRUE;
CircuitSelected := TRUE;
CIRCUIT_TANK_2:
V_Supply_Tank2 := TRUE;
V_Return_Tank2 := TRUE;
CircuitSelected := TRUE;
CIRCUIT_FILLER:
V_Supply_Filler := TRUE;
V_Return_Filler := TRUE;
CircuitSelected := TRUE;
CIRCUIT_PASTEURIZER:
V_Supply_Pasteur := TRUE;
V_Return_Pasteur := TRUE;
CircuitSelected := TRUE;
CIRCUIT_PIPING_RUN_A:
V_Supply_PipeA := TRUE;
V_Return_PipeA := TRUE;
CircuitSelected := TRUE;
CIRCUIT_PIPING_RUN_B:
V_Supply_PipeB := TRUE;
V_Return_PipeB := TRUE;
CircuitSelected := TRUE;
END_CASE;
END_FUNCTION_BLOCK
Batch records for FDA compliance
For food safety, every CIP cycle must produce a verifiable batch record:
FUNCTION_BLOCK FB_CIP_BatchLogger
VAR_INPUT
StepActive : BOOL;
StepNumber : INT;
StepName : STRING(25);
Temperature : REAL;
Concentration : REAL;
FlowRate : REAL;
ConditionsMet : BOOL;
CycleComplete : BOOL;
END_VAR
VAR_OUTPUT
Record : CIP_BatchRecord;
END_VAR
VAR
TempMin : REAL := 999.0;
TempMax : REAL := -999.0;
TempSum : REAL := 0.0;
ConcSum : REAL := 0.0;
FlowSum : REAL := 0.0;
Samples : DINT := 0;
PrevStep : INT := 0;
END_VAR
// ── New step started ──
IF StepNumber <> PrevStep THEN
// Save previous step results
IF PrevStep >= 1 AND PrevStep <= 8 AND Samples > 0 THEN
Record.StepResults[PrevStep].StepName := StepName;
Record.StepResults[PrevStep].AvgTemp := TempSum / DINT_TO_REAL(Samples);
Record.StepResults[PrevStep].MinTemp := TempMin;
Record.StepResults[PrevStep].MaxTemp := TempMax;
Record.StepResults[PrevStep].AvgConc := ConcSum / DINT_TO_REAL(Samples);
Record.StepResults[PrevStep].AvgFlow := FlowSum / DINT_TO_REAL(Samples);
Record.StepResults[PrevStep].Passed := TRUE;
END_IF;
// Reset accumulators
TempMin := 999.0;
TempMax := -999.0;
TempSum := 0.0;
ConcSum := 0.0;
FlowSum := 0.0;
Samples := 0;
PrevStep := StepNumber;
END_IF;
// ── Accumulate data while step runs ──
IF StepActive AND ConditionsMet THEN
Samples := Samples + 1;
TempSum := TempSum + Temperature;
ConcSum := ConcSum + Concentration;
FlowSum := FlowSum + FlowRate;
IF Temperature < TempMin THEN TempMin := Temperature; END_IF;
IF Temperature > TempMax THEN TempMax := Temperature; END_IF;
END_IF;
// ── Finalize record ──
IF CycleComplete THEN
Record.StepCount := StepNumber;
Record.AllStepsPassed := TRUE;
// Check all steps passed
FOR i := 1 TO Record.StepCount DO
IF NOT Record.StepResults[i].Passed THEN
Record.AllStepsPassed := FALSE;
END_IF;
END_FOR;
END_IF;
END_FUNCTION_BLOCK
Permissives before a cycle can start
These interlocks block CIP while production is active and refuse to start a cycle when the chemical or water tanks are low.
PROGRAM CIP_SafetyInterlocks
VAR
// Critical interlocks
ProductionActive : BOOL; // Process equipment in production mode
CIP_Requested : BOOL;
CIP_Permitted : BOOL;
ChemTankLevel_NaOH : REAL; // % level
ChemTankLevel_Acid : REAL;
CIP_TankLevel : REAL;
SupplyPressureOK : BOOL;
ReturnFlowPresent : BOOL;
SafetyMessage : STRING(50);
END_VAR
CIP_Permitted := TRUE;
SafetyMessage := '';
// ── Never CIP while in production! ──
IF ProductionActive THEN
CIP_Permitted := FALSE;
SafetyMessage := 'PRODUCTION ACTIVE - CIP BLOCKED';
END_IF;
// ── Chemical tank levels ──
IF ChemTankLevel_NaOH < 10.0 THEN
CIP_Permitted := FALSE;
SafetyMessage := 'CAUSTIC TANK LOW';
END_IF;
IF ChemTankLevel_Acid < 10.0 THEN
CIP_Permitted := FALSE;
SafetyMessage := 'ACID TANK LOW';
END_IF;
// ── Water supply ──
IF CIP_TankLevel < 20.0 THEN
CIP_Permitted := FALSE;
SafetyMessage := 'CIP WATER TANK LOW';
END_IF;
END_PROGRAM
How the pieces fit together
CIP is one of the most demanding PLC applications in food and beverage manufacturing. The program above combines sequential step control over 7+ wash phases, PID chemical dosing to hold the concentration, valve routing for several circuits, and an FDA-compliant batch record for every cycle. The central design rule is that the CIP step timer counts only while temperature, concentration and flow rate are all in range at the same time, so the wash time in the record is time the conditions were actually met. The interlocks that block CIP during production complete the design. They are process permissives; they protect the product and the cycle and do not replace the plant's safety systems.
The sections below cover what that basic design misses on a real plant.
Size the flow setpoint in m/s, not L/min
A single FlowRateMin in L/min is the most common thing to get wrong on a multi-circuit system. Cleaning is driven by wall shear, which follows velocity and bore: the floor is around 1.5 m/s in the return pipework, with little gained above roughly 2.1 m/s. So 250 L/min is about 2.3 m/s in 2-inch tube and barely 1.0 m/s in 3-inch. Serve a 2-inch filler loop and a 3-inch silo main from one recipe and the 3-inch main is only being rinsed, while the batch record says it was cleaned. Store the bore per circuit and qualify the step on m/s.
Tanks work the other way round. A static spray ball is a coverage device, sized near 35–40 L/min per metre of circumference at roughly 1.7–2 bar (25–30 psi) at the ball, and it needs a pressure ceiling: pushed harder, it atomises instead of forming the falling film that wets the wall.
Where you measure decides what you can claim
Put the qualifying temperature element on the return. Supply-side qualification lets you log 85 °C while the far end of a filler loop sits fifteen degrees cooler. Interlock the steam valve to proven flow, not to the step state. Heating a static line flashes the water and blows a gasket, and on a plate exchanger it bakes soil onto the plates you came to clean.
Pre-rinse has a temperature ceiling nobody writes on the P&ID: much above 55 °C, protein in the residual product coagulates and bonds to the wall, which is why pre-rinse is usually run at 40–50 °C. Juniors raise pre-rinse temperature to shorten the heat-up before caustic; the burn-on surfaces weeks later as a failing swab.
Two ways the conductivity reading misleads you
Temperature compensation in most transmitters is a linear coefficient near 2 %/°C referenced to 25 °C. It was derived for dilute aqueous solutions, and 1.5 % NaOH at 78 °C is not one. Calibrate a toroidal sensor in a bucket at ambient, trust it hot, and you are off by several tenths of a percent.
The second error is worse. Conductivity counts ions and is not specific to NaOH, so the carbonate and dissolved soil that build up in recovered caustic are counted as if they were caustic. Across a shift, free alkalinity falls while the reading stays up: the sensor says 1.6 %, titration says 1.1 %. Titrate once per shift and hold that entry in the PLC with an expiry. If the last verified titration is older than the SOP interval, block the caustic step or flag every record it produces.
Interlocks that keep chemical out of product
Blocking CIP during production is the easy half. The dangerous case is chemical reaching a product circuit that is not in CIP, and that is a valve-position problem. Interlock on position feedback, never on the command: a return valve commanded closed with a seized actuator routes 75 °C caustic into a full silo while your command bit reports normal.
Mix-proof (double-seat) valves make separation physical: two independent seals with a leakage chamber vented to atmosphere between them, so a failed seat drips to drain rather than into product. Their seat-lift function, which cleans the seat faces during CIP, should be pulsed, not held. A sustained lift admits cleaning fluid faster than the leakage chamber's drain can clear it, which is why vendors specify a timed pulse. Program it as a one-shot pulse with a mandatory rest interval, one valve per manifold at a time.
Dead legs fail without anyone noticing. The 2D rule (branch length under twice the branch's own inside diameter, measured from the inner wall of the main) is mechanical, but the PLC owns the branches that break it: sample valves, drain points, instrument tees. Pulse those open during caustic.
FUNCTION_BLOCK FB_ChemInjectPermissive
(* Gates every chemical injection valve. Step state alone is NEVER
sufficient - if the supply pump trips mid-wash, dosing must stop. *)
VAR_INPUT
StepWantsChem : BOOL; // caustic or acid step is active
SupplyPumpRun : BOOL; // VFD / contactor RUN feedback, not the command
SupplyVelocity : REAL; // m/s, derived from flow and THIS circuit's bore
ReturnFlow : REAL; // L/min at the return magflow
CircuitLocked : BOOL; // routing FB proves exactly one circuit selected
ProdValvesClosed : BOOL; // ALL product-side valve FEEDBACKS prove closed
PanelInCIP : BOOL; // swing-bend / key-switch proving
END_VAR
VAR_OUTPUT
ChemEnable : BOOL;
BlockReason : STRING(40);
END_VAR
VAR
Established : TON; // velocity must hold before the first drop of chemical
Ridethrough : TOF; // tolerate a brief flow dropout, not a sustained one
END_VAR
Established(IN := SupplyPumpRun AND (SupplyVelocity >= 1.5) AND (ReturnFlow > 0.0),
PT := T#10s);
Ridethrough(IN := Established.Q, PT := T#3s);
ChemEnable := StepWantsChem
AND Ridethrough.Q
AND CircuitLocked
AND ProdValvesClosed
AND PanelInCIP;
IF StepWantsChem AND NOT ChemEnable THEN
IF NOT PanelInCIP THEN
BlockReason := 'SWING BEND NOT IN CIP POSITION';
ELSIF NOT ProdValvesClosed THEN
BlockReason := 'PRODUCT VALVE FEEDBACK NOT CLOSED';
ELSIF NOT CircuitLocked THEN
BlockReason := 'CIRCUIT ROUTING NOT CONFIRMED';
ELSE
BlockReason := 'FLOW/VELOCITY LOW - DOSING INHIBITED';
END_IF;
ELSE
BlockReason := '';
END_IF;
END_FUNCTION_BLOCK
What the auditor asks for
Programs log averages, and auditors distrust them: a 75.4 °C mean hides a four-minute sag to 62 °C when the steam header dipped. Log time in band, time out of band, excursion count, and the worst single excursion. Add a maximum step time too, because a step that accrues only while conditions are met will otherwise run all night behind a failed steam valve.
FUNCTION_BLOCK FB_StepBandAudit
VAR_INPUT
StepRunning : BOOL;
InBand : BOOL; // the step's own ConditionsMet
ScanTime : TIME; // task period, e.g. T#100ms
MaxStepTime : TIME; // wall-clock watchdog for this step
END_VAR
VAR_OUTPUT
TimeInBand : TIME;
TimeOutBand : TIME;
Excursions : INT; // times the band was lost after first being reached
LongestOut : TIME; // worst single excursion
StepTimeout : BOOL; // never qualified - call maintenance, do not re-run
END_VAR
VAR
WallClock : TIME;
ThisOut : TIME;
PrevInBand : BOOL;
BandAchieved : BOOL;
END_VAR
IF NOT StepRunning THEN
TimeInBand := T#0s; TimeOutBand := T#0s; WallClock := T#0s;
ThisOut := T#0s; LongestOut := T#0s; Excursions := 0;
StepTimeout := FALSE; PrevInBand := FALSE; BandAchieved := FALSE;
RETURN;
END_IF;
WallClock := WallClock + ScanTime;
StepTimeout := WallClock > MaxStepTime;
IF InBand THEN
BandAchieved := TRUE;
TimeInBand := TimeInBand + ScanTime;
ThisOut := T#0s;
ELSIF BandAchieved THEN
// Ramp-up before the band is first reached is not an excursion;
// MaxStepTime is what catches a circuit that never gets there.
TimeOutBand := TimeOutBand + ScanTime;
ThisOut := ThisOut + ScanTime;
IF ThisOut > LongestOut THEN
LongestOut := ThisOut;
END_IF;
IF PrevInBand THEN
Excursions := Excursions + 1;
END_IF;
END_IF;
PrevInBand := InBand;
END_FUNCTION_BLOCK
Two habits close findings cheaply. Log a CRC of the recipe parameter block beside the recipe ID, because "Recipe 4" proves nothing about which values it held at 03:00 last Tuesday. Then attribute every skip or override to a user with a reason code, marking that record not-for-release. The PLC is also, in most architectures, not where the 21 CFR Part 11 record lives; it is the instrument feeding the historian or MES that holds the tamper-evident audit trail.
The record becomes evidence only when physical verification backs it: riboflavin coverage tests under UV at commissioning for every tank and spray device, ATP swabs at fixed post-CIP points against a site-set RLU limit, final-rinse water sampling. Run the riboflavin test before tuning a setpoint, because no sequence logic fixes a spray ball aimed at the agitator shaft.