Advanced
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.