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.