Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

PMP-0004 — Pump short-cycling

Statusverified — engine e2ff2f8, cxf:fnv1a128:33ee395683a57a95cc32a753cedc0482, 2026-08-20
Severity3
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedPMP-0003, VFD-0004
Playbooksvfd-pump-faults
SourceDOE/Hydraulic Institute, Improving Pumping System Performance: A Sourcebook for Industry, 2nd ed., PDF p.50 / printed p.48 — repeated starts wear controllers, contacts, seals, and bearings; Library-authored executable adaptation; no cited source publishes a portable starts-per-hour limit for every hydronic pump; Library graph precedents TOWER-0003, RTU-0001, HW-0001, and HP-0002 — rising-edge pulse integrated into a rolling event count
Operating statesall plant states in which this individual pump may legitimately be started; seasonal lockout with no starts is a valid clear result

Preconditions (host-enforced): Bind pump_status to independent per-pump proof of actual motor operation, not a command or an OR across a lead/lag fleet. Status transitions must represent real starts rather than communication loss, COV replay, or a changing software encoding. The host must report NO_EVAL for the first evaluation_window after engine load or state reset: the MovingAverage publishes an extrapolated pace while its window fills, so even one early start can raise the raw graph. Acquire fast enough to observe the shortest OFF/ON cycle, then evaluate on a fixed tick in the legal 57.2–360 s band at the defaults; 60 s is recommended and count_scale must equal evaluation_window/tick. Exclude approved exercise, commissioning, and functional-test sequences. When any obligation is unmet the verdict is NO_EVAL, not healthy.

Points: pump_status

Outputs:

  • yFault — True while the observed rising-edge count is strictly above max_starts in the trailing evaluation_window

Parameters:

NameDefaultUnitCXF pathDescription
evaluation_window3600.0srate.deltaTrailing start-count window. LIBRARY_PRECEDENT: one hour follows the verified short-cycling family, not a universal pump requirement. Retuning it also requires retuning count_scale.
max_starts4.01/windowcntHigh.tAllowed starts per evaluation_window. ADOPTED_TUNABLE commissioning placeholder; the pump/motor manufacturer and plant sequence are authoritative. The strict comparison leaves exactly four clear and faults at five.
count_scale60.01count.kDerived coupling evaluation_window/evaluator_tick. The default is 3600/60 and is correct only at a 60 s fixed tick; update it whenever the window or tick changes.

Description

This rule reports an individual hydronic pump starting too often. Repeated starts stress the motor, starter or drive, coupling, bearings, and seals, and usually point to unstable staging, a narrow control deadband, insufficient system storage, or unreliable proof. The signature is direct—observed starts per rolling hour—but the cause and acceptable count remain pump- and sequence-specific.

Detection Logic

start  = rising edge of pump_status
count  = MovingAverage(start, evaluation_window) × count_scale
yFault = count > max_starts

PMP-0004 block graph

Logical.Edge counts OFF-to-ON proof transitions only. Its initialization pulse has zero area at the first tick, but the partially filled moving-average window is an extrapolated event pace; the first full window is therefore host-gated. The comparison is strict and has no added persistence: the fifth observed start inside the default trailing hour raises the raw verdict. count_scale is load-bearing because the moving average integrates one-tick pulses.

Possible Diagnoses

  1. Differential-pressure or temperature deadband set too narrowly
  2. Lead/lag staging with inadequate minimum on/off times
  3. Oversized pump or insufficient hydronic buffer at low load
  4. VFD/control-loop hunting that repeatedly crosses the run threshold
  5. Overload, safety, or drive fault repeatedly tripping and auto-resetting
  6. Chattering contactor, current switch, auxiliary contact, or communication
  7. Approved exercise/test sequence not excluded by the host

Energy Impact

PROTECTIVE and QUALITATIVE_ONLY. Short cycles spend proportionally more time accelerating and less time delivering stable flow, but this Boolean rule cannot price that loss. The stronger value is avoided motor, starter/drive, seal, and bearing wear. Size the opportunity host-side from motor power, observed starts, and manufacturer start limits.

Emissions Impact

Scope 2, qualitative. Avoiding unnecessary acceleration and premature component replacement reduces electrical and embodied emissions, but neither term is computable from run proof alone.

Deviations

  • The four-start limit is adopted, not source-transcribed. Pump and motor manufacturers publish application-specific limits; 4/window is an executable commissioning placeholder and must be reviewed per asset.
  • count_scale is added to the planning table. The engine’s continuous-time MovingAverage returns a pulse rate, so evaluation_window/tick is required to recover an event count. Leaving 60 at a 300 s tick reports five times too many.
  • No alarm delay is copied from the sibling cards. The PR brief defines yFault = count > max_starts; adding TOWER-0003’s persistence would change the event-window semantics and introduce an unrequested parameter.
  • The first full window is NO_EVAL. Partial-window extrapolation is pinned in vectors rather than hidden; model reload also loses all prior start history.
  • The evaluator band is [evaluation_window/63, evaluation_window/(2×(max_starts+1))). The lower bound protects the 64-sample MovingAverage ring; the upper bound keeps the first integer count above the strict threshold observable. Defaults yield 57.2–360 s, with 60 s recommended. The evaluator must also resolve the shortest real OFF/ON dwell.
  • Confidence is MEDIUM rather than the brief’s proposed HIGH. Rising-edge detection is direct, but the shipped start limit has no portable source and a status point can be a proof/communications artifact until commissioned.
  • No Pump Delivery Failure cluster is added. Cycling has no single trigger whose repair should clear the mutually different delivery/proof signatures.

Test Vectors

13 scenarios, clock step 60 s over 14400 s.

ScenarioDescription
no_startsA stopped pump produces no rising edges and stays clear.
first_sample_true_is_inertEdge emits at initialization against its false prestate, but the zero-duration first tick contributes no moving-average area.
one_normal_startOne post-warm-up start is well below the adopted four-start allowance.
exactly_four_starts_clearThe strict comparison leaves exactly max_starts clear in a completed rolling window.
half_open_window_boundary_clearThe fifth observed start arrives exactly one window after the first; the oldest pulse has left the trailing window and the count remains four.
spread_starts_never_exceedFive total starts spread far enough apart never place more than four inside one completed trailing hour.
five_starts_faultOne start over the allowance raises the raw rolling-count verdict after warm-up.
oldest_start_ages_outFive starts raise the verdict; when the oldest pulse leaves the trailing hour, the count returns to four and clears.
status_chatter_counts_only_risesTen status changes are five starts, not ten; the rising-edge primitive counts only OFF-to-ON transitions.
warmup_rate_raw_assertsFive early starts can make the partial-window extrapolated pace exceed the threshold; the card requires host NO_EVAL for the first full window.
warmup_single_start_asserts_rawEven one early real start is extrapolated above four per hour until enough partial-window time elapses; host warm-up gating is mandatory.
reload_running_then_real_edgeA pump already true at model load does not create count area; a later real restart is counted once and remains below the limit.
subtick_cycle_aliases_clearA complete OFF-ON-OFF cycle between 60-second evaluator ticks is invisible; acquisition must resolve the shortest cycle.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 14400
  },
  "scenarios": [
    {
      "name": "no_starts",
      "description": "A stopped pump produces no rising edges and stays clear.",
      "inputs": {
        "pump_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "first_sample_true_is_inert",
      "description": "Edge emits at initialization against its false prestate, but the zero-duration first tick contributes no moving-average area.",
      "inputs": {
        "pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "one_normal_start",
      "description": "One post-warm-up start is well below the adopted four-start allowance.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 6000,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "exactly_four_starts_clear",
      "description": "The strict comparison leaves exactly max_starts clear in a completed rolling window.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4320,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 4920,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6120,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "half_open_window_boundary_clear",
      "description": "The fifth observed start arrives exactly one window after the first; the oldest pulse has left the trailing window and the count remains four.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4500,
            "value": true
          },
          {
            "t": 4620,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 6300,
            "value": true
          },
          {
            "t": 6420,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7320,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "spread_starts_never_exceed",
      "description": "Five total starts spread far enough apart never place more than four inside one completed trailing hour.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4560,
            "value": true
          },
          {
            "t": 4680,
            "value": false
          },
          {
            "t": 5520,
            "value": true
          },
          {
            "t": 5640,
            "value": false
          },
          {
            "t": 6480,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 7440,
            "value": true
          },
          {
            "t": 7560,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "five_starts_fault",
      "description": "One start over the allowance raises the raw rolling-count verdict after warm-up.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4320,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5160,
            "value": true
          },
          {
            "t": 5280,
            "value": false
          },
          {
            "t": 5640,
            "value": true
          },
          {
            "t": 5760,
            "value": false
          },
          {
            "t": 6120,
            "value": true
          },
          {
            "t": 6240,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6060,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6240,
          "to_s": 7740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7860,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "oldest_start_ages_out",
      "description": "Five starts raise the verdict; when the oldest pulse leaves the trailing hour, the count returns to four and clears.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4320,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 4920,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6120,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 6120,
          "to_s": 7140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7320,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "status_chatter_counts_only_rises",
      "description": "Ten status changes are five starts, not ten; the rising-edge primitive counts only OFF-to-ON transitions.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          },
          {
            "t": 4260,
            "value": false
          },
          {
            "t": 4440,
            "value": true
          },
          {
            "t": 4500,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4740,
            "value": false
          },
          {
            "t": 4920,
            "value": true
          },
          {
            "t": 4980,
            "value": false
          },
          {
            "t": 5160,
            "value": true
          },
          {
            "t": 5220,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 5280,
          "to_s": 7620,
          "equals": true
        }
      ]
    },
    {
      "name": "warmup_rate_raw_asserts",
      "description": "Five early starts can make the partial-window extrapolated pace exceed the threshold; the card requires host NO_EVAL for the first full window.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 360,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 660,
            "value": false
          },
          {
            "t": 900,
            "value": true
          },
          {
            "t": 960,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          },
          {
            "t": 1260,
            "value": false
          },
          {
            "t": 1500,
            "value": true
          },
          {
            "t": 1560,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 1620,
          "to_s": 3840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3960,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "warmup_single_start_asserts_raw",
      "description": "Even one early real start is extrapolated above four per hour until enough partial-window time elapses; host warm-up gating is mandatory.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 420,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 420,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1020,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "reload_running_then_real_edge",
      "description": "A pump already true at model load does not create count area; a later real restart is counted once and remains below the limit.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 600,
            "value": false
          },
          {
            "t": 4200,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "subtick_cycle_aliases_clear",
      "description": "A complete OFF-ON-OFF cycle between 60-second evaluator ticks is invisible; acquisition must resolve the shortest cycle.",
      "inputs": {
        "pump_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 10,
            "value": true
          },
          {
            "t": 20,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    }
  ]
}