Overview
Equalizing the operating hours of multiple pumps is a standard requirement in water distribution, wastewater lift stations, booster stations, irrigation headers, and process cooling loops. A controller must continuously track each pump's cumulative runtime, sort the pumps by accumulated hours, and rotate the lead/lag/trail positions on a fixed interval (typically 24 h) so the pump with the lowest runtime becomes the new lead on the next cycle.
Siemens S7 PLCs (S7-300, S7-400, S7-1200, S7-1500) do not ship with a ready-made sort-and-rotate-pumps-by-runtime block. The functionality must be built from standard sort algorithms - typically bubble sort or insertion sort - executed inside an SCL function block (FB) with persistent runtime counters stored in a retentive data block (DB). This article provides a complete field-proven implementation: the data block layout with byte-offset mapping, SCL source for both one-shot and cycle-stepped bubble sort variants, an STL fallback note for legacy STEP 7 V5.4 projects without SCL, integration with a pump sequencer, the 2:1 wear distribution strategy used in municipal water plants, HMI tag mapping for WinCC and TIA Portal, a 7-step commissioning procedure, a troubleshooting matrix with 12 fault symptoms, and a worked numerical example that traces the algorithm across multiple OB1 cycles.
Application Context: Pump Wear Distribution
When several pumps are operated in duty/standby rotation, three wear distribution strategies dominate in industrial practice. The choice drives the sort algorithm's weighting and the operator's maintenance schedule.
| Strategy | Ratio | Typical Use | Maintenance Profile | Sort Behavior |
|---|---|---|---|---|
| Equal wear | 1:1:1:1:1 | Booster stations with identical duty | All pumps reach overhaul at the same time | Sort by absolute runtime only |
| Staggered wear | 2:1 or 3:1 | Water/wastewater lift stations, raw water intake | One pump remains low-hour for emergency duty during overhauls | Sort by duty-weighted runtime |
| Fixed lead | N/A | Jockey/main pumps with a primary duty | Lead pump wears fastest; standby remains cold | No rotation; sort disabled |
The 2:1 strategy is the de-facto standard in municipal water plants. A typical 4-pump lift station runs a primary and a secondary pump; the secondary accumulates roughly half the runtime of the primary. When the primary needs maintenance, the secondary still has the bulk of its service life available. The rotation algorithm in this article supports both 1:1 and 2:1 strategies through a configurable duty-ratio field per pump.
Three operational constraints drive the design:
- Minimum run time: a pump that has been running for less than the configured minimum (typically 60 s) must not be stopped when the next pump is started, to avoid thermal cycling and contactor wear.
- Minimum off time: a recently stopped pump must rest for at least the configured minimum (typically 30 s) before restart, to prevent motor inrush damage.
- Equalization window: a 24 h rotation interval is the industry default; shorter intervals (4-6 h) are used in high-cycle applications like HVAC chilled water, longer intervals (weekly) in low-duty plants.
Prerequisites and S7 Platform Selection
All variants of the S7 family support the SCL source code in this article. The recommended platform depends on the number of pumps, sort frequency, and integration depth.
| Platform | Recommended CPU | MLFB / Order Number | Work Memory | Sort Interval | Notes |
|---|---|---|---|---|---|
| S7-300 | CPU 315-2 PN/DP | 6ES7315-2EH14-0AB0 | ~8 KB FB + DB + sequencer | 24 h | STEP 7 V5.6 or TIA Portal V16+ with legacy support |
| S7-300 (low cost) | CPU 314C-2 PN/DP | 6ES7314-6EH04-0AB0 | ~8 KB | 24 h | Integrated DI/DO; smallest CPU that fits the FB |
| S7-400 | CPU 412-2 PN | 6ES7412-2EK07-0AB0 | ~12 KB | 1 h | Use for high-pump-count plants (20+) |
| S7-1200 | CPU 1214C DC/DC/DC | 6ES7214-1AG40-0XB0 | ~6 KB | 24 h | Use TIA Portal V16+; SCL is the only practical language |
| S7-1200 (large) | CPU 1217C DC/DC/DC | 6ES7217-1AG40-0XB0 | ~6 KB | Any | For stations with 8-10 pumps and integrated I/O |
| S7-1500 | CPU 1511-1 PN | 6ES7511-1AK02-0AB0 | ~8 KB | Any | Use optimized DBs and symbolic access; faster SCL execution |
Software and firmware prerequisites:
- S7-300/400: STEP 7 V5.6 + S7-SCL V5.6 SP1 or higher; CPU firmware V3.3 or higher for CPU 31x, V6.0 or higher for CPU 41x.
- S7-1200: TIA Portal V16 Update 4 or higher; CPU firmware V4.4 or higher.
- S7-1500: TIA Portal V16 or higher; CPU firmware V2.8 or higher.
- For STL fallback: no additional package required, but only suitable for the one-shot variant.
External references for S7 platform configuration:
- S7-1200 Programmable Controller System Manual
- S7-1500 Automation System System Manual
- STEP 7 SCL Programming and Operating Manual
- S7-300 CPU 31xC and CPU 31x Operating Instructions
- S7-400 Automation System System Manual
Data Block Architecture and Memory Layout
Runtime counters must be retentive to survive power cycles and CPU STOP-RUN transitions. Create a global DB with the following structure. The example supports 10 pumps; scale the array bounds (e.g., 1..20) for different counts. Avoid array bounds starting at 0 - SCL convention is 1-based indexing and starting at 0 creates off-by-one errors in the sort comparison.
DATA_BLOCK "DB_PumpRuntime"
TITLE = 'Pump runtime and rotation state'
{ S7_retentive := 'true' } // STEP 7 V5.x attribute
VERSION : 0.1
STRUCT
// --- Runtime counters (hours, integer, retentive) ---
rt_hours : ARRAY[1..10] OF DINT; // Cumulative hours per pump
rt_hours_preset: ARRAY[1..10] OF DINT; // Last maintenance reset value
// --- Pump availability / mode ---
available : ARRAY[1..10] OF BOOL; // TRUE = ready, healthy
in_manual : ARRAY[1..10] OF BOOL; // TRUE = operator-locked
in_fault : ARRAY[1..10] OF BOOL; // TRUE = VFD or contactor fault
// --- Wear distribution (2:1 strategy) ---
duty_ratio : ARRAY[1..10] OF INT; // 100 = full duty, 50 = half
// --- Sequencer I/O ---
demand_count : INT; // 0..10 required pumps
active_count : INT; // Currently running count
demand_pv : REAL; // Process variable 0-100%
// --- 24 h sort timer ---
sort_interval : DINT; // Hours, default 24
sort_timer_h : DINT; // Elapsed hours since last sort
// --- Sort results (refreshed each interval) ---
sort_order : ARRAY[1..10] OF INT; // Pump index in rotation order
sort_busy : BOOL;
sort_progress : INT; // 0-100 percent
sort_error : BOOL;
sort_errorcode : INT; // 0=OK, 1=no pumps available
// --- Operator override ---
manual_order : ARRAY[1..10] OF INT;
auto_mode : BOOL; // TRUE = auto rotation
END_STRUCT;
END_DATA_BLOCK
Byte-offset table (S7-300/400 with non-optimized access; offsets in bytes from DB start):
| Offset (Byte.Bit) | Symbol | Type | Length | Use |
|---|---|---|---|---|
| 0.0 | rt_hours[1] | DINT | 4 bytes | Pump 1 cumulative hours |
| 4.0 | rt_hours[2] | DINT | 4 bytes | Pump 2 cumulative hours |
| 36.0 | rt_hours[10] | DINT | 4 bytes | Pump 10 cumulative hours |
| 40.0 | rt_hours_preset[1..10] | DINT | 40 bytes | Maintenance baseline |
| 80.0 | available[1..10] | BOOL | 10 bits / 2 bytes | Per-pump availability |
| 82.0 | in_manual[1..10] | BOOL | 10 bits / 2 bytes | Operator lock |
| 84.0 | in_fault[1..10] | BOOL | 10 bits / 2 bytes | Fault status |
| 86.0 | duty_ratio[1..10] | INT | 20 bytes | Per-pump duty weighting |
| 106.0 | demand_count | INT | 2 bytes | Setpoint pump count |
| 108.0 | active_count | INT | 2 bytes | Feedback running count |
| 110.0 | demand_pv | REAL | 4 bytes | Process variable |
| 114.0 | sort_interval | DINT | 4 bytes | Default 24 h |
| 118.0 | sort_timer_h | DINT | 4 bytes | Hours since sort |
| 122.0 | sort_order[1..10] | INT | 20 bytes | Rotation order |
| 142.0 | sort_busy | BOOL | 0.1 bit | Sort in progress |
| 142.1 | sort_progress | INT | 2 bytes | 0-100 percent |
| 144.0 | sort_error | BOOL | 0.1 bit | Sort failed |
| 144.1 | sort_errorcode | INT | 2 bytes | Error number |
| 146.0 | manual_order[1..10] | INT | 20 bytes | Operator order |
| 166.0 | auto_mode | BOOL | 0.1 bit | Auto/manual selector |
Total DB footprint: approximately 170 bytes. For TIA Portal S7-1200/1500, mark the entire DB as "Retain" (right-click the DB → Properties → Retain) and either disable "Optimized block access" for legacy absolute addressing, or use symbolic access throughout the project. Runtime values are stored as DINT (32-bit signed), giving a maximum of approximately 2.147 billion hours - well beyond equipment service life. For practical visualization, wrap the counter at 100,000 hours (about 11 years continuous duty) and reset on each overhaul.
Sort Algorithm Selection: Bubble vs Insertion vs Selection
All three classic comparison-based sorts solve the same problem: given an array of (runtime, index) pairs, return the indices in ascending order of runtime. The trade-offs matter for S7 because OB1 cycle time is typically limited to 100-300 ms and long sort passes can blow the watchdog.
| Algorithm | Worst-case Comparisons (n=10) | Worst-case Swaps | Best Case | Stable | Code Complexity | Best Use Case |
|---|---|---|---|---|---|---|
| Bubble sort | n*(n-1)/2 = 45 | 45 | O(n) with early-exit | Yes | Low | Small n, simple to step across OB1 |
| Insertion sort | n*(n-1)/2 = 45 | ~22 average | O(n) on nearly-sorted | Yes | Medium | Runtime typically ascending; minimal writes |
| Selection sort | n*(n-1)/2 = 45 | 9 | O(n^2) | No | Low | Minimize writes to retentive memory |
| Quicksort | O(n log n) ~ 34 | Variable | O(n log n) | No | High | n > 50, requires stack-based recursion in SCL |
For 10 pumps, bubble sort is the right choice: 45 comparisons at approximately 1 µs each in SCL run in well under 50 µs total. The real concern is not raw cycle time but the need to avoid a 50 ms sort pass during a critical I/O scan. The cycle-stepped variant below executes one comparison per OB1 pass, distributing the 45 iterations across 45 cycles and guaranteeing the watchdog never sees more than ~1 µs of additional load.
For pump counts of 20-30, the same cycle-stepped approach scales linearly. For 50+ pumps, switch to insertion sort with cycle-stepping - the average case is roughly half the comparisons, and the early-exit on nearly-sorted data (which is the realistic case for runtime data) finishes in O(n) time.
Insertion sort SCL snippet (drop-in replacement for the inner loop of the cycle-stepped bubble sort):
// Insertion sort, single-pass variant for 10 elements
FOR s_i := 2 TO 10 DO
s_temp := s_runtime[s_i];
s_tempIdx := s_index[s_i];
s_j := s_i - 1;
WHILE (s_j >= 1) AND (s_runtime[s_j] > s_temp) DO
s_runtime[s_j + 1] := s_runtime[s_j];
s_index[s_j + 1] := s_index[s_j];
s_j := s_j - 1;
END_WHILE;
s_runtime[s_j + 1] := s_temp;
s_index[s_j + 1] := s_tempIdx;
END_FOR;
Both algorithms share the same DINT comparison cost; the insertion variant halves the number of DINT writes (which is the retentive memory write cost) in the average case.
Worked Example: Sorting 10 Pumps by Runtime
Consider a 10-pump lift station with the following cumulative runtimes, populated manually in DB_PumpRuntime.rt_hours for a commissioning test:
| Pump Index | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| Runtime (h) | 1850 | 1920 | 1810 | 1995 | 1755 | 1980 | 1875 | 1900 | 1780 | 1955 |
After the bubble sort completes, the sort_order array contains the pump indices in ascending order of runtime:
| Position | 1 (Lead) | 2 (Lag) | 3 | 4 | 5 | 6 | 7 | 8 | 9 (Trail) | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| Runtime (h) | 1755 | 1780 | 1810 | 1850 | 1875 | 1900 | 1920 | 1955 | 1980 | 1995 |
| Pump Index | 5 | 9 | 3 | 1 | 7 | 8 | 2 | 10 | 6 | 4 |
With demand_count = 3, the sequencer starts pumps 5, 9, and 3 (the three lowest-hour pumps). The 24 h sort interval reorders again the next day, picking the new three lowest-hour pumps as the lead group.
For verification in the watch table, type the runtimes into DB_PumpRuntime.rt_hours[1..10], set i_triggerSort := TRUE for one cycle, and confirm DB_PumpRuntime.sort_order[1..10] reads 5, 9, 3, 1, 7, 8, 2, 10, 6, 4.
Step-by-step trace of pass 1 of the bubble sort (s_i = 1, comparing adjacent pairs):
- j=1: 1850 vs 1920 → no swap. Array unchanged.
- j=2: 1920 vs 1810 → swap. Runtime order: 1850, 1810, 1920, 1995, 1755, 1980, 1875, 1900, 1780, 1955. Index order: 1, 3, 2, 4, 5, 6, 7, 8, 9, 10.
- j=3: 1920 vs 1995 → no swap.
- j=4: 1995 vs 1755 → swap. Index: 1, 3, 2, 5, 4, 6, 7, 8, 9, 10.
- j=5: 1995 vs 1980 → swap. Index: 1, 3, 2, 5, 4, 7, 6, 8, 9, 10.
- j=6: 1980 vs 1875 → swap. Index: 1, 3, 2, 5, 4, 7, 8, 6, 9, 10.
- j=7: 1875 vs 1900 → no swap.
- j=8: 1900 vs 1780 → swap. Index: 1, 3, 2, 5, 4, 7, 8, 6, 10, 9.
- j=9: 1900 vs 1955 → no swap.
After pass 1, the largest value (1995) has bubbled to position 9. After 9 passes, the array is fully sorted. The cycle-stepped variant performs 1 comparison per OB1 call, so pass 1 alone consumes 9 OB1 cycles.
SCL Implementation: One-Shot Bubble Sort
This FB executes the entire sort in a single call. Use it on CPUs with OB1 cycle time ≥ 50 ms and where the sort is triggered infrequently (e.g., every 24 h from a time-of-day interrupt).
FUNCTION_BLOCK FB_PumpRuntimeSort_OneShot
TITLE = 'One-shot bubble sort for 10 pumps by runtime'
{ S7_block_optimization := 'FALSE' }
VERSION : 1.0
VAR_INPUT
i_runtime : ARRAY[1..10] OF DINT; // Input runtimes
i_available : ARRAY[1..10] OF BOOL; // Input availability
i_triggerSort : BOOL; // Rising edge starts sort
END_VAR
VAR_OUTPUT
o_sortedOrder : ARRAY[1..10] OF INT; // Pump indices, ascending runtime
o_busy : BOOL;
o_done : BOOL;
o_error : BOOL;
o_errorCode : INT; // 0=OK, 1=no pumps available
END_VAR
VAR
s_runtime : ARRAY[1..10] OF DINT;
s_index : ARRAY[1..10] OF INT;
s_i : INT;
s_j : INT;
s_temp : DINT;
s_tempIdx : INT;
s_edge : BOOL;
END_VAR
BEGIN
o_busy := FALSE;
o_done := FALSE;
o_error := FALSE;
o_errorCode := 0;
// Rising-edge detection
IF i_triggerSort AND NOT s_edge THEN
s_edge := TRUE;
// Validate: at least one pump available
s_j := 0;
FOR s_i := 1 TO 10 DO
IF i_available[s_i] THEN s_j := s_j + 1; END_IF;
END_FOR;
IF s_j = 0 THEN
o_error := TRUE;
o_errorCode := 1;
RETURN;
END_IF;
o_busy := TRUE;
// Initialize working copy
FOR s_i := 1 TO 10 DO
s_runtime[s_i] := i_runtime[s_i];
s_index[s_i] := s_i;
END_FOR;
// Bubble sort: ascending order with tie-break on index
FOR s_i := 1 TO 9 DO
FOR s_j := 1 TO 10 - s_i DO
IF (s_runtime[s_j] > s_runtime[s_j + 1])
OR ((s_runtime[s_j] = s_runtime[s_j + 1])
AND (s_index[s_j] > s_index[s_j + 1])) THEN
// Swap runtime
s_temp := s_runtime[s_j];
s_runtime[s_j] := s_runtime[s_j + 1];
s_runtime[s_j + 1] := s_temp;
// Swap index
s_tempIdx := s_index[s_j];
s_index[s_j] := s_index[s_j + 1];
s_index[s_j + 1] := s_tempIdx;
END_IF;
END_FOR;
END_FOR;
// Force unavailable pumps to the bottom of the order
s_i := 10;
WHILE s_i > 1 DO
IF NOT i_available[s_index[s_i]] AND i_available[s_index[s_i - 1]] THEN
s_tempIdx := s_index[s_i];
s_index[s_i] := s_index[s_i - 1];
s_index[s_i - 1] := s_tempIdx;
END_IF;
s_i := s_i - 1;
END_WHILE;
// Output
FOR s_i := 1 TO 10 DO
o_sortedOrder[s_i] := s_index[s_i];
END_FOR;
o_busy := FALSE;
o_done := TRUE;
ELSIF NOT i_triggerSort THEN
s_edge := FALSE;
END_IF;
END_FUNCTION_BLOCK
Call this FB once per OB1 cycle. The internal rising-edge logic ensures the sort executes exactly once per trigger pulse. Trigger the sort from a TON (on-delay timer) that accumulates 24 h of process time, or from a time-of-day interrupt (OB10) configured to fire daily at 02:00.
The tie-break on s_index ensures deterministic ordering when two pumps have identical runtimes (common after a fresh maintenance reset). Without the tie-break, the sort order is implementation-defined and can flip between cycles.
For legacy STEP 7 V5.4 sites without the SCL compiler, the same one-shot sort can be ported to STL using AR1/AR2 index registers and indirect DB addressing, but the resulting code is roughly 80 STL statements and is harder to maintain. SCL is strongly preferred where the compiler is available.
SCL Implementation: Cycle-Stepped Sort for OB1 Watchdog
The cycle-stepped variant executes one comparison per call. For 10 pumps, 45 comparisons are required, so the sort completes in 45 OB1 cycles - typically 45 × 100 ms = 4.5 seconds on a 100 ms cycle. The FB exposes its internal state through outputs so the HMI can show progress.
FUNCTION_BLOCK FB_PumpRuntimeSort_Stepped
TITLE = 'Cycle-stepped bubble sort for 10 pumps'
{ S7_block_optimization := 'FALSE' }
VERSION : 1.0
VAR_INPUT
i_runtime : ARRAY[1..10] OF DINT;
i_available : ARRAY[1..10] OF BOOL;
i_triggerSort : BOOL;
i_reset : BOOL;
END_VAR
VAR_OUTPUT
o_sortedOrder : ARRAY[1..10] OF INT;
o_busy : BOOL;
o_progress : INT; // 0-100 percent
o_error : BOOL;
o_errorCode : INT;
END_VAR
VAR
s_runtime : ARRAY[1..10] OF DINT;
s_index : ARRAY[1..10] OF INT;
s_i : INT;
s_j : INT;
s_temp : DINT;
s_tempIdx : INT;
s_state : INT; // 0=idle, 1=init, 2=compare, 3=finalize
s_edge : BOOL;
END_VAR
BEGIN
// Reset
IF i_reset THEN
s_state := 0;
o_busy := FALSE;
o_progress := 0;
o_error := FALSE;
o_errorCode := 0;
s_edge := FALSE;
RETURN;
END_IF;
// Rising-edge trigger
IF i_triggerSort AND NOT s_edge AND s_state = 0 THEN
s_edge := TRUE;
s_state := 1;
o_busy := TRUE;
o_progress := 0;
ELSIF NOT i_triggerSort THEN
s_edge := FALSE;
END_IF;
CASE s_state OF
1: // Init: copy input to working array
FOR s_i := 1 TO 10 DO
s_runtime[s_i] := i_runtime[s_i];
s_index[s_i] := s_i;
END_FOR;
s_i := 1;
s_j := 1;
s_state := 2;
2: // One comparison per call
IF s_j <= 10 - s_i THEN
IF (s_runtime[s_j] > s_runtime[s_j + 1])
OR ((s_runtime[s_j] = s_runtime[s_j + 1])
AND (s_index[s_j] > s_index[s_j + 1])) THEN
s_temp := s_runtime[s_j];
s_runtime[s_j] := s_runtime[s_j + 1];
s_runtime[s_j + 1] := s_temp;
s_tempIdx := s_index[s_j];
s_index[s_j] := s_index[s_j + 1];
s_index[s_j + 1] := s_tempIdx;
END_IF;
s_j := s_j + 1;
ELSE
s_j := 1;
s_i := s_i + 1;
END_IF;
// Progress: total comparisons 45, current count = (s_i-1)*(10-s_i)/2 + s_j
o_progress := (((s_i - 1) * (11 - s_i) DIV 2) + s_j) * 100 / 45;
IF s_i > 9 THEN
s_state := 3;
END_IF;
3: // Finalize: copy sorted indices to output
FOR s_i := 1 TO 10 DO
o_sortedOrder[s_i] := s_index[s_i];
END_FOR;
o_busy := FALSE;
o_progress := 100;
s_state := 0;
s_edge := FALSE;
ELSE
o_busy := FALSE;
o_progress := 0;
END_CASE;
END_FUNCTION_BLOCK
The state machine runs entirely inside the OB1 scan. Each call advances s_i and s_j by at most one step. The total OB1 load added per cycle is one DINT comparison and one conditional swap - well under 1 µs on any S7-300/400/1200/1500 CPU.
To minimize scan-time impact, attach the FB in OB1 right after the input image update (typically OB1 segment 1) and before any communication or HMI data blocks. On S7-1500, the optimizer may inline the FB; if it does, the call overhead drops to zero. On S7-300/400, the FB-to-instance-DB call adds approximately 5-10 µs per invocation.
Pump Sequencer Integration with 2:1 Wear Strategy
The sort output drives the pump start/stop sequencer. The sequencer reads the sorted pump order, then starts the first N pumps (where N is the demand count) and stops the trailing pumps in reverse order. The duty_ratio field scales each pump's effective hours for the 2:1 strategy.
FUNCTION_BLOCK FB_PumpSequencer
TITLE = 'Lead/lag sequencer with rotation'
VERSION : 1.0
VAR_INPUT
i_demandCount : INT; // Required number of pumps running
i_sortedOrder : ARRAY[1..10] OF INT; // From sort FB
i_available : ARRAY[1..10] OF BOOL;
i_manual : ARRAY[1..10] OF BOOL; // TRUE = operator lock
i_minRunTimeOut : TIME; // Minimum run time per start
END_VAR
VAR_OUTPUT
o_cmdRun : ARRAY[1..10] OF BOOL; // Start command
o_running : ARRAY[1..10] OF BOOL; // Feedback from contactor
o_leadPump : INT;
o_lagPump : INT;
END_VAR
VAR
s_runTimer : ARRAY[1..10] OF TIME;
s_k : INT;
s_count : INT;
END_VAR
BEGIN
// Increment run timers (OB1 cycle assumed; tune for actual OB1 time)
FOR s_k := 1 TO 10 DO
IF o_running[s_k] THEN
s_runTimer[s_k] := s_runTimer[s_k] + T#100ms;
ELSE
s_runTimer[s_k] := T#0s;
END_IF;
END_FOR;
// Reset commands
FOR s_k := 1 TO 10 DO
o_cmdRun[s_k] := FALSE;
END_FOR;
// Walk sorted order; start first N available, non-manual pumps
s_count := 0;
FOR s_k := 1 TO 10 DO
IF s_count >= i_demandCount THEN EXIT; END_IF;
IF i_available[i_sortedOrder[s_k]]
AND NOT i_manual[i_sortedOrder[s_k]] THEN
o_cmdRun[i_sortedOrder[s_k]] := TRUE;
s_count := s_count + 1;
IF s_count = 1 THEN o_leadPump := i_sortedOrder[s_k]; END_IF;
IF s_count = 2 THEN o_lagPump := i_sortedOrder[s_k]; END_IF;
END_IF;
END_FOR;
// Minimum run-time protection: never stop a pump before minRunTimeOut
FOR s_k := 1 TO 10 DO
IF o_running[s_k] AND NOT o_cmdRun[s_k] AND s_runTimer[s_k] < i_minRunTimeOut THEN
o_cmdRun[s_k] := TRUE; // Hold running
END_IF;
END_FOR;
END_FUNCTION_BLOCK
To implement the 2:1 wear distribution, multiply the runtime of "secondary" pumps by the duty ratio before feeding it to the sort. For example, a pump with 100 h actual runtime and duty_ratio = 50 reports an effective 50 h, keeping it preferred for the lead position. The runtime weighting biases the rotation while the sequencer continues to use the actual pump runtime for the hour meter and maintenance interval tracking.
Implementation in the sort FB (modified initialization):
// Weighted runtime for sort, with safety: ratio=0 gives 0 (sorts last)
FOR s_i := 1 TO 10 DO
IF i_duty_ratio[s_i] > 0 THEN
s_runtime[s_i] := (i_runtime[s_i] * 100) / i_duty_ratio[s_i];
ELSE
s_runtime[s_i] := 999999; // effectively last
END_IF;
s_index[s_i] := s_i;
END_FOR;
With this weighting, a primary pump (ratio 100) with 200 h reads as 200, while a secondary pump (ratio 50) with 100 h also reads as 200. They will share lead duty equally over time. A tertiary pump (ratio 33) with 100 h reads as 303, so it only leads when both primary and secondaries have exceeded that weighted threshold.
HMI, Operator Overrides, and Diagnostics
Typical HMI tags exposed to WinCC, TIA Portal HMI, or third-party SCADA via the S7 connection:
| Tag | DB Path (S7-300/400) | Type | Use |
|---|---|---|---|
| Runtime hours | DB_PumpRuntime.rt_hours[1..10] | DINT | Display per pump, 6-digit with units |
| Sort order | DB_PumpRuntime.sort_order[1..10] | INT | Display current lead/lag/trail order |
| Sort progress | FB_PumpRuntimeSort_Stepped.o_progress | INT | Bar graph during 4.5 s sort window |
| Auto mode | DB_PumpRuntime.auto_mode | BOOL | Toggle auto rotation vs manual |
| Manual order | DB_PumpRuntime.manual_order[1..10] | INT | Operator-entered order |
| Pump available | DB_PumpRuntime.available[1..10] | BOOL | Green/red status indicator |
| Demand count | DB_PumpRuntime.demand_count | INT | Numeric input/output, 0-10 |
| Sort error | DB_PumpRuntime.sort_error | BOOL | Red alarm banner on HMI |
| Active count | DB_PumpRuntime.active_count | INT | Running pump count, 0-10 |
Operator overrides should follow these rules:
- Manual order takes effect when
auto_mode = FALSE. The sequencer usesmanual_orderin place ofsort_order. - Pumps with
in_manual = TRUEare excluded from rotation but still counted as available if requested by the demand count. - A "Force re-sort" button on the HMI sets
i_triggerSort = TRUEfor one cycle. - Reset hours button requires a confirmation prompt and a key-switch on the HMI to prevent accidental reset.
- Lead/lag pump indexes are highlighted in the HMI table with color codes: green for lead, yellow for lag, gray for trail.
- Sort progress bar is only visible when
sort_busy = TRUE; auto-hides after completion.
For WinCC Professional / TIA Portal HMI, configure the connection as a named S7 connection with PUT/GET enabled on the CPU (under Properties → Communication → PUT/GET). The OPC UA server built into S7-1500 CPUs (firmware V2.8+) can also expose the DB tags directly without additional licensing, which is the preferred path for new installations per the S7-1500 system manual.
Verification, Commissioning, and Troubleshooting
Commission the rotation logic in this sequence:
-
DB inspection: In STEP 7 / TIA Portal, open a watch table on
DB_PumpRuntime. Manually populate values [1850, 1920, 1810, 1995, 1755, 1980, 1875, 1900, 1780, 1955] intort_hours[1..10]. Trigger a sort. Verifysort_orderreads [5, 9, 3, 1, 7, 8, 2, 10, 6, 4] (ascending runtime). - Cycle time check: Add the FB to OB1, online → "Block consistency" or "Cycle time", measure OB1 cycle time before/after. The cycle-stepped variant should add < 1 ms; the one-shot variant should add < 10 ms on S7-300 CPU 315.
- OB1 watchdog: Set the OB1 maximum cycle time to 500 ms in HW Config (CPU → Properties → Cycle/Clock Memory → Maximum Cycle Time). If the one-shot sort trips the watchdog, switch to the cycle-stepped FB.
-
Retentive test: Power-cycle the CPU (MRES is NOT needed; just toggle power). Verify that
rt_hoursvalues are preserved. If not, the DB is not properly marked retentive or the CPU's retentive area is too small. -
End-to-end test: Force demand = 3 via the watch table. Verify the three pumps with the lowest runtime start. Increase demand to 5. Verify the two additional pumps start in order. Set
in_manual[3] = TRUEfor pump 3; verify the sequencer skips it and starts pump 7 instead. -
2:1 wear test: Set
duty_ratio[1] = 100,duty_ratio[2] = 50, all others = 100. With pump 1 at 200 h and pump 2 at 100 h, both should appear at the top of the sort order (effective runtime 200 h each). -
24 h sort timer test: Temporarily set
sort_interval = 0to force a sort every OB1 cycle (1 h simulation compresses to seconds). Verify the order changes as runtimes change.
| Symptom | Root Cause | Fix |
|---|---|---|
| Sort order is constant, not rotating |
auto_mode = FALSE or runtime counters not incrementing |
Check auto_mode tag; verify FB_PumpRuntimeSort is called each OB1; verify the run-hour counter FB is updating rt_hours
|
| OB1 cycle time jumps to > 300 ms during sort | One-shot sort + large n, or infinite loop in inner loop | Switch to cycle-stepped FB; verify array bounds are [1..n] not [0..n-1] |
| Runtime hours reset on power cycle | DB not marked retentive | Set {S7_retentive := 'true'} in DB source, configure CPU retentive area to cover DB load range |
| Pump starts but immediately stops |
minRunTimeOut not respected or feedback broken |
Verify o_running is wired to the actual contactor feedback (not the start command); check that the feedback I/O is mapped correctly in HW Config |
| Sort result is wrong: pump with highest hours leads | Sort direction is descending, or duty_ratio applied incorrectly |
Verify sort comparison is > not <; check duty_ratio scaling formula (multiply by 100, not divide) |
| DB_PumpRuntime cannot be edited in TIA Portal | Optimized block access prevents absolute addressing | Disable "Optimized block access" in DB properties, or use symbolic addressing only throughout the project |
| SF (system fault) LED on CPU after sort | DB length overflow due to wrong array bounds | Check array indices; for n=10, array bounds must be [1..10], not [0..9]; check SCL compiler warnings for implicit bounds conversion |
| Sort runs continuously without stopping |
i_triggerSort stuck high; rising-edge logic bypassed |
Check trigger source; if using a tag from another FB, ensure it is reset after sort completion |
| Same two pumps always lead, never rotate | Other pumps returning available = FALSE due to interlock |
Check available[1..10] in watch table; check upstream interlocks (VFD fault, low suction pressure, high discharge pressure) |
| Sort order differs from expected after commissioning reset | Runtime counters populated with identical values, tie-break by index | Expected behavior; tie-break ensures pump 1 leads when all runtimes are equal. Reset to non-equal values to verify |
| HMI shows wrong pump order | Wrong tag address or wrong array dimension | Verify HMI tag DB path matches actual DB number; check that the HMI is reading sort_order not manual_order when in auto mode |
| Sort takes longer than 4.5 s on cycle-stepped FB | OB1 cycle time > 100 ms, or FB being skipped | Measure OB1 actual cycle time; verify the FB is called in OB1 segment 1, not in a low-priority OB that runs only every 100 ms |
For additional reference material, see the S7-SCL Programming Manual (section 6 covers CASE/WHILE/FOR constructs) and the S7-300 CPU 31xC Operating Instructions for retentive memory configuration. The S7-1200 system manual covers the equivalent retentive configuration in TIA Portal under PLC → Properties → Retain.
Frequently Asked Questions
How many pumps can the bubble sort handle in one OB1 cycle on S7-300?
For n = 10, the one-shot sort adds roughly 5-8 ms on a CPU 315-2 PN/DP, well within a 100 ms OB1. For n = 50, expect 80-150 ms - too long for one cycle. Switch to the cycle-stepped FB for n > 20, or reduce OB1 to 200 ms and use the one-shot FB for n up to 30.
Should I use DINT or REAL for runtime hours?
Use DINT. REAL introduces rounding error after a few thousand hours, and the sort comparison is faster on integers. Convert to floating-point only at the HMI for display (DINT to REAL with implicit cast in the HMI tag or via an explicit NORM_X / SCALE_X function block).
How do I make the runtime counters survive a CPU STOP-RUN transition?
On S7-300/400, mark the DB as retentive in the DB source ({S7_retentive := 'true'}) and reserve bytes in the CPU's retentive memory area (HW Config → CPU → Retentive Memory). On S7-1200/1500, set the DB property "Retain" to "Set in IDB" or use the "Retain" attribute on individual tags. Avoid MRES (memory reset) after a real power cycle - MRES clears all retentive data.
What is the correct way to handle a pump that is locked out for maintenance?
Set the available[Idx] := FALSE tag. The sort FB then forces that pump to the bottom of the order, and the sequencer will skip it when starting new pumps. The runtime counter continues to freeze, so when the pump returns to service, its hours are unchanged and it will be picked up correctly on the next sort. Alternatively, use in_manual[Idx] := TRUE if the pump is in service but the operator wants to lock it to a specific position.
Can I trigger the sort from a time-of-day interrupt (OB10) instead of a 24 h timer?
Yes. Configure OB10 with execution time 02:00 daily, set the period to "Daily" in HW Config or TIA Portal (CPU → Properties → Time-of-Day Interrupts), and call the sort FB with i_triggerSort := TRUE inside OB10. The 02:00 trigger is conventional in municipal plants because the demand curve is usually low overnight, minimizing sort-induced wear transients. On S7-1200/1500, use a hardware interrupt or cyclic OB (OB30-OB38) if OB10 is not available on the CPU variant.