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.

Water Treatment Plant Overview

A typical municipal or industrial water treatment plant follows this process flow:

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

Each stage requires PLC control with real-time SCADA monitoring. Let's build the complete system in Structured Text.

Data Structures for Water Treatment

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

Raw water pumps are the first stage. They must sequence intelligently based on demand and protect against dry-running:

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 must track the incoming flow rate. A typical coagulant (alum or ferric chloride) dose is 10-50 mg/L depending on raw water 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 & Backwash Sequencing

Sand or GAC (Granular Activated Carbon) filters need periodic backwashing when differential pressure rises or after a set volume:

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

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 & Alarm Configuration

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

Complete Plant Coordinator

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

Summary

Water treatment PLC programming combines multiple control disciplines: pump sequencing for intake stations, PID control for chemical dosing and chlorination, state machines for filter backwash cycles, and SCADA integration for operator visibility and regulatory compliance. The key to success is modular design — each subsystem (pumps, dosing, filters, disinfection) is an independent function block that the plant coordinator orchestrates. This makes the system testable, maintainable, and scalable from a small well-water system to a full municipal treatment plant.

Duty Rotation That Equalises Run Hours, Not Just Positions

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

Never re-rank while pumps run. Promoting a colder pump mid-run stops a running machine to start another: an extra motor start, a check-valve slam, a transient nobody asked for. Latch the new order; apply it at the next natural stop.

Never accumulate run hours in a REAL. 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: every increment falls below half an ULP and rounds away. Count whole seconds in a RETAIN integer and convert 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 Is the Design Constraint

The level band is sized so the motor never exceeds its permitted starts per hour, and the worst case is not peak inflow: cycling is fastest when inflow sits near half of pump capacity, filling and emptying the band equally quickly. Allowable starts vary with frame size and pole count, so take the figure from the vendor; minimum-run and minimum-off timers backstop it.

The free win most stations never take: with pumps off, level rise rate times wet-well plan area is raw inflow — no flow meter. With one pump running, capacity is that inflow plus the drawdown rate. Trend it: 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; let the junction-box desiccant saturate and that tube blocks, after which the reading drifts with every weather front. Ultrasonics false-echo off foam and lose range under a grease mat. Keep a hardwired high-level float regardless.

Flow-Pacing: Feedforward Carries It, Trim Only Nudges

Deadtime from injection point to analyser — pipe transport, sample line, analyser response — runs to minutes, sometimes tens of them. Any trim loop with integral time near that deadtime hunts. Set integral several times the measured deadtime and let feedforward absorb load changes.

  • Turndown. Diaphragm metering pumps typically hold stated accuracy over roughly a 10:1 range — API 675 defines turndown as exactly that: the range over which steady-state accuracy is maintained. A plant turning down 20:1 overnight is dosing blind after midnight: size for the low end, or split across two pumps.
  • Prove flow, don't trust the command. A transmitter failed to zero drives dose to minimum while water still moves; a main isolated with the pump stroking injects neat chemical into a static pipe. Gate dosing on a hardwired flow switch.
  • Hypochlorite off-gasses. It decomposes, releasing oxygen that collects in the suction line and vapour-locks the diaphragm head: the pump strokes, the counter increments, nothing moves. The signature is residual sagging while SCADA shows 60%. Fit a degassing head with flooded suction, and trend delivered volume against stroke count. Strength decays with age and heat too, so the trim's steady-state output tracks day-tank age.
  • 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). The data path is itself a compliance component: a store-and-forward buffer surviving a SCADA reboot is not a nicety.

    That also indicts the backwash sequence above, which returns a filter straight from refill to service. Beds ripen: turbidity spikes for the first minutes of a run. Skip filter-to-waste and that spike lands in the clearwell and in the 15-minute record.

    Compression. Swinging-door compression is commonly enabled by default and tuned for storage economy, not for evidence. It is a slope-based algorithm bounded by a compression deviation — not a simple deadband — and widening that deviation on a compliance tag silently discards the four-minute excursion you were obliged to record.

    Calibration is not a process value. An analyser in its cal cycle holds, or reads its standard. Stamp the historian value bad-quality from the service status bit, or you average a fabricated low into the monthly return.

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