AHU-0004 — Excessive operating state changes per hour
| Status | verified — engine e2ff2f8, cxf:fnv1a128:e8de53811e08e20ebd38bc83fa7a602f, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 1 |
| Category | COMFORT_ENERGY |
| Confidence | LOW |
| Estimation | QUALITATIVE_ONLY |
| G36 | §5.16.14 FC#4 |
| Clusters | CLU-01 |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0016, AHU-0022 |
| Playbooks | simultaneous-hc |
| Source | HVAC FDD Reference v1.0 §9, AHU-0004; G36 §5.16.14 FC#4 |
| Operating states | OS 1–5 (all) |
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.
delayOnInit = trueon persist already blocks any assertion before 3600 s, so the two windows coincide. The operating_state encoding must be stable for the life of the deployment — re-mapping the enum mid-stream registers as a transition on every point that moved. Operator activity is not a fault: periods of commissioning, manual mode forcing, or scheduled occupancy testing must be excluded host-side, since every deliberate mode change counts the same as an oscillation. When any gate is unmet the verdict is NO_EVAL, not healthy.
Points: operating_state
Outputs:
yFault— True while the number of operating-state transitions in the trailing count_window has stayed above os_max for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
os_max | 7.0 | 1/h | cntHigh.t | Transitions per hour above which the sequence counts as unstable rather than load-following |
count_window | 3600.0 | s | rate.delta | Trailing window the transitions are counted over (1 h). It also fixes the units of os_max; a host that shortens it must retune count_scale with it and read os_max as transitions per window rather than per hour |
count_scale | 12.0 | 1 | count.k | Rescales the moving average of the one-tick pulse train back into a transition count: k = count_window / host tick interval in seconds. The default 12.0 is 3600/300, correct only at a 300 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 | 3600.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (60 min — the reference ch.9 card’s own AlarmDelay for FC#4; G36-2018 Table 5.16.14.5 applies a uniform 30 min AlarmDelay to all fifteen FCs) |
Description
The sequence cannot decide what it is doing. An AHU walking between heating, cooling, economizer, and off more than a few times an hour is not following load — load does not move that fast — it is chasing a changeover threshold with nothing to hold it on one side. Valves and dampers stroke, control loops restart from a new setpoint and overshoot, and air conditioned one way is conditioned the other way minutes later. The cause is nearly always a missing or undersized deadband, in the changeover logic or in the zone demand aggregation feeding it; a close second is upstream sensor noise crossing a threshold every few minutes with the sequencing logic working as written. A member fault of CLU-01 (Simultaneous Heating & Cooling).
Detection Logic
pulse = (operating_state ≠ previous tick's operating_state) one tick wide
count = MovingAverage(pulse, count_window) × count_scale transitions in the trailing hour
yFault = count > os_max, sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
Reals.MovingAverage is a continuous-time integral mean: it accumulates u·dt
and divides by the window, so a one-tick pulse of height 1.0 encloses one tick
interval of area and n transitions in the trailing hour give
rate = n · dt / count_window. count_scale = count_window / dt = 3600/300 =
12 recovers n, which couples the parameter to the host’s tick interval — the
one thing about this rule that can be got wrong silently (see Deviations).
Only whether the state moved is consumed; chg.up and chg.down are declared
and left unconnected, since the destination state says nothing about
oscillation. The threshold is strict, so exactly seven transitions an hour
reads clear and eight alarms; the arithmetic is exact at that boundary in
IEEE-754, not approximately exact. persist then requires the count to stay
above os_max for a full hour, so roughly two hours of genuine thrashing
elapse before anything is reported, and any interruption restarts the timer.
Possible Diagnoses
- Deadband between modes too narrow — the sequence flips back as soon as it has finished acting, because the condition that ended the last mode is the condition that starts the next one
- Fluctuating zone demands near a changeover threshold — the aggregated demand signal sits on the boundary and the unit follows every wobble in it
- Sensor noise causing mode oscillation — one intermittent or poorly located sensor crosses the threshold repeatedly and the sequencing logic faithfully obeys
Energy Impact
COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. The rule sees a state index and nothing else, so it cannot say what any transition cost. The reference puts the loss at 1–3% of AHU energy, split between actuator wear and the transitions themselves, where a coil is charged and then abandoned before the air stream has settled. Confidence is LOW because no controlled study isolates cycling losses from the deadband change that fixes them and no PNNL measure covers sequencing stability. Climate-neutral. Size the opportunity per Energy Impact Reference §4.4 (unstable hours × AHU coil and fan power); this rule contributes the hours.
Emissions Impact
Scope 1 + 2, QUALITATIVE_EMISSIONS, LOW confidence; on the order of 5–15 kg CO₂e/yr from cycling losses. Both scopes appear because the transitions being counted cross between them — gas at the boiler and electricity at the chiller for the same hour of indecision. The magnitude is small enough that the number is an order of magnitude, not an estimate. Avoided-emissions basis: N/A.
Deviations
- The reference card names no points;
operating_stateis our choice. Its chapter 9 card has no Required Points table, only the logic and tunables. Only transitions are consumed and no value is ever interpreted, so any stable enumeration binds; the dictionary recommends the G36 §5.16.14 OS#1–OS#5 index and requires only that the encoding not change under the rule’s feet. - Rolling count built from a moving average, because the block set has no
windowed counter.
Integers.OnCountercounts monotonically from a reset and has no window, so it would need a host-driven hourly reset — turning the rolling count into a tumbling one whose verdict depends on where the hour boundary fell. count_scaleis coupled to the host’s tick interval.k = count_window / dt; the default 12.0 is correct only at a 300 s tick. A host ticking every 60 s must set 60.0, and leaving 12.0 reports a fifth of the true count so the rule never fires. Same family of deployment constraint as AHU-0022’s minimum sample interval, but this one fails quietly: a mis-set scale produces a plausible-looking number.- Minimum tick interval, from the same block. Each
MovingAveragekeeps a fixed 64-checkpoint ring and silently drops the oldest in-window sample past that. A window spanning n ticks retains n + 1 checkpoints, so it may span at most 63 ticks:dt ≥ count_window/63= 57.15 s, and a legal deployment hascount_scale = 3600/dt ≤ 63. At the default 300 s tick the window holds 12 samples. - Startup artifact (a): the first-tick pulse is inert.
Integers.Changecompares againstpre_u_starton the first tick, so a unit loading in OS#3 registers a change at t = 0. It encloses no area becausedtis zero there, so it never reaches the count.chg.pre_u_startis written explicitly as 0 rather than left to the engine default (also 0) and is not exposed as a card parameter, since nothing it can do survives tick 0. - Startup artifact (b): the first hour reads as a rate, not a count. While
t < count_windowthe moving average divides by elapsed time, so two changes in the first ten minutes read as 12/hr — the pace, extrapolated. Defensible, but not the reference’s completed-hour count.delayOnInit = trueblocks any assertion before 3600 s and the frontmatter precondition requires the host to report NO_EVAL over the same hour, so the artifact cannot reach a verdict. - The counting window is half-open.
ratecompares the accumulated integral now against its value onecount_windowago, so a transition exactlycount_windowold has just left the window. The reference is silent; it matters only right at the threshold, and it errs toward silence. alarm_delayequalscount_window. Both come from the reference (60 min AlarmDelay, per-hour count), but the interaction is worth stating: the count must hold aboveos_maxfor a full hour after first crossing it, so a burst that ends inside that hour never reports.relatedadds AHU-0022. The reference lists AHU-0016 only; AHU-0022 (SAT hunting) already names this fault from its side, so the link is made reciprocal.- Severity 3 (warning) and method
rule, per the reference’s chapter 9 card; its §5.8.1 index carries no severity column. Operating states OS 1–5 are declared, not gated — the reference marks the fault applicable in every state. persist.delayOnInit = true(Modelica/CDL default isfalse), the library’s standing choice: a count already aboveos_maxat load waits out the full hour instead of alarming on the first tick after a controller restart.
Notes
The fix is a deadband, and it is remote and free. Step 2.1 of the simultaneous-hc playbook gives the numbers for the heating/cooling case — G36 §5.16’s 2.8 °C (5 °F) minimum — and the same reasoning applies to whatever pair of states this unit oscillates between: the condition that leaves a mode must not be the condition that re-enters it. Confirm first that the demand signal is not itself the problem (diagnosis 3). This rule and AHU-0022 are the same pathology at two altitudes, and both tripping together is the strongest evidence for that diagnosis.
Test Vectors
9 scenarios, clock step 300 s over 14400 s.
| Scenario | Description |
|---|---|
stable_state | The unit holds one operating state for the whole run. operating_state equals chg.pre_u_start, so Integers.Change never pulses and the rolling count sits at zero — the baseline every other scenario departs from |
startup_pulse_is_inert | Startup artifact (a): operating_state is a constant 3, which differs from chg.pre_u_start (0), so Integers.Change emits a spurious pulse 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 |
four_transitions_per_hour | The sequence walks OS 1→2→3→4→5→1 every 900 s: four transitions an hour, ordinary for a unit tracking load across a day. count holds at 4.0, and the warm-up window agrees rather than overshooting — one change in the first 900 s extrapolates to the same 4/hr pace |
sustained_thrash | operating_state alternates every tick — 12 transitions an hour, the signature of a changeover threshold with no deadband. count reads 12 from the first pulse (during the first hour that is an extrapolated pace, and here the pace is the truth), cntHigh latches at t=300, and yFault follows one alarm_delay later at t=3900, the earliest instant this rule can assert |
seven_per_hour_stays_clear | Boundary, low side. The unit is quiet for the first hour, then transitions seven times an hour indefinitely — seven changes per twelve ticks, so every trailing-hour window holds exactly seven. count lands on exactly 7.0 (12.0 × 7 × 300 / 3600 is exact in IEEE-754) and os_max is a strict >, so seven transitions an hour stays clear |
eight_per_hour_trips | Boundary, high side. The same shape with eight transitions an hour. The eighth pulse enters the trailing window at t=6000, count steps to 8.0, and yFault asserts one alarm_delay later at t=9600 |
thrash_stops_before_delay | Transient: ten transitions between t=3900 and t=6600, then the sequence settles. count crosses 7 at t=6000, but the burst is already over and the count decays as pulses age out, reaching 7.0 at t=8100. cntHigh held for 1800 s — half the alarm_delay — so nothing asserts |
burst_ages_out_of_window | A two-hour thrash (t=3900 to t=10800) is long enough: count crosses at t=6000 and yFault asserts at t=9600. When the thrash stops the count does not drop at once — it decays as pulses leave the trailing hour, reaching 7.0 at t=12300, and the alarm clears on that tick because TrueDelay has no off-delay |
warmup_rate_never_asserts | Startup artifact (b): two transitions inside the first 10 minutes read as a 12/hr pace, because the growing-window divisor is elapsed time rather than count_window. cntHigh is true from t=300 to t=900, then the pace collapses as the window fills — count is 6.0 at t=1200 and 2.0 by the end of the hour. delayOnInit keeps yFault false throughout, which is the reason the frontmatter puts the first count_window under host NO_EVAL |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 300,
"horizon_s": 14400
},
"scenarios": [
{
"name": "stable_state",
"description": "The unit holds one operating state for the whole run. operating_state equals chg.pre_u_start, so Integers.Change never pulses and the rolling count sits at zero \u2014 the baseline every other scenario departs from",
"inputs": {
"operating_state": 0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 14400,
"equals": false
}
]
},
{
"name": "startup_pulse_is_inert",
"description": "Startup artifact (a): operating_state is a constant 3, which differs from chg.pre_u_start (0), so Integers.Change emits a spurious pulse on tick 0. It costs nothing \u2014 MovingAverage integrates u\u00b7dt 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": {
"operating_state": 3
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 14400,
"equals": false
}
]
},
{
"name": "four_transitions_per_hour",
"description": "The sequence walks OS 1\u21922\u21923\u21924\u21925\u21921 every 900 s: four transitions an hour, ordinary for a unit tracking load across a day. count holds at 4.0, and the warm-up window agrees rather than overshooting \u2014 one change in the first 900 s extrapolates to the same 4/hr pace",
"inputs": {
"operating_state": [
{
"t": 0,
"value": 1
},
{
"t": 900,
"value": 2
},
{
"t": 1800,
"value": 3
},
{
"t": 2700,
"value": 4
},
{
"t": 3600,
"value": 5
},
{
"t": 4500,
"value": 1
},
{
"t": 5400,
"value": 2
},
{
"t": 6300,
"value": 3
},
{
"t": 7200,
"value": 4
},
{
"t": 8100,
"value": 5
},
{
"t": 9000,
"value": 1
},
{
"t": 9900,
"value": 2
},
{
"t": 10800,
"value": 3
},
{
"t": 11700,
"value": 4
},
{
"t": 12600,
"value": 5
},
{
"t": 13500,
"value": 1
},
{
"t": 14400,
"value": 2
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 14400,
"equals": false
}
]
},
{
"name": "sustained_thrash",
"description": "operating_state alternates every tick \u2014 12 transitions an hour, the signature of a changeover threshold with no deadband. count reads 12 from the first pulse (during the first hour that is an extrapolated pace, and here the pace is the truth), cntHigh latches at t=300, and yFault follows one alarm_delay later at t=3900, the earliest instant this rule can assert",
"inputs": {
"operating_state": [
{
"t": 0,
"value": 1
},
{
"t": 300,
"value": 2
},
{
"t": 600,
"value": 1
},
{
"t": 900,
"value": 2
},
{
"t": 1200,
"value": 1
},
{
"t": 1500,
"value": 2
},
{
"t": 1800,
"value": 1
},
{
"t": 2100,
"value": 2
},
{
"t": 2400,
"value": 1
},
{
"t": 2700,
"value": 2
},
{
"t": 3000,
"value": 1
},
{
"t": 3300,
"value": 2
},
{
"t": 3600,
"value": 1
},
{
"t": 3900,
"value": 2
},
{
"t": 4200,
"value": 1
},
{
"t": 4500,
"value": 2
},
{
"t": 4800,
"value": 1
},
{
"t": 5100,
"value": 2
},
{
"t": 5400,
"value": 1
},
{
"t": 5700,
"value": 2
},
{
"t": 6000,
"value": 1
},
{
"t": 6300,
"value": 2
},
{
"t": 6600,
"value": 1
},
{
"t": 6900,
"value": 2
},
{
"t": 7200,
"value": 1
},
{
"t": 7500,
"value": 2
},
{
"t": 7800,
"value": 1
},
{
"t": 8100,
"value": 2
},
{
"t": 8400,
"value": 1
},
{
"t": 8700,
"value": 2
},
{
"t": 9000,
"value": 1
},
{
"t": 9300,
"value": 2
},
{
"t": 9600,
"value": 1
},
{
"t": 9900,
"value": 2
},
{
"t": 10200,
"value": 1
},
{
"t": 10500,
"value": 2
},
{
"t": 10800,
"value": 1
},
{
"t": 11100,
"value": 2
},
{
"t": 11400,
"value": 1
},
{
"t": 11700,
"value": 2
},
{
"t": 12000,
"value": 1
},
{
"t": 12300,
"value": 2
},
{
"t": 12600,
"value": 1
},
{
"t": 12900,
"value": 2
},
{
"t": 13200,
"value": 1
},
{
"t": 13500,
"value": 2
},
{
"t": 13800,
"value": 1
},
{
"t": 14100,
"value": 2
},
{
"t": 14400,
"value": 1
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yFault",
"from_s": 4200,
"to_s": 14400,
"equals": true
}
]
},
{
"name": "seven_per_hour_stays_clear",
"description": "Boundary, low side. The unit is quiet for the first hour, then transitions seven times an hour indefinitely \u2014 seven changes per twelve ticks, so every trailing-hour window holds exactly seven. count lands on exactly 7.0 (12.0 \u00d7 7 \u00d7 300 / 3600 is exact in IEEE-754) and os_max is a strict `>`, so seven transitions an hour stays clear",
"inputs": {
"operating_state": [
{
"t": 0,
"value": 1
},
{
"t": 3900,
"value": 2
},
{
"t": 4200,
"value": 1
},
{
"t": 4500,
"value": 2
},
{
"t": 4800,
"value": 1
},
{
"t": 5100,
"value": 2
},
{
"t": 5400,
"value": 1
},
{
"t": 5700,
"value": 2
},
{
"t": 7500,
"value": 1
},
{
"t": 7800,
"value": 2
},
{
"t": 8100,
"value": 1
},
{
"t": 8400,
"value": 2
},
{
"t": 8700,
"value": 1
},
{
"t": 9000,
"value": 2
},
{
"t": 9300,
"value": 1
},
{
"t": 11100,
"value": 2
},
{
"t": 11400,
"value": 1
},
{
"t": 11700,
"value": 2
},
{
"t": 12000,
"value": 1
},
{
"t": 12300,
"value": 2
},
{
"t": 12600,
"value": 1
},
{
"t": 12900,
"value": 2
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 14400,
"equals": false
}
]
},
{
"name": "eight_per_hour_trips",
"description": "Boundary, high side. The same shape with eight transitions an hour. The eighth pulse enters the trailing window at t=6000, count steps to 8.0, and yFault asserts one alarm_delay later at t=9600",
"inputs": {
"operating_state": [
{
"t": 0,
"value": 1
},
{
"t": 3900,
"value": 2
},
{
"t": 4200,
"value": 1
},
{
"t": 4500,
"value": 2
},
{
"t": 4800,
"value": 1
},
{
"t": 5100,
"value": 2
},
{
"t": 5400,
"value": 1
},
{
"t": 5700,
"value": 2
},
{
"t": 6000,
"value": 1
},
{
"t": 7500,
"value": 2
},
{
"t": 7800,
"value": 1
},
{
"t": 8100,
"value": 2
},
{
"t": 8400,
"value": 1
},
{
"t": 8700,
"value": 2
},
{
"t": 9000,
"value": 1
},
{
"t": 9300,
"value": 2
},
{
"t": 9600,
"value": 1
},
{
"t": 11100,
"value": 2
},
{
"t": 11400,
"value": 1
},
{
"t": 11700,
"value": 2
},
{
"t": 12000,
"value": 1
},
{
"t": 12300,
"value": 2
},
{
"t": 12600,
"value": 1
},
{
"t": 12900,
"value": 2
},
{
"t": 13200,
"value": 1
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9300,
"equals": false
},
{
"output": "yFault",
"from_s": 9900,
"to_s": 14400,
"equals": true
}
]
},
{
"name": "thrash_stops_before_delay",
"description": "Transient: ten transitions between t=3900 and t=6600, then the sequence settles. count crosses 7 at t=6000, but the burst is already over and the count decays as pulses age out, reaching 7.0 at t=8100. cntHigh held for 1800 s \u2014 half the alarm_delay \u2014 so nothing asserts",
"inputs": {
"operating_state": [
{
"t": 0,
"value": 1
},
{
"t": 3900,
"value": 2
},
{
"t": 4200,
"value": 1
},
{
"t": 4500,
"value": 2
},
{
"t": 4800,
"value": 1
},
{
"t": 5100,
"value": 2
},
{
"t": 5400,
"value": 1
},
{
"t": 5700,
"value": 2
},
{
"t": 6000,
"value": 1
},
{
"t": 6300,
"value": 2
},
{
"t": 6600,
"value": 1
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 14400,
"equals": false
}
]
},
{
"name": "burst_ages_out_of_window",
"description": "A two-hour thrash (t=3900 to t=10800) is long enough: count crosses at t=6000 and yFault asserts at t=9600. When the thrash stops the count does not drop at once \u2014 it decays as pulses leave the trailing hour, reaching 7.0 at t=12300, and the alarm clears on that tick because TrueDelay has no off-delay",
"inputs": {
"operating_state": [
{
"t": 0,
"value": 1
},
{
"t": 3900,
"value": 2
},
{
"t": 4200,
"value": 1
},
{
"t": 4500,
"value": 2
},
{
"t": 4800,
"value": 1
},
{
"t": 5100,
"value": 2
},
{
"t": 5400,
"value": 1
},
{
"t": 5700,
"value": 2
},
{
"t": 6000,
"value": 1
},
{
"t": 6300,
"value": 2
},
{
"t": 6600,
"value": 1
},
{
"t": 6900,
"value": 2
},
{
"t": 7200,
"value": 1
},
{
"t": 7500,
"value": 2
},
{
"t": 7800,
"value": 1
},
{
"t": 8100,
"value": 2
},
{
"t": 8400,
"value": 1
},
{
"t": 8700,
"value": 2
},
{
"t": 9000,
"value": 1
},
{
"t": 9300,
"value": 2
},
{
"t": 9600,
"value": 1
},
{
"t": 9900,
"value": 2
},
{
"t": 10200,
"value": 1
},
{
"t": 10500,
"value": 2
},
{
"t": 10800,
"value": 1
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9300,
"equals": false
},
{
"output": "yFault",
"from_s": 9900,
"to_s": 11700,
"equals": true
},
{
"output": "yFault",
"from_s": 12600,
"to_s": 14400,
"equals": false
}
]
},
{
"name": "warmup_rate_never_asserts",
"description": "Startup artifact (b): two transitions inside the first 10 minutes read as a 12/hr pace, because the growing-window divisor is elapsed time rather than count_window. cntHigh is true from t=300 to t=900, then the pace collapses as the window fills \u2014 count is 6.0 at t=1200 and 2.0 by the end of the hour. delayOnInit keeps yFault false throughout, which is the reason the frontmatter puts the first count_window under host NO_EVAL",
"inputs": {
"operating_state": [
{
"t": 0,
"value": 1
},
{
"t": 300,
"value": 2
},
{
"t": 600,
"value": 1
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 14400,
"equals": false
}
]
}
]
}