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

RTU-0001 — Compressor short-cycling

Statusverified — engine e2ff2f8, cxf:fnv1a128:2a5f66097f97bf8154323abae2e36ded, 2026-08-17
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedRTU-0002, RTU-0010, RTU-0011, HP-0007
Playbooksrtu-compressor-refrigerant
SourceHVAC FDD Reference v1.0 §11, RTU-0001; Albayati et al. 2023; Ebrahimifakhar et al. 2020
Operating statesall active modes (any cooling or heating call)

Preconditions (host-enforced): The host must report NO_EVAL for the first count_window (1 h) after engine start: while the moving average’s window fills, its divisor is elapsed time, so the output is an extrapolated rate rather than a completed-hour count. Unlike AHU-0004 this gate is load-bearing — alarm_delay (15 min) is shorter than count_window, so delayOnInit does not cover the warm-up window and two starts inside the first few minutes can reach a verdict (warmup_rate_asserts pins it). The unit must be enabled and calling for cooling or heating: a compressor idle because there is no load produces zero starts, and reporting that as healthy cycling is the opposite of information. comp_status must be bound per compressor — on a two-compressor unit the OR of both statuses hides every start that happens while the other circuit is already running, and undercounts the cycling of each. Host tick interval must lie in [57.2 s, 300 s) with count_scale set to match (see Deviations). When any gate is unmet the verdict is NO_EVAL, not healthy.

Points: comp_status

Outputs:

  • yFault — True while the number of compressor starts in the trailing count_window has stayed above max_starts_per_hour for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
max_starts_per_hour6.01/hcntHigh.tStarts per hour above which cycling counts as short-cycling rather than load-following; the reference’s ceiling, equivalent to a 10-minute minimum interval between starts
count_window3600.0srate.deltaTrailing window the starts are counted over (1 h). It also fixes the units of max_starts_per_hour; a host that shortens it must retune count_scale with it and read max_starts_per_hour as starts per window rather than per hour
count_scale60.01count.kRescales the moving average of the one-tick pulse train back into a start count: k = count_window / host tick interval in seconds. The default 60.0 is 3600/60, correct only at a 60 s tick; a host on a different tick MUST retune this or every count is wrong by the ratio of the two intervals
alarm_delay900.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (15 min)

Description

A compressor starting ten times an hour is not following load. Every start draws locked-rotor current through windings that have not cooled, restarts against a head pressure that has not equalized, and pumps oil out of the sump faster than the return line brings it back. The efficiency loss is modest — the reference puts it at 3–5%, the cost of running the first minutes of every cycle before the coil reaches steady state — but the mechanical damage is what costs money: a compressor worn out early is $2,000–$8,000 plus the days the space spends uncooled, which is why this card is PROTECTIVE rather than an efficiency rule. Short-cycling is a symptom, not a root cause; the rule reports that the compressor is being asked to start too often and the service call decides why.

Detection Logic

start  = rising edge of comp_status                       one tick wide
count  = MovingAverage(start, count_window) × count_scale  starts in the trailing hour
yFault = count > max_starts_per_hour, sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

RTU-0001 block graph

Logical.Edge emits u ∧ ¬pre(u), one tick wide, on every OFF→ON transition — stops and run durations are not counted. Reals.MovingAverage is a continuous-time integral mean, so a one-tick pulse of height 1.0 encloses one tick interval of area and n starts inside the window give rate = n · dt / count_window; multiplying by count_scale = count_window / dt recovers n. That makes count_scale a function of the host’s tick interval, and this rule is tighter about the tick than the rest of the library — see the first three Deviations before deploying. The comparison is strict, so exactly six starts an hour reads clear and seven alarms; on an integer-valued count that boundary is unambiguous. persist then requires the count to stay above the ceiling for 15 minutes — two to three more starts on a unit already cycling hard — which rides out a defrost sequence or a one-off pressure trip without leaving a compressor tearing itself apart for an hour. delayOnInit = true holds that window across a controller restart.

Possible Diagnoses

  1. Thermostat or controller differential set too small — the call is satisfied within a minute or two and restarts as soon as the space drifts back
  2. Equipment oversized for the load: at part load the unit can only meet the call by cycling, and no setting adjusts it
  3. Low refrigerant charge — suction pressure falls to the cutout every cycle and the low-pressure switch does the cycling
  4. Defective run capacitor: the compressor stalls on start and drops out on thermal overload
  5. Iced or fouled evaporator coil starving the suction side (RTU-0002 sees the same coil from the airside)
  6. Control board or contactor fault chattering the compressor output

Energy Impact

PROTECTIVE, MEDIUM confidence, QUALITATIVE_ONLY. The rule sees one boolean and cannot price a start, so there is no waste term computable from its inputs. Size the opportunity host-side from cycling hours × rated compressor power × the reference’s 3–5% efficiency penalty, and treat the $2,000–$8,000 avoided compressor replacement as the larger term — that probabilistic term is why the fault is severity 2. MEDIUM confidence: the mechanism is not in doubt and the sources (Albayati et al. 2023; Ebrahimifakhar et al. 2020) are field studies of packaged-unit faults, but the efficiency figure depends on cycle length and ambient conditions, which this rule does not measure. Cooling-dominant.

Emissions Impact

Scope 2, QUALITATIVE_EMISSIONS, MEDIUM confidence. AHU-0001’s convention gives a PROTECTIVE fault with no emitting stream scope “N/A”; this card does not qualify, because the 3–5% efficiency loss is electricity a compressor actually draws. The larger emissions term is indirect: a compressor replaced years early carries the embodied carbon of a new compressor plus its refrigerant charge. Avoided-emissions basis: N/A.

Deviations

  • This rule needs a faster tick than the rest of the library, and the reason is Nyquist. A start is visible only if the compressor is seen OFF on one tick and ON on the next, so the fastest observable cycling is 1800/dt starts per hour; at the library’s usual 300 s tick that ceiling is exactly 6/h — the threshold itself — and the rule could never fire. The default count_scale is therefore 60.0 (a 60 s tick) rather than AHU-0004’s 12.0. Combined with the ring floor below, a legal deployment has 57.2 s ≤ dt < 300 s; 60 s is the recommended value and the only one these vectors have exercised.
  • count_scale is coupled to the host’s tick interval and the failure is silent. k = count_window / dt, so a host ticking every 120 s must set count_scale to 30.0; left at 60.0 it reports double the true count and alarms on four starts an hour. AHU-0004’s deployment constraint verbatim.
  • Minimum tick interval, from the moving average’s ring. Each MovingAverage keeps a fixed 64-checkpoint ring and drops the oldest in-window sample past that. The retained window holds count_window/dt + 1 checkpoints, so dt ≥ 3600/6357.2 s. (AHU-0004 quotes delta/64 = 56.25 s, which omits the boundary checkpoint; neither card’s tick is near it.)
  • min_run_time is not in the graph. The reference lists it as a 5-minute tunable but its printed equation is comp_starts_per_hour > max_starts_per_hour and nothing else, and the starts-per-hour ceiling subsumes the protective intent: ten 2-minute runs is both a min-run-time violation and 10 starts an hour. A site wanting the stricter per-cycle test can add a companion rule.
  • Rolling count built from a moving average, because the block set has no windowed counter. Integers.OnCounter counts monotonically from a reset, so a trailing-hour count would need a host-driven hourly reset — a tumbling count whose verdict depends on where the hour boundary fell. AHU-0004’s trade, taken again.
  • Startup artifact (a): a spurious first-tick pulse, which costs nothing. Logical.Edge compares u against pre_u_start on the first tick, so a unit already running at load registers a start at t = 0. It encloses no area (dt is zero on the first tick) and never reaches the count, so pre_u_start is written explicitly as false and not exposed as a card parameter.
  • Startup artifact (b): the first hour reads as a rate, and here it can reach a verdict. While t < count_window the moving average divides by elapsed time, so two starts in the first three minutes read as 40/h — the pace, extrapolated. Unlike AHU-0004, alarm_delay (15 min) is shorter than the window, so that rate can assert (warmup_rate_asserts pins it). The host NO_EVAL precondition for the first count_window is not optional.
  • Strict > on a discrete count. Exactly six starts an hour is clear and seven alarms, the reference’s > max_starts_per_hour read literally. The boundary is exact in IEEE-754: 60.0 × (6 × 60 / 3600) evaluates to precisely 6.0, so the six-start case is a real pin and not a near-miss.
  • The counting window is half-open. rate compares the accumulated integral now against its value one count_window ago, so a start exactly count_window old has just left the window. The reference is silent; it matters only on the threshold and it errs toward silence.
  • How long a crossing survives is count_window minus the span of the starts that caused it. Seven starts packed into ten minutes hold cntHigh for nearly an hour and always alarm; seven spread across 54 minutes hold it for 300 s and never do — same starts per hour, opposite verdicts, and the difference is not visible in the printed equation. What the rule reports is cycling sustained above the ceiling, not every excursion through it.
  • The reference tags this fault for both RTU and HP. This card is the RTU-family instance (AHU-0025 precedent); the heat-pump sibling would restate the graph against a heat-pump compressor status and would have to say something about defrost cycles, which are starts that mean nothing is wrong.
  • Severity 2 (high), phase 2, method rule, and the tunable defaults are the reference’s chapter 11 card; its §5.8.3 index corroborates and carries no severity column. g36: null — PNNL/research-derived, not a G36 §5.16.14 clause.
  • Operating states are declared, not gated: the reference marks the fault applicable in every active mode, and the graph has nothing to exclude.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a count already above the ceiling at load waits out the full 15 minutes instead of alarming on the first tick after a restart.

Notes

Bind comp_status per compressor and deploy one instance per circuit, as the RTU point dictionary requires. An OR across two circuits undercounts: a lag start while the lead is already running never moves the signal, so a unit whose lead runs continuously can cycle its lag circuit all afternoon and read healthy.

Remediation follows the rtu-compressor-refrigerant playbook — thermostat differential, then charge, then the run capacitor — confirming resolution at fewer than 6 starts/hr with a 5-minute minimum on-time over 48 hours. The one remote check worth doing before the truck roll is diagnosis 1: if the cooling call is satisfied within a minute or two of every start, widen the differential. Firing together with RTU-0002 points at the coil rather than the controls (diagnosis 5); firing alone points at the differential or the charge.

Test Vectors

10 scenarios, clock step 60 s over 14400 s.

ScenarioDescription
three_starts_per_hourReference vector: normal cycling, three starts an hour. The compressor is idle for the first 20 minutes, then runs 8 minutes and rests 12 — a 20-minute cycle, twice the 10-minute minimum interval the 6/h ceiling encodes. The rolling count holds at 3.0 for the whole run, including the warm-up window, where three starts an hour also happens to be the extrapolated pace
single_long_runReference vector: one start, one long run. The compressor starts at t=1200 and runs to the horizon. count peaks at 3.0 (the warm-up extrapolation of one start in 20 minutes) and decays from there; one start can never reach the 6/h ceiling
startup_pulse_is_inertStartup artifact: comp_status is already true when the rule loads, so Logical.Edge sees u true against pre_u_start = false and emits a rising edge on tick 0. It costs nothing — MovingAverage integrates u*dt and dt is zero on the first tick, so the pulse encloses no area and count stays at 0.0 for the whole run
short_cycling_ten_per_hourReference vector: short-cycling at ten starts an hour. The unit is off for the first hour (so the verdict rests on a completed-window count, not on the warm-up extrapolation), then runs 2 minutes and rests 4 — a 6-minute cycle. The seventh start enters the trailing hour at t=5760 and count steps to 7.0; yFault follows one alarm_delay later at t=6660, and the count settles at exactly 10.0
six_starts_per_hour_stays_clearBoundary, low side. The compressor runs 2 minutes every 10 — exactly six starts an hour, the reference’s minimum recommended interval met exactly. count lands on exactly 6.0 (60.0 x 6 x 60 / 3600 is exact in IEEE-754) and max_starts_per_hour is a strict >, so six starts an hour reads clear. During the warm-up window the extrapolated pace is 6.0 or a hair under, never above
seven_starts_per_hour_tripsBoundary, high side. Same shape as the six-per-hour case with one more start folded into each hour: cycles of 9 and 8 minutes, seven starts per hour, repeating on a 3600 s pattern so every trailing hour holds exactly seven. The seventh start enters the window at t=6720, count steps from 6.0 to 7.0, and yFault asserts one alarm_delay later at t=7620
cycling_stops_alarm_clearsRecovery. Ten short cycles between t=3600 and t=6840 push count to 10.0 and yFault asserts at t=6660; then the compressor settles into one long run. The count does not drop at once — it decays as starts age out of the trailing hour, one per 360 s — and reaches 6.0 at t=8280, where the strict threshold goes false and the alarm clears on that same tick, since TrueDelay has no off-delay
warmup_rate_assertsStartup artifact, and the reason the host must report NO_EVAL for the first count_window: two starts two minutes apart read as a 40/h pace, because the growing-window divisor is elapsed time rather than count_window. cntHigh is true from t=60, and unlike AHU-0004 (where alarm_delay equals count_window) this rule’s 900 s delay expires long before the window fills, so yFault asserts at t=960 on the strength of two starts. The reading decays as the window grows and falls to 6.0 at t=1200, clearing the alarm
startup_spike_clearsTransient shorter than alarm_delay. A single start one tick after load extrapolates to a 60/h pace, so cntHigh is true from t=60, but the reading decays with the growing window and drops to 6.0 at t=600. cntHigh held for 480 s, barely half the alarm_delay, so nothing asserts — the same single-start history as single_long_run, seen from inside the warm-up window
spread_burst_clears_before_delayTransient in the steady-state regime. Seven starts spread evenly over 54 minutes, after an idle first hour: the seventh start pushes count to 7.0 at t=6840, but the first one leaves the trailing hour at t=7200 and the count falls back to 6.0. cntHigh held for 300 s against a 900 s alarm_delay, so nothing asserts. How long a crossing survives is count_window minus the span of the starts that caused it, which is why a tight burst alarms and a spread-out one does not
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 14400
  },
  "scenarios": [
    {
      "name": "three_starts_per_hour",
      "description": "Reference vector: normal cycling, three starts an hour. The compressor is idle for the first 20 minutes, then runs 8 minutes and rests 12 \u2014 a 20-minute cycle, twice the 10-minute minimum interval the 6/h ceiling encodes. The rolling count holds at 3.0 for the whole run, including the warm-up window, where three starts an hour also happens to be the extrapolated pace",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          },
          {
            "t": 1680,
            "value": false
          },
          {
            "t": 2400,
            "value": true
          },
          {
            "t": 2880,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 4080,
            "value": false
          },
          {
            "t": 4800,
            "value": true
          },
          {
            "t": 5280,
            "value": false
          },
          {
            "t": 6000,
            "value": true
          },
          {
            "t": 6480,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7680,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8880,
            "value": false
          },
          {
            "t": 9600,
            "value": true
          },
          {
            "t": 10080,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 11280,
            "value": false
          },
          {
            "t": 12000,
            "value": true
          },
          {
            "t": 12480,
            "value": false
          },
          {
            "t": 13200,
            "value": true
          },
          {
            "t": 13680,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "single_long_run",
      "description": "Reference vector: one start, one long run. The compressor starts at t=1200 and runs to the horizon. count peaks at 3.0 (the warm-up extrapolation of one start in 20 minutes) and decays from there; one start can never reach the 6/h ceiling",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "startup_pulse_is_inert",
      "description": "Startup artifact: comp_status is already true when the rule loads, so Logical.Edge sees u true against pre_u_start = false and emits a rising edge on tick 0. It costs nothing \u2014 MovingAverage integrates u*dt and dt is zero on the first tick, so the pulse encloses no area and count stays at 0.0 for the whole run",
      "inputs": {
        "comp_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "short_cycling_ten_per_hour",
      "description": "Reference vector: short-cycling at ten starts an hour. The unit is off for the first hour (so the verdict rests on a completed-window count, not on the warm-up extrapolation), then runs 2 minutes and rests 4 \u2014 a 6-minute cycle. The seventh start enters the trailing hour at t=5760 and count steps to 7.0; yFault follows one alarm_delay later at t=6660, and the count settles at exactly 10.0",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 3960,
            "value": true
          },
          {
            "t": 4080,
            "value": false
          },
          {
            "t": 4320,
            "value": true
          },
          {
            "t": 4440,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5040,
            "value": true
          },
          {
            "t": 5160,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 5760,
            "value": true
          },
          {
            "t": 5880,
            "value": false
          },
          {
            "t": 6120,
            "value": true
          },
          {
            "t": 6240,
            "value": false
          },
          {
            "t": 6480,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 6840,
            "value": true
          },
          {
            "t": 6960,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7320,
            "value": false
          },
          {
            "t": 7560,
            "value": true
          },
          {
            "t": 7680,
            "value": false
          },
          {
            "t": 7920,
            "value": true
          },
          {
            "t": 8040,
            "value": false
          },
          {
            "t": 8280,
            "value": true
          },
          {
            "t": 8400,
            "value": false
          },
          {
            "t": 8640,
            "value": true
          },
          {
            "t": 8760,
            "value": false
          },
          {
            "t": 9000,
            "value": true
          },
          {
            "t": 9120,
            "value": false
          },
          {
            "t": 9360,
            "value": true
          },
          {
            "t": 9480,
            "value": false
          },
          {
            "t": 9720,
            "value": true
          },
          {
            "t": 9840,
            "value": false
          },
          {
            "t": 10080,
            "value": true
          },
          {
            "t": 10200,
            "value": false
          },
          {
            "t": 10440,
            "value": true
          },
          {
            "t": 10560,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 10920,
            "value": false
          },
          {
            "t": 11160,
            "value": true
          },
          {
            "t": 11280,
            "value": false
          },
          {
            "t": 11520,
            "value": true
          },
          {
            "t": 11640,
            "value": false
          },
          {
            "t": 11880,
            "value": true
          },
          {
            "t": 12000,
            "value": false
          },
          {
            "t": 12240,
            "value": true
          },
          {
            "t": 12360,
            "value": false
          },
          {
            "t": 12600,
            "value": true
          },
          {
            "t": 12720,
            "value": false
          },
          {
            "t": 12960,
            "value": true
          },
          {
            "t": 13080,
            "value": false
          },
          {
            "t": 13320,
            "value": true
          },
          {
            "t": 13440,
            "value": false
          },
          {
            "t": 13680,
            "value": true
          },
          {
            "t": 13800,
            "value": false
          },
          {
            "t": 14040,
            "value": true
          },
          {
            "t": 14160,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6720,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "six_starts_per_hour_stays_clear",
      "description": "Boundary, low side. The compressor runs 2 minutes every 10 \u2014 exactly six starts an hour, the reference's minimum recommended interval met exactly. count lands on exactly 6.0 (60.0 x 6 x 60 / 3600 is exact in IEEE-754) and max_starts_per_hour is a strict `>`, so six starts an hour reads clear. During the warm-up window the extrapolated pace is 6.0 or a hair under, never above",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 600,
            "value": true
          },
          {
            "t": 720,
            "value": false
          },
          {
            "t": 1200,
            "value": true
          },
          {
            "t": 1320,
            "value": false
          },
          {
            "t": 1800,
            "value": true
          },
          {
            "t": 1920,
            "value": false
          },
          {
            "t": 2400,
            "value": true
          },
          {
            "t": 2520,
            "value": false
          },
          {
            "t": 3000,
            "value": true
          },
          {
            "t": 3120,
            "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
          },
          {
            "t": 6600,
            "value": true
          },
          {
            "t": 6720,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7320,
            "value": false
          },
          {
            "t": 7800,
            "value": true
          },
          {
            "t": 7920,
            "value": false
          },
          {
            "t": 8400,
            "value": true
          },
          {
            "t": 8520,
            "value": false
          },
          {
            "t": 9000,
            "value": true
          },
          {
            "t": 9120,
            "value": false
          },
          {
            "t": 9600,
            "value": true
          },
          {
            "t": 9720,
            "value": false
          },
          {
            "t": 10200,
            "value": true
          },
          {
            "t": 10320,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 10920,
            "value": false
          },
          {
            "t": 11400,
            "value": true
          },
          {
            "t": 11520,
            "value": false
          },
          {
            "t": 12000,
            "value": true
          },
          {
            "t": 12120,
            "value": false
          },
          {
            "t": 12600,
            "value": true
          },
          {
            "t": 12720,
            "value": false
          },
          {
            "t": 13200,
            "value": true
          },
          {
            "t": 13320,
            "value": false
          },
          {
            "t": 13800,
            "value": true
          },
          {
            "t": 13920,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "seven_starts_per_hour_trips",
      "description": "Boundary, high side. Same shape as the six-per-hour case with one more start folded into each hour: cycles of 9 and 8 minutes, seven starts per hour, repeating on a 3600 s pattern so every trailing hour holds exactly seven. The seventh start enters the window at t=6720, count steps from 6.0 to 7.0, and yFault asserts one alarm_delay later at t=7620",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4140,
            "value": true
          },
          {
            "t": 4260,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5160,
            "value": true
          },
          {
            "t": 5280,
            "value": false
          },
          {
            "t": 5700,
            "value": true
          },
          {
            "t": 5820,
            "value": false
          },
          {
            "t": 6240,
            "value": true
          },
          {
            "t": 6360,
            "value": false
          },
          {
            "t": 6720,
            "value": true
          },
          {
            "t": 6840,
            "value": false
          },
          {
            "t": 7200,
            "value": true
          },
          {
            "t": 7320,
            "value": false
          },
          {
            "t": 7740,
            "value": true
          },
          {
            "t": 7860,
            "value": false
          },
          {
            "t": 8280,
            "value": true
          },
          {
            "t": 8400,
            "value": false
          },
          {
            "t": 8760,
            "value": true
          },
          {
            "t": 8880,
            "value": false
          },
          {
            "t": 9300,
            "value": true
          },
          {
            "t": 9420,
            "value": false
          },
          {
            "t": 9840,
            "value": true
          },
          {
            "t": 9960,
            "value": false
          },
          {
            "t": 10320,
            "value": true
          },
          {
            "t": 10440,
            "value": false
          },
          {
            "t": 10800,
            "value": true
          },
          {
            "t": 10920,
            "value": false
          },
          {
            "t": 11340,
            "value": true
          },
          {
            "t": 11460,
            "value": false
          },
          {
            "t": 11880,
            "value": true
          },
          {
            "t": 12000,
            "value": false
          },
          {
            "t": 12360,
            "value": true
          },
          {
            "t": 12480,
            "value": false
          },
          {
            "t": 12900,
            "value": true
          },
          {
            "t": 13020,
            "value": false
          },
          {
            "t": 13440,
            "value": true
          },
          {
            "t": 13560,
            "value": false
          },
          {
            "t": 13920,
            "value": true
          },
          {
            "t": 14040,
            "value": false
          },
          {
            "t": 14400,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7560,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7680,
          "to_s": 14400,
          "equals": true
        }
      ]
    },
    {
      "name": "cycling_stops_alarm_clears",
      "description": "Recovery. Ten short cycles between t=3600 and t=6840 push count to 10.0 and yFault asserts at t=6660; then the compressor settles into one long run. The count does not drop at once \u2014 it decays as starts age out of the trailing hour, one per 360 s \u2014 and reaches 6.0 at t=8280, where the strict threshold goes false and the alarm clears on that same tick, since TrueDelay has no off-delay",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 3960,
            "value": true
          },
          {
            "t": 4080,
            "value": false
          },
          {
            "t": 4320,
            "value": true
          },
          {
            "t": 4440,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5040,
            "value": true
          },
          {
            "t": 5160,
            "value": false
          },
          {
            "t": 5400,
            "value": true
          },
          {
            "t": 5520,
            "value": false
          },
          {
            "t": 5760,
            "value": true
          },
          {
            "t": 5880,
            "value": false
          },
          {
            "t": 6120,
            "value": true
          },
          {
            "t": 6240,
            "value": false
          },
          {
            "t": 6480,
            "value": true
          },
          {
            "t": 6600,
            "value": false
          },
          {
            "t": 6840,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 6600,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6720,
          "to_s": 8160,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 8340,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "warmup_rate_asserts",
      "description": "Startup artifact, and the reason the host must report NO_EVAL for the first count_window: two starts two minutes apart read as a 40/h pace, because the growing-window divisor is elapsed time rather than count_window. cntHigh is true from t=60, and unlike AHU-0004 (where alarm_delay equals count_window) this rule's 900 s delay expires long before the window fills, so yFault asserts at t=960 on the strength of two starts. The reading decays as the window grows and falls to 6.0 at t=1200, clearing the alarm",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          },
          {
            "t": 120,
            "value": false
          },
          {
            "t": 180,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 900,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1020,
          "to_s": 1080,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1260,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "startup_spike_clears",
      "description": "Transient shorter than alarm_delay. A single start one tick after load extrapolates to a 60/h pace, so cntHigh is true from t=60, but the reading decays with the growing window and drops to 6.0 at t=600. cntHigh held for 480 s, barely half the alarm_delay, so nothing asserts \u2014 the same single-start history as single_long_run, seen from inside the warm-up window",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 60,
            "value": true
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    },
    {
      "name": "spread_burst_clears_before_delay",
      "description": "Transient in the steady-state regime. Seven starts spread evenly over 54 minutes, after an idle first hour: the seventh start pushes count to 7.0 at t=6840, but the first one leaves the trailing hour at t=7200 and the count falls back to 6.0. cntHigh held for 300 s against a 900 s alarm_delay, so nothing asserts. How long a crossing survives is count_window minus the span of the starts that caused it, which is why a tight burst alarms and a spread-out one does not",
      "inputs": {
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 3600,
            "value": true
          },
          {
            "t": 3720,
            "value": false
          },
          {
            "t": 4140,
            "value": true
          },
          {
            "t": 4260,
            "value": false
          },
          {
            "t": 4680,
            "value": true
          },
          {
            "t": 4800,
            "value": false
          },
          {
            "t": 5220,
            "value": true
          },
          {
            "t": 5340,
            "value": false
          },
          {
            "t": 5760,
            "value": true
          },
          {
            "t": 5880,
            "value": false
          },
          {
            "t": 6300,
            "value": true
          },
          {
            "t": 6420,
            "value": false
          },
          {
            "t": 6840,
            "value": true
          },
          {
            "t": 6960,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 14400,
          "equals": false
        }
      ]
    }
  ]
}