CHW-0009 — Chiller short-cycling
| Status | verified — engine e2ff2f8, cxf:fnv1a128:1d73386b26deb3b315e35f31e43ffbff, 2026-08-20 |
| Severity | 2 |
| Method | rule |
| Phase | 2 |
| Category | PROTECTIVE |
| Confidence | MEDIUM |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | CHW-0001, CHW-0007, CHW-0008 |
| Playbooks | chiller-efficiency |
| Source | PNNL-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 states | all 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:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
evaluation_window | 3600.0 | s | rate.delta | Trailing start-count window. LIBRARY_PRECEDENT: one hour is an executable observation window, not a universal manufacturer requirement. |
max_starts | 3.0 | 1/window | cntHigh.t | Allowed starts per window. ADOPTED_TUNABLE commissioning placeholder; strict comparison leaves exactly three clear and faults at four. |
count_scale | 60.0 | 1 | count.k | Derived 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
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
- Chiller oversized for shoulder-season or process load
- Staging/deadband or minimum on/off timers configured too narrowly
- Insufficient loop volume or thermal storage
- CHWST reset/control causing low-load setpoint overshoot
- Compressor, starter/drive, oil, flow, freeze, or safety trip and auto-reset
- Chattering or stale run proof and communication replay
- Multiple chiller statuses incorrectly ORed into one point
- 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_scaleis added to the brief’s parameter table. MovingAverage returns pulse rate; multiplying byevaluation_window/tickrecovers 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.
| Scenario | Description |
|---|---|
no_starts | A stopped machine produces no rising edges. |
first_sample_true_is_inert | Status true at engine initialization creates zero edge area and no counted start. |
one_normal_start | One start after a completed warm-up window stays below the three-start allowance. |
exactly_three_starts_clear | The strict comparison leaves exactly the commissioned allowance clear. |
half_open_window_boundary_clear | The fourth start arrives exactly one window after the first; that oldest pulse has left and only three remain. |
four_starts_fault | One observed start over the allowance raises the raw rolling-count verdict. |
oldest_start_ages_out | After four starts raise the verdict, it clears when the oldest contributing pulse leaves the trailing hour. |
status_chatter_counts_rising_edges | Eight status transitions are four starts, not eight; proof chatter is indistinguishable from real cycling to this graph. |
warmup_single_start_asserts_raw | Partial-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_raw | A burst during history warm-up raises the raw graph but remains host-NO_EVAL until one full window is available. |
reload_running_loses_prior_history | A fresh engine loaded while a chiller is already running cannot reconstruct starts that happened before reload. |
subtick_cycle_aliases_clear | A 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
}
]
}
]
}