TOWER-0003 — Tower fan short-cycling
| Status | verified — engine e2ff2f8, cxf:fnv1a128:ce00b61ddcba13408da191e530c6f390, 2026-08-18 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | PROTECTIVE |
| Confidence | MEDIUM |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | TOWER-0001, TOWER-0002, TOWER-0004 |
| Playbooks | cooling-tower-performance |
| Source | DOE/PNNL O&M Best Practices Guide Release 3.0 §9.5.6 and PNNL-13890 §7.5.6 — the tower fan motor must start and stop no more frequently than four to five times per hour to prevent motor overheating. Identical wording in both editions; Library extension: the HVAC FDD Reference v1.0 has no cooling-tower chapter — the TOWER family is library-authored; cxf-library simulation study, tools/simharness/README.md ‘Tower groundwork’ — the 4-climate healthy envelope that grounds this family’s approach and range bands. It has no bearing on this card’s threshold, which is literature-backed; Sibling precedent: RTU-0001 (edge-counter idiom, tick-band arithmetic, warm-up NO_EVAL contract) |
| Operating states | all states in which the tower is enabled. A tower locked out for the season produces no starts and reads clear, which is the right answer rather than a gap |
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 pace rather than a completed-hour count. The 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). tower_fan_status must be bound per fan motor: one instance per cell, and one per winding on a two-speed motor, because the low-to-high transition is itself a motor start and motor starts are what the source counts. An OR across cells hides every start that happens while another cell is already running and undercounts each of them. The point must be a run PROOF — a current switch or auxiliary contact — not the enable command: a command chattering against a locked-out motor counts starts that never happened, and a motor cycling on its own thermal overload while the command stays true counts none of them. Host tick interval must lie in [57.2 s, 360 s) with count_scale set to match (see Deviations); 60 s is recommended and the only tick these vectors exercise. A VFD-driven fan that modulates instead of cycling produces zero starts and reads clear — correctly; this rule counts starts, it does not detect speed hunting. When any gate is unmet the verdict is NO_EVAL, not healthy.
Points: tower_fan_status
Outputs:
yFault— True while the number of tower fan 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 | 5.0 | 1/h | cntHigh.t | Starts per hour above which cycling counts as short-cycling rather than load-following. 5.0 is the permissive end of the DOE/PNNL O&M guides’ four-to-five-starts-per-hour motor-protection ceiling; with the strict comparison a tower starting six times an hour alarms and five reads clear. Retune to 4.0 for the strict reading of the source, and lower still for a large fan motor whose nameplate permits fewer 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). ADOPTED from RTU-0001 — the source states a starts-per-hour ceiling, not an alarm persistence. |
Description
A tower fan motor is a large, high-inertia, across-the-line load turning a gear reducer and a fan whose blades are still windmilling when the contactor closes again. Each start pulls locked-rotor current through windings that have not cooled, and the DOE/PNNL O&M guides put a hard number on how often that is survivable: no more than four to five starts an hour. This is the only fault-side number the cooling-tower literature supplies — the family’s approach and range bands are commissioning placeholders, and this one is not. Short-cycling is a symptom, not a root cause; the rule reports that the fan is being asked to start too often and the service call decides why. Most of the time the answer is a control deadband and the fix is remote.
Detection Logic
start = rising edge of tower_fan_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 it is the first thing to check before deploying — see the first two
Deviations. The comparison is strict, so exactly five starts an hour reads clear
and six alarms; the boundary is exact in IEEE-754, since 60.0 × (5 × 60 / 3600)
evaluates to precisely 5.0. persist then requires the count to stay above the
ceiling for 15 minutes, which rides out the burst around a chiller stage change
without leaving a motor restarting all afternoon. delayOnInit = true holds that
window across a controller restart.
Possible Diagnoses
- Condenser-water or basin temperature deadband set too narrow — the fan satisfies the setpoint in a minute and restarts as the water drifts back. The commonest cause, and the one remote fix worth trying before a truck roll
- Condenser water setpoint below what the wet-bulb allows: the fan runs to capacity, overshoots when the load steps, and cycles against an unreachable target
- Cell staging with no minimum on/off timers, or several cells sharing one setpoint and hunting against each other
- A single-speed fan on a tower whose load needs modulation — cycling is the only capacity control it has, and no setting fixes it
- Cell oversized for the load: at low load one cell’s minimum output already exceeds what the loop needs
- Vibration switch or motor overload tripping and auto-resetting — the protection is doing the cycling and the underlying fault is mechanical
- A VFD faulting and restarting on undervoltage or start overcurrent, which reads identically from the status point
Energy Impact
PROTECTIVE, MEDIUM confidence, QUALITATIVE_ONLY. The rule sees one boolean and cannot price a start, so no waste term is computable from its inputs and the cost is asset life: a tower fan motor and gear reducer worn out early, plus the cell’s time out of service on a plant that may have no spare capacity in July. MEDIUM confidence rather than HIGH because the threshold, while stated plainly in two editions of the same DOE/PNNL guide, is a maintenance rule of thumb with no study behind it and no dependence on motor size — a large tower fan is often rated for fewer starts than four an hour, and a small one tolerates more.
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 a cycling fan spends every restart accelerating a high-inertia load while delivering little airflow, and the condenser water it fails to cool costs the chiller lift. Both terms are electricity the plant actually draws, neither is computable from a run status, and the larger emissions term is indirect: a motor and gear reducer replaced years early carry their embodied carbon. Avoided-emissions basis: N/A.
Deviations
- The threshold ships at the permissive end of the source band. The DOE/PNNL
guides state four to five starts per hour;
max_starts_per_hour = 5.0with a strict>alarms at six and clears at five, so nothing fires while the tower is anywhere inside the range the source calls acceptable. A site reading the requirement strictly sets 4.0; a site with a large fan motor should read its nameplate instead, since permissible starts fall with motor size and the source makes no such distinction. - This card’s number is literature-backed, and the family’s others are not.
TOWER-0001 and TOWER-0002 ship commissioning-set placeholder bands whose only
quantitative grounding is this library’s 4-climate simulation envelope
(
tools/simharness/README.md, “Tower groundwork”), with CTI/ASHRAE fault-side corroboration still pending. A starts-per-hour count is orthogonal to approach and range: it needs no fan-speed gate, no wet-bulb, and no thermal band, which is why it survives the gap that parks the other two at LOW confidence. - The tick interval is constrained at both ends, and the failure at the top end
is Nyquist. A start is visible only if the fan is seen OFF on one tick and ON
on the next, so the most this rule can ever observe is
count_window/(2·dt)starts per window —1800/dtper hour. The threshold must sit strictly below that ceiling:5.0 < 1800/dtgivesdt < 360 s. The moving average’s fixed 64-checkpoint ring sets the floor: the retained window holdscount_window/dt + 1checkpoints, sodt ≥ 3600/63≈ 57.2 s. Legal band [57.2 s, 360 s), 60 s recommended and the only tick these vectors exercise. Practical advice beyond the legal band: at 300 s the ceiling is exactly 6 starts/h, so six is the only count above the threshold the rule can represent — keepdt ≤ 120 s(ceiling 15/h) for usable headroom. Retuning the threshold to 4.0 relaxes the top of the band to 450 s. 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 three starts an hour. RTU-0001’s deployment constraint verbatim.- 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. RTU-0001’s trade, taken again. - Startup artifact (a): a spurious first-tick pulse, which costs nothing.
Logical.Edgecomparesuagainstpre_u_starton the first tick, so a fan already running when the rule loads 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 pace, 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.alarm_delay(15 min) is shorter than the window, so that pace can assert (warmup_rate_assertspins it). The host NO_EVAL precondition for the firstcount_windowis not optional. - Strict
>on a discrete count. Exactly five starts an hour is clear and six alarms, the source’s ceiling read literally. The boundary is exact in IEEE-754:60.0 × (5 × 60 / 3600)evaluates to precisely 5.0, so the five-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 source 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. Six starts inside ten minutes holdcntHighfor nearly an hour and always alarm; six spread across fifty minutes hold it for 600 s and never do — same starts per hour, opposite verdicts, and the difference is invisible in the printed equation (six_starts_per_hour_tripsagainstspread_burst_clears_before_delaypins both). What the rule reports is cycling sustained above the ceiling, not every excursion through it. - Severity 3, where RTU-0001’s compressor sibling is severity 2. Same detection shape, cheaper asset: a tower fan motor and gear reducer cost a fraction of a compressor, a multi-cell tower has redundancy a packaged rooftop does not, and no space goes uncooled while the repair is scheduled. No reference index exists for the TOWER family to carry a severity, so this is a library judgement recorded rather than inherited.
alarm_delay = 900 sis adopted from RTU-0001. The source specifies a starts-per-hour ceiling and no persistence. Fifteen minutes is one to two more starts on a tower already cycling hard — enough to ride out the burst around a chiller stage change or a one-off drive trip.persist.delayOnInit = true(CDL defaultfalse), the library’s standing choice: a fan already above the ceiling at controller start waits out the full 15 minutes instead of alarming on the first tick after a restart.clusters: []. CLU-10 (Condenser-Side Degradation) was created at batch-18 closeout; this fault would not belong to one anyway: fan cycling is a control or mechanical defect on one cell, not a symptom of the plant-wide heat-rejection syndrome TOWER-0001 and TOWER-0002 describe. Any future tower cluster is the cluster owner’s edit.- No published test vectors exist. The source states a threshold, not cases, so
every scenario in
vectors.jsonis authored from the equation and replayed against the pinned engine rev. - Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.
Notes
Bind tower_fan_status per cell and deploy one instance per fan, as the tower
point dictionary requires. An OR across a two-cell tower undercounts: a lag start
while the lead is already running never moves the signal, so a plant whose lead
cell runs continuously can cycle its lag cell all afternoon and read healthy.
Remediation follows the cooling-tower-performance playbook — deadband and staging first, then the setpoint against wet-bulb, then the drive and the mechanical inspection. Its step 1.4 is the one check worth doing before the truck roll: confirm the starts are sustained and rule out an aggressive leaving-temperature deadband. Firing alongside TOWER-0001 points at the setpoint (diagnosis 2) — a fan chasing an unreachable target both runs at capacity and overshoots.
Test Vectors
10 scenarios, clock step 60 s over 14400 s.
| Scenario | Description |
|---|---|
three_starts_per_hour | Normal staging: the tower cell is idle for the first 20 minutes, then runs 8 minutes and rests 12 — a 20-minute cycle, three starts an hour against a 5/h ceiling. 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 | One start, one long run: the fan starts at t = 1200 s 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 5/h ceiling. |
startup_pulse_is_inert | Startup artifact: tower_fan_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 | The fault: the fan 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, ten starts an hour. The sixth start enters the trailing hour at t = 5400 s and count steps to 6.0; yFault follows one alarm_delay later at t = 6300 s, and the count settles at exactly 10.0. |
five_starts_per_hour_stays_clear | Boundary, low side. After an idle first hour the fan runs 2 minutes every 12 — exactly five starts an hour, the top of the sources’ 4-5/h band met exactly. count lands on exactly 5.0 (60.0 x 5 x 60 / 3600 is exact in IEEE-754) and max_starts_per_hour is a strict >, so five starts an hour reads clear. |
six_starts_per_hour_trips | Boundary, high side. Same shape with one more start folded into each hour: 2 minutes on, 8 off, six starts an hour repeating on a 3600 s pattern so every trailing hour holds exactly six. count steps to 6.0 at t = 6600 s and yFault asserts one alarm_delay later at t = 7500 s. |
cycling_stops_alarm_clears | Recovery. Ten short cycles between t = 3600 s and t = 6840 s push count to 10.0 and yFault asserts at t = 6300 s; then the fan 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 5.0 at t = 8640 s, 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 s and alarm_delay (900 s) expires long before the window fills, so yFault asserts at t = 960 s on the strength of two starts. The reading decays as the window grows and falls below 5.0 at t = 1440 s, 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 s, but the reading decays with the growing window and drops below 5.0 at t = 720 s. cntHigh held for 600 s against a 900 s 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, and the contrast case for six_starts_per_hour_trips: the same six starts 600 s apart, but the burst stops after the sixth. count reaches 6.0 at t = 6600 s and falls back to 5.0 at t = 7200 s when the first start leaves the trailing hour, so cntHigh held 540 s against a 900 s alarm_delay and nothing asserts. How long a crossing survives is count_window minus the span of the starts that caused it. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 14400
},
"scenarios": [
{
"name": "three_starts_per_hour",
"description": "Normal staging: the tower cell is idle for the first 20 minutes, then runs 8 minutes and rests 12 \u2014 a 20-minute cycle, three starts an hour against a 5/h ceiling. 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": {
"tower_fan_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": "One start, one long run: the fan starts at t = 1200 s 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 5/h ceiling.",
"inputs": {
"tower_fan_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: tower_fan_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": {
"tower_fan_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 14400,
"equals": false
}
]
},
{
"name": "short_cycling_ten_per_hour",
"description": "The fault: the fan 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, ten starts an hour. The sixth start enters the trailing hour at t = 5400 s and count steps to 6.0; yFault follows one alarm_delay later at t = 6300 s, and the count settles at exactly 10.0.",
"inputs": {
"tower_fan_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": 6240,
"equals": false
},
{
"output": "yFault",
"from_s": 6360,
"to_s": 14400,
"equals": true
}
]
},
{
"name": "five_starts_per_hour_stays_clear",
"description": "Boundary, low side. After an idle first hour the fan runs 2 minutes every 12 \u2014 exactly five starts an hour, the top of the sources' 4-5/h band met exactly. count lands on exactly 5.0 (60.0 x 5 x 60 / 3600 is exact in IEEE-754) and max_starts_per_hour is a strict `>`, so five starts an hour reads clear.",
"inputs": {
"tower_fan_status": [
{
"t": 0,
"value": false
},
{
"t": 3600,
"value": true
},
{
"t": 3720,
"value": false
},
{
"t": 4320,
"value": true
},
{
"t": 4440,
"value": false
},
{
"t": 5040,
"value": true
},
{
"t": 5160,
"value": false
},
{
"t": 5760,
"value": true
},
{
"t": 5880,
"value": false
},
{
"t": 6480,
"value": true
},
{
"t": 6600,
"value": false
},
{
"t": 7200,
"value": true
},
{
"t": 7320,
"value": false
},
{
"t": 7920,
"value": true
},
{
"t": 8040,
"value": false
},
{
"t": 8640,
"value": true
},
{
"t": 8760,
"value": false
},
{
"t": 9360,
"value": true
},
{
"t": 9480,
"value": false
},
{
"t": 10080,
"value": true
},
{
"t": 10200,
"value": false
},
{
"t": 10800,
"value": true
},
{
"t": 10920,
"value": false
},
{
"t": 11520,
"value": true
},
{
"t": 11640,
"value": false
},
{
"t": 12240,
"value": true
},
{
"t": 12360,
"value": false
},
{
"t": 12960,
"value": true
},
{
"t": 13080,
"value": false
},
{
"t": 13680,
"value": true
},
{
"t": 13800,
"value": false
},
{
"t": 14400,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 14400,
"equals": false
}
]
},
{
"name": "six_starts_per_hour_trips",
"description": "Boundary, high side. Same shape with one more start folded into each hour: 2 minutes on, 8 off, six starts an hour repeating on a 3600 s pattern so every trailing hour holds exactly six. count steps to 6.0 at t = 6600 s and yFault asserts one alarm_delay later at t = 7500 s.",
"inputs": {
"tower_fan_status": [
{
"t": 0,
"value": false
},
{
"t": 3600,
"value": true
},
{
"t": 3720,
"value": false
},
{
"t": 4200,
"value": true
},
{
"t": 4320,
"value": false
},
{
"t": 4800,
"value": true
},
{
"t": 4920,
"value": false
},
{
"t": 5400,
"value": true
},
{
"t": 5520,
"value": false
},
{
"t": 6000,
"value": true
},
{
"t": 6120,
"value": false
},
{
"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": 7440,
"equals": false
},
{
"output": "yFault",
"from_s": 7560,
"to_s": 14400,
"equals": true
}
]
},
{
"name": "cycling_stops_alarm_clears",
"description": "Recovery. Ten short cycles between t = 3600 s and t = 6840 s push count to 10.0 and yFault asserts at t = 6300 s; then the fan 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 5.0 at t = 8640 s, where the strict threshold goes false and the alarm clears on that same tick, since TrueDelay has no off-delay.",
"inputs": {
"tower_fan_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": 6240,
"equals": false
},
{
"output": "yFault",
"from_s": 6360,
"to_s": 8580,
"equals": true
},
{
"output": "yFault",
"from_s": 8700,
"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 s and alarm_delay (900 s) expires long before the window fills, so yFault asserts at t = 960 s on the strength of two starts. The reading decays as the window grows and falls below 5.0 at t = 1440 s, clearing the alarm.",
"inputs": {
"tower_fan_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": 1380,
"equals": true
},
{
"output": "yFault",
"from_s": 1500,
"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 s, but the reading decays with the growing window and drops below 5.0 at t = 720 s. cntHigh held for 600 s against a 900 s alarm_delay, so nothing asserts \u2014 the same single-start history as single_long_run, seen from inside the warm-up window.",
"inputs": {
"tower_fan_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, and the contrast case for six_starts_per_hour_trips: the same six starts 600 s apart, but the burst stops after the sixth. count reaches 6.0 at t = 6600 s and falls back to 5.0 at t = 7200 s when the first start leaves the trailing hour, so cntHigh held 540 s against a 900 s alarm_delay and nothing asserts. How long a crossing survives is count_window minus the span of the starts that caused it.",
"inputs": {
"tower_fan_status": [
{
"t": 0,
"value": false
},
{
"t": 3600,
"value": true
},
{
"t": 3720,
"value": false
},
{
"t": 4200,
"value": true
},
{
"t": 4320,
"value": false
},
{
"t": 4800,
"value": true
},
{
"t": 4920,
"value": false
},
{
"t": 5400,
"value": true
},
{
"t": 5520,
"value": false
},
{
"t": 6000,
"value": true
},
{
"t": 6120,
"value": false
},
{
"t": 6600,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 14400,
"equals": false
}
]
}
]
}