RTU-0001 — Compressor short-cycling
| Status | verified — engine e2ff2f8, cxf:fnv1a128:2a5f66097f97bf8154323abae2e36ded, 2026-08-17 |
| Severity | 2 |
| Method | rule |
| Phase | 2 |
| Category | PROTECTIVE |
| Confidence | MEDIUM |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | RTU-0002, RTU-0010, RTU-0011, HP-0007 |
| Playbooks | rtu-compressor-refrigerant |
| Source | HVAC FDD Reference v1.0 §11, RTU-0001; Albayati et al. 2023; Ebrahimifakhar et al. 2020 |
| Operating states | all 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_assertspins 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:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
max_starts_per_hour | 6.0 | 1/h | cntHigh.t | Starts 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_window | 3600.0 | s | rate.delta | Trailing 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_scale | 60.0 | 1 | count.k | Rescales 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_delay | 900.0 | s | persist.delayTime | Continuous 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):
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
- 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
- Equipment oversized for the load: at part load the unit can only meet the call by cycling, and no setting adjusts it
- Low refrigerant charge — suction pressure falls to the cutout every cycle and the low-pressure switch does the cycling
- Defective run capacitor: the compressor stalls on start and drops out on thermal overload
- Iced or fouled evaporator coil starving the suction side (RTU-0002 sees the same coil from the airside)
- 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/dtstarts 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 defaultcount_scaleis therefore 60.0 (a 60 s tick) rather than AHU-0004’s 12.0. Combined with the ring floor below, a legal deployment has57.2 s ≤ dt < 300 s; 60 s is the recommended value and the only one these vectors have exercised. count_scaleis coupled to the host’s tick interval and the failure is silent.k = count_window / dt, so a host ticking every 120 s must setcount_scaleto 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
MovingAveragekeeps a fixed 64-checkpoint ring and drops the oldest in-window sample past that. The retained window holdscount_window/dt + 1checkpoints, sodt ≥ 3600/63≈ 57.2 s. (AHU-0004 quotesdelta/64= 56.25 s, which omits the boundary checkpoint; neither card’s tick is near it.) min_run_timeis not in the graph. The reference lists it as a 5-minute tunable but its printed equation iscomp_starts_per_hour > max_starts_per_hourand 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.OnCountercounts 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.Edgecomparesuagainstpre_u_starton the first tick, so a unit already running at load registers a start at t = 0. It encloses no area (dtis zero on the first tick) and never reaches the count, sopre_u_startis written explicitly asfalseand 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_windowthe 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_assertspins it). The host NO_EVAL precondition for the firstcount_windowis not optional. - Strict
>on a discrete count. Exactly six starts an hour is clear and seven alarms, the reference’s> max_starts_per_hourread 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.
ratecompares the accumulated integral now against its value onecount_windowago, so a start exactlycount_windowold 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_windowminus the span of the starts that caused it. Seven starts packed into ten minutes holdcntHighfor 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 isfalse), 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.
| Scenario | Description |
|---|---|
three_starts_per_hour | Reference 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_run | 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 |
startup_pulse_is_inert | 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 — 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_hour | 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 — 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_clear | Boundary, 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_trips | 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 |
cycling_stops_alarm_clears | 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 — 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_asserts | 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 |
startup_spike_clears | 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 — the same single-start history as single_long_run, seen from inside the warm-up window |
spread_burst_clears_before_delay | 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 |
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
}
]
}
]
}