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

CHW-0009 — Chiller short-cycling

Statusverified — engine e2ff2f8, cxf:fnv1a128:1d73386b26deb3b315e35f31e43ffbff, 2026-08-20
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedCHW-0001, CHW-0007, CHW-0008
Playbookschiller-efficiency
SourcePNNL-29078, Building Re-Tuning Training Guide: Central Utility Plant Heating and Cooling Control Guide, PDF pp.90-91 (§10.1) — low-load inability to turn down can overshoot the internal setpoint and short-cycle a chiller; Library graph precedents PMP-0004, TOWER-0003, RTU-0001, and HW-0001 — rising-edge pulse integrated into a rolling event count; Library-authored threshold adaptation; the chiller manufacturer and plant sequence, not this source, define acceptable starts and minimum off time
Operating statesall plant states in which this individual chiller may legitimately start; seasonal shutdown with no starts is a valid clear result

Preconditions (host-enforced): Bind chiller_status to independent per-machine proof of capacity-producing operation, not availability, command, a fleet OR, or an enumeration that changes while continuously running. Status edges must represent real machine starts rather than communication loss or integration replay. The host reports NO_EVAL for the first evaluation_window after engine load/state reset because MovingAverage extrapolates an event pace while history fills. Acquire fast enough to observe the shortest real OFF/ON dwell, then evaluate on a fixed tick satisfying evaluation_window/63 <= tick < evaluation_window/(2*max_starts); defaults permit about 57.2 to under 600 s, with 60 s recommended. count_scale must equal evaluation_window/tick. Exclude approved exercise, tests, seasonal switchover, and maintenance. Commission max_starts and minimum on/off behavior against the chiller manufacturer’s limits and the actual staging sequence.

Points: chiller_status

Outputs:

  • yFault — True while the observed per-machine 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 is an executable observation window, not a universal manufacturer requirement.
max_starts3.01/windowcntHigh.tAllowed starts per window. ADOPTED_TUNABLE commissioning placeholder; strict comparison leaves exactly three clear and faults at four.
count_scale60.01count.kDerived coupling evaluation_window/evaluator_tick. The default 3600/60 is valid only at a 60 s fixed tick and must change with window or cadence.

Description

This rule counts starts of one chiller over a trailing window. PNNL identifies low-load inability to turn down as a common mechanism: the machine quickly overshoots its internal target and shuts off, then restarts when load returns. Unstable staging, narrow deadbands, missing storage, safety trips, or unreliable proof can create the same observable signature.

Detection Logic

start = rising edge of chiller_status
count = MovingAverage(start, evaluation_window) × count_scale

yFault = count > max_starts

CHW-0009 block graph

Logical.Edge counts OFF-to-ON proof transitions. The initialization pulse has zero area, but the partially filled moving-average window extrapolates a pace; the first complete window is therefore host-NO_EVAL. The fourth observed start inside the default trailing hour raises the immediate raw verdict, with no additional delay.

Possible Diagnoses

  1. Chiller oversized for shoulder-season or process load
  2. Staging/deadband or minimum on/off timers configured too narrowly
  3. Insufficient loop volume or thermal storage
  4. CHWST reset/control causing low-load setpoint overshoot
  5. Compressor, starter/drive, oil, flow, freeze, or safety trip and auto-reset
  6. Chattering or stale run proof and communication replay
  7. Multiple chiller statuses incorrectly ORed into one point
  8. Approved exercise/test sequence not host-gated

Energy Impact

PROTECTIVE and QUALITATIVE_ONLY. Short cycling can waste transient energy and accelerate component wear, but a Boolean start counter cannot quantify either. Use machine power, OEM start limits, and plant staging history host-side.

Emissions Impact

Scope 2, qualitative. Avoided transient electricity and premature component replacement are not inferable from status edges alone.

Deviations

  • The three-start limit is adopted, not source-transcribed. The OEM minimum on/off and starts-per-hour limits are authoritative for each machine.
  • count_scale is added to the brief’s parameter table. MovingAverage returns pulse rate; multiplying by evaluation_window/tick recovers count.
  • The first full window is NO_EVAL. Partial-window event pace and reload history loss are exposed in vectors rather than hidden.
  • The legal evaluator band is [evaluation_window/63, evaluation_window/(2×max_starts)). The lower edge protects the 64-sample ring; the upper edge preserves observability of the first integer count above a strict threshold. Real OFF/ON dwell may require faster acquisition still.
  • Confidence is MEDIUM rather than the brief’s proposed HIGH. Edge counting is direct, but the shipped count and status quality require commissioning.
  • A fleet OR is invalid. Two chillers can cycle independently while the OR remains continuously true, making every lag-machine start invisible.
  • CLU-06 is unchanged. Cycling relates to efficiency but does not share the cluster’s current efficiency trigger/fix semantics, so the cluster is not broadened in this slice.

Test Vectors

12 scenarios, clock step 60 s over 14400 s.

ScenarioDescription
no_startsA stopped machine produces no rising edges.
first_sample_true_is_inertStatus true at engine initialization creates zero edge area and no counted start.
one_normal_startOne start after a completed warm-up window stays below the three-start allowance.
exactly_three_starts_clearThe strict comparison leaves exactly the commissioned allowance clear.
half_open_window_boundary_clearThe fourth start arrives exactly one window after the first; that oldest pulse has left and only three remain.
four_starts_faultOne observed start over the allowance raises the raw rolling-count verdict.
oldest_start_ages_outAfter four starts raise the verdict, it clears when the oldest contributing pulse leaves the trailing hour.
status_chatter_counts_rising_edgesEight status transitions are four starts, not eight; proof chatter is indistinguishable from real cycling to this graph.
warmup_single_start_asserts_rawPartial-window extrapolation makes one early start look faster than three per hour; the host must report NO_EVAL for the first full window.
warmup_four_starts_assert_rawA burst during history warm-up raises the raw graph but remains host-NO_EVAL until one full window is available.
reload_running_loses_prior_historyA fresh engine loaded while a chiller is already running cannot reconstruct starts that happened before reload.
subtick_cycle_aliases_clearA complete off-on-off cycle between evaluator ticks is invisible, pinning the sampling obligation.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 14400
  },
  "scenarios": [
    {
      "name": "no_starts",
      "description": "A stopped machine produces no rising edges.",
      "inputs": {
        "chiller_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "first_sample_true_is_inert",
      "description": "Status true at engine initialization creates zero edge area and no counted start.",
      "inputs": {
        "chiller_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "one_normal_start",
      "description": "One start after a completed warm-up window stays below the three-start allowance.",
      "inputs": {
        "chiller_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_three_starts_clear",
      "description": "The strict comparison leaves exactly the commissioned allowance clear.",
      "inputs": {
        "chiller_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
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "half_open_window_boundary_clear",
      "description": "The fourth start arrives exactly one window after the first; that oldest pulse has left and only three remain.",
      "inputs": {
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 4920,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6120,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7320,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "four_starts_fault",
      "description": "One observed start over the allowance raises the raw rolling-count verdict.",
      "inputs": {
        "chiller_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
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5580,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5760,
          "to_s": 7740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7980,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "oldest_start_ages_out",
      "description": "After four starts raise the verdict, it clears when the oldest contributing pulse leaves the trailing hour.",
      "inputs": {
        "chiller_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
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 5520,
          "to_s": 7140,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 7380,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "status_chatter_counts_rising_edges",
      "description": "Eight status transitions are four starts, not eight; proof chatter is indistinguishable from real cycling to this graph.",
      "inputs": {
        "chiller_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
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 5040,
          "to_s": 7620,
          "equals": true
        }
      ]
    },
    {
      "name": "warmup_single_start_asserts_raw",
      "description": "Partial-window extrapolation makes one early start look faster than three per hour; the host must report NO_EVAL for the first full window.",
      "inputs": {
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 300,
            "value": true
          },
          {
            "t": 420,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 420,
          "to_s": 1080,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1260,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "warmup_four_starts_assert_raw",
      "description": "A burst during history warm-up raises the raw graph but remains host-NO_EVAL until one full window is available.",
      "inputs": {
        "chiller_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
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 1320,
          "to_s": 3780,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3960,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "reload_running_loses_prior_history",
      "description": "A fresh engine loaded while a chiller is already running cannot reconstruct starts that happened before reload.",
      "inputs": {
        "chiller_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 evaluator ticks is invisible, pinning the sampling obligation.",
      "inputs": {
        "chiller_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 10,
            "value": true
          },
          {
            "t": 20,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    }
  ]
}