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.
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_TYPETYPE 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_VARIF 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_TYPEFUNCTION_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_VARIF 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_VARpH_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_VARIntakePumps(
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.
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.