S7 Pump Runtime Rotation: Bubble Sort Algorithm Implementation

David Krause25 min read
S7-300SiemensTutorial / How-to
Licensed PE Working through this on a live machine? A Maine-licensed engineer can take it from here — included with IMD hardware, by the hour for everything else. Book an engineer

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.

Engineering rule of thumb: For 10 pumps and a 24 h sort interval, the one-shot bubble sort runs in less than 10 ms on any S7-300 CPU 315-2 PN/DP (6ES7315-2EH14-0AB0) or higher. For OB1 cycle times under 100 ms, or pump counts above 20, always use the cycle-stepped variant.

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:

  1. 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.
  2. 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.
  3. 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:

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.

Retentivity warning: On S7-300/400, runtime values are lost on power failure if the DB is not configured as retentive. Set the "Retain" attribute in the DB properties and configure the CPU's retentive memory area (HW Config → CPU → Retentive Memory) to cover the entire DB load range. The default retentive area on a CPU 315-2 PN/DP is 256 bytes; for a larger DB, increase the "Number of retentive memory bytes" parameter.

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):

  1. j=1: 1850 vs 1920 → no swap. Array unchanged.
  2. 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.
  3. j=3: 1920 vs 1995 → no swap.
  4. j=4: 1995 vs 1755 → swap. Index: 1, 3, 2, 5, 4, 6, 7, 8, 9, 10.
  5. j=5: 1995 vs 1980 → swap. Index: 1, 3, 2, 5, 4, 7, 6, 8, 9, 10.
  6. j=6: 1980 vs 1875 → swap. Index: 1, 3, 2, 5, 4, 7, 8, 6, 9, 10.
  7. j=7: 1875 vs 1900 → no swap.
  8. j=8: 1900 vs 1780 → swap. Index: 1, 3, 2, 5, 4, 7, 8, 6, 10, 9.
  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.

IDLE s_state = 0 INIT s_state = 1 COMPARE s_state = 2 END s_i < 9: loop back 45 cycles @ 100 ms ≈ 4.5 s i_triggerSort rising edge 1 cmp + 1 swap / cycle

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:

  1. Manual order takes effect when auto_mode = FALSE. The sequencer uses manual_order in place of sort_order.
  2. Pumps with in_manual = TRUE are excluded from rotation but still counted as available if requested by the demand count.
  3. A "Force re-sort" button on the HMI sets i_triggerSort = TRUE for one cycle.
  4. Reset hours button requires a confirmation prompt and a key-switch on the HMI to prevent accidental reset.
  5. Lead/lag pump indexes are highlighted in the HMI table with color codes: green for lead, yellow for lag, gray for trail.
  6. 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:

  1. 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] into rt_hours[1..10]. Trigger a sort. Verify sort_order reads [5, 9, 3, 1, 7, 8, 2, 10, 6, 4] (ascending runtime).
  2. 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.
  3. 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.
  4. Retentive test: Power-cycle the CPU (MRES is NOT needed; just toggle power). Verify that rt_hours values are preserved. If not, the DB is not properly marked retentive or the CPU's retentive area is too small.
  5. 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] = TRUE for pump 3; verify the sequencer skips it and starts pump 7 instead.
  6. 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).
  7. 24 h sort timer test: Temporarily set sort_interval = 0 to 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.

Back to blog