PLC Water Treatment & SCADA Integration: Complete Structured Text Programming Guide

Build a water treatment control system from intake to distribution — with pump sequencing, chemical dosing PID loops, filter backwash logic, and SCADA integration in Structured Text.

The process the PLC controls

A typical municipal or industrial water treatment plant takes water through these stages:

Raw Water    ──►  Coagulation  ──►  Flocculation  ──►  Sedimentation
  Intake          (Chemical         (Slow Mix)          (Settling)
  Pumps            Dosing)
                                                            │
Distribution  ◄──  Disinfection  ◄──  Filtration    ◄──────┘
  Network          (Chlorine)         (Sand/GAC)

Each stage needs PLC control, and operators watch all of them in real time on SCADA. The sections below build the control for each stage in Structured Text, then tie them together in one plant program.

Data structures for the plant

Process variables

TYPE WaterQuality :
STRUCT
    pH            : REAL;    // 0-14 scale
    Turbidity     : REAL;    // NTU (Nephelometric Turbidity Units)
    Chlorine      : REAL;    // mg/L (ppm)
    Temperature   : REAL;    // °C
    Conductivity  : REAL;    // µS/cm
    FlowRate      : REAL;    // m³/h
    TotalFlow     : REAL;    // m³ (totalizer)
    TSSLevel      : REAL;    // mg/L Total Suspended Solids
END_STRUCT;
END_TYPE

TYPE PumpStatus :
STRUCT
    Running       : BOOL;
    Fault         : BOOL;
    AutoMode      : BOOL;
    RunHours      : REAL;    // Accumulated run hours
    Current       : REAL;    // Amps
    Speed         : REAL;    // % (for VFD-driven pumps)
    FlowRate      : REAL;    // m³/h
END_STRUCT;
END_TYPE

TYPE TankLevel :
STRUCT
    Level         : REAL;    // % (0-100)
    Volume        : REAL;    // m³
    HighAlarm     : BOOL;
    LowAlarm      : BOOL;
    HighHigh      : BOOL;    // Critical high
    LowLow        : BOOL;    // Critical low — pump protection
END_STRUCT;
END_TYPE

Intake pump station control

The raw water pumps are the first stage. They have to start and stop in sequence according to demand, and they must be protected against running dry:

Pump sequencer with lead/lag rotation

FUNCTION_BLOCK FB_PumpSequencer
VAR_INPUT
    Enable        : BOOL;
    DemandFlow    : REAL;         // Required flow in m³/h
    WellLevel     : REAL;         // Source water level %
    LowLevelCutoff: REAL := 15.0; // Stop all pumps below this
END_VAR
VAR_OUTPUT
    Pump1_Cmd     : BOOL;
    Pump2_Cmd     : BOOL;
    Pump3_Cmd     : BOOL;
    ActivePumps   : INT;
    TotalFlow     : REAL;
END_VAR
VAR
    PumpCapacity  : REAL := 150.0;  // m³/h per pump
    LeadPump      : INT := 1;       // Rotates 1→2→3→1
    RotationTimer : TON;
    RotationTime  : TIME := T#24h;  // Rotate lead every 24 hours
END_VAR

// ── Dry-run protection ──
IF WellLevel < LowLevelCutoff OR NOT Enable THEN
    Pump1_Cmd := FALSE;
    Pump2_Cmd := FALSE;
    Pump3_Cmd := FALSE;
    ActivePumps := 0;
    RETURN;
END_IF;

// ── Calculate pumps needed ──
IF DemandFlow <= PumpCapacity THEN
    ActivePumps := 1;
ELSIF DemandFlow <= (PumpCapacity * 2.0) THEN
    ActivePumps := 2;
ELSE
    ActivePumps := 3;
END_IF;

// ── Lead/lag rotation ──
RotationTimer(IN := TRUE, PT := RotationTime);
IF RotationTimer.Q THEN
    LeadPump := LeadPump + 1;
    IF LeadPump > 3 THEN
        LeadPump := 1;
    END_IF;
    RotationTimer(IN := FALSE, PT := RotationTime);
END_IF;

// ── Assign pump commands based on lead position ──
CASE LeadPump OF
    1:
        Pump1_Cmd := ActivePumps >= 1;
        Pump2_Cmd := ActivePumps >= 2;
        Pump3_Cmd := ActivePumps >= 3;
    2:
        Pump2_Cmd := ActivePumps >= 1;
        Pump3_Cmd := ActivePumps >= 2;
        Pump1_Cmd := ActivePumps >= 3;
    3:
        Pump3_Cmd := ActivePumps >= 1;
        Pump1_Cmd := ActivePumps >= 2;
        Pump2_Cmd := ActivePumps >= 3;
END_CASE;

TotalFlow := INT_TO_REAL(ActivePumps) * PumpCapacity;
END_FUNCTION_BLOCK

Chemical dosing control

Coagulant dosing with flow-paced PID

Chemical dosing has to follow the incoming flow rate. A typical coagulant dose (alum or ferric chloride) is 10-50 mg/L, depending on the raw water turbidity. The block below sets a base dose from the raw turbidity and trims it with a PID on the settled turbidity:

FUNCTION_BLOCK FB_ChemicalDosing
VAR_INPUT
    Enable          : BOOL;
    InletFlow       : REAL;      // m³/h — raw water flow
    RawTurbidity    : REAL;      // NTU — incoming turbidity
    SettledTurbidity: REAL;      // NTU — after sedimentation (feedback)
    TargetTurbidity : REAL;      // NTU setpoint (typically 1-5 NTU)
END_VAR
VAR_OUTPUT
    DosingPumpSpeed : REAL;      // 0-100% VFD speed
    DoseRate        : REAL;      // mg/L actual
    ChemicalUsage   : REAL;      // kg/h
    TankLevelLow    : BOOL;
END_VAR
VAR
    // PID controller variables
    Error           : REAL;
    Integral        : REAL := 0.0;
    Derivative      : REAL;
    PrevError       : REAL := 0.0;
    PID_Output      : REAL;
    
    // Tuning parameters
    Kp              : REAL := 2.0;
    Ki              : REAL := 0.1;
    Kd              : REAL := 0.5;
    
    // Flow-paced base dose (mg/L lookup from turbidity)
    BaseDose        : REAL;
    MaxDose         : REAL := 60.0;   // mg/L safety limit
END_VAR

IF NOT Enable THEN
    DosingPumpSpeed := 0.0;
    Integral := 0.0;
    RETURN;
END_IF;

// ── Flow-paced base dose ── (higher turbidity = more chemical)
IF RawTurbidity < 10.0 THEN
    BaseDose := 15.0;    // mg/L
ELSIF RawTurbidity < 50.0 THEN
    BaseDose := 25.0;
ELSIF RawTurbidity < 200.0 THEN
    BaseDose := 40.0;
ELSE
    BaseDose := 55.0;    // Storm event / high turbidity
END_IF;

// ── PID trim based on settled water quality ──
Error := SettledTurbidity - TargetTurbidity;
Integral := Integral + (Error * Ki);

// Anti-windup
IF Integral > 20.0 THEN Integral := 20.0; END_IF;
IF Integral < -20.0 THEN Integral := -20.0; END_IF;

Derivative := (Error - PrevError) * Kd;
PrevError := Error;

PID_Output := (Error * Kp) + Integral + Derivative;

// ── Calculate final dose ──
DoseRate := BaseDose + PID_Output;
IF DoseRate < 5.0 THEN DoseRate := 5.0; END_IF;      // Minimum dose
IF DoseRate > MaxDose THEN DoseRate := MaxDose; END_IF; // Safety cap

// ── Convert dose to pump speed ──
// Chemical usage (kg/h) = DoseRate (mg/L) × Flow (m³/h) / 1000
ChemicalUsage := (DoseRate * InletFlow) / 1000.0;

// Map chemical usage to pump speed (pump max = 50 kg/h)
DosingPumpSpeed := (ChemicalUsage / 50.0) * 100.0;
IF DosingPumpSpeed > 100.0 THEN DosingPumpSpeed := 100.0; END_IF;
END_FUNCTION_BLOCK

Filter control and backwash sequencing

Sand or GAC (granular activated carbon) filters need backwashing from time to time: when the differential pressure across the bed rises, or after a set volume has passed through. This block also starts a backwash on turbidity breakthrough or an operator request:

Filter backwash state machine

TYPE FilterState : (
    FILTER_SERVICE,       // Normal filtering
    FILTER_BACKWASH_INIT, // Preparing for backwash
    FILTER_DRAIN_DOWN,    // Lower water level
    FILTER_AIR_SCOUR,     // Air agitation
    FILTER_BACKWASH_RISE, // Upflow wash
    FILTER_RINSE,         // Settle and rinse
    FILTER_REFILL,        // Fill to operating level
    FILTER_IDLE           // Offline / maintenance
);
END_TYPE

FUNCTION_BLOCK FB_FilterControl
VAR_INPUT
    Enable          : BOOL;
    DiffPressure    : REAL;     // mbar across filter bed
    FilteredVolume  : REAL;     // m³ since last backwash
    ManualBackwash  : BOOL;     // Operator trigger
    Turbidity_Out   : REAL;     // NTU of filtered water
END_VAR
VAR_OUTPUT
    State           : FilterState;
    InletValve      : BOOL;
    OutletValve     : BOOL;
    DrainValve      : BOOL;
    BackwashValve   : BOOL;
    AirScourBlower  : BOOL;
    BackwashPump    : BOOL;
    FilterOnline    : BOOL;
END_VAR
VAR
    StepTimer       : TON;
    BackwashTrigger : BOOL;
    
    // Backwash trigger thresholds
    MaxDiffPressure : REAL := 1500.0;  // mbar
    MaxVolume       : REAL := 5000.0;  // m³
    MaxTurbidity    : REAL := 1.0;     // NTU breakthrough
    
    // Backwash step durations
    DrainTime       : TIME := T#3m;
    AirScourTime    : TIME := T#5m;
    BackwashTime    : TIME := T#10m;
    RinseTime       : TIME := T#5m;
    RefillTime      : TIME := T#4m;
END_VAR

// ── Check backwash triggers ──
BackwashTrigger := (DiffPressure > MaxDiffPressure)
                OR (FilteredVolume > MaxVolume)
                OR (Turbidity_Out > MaxTurbidity)
                OR ManualBackwash;

CASE State OF
    FILTER_SERVICE:
        InletValve := TRUE;
        OutletValve := TRUE;
        DrainValve := FALSE;
        BackwashValve := FALSE;
        AirScourBlower := FALSE;
        BackwashPump := FALSE;
        FilterOnline := TRUE;
        
        IF BackwashTrigger AND Enable THEN
            State := FILTER_BACKWASH_INIT;
        END_IF;
    
    FILTER_BACKWASH_INIT:
        InletValve := FALSE;
        OutletValve := FALSE;
        FilterOnline := FALSE;
        StepTimer(IN := FALSE, PT := T#0s);
        State := FILTER_DRAIN_DOWN;
    
    FILTER_DRAIN_DOWN:
        DrainValve := TRUE;
        StepTimer(IN := TRUE, PT := DrainTime);
        IF StepTimer.Q THEN
            DrainValve := FALSE;
            StepTimer(IN := FALSE, PT := T#0s);
            State := FILTER_AIR_SCOUR;
        END_IF;
    
    FILTER_AIR_SCOUR:
        AirScourBlower := TRUE;
        StepTimer(IN := TRUE, PT := AirScourTime);
        IF StepTimer.Q THEN
            AirScourBlower := FALSE;
            StepTimer(IN := FALSE, PT := T#0s);
            State := FILTER_BACKWASH_RISE;
        END_IF;
    
    FILTER_BACKWASH_RISE:
        BackwashValve := TRUE;
        BackwashPump := TRUE;
        StepTimer(IN := TRUE, PT := BackwashTime);
        IF StepTimer.Q THEN
            BackwashPump := FALSE;
            BackwashValve := FALSE;
            StepTimer(IN := FALSE, PT := T#0s);
            State := FILTER_RINSE;
        END_IF;
    
    FILTER_RINSE:
        InletValve := TRUE;
        DrainValve := TRUE;
        StepTimer(IN := TRUE, PT := RinseTime);
        IF StepTimer.Q THEN
            DrainValve := FALSE;
            StepTimer(IN := FALSE, PT := T#0s);
            State := FILTER_REFILL;
        END_IF;
    
    FILTER_REFILL:
        InletValve := TRUE;
        StepTimer(IN := TRUE, PT := RefillTime);
        IF StepTimer.Q THEN
            OutletValve := TRUE;
            FilterOnline := TRUE;
            StepTimer(IN := FALSE, PT := T#0s);
            State := FILTER_SERVICE;
        END_IF;
    
    FILTER_IDLE:
        InletValve := FALSE;
        OutletValve := FALSE;
        FilterOnline := FALSE;
END_CASE;
END_FUNCTION_BLOCK

Chlorine disinfection control

The chlorine loop trims the dose to hold a residual setpoint, and calculates CT (residual times contact time) against a minimum:

FUNCTION_BLOCK FB_ChlorineControl
VAR_INPUT
    Enable            : BOOL;
    PlantFlow         : REAL;       // m³/h
    ChlorineResidual  : REAL;       // mg/L (analyzer reading)
    TargetResidual    : REAL;       // mg/L setpoint (typically 0.5-2.0)
    ContactTime       : REAL;       // minutes in contact tank
END_VAR
VAR_OUTPUT
    ChlorinePumpSpeed : REAL;       // 0-100%
    CTValue           : REAL;       // mg·min/L (must exceed minimum)
    CTCompliant       : BOOL;       // TRUE if CT meets regulation
    DoseRate          : REAL;       // mg/L being applied
END_VAR
VAR
    Error             : REAL;
    Integral          : REAL := 0.0;
    PrevError         : REAL := 0.0;
    PID_Out           : REAL;
    Kp                : REAL := 3.0;
    Ki                : REAL := 0.05;
    Kd                : REAL := 1.0;
    MinCT             : REAL := 15.0;
END_VAR

IF NOT Enable THEN
    ChlorinePumpSpeed := 0.0;
    RETURN;
END_IF;

Error := TargetResidual - ChlorineResidual;
Integral := Integral + (Error * Ki);
IF Integral > 30.0 THEN Integral := 30.0; END_IF;
IF Integral < -10.0 THEN Integral := -10.0; END_IF;

PID_Out := (Error * Kp) + Integral + ((Error - PrevError) * Kd);
PrevError := Error;

DoseRate := 2.0 + PID_Out;
IF DoseRate < 0.5 THEN DoseRate := 0.5; END_IF;
IF DoseRate > 8.0 THEN DoseRate := 8.0; END_IF;

ChlorinePumpSpeed := (DoseRate * PlantFlow) / (10.0 * 100.0) * 100.0;
IF ChlorinePumpSpeed > 100.0 THEN ChlorinePumpSpeed := 100.0; END_IF;

CTValue := ChlorineResidual * ContactTime;
CTCompliant := CTValue >= MinCT;
END_FUNCTION_BLOCK

SCADA integration and alarm configuration

The water quality alarms are simple comparisons against setpoints that SCADA can display and log:

PROGRAM WaterTreatmentAlarms
VAR
    WQ             : WaterQuality;
    pH_HighAlarm   : BOOL;
    pH_LowAlarm    : BOOL;
    TurbidityAlarm : BOOL;
    ChlorineHigh   : BOOL;
    ChlorineLow    : BOOL;
    CTNonCompliant : BOOL;
    
    pH_HighSP      : REAL := 8.5;
    pH_LowSP       : REAL := 6.5;
    TurbiditySP    : REAL := 1.0;
    Cl2_HighSP     : REAL := 4.0;
    Cl2_LowSP      : REAL := 0.2;
END_VAR

pH_HighAlarm   := WQ.pH > pH_HighSP;
pH_LowAlarm    := WQ.pH < pH_LowSP;
TurbidityAlarm := WQ.Turbidity > TurbiditySP;
ChlorineHigh   := WQ.Chlorine > Cl2_HighSP;
ChlorineLow    := WQ.Chlorine < Cl2_LowSP;
END_PROGRAM

The plant coordinator

One program calls an instance of each block and wires the process values between them:

PROGRAM WaterTreatmentPlant
VAR
    IntakePumps    : FB_PumpSequencer;
    CoagDosing     : FB_ChemicalDosing;
    Filter1        : FB_FilterControl;
    Filter2        : FB_FilterControl;
    Chlorination   : FB_ChlorineControl;
    
    RawWater       : WaterQuality;
    SettledWater   : WaterQuality;
    FilteredWater  : WaterQuality;
    FinishedWater  : WaterQuality;
    
    PlantRunning   : BOOL := FALSE;
    AutoMode       : BOOL := TRUE;
    ClearWellLevel : TankLevel;
END_VAR

IntakePumps(
    Enable     := PlantRunning,
    DemandFlow := 300.0,
    WellLevel  := 65.0
);

CoagDosing(
    Enable           := PlantRunning AND AutoMode,
    InletFlow        := RawWater.FlowRate,
    RawTurbidity     := RawWater.Turbidity,
    SettledTurbidity  := SettledWater.Turbidity,
    TargetTurbidity  := 2.0
);

Filter1(
    Enable       := PlantRunning,
    DiffPressure := 800.0,
    FilteredVolume := 3200.0,
    Turbidity_Out := FilteredWater.Turbidity
);

Filter2(
    Enable       := PlantRunning,
    DiffPressure := 650.0,
    FilteredVolume := 2800.0,
    Turbidity_Out := FilteredWater.Turbidity
);

Chlorination(
    Enable           := PlantRunning,
    PlantFlow        := RawWater.FlowRate,
    ChlorineResidual := FinishedWater.Chlorine,
    TargetResidual   := 1.0,
    ContactTime      := 30.0
);
END_PROGRAM

How the pieces fit together

The plant so far uses pump sequencing at the intake, PID control for chemical dosing and chlorination, a state machine for the filter backwash, and SCADA integration so operators can see the plant and the plant can demonstrate regulatory compliance. Each subsystem (pumps, dosing, filters, disinfection) is an independent function block, and the plant coordinator calls them all. You can test each block on its own and maintain it without touching the others, and the same structure scales from a small well-water system to a full municipal treatment plant.

The sections that follow go further into what the simple versions above leave out.

Duty rotation that equalises run hours

A rotation timer moves the lead position, and that is all it does: the run hours do not even out. Where the duty pump carries the base load and the lag pump only cuts in at peak, round-robin still overworks whichever pump holds the lead slot each morning. Equalise on the lowest accumulated run time at each start instead.

Never re-rank while pumps are running. Promoting a colder pump mid-run means stopping a running machine to start another one, which costs an extra motor start, a check-valve slam and a transient nobody asked for. Latch the new order and apply it at the next natural stop.

Never accumulate run hours in a REAL either. A single-precision float carries a 24-bit mantissa. Feed it 0.1 s increments (2.78 x 10^-5 h) each scan and the accumulator flatlines at exactly 512.0 hours, because from there every increment falls below half an ULP and rounds away. Count whole seconds in a RETAIN integer and convert to hours only for display.

FUNCTION_BLOCK FB_DutyAssign
VAR_INPUT
    PumpsWanted : INT;                      // how many pumps the level logic asks for
    Available   : ARRAY [1..3] OF BOOL;     // in AUTO, no fault, min-off timer expired
    Running     : ARRAY [1..3] OF BOOL;     // run feedback, not the command
    ScanMs      : UDINT := 100;             // must match the actual call interval
END_VAR
VAR_OUTPUT
    Cmd         : ARRAY [1..3] OF BOOL;
    RunHours    : ARRAY [1..3] OF REAL;     // display only — never the accumulator
END_VAR
VAR RETAIN
    RunSec      : ARRAY [1..3] OF UDINT;    // the real accumulator: whole seconds
    MsRem       : ARRAY [1..3] OF UDINT;
END_VAR
VAR
    i, j, best  : INT;
    tmp         : INT;                      // swap temp — never reuse a FOR counter
    nRunning    : INT;
    Order       : ARRAY [1..3] OF INT;      // duty order, coldest pump first
    Ranked      : BOOL := FALSE;
    Started     : INT;
END_VAR

// ── 1. Accumulate in integer seconds, retained across power cycles ──────
FOR i := 1 TO 3 DO
    IF Running[i] THEN
        MsRem[i] := MsRem[i] + ScanMs;
        IF MsRem[i] >= 1000 THEN
            RunSec[i] := RunSec[i] + (MsRem[i] / 1000);
            MsRem[i]  := MsRem[i] MOD 1000;
        END_IF;
    END_IF;
    RunHours[i] := UDINT_TO_REAL(RunSec[i]) / 3600.0;
END_FOR;

// ── 2. Re-rank only when the station is idle ───────────────────────────
nRunning := 0;
FOR i := 1 TO 3 DO
    IF Running[i] THEN nRunning := nRunning + 1; END_IF;
END_FOR;

IF nRunning = 0 THEN
    FOR i := 1 TO 3 DO
        Order[i] := i;
    END_FOR;
    FOR i := 1 TO 2 DO                      // selection sort on run seconds
        best := i;
        FOR j := i + 1 TO 3 DO
            IF RunSec[Order[j]] < RunSec[Order[best]] THEN
                best := j;
            END_IF;
        END_FOR;
        IF best <> i THEN
            tmp         := Order[i];
            Order[i]    := Order[best];
            Order[best] := tmp;
        END_IF;
    END_FOR;
    Ranked := TRUE;
END_IF;

// ── 3. Deal commands down the ranked list, skipping unavailable pumps ───
// A pump in local, faulted, or inside its min-off timer is not a candidate:
// slide down the order rather than stalling on it.
FOR i := 1 TO 3 DO
    Cmd[i] := FALSE;
END_FOR;

IF Ranked THEN
    Started := 0;
    FOR i := 1 TO 3 DO
        IF Started >= PumpsWanted THEN
            EXIT;
        END_IF;
        IF Available[Order[i]] THEN
            Cmd[Order[i]] := TRUE;
            Started := Started + 1;
        END_IF;
    END_FOR;
END_IF;
END_FUNCTION_BLOCK

Wet wells: starts per hour set the design

You size the level band so the motor never exceeds its permitted starts per hour. The worst case for that is not peak inflow. Cycling is fastest when the inflow sits near half of the pump capacity, because the band then fills and empties equally quickly. Allowable starts vary with frame size and pole count, so take the figure from the motor vendor, and back it up with minimum-run and minimum-off timers.

The wet well also gives you a flow measurement for free, and many stations never use it. With the pumps off, the level rise rate times the wet-well plan area is the raw inflow, with no flow meter. With one pump running, the pump's capacity is that inflow plus the drawdown rate. Trend it. A slow decline is wear; a 30% step is a rag ball.

FUNCTION_BLOCK FB_WetWellInflow
VAR_INPUT
    Level        : REAL;            // m above floor, from the transducer
    PumpsRunning : INT;             // confirmed running, from run feedback
    AreaM2       : REAL;            // effective plan area of the wet well
    BaselineCap  : REAL;            // m³/h measured at commissioning
    SampleTime   : TIME := T#10s;
END_VAR
VAR_OUTPUT
    InflowM3h    : REAL;
    PumpCapM3h   : REAL;
    CapacityPct  : REAL;            // trend this — it is your early warning
    DegradedCap  : BOOL;
END_VAR
VAR
    Sample       : TON;
    PrevLevel    : REAL;
    FirstPass    : BOOL := TRUE;
    LastInflow   : REAL;
    HaveInflow   : BOOL := FALSE;   // no capacity maths until inflow is measured
    SampleHours  : REAL;
    dLdt         : REAL;            // metres per hour, signed
END_VAR

// Self-resetting sample clock. Note the true period is PT plus one scan,
// so keep PT well above the scan time or correct SampleHours for it.
Sample(IN := NOT Sample.Q, PT := SampleTime);

IF Sample.Q THEN
    // TIME_TO_DINT yields milliseconds on CODESYS, TwinCAT and TIA. IEC 61131-3
    // leaves the TIME representation implementer-specific — confirm on your target.
    SampleHours := DINT_TO_REAL(TIME_TO_DINT(SampleTime)) / 3600000.0;

    IF FirstPass THEN
        FirstPass := FALSE;
    ELSE
        dLdt := (Level - PrevLevel) / SampleHours;

        IF PumpsRunning = 0 THEN
            // Filling: dV/dt is the raw inflow. No flow meter required.
            InflowM3h := dLdt * AreaM2;
            IF InflowM3h < 0.0 THEN
                InflowM3h := 0.0;   // falling with pumps off = passing check valve
            END_IF;
            LastInflow := InflowM3h;
            HaveInflow := TRUE;

        ELSIF PumpsRunning = 1 THEN
            // Drawing down: Qpump = Qin - dV/dt, and dLdt is negative here.
            // Without a measured inflow this understates capacity by the whole
            // inflow and would raise a false DegradedCap on a running start.
            IF HaveInflow THEN
                PumpCapM3h := LastInflow - (dLdt * AreaM2);
                IF BaselineCap > 0.0 THEN
                    CapacityPct := (PumpCapM3h / BaselineCap) * 100.0;
                    DegradedCap := CapacityPct < 70.0;
                END_IF;
            END_IF;
        END_IF;
    END_IF;

    PrevLevel := Level;
END_IF;
END_FUNCTION_BLOCK

Level instruments fail in specific ways. A submersible transducer vents through a tube in its cable. If the desiccant in the junction box saturates, that tube blocks, and from then on the reading drifts with every weather front. Ultrasonics false-echo off foam and lose range under a grease mat. Whatever transmitter you fit, keep a hardwired high-level float as well.

Flow pacing: feedforward does the work, the trim only nudges

The deadtime from injection point to analyser (pipe transport, sample line and analyser response together) runs to minutes, sometimes tens of minutes. Any trim loop with an integral time close to that deadtime will hunt. Set the integral time to several times the measured deadtime, and let the feedforward absorb the load changes.

Watch the pump's turndown. Diaphragm metering pumps typically hold their stated accuracy over roughly a 10:1 range, and API 675 defines turndown as exactly that: the range over which steady-state accuracy is maintained. A plant that turns down 20:1 overnight is dosing blind after midnight. Size the pump for the low end, or split the duty across two pumps.

Prove the flow rather than trusting the command. A flow transmitter that fails to zero drives the dose to minimum while water is still moving, and a main isolated while the pump keeps stroking injects neat chemical into a static pipe. Gate the dosing on a hardwired flow switch.

Hypochlorite off-gasses. It decomposes and releases oxygen, which collects in the suction line and vapour-locks the diaphragm head. The pump strokes and the counter increments, but nothing moves. The signature is a residual that sags while SCADA shows the pump at 60%. Fit a degassing head with flooded suction, and trend the delivered volume against the stroke count. Hypochlorite strength also decays with age and heat, so the trim's steady-state output tracks the age of the day tank.

What the historian owes the regulator

Under the US Interim Enhanced Surface Water Treatment Rule, each individual filter needs a continuous turbidimeter recorded at least every 15 minutes (40 CFR 141.174, for systems serving 10,000 or more; smaller systems carry the parallel LT1ESWTR requirement at 40 CFR 141.560). That makes the data path part of compliance, and a store-and-forward buffer that survives a SCADA reboot is not a nicety.

It also shows up a gap in the backwash sequence above, which returns a filter straight from refill to service. A freshly washed bed has to ripen, and turbidity spikes for the first minutes of the run. Without a filter-to-waste step, that spike lands in the clearwell and in the 15-minute record.

Check the historian's compression settings. Swinging-door compression is commonly enabled by default and tuned to save storage, which is the wrong goal for evidence. It is a slope-based algorithm bounded by a compression deviation which behaves differently from a simple deadband, and widening that deviation on a compliance tag silently discards the four-minute excursion you were obliged to record.

A calibration reading is not a process value. An analyser in its cal cycle either holds its last value or reads its standard. Stamp the historian value as bad quality from the service status bit, or you will average a fabricated low into the monthly return.

Regulatory CT does not use the theoretical detention time. It uses T10, the theoretical detention time multiplied by a baffling factor, which EPA guidance scales from about 0.1 for an unbaffled agitated basin to 0.7 for superior baffling, and 1.0 only for true plug flow. The required CT is itself a lookup on pH, temperature and free chlorine, so a plant that passes comfortably in August can fail in February with an identical residual.